lukkawolff commited on
Commit
830fbdc
·
1 Parent(s): 5084ad7

streamlit app

Browse files
.streamlit/config.toml ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ [theme]
2
+ base = "light"
3
+ primaryColor = "#002BFF"
4
+ backgroundColor = "#FAFAFA"
5
+ secondaryBackgroundColor = "#F0F0F0"
6
+ textColor = "#0A0A0A"
7
+
8
+ [server]
9
+ headless = true
app.py ADDED
@@ -0,0 +1,511 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Comic panel search UI."""
2
+
3
+ import base64
4
+ import os
5
+ import sys
6
+ from io import BytesIO
7
+ from pathlib import Path
8
+
9
+ import streamlit as st
10
+ from dotenv import load_dotenv
11
+ from PIL import Image
12
+
13
+ ROOT = Path(__file__).parent
14
+ sys.path.insert(0, str(ROOT))
15
+ load_dotenv(ROOT / ".env")
16
+
17
+ st.set_page_config(
18
+ page_title="Comic Panel Search",
19
+ page_icon="💥",
20
+ layout="wide",
21
+ initial_sidebar_state="collapsed",
22
+ )
23
+
24
+ st.markdown("""
25
+ <style>
26
+ #MainMenu, footer, [data-testid="collapsedControl"] { visibility: hidden; }
27
+ .block-container { padding-top: 1.5rem !important; max-width: 1400px !important; }
28
+
29
+ /* result card */
30
+ .rc {
31
+ background: #fff;
32
+ border: 1px solid #e5e5e5;
33
+ border-radius: 8px;
34
+ padding: 18px 20px;
35
+ display: grid;
36
+ grid-template-columns: 62px 1fr;
37
+ gap: 18px;
38
+ margin-bottom: 10px;
39
+ }
40
+ .rc:hover { box-shadow: 0 2px 10px rgba(0,0,0,0.07); }
41
+ .rc-left {
42
+ display: flex;
43
+ flex-direction: column;
44
+ align-items: center;
45
+ gap: 8px;
46
+ padding-top: 2px;
47
+ }
48
+ .rc-rank {
49
+ width: 40px; height: 40px;
50
+ background: rgba(0,43,255,0.05);
51
+ border-radius: 6px;
52
+ display: flex; align-items: center; justify-content: center;
53
+ font-weight: 700; font-size: 15px; color: #0a0a0a;
54
+ flex-shrink: 0;
55
+ }
56
+ .rc-score-lbl {
57
+ font-size: 10px; font-weight: 600; text-transform: uppercase;
58
+ letter-spacing: 0.06em; color: #737373; text-align: center;
59
+ }
60
+ .rc-score-val {
61
+ font-family: monospace; font-size: 12px;
62
+ color: #0a0a0a; text-align: center;
63
+ }
64
+ .rc-tag {
65
+ font-size: 10px; background: rgba(0,43,255,0.06);
66
+ color: #002BFF; border-radius: 4px;
67
+ padding: 2px 5px; font-weight: 600;
68
+ display: inline-block; margin: 1px;
69
+ }
70
+ .rc-right {
71
+ display: flex; flex-direction: column; gap: 8px; min-width: 0;
72
+ }
73
+ .rc-img {
74
+ max-width: 100%; max-height: 200px;
75
+ width: auto; height: auto;
76
+ object-fit: contain; border-radius: 4px;
77
+ display: block; background: #f5f5f5;
78
+ }
79
+ .rc-id {
80
+ font-family: monospace; font-size: 13px;
81
+ font-weight: 600; color: #0a0a0a;
82
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
83
+ }
84
+ .rc-field {
85
+ display: grid; grid-template-columns: 95px 1fr;
86
+ gap: 6px; font-size: 12px; line-height: 1.5;
87
+ }
88
+ .rc-key {
89
+ color: #737373; font-family: monospace;
90
+ font-size: 11px; overflow: hidden;
91
+ text-overflow: ellipsis; white-space: nowrap;
92
+ }
93
+ .rc-val {
94
+ color: #0a0a0a; word-break: break-word;
95
+ }
96
+ .rh {
97
+ font-size: 13px; color: #737373;
98
+ padding-bottom: 12px;
99
+ border-bottom: 1px solid #e5e5e5;
100
+ margin-bottom: 14px;
101
+ }
102
+ .rh strong { color: #0a0a0a; }
103
+ .ph {
104
+ text-align: center; padding: 72px 0;
105
+ color: #737373; font-size: 14px;
106
+ }
107
+ .section-label {
108
+ font-size: 11px; font-weight: 600;
109
+ text-transform: uppercase; letter-spacing: 0.08em;
110
+ color: #737373; margin: 0;
111
+ }
112
+ </style>
113
+ """, unsafe_allow_html=True)
114
+
115
+ # ── constants & resources ─────────────────────────────────────────────────────
116
+
117
+ NAMESPACE = os.environ.get("PINECONE_NAMESPACE", "comics-v1")
118
+ SHOW_FIELDS = ["comic_id", "book_id", "page_num", "panel_num", "source", "ocr_text"]
119
+
120
+ OPS = ["==", "!=", ">", ">=", "<", "<=", "In", "Not In", "Match phrase", "Match all", "Match any"]
121
+ _MATCH_OPS = {"Match phrase", "Match all", "Match any"}
122
+ _OP_MAP = {
123
+ "==": "$eq", "!=": "$ne",
124
+ ">": "$gt", ">=": "$gte",
125
+ "<": "$lt", "<=": "$lte",
126
+ "In": "$in", "Not In": "$nin",
127
+ "Match phrase": "$match_phrase",
128
+ "Match all": "$match_all",
129
+ "Match any": "$match_any",
130
+ }
131
+ ALL_FIELDS = ["comic_id", "book_id", "page_num", "panel_num", "source", "ocr_text", "image_path", "is_ad_page"]
132
+
133
+ @st.cache_resource(show_spinner="Connecting to Pinecone…")
134
+ def _index():
135
+ from pinecone import Pinecone
136
+ pc = Pinecone(api_key=os.environ["PINECONE_API_KEY"])
137
+ return pc.preview.index(name=os.environ.get("PINECONE_INDEX_NAME", "comic-panels"))
138
+
139
+ @st.cache_resource(show_spinner="Loading CLIP model…")
140
+ def _load_clip():
141
+ from src.embeddings.embed_images import _init_model
142
+ _init_model()
143
+
144
+ # ── helpers ───────────────────────────────────────────────────────────────────
145
+
146
+ def _b64(path: str) -> str | None:
147
+ try:
148
+ p = Path(path)
149
+ if not p.is_absolute():
150
+ p = ROOT / p
151
+ img = Image.open(p).convert("RGB")
152
+ scale = min(1.0, 500 / img.width)
153
+ img = img.resize((int(img.width * scale), int(img.height * scale)), Image.LANCZOS)
154
+ buf = BytesIO()
155
+ img.save(buf, "JPEG", quality=82)
156
+ return base64.b64encode(buf.getvalue()).decode()
157
+ except Exception:
158
+ return None
159
+
160
+ def _esc(s: str) -> str:
161
+ return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
162
+
163
+ def _coerce(s: str):
164
+ try: return int(s)
165
+ except ValueError: pass
166
+ try: return float(s)
167
+ except ValueError: pass
168
+ if s.lower() in ("true", "yes"): return True
169
+ if s.lower() in ("false", "no"): return False
170
+ return s
171
+
172
+ def _add_filter():
173
+ fid = st.session_state.filter_next_id
174
+ st.session_state.filter_rows = st.session_state.filter_rows + [fid]
175
+ st.session_state.filter_next_id += 1
176
+
177
+ def _remove_filter(fid: int):
178
+ st.session_state.filter_rows = [r for r in st.session_state.filter_rows if r != fid]
179
+
180
+ def _build_filters() -> dict:
181
+ row_conds = []
182
+ for fid in st.session_state.filter_rows:
183
+ k = st.session_state.get(f"fk_{fid}", "").strip()
184
+ op = st.session_state.get(f"fo_{fid}", "==")
185
+ val_str = st.session_state.get(f"fv_{fid}", "").strip()
186
+ if not k or not val_str:
187
+ continue
188
+ if op in ("In", "Not In"):
189
+ vals = [_coerce(v.strip()) for v in val_str.split(",")]
190
+ row_conds.append({k: {"$in" if op == "In" else "$nin": vals}})
191
+ elif op in _MATCH_OPS:
192
+ row_conds.append({k: {_OP_MAP[op]: val_str}})
193
+ else:
194
+ row_conds.append({k: {_OP_MAP[op]: _coerce(val_str)}})
195
+
196
+ combinator = st.session_state.get("filter_combinator", "And")
197
+ if len(row_conds) == 0:
198
+ user_filter = None
199
+ elif len(row_conds) == 1:
200
+ user_filter = row_conds[0]
201
+ elif combinator == "Or":
202
+ user_filter = {"$or": row_conds}
203
+ else:
204
+ user_filter = {"$and": row_conds}
205
+
206
+ exclude = st.session_state.get("exclude_ads", True)
207
+ if exclude and user_filter:
208
+ return {"$and": [{"is_ad_page": {"$eq": False}}, user_filter]}
209
+ elif exclude:
210
+ return {"is_ad_page": {"$eq": False}}
211
+ elif user_filter:
212
+ return user_filter
213
+ return {}
214
+
215
+ def _card(hit: dict, rank: int, show_fields: list):
216
+ score = hit.get("_score") or hit.get("rrf_score") or 0.0
217
+ sources = hit.get("sources") or []
218
+ hit_id = _esc(str(hit.get("_id", "—")))
219
+
220
+ tags = "".join(f'<span class="rc-tag">{s}</span>' for s in sources)
221
+
222
+ img_html = ""
223
+ if b64 := _b64(hit.get("image_path", "")):
224
+ img_html = f'<img class="rc-img" src="data:image/jpeg;base64,{b64}"/>'
225
+
226
+ fields = ""
227
+ for k in show_fields:
228
+ v = hit.get(k)
229
+ if v is None or str(v).strip() == "":
230
+ continue
231
+ s = str(v)
232
+ if len(s) > 500:
233
+ s = s[:500] + "…"
234
+ fields += f'<div class="rc-field"><span class="rc-key">{k}</span><span class="rc-val">{_esc(s)}</span></div>'
235
+
236
+ st.markdown(f"""
237
+ <div class="rc">
238
+ <div class="rc-left">
239
+ <div class="rc-rank">{rank}</div>
240
+ <div class="rc-score-lbl">Score</div>
241
+ <div class="rc-score-val">{score:.4f}</div>
242
+ {tags}
243
+ </div>
244
+ <div class="rc-right">
245
+ {img_html}
246
+ <div class="rc-id">_id: {hit_id}</div>
247
+ {fields}
248
+ </div>
249
+ </div>""", unsafe_allow_html=True)
250
+
251
+ # ── example queries ───────────────────────────────────────────────────────────
252
+
253
+ EXAMPLES = {
254
+ "🎨 Visual (Dense)": [
255
+ ("Hero flying", "hero in cape flying through sky"),
256
+ ("Fistfight", "two men punching fighting brawl"),
257
+ ("Explosion", "explosion fire destruction chaos"),
258
+ ("Scientist in lab", "scientist laboratory chemicals"),
259
+ ("Rocket in space", "spaceship rocket outer space stars"),
260
+ ("Villain menacing", "villain sinister evil grin"),
261
+ ("Romance", "couple romance embrace kissing"),
262
+ ("Detective", "detective investigating crime scene"),
263
+ ("Car chase", "car chase street pursuit"),
264
+ ("Monster horror", "monster creature attacking horror"),
265
+ ("Woman captured", "woman captured tied up danger"),
266
+ ("Man with gun", "man pointing gun threatening"),
267
+ ],
268
+ "🔤 Keyword (Sparse)": [
269
+ ("Secret formula", "secret formula"),
270
+ ("Help / danger", "help me danger"),
271
+ ("Villain escape", "villain escape capture"),
272
+ ("Mysterious stranger", "mysterious stranger"),
273
+ ("Great power", "great power"),
274
+ ("Bank robbery", "robbery bank heist money"),
275
+ ("Crime mystery", "crime mystery clue evidence"),
276
+ ("Space adventure", "space adventure rocket planet"),
277
+ ],
278
+ "💥 Sound effects (FTS)": [
279
+ ("POW / BANG / ZAP", "BANG OR POW OR ZAP"),
280
+ ("KAPOW / WHAM / CRASH", "KAPOW OR WHAM OR CRASH"),
281
+ ("BOOM / BLAST", "BOOM OR BLAST OR KA-BOOM"),
282
+ ("THUD / SMASH", "THUD OR CRUNCH OR SMASH"),
283
+ ],
284
+ "💬 Phrases (FTS)": [
285
+ ('"secret formula"', '"secret formula"'),
286
+ ('"great danger"', '"great danger"'),
287
+ ("detective AND murder", "detective AND murder"),
288
+ ("villain AND escape", "villain AND escape"),
289
+ ("danger AND rescue", "danger AND rescue"),
290
+ ],
291
+ }
292
+
293
+ # ── session state ─────────────────────────────────────────────────────────────
294
+
295
+ for k, v in {
296
+ "fts_on": True, "dense_on": False, "sparse_on": False,
297
+ "fts_q": "*", "dense_q": "", "sparse_q": "",
298
+ "fts_type": "Query string",
299
+ "top_k": 20, "exclude_ads": True,
300
+ "filter_rows": [], "filter_next_id": 0, "filter_combinator": "And",
301
+ "show_fields": ["ocr_text"],
302
+ "results": None, "result_meta": {},
303
+ "run": False,
304
+ }.items():
305
+ st.session_state.setdefault(k, v)
306
+
307
+
308
+ def _use_example(query: str, signal: str):
309
+ """on_click callback — runs before rerun so widget keys can be set safely."""
310
+ if signal == "dense":
311
+ st.session_state.dense_on = True
312
+ st.session_state.dense_q = query
313
+ elif signal == "sparse":
314
+ st.session_state.sparse_on = True
315
+ st.session_state.sparse_q = query
316
+ else:
317
+ st.session_state.fts_on = True
318
+ st.session_state.fts_q = query
319
+ st.session_state.run = True
320
+
321
+ # ── header ────────────────────────────────────────────────────────────────────
322
+
323
+ st.markdown("## Browse your index")
324
+ st.caption(f"comic-panels · 1,229,664 panels · namespace: {NAMESPACE}")
325
+
326
+ # ── two-column layout ─────────────────────────────────────────────────────────
327
+
328
+ left, right = st.columns([1, 2.8], gap="large")
329
+
330
+ with left:
331
+ # ── Search signals ────────────────────────────────────────────────────
332
+ st.markdown('<p class="section-label">Search signals</p>', unsafe_allow_html=True)
333
+
334
+ # Full-text
335
+ fts_on = st.toggle("Full-text search", key="fts_on")
336
+ if fts_on:
337
+ st.selectbox("Type", ["Query string", "BM25 (text)"], key="fts_type",
338
+ label_visibility="collapsed")
339
+ st.text_input(
340
+ "fts_query", key="fts_q", label_visibility="collapsed",
341
+ placeholder='"phrase" OR keyword · BANG OR POW · field AND value',
342
+ )
343
+
344
+ # Dense
345
+ dense_on = st.toggle("Dense — visual similarity", key="dense_on")
346
+ if dense_on:
347
+ st.text_input(
348
+ "dense_query", key="dense_q", label_visibility="collapsed",
349
+ placeholder="describe what you see: hero flying through sky",
350
+ help="Embedded with OpenCLIP ViT-B/16",
351
+ )
352
+
353
+ # Sparse
354
+ sparse_on = st.toggle("Sparse — keyword expansion", key="sparse_on")
355
+ if sparse_on:
356
+ st.text_input(
357
+ "sparse_query", key="sparse_q", label_visibility="collapsed",
358
+ placeholder="keywords: secret formula villain escape",
359
+ help="Embedded with pinecone-sparse-english-v0",
360
+ )
361
+
362
+ st.divider()
363
+
364
+ # ── Filters ───────────────────────────────────────────────────────────
365
+ fhdr_l, fhdr_r = st.columns([1, 2])
366
+ with fhdr_l:
367
+ st.markdown('<p class="section-label">Filters</p>', unsafe_allow_html=True)
368
+ if st.session_state.filter_rows:
369
+ with fhdr_r:
370
+ st.selectbox("Combine", ["And", "Or"], key="filter_combinator",
371
+ label_visibility="collapsed",
372
+ help="How to combine multiple filter conditions")
373
+
374
+ for fid in st.session_state.filter_rows:
375
+ op = st.session_state.get(f"fo_{fid}", "==")
376
+ op_w = max(2.0, min(3.2, len(op) * 0.25))
377
+ fv_w = (6.4 - op_w - 0.4) / 2
378
+ c1, c2, c3, c4 = st.columns([fv_w, op_w, fv_w, 0.4])
379
+ with c1:
380
+ st.text_input("Key", key=f"fk_{fid}",
381
+ label_visibility="collapsed", placeholder="field")
382
+ with c2:
383
+ st.selectbox("Op", OPS, key=f"fo_{fid}", label_visibility="collapsed")
384
+ with c3:
385
+ st.text_input("Value", key=f"fv_{fid}",
386
+ label_visibility="collapsed", placeholder="value")
387
+ with c4:
388
+ st.button("✕", key=f"fr_{fid}",
389
+ on_click=_remove_filter, args=(fid,),
390
+ use_container_width=True)
391
+
392
+ st.button("+ Add filter", key="add_filter",
393
+ on_click=_add_filter, use_container_width=True)
394
+ st.checkbox("Exclude ad pages", key="exclude_ads")
395
+
396
+ st.divider()
397
+
398
+ # ── Results ───────────────────────────────────────────────────────────
399
+ st.markdown('<p class="section-label">Results</p>', unsafe_allow_html=True)
400
+ st.number_input("Top K", min_value=1, max_value=200, step=5, key="top_k")
401
+ st.multiselect("Fields", ALL_FIELDS, key="show_fields",
402
+ label_visibility="collapsed",
403
+ placeholder="Fields to display in results")
404
+
405
+ st.write("")
406
+ if st.button("Run query", type="primary", use_container_width=True):
407
+ st.session_state.run = True
408
+
409
+ st.divider()
410
+
411
+ # ── Examples ──────────────────────────────────────────────────────────
412
+ with st.expander("💡 Example queries"):
413
+ for category, items in EXAMPLES.items():
414
+ st.markdown(f'<p class="section-label" style="margin:8px 0 4px">{category}</p>',
415
+ unsafe_allow_html=True)
416
+ for label, query in items:
417
+ if "Visual" in category:
418
+ sig = "dense"
419
+ elif "Keyword" in category:
420
+ sig = "sparse"
421
+ else:
422
+ sig = "fts"
423
+ st.button(label, key=f"ex·{label}", use_container_width=True,
424
+ on_click=_use_example, args=(query, sig))
425
+
426
+ # ── run search ────────────────────────────────────────────────────────────────
427
+
428
+ if st.session_state.run:
429
+ st.session_state.run = False
430
+
431
+ fts_on = st.session_state.fts_on
432
+ dense_on = st.session_state.dense_on
433
+ sparse_on = st.session_state.sparse_on
434
+
435
+ if not any([fts_on, dense_on, sparse_on]):
436
+ st.session_state.result_meta = {"error": "Enable at least one search signal."}
437
+ st.session_state.results = []
438
+ else:
439
+ _load_clip()
440
+ idx = _index()
441
+ filters = _build_filters()
442
+ top_k = int(st.session_state.top_k)
443
+
444
+ from src.search.search_dense import search_dense
445
+ from src.search.search_sparse import search_sparse
446
+ from src.search.search_fts import search_fts
447
+ from src.search.fusion import rrf_merge
448
+
449
+ groups, error = [], None
450
+ try:
451
+ if dense_on and st.session_state.dense_q.strip():
452
+ from src.embeddings.embed_images import embed_text_query
453
+ vec = embed_text_query(st.session_state.dense_q.strip())
454
+ r = search_dense(idx, NAMESPACE, vec, top_k=top_k, filters=filters)
455
+ groups.append(("dense", r.get("result", {}).get("hits", [])))
456
+
457
+ if sparse_on and st.session_state.sparse_q.strip():
458
+ from src.embeddings.embed_sparse_text import embed_sparse_query
459
+ svec = embed_sparse_query(st.session_state.sparse_q.strip())
460
+ if svec:
461
+ r = search_sparse(idx, NAMESPACE, svec, top_k=top_k, filters=filters)
462
+ groups.append(("sparse", r.get("result", {}).get("hits", [])))
463
+
464
+ if fts_on and st.session_state.fts_q.strip():
465
+ r = search_fts(idx, NAMESPACE, st.session_state.fts_q.strip(),
466
+ top_k=top_k, filters=filters)
467
+ groups.append(("fts", r.get("result", {}).get("hits", [])))
468
+
469
+ except Exception as exc:
470
+ error = str(exc)
471
+
472
+ if error:
473
+ st.session_state.results = []
474
+ st.session_state.result_meta = {"error": error}
475
+ elif not groups:
476
+ st.session_state.results = []
477
+ st.session_state.result_meta = {"info": "No query text provided for the enabled signals."}
478
+ else:
479
+ merged = rrf_merge(groups) if len(groups) > 1 else groups[0][1]
480
+ src = "hybrid · RRF" if len(groups) > 1 else groups[0][0]
481
+ st.session_state.results = merged[:top_k]
482
+ st.session_state.result_meta = {"source": src, "top_k": top_k}
483
+
484
+ # ── results panel ─────────────────────────────────────────────────────────────
485
+
486
+ with right:
487
+ results = st.session_state.results
488
+ meta = st.session_state.result_meta
489
+
490
+ if meta.get("error"):
491
+ st.error(meta["error"])
492
+ elif meta.get("info"):
493
+ st.warning(meta["info"])
494
+ elif results is None:
495
+ st.markdown('<div class="ph">Configure your query and click <strong>Run query</strong></div>',
496
+ unsafe_allow_html=True)
497
+ elif len(results) == 0:
498
+ st.markdown('<div class="ph">No results — try adjusting your query or enabling more signals.</div>',
499
+ unsafe_allow_html=True)
500
+ else:
501
+ src = meta.get("source", "")
502
+ tk = meta.get("top_k", "?")
503
+ st.markdown(
504
+ f'<div class="rh">Search: <strong>{len(results)} results</strong>'
505
+ f'&nbsp;&nbsp;·&nbsp;&nbsp;top_k={tk}'
506
+ f'&nbsp;&nbsp;·&nbsp;&nbsp;{src}</div>',
507
+ unsafe_allow_html=True,
508
+ )
509
+ show_fields = st.session_state.show_fields or SHOW_FIELDS
510
+ for i, hit in enumerate(results, 1):
511
+ _card(hit, i, show_fields)
config.yaml CHANGED
@@ -3,7 +3,7 @@ pinecone:
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
 
3
  namespace: comics-v1
4
 
5
  dense_embedding:
6
+ model_name: open_clip_ViT-B-16
7
  field_name: image_dense
8
  dimension: 512
9
  metric: cosine
run_pipeline.sh ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5
+ cd "$PROJECT_DIR"
6
+
7
+ set -a && source .env && set +a
8
+
9
+ PYTHON=".venv/bin/python"
10
+ LOG_DIR="logs"
11
+ mkdir -p "$LOG_DIR"
12
+
13
+ log() { echo "[$(date '+%H:%M:%S')] $*" | tee -a "$LOG_DIR/pipeline.log"; }
14
+
15
+ log "=== Pipeline start ==="
16
+
17
+ log "Step 1: build_manifest"
18
+ $PYTHON -m src.ingest.build_manifest 2>&1 | tee -a "$LOG_DIR/build_manifest.log"
19
+ log "Step 1 done"
20
+
21
+ log "Step 2: embed_images"
22
+ $PYTHON -m src.embeddings.embed_images 2>&1 | tee -a "$LOG_DIR/embed_images.log"
23
+ log "Step 2 done"
24
+
25
+ log "Step 3: embed_sparse_text"
26
+ $PYTHON -m src.embeddings.embed_sparse_text 2>&1 | tee -a "$LOG_DIR/embed_sparse_text.log"
27
+ log "Step 3 done"
28
+
29
+ log "Step 4: create_index (skips if exists)"
30
+ $PYTHON -m src.pinecone.create_index 2>&1 | tee -a "$LOG_DIR/create_index.log"
31
+ log "Step 4 done"
32
+
33
+ log "Step 5: build_documents"
34
+ $PYTHON -m src.pinecone.build_documents 2>&1 | tee -a "$LOG_DIR/build_documents.log"
35
+ log "Step 5 done"
36
+
37
+ log "Step 6: upsert_panels"
38
+ $PYTHON -m src.pinecone.upsert_panels 2>&1 | tee -a "$LOG_DIR/upsert_panels.log"
39
+ log "Step 6 done"
40
+
41
+ log "=== Pipeline complete ==="
src/embeddings/embed_images.py CHANGED
@@ -1,4 +1,9 @@
1
- """Generate dense image embeddings for each panel using OpenCLIP."""
 
 
 
 
 
2
 
3
  from pathlib import Path
4
  import os
@@ -6,14 +11,17 @@ 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 = (
19
  "mps" if torch.backends.mps.is_available()
@@ -21,22 +29,51 @@ _DEVICE = (
21
  else "cpu"
22
  )
23
 
24
- _model, _, _preprocess = open_clip.create_model_and_transforms(_ARCH, pretrained="openai")
25
- _model.eval()
26
- _model = _model.to(_DEVICE)
27
- _tokenizer = open_clip.get_tokenizer(_ARCH)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
 
 
 
29
 
30
- def embed_image(image: Image.Image) -> list[float]:
31
- tensor = _preprocess(image).unsqueeze(0).to(_DEVICE)
 
 
 
 
 
 
 
 
 
32
  with torch.no_grad():
33
- features = _model.encode_image(tensor)
34
  features = features / features.norm(dim=-1, keepdim=True)
35
- return features[0].cpu().tolist()
36
 
37
 
38
  def embed_text_query(text: str) -> list[float]:
39
- """Embed a text query into the same vector space as panel images."""
40
  tokens = _tokenizer([text]).to(_DEVICE)
41
  with torch.no_grad():
42
  features = _model.encode_text(tokens)
@@ -44,39 +81,113 @@ def embed_text_query(text: str) -> list[float]:
44
  return features[0].cpu().tolist()
45
 
46
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  def main():
48
  import argparse
49
  parser = argparse.ArgumentParser()
50
- parser.add_argument("--limit", type=int, default=None, help="Cap number of panels to embed (for dry runs)")
 
 
 
51
  args = parser.parse_args()
52
 
 
 
 
 
 
 
 
53
  manifest = pd.read_parquet(MANIFEST_PATH)
54
  if args.limit:
55
  manifest = manifest.head(args.limit)
56
- print(f"Embedding {len(manifest)} panels with {_ARCH} on {_DEVICE}")
57
 
58
- rows = []
59
- failures = []
60
-
61
- for row in tqdm(manifest.itertuples(index=False), total=len(manifest)):
62
- try:
63
- image = Image.open(row.image_path).convert("RGB")
64
- vector = embed_image(image)
65
- rows.append({"panel_id": row.panel_id, "image_dense": vector})
66
- except Exception as exc:
67
- failures.append({
68
- "panel_id": row.panel_id,
69
- "image_path": row.image_path,
70
- "error": str(exc),
71
- })
72
-
73
- OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
74
- pd.DataFrame(rows).to_parquet(OUTPUT_PATH, index=False)
75
- print(f"Saved {len(rows)} vectors → {OUTPUT_PATH}")
76
-
77
- if failures:
78
- pd.DataFrame(failures).to_csv(FAILURES_PATH, index=False)
79
- print(f"Failed: {len(failures)} panels → {FAILURES_PATH}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
 
81
 
82
  if __name__ == "__main__":
 
1
+ """Generate dense image embeddings for each panel using OpenCLIP.
2
+
3
+ Processes panels in chunks of CHUNK_SIZE, writing one parquet file per chunk.
4
+ Resumable: skips chunks whose output file already exists.
5
+ Run merge_chunks() at the end or pass --merge to produce the final parquet.
6
+ """
7
 
8
  from pathlib import Path
9
  import os
 
11
  from PIL import Image
12
  from tqdm import tqdm
13
  import torch
14
+ from torch.utils.data import Dataset, DataLoader
15
  import open_clip
16
 
17
 
18
  MANIFEST_PATH = Path("data/comics/processed/panels_manifest.parquet")
19
  OUTPUT_PATH = Path("data/comics/processed/image_dense_vectors.parquet")
20
+ CHUNKS_DIR = Path("data/comics/processed/image_dense_chunks")
21
  FAILURES_PATH = Path("data/comics/processed/image_embedding_failures.csv")
22
+ CHUNK_SIZE = 10_000
23
 
24
+ _MODEL_NAME = os.environ.get("IMAGE_EMBED_MODEL", "open_clip_ViT-B-16")
25
  _ARCH = _MODEL_NAME.replace("open_clip_", "")
26
  _DEVICE = (
27
  "mps" if torch.backends.mps.is_available()
 
29
  else "cpu"
30
  )
31
 
32
+ # Lazy init so DataLoader workers don't each load the model on spawn
33
+ _model = None
34
+ _preprocess = None
35
+ _tokenizer = None
36
+
37
+
38
+ def _init_model():
39
+ global _model, _preprocess, _tokenizer
40
+ if _model is not None:
41
+ return
42
+ model, _, preprocess = open_clip.create_model_and_transforms(_ARCH, pretrained="openai")
43
+ model.eval()
44
+ model = model.to(_DEVICE)
45
+ _model = model
46
+ _preprocess = preprocess
47
+ _tokenizer = open_clip.get_tokenizer(_ARCH)
48
+
49
+
50
+ class PanelDataset(Dataset):
51
+ def __init__(self, image_paths: list[str], preprocess):
52
+ self.image_paths = image_paths
53
+ self.preprocess = preprocess
54
 
55
+ def __len__(self):
56
+ return len(self.image_paths)
57
 
58
+ def __getitem__(self, idx):
59
+ try:
60
+ img = Image.open(self.image_paths[idx]).convert("RGB")
61
+ return self.preprocess(img), True
62
+ except Exception:
63
+ return torch.zeros(3, 224, 224), False
64
+
65
+
66
+ def embed_images_batch(images: list[Image.Image]) -> list[list[float]]:
67
+ _init_model()
68
+ tensors = torch.stack([_preprocess(img) for img in images]).to(_DEVICE)
69
  with torch.no_grad():
70
+ features = _model.encode_image(tensors)
71
  features = features / features.norm(dim=-1, keepdim=True)
72
+ return features.cpu().tolist()
73
 
74
 
75
  def embed_text_query(text: str) -> list[float]:
76
+ _init_model()
77
  tokens = _tokenizer([text]).to(_DEVICE)
78
  with torch.no_grad():
79
  features = _model.encode_text(tokens)
 
81
  return features[0].cpu().tolist()
82
 
83
 
84
+ def merge_chunks() -> None:
85
+ chunk_files = sorted(CHUNKS_DIR.glob("chunk_*.parquet"))
86
+ if not chunk_files:
87
+ print("No chunk files found to merge")
88
+ return
89
+ print(f"Merging {len(chunk_files)} chunk files...")
90
+ df = pd.concat([pd.read_parquet(f) for f in chunk_files], ignore_index=True)
91
+ df.to_parquet(OUTPUT_PATH, index=False)
92
+ print(f"Merged {len(df):,} vectors → {OUTPUT_PATH}")
93
+
94
+
95
+ def done_chunks() -> set[int]:
96
+ if not CHUNKS_DIR.exists():
97
+ return set()
98
+ return {int(f.stem.split("_")[1]) for f in CHUNKS_DIR.glob("chunk_*.parquet")}
99
+
100
+
101
  def main():
102
  import argparse
103
  parser = argparse.ArgumentParser()
104
+ parser.add_argument("--limit", type=int, default=None)
105
+ parser.add_argument("--batch-size", type=int, default=96)
106
+ parser.add_argument("--num-workers", type=int, default=4)
107
+ parser.add_argument("--merge", action="store_true", help="Merge existing chunks and exit")
108
  args = parser.parse_args()
109
 
110
+ if args.merge:
111
+ merge_chunks()
112
+ return
113
+
114
+ _init_model()
115
+ CHUNKS_DIR.mkdir(parents=True, exist_ok=True)
116
+
117
  manifest = pd.read_parquet(MANIFEST_PATH)
118
  if args.limit:
119
  manifest = manifest.head(args.limit)
 
120
 
121
+ already_done = done_chunks()
122
+ total_chunks = (len(manifest) + CHUNK_SIZE - 1) // CHUNK_SIZE
123
+ remaining = [i for i in range(total_chunks) if i not in already_done]
124
+
125
+ print(f"Total panels: {len(manifest):,}")
126
+ print(f"Total chunks: {total_chunks:,} (chunk_size={CHUNK_SIZE})")
127
+ print(f"Done chunks: {len(already_done):,}")
128
+ print(f"Remaining: {len(remaining):,}")
129
+ print(f"Model: {_ARCH} on {_DEVICE}, batch_size={args.batch_size}, num_workers={args.num_workers}")
130
+
131
+ all_failures = []
132
+
133
+ for chunk_idx in remaining:
134
+ start = chunk_idx * CHUNK_SIZE
135
+ end = min(start + CHUNK_SIZE, len(manifest))
136
+ chunk_df = manifest.iloc[start:end]
137
+
138
+ panel_ids = chunk_df["panel_id"].tolist()
139
+ image_paths = chunk_df["image_path"].tolist()
140
+
141
+ dataset = PanelDataset(image_paths, _preprocess)
142
+ loader = DataLoader(
143
+ dataset,
144
+ batch_size=args.batch_size,
145
+ num_workers=args.num_workers,
146
+ pin_memory=False,
147
+ prefetch_factor=2 if args.num_workers > 0 else None,
148
+ )
149
+
150
+ rows = []
151
+ failures = []
152
+ item_offset = 0
153
+
154
+ pbar = tqdm(loader, desc=f"chunk {chunk_idx+1}/{total_chunks}", leave=False)
155
+ for tensors, valid_flags in pbar:
156
+ batch_len = len(tensors)
157
+ batch_ids = panel_ids[item_offset: item_offset + batch_len]
158
+ batch_paths = image_paths[item_offset: item_offset + batch_len]
159
+ item_offset += batch_len
160
+
161
+ valid_mask = valid_flags.bool()
162
+ valid_indices = valid_mask.nonzero(as_tuple=True)[0]
163
+ invalid_indices = (~valid_mask).nonzero(as_tuple=True)[0].tolist()
164
+
165
+ for i in invalid_indices:
166
+ failures.append({"panel_id": batch_ids[i], "image_path": batch_paths[i], "error": "image load failed"})
167
+
168
+ if valid_indices.numel() > 0:
169
+ valid_tensors = tensors[valid_indices].to(_DEVICE)
170
+ with torch.no_grad():
171
+ features = _model.encode_image(valid_tensors)
172
+ features = features / features.norm(dim=-1, keepdim=True)
173
+ for i, vec in enumerate(features.cpu().tolist()):
174
+ rows.append({"panel_id": batch_ids[valid_indices[i].item()], "image_dense": vec})
175
+
176
+ chunk_path = CHUNKS_DIR / f"chunk_{chunk_idx:06d}.parquet"
177
+ pd.DataFrame(rows).to_parquet(chunk_path, index=False)
178
+ all_failures.extend(failures)
179
+
180
+ del loader # release worker processes and file descriptors before next chunk
181
+
182
+ done_count = len(already_done) + (remaining.index(chunk_idx) + 1)
183
+ print(f" chunk {chunk_idx:06d}: {len(rows)} vectors saved ({done_count}/{total_chunks} chunks done)")
184
+
185
+ if all_failures:
186
+ pd.DataFrame(all_failures).to_csv(FAILURES_PATH, index=False)
187
+ print(f"Failures: {len(all_failures)} → {FAILURES_PATH}")
188
+
189
+ print("All chunks complete. Merging...")
190
+ merge_chunks()
191
 
192
 
193
  if __name__ == "__main__":
src/embeddings/embed_sparse_text.py CHANGED
@@ -1,7 +1,12 @@
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
@@ -9,65 +14,112 @@ from pinecone import Pinecone
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__":
 
1
+ """Generate sparse text embeddings for each panel using Pinecone inference.
2
+
3
+ Processes panels in chunks of CHUNK_SIZE, writing one parquet file per chunk.
4
+ Resumable: skips chunks whose output file already exists.
5
+ """
6
 
7
  from pathlib import Path
8
  import os
9
+ import time
10
  import pandas as pd
11
  from tqdm import tqdm
12
  from pinecone import Pinecone
 
14
 
15
  MANIFEST_PATH = Path("data/comics/processed/panels_manifest.parquet")
16
  OUTPUT_PATH = Path("data/comics/processed/text_sparse_vectors.parquet")
17
+ CHUNKS_DIR = Path("data/comics/processed/sparse_chunks")
18
+ CHUNK_SIZE = 10_000
19
+ BATCH_SIZE = 96
20
 
21
  SPARSE_MODEL = os.environ.get("SPARSE_EMBED_MODEL", "pinecone-sparse-english-v0")
 
 
22
  _pc = Pinecone(api_key=os.environ["PINECONE_API_KEY"])
23
 
24
 
25
+ def embed_sparse_batch(texts: list[str], input_type: str = "passage", retries: int = 5) -> list[dict | None]:
 
26
  non_empty = [(i, t) for i, t in enumerate(texts) if t.strip()]
27
  results: list[dict | None] = [None] * len(texts)
 
28
  if not non_empty:
29
  return results
 
30
  indices, batch_texts = zip(*non_empty)
31
+ for attempt in range(retries):
32
+ try:
33
+ response = _pc.inference.embed(
34
+ model=SPARSE_MODEL,
35
+ inputs=list(batch_texts),
36
+ parameters={"input_type": input_type},
37
+ )
38
+ for idx, embedding in zip(indices, response):
39
+ if embedding.sparse_indices and embedding.sparse_values:
40
+ results[idx] = {
41
+ "indices": embedding.sparse_indices,
42
+ "values": embedding.sparse_values,
43
+ }
44
+ return results
45
+ except Exception as exc:
46
+ if attempt == retries - 1:
47
+ raise
48
+ wait = 2 ** attempt
49
+ print(f" Retry {attempt + 1}: {exc}; waiting {wait}s")
50
+ time.sleep(wait)
51
  return results
52
 
53
 
54
  def embed_sparse_query(text: str) -> dict | None:
 
55
  if not text.strip():
56
  return None
57
+ return embed_sparse_batch([text], input_type="query")[0]
 
58
 
59
 
60
+ def merge_chunks() -> None:
61
+ chunk_files = sorted(CHUNKS_DIR.glob("chunk_*.parquet"))
62
+ if not chunk_files:
63
+ print("No chunk files to merge")
64
+ return
65
+ print(f"Merging {len(chunk_files)} chunk files...")
66
+ df = pd.concat([pd.read_parquet(f) for f in chunk_files], ignore_index=True)
67
+ df.to_parquet(OUTPUT_PATH, index=False)
68
+ print(f"Merged {len(df):,} rows → {OUTPUT_PATH}")
69
+
70
 
71
+ def done_chunks() -> set[int]:
72
+ if not CHUNKS_DIR.exists():
73
+ return set()
74
+ return {int(f.stem.split("_")[1]) for f in CHUNKS_DIR.glob("chunk_*.parquet")}
75
 
 
 
 
 
76
 
77
+ def main():
78
+ import argparse
79
+ parser = argparse.ArgumentParser()
80
+ parser.add_argument("--merge", action="store_true")
81
+ args = parser.parse_args()
82
 
83
+ if args.merge:
84
+ merge_chunks()
85
+ return
86
 
87
+ CHUNKS_DIR.mkdir(parents=True, exist_ok=True)
88
+
89
+ manifest = pd.read_parquet(MANIFEST_PATH)
90
+ already_done = done_chunks()
91
+ total_chunks = (len(manifest) + CHUNK_SIZE - 1) // CHUNK_SIZE
92
+ remaining = [i for i in range(total_chunks) if i not in already_done]
93
+
94
+ print(f"Total panels: {len(manifest):,} | chunks done: {len(already_done)}/{total_chunks} | remaining: {len(remaining)}")
95
+ print(f"Model: {SPARSE_MODEL}")
96
+
97
+ for chunk_idx in remaining:
98
+ start = chunk_idx * CHUNK_SIZE
99
+ end = min(start + CHUNK_SIZE, len(manifest))
100
+ chunk_df = manifest.iloc[start:end]
101
+
102
+ texts = chunk_df["search_text"].fillna("").tolist()
103
+ panel_ids = chunk_df["panel_id"].tolist()
104
+ rows = []
105
+
106
+ pbar = tqdm(range(0, len(texts), BATCH_SIZE), desc=f"chunk {chunk_idx+1}/{total_chunks}", leave=False)
107
+ for start_b in pbar:
108
+ batch = texts[start_b: start_b + BATCH_SIZE]
109
+ batch_ids = panel_ids[start_b: start_b + BATCH_SIZE]
110
+ sparse_results = embed_sparse_batch(batch)
111
+ for pid, sparse in zip(batch_ids, sparse_results):
112
+ rows.append({"panel_id": pid, "text_sparse": sparse})
113
+
114
+ chunk_path = CHUNKS_DIR / f"chunk_{chunk_idx:06d}.parquet"
115
+ pd.DataFrame(rows).to_parquet(chunk_path, index=False)
116
+
117
+ done_count = len(already_done) + (remaining.index(chunk_idx) + 1)
118
+ filled = sum(1 for r in rows if r["text_sparse"] is not None)
119
+ print(f" chunk {chunk_idx:06d}: {len(rows)} rows ({filled} with vectors) [{done_count}/{total_chunks} done]")
120
+
121
+ print("All chunks complete. Merging...")
122
+ merge_chunks()
123
 
124
 
125
  if __name__ == "__main__":
src/ingest/build_manifest.py CHANGED
@@ -104,6 +104,10 @@ def build_ocr_lookup(ocr_df: pd.DataFrame) -> dict[str, str]:
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:
@@ -111,15 +115,23 @@ def build_ocr_lookup(ocr_df: pd.DataFrame) -> dict[str, str]:
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"
@@ -138,6 +150,9 @@ def main():
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():
 
104
 
105
  print(f" Using columns: book={book_col}, page={page_col}, panel={panel_col}, text={text_col}")
106
 
107
+ # Aggregate all textbox texts per panel (CSV has one row per textbox)
108
+ from collections import defaultdict
109
+ panel_texts: dict[str, list[str]] = defaultdict(list)
110
+
111
  for row in ocr_df.itertuples(index=False):
112
  book = str(getattr(row, book_col)).strip()
113
  try:
 
115
  panel = int(getattr(row, panel_col))
116
  except (ValueError, TypeError):
117
  continue
118
+ val = getattr(row, text_col, None)
119
+ if val is not None and str(val).strip() and str(val).lower() != "nan":
120
+ panel_texts[f"{book}:{page}:{panel}"].append(str(val).strip())
121
+
122
+ for key, texts in panel_texts.items():
123
+ lookup[key] = " ".join(texts)
124
 
125
  print(f" OCR lookup entries: {len(lookup)}")
126
  return lookup
127
 
128
 
129
  def main():
130
+ import argparse
131
+ parser = argparse.ArgumentParser()
132
+ parser.add_argument("--limit", type=int, default=None, help="Cap number of panels (for dry runs)")
133
+ args = parser.parse_args()
134
+
135
  panel_dir = RAW_DIR / "raw_panel_images"
136
  ocr_path = RAW_DIR / "COMICS_ocr_file.csv"
137
  ad_pages_path = RAW_DIR / "predadpages.txt"
 
150
  if p.is_file() and p.suffix.lower() in {".jpg", ".jpeg", ".png"}
151
  )
152
  print(f"Found {len(panel_paths)} panel images")
153
+ if args.limit:
154
+ panel_paths = panel_paths[:args.limit]
155
+ print(f"Limiting to {len(panel_paths)} panels (--limit {args.limit})")
156
 
157
  ocr_lookup: dict[str, str] = {}
158
  if ocr_path.exists():
src/ingest/clean_text.py CHANGED
Binary files a/src/ingest/clean_text.py and b/src/ingest/clean_text.py differ
 
src/pinecone/build_documents.py CHANGED
@@ -12,10 +12,20 @@ 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():
@@ -37,7 +47,7 @@ def main():
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,
@@ -52,7 +62,10 @@ def main():
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
 
12
  OUTPUT_PATH = PROCESSED_DIR / "pinecone_documents.jsonl"
13
 
14
 
15
+ def to_list(val) -> list:
16
+ """Convert numpy array or other sequence to a plain Python list."""
17
+ import numpy as np
18
+ if isinstance(val, np.ndarray):
19
+ return val.tolist()
20
+ return list(val)
21
+
22
+
23
  def valid_sparse(sparse) -> bool:
24
  if not isinstance(sparse, dict):
25
  return False
26
+ indices = sparse.get("indices")
27
+ values = sparse.get("values")
28
+ return indices is not None and len(indices) > 0 and values is not None and len(values) > 0
29
 
30
 
31
  def main():
 
47
  for row in df.itertuples(index=False):
48
  doc: dict = {
49
  "_id": row.panel_id,
50
+ "image_dense": to_list(row.image_dense),
51
  "ocr_text": row.ocr_text_clean or "",
52
  "search_text": row.search_text or "",
53
  "comic_id": row.comic_id,
 
62
 
63
  sparse_vec = getattr(row, "text_sparse", None)
64
  if valid_sparse(sparse_vec):
65
+ doc["text_sparse"] = {
66
+ "indices": to_list(sparse_vec["indices"]),
67
+ "values": to_list(sparse_vec["values"]),
68
+ }
69
 
70
  f.write(json.dumps(doc) + "\n")
71
  count += 1
src/pinecone/upsert_panels.py CHANGED
@@ -51,10 +51,10 @@ def validate_search(index) -> None:
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():
 
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.matches
55
  print(f"Validation search returned {len(hits)} hits")
56
  for h in hits:
57
+ print(f" {h.id} score={h.score:.4f}")
58
 
59
 
60
  def main():
src/search/search_dense.py CHANGED
@@ -11,13 +11,14 @@ _INCLUDE_FIELDS = [
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(
@@ -30,13 +31,14 @@ def search_dense_with_phrase_filter(
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():
 
11
 
12
 
13
  def search_dense(index, namespace: str, query_vector: list[float], top_k: int = 20, filters: dict | None = None) -> dict:
14
+ response = 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
+ return {"result": {"hits": [h.to_dict() for h in response.matches]}}
22
 
23
 
24
  def search_dense_with_phrase_filter(
 
31
  ) -> dict:
32
  merged_filter = dict(filters) if filters else {}
33
  merged_filter["search_text"] = {"$match_phrase": phrase}
34
+ response = index.documents.search(
35
  namespace=namespace,
36
  top_k=top_k,
37
  score_by=[{"type": "dense_vector", "field": "image_dense", "values": query_vector}],
38
  filter=merged_filter,
39
  include_fields=_INCLUDE_FIELDS,
40
  )
41
+ return {"result": {"hits": [h.to_dict() for h in response.matches]}}
42
 
43
 
44
  def main():
src/search/search_fts.py CHANGED
@@ -11,13 +11,14 @@ _INCLUDE_FIELDS = [
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():
 
11
 
12
 
13
  def search_fts(index, namespace: str, query: str, top_k: int = 20, filters: dict | None = None) -> dict:
14
+ response = 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
+ return {"result": {"hits": [h.to_dict() for h in response.matches]}}
22
 
23
 
24
  def main():
src/search/search_sparse.py CHANGED
@@ -11,13 +11,14 @@ _INCLUDE_FIELDS = [
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():
 
11
 
12
 
13
  def search_sparse(index, namespace: str, query_sparse: dict, top_k: int = 20, filters: dict | None = None) -> dict:
14
+ response = 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
+ return {"result": {"hits": [h.to_dict() for h in response.matches]}}
22
 
23
 
24
  def main():