lukkawolff Claude Sonnet 4.6 commited on
Commit
3bf4df9
·
1 Parent(s): c10184e

restructure project: pivot from multi-approach notebooks to focused COMICS panel search build

Browse files

The original scaffold explored three separate retrieval approaches across six
numbered notebooks (text+dense, image embeddings, hybrid) with a flat utils/
package. This commit replaces that structure with a production-oriented layout
targeting the COMICS dataset specifically.

Key changes:
- Removed numbered exploration notebooks (00–06) and flat utils/ package
- Added src/ package with modules for ingest, embeddings, pinecone, and search
- ingest/: manifest building and OCR text cleaning
- embeddings/: dense image embeddings (embed_images) and sparse text (embed_sparse_text)
- pinecone/: index creation, document assembly, and panel upserting
- search/: dense, sparse, FTS, and combined search with client-side fusion
- Added notebooks/query_comics_index.ipynb as the single interactive entry point
- Added config.yaml for shared runtime configuration
- Added updated-build.md capturing the revised build spec and data source decisions
(COMICS dataset via umiacs.umd.edu, Pinecone document-schema index with
one dense + one sparse vector field + FTS-enabled string fields)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

00_data_acquisition.ipynb DELETED
@@ -1,143 +0,0 @@
1
- {
2
- "cells": [
3
- {
4
- "cell_type": "markdown",
5
- "metadata": {},
6
- "source": [
7
- "# 00 · Data Acquisition\n",
8
- "\n",
9
- "Download public-domain golden age comics from the [Digital Comic Museum](https://digitalcomicmuseum.com/index.php?cid=110), extract their pages, and build a manifest CSV.\n",
10
- "\n",
11
- "**Comics used (all public domain):**\n",
12
- "Add direct CBZ download URLs to `COMIC_URLS` below. You can find download links by browsing the category page and clicking through to each comic's download page."
13
- ]
14
- },
15
- {
16
- "cell_type": "code",
17
- "execution_count": null,
18
- "metadata": {},
19
- "outputs": [],
20
- "source": [
21
- "from pathlib import Path\n",
22
- "import pandas as pd\n",
23
- "import sys\n",
24
- "sys.path.insert(0, str(Path().resolve()))\n",
25
- "\n",
26
- "from utils.downloader import download_comic\n",
27
- "from utils.extractor import extract_comic\n",
28
- "\n",
29
- "RAW_DIR = Path(\"data/raw\")\n",
30
- "PAGES_DIR = Path(\"data/pages\")\n",
31
- "MAX_PAGES = 30 # set lower to reduce cost during testing"
32
- ]
33
- },
34
- {
35
- "cell_type": "markdown",
36
- "metadata": {},
37
- "source": [
38
- "## Define comics to download\n",
39
- "\n",
40
- "Populate `COMIC_URLS` with `(filename, url)` tuples. \n",
41
- "Direct download links look like: `https://digitalcomicmuseum.com/download.php?...`"
42
- ]
43
- },
44
- {
45
- "cell_type": "code",
46
- "execution_count": null,
47
- "metadata": {},
48
- "outputs": [],
49
- "source": [
50
- "# Add (filename, url) pairs — get URLs from the DCM download pages\n",
51
- "COMIC_URLS = [\n",
52
- " # (\"comic_filename.cbz\", \"https://...\"),\n",
53
- "]\n",
54
- "\n",
55
- "if not COMIC_URLS:\n",
56
- " print(\"⚠️ No comic URLs defined. Add entries to COMIC_URLS above.\")\n",
57
- " print(\" Browse https://digitalcomicmuseum.com/index.php?cid=110 to find download links.\")"
58
- ]
59
- },
60
- {
61
- "cell_type": "code",
62
- "execution_count": null,
63
- "metadata": {},
64
- "outputs": [],
65
- "source": [
66
- "# Download archives\n",
67
- "archive_paths = []\n",
68
- "for filename, url in COMIC_URLS:\n",
69
- " path = download_comic(url, RAW_DIR, filename)\n",
70
- " archive_paths.append(path)\n",
71
- "print(f\"Downloaded {len(archive_paths)} archives.\")"
72
- ]
73
- },
74
- {
75
- "cell_type": "markdown",
76
- "metadata": {},
77
- "source": [
78
- "## Extract pages"
79
- ]
80
- },
81
- {
82
- "cell_type": "code",
83
- "execution_count": null,
84
- "metadata": {},
85
- "outputs": [],
86
- "source": [
87
- "all_pages = []\n",
88
- "for archive in archive_paths:\n",
89
- " pages = extract_comic(archive, PAGES_DIR)\n",
90
- " # Honour MAX_PAGES per comic to control cost\n",
91
- " pages = pages[:MAX_PAGES]\n",
92
- " all_pages.extend(pages)\n",
93
- " print(f\" {archive.name}: {len(pages)} pages\")\n",
94
- "\n",
95
- "print(f\"\\nTotal pages: {len(all_pages)}\")"
96
- ]
97
- },
98
- {
99
- "cell_type": "markdown",
100
- "metadata": {},
101
- "source": [
102
- "## Build and save manifest"
103
- ]
104
- },
105
- {
106
- "cell_type": "code",
107
- "execution_count": null,
108
- "metadata": {},
109
- "outputs": [],
110
- "source": [
111
- "manifest = pd.DataFrame(all_pages)\n",
112
- "manifest[\"page_id\"] = manifest[\"comic_slug\"] + \"_p\" + manifest[\"page_num\"].astype(str).str.zfill(3)\n",
113
- "manifest.to_csv(\"data/manifest.csv\", index=False)\n",
114
- "print(f\"Saved manifest with {len(manifest)} rows.\")\n",
115
- "manifest.head()"
116
- ]
117
- },
118
- {
119
- "cell_type": "code",
120
- "execution_count": null,
121
- "metadata": {},
122
- "outputs": [],
123
- "source": [
124
- "# Quick sanity check — display first page of each comic\n",
125
- "from IPython.display import display, Image as IPyImage\n",
126
- "from PIL import Image\n",
127
- "\n",
128
- "for slug, grp in manifest.groupby(\"comic_slug\"):\n",
129
- " first = grp.iloc[0]\n",
130
- " print(f\"\\n{first['comic_title']} — {len(grp)} pages\")\n",
131
- " img = Image.open(first[\"file_path\"])\n",
132
- " img.thumbnail((300, 400))\n",
133
- " display(img)"
134
- ]
135
- }
136
- ],
137
- "metadata": {
138
- "kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"},
139
- "language_info": {"name": "python", "version": "3.11.0"}
140
- },
141
- "nbformat": 4,
142
- "nbformat_minor": 4
143
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
01_transcription.ipynb DELETED
@@ -1,200 +0,0 @@
1
- {
2
- "cells": [
3
- {
4
- "cell_type": "markdown",
5
- "metadata": {},
6
- "source": [
7
- "# 01 · Transcription Pipeline\n",
8
- "\n",
9
- "Run [comic-transcriber](https://github.com/nearestnabors/comic-transcriber) on each page to extract structured panel descriptions and dialogue, then parse and save as JSON for downstream embedding pipelines.\n",
10
- "\n",
11
- "**Output per page:**\n",
12
- "```json\n",
13
- "{\n",
14
- " \"page_id\": \"action_comics_001_p003\",\n",
15
- " \"comic_title\": \"Action Comics 001\",\n",
16
- " \"page_num\": 3,\n",
17
- " \"file_path\": \"data/pages/...\",\n",
18
- " \"all_dialogue\": \"Look out! The villains are here...\",\n",
19
- " \"scene_descriptions\": \"A hero leaps across rooftops...\",\n",
20
- " \"raw_transcript\": \"## PAGE 3, PANEL 1...\"\n",
21
- "}\n",
22
- "```"
23
- ]
24
- },
25
- {
26
- "cell_type": "code",
27
- "execution_count": null,
28
- "metadata": {},
29
- "outputs": [],
30
- "source": [
31
- "import json\n",
32
- "import re\n",
33
- "import subprocess\n",
34
- "from pathlib import Path\n",
35
- "\n",
36
- "import pandas as pd\n",
37
- "from tqdm import tqdm\n",
38
- "\n",
39
- "TRANSCRIPTS_DIR = Path(\"data/transcripts\")\n",
40
- "TRANSCRIPTS_DIR.mkdir(parents=True, exist_ok=True)\n",
41
- "\n",
42
- "manifest = pd.read_csv(\"data/manifest.csv\")\n",
43
- "print(f\"Pages to transcribe: {len(manifest)}\")"
44
- ]
45
- },
46
- {
47
- "cell_type": "markdown",
48
- "metadata": {},
49
- "source": [
50
- "## Install comic-transcriber\n",
51
- "\n",
52
- "comic-transcriber runs via `npx`. Node.js must be installed."
53
- ]
54
- },
55
- {
56
- "cell_type": "code",
57
- "execution_count": null,
58
- "metadata": {},
59
- "outputs": [],
60
- "source": [
61
- "# Verify npx is available\n",
62
- "result = subprocess.run([\"npx\", \"--version\"], capture_output=True, text=True)\n",
63
- "if result.returncode != 0:\n",
64
- " raise RuntimeError(\"npx not found — install Node.js from https://nodejs.org\")\n",
65
- "print(f\"npx {result.stdout.strip()} ✓\")"
66
- ]
67
- },
68
- {
69
- "cell_type": "markdown",
70
- "metadata": {},
71
- "source": [
72
- "## Parsing helpers"
73
- ]
74
- },
75
- {
76
- "cell_type": "code",
77
- "execution_count": null,
78
- "metadata": {},
79
- "outputs": [],
80
- "source": [
81
- "def parse_transcript(raw: str) -> tuple[str, str]:\n",
82
- " \"\"\"Extract dialogue and scene descriptions from comic-transcriber markdown output.\"\"\"\n",
83
- " dialogue_lines = []\n",
84
- " description_lines = []\n",
85
- "\n",
86
- " for line in raw.splitlines():\n",
87
- " line = line.strip()\n",
88
- " if not line or line.startswith(\"#\"):\n",
89
- " continue\n",
90
- " # Italic lines (wrapped in * or _) are scene descriptions\n",
91
- " if re.match(r'^[*_].+[*_]$', line):\n",
92
- " description_lines.append(re.sub(r'^[*_]|[*_]$', '', line))\n",
93
- " # Bold name followed by colon is dialogue: **CHARACTER**: text\n",
94
- " elif re.match(r'^\\*\\*.+\\*\\*:', line):\n",
95
- " dialogue_lines.append(re.sub(r'^\\*\\*.+\\*\\*:\\s*', '', line))\n",
96
- " # Plain lines without markup — treat as description\n",
97
- " elif not line.startswith(\"[\"):\n",
98
- " description_lines.append(line)\n",
99
- "\n",
100
- " return \" \".join(dialogue_lines), \" \".join(description_lines)"
101
- ]
102
- },
103
- {
104
- "cell_type": "markdown",
105
- "metadata": {},
106
- "source": [
107
- "## Run transcription"
108
- ]
109
- },
110
- {
111
- "cell_type": "code",
112
- "execution_count": null,
113
- "metadata": {},
114
- "outputs": [],
115
- "source": [
116
- "def transcribe_page(image_path: str) -> str:\n",
117
- " \"\"\"Run comic-transcriber on a single page image and return raw markdown.\"\"\"\n",
118
- " result = subprocess.run(\n",
119
- " [\"npx\", \"comic-transcriber\", image_path],\n",
120
- " capture_output=True, text=True, timeout=120\n",
121
- " )\n",
122
- " if result.returncode != 0:\n",
123
- " raise RuntimeError(f\"comic-transcriber failed: {result.stderr[:500]}\")\n",
124
- " return result.stdout\n",
125
- "\n",
126
- "\n",
127
- "records = []\n",
128
- "errors = []\n",
129
- "\n",
130
- "for _, row in tqdm(manifest.iterrows(), total=len(manifest), desc=\"Transcribing\"):\n",
131
- " out_file = TRANSCRIPTS_DIR / f\"{row['page_id']}.json\"\n",
132
- " if out_file.exists():\n",
133
- " records.append(json.loads(out_file.read_text()))\n",
134
- " continue\n",
135
- " try:\n",
136
- " raw = transcribe_page(row[\"file_path\"])\n",
137
- " dialogue, descriptions = parse_transcript(raw)\n",
138
- " record = {\n",
139
- " \"page_id\": row[\"page_id\"],\n",
140
- " \"comic_title\": row[\"comic_title\"],\n",
141
- " \"comic_slug\": row[\"comic_slug\"],\n",
142
- " \"page_num\": int(row[\"page_num\"]),\n",
143
- " \"file_path\": row[\"file_path\"],\n",
144
- " \"all_dialogue\": dialogue,\n",
145
- " \"scene_descriptions\": descriptions,\n",
146
- " \"raw_transcript\": raw,\n",
147
- " }\n",
148
- " out_file.write_text(json.dumps(record, ensure_ascii=False, indent=2))\n",
149
- " records.append(record)\n",
150
- " except Exception as e:\n",
151
- " errors.append({\"page_id\": row[\"page_id\"], \"error\": str(e)})\n",
152
- " print(f\" ✗ {row['page_id']}: {e}\")\n",
153
- "\n",
154
- "print(f\"\\n✓ {len(records)} transcribed ✗ {len(errors)} errors\")"
155
- ]
156
- },
157
- {
158
- "cell_type": "markdown",
159
- "metadata": {},
160
- "source": [
161
- "## Spot-check"
162
- ]
163
- },
164
- {
165
- "cell_type": "code",
166
- "execution_count": null,
167
- "metadata": {},
168
- "outputs": [],
169
- "source": [
170
- "if records:\n",
171
- " sample = records[0]\n",
172
- " print(f\"page_id: {sample['page_id']}\")\n",
173
- " print(f\"all_dialogue: {sample['all_dialogue'][:300]}\")\n",
174
- " print(f\"scene_descriptions: {sample['scene_descriptions'][:300]}\")\n",
175
- " print(f\"\\nraw_transcript preview:\\n{sample['raw_transcript'][:500]}\")"
176
- ]
177
- },
178
- {
179
- "cell_type": "code",
180
- "execution_count": null,
181
- "metadata": {},
182
- "outputs": [],
183
- "source": [
184
- "# Summary stats\n",
185
- "df = pd.DataFrame(records)\n",
186
- "print(df[[\"comic_title\", \"page_num\", \"all_dialogue\", \"scene_descriptions\"]]\n",
187
- " .assign(dialogue_len=df[\"all_dialogue\"].str.len(),\n",
188
- " desc_len=df[\"scene_descriptions\"].str.len())\n",
189
- " .groupby(\"comic_title\")[[\"dialogue_len\", \"desc_len\"]]\n",
190
- " .describe())"
191
- ]
192
- }
193
- ],
194
- "metadata": {
195
- "kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"},
196
- "language_info": {"name": "python", "version": "3.11.0"}
197
- },
198
- "nbformat": 4,
199
- "nbformat_minor": 4
200
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
02_approach1_text_dense_fts.ipynb DELETED
@@ -1,289 +0,0 @@
1
- {
2
- "cells": [
3
- {
4
- "cell_type": "markdown",
5
- "metadata": {},
6
- "source": [
7
- "# 02 · Approach 1: LLM Caption → Dense + FTS\n",
8
- "\n",
9
- "**Strategy:** Use Claude to generate rich scene descriptions from the comic-transcriber output, then embed those descriptions with Pinecone's integrated `llama-text-embed-v2`. Dialogue text is stored as a full-text search (FTS) field for exact keyword retrieval.\n",
10
- "\n",
11
- "**Namespace:** `ns-text-caption`\n",
12
- "\n",
13
- "**Blog post insight:** LLM captions add semantic richness — a query for *\"a moment of betrayal\"* can surface relevant pages even when no character says that word. FTS on dialogue nails exact quotes and sound effects.\n",
14
- "\n",
15
- "---\n",
16
- "\n",
17
- "## When to use this approach\n",
18
- "- You want semantic search over *meaning* (themes, mood, setting)\n",
19
- "- Your source content is primarily visual with little usable text metadata\n",
20
- "- You want keyword search on transcribed dialogue/captions as a complementary signal"
21
- ]
22
- },
23
- {
24
- "cell_type": "code",
25
- "execution_count": null,
26
- "metadata": {},
27
- "outputs": [],
28
- "source": [
29
- "import json\n",
30
- "import os\n",
31
- "import time\n",
32
- "from pathlib import Path\n",
33
- "\n",
34
- "import anthropic\n",
35
- "from dotenv import load_dotenv\n",
36
- "from pinecone import Pinecone\n",
37
- "from pinecone.preview import SchemaBuilder\n",
38
- "from tqdm import tqdm\n",
39
- "\n",
40
- "load_dotenv()\n",
41
- "\n",
42
- "TRANSCRIPTS_DIR = Path(\"data/transcripts\")\n",
43
- "INDEX_NAME = \"comics-multimodal\"\n",
44
- "NAMESPACE = \"ns-text-caption\"\n",
45
- "BATCH_SIZE = 50\n",
46
- "\n",
47
- "pc = Pinecone(api_key=os.environ[\"PINECONE_API_KEY\"])\n",
48
- "claude = anthropic.Anthropic()"
49
- ]
50
- },
51
- {
52
- "cell_type": "markdown",
53
- "metadata": {},
54
- "source": [
55
- "## Create (or reuse) the hybrid index\n",
56
- "\n",
57
- "This index is shared across all three approaches. It has dense + sparse + FTS fields in a single index via `SchemaBuilder`. Run this cell once — it handles the 409 case if the index already exists."
58
- ]
59
- },
60
- {
61
- "cell_type": "code",
62
- "execution_count": null,
63
- "metadata": {},
64
- "outputs": [],
65
- "source": [
66
- "schema = (\n",
67
- " SchemaBuilder()\n",
68
- " .add_string_field(\n",
69
- " name=\"dialogue\",\n",
70
- " full_text_search={\"language\": \"en\", \"stemming\": True, \"stop_words\": True},\n",
71
- " )\n",
72
- " .add_string_field(name=\"comic_title\", filterable=True)\n",
73
- " .add_string_field(name=\"page_id\", filterable=True)\n",
74
- " .add_dense_vector_field(name=\"embedding\", dimension=1024, metric=\"dotproduct\")\n",
75
- " .add_sparse_vector_field(name=\"sparse_embedding\")\n",
76
- " .build()\n",
77
- ")\n",
78
- "\n",
79
- "try:\n",
80
- " index_model = pc.preview.indexes.create(\n",
81
- " name=INDEX_NAME,\n",
82
- " schema=schema,\n",
83
- " read_capacity={\"mode\": \"OnDemand\"},\n",
84
- " )\n",
85
- " print(f\"Created index: {INDEX_NAME}\")\n",
86
- "except Exception as e:\n",
87
- " if \"409\" in str(e) or \"already exists\" in str(e).lower():\n",
88
- " print(f\"Index '{INDEX_NAME}' already exists — reusing.\")\n",
89
- " else:\n",
90
- " raise\n",
91
- "\n",
92
- "# Wait for ready\n",
93
- "while True:\n",
94
- " info = pc.preview.indexes.describe(name=INDEX_NAME)\n",
95
- " if info.status.ready:\n",
96
- " INDEX_HOST = info.host\n",
97
- " break\n",
98
- " print(\" waiting for index to be ready...\")\n",
99
- " time.sleep(3)\n",
100
- "\n",
101
- "print(f\"Index ready. Host: {INDEX_HOST}\")\n",
102
- "index = pc.index(host=INDEX_HOST)"
103
- ]
104
- },
105
- {
106
- "cell_type": "markdown",
107
- "metadata": {},
108
- "source": [
109
- "## Generate LLM scene captions\n",
110
- "\n",
111
- "We use `claude-haiku-4-5-20251001` (fast and cheap) to generate a rich scene description from the comic-transcriber transcript. The prompt is included verbatim so you can reproduce or tune it."
112
- ]
113
- },
114
- {
115
- "cell_type": "code",
116
- "execution_count": null,
117
- "metadata": {},
118
- "outputs": [],
119
- "source": [
120
- "CAPTION_PROMPT = \"\"\"\\\n",
121
- "You are describing a comic book page for a semantic search index.\n",
122
- "Based on the transcript below, write a dense 2–4 sentence scene description covering:\n",
123
- "- Setting and time of day\n",
124
- "- Characters present and their emotional state\n",
125
- "- Key action or plot moment happening\n",
126
- "- Visual mood or atmosphere\n",
127
- "\n",
128
- "Be specific. Do NOT include meta-commentary about the comic itself.\n",
129
- "Write only the description, no preamble.\n",
130
- "\n",
131
- "TRANSCRIPT:\n",
132
- "{transcript}\"\"\"\n",
133
- "\n",
134
- "\n",
135
- "def generate_caption(record: dict) -> str:\n",
136
- " transcript = record[\"raw_transcript\"][:3000] # stay well within context\n",
137
- " msg = claude.messages.create(\n",
138
- " model=\"claude-haiku-4-5-20251001\",\n",
139
- " max_tokens=256,\n",
140
- " messages=[{\"role\": \"user\", \"content\": CAPTION_PROMPT.format(transcript=transcript)}],\n",
141
- " )\n",
142
- " return msg.content[0].text.strip()"
143
- ]
144
- },
145
- {
146
- "cell_type": "code",
147
- "execution_count": null,
148
- "metadata": {},
149
- "outputs": [],
150
- "source": [
151
- "records = [json.loads(p.read_text()) for p in sorted(TRANSCRIPTS_DIR.glob(\"*.json\"))]\n",
152
- "print(f\"Loaded {len(records)} transcripts\")\n",
153
- "\n",
154
- "# Generate captions (cached in each record's JSON to avoid re-billing)\n",
155
- "for record in tqdm(records, desc=\"Generating captions\"):\n",
156
- " if record.get(\"llm_caption\"):\n",
157
- " continue\n",
158
- " record[\"llm_caption\"] = generate_caption(record)\n",
159
- " path = TRANSCRIPTS_DIR / f\"{record['page_id']}.json\"\n",
160
- " path.write_text(json.dumps(record, ensure_ascii=False, indent=2))\n",
161
- "\n",
162
- "print(\"Captions ready.\")"
163
- ]
164
- },
165
- {
166
- "cell_type": "markdown",
167
- "metadata": {},
168
- "source": [
169
- "## Upsert to Pinecone (ns-text-caption)\n",
170
- "\n",
171
- "We pass the LLM caption as the `embedding` text field — Pinecone's integrated `llama-text-embed-v2` inference embeds it automatically. Dialogue goes into the FTS `dialogue` field."
172
- ]
173
- },
174
- {
175
- "cell_type": "code",
176
- "execution_count": null,
177
- "metadata": {},
178
- "outputs": [],
179
- "source": [
180
- "documents = [\n",
181
- " {\n",
182
- " \"_id\": r[\"page_id\"],\n",
183
- " \"embedding\": r[\"llm_caption\"], # integrated inference embeds this\n",
184
- " \"dialogue\": r[\"all_dialogue\"],\n",
185
- " \"comic_title\": r[\"comic_title\"],\n",
186
- " \"page_id\": r[\"page_id\"],\n",
187
- " \"page_num\": r[\"page_num\"],\n",
188
- " \"file_path\": r[\"file_path\"],\n",
189
- " \"llm_caption\": r[\"llm_caption\"],\n",
190
- " }\n",
191
- " for r in records\n",
192
- "]\n",
193
- "\n",
194
- "index.documents.batch_upsert(\n",
195
- " namespace=NAMESPACE,\n",
196
- " documents=documents,\n",
197
- " batch_size=BATCH_SIZE,\n",
198
- " show_progress=True,\n",
199
- ")\n",
200
- "print(f\"Upserted {len(documents)} pages to {NAMESPACE}.\")"
201
- ]
202
- },
203
- {
204
- "cell_type": "markdown",
205
- "metadata": {},
206
- "source": [
207
- "## Search demos"
208
- ]
209
- },
210
- {
211
- "cell_type": "code",
212
- "execution_count": null,
213
- "metadata": {},
214
- "outputs": [],
215
- "source": [
216
- "from IPython.display import display\n",
217
- "from PIL import Image\n",
218
- "\n",
219
- "\n",
220
- "def show_results(matches, label=\"\"):\n",
221
- " if label:\n",
222
- " print(f\"\\n{'='*60}\\n{label}\\n{'='*60}\")\n",
223
- " for m in matches:\n",
224
- " caption = getattr(m, \"llm_caption\", \"\") or \"\"\n",
225
- " print(f\"[{m._score:.3f}] {m._id} — {caption[:120]}\")\n",
226
- " fp = getattr(m, \"file_path\", None)\n",
227
- " if fp and Path(fp).exists():\n",
228
- " img = Image.open(fp)\n",
229
- " img.thumbnail((250, 350))\n",
230
- " display(img)"
231
- ]
232
- },
233
- {
234
- "cell_type": "code",
235
- "execution_count": null,
236
- "metadata": {},
237
- "outputs": [],
238
- "source": [
239
- "# Dense semantic search on LLM captions\n",
240
- "resp = index.documents.search(\n",
241
- " namespace=NAMESPACE,\n",
242
- " top_k=3,\n",
243
- " score_by=[{\"type\": \"dense_vector\", \"field\": \"embedding\", \"query\": \"hero discovers villain's secret lair\"}],\n",
244
- " include_fields=[\"page_id\", \"comic_title\", \"llm_caption\", \"file_path\"],\n",
245
- ")\n",
246
- "show_results(resp.matches, \"Dense semantic: 'hero discovers villain's secret lair'\")"
247
- ]
248
- },
249
- {
250
- "cell_type": "code",
251
- "execution_count": null,
252
- "metadata": {},
253
- "outputs": [],
254
- "source": [
255
- "# FTS on dialogue\n",
256
- "resp = index.documents.search(\n",
257
- " namespace=NAMESPACE,\n",
258
- " top_k=3,\n",
259
- " score_by=[{\"type\": \"text\", \"fields\": [\"dialogue\"], \"query\": \"look out\"}],\n",
260
- " include_fields=[\"page_id\", \"comic_title\", \"dialogue\", \"file_path\"],\n",
261
- ")\n",
262
- "show_results(resp.matches, \"FTS on dialogue: 'look out'\")"
263
- ]
264
- },
265
- {
266
- "cell_type": "code",
267
- "execution_count": null,
268
- "metadata": {},
269
- "outputs": [],
270
- "source": [
271
- "# Filter by keyword in dialogue, rank by dense vector semantics\n",
272
- "resp = index.documents.search(\n",
273
- " namespace=NAMESPACE,\n",
274
- " top_k=3,\n",
275
- " score_by=[{\"type\": \"dense_vector\", \"field\": \"embedding\", \"query\": \"tense confrontation\"}],\n",
276
- " filter={\"dialogue\": {\"$match_any\": [\"help\", \"stop\", \"run\"]}},\n",
277
- " include_fields=[\"page_id\", \"comic_title\", \"llm_caption\", \"file_path\"],\n",
278
- ")\n",
279
- "show_results(resp.matches, \"Keyword filter + dense rerank: 'tense confrontation' where dialogue contains help/stop/run\")"
280
- ]
281
- }
282
- ],
283
- "metadata": {
284
- "kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"},
285
- "language_info": {"name": "python", "version": "3.11.0"}
286
- },
287
- "nbformat": 4,
288
- "nbformat_minor": 4
289
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
03_approach2_image_embeddings.ipynb DELETED
@@ -1,248 +0,0 @@
1
- {
2
- "cells": [
3
- {
4
- "cell_type": "markdown",
5
- "metadata": {},
6
- "source": [
7
- "# 03 · Approach 2: Direct Image Embeddings (Voyage AI)\n",
8
- "\n",
9
- "**Strategy:** Embed each comic page image directly using [Voyage AI's `voyage-multimodal-3`](https://docs.voyageai.com/docs/multimodal-embeddings) — no text description needed. Both images and text queries live in the same 1024-dimensional embedding space, enabling cross-modal search.\n",
10
- "\n",
11
- "**Namespace:** `ns-image-clip`\n",
12
- "\n",
13
- "**Blog post insight:** CLIP-style embeddings capture *visual* information that text descriptions miss: art style, panel composition, colour tone, action pose. But they're less precise for abstract semantic concepts — a query for *\"betrayal\"* works better with a language model.\n",
14
- "\n",
15
- "---\n",
16
- "\n",
17
- "## When to use this approach\n",
18
- "- You want visual similarity search (find pages that look alike)\n",
19
- "- You need cross-modal retrieval: text queries returning images, or image queries returning images\n",
20
- "- You have no text transcripts and don't want to generate them"
21
- ]
22
- },
23
- {
24
- "cell_type": "code",
25
- "execution_count": null,
26
- "metadata": {},
27
- "outputs": [],
28
- "source": [
29
- "import json\n",
30
- "import os\n",
31
- "import time\n",
32
- "from pathlib import Path\n",
33
- "\n",
34
- "import voyageai\n",
35
- "from dotenv import load_dotenv\n",
36
- "from pinecone import Pinecone\n",
37
- "from PIL import Image\n",
38
- "from tqdm import tqdm\n",
39
- "\n",
40
- "load_dotenv()\n",
41
- "\n",
42
- "TRANSCRIPTS_DIR = Path(\"data/transcripts\")\n",
43
- "INDEX_NAME = \"comics-multimodal\"\n",
44
- "NAMESPACE = \"ns-image-clip\"\n",
45
- "BATCH_SIZE = 10 # Voyage multimodal batches are smaller due to image payload\n",
46
- "\n",
47
- "pc = Pinecone(api_key=os.environ[\"PINECONE_API_KEY\"])\n",
48
- "vo = voyageai.Client() # reads VOYAGE_API_KEY from env\n",
49
- "\n",
50
- "# Connect to the shared index (created in notebook 02)\n",
51
- "info = pc.preview.indexes.describe(name=INDEX_NAME)\n",
52
- "INDEX_HOST = info.host\n",
53
- "index = pc.index(host=INDEX_HOST)\n",
54
- "print(f\"Connected to {INDEX_NAME} at {INDEX_HOST}\")"
55
- ]
56
- },
57
- {
58
- "cell_type": "markdown",
59
- "metadata": {},
60
- "source": [
61
- "## Embed page images with Voyage AI\n",
62
- "\n",
63
- "Each page is embedded as a PIL Image. Voyage `voyage-multimodal-3` accepts images directly — no base64 encoding step needed.\n",
64
- "\n",
65
- "**Cost:** ~$0.000006/image. For 150 pages ≈ $0.0009 total."
66
- ]
67
- },
68
- {
69
- "cell_type": "code",
70
- "execution_count": null,
71
- "metadata": {},
72
- "outputs": [],
73
- "source": [
74
- "records = [json.loads(p.read_text()) for p in sorted(TRANSCRIPTS_DIR.glob(\"*.json\"))]\n",
75
- "print(f\"Pages to embed: {len(records)}\")"
76
- ]
77
- },
78
- {
79
- "cell_type": "code",
80
- "execution_count": null,
81
- "metadata": {},
82
- "outputs": [],
83
- "source": [
84
- "def embed_images_batch(paths: list[str]) -> list[list[float]]:\n",
85
- " \"\"\"Embed a batch of image paths. Returns list of 1024-dim vectors.\"\"\"\n",
86
- " inputs = [[Image.open(p).convert(\"RGB\")] for p in paths]\n",
87
- " result = vo.multimodal_embed(inputs, model=\"voyage-multimodal-3\", input_type=\"document\")\n",
88
- " return result.embeddings\n",
89
- "\n",
90
- "\n",
91
- "# Embed all pages (cache vectors in the JSON records to avoid re-billing)\n",
92
- "for i in tqdm(range(0, len(records), BATCH_SIZE), desc=\"Embedding images\"):\n",
93
- " batch = records[i : i + BATCH_SIZE]\n",
94
- " # Skip pages that already have a cached vector\n",
95
- " needs_embedding = [r for r in batch if not r.get(\"image_vector\")]\n",
96
- " if not needs_embedding:\n",
97
- " continue\n",
98
- " paths = [r[\"file_path\"] for r in needs_embedding]\n",
99
- " vectors = embed_images_batch(paths)\n",
100
- " for r, v in zip(needs_embedding, vectors):\n",
101
- " r[\"image_vector\"] = v\n",
102
- " path = TRANSCRIPTS_DIR / f\"{r['page_id']}.json\"\n",
103
- " path.write_text(json.dumps(r, ensure_ascii=False, indent=2))\n",
104
- "\n",
105
- "print(\"Image vectors ready.\")"
106
- ]
107
- },
108
- {
109
- "cell_type": "markdown",
110
- "metadata": {},
111
- "source": [
112
- "## Upsert to Pinecone (ns-image-clip)\n",
113
- "\n",
114
- "Unlike the text-caption namespace, here we **bring our own vectors** — we pass the 1024-dim Voyage vector directly rather than letting Pinecone's integrated inference embed a text field."
115
- ]
116
- },
117
- {
118
- "cell_type": "code",
119
- "execution_count": null,
120
- "metadata": {},
121
- "outputs": [],
122
- "source": [
123
- "documents = [\n",
124
- " {\n",
125
- " \"_id\": r[\"page_id\"],\n",
126
- " \"embedding\": r[\"image_vector\"], # pre-computed Voyage vector (BYOV)\n",
127
- " \"dialogue\": r[\"all_dialogue\"],\n",
128
- " \"comic_title\": r[\"comic_title\"],\n",
129
- " \"page_id\": r[\"page_id\"],\n",
130
- " \"page_num\": r[\"page_num\"],\n",
131
- " \"file_path\": r[\"file_path\"],\n",
132
- " }\n",
133
- " for r in records\n",
134
- "]\n",
135
- "\n",
136
- "index.documents.batch_upsert(\n",
137
- " namespace=NAMESPACE,\n",
138
- " documents=documents,\n",
139
- " batch_size=BATCH_SIZE,\n",
140
- " show_progress=True,\n",
141
- ")\n",
142
- "print(f\"Upserted {len(documents)} pages to {NAMESPACE}.\")"
143
- ]
144
- },
145
- {
146
- "cell_type": "markdown",
147
- "metadata": {},
148
- "source": [
149
- "## Search demos\n",
150
- "\n",
151
- "All queries are embedded with Voyage AI at query time — both text and image queries go through the same model, landing in the same vector space."
152
- ]
153
- },
154
- {
155
- "cell_type": "code",
156
- "execution_count": null,
157
- "metadata": {},
158
- "outputs": [],
159
- "source": [
160
- "from IPython.display import display\n",
161
- "\n",
162
- "\n",
163
- "def show_results(matches, label=\"\"):\n",
164
- " if label:\n",
165
- " print(f\"\\n{'='*60}\\n{label}\\n{'='*60}\")\n",
166
- " for m in matches:\n",
167
- " comic = getattr(m, \"comic_title\", \"\")\n",
168
- " page = getattr(m, \"page_num\", \"\")\n",
169
- " print(f\"[{m._score:.3f}] {m._id} ({comic}, p{page})\")\n",
170
- " fp = getattr(m, \"file_path\", None)\n",
171
- " if fp and Path(fp).exists():\n",
172
- " img = Image.open(fp)\n",
173
- " img.thumbnail((250, 350))\n",
174
- " display(img)"
175
- ]
176
- },
177
- {
178
- "cell_type": "code",
179
- "execution_count": null,
180
- "metadata": {},
181
- "outputs": [],
182
- "source": [
183
- "# Text-to-image: embed a text query with Voyage, search image vectors\n",
184
- "text_query = \"dark alley chase scene at night\"\n",
185
- "result = vo.multimodal_embed([[text_query]], model=\"voyage-multimodal-3\", input_type=\"query\")\n",
186
- "query_vector = result.embeddings[0]\n",
187
- "\n",
188
- "resp = index.documents.search(\n",
189
- " namespace=NAMESPACE,\n",
190
- " top_k=3,\n",
191
- " score_by=[{\"type\": \"dense_vector\", \"field\": \"embedding\", \"query\": query_vector}],\n",
192
- " include_fields=[\"page_id\", \"comic_title\", \"page_num\", \"file_path\"],\n",
193
- ")\n",
194
- "show_results(resp.matches, f\"Text-to-image: '{text_query}'\")"
195
- ]
196
- },
197
- {
198
- "cell_type": "code",
199
- "execution_count": null,
200
- "metadata": {},
201
- "outputs": [],
202
- "source": [
203
- "# Image-to-image: use a page image as the query to find visually similar pages\n",
204
- "query_page = records[0] # change to any page you want to use as a query\n",
205
- "query_img = Image.open(query_page[\"file_path\"]).convert(\"RGB\")\n",
206
- "result = vo.multimodal_embed([[query_img]], model=\"voyage-multimodal-3\", input_type=\"query\")\n",
207
- "img_query_vector = result.embeddings[0]\n",
208
- "\n",
209
- "print(f\"Query image: {query_page['page_id']}\")\n",
210
- "query_img.thumbnail((250, 350))\n",
211
- "display(query_img)\n",
212
- "\n",
213
- "resp = index.documents.search(\n",
214
- " namespace=NAMESPACE,\n",
215
- " top_k=4,\n",
216
- " score_by=[{\"type\": \"dense_vector\", \"field\": \"embedding\", \"query\": img_query_vector}],\n",
217
- " # Exclude the query page itself\n",
218
- " filter={\"page_id\": {\"$ne\": query_page[\"page_id\"]}},\n",
219
- " include_fields=[\"page_id\", \"comic_title\", \"page_num\", \"file_path\"],\n",
220
- ")\n",
221
- "show_results(resp.matches, \"Image-to-image: visually similar pages\")"
222
- ]
223
- },
224
- {
225
- "cell_type": "code",
226
- "execution_count": null,
227
- "metadata": {},
228
- "outputs": [],
229
- "source": [
230
- "# Cross-comic visual style: find action scenes across all comics\n",
231
- "result = vo.multimodal_embed([[\"superhero punching villain action scene\"]], model=\"voyage-multimodal-3\", input_type=\"query\")\n",
232
- "resp = index.documents.search(\n",
233
- " namespace=NAMESPACE,\n",
234
- " top_k=5,\n",
235
- " score_by=[{\"type\": \"dense_vector\", \"field\": \"embedding\", \"query\": result.embeddings[0]}],\n",
236
- " include_fields=[\"page_id\", \"comic_title\", \"page_num\", \"file_path\"],\n",
237
- ")\n",
238
- "show_results(resp.matches, \"Cross-comic: 'superhero punching villain action scene'\")"
239
- ]
240
- }
241
- ],
242
- "metadata": {
243
- "kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"},
244
- "language_info": {"name": "python", "version": "3.11.0"}
245
- },
246
- "nbformat": 4,
247
- "nbformat_minor": 4
248
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
04_approach3_hybrid.ipynb DELETED
@@ -1,266 +0,0 @@
1
- {
2
- "cells": [
3
- {
4
- "cell_type": "markdown",
5
- "metadata": {},
6
- "source": [
7
- "# 04 · Approach 3: Full Hybrid (Dense + Sparse + FTS)\n",
8
- "\n",
9
- "**Strategy:** Combine all three Pinecone search signals in a single namespace:\n",
10
- "- **Dense vector** — LLM caption embedded with `llama-text-embed-v2` (semantic meaning)\n",
11
- "- **Sparse vector** — dialogue embedded with `pinecone-sparse-english-v0` (keyword relevance)\n",
12
- "- **FTS field** — raw dialogue text indexed with BM25 (exact phrase matching)\n",
13
- "\n",
14
- "**Namespace:** `ns-hybrid`\n",
15
- "\n",
16
- "**Blog post insight:** A single Pinecone index can store all three signal types together. The key design constraint: each query picks *one* ranking signal — but you can chain them: use FTS/sparse filters to narrow the candidate set, then dense vectors re-rank for semantic relevance. This gives you the precision of keyword search with the recall of semantic search.\n",
17
- "\n",
18
- "---\n",
19
- "\n",
20
- "## Signal comparison\n",
21
- "\n",
22
- "| Signal | Strengths | Weaknesses |\n",
23
- "|---|---|---|\n",
24
- "| Dense (LLM caption) | Conceptual themes, mood, setting | Misses specific names/words |\n",
25
- "| Sparse (dialogue BM25) | Exact character names, sound effects (KAPOW!) | No semantic understanding |\n",
26
- "| FTS (BM25 w/ stemming) | Phrase matching, stemming (\"run\" → \"running\") | Still keyword-based |\n",
27
- "| Filter + dense rerank | Precision of keywords + recall of semantics | Requires both signals |\n"
28
- ]
29
- },
30
- {
31
- "cell_type": "code",
32
- "execution_count": null,
33
- "metadata": {},
34
- "outputs": [],
35
- "source": [
36
- "import json\n",
37
- "import os\n",
38
- "import time\n",
39
- "from pathlib import Path\n",
40
- "\n",
41
- "from dotenv import load_dotenv\n",
42
- "from pinecone import Pinecone\n",
43
- "from tqdm import tqdm\n",
44
- "\n",
45
- "load_dotenv()\n",
46
- "\n",
47
- "TRANSCRIPTS_DIR = Path(\"data/transcripts\")\n",
48
- "INDEX_NAME = \"comics-multimodal\"\n",
49
- "NAMESPACE = \"ns-hybrid\"\n",
50
- "BATCH_SIZE = 50\n",
51
- "\n",
52
- "pc = Pinecone(api_key=os.environ[\"PINECONE_API_KEY\"])\n",
53
- "\n",
54
- "info = pc.preview.indexes.describe(name=INDEX_NAME)\n",
55
- "INDEX_HOST = info.host\n",
56
- "index = pc.index(host=INDEX_HOST)\n",
57
- "print(f\"Connected to {INDEX_NAME}\")"
58
- ]
59
- },
60
- {
61
- "cell_type": "markdown",
62
- "metadata": {},
63
- "source": [
64
- "## Generate sparse vectors\n",
65
- "\n",
66
- "We use Pinecone's `pinecone-sparse-english-v0` model (via the inference API) to embed dialogue text into sparse vectors. These complement the dense LLM caption vectors."
67
- ]
68
- },
69
- {
70
- "cell_type": "code",
71
- "execution_count": null,
72
- "metadata": {},
73
- "outputs": [],
74
- "source": [
75
- "records = [json.loads(p.read_text()) for p in sorted(TRANSCRIPTS_DIR.glob(\"*.json\"))]\n",
76
- "print(f\"Loaded {len(records)} transcripts\")"
77
- ]
78
- },
79
- {
80
- "cell_type": "code",
81
- "execution_count": null,
82
- "metadata": {},
83
- "outputs": [],
84
- "source": [
85
- "def embed_sparse_batch(texts: list[str]) -> list[dict]:\n",
86
- " \"\"\"Embed texts with pinecone-sparse-english-v0. Returns list of {indices, values} dicts.\"\"\"\n",
87
- " result = pc.inference.embed(\n",
88
- " model=\"pinecone-sparse-english-v0\",\n",
89
- " inputs=texts,\n",
90
- " parameters={\"input_type\": \"passage\"},\n",
91
- " )\n",
92
- " return [{\"indices\": e.sparse_indices, \"values\": e.sparse_values} for e in result]\n",
93
- "\n",
94
- "\n",
95
- "# Cache sparse vectors in transcript JSON to avoid re-billing\n",
96
- "for i in tqdm(range(0, len(records), BATCH_SIZE), desc=\"Sparse embedding dialogue\"):\n",
97
- " batch = records[i : i + BATCH_SIZE]\n",
98
- " needs = [r for r in batch if not r.get(\"sparse_vector\")]\n",
99
- " if not needs:\n",
100
- " continue\n",
101
- " texts = [r[\"all_dialogue\"] or r[\"scene_descriptions\"] for r in needs]\n",
102
- " sparse_vecs = embed_sparse_batch(texts)\n",
103
- " for r, sv in zip(needs, sparse_vecs):\n",
104
- " r[\"sparse_vector\"] = sv\n",
105
- " path = TRANSCRIPTS_DIR / f\"{r['page_id']}.json\"\n",
106
- " path.write_text(json.dumps(r, ensure_ascii=False, indent=2))\n",
107
- "\n",
108
- "print(\"Sparse vectors ready.\")"
109
- ]
110
- },
111
- {
112
- "cell_type": "markdown",
113
- "metadata": {},
114
- "source": [
115
- "## Upsert to Pinecone (ns-hybrid)\n",
116
- "\n",
117
- "Each document carries:\n",
118
- "- `embedding` — the LLM caption text (Pinecone auto-embeds with `llama-text-embed-v2`)\n",
119
- "- `sparse_embedding` — pre-computed sparse vector from `pinecone-sparse-english-v0`\n",
120
- "- `dialogue` — raw text for the FTS field"
121
- ]
122
- },
123
- {
124
- "cell_type": "code",
125
- "execution_count": null,
126
- "metadata": {},
127
- "outputs": [],
128
- "source": [
129
- "documents = [\n",
130
- " {\n",
131
- " \"_id\": r[\"page_id\"],\n",
132
- " \"embedding\": r[\"llm_caption\"], # integrated inference → dense vector\n",
133
- " \"sparse_embedding\": r[\"sparse_vector\"], # BYOV sparse vector\n",
134
- " \"dialogue\": r[\"all_dialogue\"], # FTS field\n",
135
- " \"comic_title\": r[\"comic_title\"],\n",
136
- " \"page_id\": r[\"page_id\"],\n",
137
- " \"page_num\": r[\"page_num\"],\n",
138
- " \"file_path\": r[\"file_path\"],\n",
139
- " \"llm_caption\": r[\"llm_caption\"],\n",
140
- " }\n",
141
- " for r in records\n",
142
- "]\n",
143
- "\n",
144
- "index.documents.batch_upsert(\n",
145
- " namespace=NAMESPACE,\n",
146
- " documents=documents,\n",
147
- " batch_size=BATCH_SIZE,\n",
148
- " show_progress=True,\n",
149
- ")\n",
150
- "print(f\"Upserted {len(documents)} pages to {NAMESPACE}.\")"
151
- ]
152
- },
153
- {
154
- "cell_type": "markdown",
155
- "metadata": {},
156
- "source": [
157
- "## Search demos — comparing all four query modes"
158
- ]
159
- },
160
- {
161
- "cell_type": "code",
162
- "execution_count": null,
163
- "metadata": {},
164
- "outputs": [],
165
- "source": [
166
- "from IPython.display import display\n",
167
- "from PIL import Image\n",
168
- "\n",
169
- "\n",
170
- "def show_results(matches, label=\"\"):\n",
171
- " if label:\n",
172
- " print(f\"\\n{'='*60}\\n{label}\\n{'='*60}\")\n",
173
- " for m in matches:\n",
174
- " caption = getattr(m, \"llm_caption\", \"\") or \"\"\n",
175
- " print(f\"[{m._score:.3f}] {m._id} — {caption[:100]}\")\n",
176
- " fp = getattr(m, \"file_path\", None)\n",
177
- " if fp and Path(fp).exists():\n",
178
- " img = Image.open(fp)\n",
179
- " img.thumbnail((250, 350))\n",
180
- " display(img)"
181
- ]
182
- },
183
- {
184
- "cell_type": "code",
185
- "execution_count": null,
186
- "metadata": {},
187
- "outputs": [],
188
- "source": [
189
- "# 1. Dense-only: semantic search on LLM captions\n",
190
- "resp = index.documents.search(\n",
191
- " namespace=NAMESPACE,\n",
192
- " top_k=3,\n",
193
- " score_by=[{\"type\": \"dense_vector\", \"field\": \"embedding\", \"query\": \"a moment of betrayal and deception\"}],\n",
194
- " include_fields=[\"page_id\", \"comic_title\", \"llm_caption\", \"file_path\"],\n",
195
- ")\n",
196
- "show_results(resp.matches, \"Dense only: 'a moment of betrayal and deception'\")"
197
- ]
198
- },
199
- {
200
- "cell_type": "code",
201
- "execution_count": null,
202
- "metadata": {},
203
- "outputs": [],
204
- "source": [
205
- "# 2. Sparse-only: keyword search on dialogue\n",
206
- "sparse_query = pc.inference.embed(\n",
207
- " model=\"pinecone-sparse-english-v0\",\n",
208
- " inputs=[\"KAPOW detective badge\"],\n",
209
- " parameters={\"input_type\": \"query\"},\n",
210
- ")[0]\n",
211
- "\n",
212
- "resp = index.documents.search(\n",
213
- " namespace=NAMESPACE,\n",
214
- " top_k=3,\n",
215
- " score_by=[{\n",
216
- " \"type\": \"sparse_vector\",\n",
217
- " \"field\": \"sparse_embedding\",\n",
218
- " \"query\": {\"indices\": sparse_query.sparse_indices, \"values\": sparse_query.sparse_values},\n",
219
- " }],\n",
220
- " include_fields=[\"page_id\", \"comic_title\", \"dialogue\", \"file_path\"],\n",
221
- ")\n",
222
- "show_results(resp.matches, \"Sparse only: 'KAPOW detective badge'\")"
223
- ]
224
- },
225
- {
226
- "cell_type": "code",
227
- "execution_count": null,
228
- "metadata": {},
229
- "outputs": [],
230
- "source": [
231
- "# 3. FTS: BM25 on dialogue field (stemming catches run/running/ran)\n",
232
- "resp = index.documents.search(\n",
233
- " namespace=NAMESPACE,\n",
234
- " top_k=3,\n",
235
- " score_by=[{\"type\": \"text\", \"fields\": [\"dialogue\"], \"query\": \"running from danger\"}],\n",
236
- " include_fields=[\"page_id\", \"comic_title\", \"dialogue\", \"file_path\"],\n",
237
- ")\n",
238
- "show_results(resp.matches, \"FTS (BM25 + stemming): 'running from danger'\")"
239
- ]
240
- },
241
- {
242
- "cell_type": "code",
243
- "execution_count": null,
244
- "metadata": {},
245
- "outputs": [],
246
- "source": [
247
- "# 4. Filter + dense rerank — the \"hybrid\" pattern\n",
248
- "# Keyword filter narrows candidates, dense vector re-ranks by semantic relevance\n",
249
- "resp = index.documents.search(\n",
250
- " namespace=NAMESPACE,\n",
251
- " top_k=3,\n",
252
- " score_by=[{\"type\": \"dense_vector\", \"field\": \"embedding\", \"query\": \"tense face-off between hero and villain\"}],\n",
253
- " filter={\"dialogue\": {\"$match_any\": [\"stop\", \"never\", \"finish\"]}},\n",
254
- " include_fields=[\"page_id\", \"comic_title\", \"llm_caption\", \"file_path\"],\n",
255
- ")\n",
256
- "show_results(resp.matches, \"Keyword filter + dense rerank: semantic 'tense face-off' where dialogue has stop/never/finish\")"
257
- ]
258
- }
259
- ],
260
- "metadata": {
261
- "kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"},
262
- "language_info": {"name": "python", "version": "3.11.0"}
263
- },
264
- "nbformat": 4,
265
- "nbformat_minor": 4
266
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
05_story_builder.ipynb DELETED
@@ -1,251 +0,0 @@
1
- {
2
- "cells": [
3
- {
4
- "cell_type": "markdown",
5
- "metadata": {},
6
- "source": [
7
- "# 05 · Story Builder Demo\n",
8
- "\n",
9
- "Two demos in one notebook.\n",
10
- "\n",
11
- "**Section 1 — Cross-namespace comparison:** The same query goes to all three namespaces simultaneously. Results are shown side-by-side so you can see exactly how each embedding approach retrieves different pages for the same search.\n",
12
- "\n",
13
- "**Section 2 — Narrative assembler:** Chain four queries (setup → conflict → climax → resolution) to stitch pages from multiple different comics into a brand-new story arc."
14
- ]
15
- },
16
- {
17
- "cell_type": "code",
18
- "execution_count": null,
19
- "metadata": {},
20
- "outputs": [],
21
- "source": [
22
- "import json\n",
23
- "import os\n",
24
- "from pathlib import Path\n",
25
- "\n",
26
- "import matplotlib.pyplot as plt\n",
27
- "import voyageai\n",
28
- "from dotenv import load_dotenv\n",
29
- "from PIL import Image\n",
30
- "from pinecone import Pinecone\n",
31
- "\n",
32
- "load_dotenv()\n",
33
- "\n",
34
- "TRANSCRIPTS_DIR = Path(\"data/transcripts\")\n",
35
- "INDEX_NAME = \"comics-multimodal\"\n",
36
- "\n",
37
- "pc = Pinecone(api_key=os.environ[\"PINECONE_API_KEY\"])\n",
38
- "vo = voyageai.Client()\n",
39
- "\n",
40
- "info = pc.preview.indexes.describe(name=INDEX_NAME)\n",
41
- "index = pc.index(host=info.host)\n",
42
- "print(f\"Connected to {INDEX_NAME}\")"
43
- ]
44
- },
45
- {
46
- "cell_type": "code",
47
- "execution_count": null,
48
- "metadata": {},
49
- "outputs": [],
50
- "source": [
51
- "# Helper: embed query text into Voyage space (for ns-image-clip)\n",
52
- "def voyage_query_vector(text: str) -> list[float]:\n",
53
- " result = vo.multimodal_embed([[text]], model=\"voyage-multimodal-3\", input_type=\"query\")\n",
54
- " return result.embeddings[0]\n",
55
- "\n",
56
- "\n",
57
- "# Helper: search a namespace, return matches\n",
58
- "def search_namespace(namespace: str, query: str, top_k: int = 5) -> list:\n",
59
- " if namespace == \"ns-image-clip\":\n",
60
- " # Voyage multimodal vector — text and image in the same space\n",
61
- " qvec = voyage_query_vector(query)\n",
62
- " score_by = [{\"type\": \"dense_vector\", \"field\": \"embedding\", \"query\": qvec}]\n",
63
- " else:\n",
64
- " # llama-text-embed-v2 via integrated inference\n",
65
- " score_by = [{\"type\": \"dense_vector\", \"field\": \"embedding\", \"query\": query}]\n",
66
- "\n",
67
- " resp = index.documents.search(\n",
68
- " namespace=namespace,\n",
69
- " top_k=top_k,\n",
70
- " score_by=score_by,\n",
71
- " include_fields=[\"page_id\", \"comic_title\", \"page_num\", \"file_path\", \"llm_caption\"],\n",
72
- " )\n",
73
- " return resp.matches"
74
- ]
75
- },
76
- {
77
- "cell_type": "markdown",
78
- "metadata": {},
79
- "source": [
80
- "---\n",
81
- "## Section 1 — Cross-namespace comparison\n",
82
- "\n",
83
- "Same query, three namespaces, side-by-side."
84
- ]
85
- },
86
- {
87
- "cell_type": "code",
88
- "execution_count": null,
89
- "metadata": {},
90
- "outputs": [],
91
- "source": [
92
- "def compare_namespaces(query: str, top_k: int = 3):\n",
93
- " namespaces = [\n",
94
- " (\"ns-text-caption\", \"LLM Caption\\n(dense)\"),\n",
95
- " (\"ns-image-clip\", \"Voyage Image\\n(visual)\"),\n",
96
- " (\"ns-hybrid\", \"Hybrid\\n(dense+sparse+FTS)\"),\n",
97
- " ]\n",
98
- "\n",
99
- " all_results = {}\n",
100
- " for ns, _ in namespaces:\n",
101
- " all_results[ns] = search_namespace(ns, query, top_k=top_k)\n",
102
- "\n",
103
- " fig, axes = plt.subplots(top_k, len(namespaces), figsize=(14, top_k * 4))\n",
104
- " fig.suptitle(f'Query: \"{query}\"', fontsize=13, fontweight=\"bold\", y=1.01)\n",
105
- "\n",
106
- " for col, (ns, ns_label) in enumerate(namespaces):\n",
107
- " axes[0][col].set_title(ns_label, fontsize=11, pad=8)\n",
108
- " for row, match in enumerate(all_results[ns]):\n",
109
- " ax = axes[row][col]\n",
110
- " fp = getattr(match, \"file_path\", None)\n",
111
- " if fp and Path(fp).exists():\n",
112
- " img = Image.open(fp).convert(\"RGB\")\n",
113
- " ax.imshow(img)\n",
114
- " else:\n",
115
- " ax.text(0.5, 0.5, \"no image\", ha=\"center\", va=\"center\")\n",
116
- " comic = getattr(match, \"comic_title\", \"\") or \"\"\n",
117
- " page = getattr(match, \"page_num\", \"\") or \"\"\n",
118
- " ax.set_xlabel(f\"{comic}\\np{page} score={match._score:.3f}\", fontsize=8)\n",
119
- " ax.set_xticks([])\n",
120
- " ax.set_yticks([])\n",
121
- "\n",
122
- " plt.tight_layout()\n",
123
- " plt.show()"
124
- ]
125
- },
126
- {
127
- "cell_type": "code",
128
- "execution_count": null,
129
- "metadata": {},
130
- "outputs": [],
131
- "source": [
132
- "compare_namespaces(\"detective discovers a clue\")"
133
- ]
134
- },
135
- {
136
- "cell_type": "code",
137
- "execution_count": null,
138
- "metadata": {},
139
- "outputs": [],
140
- "source": [
141
- "compare_namespaces(\"KAPOW punch fight action\")"
142
- ]
143
- },
144
- {
145
- "cell_type": "code",
146
- "execution_count": null,
147
- "metadata": {},
148
- "outputs": [],
149
- "source": [
150
- "compare_namespaces(\"mysterious figure in shadows\")"
151
- ]
152
- },
153
- {
154
- "cell_type": "markdown",
155
- "metadata": {},
156
- "source": [
157
- "---\n",
158
- "## Section 2 — Narrative Assembler\n",
159
- "\n",
160
- "Chain four queries — one per story beat — and assemble the top matching page from each into a 2×2 comic grid.\n",
161
- "\n",
162
- "Pages are deduplicated: if two beats return the same page, we take the next best match."
163
- ]
164
- },
165
- {
166
- "cell_type": "code",
167
- "execution_count": null,
168
- "metadata": {},
169
- "outputs": [],
170
- "source": [
171
- "def assemble_story(story_beats: list[tuple[str, str]], namespace: str = \"ns-hybrid\"):\n",
172
- " \"\"\"\n",
173
- " story_beats: list of (beat_label, query_text)\n",
174
- " Fetches top pages per beat (with dedup) and renders a grid.\n",
175
- " \"\"\"\n",
176
- " used_ids = set()\n",
177
- " selected = [] # list of (label, match)\n",
178
- "\n",
179
- " for label, query in story_beats:\n",
180
- " matches = search_namespace(namespace, query, top_k=10)\n",
181
- " for m in matches:\n",
182
- " if m._id not in used_ids:\n",
183
- " used_ids.add(m._id)\n",
184
- " selected.append((label, m))\n",
185
- " break\n",
186
- "\n",
187
- " n = len(selected)\n",
188
- " cols = 2\n",
189
- " rows = (n + 1) // cols\n",
190
- " fig, axes = plt.subplots(rows, cols, figsize=(12, rows * 5))\n",
191
- " axes = axes.flatten() if n > 1 else [axes]\n",
192
- "\n",
193
- " for i, (label, match) in enumerate(selected):\n",
194
- " ax = axes[i]\n",
195
- " fp = getattr(match, \"file_path\", None)\n",
196
- " if fp and Path(fp).exists():\n",
197
- " ax.imshow(Image.open(fp).convert(\"RGB\"))\n",
198
- " comic = getattr(match, \"comic_title\", \"\") or \"\"\n",
199
- " page = getattr(match, \"page_num\", \"\") or \"\"\n",
200
- " caption = getattr(match, \"llm_caption\", \"\") or \"\"\n",
201
- " ax.set_title(f\"{label}\", fontsize=11, fontweight=\"bold\")\n",
202
- " ax.set_xlabel(f\"{comic} · p{page}\\n{caption[:80]}…\", fontsize=8)\n",
203
- " ax.set_xticks([])\n",
204
- " ax.set_yticks([])\n",
205
- "\n",
206
- " # Hide unused axes\n",
207
- " for j in range(n, len(axes)):\n",
208
- " axes[j].set_visible(False)\n",
209
- "\n",
210
- " fig.suptitle(\"Assembled Story Arc\", fontsize=14, fontweight=\"bold\")\n",
211
- " plt.tight_layout()\n",
212
- " plt.show()"
213
- ]
214
- },
215
- {
216
- "cell_type": "code",
217
- "execution_count": null,
218
- "metadata": {},
219
- "outputs": [],
220
- "source": [
221
- "assemble_story([\n",
222
- " (\"1. Setup\", \"detective arrives at a crime scene\"),\n",
223
- " (\"2. Conflict\", \"villain threatens the hero\"),\n",
224
- " (\"3. Climax\", \"dramatic fight or physical confrontation\"),\n",
225
- " (\"4. Resolution\", \"hero wins and villain is defeated\"),\n",
226
- "])"
227
- ]
228
- },
229
- {
230
- "cell_type": "code",
231
- "execution_count": null,
232
- "metadata": {},
233
- "outputs": [],
234
- "source": [
235
- "# Try your own story arc:\n",
236
- "assemble_story([\n",
237
- " (\"1. Setup\", \"mysterious stranger arrives in town\"),\n",
238
- " (\"2. Tension\", \"secret revealed in a dimly lit room\"),\n",
239
- " (\"3. Climax\", \"desperate chase through the streets\"),\n",
240
- " (\"4. Twist\", \"unexpected alliance between enemies\"),\n",
241
- "])"
242
- ]
243
- }
244
- ],
245
- "metadata": {
246
- "kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"},
247
- "language_info": {"name": "python", "version": "3.11.0"}
248
- },
249
- "nbformat": 4,
250
- "nbformat_minor": 4
251
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
06_panel_level_exploration.ipynb DELETED
@@ -1,333 +0,0 @@
1
- {
2
- "cells": [
3
- {
4
- "cell_type": "markdown",
5
- "metadata": {},
6
- "source": [
7
- "# 06 · Panel-Level Exploration (Bonus)\n",
8
- "\n",
9
- "**Goal:** Explore whether embedding individual *panels* rather than full *pages* produces meaningfully better retrieval.\n",
10
- "\n",
11
- "**Hypothesis:** A page of 6 panels covers multiple scenes and characters. Embedding the whole page averages out these signals. A panel-level embedding is more focused — a query for *\"a character looking surprised\"* should return a single panel, not a page where that moment is one of six.\n",
12
- "\n",
13
- "**Namespace:** `ns-panels`\n",
14
- "\n",
15
- "**Panel detection approach:** OpenCV contour detection. Comic panels have strong rectangular borders; edge detection + bounding box extraction works surprisingly well on classic golden-age comics without needing an ML model.\n",
16
- "\n",
17
- "---\n",
18
- "\n",
19
- "## When to use panel-level embeddings\n",
20
- "- Maximum retrieval precision (the story-builder demo becomes much more compelling)\n",
21
- "- Character-level queries: \"find all panels featuring a woman in a cape\"\n",
22
- "- Panel count is manageable — 5 comics × 30 pages × ~6 panels = ~900 panels"
23
- ]
24
- },
25
- {
26
- "cell_type": "code",
27
- "execution_count": null,
28
- "metadata": {},
29
- "outputs": [],
30
- "source": [
31
- "import json\n",
32
- "import os\n",
33
- "from pathlib import Path\n",
34
- "\n",
35
- "import cv2\n",
36
- "import numpy as np\n",
37
- "import voyageai\n",
38
- "from dotenv import load_dotenv\n",
39
- "from IPython.display import display\n",
40
- "from PIL import Image\n",
41
- "from pinecone import Pinecone\n",
42
- "from tqdm import tqdm\n",
43
- "\n",
44
- "load_dotenv()\n",
45
- "\n",
46
- "TRANSCRIPTS_DIR = Path(\"data/transcripts\")\n",
47
- "PANELS_DIR = Path(\"data/panels\")\n",
48
- "PANELS_DIR.mkdir(parents=True, exist_ok=True)\n",
49
- "\n",
50
- "INDEX_NAME = \"comics-multimodal\"\n",
51
- "NAMESPACE = \"ns-panels\"\n",
52
- "MIN_PANEL_W = 100 # pixels — ignore tiny boxes (captions, gutters)\n",
53
- "MIN_PANEL_H = 100\n",
54
- "\n",
55
- "pc = Pinecone(api_key=os.environ[\"PINECONE_API_KEY\"])\n",
56
- "vo = voyageai.Client()\n",
57
- "\n",
58
- "info = pc.preview.indexes.describe(name=INDEX_NAME)\n",
59
- "index = pc.index(host=info.host)\n",
60
- "print(f\"Connected to {INDEX_NAME}\")"
61
- ]
62
- },
63
- {
64
- "cell_type": "markdown",
65
- "metadata": {},
66
- "source": [
67
- "## Panel detection with OpenCV\n",
68
- "\n",
69
- "Classic comics have clean rectangular panel borders. We:\n",
70
- "1. Convert to grayscale and apply binary threshold\n",
71
- "2. Find external contours\n",
72
- "3. Filter by minimum area and aspect ratio\n",
73
- "4. Return bounding boxes sorted top-to-bottom, left-to-right (reading order)"
74
- ]
75
- },
76
- {
77
- "cell_type": "code",
78
- "execution_count": null,
79
- "metadata": {},
80
- "outputs": [],
81
- "source": [
82
- "def detect_panels(image_path: str, min_w: int = MIN_PANEL_W, min_h: int = MIN_PANEL_H) -> list[tuple]:\n",
83
- " \"\"\"Return list of (x, y, w, h) bounding boxes for detected panels.\"\"\"\n",
84
- " img = cv2.imread(image_path)\n",
85
- " if img is None:\n",
86
- " return []\n",
87
- " gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)\n",
88
- " _, bw = cv2.threshold(gray, 200, 255, cv2.THRESH_BINARY_INV)\n",
89
- " kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))\n",
90
- " bw = cv2.morphologyEx(bw, cv2.MORPH_CLOSE, kernel)\n",
91
- " contours, _ = cv2.findContours(bw, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)\n",
92
- "\n",
93
- " boxes = []\n",
94
- " h_img, w_img = img.shape[:2]\n",
95
- " for c in contours:\n",
96
- " x, y, w, h = cv2.boundingRect(c)\n",
97
- " # Skip boxes that are the whole page or too small\n",
98
- " if w < min_w or h < min_h:\n",
99
- " continue\n",
100
- " if w > w_img * 0.95 and h > h_img * 0.95:\n",
101
- " continue\n",
102
- " boxes.append((x, y, w, h))\n",
103
- "\n",
104
- " # Sort reading order: top-to-bottom, then left-to-right\n",
105
- " boxes.sort(key=lambda b: (b[1] // (h_img // 4), b[0]))\n",
106
- " return boxes\n",
107
- "\n",
108
- "\n",
109
- "def crop_panel(image_path: str, box: tuple, out_path: Path) -> str | None:\n",
110
- " \"\"\"Crop a panel bounding box and save. Returns path or None on failure.\"\"\"\n",
111
- " if out_path.exists():\n",
112
- " return str(out_path)\n",
113
- " img = Image.open(image_path).convert(\"RGB\")\n",
114
- " x, y, w, h = box\n",
115
- " panel = img.crop((x, y, x + w, y + h))\n",
116
- " panel.save(out_path, quality=90)\n",
117
- " return str(out_path)"
118
- ]
119
- },
120
- {
121
- "cell_type": "code",
122
- "execution_count": null,
123
- "metadata": {},
124
- "outputs": [],
125
- "source": [
126
- "# Detect and crop all panels, build panel manifest\n",
127
- "records = [json.loads(p.read_text()) for p in sorted(TRANSCRIPTS_DIR.glob(\"*.json\"))]\n",
128
- "panel_data = []\n",
129
- "\n",
130
- "for r in tqdm(records, desc=\"Detecting panels\"):\n",
131
- " boxes = detect_panels(r[\"file_path\"])\n",
132
- " if not boxes:\n",
133
- " # Fall back: treat the whole page as a single panel\n",
134
- " boxes = [(0, 0, 9999, 9999)]\n",
135
- " slug_dir = PANELS_DIR / r[\"comic_slug\"]\n",
136
- " slug_dir.mkdir(parents=True, exist_ok=True)\n",
137
- " for i, box in enumerate(boxes):\n",
138
- " panel_id = f\"{r['page_id']}_panel{i+1:02d}\"\n",
139
- " out_path = slug_dir / f\"{panel_id}.jpg\"\n",
140
- " if box == (0, 0, 9999, 9999):\n",
141
- " # Copy full page\n",
142
- " import shutil\n",
143
- " shutil.copy(r[\"file_path\"], out_path)\n",
144
- " else:\n",
145
- " crop_panel(r[\"file_path\"], box, out_path)\n",
146
- " panel_data.append({\n",
147
- " \"panel_id\": panel_id,\n",
148
- " \"page_id\": r[\"page_id\"],\n",
149
- " \"comic_title\": r[\"comic_title\"],\n",
150
- " \"comic_slug\": r[\"comic_slug\"],\n",
151
- " \"page_num\": r[\"page_num\"],\n",
152
- " \"panel_num\": i + 1,\n",
153
- " \"file_path\": str(out_path),\n",
154
- " })\n",
155
- "\n",
156
- "print(f\"Total panels detected: {len(panel_data)} ({len(panel_data)/len(records):.1f} per page avg)\")"
157
- ]
158
- },
159
- {
160
- "cell_type": "markdown",
161
- "metadata": {},
162
- "source": [
163
- "## Spot-check panel crops"
164
- ]
165
- },
166
- {
167
- "cell_type": "code",
168
- "execution_count": null,
169
- "metadata": {},
170
- "outputs": [],
171
- "source": [
172
- "# Show the first 6 panels from the first page\n",
173
- "first_page_panels = [p for p in panel_data if p[\"page_id\"] == panel_data[0][\"page_id\"]][:6]\n",
174
- "fig, axes = plt.subplots(1, len(first_page_panels), figsize=(14, 4))\n",
175
- "if len(first_page_panels) == 1:\n",
176
- " axes = [axes]\n",
177
- "for ax, p in zip(axes, first_page_panels):\n",
178
- " ax.imshow(Image.open(p[\"file_path\"]))\n",
179
- " ax.set_title(f\"Panel {p['panel_num']}\", fontsize=9)\n",
180
- " ax.axis(\"off\")\n",
181
- "plt.suptitle(f\"Detected panels — {first_page_panels[0]['page_id']}\", fontsize=11)\n",
182
- "plt.tight_layout()\n",
183
- "plt.show()"
184
- ]
185
- },
186
- {
187
- "cell_type": "markdown",
188
- "metadata": {},
189
- "source": [
190
- "## Embed panels and upsert to ns-panels"
191
- ]
192
- },
193
- {
194
- "cell_type": "code",
195
- "execution_count": null,
196
- "metadata": {},
197
- "outputs": [],
198
- "source": [
199
- "import matplotlib.pyplot as plt\n",
200
- "\n",
201
- "EMBED_BATCH = 10\n",
202
- "\n",
203
- "for i in tqdm(range(0, len(panel_data), EMBED_BATCH), desc=\"Embedding panels\"):\n",
204
- " batch = panel_data[i : i + EMBED_BATCH]\n",
205
- " needs = [p for p in batch if not p.get(\"image_vector\")]\n",
206
- " if not needs:\n",
207
- " continue\n",
208
- " inputs = [[Image.open(p[\"file_path\"]).convert(\"RGB\")] for p in needs]\n",
209
- " result = vo.multimodal_embed(inputs, model=\"voyage-multimodal-3\", input_type=\"document\")\n",
210
- " for p, vec in zip(needs, result.embeddings):\n",
211
- " p[\"image_vector\"] = vec\n",
212
- "\n",
213
- "print(\"Panel vectors ready.\")"
214
- ]
215
- },
216
- {
217
- "cell_type": "code",
218
- "execution_count": null,
219
- "metadata": {},
220
- "outputs": [],
221
- "source": [
222
- "documents = [\n",
223
- " {\n",
224
- " \"_id\": p[\"panel_id\"],\n",
225
- " \"embedding\": p[\"image_vector\"],\n",
226
- " \"comic_title\": p[\"comic_title\"],\n",
227
- " \"page_id\": p[\"page_id\"],\n",
228
- " \"page_num\": p[\"page_num\"],\n",
229
- " \"panel_num\": p[\"panel_num\"],\n",
230
- " \"file_path\": p[\"file_path\"],\n",
231
- " }\n",
232
- " for p in panel_data if p.get(\"image_vector\")\n",
233
- "]\n",
234
- "\n",
235
- "index.documents.batch_upsert(\n",
236
- " namespace=NAMESPACE,\n",
237
- " documents=documents,\n",
238
- " batch_size=50,\n",
239
- " show_progress=True,\n",
240
- ")\n",
241
- "print(f\"Upserted {len(documents)} panels to {NAMESPACE}.\")"
242
- ]
243
- },
244
- {
245
- "cell_type": "markdown",
246
- "metadata": {},
247
- "source": [
248
- "## Compare: page-level vs panel-level retrieval\n",
249
- "\n",
250
- "Run the same query against `ns-image-clip` (page-level) and `ns-panels` (panel-level) and see which gives more precise results."
251
- ]
252
- },
253
- {
254
- "cell_type": "code",
255
- "execution_count": null,
256
- "metadata": {},
257
- "outputs": [],
258
- "source": [
259
- "def compare_page_vs_panel(query: str, top_k: int = 4):\n",
260
- " qvec = vo.multimodal_embed([[query]], model=\"voyage-multimodal-3\", input_type=\"query\").embeddings[0]\n",
261
- "\n",
262
- " resp_page = index.documents.search(\n",
263
- " namespace=\"ns-image-clip\",\n",
264
- " top_k=top_k,\n",
265
- " score_by=[{\"type\": \"dense_vector\", \"field\": \"embedding\", \"query\": qvec}],\n",
266
- " include_fields=[\"page_id\", \"comic_title\", \"page_num\", \"file_path\"],\n",
267
- " )\n",
268
- " resp_panel = index.documents.search(\n",
269
- " namespace=NAMESPACE,\n",
270
- " top_k=top_k,\n",
271
- " score_by=[{\"type\": \"dense_vector\", \"field\": \"embedding\", \"query\": qvec}],\n",
272
- " include_fields=[\"page_id\", \"comic_title\", \"page_num\", \"panel_num\", \"file_path\"],\n",
273
- " )\n",
274
- "\n",
275
- " fig, axes = plt.subplots(2, top_k, figsize=(14, 8))\n",
276
- " fig.suptitle(f'Query: \"{query}\"', fontsize=12, fontweight=\"bold\")\n",
277
- "\n",
278
- " for col, m in enumerate(resp_page.matches):\n",
279
- " ax = axes[0][col]\n",
280
- " fp = getattr(m, \"file_path\", None)\n",
281
- " if fp and Path(fp).exists():\n",
282
- " ax.imshow(Image.open(fp))\n",
283
- " ax.set_title(f\"Page-level\\n{getattr(m, 'comic_title', '')} p{getattr(m, 'page_num', '')}\", fontsize=8)\n",
284
- " ax.axis(\"off\")\n",
285
- "\n",
286
- " for col, m in enumerate(resp_panel.matches):\n",
287
- " ax = axes[1][col]\n",
288
- " fp = getattr(m, \"file_path\", None)\n",
289
- " if fp and Path(fp).exists():\n",
290
- " ax.imshow(Image.open(fp))\n",
291
- " panel_n = getattr(m, \"panel_num\", \"\")\n",
292
- " ax.set_title(f\"Panel-level\\n{getattr(m, 'comic_title', '')} p{getattr(m, 'page_num', '')} panel{panel_n}\", fontsize=8)\n",
293
- " ax.axis(\"off\")\n",
294
- "\n",
295
- " plt.tight_layout()\n",
296
- " plt.show()"
297
- ]
298
- },
299
- {
300
- "cell_type": "code",
301
- "execution_count": null,
302
- "metadata": {},
303
- "outputs": [],
304
- "source": [
305
- "compare_page_vs_panel(\"character looking surprised or shocked\")"
306
- ]
307
- },
308
- {
309
- "cell_type": "code",
310
- "execution_count": null,
311
- "metadata": {},
312
- "outputs": [],
313
- "source": [
314
- "compare_page_vs_panel(\"close up face of a villain sneering\")"
315
- ]
316
- },
317
- {
318
- "cell_type": "code",
319
- "execution_count": null,
320
- "metadata": {},
321
- "outputs": [],
322
- "source": [
323
- "compare_page_vs_panel(\"two people talking at a table\")"
324
- ]
325
- }
326
- ],
327
- "metadata": {
328
- "kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"},
329
- "language_info": {"name": "python", "version": "3.11.0"}
330
- },
331
- "nbformat": 4,
332
- "nbformat_minor": 4
333
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
PLAN.md DELETED
@@ -1,311 +0,0 @@
1
- # Plan: Multimodal Comics Search with Pinecone
2
-
3
- ## Context
4
-
5
- Build a blog post-quality project that demonstrates Pinecone's vector search capabilities using public-domain golden age comics from the Digital Comic Museum. The project showcases multimodal data handling — combining image embeddings, LLM-generated descriptions, sparse vectors, and full-text search across different Pinecone namespaces. The goal is thought leadership content showing real trade-offs between embedding approaches, culminating in a "story builder" demo that assembles comic panels from multiple comics into a narrative.
6
-
7
- ---
8
-
9
- ## Project Structure
10
-
11
- ```
12
- comics-pinecone/
13
- ├── 00_data_acquisition.ipynb # Download + extract comics
14
- ├── 01_transcription.ipynb # comic-transcriber pipeline
15
- ├── 02_approach1_text_dense_fts.ipynb # Approach 1: LLM caption → dense + FTS
16
- ├── 03_approach2_image_embeddings.ipynb # Approach 2: Direct image embeddings (CLIP)
17
- ├── 04_approach3_hybrid.ipynb # Approach 3: Dense + Sparse + FTS hybrid
18
- ├── 05_story_builder.ipynb # Demo: cross-namespace comparison + story assembly
19
- ├── 06_panel_level_exploration.ipynb # Bonus: panel detection + panel-level embeddings
20
- ├── data/
21
- │ ├── raw/ # Downloaded CBZ/zip files
22
- │ ├── pages/ # Extracted page images (PNG)
23
- │ └── transcripts/ # comic-transcriber markdown output
24
- ├── utils/
25
- │ ├── downloader.py
26
- │ ├── extractor.py
27
- │ └── embedders.py
28
- └── .env.example
29
- ```
30
-
31
- ---
32
-
33
- ## Notebook 00 — Data Acquisition
34
-
35
- **Goal:** Download ~5–10 comics from https://digitalcomicmuseum.com/index.php?cid=110
36
-
37
- **Steps:**
38
- 1. Identify download links from the category page (web scraping or manual curation)
39
- 2. Download CBZ/ZIP archives
40
- 3. Extract to `data/pages/<comic_title>/page_001.jpg` etc.
41
- 4. Build a manifest CSV: `comic_title, page_num, file_path, image_url`
42
-
43
- **Key decision:** Start with 5 comics (~50–150 pages total) to keep costs and runtime manageable for a blog post demo.
44
-
45
- ---
46
-
47
- ## Notebook 01 — Transcription Pipeline
48
-
49
- **Goal:** Run comic-transcriber on each page and produce structured output.
50
-
51
- **Steps:**
52
- 1. Install comic-transcriber via NPM: `npx comic-transcriber`
53
- 2. Process each page image → outputs structured markdown per page:
54
- ```
55
- ## PAGE X, PANEL Y
56
- [Scene description]
57
- **CHARACTER**: Dialogue
58
- ```
59
- 3. Parse the markdown output into a structured Python dict per page:
60
- - `page_id`: `{comic_title}_{page_num}`
61
- - `panels`: list of panel dicts (description, dialogue)
62
- - `all_dialogue`: concatenated dialogue text (for FTS)
63
- - `scene_summary`: concatenated descriptions (for dense embedding)
64
- 4. Save to `data/transcripts/{comic_title}.json`
65
-
66
- **Output schema per page:**
67
- ```json
68
- {
69
- "page_id": "action_comics_001_p003",
70
- "comic_title": "Action Comics 001",
71
- "page_num": 3,
72
- "file_path": "data/pages/action_comics_001/page_003.jpg",
73
- "all_dialogue": "Look out! The villains are here...",
74
- "scene_descriptions": "A hero leaps across rooftops under a full moon...",
75
- "raw_transcript": "## PAGE 3, PANEL 1..."
76
- }
77
- ```
78
-
79
- ---
80
-
81
- ## Namespace Strategy (Single Pinecone Index)
82
-
83
- One **hybrid index** (`comics-multimodal`) via `pc.preview.indexes.create` with `SchemaBuilder`. A single Pinecone index can hold dense + sparse + FTS fields simultaneously (confirmed in docs: https://docs.pinecone.io/guides/search/full-text-search#schema-definition). Three namespaces, each representing a different embedding strategy.
84
-
85
- **Critical constraint:** Each query uses exactly one ranking signal (dense, sparse, or BM25/text). To combine signals, use text-match filters (`$match_phrase`, `$match_all`) to narrow candidates, then rank by vector.
86
-
87
- | Namespace | Embedding source | Model | Dim | Also has |
88
- |---|---|---|---|---|
89
- | `ns-text-caption` | LLM scene description → dense text embedding | `llama-text-embed-v2` | 1024 | FTS on dialogue |
90
- | `ns-image-clip` | Raw comic page image → multimodal embedding | `voyage-multimodal-3` | 1024 | — |
91
- | `ns-hybrid` | LLM description (dense) + dialogue (sparse + FTS) | `llama-text-embed-v2` + `pinecone-sparse-english-v0` | 1024 | FTS + sparse |
92
-
93
- **Index configuration (matches Pinecone web console):**
94
-
95
- | Setting | Value |
96
- |---|---|
97
- | Search type | Search by both meaning and exact words |
98
- | Embeddings | Dense + sparse |
99
- | Setup by model | `llama-text-embed-v2` (integrated inference for text namespaces) |
100
- | Dimension | 1,024 |
101
- | Metric | dotproduct (unit vectors → equivalent to cosine) |
102
- | Dense field name | `embedding` |
103
- | Sparse field name | `sparse_embedding` |
104
- | FTS field | `dialogue`, language: en, stemming: ON, stop words: ON |
105
-
106
- **Index schema (via `SchemaBuilder`):**
107
- ```python
108
- schema = (
109
- SchemaBuilder()
110
- .add_string_field(name="dialogue", full_text_search={"language": "en", "stemming": True, "stop_words": True})
111
- .add_string_field(name="comic_title", filterable=True)
112
- .add_string_field(name="page_id", filterable=True)
113
- .add_dense_vector_field(name="embedding", dimension=1024, metric="dotproduct")
114
- .add_sparse_vector_field(name="sparse_embedding")
115
- .build()
116
- )
117
- ```
118
-
119
- **Upsert pattern differs by namespace:**
120
- - `ns-text-caption` / `ns-hybrid`: pass text field → Pinecone auto-embeds via `llama-text-embed-v2` (integrated inference)
121
- - `ns-image-clip`: upsert raw 1024-dim Voyage AI vectors directly (bypass integrated inference)
122
-
123
- ---
124
-
125
- ## Notebook 02 — Approach 1: Text Caption + Dense + FTS
126
-
127
- **Embedding pipeline:**
128
- 1. Use Claude (`claude-haiku-4-5-20251001`) to generate a rich scene description per page from the comic-transcriber output. Prompt focuses on: setting, action, mood, characters present, visual style.
129
- 2. Embed the description using Pinecone's integrated `llama-text-embed-v2` (via `create_index_for_model`)
130
- 3. Store dialogue text as a separate FTS field using `pc.preview.indexes` with `SchemaBuilder`
131
-
132
- **Note:** Since we need both dense + FTS, this may require a hybrid index type or two separate calls. Check Pinecone's current support for combined dense + FTS in a single index. If not supported together, use two separate indexes and merge results at query time.
133
-
134
- **Metadata per vector:**
135
- ```python
136
- {
137
- "page_id": "action_comics_001_p003",
138
- "comic_title": "Action Comics 001",
139
- "page_num": 3,
140
- "image_url": "...",
141
- "scene_description": "...", # the LLM caption
142
- "all_dialogue": "..." # FTS field
143
- }
144
- ```
145
-
146
- **Demo queries in notebook:**
147
- - `"hero discovers villain's secret lair"` → semantic search on descriptions
148
- - `"Look out"` → FTS search on dialogue
149
- - Combined: FTS filter + dense reranking
150
-
151
- **Blog post insight:** LLM captions add semantic richness but lose visual detail. FTS on dialogue is great for exact quote matching.
152
-
153
- ---
154
-
155
- ## Notebook 03 — Approach 2: Direct Image Embeddings
156
-
157
- **Model:** Voyage AI `voyage-multimodal-3` (1024-dim, cosine)
158
- - API: `voyageai` Python package (`pip install voyageai`)
159
- - Cost: ~$0.000006/image
160
- - Handles both images and text queries in the same embedding space — enables cross-modal search
161
-
162
- **Pipeline:**
163
- ```python
164
- import voyageai
165
- vo = voyageai.Client() # reads VOYAGE_API_KEY from env
166
-
167
- # Indexing: embed page image
168
- result = vo.multimodal_embed([[page_image]], model="voyage-multimodal-3", input_type="document")
169
- vector = result.embeddings[0] # 1024-dim list
170
-
171
- # Querying: embed text query into same space
172
- result = vo.multimodal_embed([["dark alley chase scene"]], model="voyage-multimodal-3", input_type="query")
173
- query_vector = result.embeddings[0]
174
- ```
175
-
176
- **Demo queries:**
177
- - Text-to-image: `"dark alley chase scene"` → embed text, search `ns-image-clip`
178
- - Image-to-image: Use a comic page image as the query input → find visually similar pages
179
- - Cross-comic visual style matching: find pages with similar composition across comics
180
-
181
- **Blog post insight:** Voyage multimodal embeddings capture visual style and composition that text descriptions miss. But they're less precise for abstract semantic concepts like "betrayal."
182
-
183
- ---
184
-
185
- ## Notebook 04 — Approach 3: Full Hybrid (Dense + Sparse + FTS)
186
-
187
- **Pipeline:**
188
- 1. Dense vector: Same as Approach 1 (LLM caption → `llama-text-embed-v2`)
189
- 2. Sparse vector: Embed the dialogue text using `pinecone-sparse-english-v0`
190
- 3. FTS field: Dialogue text with BM25 (via `SchemaBuilder`)
191
-
192
- **Index type:** Single hybrid index via `SchemaBuilder` (dense + sparse + FTS fields in one index). All in `ns-hybrid` namespace.
193
-
194
- **Query types to demonstrate (one ranking signal per query, per Pinecone constraint):**
195
-
196
- | Query mode | Ranking signal | Notes |
197
- |---|---|---|
198
- | Dense semantic | `dense_vector` | "find scenes about justice" |
199
- | Sparse keyword | `sparse_vector` | "KAPOW detective badge" |
200
- | BM25 / FTS | `text` on `dialogue` field | exact phrase in speech bubbles |
201
- | Filter + rank | Text `$match_phrase` filter → dense ranking | narrow by keyword, then rerank by semantics |
202
-
203
- ```python
204
- # Filter by keyword, rank by vector (the "hybrid" pattern in Pinecone)
205
- index.documents.search(
206
- namespace="ns-hybrid",
207
- top_k=5,
208
- score_by=[{"type": "dense_vector", "field": "embedding", "query": query_vector}],
209
- filter={"dialogue": {"$match_phrase": "look out"}},
210
- )
211
- ```
212
-
213
- **Blog post insight:** A single index holds all three signal types. The key lesson: Pinecone picks one ranker per query, but you can chain: FTS/sparse filters narrow the candidate set, then dense vector re-ranks for semantic relevance. Dense retrieves conceptually similar scenes; sparse/FTS catches exact names, sound effects (KAPOW!), specific dialogue.
214
-
215
- ---
216
-
217
- ## Notebook 05 — Story Builder Demo
218
-
219
- **Core demo:** Two sections in one notebook.
220
-
221
- **Section 1 — Cross-namespace comparison view:**
222
- - User types one query: e.g. `"detective discovers a clue"`
223
- - Same query sent to all 3 namespaces simultaneously (parallel calls)
224
- - Results displayed side-by-side: 3 columns (ns-text-caption | ns-image-clip | ns-hybrid), top-5 each
225
- - Each result shows: thumbnail, comic title, page num, match score
226
- - Purpose: show readers concretely how the same query returns different results depending on embedding strategy
227
-
228
- **Section 2 — Narrative assembler:**
229
- ```
230
- Query 1 (setup): "detective arrives at crime scene"
231
- Query 2 (conflict): "villain threatens the hero"
232
- Query 3 (climax): "dramatic fight or confrontation"
233
- Query 4 (resolution): "hero wins, villain defeated"
234
- ```
235
- - Each query fetches top-1 page from `ns-hybrid` (best all-round namespace)
236
- - Deduplicate: if two queries return the same page, fetch next best
237
- - Display the 4 pages in sequence as a 2×2 image grid with caption: comic title + page num
238
- - Output: a brand new "story" assembled from panels across multiple comics
239
-
240
- **Output format:** Inline IPython `display(Image(...))` grid — no external UI needed, renders directly in the notebook.
241
-
242
- ---
243
-
244
- ## Notebook 06 — Panel-Level Exploration (Bonus)
245
-
246
- **Goal:** Explore whether panel-level granularity produces meaningfully better results.
247
-
248
- **Panel detection approach:**
249
- - Option A (simple): Use `comic-panels` Python library or OpenCV edge detection + contour finding
250
- - Option B (ML): Use a pre-trained comic panel segmentation model (e.g. from HuggingFace)
251
-
252
- **Pipeline:**
253
- 1. Detect panel bounding boxes per page
254
- 2. Crop individual panels (100–400 panels per comic)
255
- 3. Embed each panel (both image CLIP + LLM caption for the panel's transcribed text)
256
- 4. Upsert to a `ns-panels` namespace
257
-
258
- **Demo:** "Find all panels of a character running" — much more precise than page-level.
259
-
260
- **Blog post insight:** Panel-level is more powerful for the story-builder, but requires significant additional pipeline complexity. Page-level is a good starting point for most use cases.
261
-
262
- ---
263
-
264
- ## Key Technical Decisions
265
-
266
- 1. **Pinecone index type for dense + FTS:** ✅ Confirmed — a single hybrid index via `SchemaBuilder` supports dense + sparse + FTS fields. One ranking signal per query; use text-match filters + vector ranking as the "hybrid" pattern.
267
-
268
- 2. **Image model:** ✅ Confirmed — Voyage AI `voyage-multimodal-3`. Requires `VOYAGE_API_KEY`. Include Jina CLIP v2 as a free open-source footnote for readers who want no extra API key.
269
-
270
- 3. **LLM for captions:** Use `claude-haiku-4-5-20251001` (fast + cheap). Prompt should be consistent and reproducible — include it verbatim in the notebook for transparency.
271
-
272
- 4. **Rate limiting / cost controls:** Comic Transcriber + Claude captions + Voyage AI embeddings will have associated costs. Add a `MAX_PAGES` constant to each notebook so readers can test with fewer pages.
273
-
274
- ---
275
-
276
- ## Environment Variables
277
-
278
- ```
279
- PINECONE_API_KEY=
280
- ANTHROPIC_API_KEY= # for LLM captions
281
- VOYAGE_API_KEY= # for image embeddings
282
- ```
283
-
284
- ---
285
-
286
- ## Verification Plan
287
-
288
- 1. **Notebook 00:** Verify `data/pages/` populated with images; manifest CSV has correct row count
289
- 2. **Notebook 01:** Spot-check 3 transcripts; confirm dialogue and description fields are non-empty
290
- 3. **Notebook 02:** Run 5 test queries; verify semantic matches are qualitatively relevant
291
- 4. **Notebook 03:** Run text-to-image query; verify returned images visually match query intent
292
- 5. **Notebook 04:** Compare hybrid vs. dense-only recall on 3 test queries
293
- 6. **Notebook 05:** Build one complete story arc end-to-end; display assembled panel grid
294
- 7. **Notebook 06 (bonus):** Verify panel crops look correct; compare panel-level vs. page-level for a test query
295
-
296
- ---
297
-
298
- ## Critical Files to Create
299
-
300
- - `00_data_acquisition.ipynb`
301
- - `01_transcription.ipynb`
302
- - `02_approach1_text_dense_fts.ipynb`
303
- - `03_approach2_image_embeddings.ipynb`
304
- - `04_approach3_hybrid.ipynb`
305
- - `05_story_builder.ipynb`
306
- - `06_panel_level_exploration.ipynb`
307
- - `utils/downloader.py` — comic download helpers
308
- - `utils/extractor.py` — CBZ extraction + page listing
309
- - `utils/embedders.py` — shared embedding functions (Voyage, llama-text-embed-v2, sparse)
310
- - `.env.example`
311
- - `requirements.txt`
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
README.md ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # COMICS Panel Search
2
+
3
+ Panel-level vector search over the [COMICS dataset](https://github.com/miyyer/comics) using Pinecone. Supports dense image search (OpenCLIP), sparse keyword search, full-text search over OCR text, and combined retrieval with Reciprocal Rank Fusion.
4
+
5
+ ## Setup
6
+
7
+ ```bash
8
+ python -m venv .venv
9
+ source .venv/bin/activate
10
+ pip install -r requirements.txt
11
+ ```
12
+
13
+ Copy `.env` and fill in your keys:
14
+
15
+ ```bash
16
+ PINECONE_API_KEY="..."
17
+ PINECONE_INDEX_NAME="comic-panels"
18
+ PINECONE_NAMESPACE="comics-v1"
19
+ COMICS_DATA_DIR="data/comics"
20
+ ```
21
+
22
+ ## Step 1: Download COMICS data
23
+
24
+ ```bash
25
+ mkdir -p data/comics/raw
26
+ cd data/comics/raw
27
+
28
+ wget -c https://obj.umiacs.umd.edu/comics/raw_panel_images.tar.gz
29
+ wget -c https://obj.umiacs.umd.edu/comics/COMICS_ocr_file.csv
30
+ wget -c https://obj.umiacs.umd.edu/comics/predadpages.txt
31
+
32
+ tar -xzf raw_panel_images.tar.gz
33
+ cd ../../..
34
+ ```
35
+
36
+ > Optional — only needed if you want page-level reconstruction later:
37
+ > ```bash
38
+ > wget -c https://obj.umiacs.umd.edu/comics/raw_page_images.tar.gz
39
+ > ```
40
+
41
+ ## Step 2: Run the ingestion pipeline
42
+
43
+ ```bash
44
+ python -m src.ingest.build_manifest
45
+ python -m src.embeddings.embed_images
46
+ python -m src.embeddings.embed_sparse_text
47
+ python -m src.pinecone.create_index
48
+ python -m src.pinecone.build_documents
49
+ python -m src.pinecone.upsert_panels
50
+ ```
51
+
52
+ Each script is idempotent and saves intermediate results to `data/comics/processed/` before the next step depends on them.
53
+
54
+ > **First run note:** `build_manifest` will print the OCR CSV column names. If the auto-detected column mapping looks wrong, update `build_ocr_lookup()` in `src/ingest/build_manifest.py` with the correct column names and re-run.
55
+
56
+ ## Step 3: Query the index
57
+
58
+ ```bash
59
+ jupyter lab notebooks/query_comics_index.ipynb
60
+ ```
61
+
62
+ Or open the notebook directly:
63
+
64
+ ```bash
65
+ jupyter notebook notebooks/query_comics_index.ipynb
66
+ ```
67
+
68
+ ## Search smoke tests
69
+
70
+ ```bash
71
+ python -m src.search.search_fts --query '"secret formula"'
72
+ python -m src.search.search_dense --query "a detective in a dark alley"
73
+ python -m src.search.search_sparse --query "masked villain laboratory"
74
+ python -m src.search.search_combined --query "a laboratory panel containing formula"
75
+ ```
76
+
77
+ ## Project structure
78
+
79
+ ```
80
+ src/
81
+ ingest/
82
+ clean_text.py # Conservative OCR cleaning
83
+ build_manifest.py # Panel manifest from images + OCR CSV
84
+ embeddings/
85
+ embed_images.py # OpenCLIP image → dense vectors
86
+ embed_sparse_text.py # pinecone-sparse-english-v0 → sparse vectors
87
+ pinecone/
88
+ create_index.py # Create document-schema Pinecone index
89
+ build_documents.py # Join manifest + vectors → JSONL
90
+ upsert_panels.py # Batch upsert to Pinecone with retry
91
+ search/
92
+ search_dense.py # Dense image vector search
93
+ search_sparse.py # Sparse text vector search
94
+ search_fts.py # Full-text search (BM25)
95
+ search_combined.py # All three signals + RRF merge
96
+ fusion.py # Reciprocal Rank Fusion
97
+ router.py # Route query to signal(s)
98
+ notebooks/
99
+ query_comics_index.ipynb # Interactive search playground
100
+ data/
101
+ comics/
102
+ raw/ # Downloaded COMICS files
103
+ processed/ # Manifest, vectors, and Pinecone documents
104
+ config.yaml # Model and field configuration
105
+ ```
106
+
107
+ ## Configuration
108
+
109
+ `config.yaml` controls the embedding model, index name, namespace, and field names. If you change `dense_embedding.dimension`, update it before running `create_index.py`.
110
+
111
+ Default dense model: `open_clip_ViT-B-32` (512-dim, cosine).
112
+
113
+ ## Data model
114
+
115
+ Each Pinecone document = one comic panel:
116
+
117
+ | Field | Type | Description |
118
+ |---|---|---|
119
+ | `_id` | string | `{comic_id}:{page_num}:{panel_num}` |
120
+ | `image_dense` | dense vector | 512-dim OpenCLIP embedding |
121
+ | `text_sparse` | sparse vector | pinecone-sparse-english-v0 on OCR text |
122
+ | `ocr_text` | string (FTS) | Raw cleaned OCR text |
123
+ | `search_text` | string (FTS) | Search-optimized text (= ocr_text for v1) |
124
+ | `comic_id` | metadata | Book identifier |
125
+ | `page_num` | metadata | Page number within book |
126
+ | `panel_num` | metadata | Panel number on the page |
127
+ | `is_ad_page` | metadata | True if page is an advertisement |
config.yaml ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ pinecone:
2
+ index_name: comic-panels
3
+ namespace: comics-v1
4
+
5
+ dense_embedding:
6
+ model_name: open_clip_ViT-B-32
7
+ field_name: image_dense
8
+ dimension: 512
9
+ metric: cosine
10
+
11
+ sparse_embedding:
12
+ model_name: pinecone-sparse-english-v0
13
+ field_name: text_sparse
14
+ source_text_field: search_text
15
+
16
+ fields:
17
+ dense_vector: image_dense
18
+ sparse_vector: text_sparse
19
+ fts_text:
20
+ - ocr_text
21
+ - search_text
22
+
23
+ data:
24
+ comics_data_dir: data/comics
25
+ manifest_path: data/comics/processed/panels_manifest.parquet
26
+ dense_vectors_path: data/comics/processed/image_dense_vectors.parquet
27
+ sparse_vectors_path: data/comics/processed/text_sparse_vectors.parquet
28
+ pinecone_documents_path: data/comics/processed/pinecone_documents.jsonl
notebooks/query_comics_index.ipynb ADDED
@@ -0,0 +1,394 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "id": "a1b2c3d4-0001",
6
+ "metadata": {},
7
+ "source": ["# COMICS Panel Search — Query Playground\n", "\n", "Developer-facing notebook for validating search quality and comparing dense, sparse, FTS, and combined retrieval over the Pinecone COMICS index."]
8
+ },
9
+ {
10
+ "cell_type": "code",
11
+ "execution_count": null,
12
+ "id": "a1b2c3d4-0002",
13
+ "metadata": {},
14
+ "outputs": [],
15
+ "source": [
16
+ "import os\n",
17
+ "import sys\n",
18
+ "from pathlib import Path\n",
19
+ "\n",
20
+ "from dotenv import load_dotenv\n",
21
+ "from pinecone import Pinecone\n",
22
+ "from PIL import Image\n",
23
+ "import pandas as pd\n",
24
+ "from IPython.display import display, Markdown\n",
25
+ "\n",
26
+ "load_dotenv()\n",
27
+ "\n",
28
+ "PROJECT_ROOT = Path.cwd().parent if Path.cwd().name == \"notebooks\" else Path.cwd()\n",
29
+ "sys.path.insert(0, str(PROJECT_ROOT))\n",
30
+ "\n",
31
+ "PINECONE_API_KEY = os.environ[\"PINECONE_API_KEY\"]\n",
32
+ "PINECONE_INDEX_NAME = os.environ.get(\"PINECONE_INDEX_NAME\", \"comic-panels\")\n",
33
+ "PINECONE_NAMESPACE = os.environ.get(\"PINECONE_NAMESPACE\", \"comics-v1\")\n",
34
+ "COMICS_DATA_DIR = Path(os.environ.get(\"COMICS_DATA_DIR\", \"data/comics\"))\n",
35
+ "\n",
36
+ "pc = Pinecone(api_key=PINECONE_API_KEY)\n",
37
+ "index = pc.preview.index(name=PINECONE_INDEX_NAME)\n",
38
+ "\n",
39
+ "print(\"Connected to:\", PINECONE_INDEX_NAME)\n",
40
+ "print(\"Namespace: \", PINECONE_NAMESPACE)"
41
+ ]
42
+ },
43
+ {
44
+ "cell_type": "code",
45
+ "execution_count": null,
46
+ "id": "a1b2c3d4-0003",
47
+ "metadata": {},
48
+ "outputs": [],
49
+ "source": [
50
+ "from src.search.search_dense import search_dense, search_dense_with_phrase_filter\n",
51
+ "from src.search.search_sparse import search_sparse\n",
52
+ "from src.search.search_fts import search_fts\n",
53
+ "from src.search.search_combined import search_combined\n",
54
+ "from src.search.router import route_query"
55
+ ]
56
+ },
57
+ {
58
+ "cell_type": "code",
59
+ "execution_count": null,
60
+ "id": "a1b2c3d4-0004",
61
+ "metadata": {},
62
+ "outputs": [],
63
+ "source": [
64
+ "from src.embeddings.embed_images import embed_text_query\n",
65
+ "from src.embeddings.embed_sparse_text import embed_sparse_query\n",
66
+ "\n",
67
+ "\n",
68
+ "def embed_query_dense(query: str) -> list[float]:\n",
69
+ " \"\"\"Embed a text query using the text side of the OpenCLIP model.\"\"\"\n",
70
+ " return embed_text_query(query)\n",
71
+ "\n",
72
+ "\n",
73
+ "def embed_query_sparse(query: str) -> dict | None:\n",
74
+ " \"\"\"Embed a text query using pinecone-sparse-english-v0 in query mode.\"\"\"\n",
75
+ " return embed_sparse_query(query)"
76
+ ]
77
+ },
78
+ {
79
+ "cell_type": "code",
80
+ "execution_count": null,
81
+ "id": "a1b2c3d4-0005",
82
+ "metadata": {},
83
+ "outputs": [],
84
+ "source": [
85
+ "def display_results(results, max_items=10, image_width=300):\n",
86
+ " for i, hit in enumerate(results[:max_items], start=1):\n",
87
+ " fields = hit.get(\"fields\", hit)\n",
88
+ "\n",
89
+ " panel_id = hit.get(\"_id\") or hit.get(\"panel_id\")\n",
90
+ " score = hit.get(\"_score\") or hit.get(\"score\") or hit.get(\"rrf_score\")\n",
91
+ " sources = hit.get(\"sources\", fields.get(\"sources\", []))\n",
92
+ "\n",
93
+ " image_path = fields.get(\"image_path\") or hit.get(\"image_path\")\n",
94
+ " ocr_text = fields.get(\"ocr_text\") or hit.get(\"ocr_text\", \"\")\n",
95
+ " comic_id = fields.get(\"comic_id\") or hit.get(\"comic_id\")\n",
96
+ " page_num = fields.get(\"page_num\") or hit.get(\"page_num\")\n",
97
+ " panel_num = fields.get(\"panel_num\") or hit.get(\"panel_num\")\n",
98
+ "\n",
99
+ " display(Markdown(f\"### {i}. `{panel_id}`\"))\n",
100
+ " display(Markdown(\n",
101
+ " f\"**Score:** `{score}` \\n\"\n",
102
+ " f\"**Sources:** `{sources}` \\n\"\n",
103
+ " f\"**Comic:** `{comic_id}` \\n\"\n",
104
+ " f\"**Page:** `{page_num}` \\n\"\n",
105
+ " f\"**Panel:** `{panel_num}`\"\n",
106
+ " ))\n",
107
+ "\n",
108
+ " if image_path and Path(image_path).exists():\n",
109
+ " img = Image.open(image_path)\n",
110
+ " display(img.resize((image_width, int(image_width * img.height / img.width))))\n",
111
+ " else:\n",
112
+ " display(Markdown(f\"`Image not found: {image_path}`\"))\n",
113
+ "\n",
114
+ " if ocr_text:\n",
115
+ " display(Markdown(f\"**OCR:** {ocr_text}\"))\n",
116
+ "\n",
117
+ " display(Markdown(\"---\"))"
118
+ ]
119
+ },
120
+ {
121
+ "cell_type": "markdown",
122
+ "id": "a1b2c3d4-0006",
123
+ "metadata": {},
124
+ "source": ["## Dense Search\n", "\n", "Semantic / visual search — best for descriptive scene queries."]
125
+ },
126
+ {
127
+ "cell_type": "code",
128
+ "execution_count": null,
129
+ "id": "a1b2c3d4-0007",
130
+ "metadata": {},
131
+ "outputs": [],
132
+ "source": [
133
+ "query = \"a detective in a dark alley\"\n",
134
+ "top_k = 10\n",
135
+ "\n",
136
+ "dense_vector = embed_query_dense(query)\n",
137
+ "\n",
138
+ "dense_response = search_dense(\n",
139
+ " index=index,\n",
140
+ " namespace=PINECONE_NAMESPACE,\n",
141
+ " query_vector=dense_vector,\n",
142
+ " top_k=top_k,\n",
143
+ " filters={\"is_ad_page\": False},\n",
144
+ ")\n",
145
+ "\n",
146
+ "dense_hits = dense_response[\"result\"][\"hits\"]\n",
147
+ "display_results(dense_hits, max_items=top_k)"
148
+ ]
149
+ },
150
+ {
151
+ "cell_type": "markdown",
152
+ "id": "a1b2c3d4-0008",
153
+ "metadata": {},
154
+ "source": ["## Sparse Search\n", "\n", "Keyword/lexical search — best for names, specific terms, and short phrases."]
155
+ },
156
+ {
157
+ "cell_type": "code",
158
+ "execution_count": null,
159
+ "id": "a1b2c3d4-0009",
160
+ "metadata": {},
161
+ "outputs": [],
162
+ "source": [
163
+ "query = \"masked villain laboratory\"\n",
164
+ "top_k = 10\n",
165
+ "\n",
166
+ "sparse_vector = embed_query_sparse(query)\n",
167
+ "\n",
168
+ "sparse_response = search_sparse(\n",
169
+ " index=index,\n",
170
+ " namespace=PINECONE_NAMESPACE,\n",
171
+ " query_sparse=sparse_vector,\n",
172
+ " top_k=top_k,\n",
173
+ " filters={\"is_ad_page\": False},\n",
174
+ ")\n",
175
+ "\n",
176
+ "sparse_hits = sparse_response[\"result\"][\"hits\"]\n",
177
+ "display_results(sparse_hits, max_items=top_k)"
178
+ ]
179
+ },
180
+ {
181
+ "cell_type": "markdown",
182
+ "id": "a1b2c3d4-0010",
183
+ "metadata": {},
184
+ "source": ["## Full-Text Search\n", "\n", "BM25 over OCR/dialogue — best for exact phrases, quoted dialogue, sound effects, Boolean operators."]
185
+ },
186
+ {
187
+ "cell_type": "code",
188
+ "execution_count": null,
189
+ "id": "a1b2c3d4-0011",
190
+ "metadata": {},
191
+ "outputs": [],
192
+ "source": [
193
+ "query = '\"secret formula\"'\n",
194
+ "top_k = 10\n",
195
+ "\n",
196
+ "fts_response = search_fts(\n",
197
+ " index=index,\n",
198
+ " namespace=PINECONE_NAMESPACE,\n",
199
+ " query=query,\n",
200
+ " top_k=top_k,\n",
201
+ " filters={\"is_ad_page\": False},\n",
202
+ ")\n",
203
+ "\n",
204
+ "fts_hits = fts_response[\"result\"][\"hits\"]\n",
205
+ "display_results(fts_hits, max_items=top_k)"
206
+ ]
207
+ },
208
+ {
209
+ "cell_type": "markdown",
210
+ "id": "a1b2c3d4-0012",
211
+ "metadata": {},
212
+ "source": ["## Combined Search (RRF)\n", "\n", "Runs dense + sparse + FTS (based on router) and merges results with Reciprocal Rank Fusion."]
213
+ },
214
+ {
215
+ "cell_type": "code",
216
+ "execution_count": null,
217
+ "id": "a1b2c3d4-0013",
218
+ "metadata": {},
219
+ "outputs": [],
220
+ "source": [
221
+ "query = \"a laboratory panel containing formula\"\n",
222
+ "top_k = 10\n",
223
+ "\n",
224
+ "route = route_query(query)\n",
225
+ "print(\"Route:\", route)\n",
226
+ "\n",
227
+ "dense_vector = embed_query_dense(query) if route[\"dense\"] else None\n",
228
+ "sparse_vector = embed_query_sparse(query) if route[\"sparse\"] else None\n",
229
+ "\n",
230
+ "combined_hits = search_combined(\n",
231
+ " index=index,\n",
232
+ " namespace=PINECONE_NAMESPACE,\n",
233
+ " query_text=query,\n",
234
+ " dense_query_vector=dense_vector,\n",
235
+ " sparse_query_vector=sparse_vector,\n",
236
+ " top_k=top_k,\n",
237
+ " filters={\"is_ad_page\": False},\n",
238
+ " run_fts=route[\"fts\"],\n",
239
+ ")\n",
240
+ "\n",
241
+ "display_results(combined_hits, max_items=top_k)"
242
+ ]
243
+ },
244
+ {
245
+ "cell_type": "markdown",
246
+ "id": "a1b2c3d4-0014",
247
+ "metadata": {},
248
+ "source": ["## Compare Modes Side-by-Side\n", "\n", "Run the same query across all three signals and compare results in a DataFrame."]
249
+ },
250
+ {
251
+ "cell_type": "code",
252
+ "execution_count": null,
253
+ "id": "a1b2c3d4-0015",
254
+ "metadata": {},
255
+ "outputs": [],
256
+ "source": [
257
+ "query = \"secret formula laboratory\"\n",
258
+ "top_k = 10\n",
259
+ "\n",
260
+ "dense_vector = embed_query_dense(query)\n",
261
+ "sparse_vector = embed_query_sparse(query)\n",
262
+ "\n",
263
+ "dense_hits = search_dense(\n",
264
+ " index=index, namespace=PINECONE_NAMESPACE,\n",
265
+ " query_vector=dense_vector, top_k=top_k,\n",
266
+ " filters={\"is_ad_page\": False},\n",
267
+ ")[\"result\"][\"hits\"]\n",
268
+ "\n",
269
+ "sparse_hits = search_sparse(\n",
270
+ " index=index, namespace=PINECONE_NAMESPACE,\n",
271
+ " query_sparse=sparse_vector, top_k=top_k,\n",
272
+ " filters={\"is_ad_page\": False},\n",
273
+ ")[\"result\"][\"hits\"]\n",
274
+ "\n",
275
+ "fts_hits = search_fts(\n",
276
+ " index=index, namespace=PINECONE_NAMESPACE,\n",
277
+ " query=query, top_k=top_k,\n",
278
+ " filters={\"is_ad_page\": False},\n",
279
+ ")[\"result\"][\"hits\"]\n",
280
+ "\n",
281
+ "\n",
282
+ "def hits_to_rows(mode, hits):\n",
283
+ " rows = []\n",
284
+ " for rank, hit in enumerate(hits, start=1):\n",
285
+ " fields = hit.get(\"fields\", {})\n",
286
+ " rows.append({\n",
287
+ " \"mode\": mode,\n",
288
+ " \"rank\": rank,\n",
289
+ " \"panel_id\": hit.get(\"_id\"),\n",
290
+ " \"score\": hit.get(\"_score\"),\n",
291
+ " \"ocr_text\": str(fields.get(\"ocr_text\", \"\"))[:60],\n",
292
+ " \"comic_id\": fields.get(\"comic_id\"),\n",
293
+ " \"page_num\": fields.get(\"page_num\"),\n",
294
+ " \"panel_num\": fields.get(\"panel_num\"),\n",
295
+ " })\n",
296
+ " return rows\n",
297
+ "\n",
298
+ "\n",
299
+ "df = pd.DataFrame(\n",
300
+ " hits_to_rows(\"dense\", dense_hits)\n",
301
+ " + hits_to_rows(\"sparse\", sparse_hits)\n",
302
+ " + hits_to_rows(\"fts\", fts_hits)\n",
303
+ ")\n",
304
+ "df"
305
+ ]
306
+ },
307
+ {
308
+ "cell_type": "markdown",
309
+ "id": "a1b2c3d4-0016",
310
+ "metadata": {},
311
+ "source": ["## Metadata Filters\n", "\n", "Examples of filtering by comic, page range, or ad-page exclusion."]
312
+ },
313
+ {
314
+ "cell_type": "code",
315
+ "execution_count": null,
316
+ "id": "a1b2c3d4-0017",
317
+ "metadata": {},
318
+ "outputs": [],
319
+ "source": [
320
+ "# Exclude ad pages (applied by default in all cells above)\n",
321
+ "filters = {\"is_ad_page\": False}\n",
322
+ "\n",
323
+ "# Filter to a specific comic\n",
324
+ "# filters = {\"comic_id\": \"some_comic_id\", \"is_ad_page\": False}\n",
325
+ "\n",
326
+ "# Filter to a page range\n",
327
+ "# filters = {\"page_num\": {\"$gte\": 5, \"$lte\": 20}, \"is_ad_page\": False}\n",
328
+ "\n",
329
+ "query = \"hero battles villain\"\n",
330
+ "dense_vector = embed_query_dense(query)\n",
331
+ "response = search_dense(index=index, namespace=PINECONE_NAMESPACE,\n",
332
+ " query_vector=dense_vector, top_k=5, filters=filters)\n",
333
+ "display_results(response[\"result\"][\"hits\"], max_items=5)"
334
+ ]
335
+ },
336
+ {
337
+ "cell_type": "markdown",
338
+ "id": "a1b2c3d4-0018",
339
+ "metadata": {},
340
+ "source": ["## Manual Evaluation\n", "\n", "Run a battery of test queries across all modes and inspect results."]
341
+ },
342
+ {
343
+ "cell_type": "code",
344
+ "execution_count": null,
345
+ "id": "a1b2c3d4-0019",
346
+ "metadata": {},
347
+ "outputs": [],
348
+ "source": [
349
+ "test_queries = [\n",
350
+ " '\"secret formula\"',\n",
351
+ " \"BANG\",\n",
352
+ " \"a detective in a dark alley\",\n",
353
+ " \"a superhero flying through the sky\",\n",
354
+ " \"masked villain laboratory\",\n",
355
+ " \"a laboratory panel containing formula\",\n",
356
+ "]\n",
357
+ "\n",
358
+ "for q in test_queries:\n",
359
+ " display(Markdown(f\"---\\n### Query: `{q}`\"))\n",
360
+ " route = route_query(q)\n",
361
+ " display(Markdown(f\"Route: `{route}`\"))\n",
362
+ "\n",
363
+ " dense_vector = embed_query_dense(q) if route[\"dense\"] else None\n",
364
+ " sparse_vector = embed_query_sparse(q) if route[\"sparse\"] else None\n",
365
+ "\n",
366
+ " hits = search_combined(\n",
367
+ " index=index,\n",
368
+ " namespace=PINECONE_NAMESPACE,\n",
369
+ " query_text=q,\n",
370
+ " dense_query_vector=dense_vector,\n",
371
+ " sparse_query_vector=sparse_vector,\n",
372
+ " top_k=5,\n",
373
+ " filters={\"is_ad_page\": False},\n",
374
+ " run_fts=route[\"fts\"],\n",
375
+ " )\n",
376
+ "\n",
377
+ " display_results(hits, max_items=5)"
378
+ ]
379
+ }
380
+ ],
381
+ "metadata": {
382
+ "kernelspec": {
383
+ "display_name": "multi-modal",
384
+ "language": "python",
385
+ "name": "multi-modal"
386
+ },
387
+ "language_info": {
388
+ "name": "python",
389
+ "version": "3.13.13"
390
+ }
391
+ },
392
+ "nbformat": 4,
393
+ "nbformat_minor": 5
394
+ }
requirements.txt CHANGED
@@ -1,11 +1,13 @@
1
  pinecone>=6.0.0
2
- anthropic>=0.40.0
3
- voyageai>=0.3.0
4
  pillow>=10.0.0
5
  requests>=2.31.0
6
  python-dotenv>=1.0.0
7
  pandas>=2.0.0
 
8
  tqdm>=4.66.0
9
  ipython>=8.0.0
10
  matplotlib>=3.7.0
11
  jupyter>=1.0.0
 
 
1
  pinecone>=6.0.0
2
+ open-clip-torch>=2.20.0
3
+ torch>=2.0.0
4
  pillow>=10.0.0
5
  requests>=2.31.0
6
  python-dotenv>=1.0.0
7
  pandas>=2.0.0
8
+ pyarrow>=14.0.0
9
  tqdm>=4.66.0
10
  ipython>=8.0.0
11
  matplotlib>=3.7.0
12
  jupyter>=1.0.0
13
+ pyyaml>=6.0
{utils → src}/__init__.py RENAMED
File without changes
comics-idx.ipynb → src/embeddings/__init__.py RENAMED
File without changes
src/embeddings/embed_images.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Generate dense image embeddings for each panel using OpenCLIP."""
2
+
3
+ from pathlib import Path
4
+ import os
5
+ import pandas as pd
6
+ from PIL import Image
7
+ from tqdm import tqdm
8
+ import torch
9
+ import open_clip
10
+
11
+
12
+ MANIFEST_PATH = Path("data/comics/processed/panels_manifest.parquet")
13
+ OUTPUT_PATH = Path("data/comics/processed/image_dense_vectors.parquet")
14
+ FAILURES_PATH = Path("data/comics/processed/image_embedding_failures.csv")
15
+
16
+ _MODEL_NAME = os.environ.get("IMAGE_EMBED_MODEL", "open_clip_ViT-B-32")
17
+ _ARCH = _MODEL_NAME.replace("open_clip_", "")
18
+ _DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
19
+
20
+ _model, _, _preprocess = open_clip.create_model_and_transforms(_ARCH, pretrained="openai")
21
+ _model.eval()
22
+ _model = _model.to(_DEVICE)
23
+ _tokenizer = open_clip.get_tokenizer(_ARCH)
24
+
25
+
26
+ def embed_image(image: Image.Image) -> list[float]:
27
+ tensor = _preprocess(image).unsqueeze(0).to(_DEVICE)
28
+ with torch.no_grad():
29
+ features = _model.encode_image(tensor)
30
+ features = features / features.norm(dim=-1, keepdim=True)
31
+ return features[0].cpu().tolist()
32
+
33
+
34
+ def embed_text_query(text: str) -> list[float]:
35
+ """Embed a text query into the same vector space as panel images."""
36
+ tokens = _tokenizer([text]).to(_DEVICE)
37
+ with torch.no_grad():
38
+ features = _model.encode_text(tokens)
39
+ features = features / features.norm(dim=-1, keepdim=True)
40
+ return features[0].cpu().tolist()
41
+
42
+
43
+ def main():
44
+ manifest = pd.read_parquet(MANIFEST_PATH)
45
+ print(f"Embedding {len(manifest)} panels with {_ARCH} on {_DEVICE}")
46
+
47
+ rows = []
48
+ failures = []
49
+
50
+ for row in tqdm(manifest.itertuples(index=False), total=len(manifest)):
51
+ try:
52
+ image = Image.open(row.image_path).convert("RGB")
53
+ vector = embed_image(image)
54
+ rows.append({"panel_id": row.panel_id, "image_dense": vector})
55
+ except Exception as exc:
56
+ failures.append({
57
+ "panel_id": row.panel_id,
58
+ "image_path": row.image_path,
59
+ "error": str(exc),
60
+ })
61
+
62
+ OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
63
+ pd.DataFrame(rows).to_parquet(OUTPUT_PATH, index=False)
64
+ print(f"Saved {len(rows)} vectors → {OUTPUT_PATH}")
65
+
66
+ if failures:
67
+ pd.DataFrame(failures).to_csv(FAILURES_PATH, index=False)
68
+ print(f"Failed: {len(failures)} panels → {FAILURES_PATH}")
69
+
70
+
71
+ if __name__ == "__main__":
72
+ main()
src/embeddings/embed_sparse_text.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Generate sparse text embeddings for each panel using Pinecone inference."""
2
+
3
+ from pathlib import Path
4
+ import os
5
+ import pandas as pd
6
+ from tqdm import tqdm
7
+ from pinecone import Pinecone
8
+
9
+
10
+ MANIFEST_PATH = Path("data/comics/processed/panels_manifest.parquet")
11
+ OUTPUT_PATH = Path("data/comics/processed/text_sparse_vectors.parquet")
12
+
13
+ SPARSE_MODEL = os.environ.get("SPARSE_EMBED_MODEL", "pinecone-sparse-english-v0")
14
+ BATCH_SIZE = 96 # Stay within Pinecone inference batch limits
15
+
16
+ _pc = Pinecone(api_key=os.environ["PINECONE_API_KEY"])
17
+
18
+
19
+ def embed_sparse_batch(texts: list[str], input_type: str = "passage") -> list[dict | None]:
20
+ """Embed a batch of texts using pinecone-sparse-english-v0."""
21
+ non_empty = [(i, t) for i, t in enumerate(texts) if t.strip()]
22
+ results: list[dict | None] = [None] * len(texts)
23
+
24
+ if not non_empty:
25
+ return results
26
+
27
+ indices, batch_texts = zip(*non_empty)
28
+ response = _pc.inference.embed(
29
+ model=SPARSE_MODEL,
30
+ inputs=list(batch_texts),
31
+ parameters={"input_type": input_type},
32
+ )
33
+
34
+ for idx, embedding in zip(indices, response):
35
+ if embedding.sparse_indices and embedding.sparse_values:
36
+ results[idx] = {
37
+ "indices": embedding.sparse_indices,
38
+ "values": embedding.sparse_values,
39
+ }
40
+
41
+ return results
42
+
43
+
44
+ def embed_sparse_query(text: str) -> dict | None:
45
+ """Embed a single query string for search time."""
46
+ if not text.strip():
47
+ return None
48
+ results = embed_sparse_batch([text], input_type="query")
49
+ return results[0]
50
+
51
+
52
+ def main():
53
+ manifest = pd.read_parquet(MANIFEST_PATH)
54
+ print(f"Sparse-embedding {len(manifest)} panels with {SPARSE_MODEL}")
55
+
56
+ texts = manifest["search_text"].fillna("").tolist()
57
+ panel_ids = manifest["panel_id"].tolist()
58
+
59
+ all_sparse: list[dict | None] = []
60
+ for start in tqdm(range(0, len(texts), BATCH_SIZE)):
61
+ batch = texts[start : start + BATCH_SIZE]
62
+ all_sparse.extend(embed_sparse_batch(batch))
63
+
64
+ rows = [{"panel_id": pid, "text_sparse": sparse} for pid, sparse in zip(panel_ids, all_sparse)]
65
+
66
+ OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
67
+ pd.DataFrame(rows).to_parquet(OUTPUT_PATH, index=False)
68
+
69
+ filled = sum(1 for r in rows if r["text_sparse"] is not None)
70
+ print(f"Saved {len(rows)} rows ({filled} with sparse vectors) → {OUTPUT_PATH}")
71
+
72
+
73
+ if __name__ == "__main__":
74
+ main()
src/ingest/__init__.py ADDED
File without changes
src/ingest/build_manifest.py ADDED
@@ -0,0 +1,223 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Build a panel manifest from COMICS raw panel images and OCR CSV."""
2
+
3
+ from pathlib import Path
4
+ import hashlib
5
+ import re
6
+ import pandas as pd
7
+
8
+ from src.ingest.clean_text import clean_ocr, build_search_text
9
+
10
+
11
+ COMICS_DATA_DIR = Path("data/comics")
12
+ RAW_DIR = COMICS_DATA_DIR / "raw"
13
+ PROCESSED_DIR = COMICS_DATA_DIR / "processed"
14
+ PROCESSED_DIR.mkdir(parents=True, exist_ok=True)
15
+
16
+
17
+ def stable_hash(value: str) -> str:
18
+ return hashlib.sha1(value.encode("utf-8")).hexdigest()[:16]
19
+
20
+
21
+ def parse_panel_path(image_path: Path) -> dict:
22
+ """
23
+ Infer comic_id, page_num, panel_num from an image path.
24
+
25
+ COMICS raw_panel_images layout (from dataset inspection):
26
+ raw_panel_images/{book_id}/{page_num:04d}_{panel_num:04d}.jpg
27
+
28
+ Returns partial metadata; fields are None if pattern doesn't match.
29
+ """
30
+ parts = image_path.parts
31
+ # Expect: ...raw_panel_images/<book_id>/<filename>
32
+ try:
33
+ panel_dir_idx = next(i for i, p in enumerate(parts) if p == "raw_panel_images")
34
+ book_id = parts[panel_dir_idx + 1] if panel_dir_idx + 1 < len(parts) else None
35
+ except StopIteration:
36
+ book_id = None
37
+
38
+ page_num, panel_num = None, None
39
+ m = re.match(r"^(\d+)_(\d+)\.(jpg|jpeg|png)$", image_path.name, re.IGNORECASE)
40
+ if m:
41
+ page_num = int(m.group(1))
42
+ panel_num = int(m.group(2))
43
+
44
+ comic_id = book_id
45
+ page_id = f"{book_id}_p{page_num:04d}" if book_id and page_num is not None else None
46
+ panel_id = (
47
+ f"{comic_id}:{page_num}:{panel_num}"
48
+ if comic_id and page_num is not None and panel_num is not None
49
+ else f"comics:{stable_hash(str(image_path))}"
50
+ )
51
+
52
+ return {
53
+ "panel_id": panel_id,
54
+ "comic_id": comic_id,
55
+ "book_id": book_id,
56
+ "page_id": page_id,
57
+ "page_num": page_num,
58
+ "panel_num": panel_num,
59
+ }
60
+
61
+
62
+ def load_ad_pages(ad_pages_path: Path) -> set[str]:
63
+ """Load advertisement page identifiers from predadpages.txt."""
64
+ if not ad_pages_path.exists():
65
+ print(f" Warning: {ad_pages_path} not found; no ad-page flags will be set")
66
+ return set()
67
+ lines = ad_pages_path.read_text().splitlines()
68
+ return {line.strip() for line in lines if line.strip()}
69
+
70
+
71
+ def build_ocr_lookup(ocr_df: pd.DataFrame) -> dict[str, str]:
72
+ """
73
+ Build a mapping from panel_id keys to OCR text.
74
+
75
+ After inspecting the OCR CSV columns, this function maps panel image
76
+ paths to their OCR text. The COMICS OCR CSV format uses columns:
77
+ comic_no, page_no, panel_no, ocr (or similar)
78
+
79
+ Prints column info on first run so you can verify the mapping is correct.
80
+ """
81
+ print("\nOCR CSV columns:", list(ocr_df.columns))
82
+ print("OCR CSV shape:", ocr_df.shape)
83
+ print("OCR CSV sample rows:")
84
+ print(ocr_df.head(3).to_string())
85
+ print()
86
+
87
+ # Best-effort mapping based on common COMICS dataset column names.
88
+ # Adjust the column names below after inspecting the output above.
89
+ lookup: dict[str, str] = {}
90
+
91
+ # Try to find comic/book, page, panel, and text columns
92
+ col_map = {c.lower(): c for c in ocr_df.columns}
93
+
94
+ book_col = next((col_map[k] for k in ["comic_no", "book_id", "comic_id", "book_no"] if k in col_map), None)
95
+ page_col = next((col_map[k] for k in ["page_no", "page_num", "page_id", "page"] if k in col_map), None)
96
+ panel_col = next((col_map[k] for k in ["panel_no", "panel_num", "panel_id", "panel"] if k in col_map), None)
97
+ text_col = next((col_map[k] for k in ["ocr", "text", "ocr_text", "dialogue"] if k in col_map), None)
98
+
99
+ if not all([book_col, page_col, panel_col, text_col]):
100
+ print(f" Warning: Could not auto-map all OCR columns.")
101
+ print(f" book_col={book_col}, page_col={page_col}, panel_col={panel_col}, text_col={text_col}")
102
+ print(" Update build_ocr_lookup() with the correct column names after inspection.")
103
+ return lookup
104
+
105
+ print(f" Using columns: book={book_col}, page={page_col}, panel={panel_col}, text={text_col}")
106
+
107
+ for row in ocr_df.itertuples(index=False):
108
+ book = str(getattr(row, book_col)).strip()
109
+ try:
110
+ page = int(getattr(row, page_col))
111
+ panel = int(getattr(row, panel_col))
112
+ except (ValueError, TypeError):
113
+ continue
114
+ text = str(getattr(row, text_col)) if getattr(row, text_col, None) is not None else ""
115
+ key = f"{book}:{page}:{panel}"
116
+ lookup[key] = text
117
+
118
+ print(f" OCR lookup entries: {len(lookup)}")
119
+ return lookup
120
+
121
+
122
+ def main():
123
+ panel_dir = RAW_DIR / "raw_panel_images"
124
+ ocr_path = RAW_DIR / "COMICS_ocr_file.csv"
125
+ ad_pages_path = RAW_DIR / "predadpages.txt"
126
+
127
+ if not panel_dir.exists():
128
+ raise FileNotFoundError(
129
+ f"Panel image directory not found: {panel_dir}\n"
130
+ "Run the download step first:\n"
131
+ " mkdir -p data/comics/raw && cd data/comics/raw\n"
132
+ " wget -c https://obj.umiacs.umd.edu/comics/raw_panel_images.tar.gz\n"
133
+ " tar -xzf raw_panel_images.tar.gz"
134
+ )
135
+
136
+ panel_paths = sorted(
137
+ p for p in panel_dir.rglob("*")
138
+ if p.is_file() and p.suffix.lower() in {".jpg", ".jpeg", ".png"}
139
+ )
140
+ print(f"Found {len(panel_paths)} panel images")
141
+
142
+ ocr_lookup: dict[str, str] = {}
143
+ if ocr_path.exists():
144
+ ocr_df = pd.read_csv(ocr_path)
145
+ ocr_lookup = build_ocr_lookup(ocr_df)
146
+ else:
147
+ print(f"Warning: OCR file not found at {ocr_path}; all panels will have empty OCR")
148
+
149
+ ad_pages = load_ad_pages(ad_pages_path)
150
+
151
+ rows = []
152
+ unmatched_images = []
153
+
154
+ for image_path in panel_paths:
155
+ meta = parse_panel_path(image_path)
156
+ panel_id = meta["panel_id"]
157
+
158
+ ocr_text_raw = ocr_lookup.get(panel_id, "")
159
+ if not ocr_text_raw and meta["comic_id"] and meta["page_num"] is not None:
160
+ # Try zero-padded fallback keys
161
+ for fmt in [f"{meta['comic_id']}:{meta['page_num']:04d}:{meta['panel_num']:04d}"]:
162
+ if fmt in ocr_lookup:
163
+ ocr_text_raw = ocr_lookup[fmt]
164
+ break
165
+
166
+ if not ocr_text_raw:
167
+ unmatched_images.append(str(image_path))
168
+
169
+ ocr_text_clean = clean_ocr(ocr_text_raw)
170
+ search_text = build_search_text(ocr_text_clean)
171
+
172
+ # Check if this is an ad page
173
+ is_ad = (
174
+ meta["page_id"] in ad_pages
175
+ or str(image_path) in ad_pages
176
+ or image_path.name in ad_pages
177
+ )
178
+
179
+ rows.append({
180
+ "panel_id": panel_id,
181
+ "image_path": str(image_path),
182
+ "ocr_text_raw": ocr_text_raw,
183
+ "ocr_text_clean": ocr_text_clean,
184
+ "search_text": search_text,
185
+ "comic_id": meta["comic_id"],
186
+ "book_id": meta["book_id"],
187
+ "page_id": meta["page_id"],
188
+ "page_num": meta["page_num"],
189
+ "panel_num": meta["panel_num"],
190
+ "is_ad_page": is_ad,
191
+ "source": "COMICS",
192
+ })
193
+
194
+ manifest = pd.DataFrame(rows)
195
+
196
+ # Validate: all panel_ids must be unique
197
+ dupes = manifest["panel_id"].duplicated().sum()
198
+ if dupes:
199
+ print(f"Warning: {dupes} duplicate panel_ids found; using path-hash fallback for those rows")
200
+ dup_mask = manifest["panel_id"].duplicated(keep=False)
201
+ manifest.loc[dup_mask, "panel_id"] = manifest.loc[dup_mask, "image_path"].apply(
202
+ lambda p: f"comics:{stable_hash(p)}"
203
+ )
204
+
205
+ manifest.to_parquet(PROCESSED_DIR / "panels_manifest.parquet", index=False)
206
+ manifest.to_json(PROCESSED_DIR / "panels_manifest.jsonl", orient="records", lines=True)
207
+
208
+ print(f"\nManifest written: {len(manifest)} panels")
209
+ print(f" With OCR text: {(manifest['ocr_text_clean'] != '').sum()}")
210
+ print(f" Empty OCR: {(manifest['ocr_text_clean'] == '').sum()}")
211
+ print(f" Ad pages: {manifest['is_ad_page'].sum()}")
212
+ print(f" Unmatched OCR: {len(unmatched_images)}")
213
+
214
+ if unmatched_images[:5]:
215
+ print(f" Sample unmatched: {unmatched_images[:5]}")
216
+
217
+ unmatched_ocr_keys = set(ocr_lookup) - {r["panel_id"] for r in rows}
218
+ if unmatched_ocr_keys:
219
+ print(f" OCR rows with no matching image: {len(unmatched_ocr_keys)}")
220
+
221
+
222
+ if __name__ == "__main__":
223
+ main()
src/ingest/clean_text.py ADDED
Binary file (588 Bytes). View file
 
src/pinecone/__init__.py ADDED
File without changes
src/pinecone/build_documents.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Join manifest, dense vectors, and sparse vectors into Pinecone documents."""
2
+
3
+ from pathlib import Path
4
+ import json
5
+ import pandas as pd
6
+
7
+
8
+ PROCESSED_DIR = Path("data/comics/processed")
9
+ MANIFEST_PATH = PROCESSED_DIR / "panels_manifest.parquet"
10
+ DENSE_PATH = PROCESSED_DIR / "image_dense_vectors.parquet"
11
+ SPARSE_PATH = PROCESSED_DIR / "text_sparse_vectors.parquet"
12
+ OUTPUT_PATH = PROCESSED_DIR / "pinecone_documents.jsonl"
13
+
14
+
15
+ def valid_sparse(sparse) -> bool:
16
+ if not isinstance(sparse, dict):
17
+ return False
18
+ return bool(sparse.get("indices")) and bool(sparse.get("values"))
19
+
20
+
21
+ def main():
22
+ manifest = pd.read_parquet(MANIFEST_PATH)
23
+ dense = pd.read_parquet(DENSE_PATH)
24
+ sparse = pd.read_parquet(SPARSE_PATH)
25
+
26
+ # Inner join on dense — only panels that were successfully embedded
27
+ df = manifest.merge(dense, on="panel_id", how="inner")
28
+ # Left join on sparse — panels without text keep their document slot
29
+ df = df.merge(sparse, on="panel_id", how="left")
30
+
31
+ dropped = len(manifest) - len(df)
32
+ if dropped:
33
+ print(f"Warning: {dropped} panels dropped (missing dense vector)")
34
+
35
+ count = 0
36
+ with open(OUTPUT_PATH, "w") as f:
37
+ for row in df.itertuples(index=False):
38
+ doc: dict = {
39
+ "_id": row.panel_id,
40
+ "image_dense": row.image_dense,
41
+ "ocr_text": row.ocr_text_clean or "",
42
+ "search_text": row.search_text or "",
43
+ "comic_id": row.comic_id,
44
+ "book_id": row.book_id,
45
+ "page_id": row.page_id,
46
+ "page_num": int(row.page_num) if row.page_num is not None and not pd.isna(row.page_num) else None,
47
+ "panel_num": int(row.panel_num) if row.panel_num is not None and not pd.isna(row.panel_num) else None,
48
+ "image_path": row.image_path,
49
+ "source": "COMICS",
50
+ "is_ad_page": bool(row.is_ad_page),
51
+ }
52
+
53
+ sparse_vec = getattr(row, "text_sparse", None)
54
+ if valid_sparse(sparse_vec):
55
+ doc["text_sparse"] = sparse_vec
56
+
57
+ f.write(json.dumps(doc) + "\n")
58
+ count += 1
59
+
60
+ print(f"Wrote {count} documents → {OUTPUT_PATH}")
61
+ with_sparse = sum(1 for row in df.itertuples(index=False) if valid_sparse(getattr(row, "text_sparse", None)))
62
+ print(f" With sparse vector: {with_sparse}")
63
+ print(f" Dense only: {count - with_sparse}")
64
+
65
+
66
+ if __name__ == "__main__":
67
+ main()
src/pinecone/create_index.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Create the Pinecone document-schema index for comic panel search."""
2
+
3
+ import os
4
+ import time
5
+ from pinecone import Pinecone
6
+ from pinecone.preview import SchemaBuilder
7
+
8
+
9
+ INDEX_NAME = os.environ.get("PINECONE_INDEX_NAME", "comic-panels")
10
+ IMAGE_EMBED_DIM = int(os.environ.get("IMAGE_EMBED_DIM", "512"))
11
+ IMAGE_EMBED_METRIC = os.environ.get("IMAGE_EMBED_METRIC", "cosine")
12
+
13
+
14
+ def create_index(pc: Pinecone) -> None:
15
+ existing = [idx.name for idx in pc.list_indexes()]
16
+ if INDEX_NAME in existing:
17
+ print(f"Index '{INDEX_NAME}' already exists — skipping creation")
18
+ return
19
+
20
+ print(f"Creating index '{INDEX_NAME}' (dim={IMAGE_EMBED_DIM}, metric={IMAGE_EMBED_METRIC})")
21
+
22
+ schema = (
23
+ SchemaBuilder()
24
+ .add_dense_vector_field(
25
+ "image_dense",
26
+ dimension=IMAGE_EMBED_DIM,
27
+ metric=IMAGE_EMBED_METRIC,
28
+ )
29
+ .add_sparse_vector_field("text_sparse")
30
+ .add_string_field("ocr_text", full_text_search={"language": "en"})
31
+ .add_string_field("search_text", full_text_search={"language": "en"})
32
+ .build()
33
+ )
34
+
35
+ pc.preview.indexes.create(name=INDEX_NAME, schema=schema)
36
+
37
+ # Wait for the index to be ready
38
+ for _ in range(60):
39
+ status = pc.describe_index(INDEX_NAME).status
40
+ if status.get("ready"):
41
+ print(f"Index '{INDEX_NAME}' is ready")
42
+ return
43
+ print(" waiting for index to be ready...")
44
+ time.sleep(5)
45
+
46
+ raise TimeoutError(f"Index '{INDEX_NAME}' did not become ready within 5 minutes")
47
+
48
+
49
+ def main():
50
+ pc = Pinecone(api_key=os.environ["PINECONE_API_KEY"])
51
+ create_index(pc)
52
+
53
+
54
+ if __name__ == "__main__":
55
+ main()
src/pinecone/upsert_panels.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Upsert comic panel documents into Pinecone."""
2
+
3
+ import json
4
+ import os
5
+ import time
6
+ from pathlib import Path
7
+
8
+ from pinecone import Pinecone
9
+
10
+
11
+ INDEX_NAME = os.environ.get("PINECONE_INDEX_NAME", "comic-panels")
12
+ NAMESPACE = os.environ.get("PINECONE_NAMESPACE", "comics-v1")
13
+ DOCS_PATH = Path("data/comics/processed/pinecone_documents.jsonl")
14
+ BATCH_SIZE = 100
15
+
16
+
17
+ def batched_jsonl(path: Path, batch_size: int = BATCH_SIZE):
18
+ batch = []
19
+ with open(path) as f:
20
+ for line in f:
21
+ batch.append(json.loads(line))
22
+ if len(batch) >= batch_size:
23
+ yield batch
24
+ batch = []
25
+ if batch:
26
+ yield batch
27
+
28
+
29
+ def upsert_with_retry(index, batch: list, retries: int = 5):
30
+ for attempt in range(retries):
31
+ try:
32
+ return index.documents.upsert(namespace=NAMESPACE, documents=batch)
33
+ except Exception as exc:
34
+ if attempt == retries - 1:
35
+ raise
36
+ wait = 2 ** attempt
37
+ print(f" Upsert attempt {attempt + 1} failed: {exc}; retrying in {wait}s")
38
+ time.sleep(wait)
39
+
40
+
41
+ def validate_search(index) -> None:
42
+ """Quick sanity check: run a dense search after upsert."""
43
+ import random
44
+ dummy_vector = [random.gauss(0, 1) for _ in range(int(os.environ.get("IMAGE_EMBED_DIM", "512")))]
45
+ norm = sum(x ** 2 for x in dummy_vector) ** 0.5
46
+ dummy_vector = [x / norm for x in dummy_vector]
47
+
48
+ results = index.documents.search(
49
+ namespace=NAMESPACE,
50
+ top_k=3,
51
+ score_by=[{"type": "dense_vector", "field": "image_dense", "values": dummy_vector}],
52
+ include_fields=["panel_id", "comic_id", "page_num"],
53
+ )
54
+ hits = results.get("result", {}).get("hits", [])
55
+ print(f"Validation search returned {len(hits)} hits")
56
+ for h in hits:
57
+ print(f" {h.get('_id')} score={h.get('_score'):.4f}")
58
+
59
+
60
+ def main():
61
+ pc = Pinecone(api_key=os.environ["PINECONE_API_KEY"])
62
+ index = pc.preview.index(name=INDEX_NAME)
63
+
64
+ total = 0
65
+ failed_batches = 0
66
+
67
+ for batch_num, batch in enumerate(batched_jsonl(DOCS_PATH), start=1):
68
+ try:
69
+ upsert_with_retry(index, batch)
70
+ total += len(batch)
71
+ if batch_num % 10 == 0:
72
+ print(f" Upserted {total} documents...")
73
+ except Exception as exc:
74
+ failed_batches += 1
75
+ print(f" Batch {batch_num} failed permanently: {exc}")
76
+
77
+ print(f"\nUpsert complete: {total} documents, {failed_batches} failed batches")
78
+
79
+ if total > 0:
80
+ validate_search(index)
81
+
82
+
83
+ if __name__ == "__main__":
84
+ main()
src/search/__init__.py ADDED
File without changes
src/search/fusion.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Reciprocal Rank Fusion for merging results from multiple search signals."""
2
+
3
+
4
+ def rrf_merge(result_groups: list[tuple[str, list]], k: int = 60) -> list[dict]:
5
+ """
6
+ Merge ranked result lists using Reciprocal Rank Fusion.
7
+
8
+ Args:
9
+ result_groups: list of (source_name, hits) tuples where each hit is a
10
+ dict with at least '_id'.
11
+ k: RRF smoothing constant (default 60 per original paper).
12
+
13
+ Returns:
14
+ List of hit dicts sorted by descending rrf_score, with a 'sources' key
15
+ added to each hit indicating which signals returned it.
16
+ """
17
+ scores: dict[str, float] = {}
18
+ payloads: dict[str, dict] = {}
19
+
20
+ for source_name, hits in result_groups:
21
+ for rank, hit in enumerate(hits, start=1):
22
+ panel_id = hit.get("_id") or hit.get("panel_id")
23
+ if panel_id is None:
24
+ continue
25
+
26
+ scores[panel_id] = scores.get(panel_id, 0.0) + 1.0 / (k + rank)
27
+
28
+ if panel_id not in payloads:
29
+ payloads[panel_id] = dict(hit)
30
+ payloads[panel_id]["sources"] = []
31
+
32
+ payloads[panel_id]["sources"].append(source_name)
33
+
34
+ ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True)
35
+
36
+ output = []
37
+ for panel_id, score in ranked:
38
+ item = payloads[panel_id]
39
+ item["rrf_score"] = score
40
+ output.append(item)
41
+
42
+ return output
src/search/router.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Route a query to the appropriate search signals."""
2
+
3
+
4
+ _EXACT_INTENT_PHRASES = {
5
+ "says", "where they say", "exact", "phrase", "quote",
6
+ "dialogue", "sound effect", "contains",
7
+ }
8
+
9
+
10
+ def route_query(query: str) -> dict[str, bool]:
11
+ """
12
+ Decide which search signals to run for a given query string.
13
+
14
+ Returns a dict with keys: dense, sparse, fts — each True or False.
15
+
16
+ Routing logic:
17
+ - dense is always True (visual/semantic search runs for every query)
18
+ - sparse is True for any non-empty query (keyword overlap)
19
+ - fts is True when the query has quoted phrases, Boolean operators,
20
+ all-caps sound effects, or explicit exact-match intent language
21
+ """
22
+ q = query.strip()
23
+ if not q:
24
+ return {"dense": False, "sparse": False, "fts": False}
25
+
26
+ tokens = q.split()
27
+ has_quotes = '"' in q or "'" in q
28
+ has_boolean = any(t.upper() in {"AND", "OR", "NOT"} for t in tokens)
29
+ has_all_caps = any(t.isupper() and len(t) >= 3 for t in tokens)
30
+ has_exact_intent = any(phrase in q.lower() for phrase in _EXACT_INTENT_PHRASES)
31
+
32
+ return {
33
+ "dense": True,
34
+ "sparse": True,
35
+ "fts": has_quotes or has_boolean or has_all_caps or has_exact_intent,
36
+ }
src/search/search_combined.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Combined search: run dense, sparse, and FTS in parallel and merge with RRF."""
2
+
3
+ import os
4
+ from pinecone import Pinecone
5
+
6
+ from src.search.search_dense import search_dense
7
+ from src.search.search_sparse import search_sparse
8
+ from src.search.search_fts import search_fts
9
+ from src.search.fusion import rrf_merge
10
+
11
+
12
+ def search_combined(
13
+ index,
14
+ namespace: str,
15
+ query_text: str,
16
+ dense_query_vector: list[float] | None = None,
17
+ sparse_query_vector: dict | None = None,
18
+ top_k: int = 20,
19
+ filters: dict | None = None,
20
+ run_fts: bool = True,
21
+ ) -> list[dict]:
22
+ result_groups = []
23
+
24
+ if dense_query_vector is not None:
25
+ dense_response = search_dense(index, namespace, dense_query_vector, top_k=top_k, filters=filters)
26
+ result_groups.append(("dense", dense_response.get("result", {}).get("hits", [])))
27
+
28
+ if sparse_query_vector is not None:
29
+ sparse_response = search_sparse(index, namespace, sparse_query_vector, top_k=top_k, filters=filters)
30
+ result_groups.append(("sparse", sparse_response.get("result", {}).get("hits", [])))
31
+
32
+ if run_fts and query_text.strip():
33
+ fts_response = search_fts(index, namespace, query_text, top_k=top_k, filters=filters)
34
+ result_groups.append(("fts", fts_response.get("result", {}).get("hits", [])))
35
+
36
+ if not result_groups:
37
+ return []
38
+
39
+ return rrf_merge(result_groups)[:top_k]
40
+
41
+
42
+ def main():
43
+ import argparse
44
+ from src.search.router import route_query
45
+ from src.embeddings.embed_images import embed_text_query
46
+ from src.embeddings.embed_sparse_text import embed_sparse_query
47
+
48
+ parser = argparse.ArgumentParser(description="Combined dense+sparse+FTS search")
49
+ parser.add_argument("--query", required=True)
50
+ parser.add_argument("--top-k", type=int, default=10)
51
+ parser.add_argument("--no-ads", action="store_true", default=True)
52
+ args = parser.parse_args()
53
+
54
+ pc = Pinecone(api_key=os.environ["PINECONE_API_KEY"])
55
+ index = pc.preview.index(name=os.environ.get("PINECONE_INDEX_NAME", "comic-panels"))
56
+ namespace = os.environ.get("PINECONE_NAMESPACE", "comics-v1")
57
+
58
+ route = route_query(args.query)
59
+ dense_vector = embed_text_query(args.query) if route["dense"] else None
60
+ sparse_vector = embed_sparse_query(args.query) if route["sparse"] else None
61
+ filters = {"is_ad_page": False} if args.no_ads else {}
62
+
63
+ hits = search_combined(
64
+ index=index,
65
+ namespace=namespace,
66
+ query_text=args.query,
67
+ dense_query_vector=dense_vector,
68
+ sparse_query_vector=sparse_vector,
69
+ top_k=args.top_k,
70
+ filters=filters,
71
+ run_fts=route["fts"],
72
+ )
73
+
74
+ print(f"Query: {args.query!r} route={route} ({len(hits)} hits)")
75
+ for i, hit in enumerate(hits, 1):
76
+ fields = hit.get("fields", hit)
77
+ sources = hit.get("sources", [])
78
+ print(f" {i}. [rrf={hit.get('rrf_score', 0):.4f} src={sources}] {hit.get('_id')} | {fields.get('ocr_text', '')[:80]}")
79
+
80
+
81
+ if __name__ == "__main__":
82
+ main()
src/search/search_dense.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Dense vector search over panel image embeddings."""
2
+
3
+ import os
4
+ from pinecone import Pinecone
5
+
6
+
7
+ _INCLUDE_FIELDS = [
8
+ "ocr_text", "search_text", "comic_id", "book_id",
9
+ "page_id", "page_num", "panel_num", "image_path", "source", "is_ad_page",
10
+ ]
11
+
12
+
13
+ def search_dense(index, namespace: str, query_vector: list[float], top_k: int = 20, filters: dict | None = None) -> dict:
14
+ return index.documents.search(
15
+ namespace=namespace,
16
+ top_k=top_k,
17
+ score_by=[{"type": "dense_vector", "field": "image_dense", "values": query_vector}],
18
+ filter=filters or {},
19
+ include_fields=_INCLUDE_FIELDS,
20
+ )
21
+
22
+
23
+ def search_dense_with_phrase_filter(
24
+ index,
25
+ namespace: str,
26
+ query_vector: list[float],
27
+ phrase: str,
28
+ top_k: int = 20,
29
+ filters: dict | None = None,
30
+ ) -> dict:
31
+ merged_filter = dict(filters) if filters else {}
32
+ merged_filter["search_text"] = {"$match_phrase": phrase}
33
+ return index.documents.search(
34
+ namespace=namespace,
35
+ top_k=top_k,
36
+ score_by=[{"type": "dense_vector", "field": "image_dense", "values": query_vector}],
37
+ filter=merged_filter,
38
+ include_fields=_INCLUDE_FIELDS,
39
+ )
40
+
41
+
42
+ def main():
43
+ import argparse
44
+ from src.embeddings.embed_images import embed_text_query
45
+
46
+ parser = argparse.ArgumentParser(description="Dense image search")
47
+ parser.add_argument("--query", required=True)
48
+ parser.add_argument("--top-k", type=int, default=10)
49
+ parser.add_argument("--no-ads", action="store_true", default=True)
50
+ args = parser.parse_args()
51
+
52
+ pc = Pinecone(api_key=os.environ["PINECONE_API_KEY"])
53
+ index = pc.preview.index(name=os.environ.get("PINECONE_INDEX_NAME", "comic-panels"))
54
+ namespace = os.environ.get("PINECONE_NAMESPACE", "comics-v1")
55
+
56
+ query_vector = embed_text_query(args.query)
57
+ filters = {"is_ad_page": False} if args.no_ads else {}
58
+ response = search_dense(index, namespace, query_vector, top_k=args.top_k, filters=filters)
59
+
60
+ hits = response.get("result", {}).get("hits", [])
61
+ print(f"Query: {args.query!r} ({len(hits)} hits)")
62
+ for i, hit in enumerate(hits, 1):
63
+ fields = hit.get("fields", {})
64
+ print(f" {i}. [{hit.get('_score', 0):.4f}] {hit['_id']} | {fields.get('ocr_text', '')[:80]}")
65
+
66
+
67
+ if __name__ == "__main__":
68
+ main()
src/search/search_fts.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Full-text search over panel OCR/dialogue text."""
2
+
3
+ import os
4
+ from pinecone import Pinecone
5
+
6
+
7
+ _INCLUDE_FIELDS = [
8
+ "ocr_text", "search_text", "comic_id", "book_id",
9
+ "page_id", "page_num", "panel_num", "image_path", "source", "is_ad_page",
10
+ ]
11
+
12
+
13
+ def search_fts(index, namespace: str, query: str, top_k: int = 20, filters: dict | None = None) -> dict:
14
+ return index.documents.search(
15
+ namespace=namespace,
16
+ top_k=top_k,
17
+ score_by=[{"type": "text", "field": "search_text", "query": query}],
18
+ filter=filters or {},
19
+ include_fields=_INCLUDE_FIELDS,
20
+ )
21
+
22
+
23
+ def main():
24
+ import argparse
25
+
26
+ parser = argparse.ArgumentParser(description="Full-text search over comic dialogue")
27
+ parser.add_argument("--query", required=True, help='e.g. "secret formula" or BANG or formula AND detective')
28
+ parser.add_argument("--top-k", type=int, default=10)
29
+ parser.add_argument("--no-ads", action="store_true", default=True)
30
+ args = parser.parse_args()
31
+
32
+ pc = Pinecone(api_key=os.environ["PINECONE_API_KEY"])
33
+ index = pc.preview.index(name=os.environ.get("PINECONE_INDEX_NAME", "comic-panels"))
34
+ namespace = os.environ.get("PINECONE_NAMESPACE", "comics-v1")
35
+
36
+ filters = {"is_ad_page": False} if args.no_ads else {}
37
+ response = search_fts(index, namespace, args.query, top_k=args.top_k, filters=filters)
38
+
39
+ hits = response.get("result", {}).get("hits", [])
40
+ print(f"Query: {args.query!r} ({len(hits)} hits)")
41
+ for i, hit in enumerate(hits, 1):
42
+ fields = hit.get("fields", {})
43
+ print(f" {i}. [{hit.get('_score', 0):.4f}] {hit['_id']} | {fields.get('ocr_text', '')[:80]}")
44
+
45
+
46
+ if __name__ == "__main__":
47
+ main()
src/search/search_sparse.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sparse vector search over panel OCR/dialogue text."""
2
+
3
+ import os
4
+ from pinecone import Pinecone
5
+
6
+
7
+ _INCLUDE_FIELDS = [
8
+ "ocr_text", "search_text", "comic_id", "book_id",
9
+ "page_id", "page_num", "panel_num", "image_path", "source", "is_ad_page",
10
+ ]
11
+
12
+
13
+ def search_sparse(index, namespace: str, query_sparse: dict, top_k: int = 20, filters: dict | None = None) -> dict:
14
+ return index.documents.search(
15
+ namespace=namespace,
16
+ top_k=top_k,
17
+ score_by=[{"type": "sparse_vector", "field": "text_sparse", "sparse_values": query_sparse}],
18
+ filter=filters or {},
19
+ include_fields=_INCLUDE_FIELDS,
20
+ )
21
+
22
+
23
+ def main():
24
+ import argparse
25
+ from src.embeddings.embed_sparse_text import embed_sparse_query
26
+
27
+ parser = argparse.ArgumentParser(description="Sparse text search")
28
+ parser.add_argument("--query", required=True)
29
+ parser.add_argument("--top-k", type=int, default=10)
30
+ parser.add_argument("--no-ads", action="store_true", default=True)
31
+ args = parser.parse_args()
32
+
33
+ pc = Pinecone(api_key=os.environ["PINECONE_API_KEY"])
34
+ index = pc.preview.index(name=os.environ.get("PINECONE_INDEX_NAME", "comic-panels"))
35
+ namespace = os.environ.get("PINECONE_NAMESPACE", "comics-v1")
36
+
37
+ query_sparse = embed_sparse_query(args.query)
38
+ if not query_sparse:
39
+ print("Empty query — no sparse vector produced")
40
+ return
41
+
42
+ filters = {"is_ad_page": False} if args.no_ads else {}
43
+ response = search_sparse(index, namespace, query_sparse, top_k=args.top_k, filters=filters)
44
+
45
+ hits = response.get("result", {}).get("hits", [])
46
+ print(f"Query: {args.query!r} ({len(hits)} hits)")
47
+ for i, hit in enumerate(hits, 1):
48
+ fields = hit.get("fields", {})
49
+ print(f" {i}. [{hit.get('_score', 0):.4f}] {hit['_id']} | {fields.get('ocr_text', '')[:80]}")
50
+
51
+
52
+ if __name__ == "__main__":
53
+ main()
updated-build.md ADDED
@@ -0,0 +1,1610 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # COMICS Panel Search: Pinecone Build Spec
2
+
3
+ Last updated: 2026-05-27
4
+
5
+ ## Goal
6
+
7
+ Build a Pinecone-backed panel search index over the COMICS dataset.
8
+
9
+ Each individual comic panel should be one searchable document in Pinecone. The system should support:
10
+
11
+ 1. Dense vector search over panel images.
12
+ 2. Sparse vector search over panel OCR/search text.
13
+ 3. Full-text search over OCR/search text.
14
+ 4. Combined retrieval by running separate searches and merging results client-side.
15
+ 5. A Jupyter notebook for manually running and inspecting queries.
16
+
17
+ Do not build the UI, Streamlit app, FastAPI app, story builder, sequencing logic, generation layer, or fine-tuning workflow yet.
18
+
19
+ ## Source notes
20
+
21
+ Use these sources as grounding references while implementing:
22
+
23
+ - COMICS GitHub repo: https://github.com/miyyer/comics
24
+ - COMICS direct downloads: https://obj.umiacs.umd.edu/comics/index.html
25
+ - Pinecone indexing overview: https://docs.pinecone.io/guides/index-data/indexing-overview
26
+ - Pinecone data modeling: https://docs.pinecone.io/guides/index-data/data-modeling
27
+ - Pinecone search overview: https://docs.pinecone.io/guides/search/search-overview
28
+
29
+ Important implementation assumptions:
30
+
31
+ - The COMICS repo provides `setup.sh`, which downloads raw panel images, OCR transcriptions, advertisement page IDs, cached features, and original page images.
32
+ - For this project, use the raw dataset files directly instead of depending on the old Python 2/Theano training pipeline.
33
+ - Pinecone document-schema indexes can hold a `dense_vector`, a `sparse_vector`, one or more FTS-enabled `string` fields, and metadata on the same JSON document.
34
+ - Metadata fields do not need to be declared in the schema. Any undeclared fields upserted with a document are stored and indexed for filtering.
35
+ - A document-schema index supports at most one dense vector field and at most one sparse vector field.
36
+ - A single Pinecone document search request ranks by one scoring signal. To blend dense, sparse, and BM25/FTS rankings, run separate searches and merge results client-side.
37
+
38
+ ## Core data model
39
+
40
+ One comic panel equals one Pinecone document.
41
+
42
+ Do not index full pages as the primary retrieval unit. Store page-level metadata on each panel so the exact panel can be displayed and traced back to its source page later.
43
+
44
+ Target document shape:
45
+
46
+ ```json
47
+ {
48
+ "_id": "comic_id:page_num:panel_num",
49
+
50
+ "image_dense": [0.012, -0.034, 0.091],
51
+ "text_sparse": {
52
+ "indices": [123, 456, 789],
53
+ "values": [0.41, 0.29, 0.13]
54
+ },
55
+
56
+ "ocr_text": "Wait... I know that symbol.",
57
+ "search_text": "Wait... I know that symbol.",
58
+
59
+ "comic_id": "black_terror_001",
60
+ "book_id": "black_terror_001",
61
+ "page_id": "page_012",
62
+ "page_num": 12,
63
+ "panel_num": 4,
64
+ "image_path": "data/comics/raw/raw_panel_images/...",
65
+ "source": "COMICS",
66
+ "is_ad_page": false
67
+ }
68
+ ```
69
+
70
+ ## Target repository structure
71
+
72
+ ```text
73
+ comic-search/
74
+ data/
75
+ comics/
76
+ raw/
77
+ raw_panel_images.tar.gz
78
+ COMICS_ocr_file.csv
79
+ predadpages.txt
80
+ raw_panel_images/
81
+ processed/
82
+ panels_manifest.parquet
83
+ panels_manifest.jsonl
84
+ image_dense_vectors.parquet
85
+ text_sparse_vectors.parquet
86
+ pinecone_documents.jsonl
87
+ notebooks/
88
+ query_comics_index.ipynb
89
+ src/
90
+ ingest/
91
+ build_manifest.py
92
+ clean_text.py
93
+ embeddings/
94
+ embed_images.py
95
+ embed_sparse_text.py
96
+ pinecone/
97
+ create_index.py
98
+ build_documents.py
99
+ upsert_panels.py
100
+ search/
101
+ search_dense.py
102
+ search_sparse.py
103
+ search_fts.py
104
+ search_combined.py
105
+ fusion.py
106
+ router.py
107
+ README.md
108
+ requirements.txt
109
+ config.yaml
110
+ ```
111
+
112
+ ## Environment variables
113
+
114
+ Required:
115
+
116
+ ```bash
117
+ export PINECONE_API_KEY="..."
118
+ export PINECONE_INDEX_NAME="comic-panels"
119
+ export PINECONE_NAMESPACE="comics-v1"
120
+ export COMICS_DATA_DIR="data/comics"
121
+ ```
122
+
123
+ Optional:
124
+
125
+ ```bash
126
+ export IMAGE_EMBED_MODEL="open_clip_ViT-B-32"
127
+ export IMAGE_EMBED_DIM="512"
128
+ export IMAGE_EMBED_METRIC="cosine"
129
+ export SPARSE_EMBED_MODEL="pinecone-sparse-english-v0"
130
+ ```
131
+
132
+ ## Config file
133
+
134
+ Create:
135
+
136
+ ```text
137
+ config.yaml
138
+ ```
139
+
140
+ Suggested contents:
141
+
142
+ ```yaml
143
+ pinecone:
144
+ index_name: comic-panels
145
+ namespace: comics-v1
146
+
147
+ dense_embedding:
148
+ model_name: open_clip_ViT-B-32
149
+ field_name: image_dense
150
+ dimension: 512
151
+ metric: cosine
152
+
153
+ sparse_embedding:
154
+ model_name: pinecone-sparse-english-v0
155
+ field_name: text_sparse
156
+ source_text_field: search_text
157
+
158
+ fields:
159
+ dense_vector: image_dense
160
+ sparse_vector: text_sparse
161
+ fts_text:
162
+ - ocr_text
163
+ - search_text
164
+
165
+ data:
166
+ comics_data_dir: data/comics
167
+ manifest_path: data/comics/processed/panels_manifest.parquet
168
+ dense_vectors_path: data/comics/processed/image_dense_vectors.parquet
169
+ sparse_vectors_path: data/comics/processed/text_sparse_vectors.parquet
170
+ pinecone_documents_path: data/comics/processed/pinecone_documents.jsonl
171
+ ```
172
+
173
+ If the selected dense image embedding model has a different dimension, update `dense_embedding.dimension` before creating the Pinecone index.
174
+
175
+ ## Step 1: Download COMICS files
176
+
177
+ Create a download script or include this in the README:
178
+
179
+ ```bash
180
+ mkdir -p data/comics/raw
181
+ cd data/comics/raw
182
+
183
+ wget -c https://obj.umiacs.umd.edu/comics/raw_panel_images.tar.gz
184
+ wget -c https://obj.umiacs.umd.edu/comics/COMICS_ocr_file.csv
185
+ wget -c https://obj.umiacs.umd.edu/comics/predadpages.txt
186
+
187
+ tar -xzf raw_panel_images.tar.gz
188
+ ```
189
+
190
+ Optional, only if page-level reconstruction is needed later:
191
+
192
+ ```bash
193
+ wget -c https://obj.umiacs.umd.edu/comics/raw_page_images.tar.gz
194
+ ```
195
+
196
+ Do not rely on the old COMICS training pipeline unless necessary. Use the downloaded raw panel images and OCR CSV directly.
197
+
198
+ ## Step 2: Clean OCR text
199
+
200
+ Create:
201
+
202
+ ```text
203
+ src/ingest/clean_text.py
204
+ ```
205
+
206
+ Clean text conservatively. Comics contain sound effects, stylized spelling, names, unusual punctuation, and OCR noise.
207
+
208
+ Requirements:
209
+
210
+ - Normalize whitespace.
211
+ - Remove obvious OCR junk.
212
+ - Preserve punctuation where useful.
213
+ - Preserve all-caps sound effects such as `BANG`, `WHAM`, `CRASH`.
214
+ - Preserve names, unusual spellings, and comic-style phrasing.
215
+ - Store both raw and cleaned text.
216
+ - Do not aggressively lowercase the stored text fields. Pinecone FTS applies server-side tokenization and lowercasing for FTS-enabled fields.
217
+
218
+ Example:
219
+
220
+ ```python
221
+ import re
222
+
223
+
224
+ def clean_ocr(text: str | None) -> str:
225
+ if not text:
226
+ return ""
227
+
228
+ text = str(text)
229
+ text = text.replace("\u0000", " ")
230
+ text = re.sub(r"\s+", " ", text).strip()
231
+
232
+ # Keep this conservative. Do not remove punctuation or all-caps words.
233
+ return text
234
+
235
+
236
+ def build_search_text(ocr_text_clean: str, extra_terms: list[str] | None = None) -> str:
237
+ parts = [ocr_text_clean]
238
+ if extra_terms:
239
+ parts.extend(extra_terms)
240
+
241
+ return " ".join(p for p in parts if p).strip()
242
+ ```
243
+
244
+ For v1:
245
+
246
+ ```python
247
+ search_text = ocr_text_clean
248
+ ```
249
+
250
+ ## Step 3: Build the panel manifest
251
+
252
+ Create:
253
+
254
+ ```text
255
+ src/ingest/build_manifest.py
256
+ ```
257
+
258
+ This script should read:
259
+
260
+ ```text
261
+ data/comics/raw/raw_panel_images/
262
+ data/comics/raw/COMICS_ocr_file.csv
263
+ data/comics/raw/predadpages.txt
264
+ ```
265
+
266
+ Do not hardcode assumptions about the CSV columns or image filename format before inspecting them. Build a deterministic mapping between panel image paths and OCR rows.
267
+
268
+ Output one row per panel:
269
+
270
+ ```python
271
+ {
272
+ "panel_id": str,
273
+ "image_path": str,
274
+ "ocr_text_raw": str,
275
+ "ocr_text_clean": str,
276
+ "search_text": str,
277
+ "comic_id": str | None,
278
+ "book_id": str | None,
279
+ "page_id": str | None,
280
+ "page_num": int | None,
281
+ "panel_num": int | None,
282
+ "is_ad_page": bool,
283
+ "source": "COMICS"
284
+ }
285
+ ```
286
+
287
+ ID requirements:
288
+
289
+ ```text
290
+ Preferred: {comic_id}:{page_num}:{panel_num}
291
+ Fallback: comics:{sha1(image_path)}
292
+ ```
293
+
294
+ Validation requirements:
295
+
296
+ - Every row must have a stable `panel_id`.
297
+ - Every row must point to an existing image file.
298
+ - Empty OCR is allowed.
299
+ - Empty OCR panels must still be indexed because they are visually searchable.
300
+ - `search_text` must be non-null, but can be an empty string.
301
+ - Ad pages should be flagged with `is_ad_page`, not automatically dropped.
302
+ - Log unmatched panel images and unmatched OCR rows.
303
+ - Do not silently drop rows unless the image is corrupt or unreadable.
304
+
305
+ Write:
306
+
307
+ ```text
308
+ data/comics/processed/panels_manifest.parquet
309
+ data/comics/processed/panels_manifest.jsonl
310
+ ```
311
+
312
+ Suggested skeleton:
313
+
314
+ ```python
315
+ from pathlib import Path
316
+ import hashlib
317
+ import pandas as pd
318
+
319
+ from src.ingest.clean_text import clean_ocr, build_search_text
320
+
321
+
322
+ COMICS_DATA_DIR = Path("data/comics")
323
+ RAW_DIR = COMICS_DATA_DIR / "raw"
324
+ PROCESSED_DIR = COMICS_DATA_DIR / "processed"
325
+ PROCESSED_DIR.mkdir(parents=True, exist_ok=True)
326
+
327
+
328
+ def stable_hash(value: str) -> str:
329
+ return hashlib.sha1(value.encode("utf-8")).hexdigest()[:16]
330
+
331
+
332
+ def infer_panel_id(image_path: Path, row: dict | None = None) -> str:
333
+ # Prefer structured IDs when comic/page/panel metadata is available.
334
+ # Fallback to deterministic path hash.
335
+ return f"comics:{stable_hash(str(image_path))}"
336
+
337
+
338
+ def main():
339
+ panel_dir = RAW_DIR / "raw_panel_images"
340
+ ocr_path = RAW_DIR / "COMICS_ocr_file.csv"
341
+ ad_pages_path = RAW_DIR / "predadpages.txt"
342
+
343
+ ocr_df = pd.read_csv(ocr_path)
344
+ panel_paths = sorted(panel_dir.rglob("*"))
345
+
346
+ panel_paths = [
347
+ p for p in panel_paths
348
+ if p.is_file() and p.suffix.lower() in {".jpg", ".jpeg", ".png"}
349
+ ]
350
+
351
+ # TODO: inspect ocr_df columns and build robust mapping to panel image paths.
352
+ print("OCR columns:", list(ocr_df.columns))
353
+ print("Panel count:", len(panel_paths))
354
+
355
+ rows = []
356
+ for image_path in panel_paths:
357
+ ocr_text_raw = ""
358
+ ocr_text_clean = clean_ocr(ocr_text_raw)
359
+ search_text = build_search_text(ocr_text_clean)
360
+
361
+ rows.append({
362
+ "panel_id": infer_panel_id(image_path),
363
+ "image_path": str(image_path),
364
+ "ocr_text_raw": ocr_text_raw,
365
+ "ocr_text_clean": ocr_text_clean,
366
+ "search_text": search_text,
367
+ "comic_id": None,
368
+ "book_id": None,
369
+ "page_id": None,
370
+ "page_num": None,
371
+ "panel_num": None,
372
+ "is_ad_page": False,
373
+ "source": "COMICS",
374
+ })
375
+
376
+ manifest = pd.DataFrame(rows)
377
+ manifest.to_parquet(PROCESSED_DIR / "panels_manifest.parquet", index=False)
378
+ manifest.to_json(PROCESSED_DIR / "panels_manifest.jsonl", orient="records", lines=True)
379
+
380
+
381
+ if __name__ == "__main__":
382
+ main()
383
+ ```
384
+
385
+ The agent should replace the TODO with the real filename-to-OCR mapping after inspecting `COMICS_ocr_file.csv`.
386
+
387
+ ## Step 4: Create dense image embeddings
388
+
389
+ Create:
390
+
391
+ ```text
392
+ src/embeddings/embed_images.py
393
+ ```
394
+
395
+ Use a multimodal image embedding model suitable for text-to-image and image-to-image search. CLIP or SigLIP-style models are acceptable for v1.
396
+
397
+ Requirements:
398
+
399
+ - Embed the panel image, not the page.
400
+ - Use one dense vector per panel.
401
+ - Use the same model family at query time.
402
+ - Store the model name, dimension, and metric in `config.yaml`.
403
+ - Save vectors before upsert so the process is restartable.
404
+ - Log failed image reads.
405
+ - Do not drop rows with empty OCR.
406
+
407
+ Output:
408
+
409
+ ```text
410
+ data/comics/processed/image_dense_vectors.parquet
411
+ ```
412
+
413
+ Each row:
414
+
415
+ ```python
416
+ {
417
+ "panel_id": str,
418
+ "image_dense": list[float]
419
+ }
420
+ ```
421
+
422
+ Suggested skeleton:
423
+
424
+ ```python
425
+ from pathlib import Path
426
+ import pandas as pd
427
+ from PIL import Image
428
+ from tqdm import tqdm
429
+
430
+
431
+ MANIFEST_PATH = Path("data/comics/processed/panels_manifest.parquet")
432
+ OUTPUT_PATH = Path("data/comics/processed/image_dense_vectors.parquet")
433
+
434
+
435
+ def embed_image(image: Image.Image) -> list[float]:
436
+ """
437
+ Use the selected CLIP/SigLIP-style image encoder.
438
+ Must return a vector matching config.yaml dense_embedding.dimension.
439
+ """
440
+ raise NotImplementedError("Wire this to the selected image embedding model.")
441
+
442
+
443
+ def main():
444
+ manifest = pd.read_parquet(MANIFEST_PATH)
445
+
446
+ rows = []
447
+ failures = []
448
+
449
+ for row in tqdm(manifest.itertuples(index=False), total=len(manifest)):
450
+ try:
451
+ image = Image.open(row.image_path).convert("RGB")
452
+ vector = embed_image(image)
453
+ rows.append({
454
+ "panel_id": row.panel_id,
455
+ "image_dense": vector,
456
+ })
457
+ except Exception as exc:
458
+ failures.append({
459
+ "panel_id": row.panel_id,
460
+ "image_path": row.image_path,
461
+ "error": str(exc),
462
+ })
463
+
464
+ pd.DataFrame(rows).to_parquet(OUTPUT_PATH, index=False)
465
+
466
+ if failures:
467
+ pd.DataFrame(failures).to_csv(
468
+ "data/comics/processed/image_embedding_failures.csv",
469
+ index=False,
470
+ )
471
+
472
+
473
+ if __name__ == "__main__":
474
+ main()
475
+ ```
476
+
477
+ ## Step 5: Create sparse text embeddings
478
+
479
+ Create:
480
+
481
+ ```text
482
+ src/embeddings/embed_sparse_text.py
483
+ ```
484
+
485
+ Use cleaned `search_text`.
486
+
487
+ Preferred model:
488
+
489
+ ```text
490
+ pinecone-sparse-english-v0
491
+ ```
492
+
493
+ Requirements:
494
+
495
+ - Use passage/document mode for indexing.
496
+ - Use query mode for user queries.
497
+ - Skip sparse embedding only when `search_text` is empty.
498
+ - Store empty text rows with no sparse vector, but keep the panel document.
499
+ - Save vectors before upsert.
500
+ - Sparse vectors must use parallel `indices` and `values` arrays.
501
+ - Respect sparse vector limits, including max non-zero values per vector.
502
+
503
+ Output:
504
+
505
+ ```text
506
+ data/comics/processed/text_sparse_vectors.parquet
507
+ ```
508
+
509
+ Each row:
510
+
511
+ ```python
512
+ {
513
+ "panel_id": str,
514
+ "text_sparse": {
515
+ "indices": [123, 456],
516
+ "values": [0.25, 0.19]
517
+ }
518
+ }
519
+ ```
520
+
521
+ Suggested skeleton:
522
+
523
+ ```python
524
+ from pathlib import Path
525
+ import pandas as pd
526
+ from tqdm import tqdm
527
+
528
+
529
+ MANIFEST_PATH = Path("data/comics/processed/panels_manifest.parquet")
530
+ OUTPUT_PATH = Path("data/comics/processed/text_sparse_vectors.parquet")
531
+
532
+
533
+ def embed_sparse_passage(text: str) -> dict:
534
+ """
535
+ Use pinecone-sparse-english-v0 or another sparse encoder in passage/document mode.
536
+ Must return:
537
+ {
538
+ "indices": [...],
539
+ "values": [...]
540
+ }
541
+ """
542
+ raise NotImplementedError("Wire this to the selected sparse embedding model.")
543
+
544
+
545
+ def main():
546
+ manifest = pd.read_parquet(MANIFEST_PATH)
547
+
548
+ rows = []
549
+ for row in tqdm(manifest.itertuples(index=False), total=len(manifest)):
550
+ text = row.search_text or ""
551
+
552
+ if not text.strip():
553
+ sparse = None
554
+ else:
555
+ sparse = embed_sparse_passage(text)
556
+
557
+ rows.append({
558
+ "panel_id": row.panel_id,
559
+ "text_sparse": sparse,
560
+ })
561
+
562
+ pd.DataFrame(rows).to_parquet(OUTPUT_PATH, index=False)
563
+
564
+
565
+ if __name__ == "__main__":
566
+ main()
567
+ ```
568
+
569
+ ## Step 6: Create the Pinecone index
570
+
571
+ Create:
572
+
573
+ ```text
574
+ src/pinecone/create_index.py
575
+ ```
576
+
577
+ Create one document-schema index with:
578
+
579
+ ```text
580
+ image_dense dense_vector
581
+ text_sparse sparse_vector
582
+ ocr_text string with full_text_search enabled
583
+ search_text string with full_text_search enabled
584
+ ```
585
+
586
+ Metadata fields should not be declared in the schema. Include them at upsert time.
587
+
588
+ Logical schema:
589
+
590
+ ```python
591
+ from pinecone import Pinecone
592
+ from pinecone.preview import SchemaBuilder
593
+ import os
594
+
595
+ pc = Pinecone(api_key=os.environ["PINECONE_API_KEY"])
596
+
597
+ INDEX_NAME = os.environ.get("PINECONE_INDEX_NAME", "comic-panels")
598
+ IMAGE_EMBED_DIM = int(os.environ.get("IMAGE_EMBED_DIM", "512"))
599
+ IMAGE_EMBED_METRIC = os.environ.get("IMAGE_EMBED_METRIC", "cosine")
600
+
601
+ schema = (
602
+ SchemaBuilder()
603
+ .add_dense_vector_field(
604
+ "image_dense",
605
+ dimension=IMAGE_EMBED_DIM,
606
+ metric=IMAGE_EMBED_METRIC,
607
+ )
608
+ .add_sparse_vector_field("text_sparse")
609
+ .add_string_field(
610
+ "ocr_text",
611
+ full_text_search={"language": "en"},
612
+ )
613
+ .add_string_field(
614
+ "search_text",
615
+ full_text_search={"language": "en"},
616
+ )
617
+ .build()
618
+ )
619
+
620
+ pc.preview.indexes.create(
621
+ name=INDEX_NAME,
622
+ schema=schema,
623
+ )
624
+ ```
625
+
626
+ Important:
627
+
628
+ - Do not create separate Pinecone indexes for image, sparse, and FTS search.
629
+ - Use one document-schema index.
630
+ - Do not declare metadata-only fields in the schema.
631
+ - Plan fields carefully because schema migration may require recreating the index.
632
+ - Use the dense vector dimension from the selected image embedding model.
633
+ - Use cosine or dotproduct depending on the embedding model. If unsure, use cosine for normalized CLIP-style vectors.
634
+
635
+ ## Step 7: Build Pinecone documents
636
+
637
+ Create:
638
+
639
+ ```text
640
+ src/pinecone/build_documents.py
641
+ ```
642
+
643
+ Join:
644
+
645
+ ```text
646
+ panels_manifest.parquet
647
+ image_dense_vectors.parquet
648
+ text_sparse_vectors.parquet
649
+ ```
650
+
651
+ Output:
652
+
653
+ ```text
654
+ data/comics/processed/pinecone_documents.jsonl
655
+ ```
656
+
657
+ Each document:
658
+
659
+ ```python
660
+ {
661
+ "_id": panel_id,
662
+
663
+ "image_dense": image_dense,
664
+
665
+ # Only include this when available and non-empty.
666
+ "text_sparse": {
667
+ "indices": [...],
668
+ "values": [...]
669
+ },
670
+
671
+ "ocr_text": ocr_text_clean,
672
+ "search_text": search_text,
673
+
674
+ # Metadata fields:
675
+ "comic_id": comic_id,
676
+ "book_id": book_id,
677
+ "page_id": page_id,
678
+ "page_num": page_num,
679
+ "panel_num": panel_num,
680
+ "image_path": image_path,
681
+ "source": "COMICS",
682
+ "is_ad_page": is_ad_page
683
+ }
684
+ ```
685
+
686
+ Requirements:
687
+
688
+ - Do not include raw image bytes.
689
+ - Do not include huge blobs.
690
+ - Store local image paths for now.
691
+ - Later, image paths can become S3/GCS/R2 URLs.
692
+ - Preserve panels with empty OCR.
693
+ - Preserve panels with missing sparse vectors.
694
+ - Every document must have `_id`, `image_dense`, `ocr_text`, `search_text`, and metadata.
695
+ - Only include `text_sparse` when it has non-empty `indices` and `values`.
696
+
697
+ Suggested skeleton:
698
+
699
+ ```python
700
+ from pathlib import Path
701
+ import json
702
+ import pandas as pd
703
+
704
+
705
+ PROCESSED_DIR = Path("data/comics/processed")
706
+ MANIFEST_PATH = PROCESSED_DIR / "panels_manifest.parquet"
707
+ DENSE_PATH = PROCESSED_DIR / "image_dense_vectors.parquet"
708
+ SPARSE_PATH = PROCESSED_DIR / "text_sparse_vectors.parquet"
709
+ OUTPUT_PATH = PROCESSED_DIR / "pinecone_documents.jsonl"
710
+
711
+
712
+ def valid_sparse(sparse) -> bool:
713
+ if not isinstance(sparse, dict):
714
+ return False
715
+ return bool(sparse.get("indices")) and bool(sparse.get("values"))
716
+
717
+
718
+ def main():
719
+ manifest = pd.read_parquet(MANIFEST_PATH)
720
+ dense = pd.read_parquet(DENSE_PATH)
721
+ sparse = pd.read_parquet(SPARSE_PATH)
722
+
723
+ df = manifest.merge(dense, on="panel_id", how="inner")
724
+ df = df.merge(sparse, on="panel_id", how="left")
725
+
726
+ with open(OUTPUT_PATH, "w") as f:
727
+ for row in df.itertuples(index=False):
728
+ doc = {
729
+ "_id": row.panel_id,
730
+ "image_dense": row.image_dense,
731
+ "ocr_text": row.ocr_text_clean or "",
732
+ "search_text": row.search_text or "",
733
+ "comic_id": row.comic_id,
734
+ "book_id": row.book_id,
735
+ "page_id": row.page_id,
736
+ "page_num": row.page_num,
737
+ "panel_num": row.panel_num,
738
+ "image_path": row.image_path,
739
+ "source": "COMICS",
740
+ "is_ad_page": bool(row.is_ad_page),
741
+ }
742
+
743
+ sparse_vec = getattr(row, "text_sparse", None)
744
+ if valid_sparse(sparse_vec):
745
+ doc["text_sparse"] = sparse_vec
746
+
747
+ f.write(json.dumps(doc) + "\n")
748
+
749
+
750
+ if __name__ == "__main__":
751
+ main()
752
+ ```
753
+
754
+ ## Step 8: Upsert documents to Pinecone
755
+
756
+ Create:
757
+
758
+ ```text
759
+ src/pinecone/upsert_panels.py
760
+ ```
761
+
762
+ Pseudo-code:
763
+
764
+ ```python
765
+ import json
766
+ import os
767
+ import time
768
+ from pathlib import Path
769
+
770
+ from pinecone import Pinecone
771
+
772
+
773
+ pc = Pinecone(api_key=os.environ["PINECONE_API_KEY"])
774
+
775
+ INDEX_NAME = os.environ.get("PINECONE_INDEX_NAME", "comic-panels")
776
+ NAMESPACE = os.environ.get("PINECONE_NAMESPACE", "comics-v1")
777
+ DOCS_PATH = Path("data/comics/processed/pinecone_documents.jsonl")
778
+
779
+ index = pc.preview.index(name=INDEX_NAME)
780
+
781
+
782
+ def batched_jsonl(path: Path, batch_size: int = 100):
783
+ batch = []
784
+ with open(path) as f:
785
+ for line in f:
786
+ batch.append(json.loads(line))
787
+ if len(batch) >= batch_size:
788
+ yield batch
789
+ batch = []
790
+
791
+ if batch:
792
+ yield batch
793
+
794
+
795
+ def upsert_with_retry(batch, retries=5):
796
+ for attempt in range(retries):
797
+ try:
798
+ return index.documents.upsert(
799
+ namespace=NAMESPACE,
800
+ documents=batch,
801
+ )
802
+ except Exception:
803
+ if attempt == retries - 1:
804
+ raise
805
+ time.sleep(2 ** attempt)
806
+
807
+
808
+ def main():
809
+ for batch in batched_jsonl(DOCS_PATH, batch_size=100):
810
+ upsert_with_retry(batch)
811
+
812
+
813
+ if __name__ == "__main__":
814
+ main()
815
+ ```
816
+
817
+ Requirements:
818
+
819
+ - Upserts must be idempotent.
820
+ - Re-running the job should overwrite/update the same `panel_id`.
821
+ - Use deterministic IDs.
822
+ - Batch writes.
823
+ - Log batch failures.
824
+ - Retry transient failures with exponential backoff.
825
+ - After upsert, run a quick validation search.
826
+
827
+ ## Step 9: Dense search primitive
828
+
829
+ Create:
830
+
831
+ ```text
832
+ src/search/search_dense.py
833
+ ```
834
+
835
+ Use for visual/conceptual queries:
836
+
837
+ ```text
838
+ a detective in a dark alley
839
+ a superhero punching a robot
840
+ a close-up of a frightened face
841
+ ```
842
+
843
+ Process:
844
+
845
+ 1. Embed the user query using the text side of the same multimodal model used for panel image embeddings.
846
+ 2. Search the `image_dense` field.
847
+ 3. Return panel metadata and OCR fields.
848
+
849
+ ```python
850
+ def search_dense(index, namespace, query_vector, top_k=20, filters=None):
851
+ return index.documents.search(
852
+ namespace=namespace,
853
+ top_k=top_k,
854
+ score_by=[
855
+ {
856
+ "type": "dense_vector",
857
+ "field": "image_dense",
858
+ "values": query_vector,
859
+ }
860
+ ],
861
+ filter=filters or {},
862
+ include_fields=[
863
+ "ocr_text",
864
+ "search_text",
865
+ "comic_id",
866
+ "book_id",
867
+ "page_id",
868
+ "page_num",
869
+ "panel_num",
870
+ "image_path",
871
+ "source",
872
+ "is_ad_page",
873
+ ],
874
+ )
875
+ ```
876
+
877
+ ## Step 10: Sparse search primitive
878
+
879
+ Create:
880
+
881
+ ```text
882
+ src/search/search_sparse.py
883
+ ```
884
+
885
+ Use for text-heavy lexical/conceptual queries:
886
+
887
+ ```text
888
+ masked villain laboratory
889
+ secret treasure island
890
+ police detective murder clue
891
+ ```
892
+
893
+ Process:
894
+
895
+ 1. Sparse-embed the query using query mode.
896
+ 2. Search the `text_sparse` field.
897
+ 3. Return panel metadata and OCR fields.
898
+
899
+ ```python
900
+ def search_sparse(index, namespace, query_sparse, top_k=20, filters=None):
901
+ return index.documents.search(
902
+ namespace=namespace,
903
+ top_k=top_k,
904
+ score_by=[
905
+ {
906
+ "type": "sparse_vector",
907
+ "field": "text_sparse",
908
+ "sparse_values": query_sparse,
909
+ }
910
+ ],
911
+ filter=filters or {},
912
+ include_fields=[
913
+ "ocr_text",
914
+ "search_text",
915
+ "comic_id",
916
+ "book_id",
917
+ "page_id",
918
+ "page_num",
919
+ "panel_num",
920
+ "image_path",
921
+ "source",
922
+ "is_ad_page",
923
+ ],
924
+ )
925
+ ```
926
+
927
+ ## Step 11: Full-text search primitive
928
+
929
+ Create:
930
+
931
+ ```text
932
+ src/search/search_fts.py
933
+ ```
934
+
935
+ Use for:
936
+
937
+ ```text
938
+ exact dialogue
939
+ quoted phrases
940
+ character names
941
+ sound effects
942
+ Boolean-style keyword search
943
+ ```
944
+
945
+ Examples:
946
+
947
+ ```text
948
+ "secret formula"
949
+ BANG
950
+ laboratory AND formula
951
+ treasure NOT island
952
+ ```
953
+
954
+ Basic FTS search:
955
+
956
+ ```python
957
+ def search_fts(index, namespace, query, top_k=20, filters=None):
958
+ return index.documents.search(
959
+ namespace=namespace,
960
+ top_k=top_k,
961
+ score_by=[
962
+ {
963
+ "type": "text",
964
+ "field": "search_text",
965
+ "query": query,
966
+ }
967
+ ],
968
+ filter=filters or {},
969
+ include_fields=[
970
+ "ocr_text",
971
+ "search_text",
972
+ "comic_id",
973
+ "book_id",
974
+ "page_id",
975
+ "page_num",
976
+ "panel_num",
977
+ "image_path",
978
+ "source",
979
+ "is_ad_page",
980
+ ],
981
+ )
982
+ ```
983
+
984
+ For exact phrase filters, use FTS-enabled string fields as filters:
985
+
986
+ ```python
987
+ def search_dense_with_phrase_filter(
988
+ index,
989
+ namespace,
990
+ query_vector,
991
+ phrase,
992
+ top_k=20,
993
+ filters=None,
994
+ ):
995
+ merged_filter = filters.copy() if filters else {}
996
+ merged_filter["search_text"] = {"$match_phrase": phrase}
997
+
998
+ return index.documents.search(
999
+ namespace=namespace,
1000
+ top_k=top_k,
1001
+ score_by=[
1002
+ {
1003
+ "type": "dense_vector",
1004
+ "field": "image_dense",
1005
+ "values": query_vector,
1006
+ }
1007
+ ],
1008
+ filter=merged_filter,
1009
+ include_fields=[
1010
+ "ocr_text",
1011
+ "search_text",
1012
+ "comic_id",
1013
+ "book_id",
1014
+ "page_id",
1015
+ "page_num",
1016
+ "panel_num",
1017
+ "image_path",
1018
+ "source",
1019
+ "is_ad_page",
1020
+ ],
1021
+ )
1022
+ ```
1023
+
1024
+ ## Step 12: Combined search primitive
1025
+
1026
+ Create:
1027
+
1028
+ ```text
1029
+ src/search/search_combined.py
1030
+ src/search/fusion.py
1031
+ ```
1032
+
1033
+ A single Pinecone document search request ranks by one signal. For a true blend of dense, sparse, and FTS, run separate searches and merge client-side.
1034
+
1035
+ Use Reciprocal Rank Fusion for v1.
1036
+
1037
+ ```python
1038
+ def rrf_merge(result_groups, k=60):
1039
+ scores = {}
1040
+ payloads = {}
1041
+
1042
+ for source_name, hits in result_groups:
1043
+ for rank, hit in enumerate(hits, start=1):
1044
+ panel_id = hit["_id"]
1045
+
1046
+ scores.setdefault(panel_id, 0.0)
1047
+ scores[panel_id] += 1.0 / (k + rank)
1048
+
1049
+ if panel_id not in payloads:
1050
+ payloads[panel_id] = dict(hit)
1051
+ payloads[panel_id]["sources"] = []
1052
+
1053
+ payloads[panel_id]["sources"].append(source_name)
1054
+
1055
+ ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True)
1056
+
1057
+ output = []
1058
+ for panel_id, score in ranked:
1059
+ item = payloads[panel_id]
1060
+ item["rrf_score"] = score
1061
+ output.append(item)
1062
+
1063
+ return output
1064
+ ```
1065
+
1066
+ Combined search:
1067
+
1068
+ ```python
1069
+ from src.search.search_dense import search_dense
1070
+ from src.search.search_sparse import search_sparse
1071
+ from src.search.search_fts import search_fts
1072
+ from src.search.fusion import rrf_merge
1073
+
1074
+
1075
+ def search_combined(
1076
+ index,
1077
+ namespace,
1078
+ query_text,
1079
+ dense_query_vector,
1080
+ sparse_query_vector=None,
1081
+ top_k=20,
1082
+ filters=None,
1083
+ run_fts=True,
1084
+ ):
1085
+ result_groups = []
1086
+
1087
+ dense_response = search_dense(
1088
+ index=index,
1089
+ namespace=namespace,
1090
+ query_vector=dense_query_vector,
1091
+ top_k=top_k,
1092
+ filters=filters,
1093
+ )
1094
+ result_groups.append(("dense", dense_response["result"]["hits"]))
1095
+
1096
+ if sparse_query_vector:
1097
+ sparse_response = search_sparse(
1098
+ index=index,
1099
+ namespace=namespace,
1100
+ query_sparse=sparse_query_vector,
1101
+ top_k=top_k,
1102
+ filters=filters,
1103
+ )
1104
+ result_groups.append(("sparse", sparse_response["result"]["hits"]))
1105
+
1106
+ if run_fts:
1107
+ fts_response = search_fts(
1108
+ index=index,
1109
+ namespace=namespace,
1110
+ query=query_text,
1111
+ top_k=top_k,
1112
+ filters=filters,
1113
+ )
1114
+ result_groups.append(("fts", fts_response["result"]["hits"]))
1115
+
1116
+ return rrf_merge(result_groups)[:top_k]
1117
+ ```
1118
+
1119
+ Do not directly compare raw dense, sparse, and BM25 scores. Merge by rank unless there is a later calibration step.
1120
+
1121
+ ## Step 13: Query router
1122
+
1123
+ Create:
1124
+
1125
+ ```text
1126
+ src/search/router.py
1127
+ ```
1128
+
1129
+ The router should decide which signals to run.
1130
+
1131
+ ```python
1132
+ def route_query(query: str) -> dict:
1133
+ q = query.strip()
1134
+ tokens = q.split()
1135
+
1136
+ has_quotes = '"' in q or "'" in q
1137
+ has_boolean = any(t.upper() in {"AND", "OR", "NOT"} for t in tokens)
1138
+ has_all_caps = any(t.isupper() and len(t) >= 3 for t in tokens)
1139
+ has_exact_intent = any(
1140
+ phrase in q.lower()
1141
+ for phrase in [
1142
+ "says",
1143
+ "where they say",
1144
+ "exact",
1145
+ "phrase",
1146
+ "quote",
1147
+ "dialogue",
1148
+ "sound effect",
1149
+ "contains",
1150
+ ]
1151
+ )
1152
+
1153
+ return {
1154
+ "dense": True,
1155
+ "sparse": bool(q),
1156
+ "fts": has_quotes or has_boolean or has_all_caps or has_exact_intent,
1157
+ }
1158
+ ```
1159
+
1160
+ Default behavior:
1161
+
1162
+ ```text
1163
+ Natural-language visual query: dense + sparse
1164
+ Quoted/exact/dialogue query: dense + sparse + FTS
1165
+ All-caps sound effect query: dense + sparse + FTS
1166
+ Boolean query: FTS + optional dense/sparse
1167
+ ```
1168
+
1169
+ ## Step 14: Jupyter query notebook
1170
+
1171
+ Create:
1172
+
1173
+ ```text
1174
+ notebooks/query_comics_index.ipynb
1175
+ ```
1176
+
1177
+ This notebook is not a UI. It is a developer-facing query playground for validating search quality, inspecting returned panels, and comparing dense, sparse, FTS, and combined retrieval.
1178
+
1179
+ The notebook should make it easy to:
1180
+
1181
+ 1. Connect to the existing Pinecone index.
1182
+ 2. Run dense image search from a text query.
1183
+ 3. Run sparse text search from a text query.
1184
+ 4. Run full-text search against OCR/search text.
1185
+ 5. Run combined search with RRF.
1186
+ 6. Display returned panel images inline.
1187
+ 7. Compare results across search modes.
1188
+ 8. Inspect metadata, OCR text, and source signals.
1189
+
1190
+ The notebook should call the repo’s search primitives rather than duplicating search logic.
1191
+
1192
+ ### Notebook setup cell
1193
+
1194
+ ```python
1195
+ import os
1196
+ import sys
1197
+ from pathlib import Path
1198
+
1199
+ from pinecone import Pinecone
1200
+ from PIL import Image
1201
+ import pandas as pd
1202
+ from IPython.display import display, Markdown
1203
+
1204
+ PROJECT_ROOT = Path.cwd().parent if Path.cwd().name == "notebooks" else Path.cwd()
1205
+ sys.path.append(str(PROJECT_ROOT))
1206
+
1207
+ PINECONE_API_KEY = os.environ["PINECONE_API_KEY"]
1208
+ PINECONE_INDEX_NAME = os.environ.get("PINECONE_INDEX_NAME", "comic-panels")
1209
+ PINECONE_NAMESPACE = os.environ.get("PINECONE_NAMESPACE", "comics-v1")
1210
+ COMICS_DATA_DIR = Path(os.environ.get("COMICS_DATA_DIR", "data/comics"))
1211
+
1212
+ pc = Pinecone(api_key=PINECONE_API_KEY)
1213
+ index = pc.preview.index(name=PINECONE_INDEX_NAME)
1214
+
1215
+ print("Connected to:", PINECONE_INDEX_NAME)
1216
+ print("Namespace:", PINECONE_NAMESPACE)
1217
+ ```
1218
+
1219
+ ### Notebook imports cell
1220
+
1221
+ ```python
1222
+ from src.search.search_dense import search_dense
1223
+ from src.search.search_sparse import search_sparse
1224
+ from src.search.search_fts import search_fts
1225
+ from src.search.search_combined import search_combined
1226
+ from src.search.router import route_query
1227
+ ```
1228
+
1229
+ ### Notebook embedding helper cell
1230
+
1231
+ The notebook needs helpers to embed queries. These should call the same embedding modules/config used during ingestion.
1232
+
1233
+ ```python
1234
+ def embed_query_dense(query: str):
1235
+ """
1236
+ Use the text side of the same multimodal model used for panel image embeddings.
1237
+ Must return a dense vector with the same dimension as image_dense.
1238
+ """
1239
+ raise NotImplementedError("Wire this to the selected CLIP/SigLIP query embedding function.")
1240
+
1241
+
1242
+ def embed_query_sparse(query: str):
1243
+ """
1244
+ Use the sparse model in query mode.
1245
+ Must return:
1246
+ {
1247
+ "indices": [...],
1248
+ "values": [...]
1249
+ }
1250
+ """
1251
+ raise NotImplementedError("Wire this to pinecone-sparse-english-v0 query embedding.")
1252
+ ```
1253
+
1254
+ Agents should replace these stubs with calls into the project’s embedding modules once those modules exist.
1255
+
1256
+ ### Notebook display helper cell
1257
+
1258
+ ```python
1259
+ def display_results(results, max_items=10, image_width=300):
1260
+ for i, hit in enumerate(results[:max_items], start=1):
1261
+ fields = hit.get("fields", hit)
1262
+
1263
+ panel_id = hit.get("_id") or hit.get("panel_id")
1264
+ score = hit.get("_score") or hit.get("score") or hit.get("rrf_score")
1265
+ sources = hit.get("sources", fields.get("sources", []))
1266
+
1267
+ image_path = fields.get("image_path") or hit.get("image_path")
1268
+ ocr_text = fields.get("ocr_text") or hit.get("ocr_text", "")
1269
+ comic_id = fields.get("comic_id") or hit.get("comic_id")
1270
+ page_num = fields.get("page_num") or hit.get("page_num")
1271
+ panel_num = fields.get("panel_num") or hit.get("panel_num")
1272
+
1273
+ display(Markdown(f"### {i}. `{panel_id}`"))
1274
+ display(Markdown(
1275
+ f"**Score:** `{score}` \n"
1276
+ f"**Sources:** `{sources}` \n"
1277
+ f"**Comic:** `{comic_id}` \n"
1278
+ f"**Page:** `{page_num}` \n"
1279
+ f"**Panel:** `{panel_num}`"
1280
+ ))
1281
+
1282
+ if image_path and Path(image_path).exists():
1283
+ img = Image.open(image_path)
1284
+ display(img.resize((image_width, int(image_width * img.height / img.width))))
1285
+ else:
1286
+ display(Markdown(f"`Image not found: {image_path}`"))
1287
+
1288
+ if ocr_text:
1289
+ display(Markdown(f"**OCR:** {ocr_text}"))
1290
+
1291
+ display(Markdown("---"))
1292
+ ```
1293
+
1294
+ ### Dense search section
1295
+
1296
+ ```python
1297
+ query = "a detective in a dark alley"
1298
+ top_k = 10
1299
+
1300
+ dense_vector = embed_query_dense(query)
1301
+
1302
+ dense_response = search_dense(
1303
+ index=index,
1304
+ namespace=PINECONE_NAMESPACE,
1305
+ query_vector=dense_vector,
1306
+ top_k=top_k,
1307
+ filters={"is_ad_page": False},
1308
+ )
1309
+
1310
+ dense_hits = dense_response["result"]["hits"]
1311
+ display_results(dense_hits, max_items=top_k)
1312
+ ```
1313
+
1314
+ Use for searches like:
1315
+
1316
+ ```text
1317
+ a superhero flying through the sky
1318
+ a monster attacking a city
1319
+ a close-up of a frightened face
1320
+ a robot in a laboratory
1321
+ ```
1322
+
1323
+ ### Sparse search section
1324
+
1325
+ ```python
1326
+ query = "masked villain laboratory"
1327
+ top_k = 10
1328
+
1329
+ sparse_vector = embed_query_sparse(query)
1330
+
1331
+ sparse_response = search_sparse(
1332
+ index=index,
1333
+ namespace=PINECONE_NAMESPACE,
1334
+ query_sparse=sparse_vector,
1335
+ top_k=top_k,
1336
+ filters={"is_ad_page": False},
1337
+ )
1338
+
1339
+ sparse_hits = sparse_response["result"]["hits"]
1340
+ display_results(sparse_hits, max_items=top_k)
1341
+ ```
1342
+
1343
+ Use for searches like:
1344
+
1345
+ ```text
1346
+ secret formula
1347
+ police detective clue
1348
+ treasure island
1349
+ masked villain laboratory
1350
+ ```
1351
+
1352
+ ### Full-text search section
1353
+
1354
+ ```python
1355
+ query = '"secret formula"'
1356
+ top_k = 10
1357
+
1358
+ fts_response = search_fts(
1359
+ index=index,
1360
+ namespace=PINECONE_NAMESPACE,
1361
+ query=query,
1362
+ top_k=top_k,
1363
+ filters={"is_ad_page": False},
1364
+ )
1365
+
1366
+ fts_hits = fts_response["result"]["hits"]
1367
+ display_results(fts_hits, max_items=top_k)
1368
+ ```
1369
+
1370
+ Use for searches like:
1371
+
1372
+ ```text
1373
+ "secret formula"
1374
+ BANG
1375
+ WHAM
1376
+ laboratory AND formula
1377
+ treasure NOT island
1378
+ ```
1379
+
1380
+ ### Combined search section
1381
+
1382
+ ```python
1383
+ query = "a laboratory panel containing formula"
1384
+ top_k = 10
1385
+
1386
+ route = route_query(query)
1387
+
1388
+ dense_vector = embed_query_dense(query) if route["dense"] else None
1389
+ sparse_vector = embed_query_sparse(query) if route["sparse"] else None
1390
+
1391
+ combined_hits = search_combined(
1392
+ index=index,
1393
+ namespace=PINECONE_NAMESPACE,
1394
+ query_text=query,
1395
+ dense_query_vector=dense_vector,
1396
+ sparse_query_vector=sparse_vector,
1397
+ top_k=top_k,
1398
+ filters={"is_ad_page": False},
1399
+ run_fts=route["fts"],
1400
+ )
1401
+
1402
+ display_results(combined_hits, max_items=top_k)
1403
+ ```
1404
+
1405
+ ### Compare modes section
1406
+
1407
+ ```python
1408
+ query = "secret formula laboratory"
1409
+ top_k = 10
1410
+
1411
+ dense_vector = embed_query_dense(query)
1412
+ sparse_vector = embed_query_sparse(query)
1413
+
1414
+ dense_hits = search_dense(
1415
+ index=index,
1416
+ namespace=PINECONE_NAMESPACE,
1417
+ query_vector=dense_vector,
1418
+ top_k=top_k,
1419
+ filters={"is_ad_page": False},
1420
+ )["result"]["hits"]
1421
+
1422
+ sparse_hits = search_sparse(
1423
+ index=index,
1424
+ namespace=PINECONE_NAMESPACE,
1425
+ query_sparse=sparse_vector,
1426
+ top_k=top_k,
1427
+ filters={"is_ad_page": False},
1428
+ )["result"]["hits"]
1429
+
1430
+ fts_hits = search_fts(
1431
+ index=index,
1432
+ namespace=PINECONE_NAMESPACE,
1433
+ query=query,
1434
+ top_k=top_k,
1435
+ filters={"is_ad_page": False},
1436
+ )["result"]["hits"]
1437
+
1438
+
1439
+ def hits_to_rows(mode, hits):
1440
+ rows = []
1441
+ for rank, hit in enumerate(hits, start=1):
1442
+ fields = hit.get("fields", {})
1443
+ rows.append({
1444
+ "mode": mode,
1445
+ "rank": rank,
1446
+ "panel_id": hit.get("_id"),
1447
+ "score": hit.get("_score"),
1448
+ "ocr_text": fields.get("ocr_text"),
1449
+ "image_path": fields.get("image_path"),
1450
+ "comic_id": fields.get("comic_id"),
1451
+ "page_num": fields.get("page_num"),
1452
+ "panel_num": fields.get("panel_num"),
1453
+ })
1454
+ return rows
1455
+
1456
+
1457
+ df = pd.DataFrame(
1458
+ hits_to_rows("dense", dense_hits)
1459
+ + hits_to_rows("sparse", sparse_hits)
1460
+ + hits_to_rows("fts", fts_hits)
1461
+ )
1462
+
1463
+ df
1464
+ ```
1465
+
1466
+ ### Metadata filtering examples
1467
+
1468
+ ```python
1469
+ filters = {
1470
+ "is_ad_page": False
1471
+ }
1472
+ ```
1473
+
1474
+ ```python
1475
+ filters = {
1476
+ "comic_id": "some_comic_id",
1477
+ "is_ad_page": False
1478
+ }
1479
+ ```
1480
+
1481
+ ```python
1482
+ filters = {
1483
+ "page_num": {"$gte": 5, "$lte": 20},
1484
+ "is_ad_page": False
1485
+ }
1486
+ ```
1487
+
1488
+ ### Manual evaluation section
1489
+
1490
+ ```python
1491
+ test_queries = [
1492
+ '"secret formula"',
1493
+ "BANG",
1494
+ "a detective in a dark alley",
1495
+ "a superhero flying through the sky",
1496
+ "masked villain laboratory",
1497
+ "a laboratory panel containing formula",
1498
+ ]
1499
+
1500
+ for q in test_queries:
1501
+ print("=" * 80)
1502
+ print("Query:", q)
1503
+
1504
+ route = route_query(q)
1505
+ dense_vector = embed_query_dense(q) if route["dense"] else None
1506
+ sparse_vector = embed_query_sparse(q) if route["sparse"] else None
1507
+
1508
+ hits = search_combined(
1509
+ index=index,
1510
+ namespace=PINECONE_NAMESPACE,
1511
+ query_text=q,
1512
+ dense_query_vector=dense_vector,
1513
+ sparse_query_vector=sparse_vector,
1514
+ top_k=5,
1515
+ filters={"is_ad_page": False},
1516
+ run_fts=route["fts"],
1517
+ )
1518
+
1519
+ display_results(hits, max_items=5)
1520
+ ```
1521
+
1522
+ ## README command flow
1523
+
1524
+ The README should let a new developer run:
1525
+
1526
+ ```bash
1527
+ python -m src.ingest.build_manifest
1528
+ python -m src.embeddings.embed_images
1529
+ python -m src.embeddings.embed_sparse_text
1530
+ python -m src.pinecone.create_index
1531
+ python -m src.pinecone.build_documents
1532
+ python -m src.pinecone.upsert_panels
1533
+ ```
1534
+
1535
+ Then launch the query notebook:
1536
+
1537
+ ```bash
1538
+ jupyter lab notebooks/query_comics_index.ipynb
1539
+ ```
1540
+
1541
+ or:
1542
+
1543
+ ```bash
1544
+ jupyter notebook notebooks/query_comics_index.ipynb
1545
+ ```
1546
+
1547
+ Optional search smoke tests:
1548
+
1549
+ ```bash
1550
+ python -m src.search.search_fts --query '"secret formula"'
1551
+ python -m src.search.search_dense --query "a detective in a dark alley"
1552
+ python -m src.search.search_combined --query "a laboratory panel containing formula"
1553
+ ```
1554
+
1555
+ ## Non-goals
1556
+
1557
+ Do not build:
1558
+
1559
+ - UI
1560
+ - Streamlit app
1561
+ - FastAPI app
1562
+ - Story builder
1563
+ - Panel sequencing system
1564
+ - Generative story construction
1565
+ - Fine-tuning pipeline
1566
+ - ComicsPAP task evaluation
1567
+ - Page-level search as the primary retrieval unit
1568
+
1569
+ ## Final deliverables
1570
+
1571
+ The agent should produce:
1572
+
1573
+ 1. COMICS download instructions.
1574
+ 2. Manifest builder.
1575
+ 3. OCR cleaning module.
1576
+ 4. Dense image embedding script.
1577
+ 5. Sparse text embedding script.
1578
+ 6. Pinecone document-schema index creation script.
1579
+ 7. Pinecone document builder.
1580
+ 8. Pinecone upsert script.
1581
+ 9. Dense search primitive.
1582
+ 10. Sparse search primitive.
1583
+ 11. FTS search primitive.
1584
+ 12. Combined search primitive with RRF.
1585
+ 13. Query router.
1586
+ 14. Jupyter notebook for querying the Pinecone index.
1587
+ 15. README command flow.
1588
+ 16. `config.yaml`.
1589
+ 17. `requirements.txt`.
1590
+
1591
+ ## Acceptance criteria
1592
+
1593
+ The build is complete when:
1594
+
1595
+ - The COMICS panel manifest exists.
1596
+ - Each panel has a deterministic `panel_id`.
1597
+ - Raw and cleaned OCR fields exist.
1598
+ - Panel image embeddings have been generated and saved.
1599
+ - Sparse text embeddings have been generated and saved where text exists.
1600
+ - A Pinecone document-schema index exists with:
1601
+ - `image_dense`
1602
+ - `text_sparse`
1603
+ - `ocr_text`
1604
+ - `search_text`
1605
+ - Documents have been upserted into the configured namespace.
1606
+ - Dense search returns relevant visual/conceptual panels.
1607
+ - Sparse search returns relevant text-heavy panels.
1608
+ - FTS search returns panels with matching dialogue, names, phrases, or sound effects.
1609
+ - Combined search merges dense, sparse, and FTS results with RRF.
1610
+ - The Jupyter notebook can run each search mode and display panel images inline.
utils/downloader.py DELETED
@@ -1,24 +0,0 @@
1
- """Download comic archives from URLs to data/raw/."""
2
- import hashlib
3
- from pathlib import Path
4
-
5
- import requests
6
-
7
-
8
- def download_comic(url: str, raw_dir: Path, filename: str | None = None) -> Path:
9
- """Download a CBZ/ZIP from url into raw_dir. Returns the local path."""
10
- raw_dir.mkdir(parents=True, exist_ok=True)
11
- if filename is None:
12
- filename = url.split("/")[-1].split("?")[0] or hashlib.md5(url.encode()).hexdigest() + ".cbz"
13
- dest = raw_dir / filename
14
- if dest.exists():
15
- print(f" already downloaded: {dest.name}")
16
- return dest
17
- print(f" downloading {filename}...")
18
- resp = requests.get(url, stream=True, timeout=60)
19
- resp.raise_for_status()
20
- with open(dest, "wb") as f:
21
- for chunk in resp.iter_content(chunk_size=65536):
22
- f.write(chunk)
23
- print(f" saved → {dest}")
24
- return dest
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
utils/embedders.py DELETED
@@ -1,48 +0,0 @@
1
- """Shared embedding helpers for all notebooks."""
2
- import base64
3
- import os
4
- from io import BytesIO
5
- from pathlib import Path
6
-
7
- import voyageai
8
- from PIL import Image
9
-
10
-
11
- def _image_to_base64(image_path: str | Path) -> str:
12
- img = Image.open(image_path).convert("RGB")
13
- buf = BytesIO()
14
- img.save(buf, format="JPEG", quality=85)
15
- return base64.b64encode(buf.getvalue()).decode()
16
-
17
-
18
- def embed_images_voyage(
19
- image_paths: list[str | Path],
20
- batch_size: int = 10,
21
- ) -> list[list[float]]:
22
- """Embed comic page images with voyage-multimodal-3. Returns list of 1024-dim vectors."""
23
- vo = voyageai.Client()
24
- all_vectors = []
25
- for i in range(0, len(image_paths), batch_size):
26
- batch = image_paths[i : i + batch_size]
27
- inputs = []
28
- for p in batch:
29
- img = Image.open(p).convert("RGB")
30
- inputs.append([img])
31
- result = vo.multimodal_embed(inputs, model="voyage-multimodal-3", input_type="document")
32
- all_vectors.extend(result.embeddings)
33
- return all_vectors
34
-
35
-
36
- def embed_query_voyage(query_text: str) -> list[float]:
37
- """Embed a text query with voyage-multimodal-3 for cross-modal search."""
38
- vo = voyageai.Client()
39
- result = vo.multimodal_embed([[query_text]], model="voyage-multimodal-3", input_type="query")
40
- return result.embeddings[0]
41
-
42
-
43
- def embed_query_image_voyage(image_path: str | Path) -> list[float]:
44
- """Embed an image as a query with voyage-multimodal-3 for image-to-image search."""
45
- vo = voyageai.Client()
46
- img = Image.open(image_path).convert("RGB")
47
- result = vo.multimodal_embed([[img]], model="voyage-multimodal-3", input_type="query")
48
- return result.embeddings[0]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
utils/extractor.py DELETED
@@ -1,36 +0,0 @@
1
- """Extract pages from CBZ/ZIP comic archives into data/pages/<comic_slug>/."""
2
- import zipfile
3
- from pathlib import Path
4
-
5
-
6
- def slugify(name: str) -> str:
7
- return name.lower().replace(" ", "_").replace("-", "_")
8
-
9
-
10
- def extract_comic(archive_path: Path, pages_dir: Path) -> list[dict]:
11
- """Extract image pages from a CBZ/ZIP archive. Returns a list of page dicts."""
12
- slug = slugify(archive_path.stem)
13
- out_dir = pages_dir / slug
14
- out_dir.mkdir(parents=True, exist_ok=True)
15
-
16
- image_exts = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
17
- pages = []
18
-
19
- with zipfile.ZipFile(archive_path) as zf:
20
- image_names = sorted(
21
- n for n in zf.namelist()
22
- if Path(n).suffix.lower() in image_exts and not Path(n).name.startswith(".")
23
- )
24
- for i, name in enumerate(image_names, start=1):
25
- suffix = Path(name).suffix.lower()
26
- dest = out_dir / f"page_{i:03d}{suffix}"
27
- if not dest.exists():
28
- dest.write_bytes(zf.read(name))
29
- pages.append({
30
- "comic_slug": slug,
31
- "comic_title": archive_path.stem,
32
- "page_num": i,
33
- "file_path": str(dest),
34
- })
35
-
36
- return pages