Spaces:
Sleeping
restructure project: pivot from multi-approach notebooks to focused COMICS panel search build
Browse filesThe 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 +0 -143
- 01_transcription.ipynb +0 -200
- 02_approach1_text_dense_fts.ipynb +0 -289
- 03_approach2_image_embeddings.ipynb +0 -248
- 04_approach3_hybrid.ipynb +0 -266
- 05_story_builder.ipynb +0 -251
- 06_panel_level_exploration.ipynb +0 -333
- PLAN.md +0 -311
- README.md +127 -0
- config.yaml +28 -0
- notebooks/query_comics_index.ipynb +394 -0
- requirements.txt +4 -2
- {utils → src}/__init__.py +0 -0
- comics-idx.ipynb → src/embeddings/__init__.py +0 -0
- src/embeddings/embed_images.py +72 -0
- src/embeddings/embed_sparse_text.py +74 -0
- src/ingest/__init__.py +0 -0
- src/ingest/build_manifest.py +223 -0
- src/ingest/clean_text.py +0 -0
- src/pinecone/__init__.py +0 -0
- src/pinecone/build_documents.py +67 -0
- src/pinecone/create_index.py +55 -0
- src/pinecone/upsert_panels.py +84 -0
- src/search/__init__.py +0 -0
- src/search/fusion.py +42 -0
- src/search/router.py +36 -0
- src/search/search_combined.py +82 -0
- src/search/search_dense.py +68 -0
- src/search/search_fts.py +47 -0
- src/search/search_sparse.py +53 -0
- updated-build.md +1610 -0
- utils/downloader.py +0 -24
- utils/embedders.py +0 -48
- utils/extractor.py +0 -36
|
@@ -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 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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`
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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 |
|
|
@@ -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
|
|
@@ -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 |
+
}
|
|
@@ -1,11 +1,13 @@
|
|
| 1 |
pinecone>=6.0.0
|
| 2 |
-
|
| 3 |
-
|
| 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
|
|
File without changes
|
|
File without changes
|
|
@@ -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()
|
|
@@ -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()
|
|
File without changes
|
|
@@ -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()
|
|
Binary file (588 Bytes). View file
|
|
|
|
File without changes
|
|
@@ -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()
|
|
@@ -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()
|
|
@@ -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()
|
|
File without changes
|
|
@@ -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
|
|
@@ -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 |
+
}
|
|
@@ -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()
|
|
@@ -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()
|
|
@@ -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()
|
|
@@ -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()
|
|
@@ -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.
|
|
@@ -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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|