Yeroyan commited on
Commit
a6f316a
·
1 Parent(s): 49f9f3c

add cascaseded retrievers

Browse files
visual_rag/__init__.py CHANGED
@@ -61,6 +61,16 @@ try:
61
  except ImportError:
62
  TwoStageRetriever = None
63
 
 
 
 
 
 
 
 
 
 
 
64
  # Config utilities (always available)
65
  from visual_rag.config import load_config, get, get_section
66
 
@@ -74,6 +84,8 @@ __all__ = [
74
  "QdrantIndexer",
75
  "CloudinaryUploader",
76
  "TwoStageRetriever",
 
 
77
 
78
  # Config utilities
79
  "load_config",
 
61
  except ImportError:
62
  TwoStageRetriever = None
63
 
64
+ try:
65
+ from visual_rag.retrieval.multi_vector import MultiVectorRetriever
66
+ except ImportError:
67
+ MultiVectorRetriever = None
68
+
69
+ try:
70
+ from visual_rag.qdrant_admin import QdrantAdmin
71
+ except ImportError:
72
+ QdrantAdmin = None
73
+
74
  # Config utilities (always available)
75
  from visual_rag.config import load_config, get, get_section
76
 
 
84
  "QdrantIndexer",
85
  "CloudinaryUploader",
86
  "TwoStageRetriever",
87
+ "MultiVectorRetriever",
88
+ "QdrantAdmin",
89
 
90
  # Config utilities
91
  "load_config",
visual_rag/cli/main.py CHANGED
@@ -24,6 +24,7 @@ import argparse
24
  import logging
25
  from pathlib import Path
26
  from typing import Optional
 
27
 
28
  from dotenv import load_dotenv
29
 
@@ -94,13 +95,31 @@ def cmd_process(args):
94
  model_name = args.model or config.get("model", {}).get("name", "vidore/colSmol-500M")
95
  collection_name = args.collection or config.get("qdrant", {}).get("collection_name", "visual_documents")
96
 
97
- # Initialize embedder
 
 
 
 
 
 
 
 
98
  logger.info(f"🤖 Initializing embedder: {model_name}")
99
- embedder = VisualEmbedder(model_name=model_name, batch_size=args.batch_size)
 
 
 
 
 
100
 
101
  # Initialize Qdrant indexer
102
- qdrant_url = os.getenv("QDRANT_URL")
103
- qdrant_api_key = os.getenv("QDRANT_API_KEY")
 
 
 
 
 
104
 
105
  if not qdrant_url:
106
  logger.error("❌ QDRANT_URL environment variable not set")
@@ -111,11 +130,42 @@ def cmd_process(args):
111
  url=qdrant_url,
112
  api_key=qdrant_api_key,
113
  collection_name=collection_name,
 
 
114
  )
115
 
116
  # Create collection if needed
117
  indexer.create_collection(force_recreate=args.force_recreate)
118
- indexer.create_payload_indexes()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
 
120
  # Initialize Cloudinary uploader (optional)
121
  cloudinary_uploader = None
@@ -135,6 +185,9 @@ def cmd_process(args):
135
  metadata_mapping=metadata_mapping,
136
  config=config,
137
  embedding_strategy=args.strategy,
 
 
 
138
  )
139
 
140
  # Process PDFs
@@ -177,13 +230,18 @@ def cmd_process(args):
177
  def cmd_search(args):
178
  """Search documents."""
179
  from visual_rag import VisualEmbedder
180
- from visual_rag.retrieval import TwoStageRetriever
181
  from qdrant_client import QdrantClient
182
 
183
  load_dotenv()
184
 
185
- qdrant_url = os.getenv("QDRANT_URL")
186
- qdrant_api_key = os.getenv("QDRANT_API_KEY")
 
 
 
 
 
187
 
188
  if not qdrant_url:
189
  logger.error("❌ QDRANT_URL not set")
@@ -191,11 +249,19 @@ def cmd_search(args):
191
 
192
  # Initialize
193
  logger.info(f"🤖 Loading model: {args.model}")
194
- embedder = VisualEmbedder(model_name=args.model)
195
-
196
  logger.info(f"🔌 Connecting to Qdrant")
197
- client = QdrantClient(url=qdrant_url, api_key=qdrant_api_key)
198
- retriever = TwoStageRetriever(client, args.collection)
 
 
 
 
 
 
 
 
199
 
200
  # Embed query
201
  logger.info(f"🔍 Query: {args.query}")
@@ -204,19 +270,43 @@ def cmd_search(args):
204
  # Build filter
205
  filter_obj = None
206
  if args.year or args.source or args.district:
207
- filter_obj = retriever.build_filter(
208
  year=args.year,
209
  source=args.source,
210
  district=args.district,
211
  )
212
 
213
  # Search
214
- results = retriever.search(
215
- query_embedding=query_embedding.numpy(),
216
- top_k=args.top_k,
217
- prefetch_k=args.prefetch_k,
218
- filter_obj=filter_obj,
219
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
220
 
221
  # Display results
222
  logger.info(f"\n📊 Results ({len(results)}):")
@@ -245,14 +335,26 @@ def cmd_info(args):
245
 
246
  load_dotenv()
247
 
248
- qdrant_url = os.getenv("QDRANT_URL")
249
- qdrant_api_key = os.getenv("QDRANT_API_KEY")
 
 
 
 
 
250
 
251
  if not qdrant_url:
252
  logger.error("❌ QDRANT_URL not set")
253
  sys.exit(1)
254
 
255
- client = QdrantClient(url=qdrant_url, api_key=qdrant_api_key)
 
 
 
 
 
 
 
256
 
257
  try:
258
  info = client.get_collection(args.collection)
@@ -345,6 +447,22 @@ Examples:
345
  "--no-cloudinary", action="store_true",
346
  help="Skip Cloudinary uploads"
347
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
348
  process_parser.add_argument(
349
  "--no-skip-existing", action="store_true",
350
  help="Process all pages even if they exist in Qdrant"
@@ -363,6 +481,41 @@ Examples:
363
  help="Embedding strategy: 'pooling' (NOVEL), 'standard' (BASELINE), "
364
  "'all' (embed once, store BOTH for comparison)"
365
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
366
  process_parser.set_defaults(func=cmd_process)
367
 
368
  # =========================================================================
@@ -384,14 +537,31 @@ Examples:
384
  "--model", type=str, default="vidore/colSmol-500M",
385
  help="Model name"
386
  )
 
 
 
 
 
 
 
387
  search_parser.add_argument(
388
  "--top-k", type=int, default=10,
389
  help="Number of results"
390
  )
 
 
 
 
 
391
  search_parser.add_argument(
392
  "--prefetch-k", type=int, default=200,
393
  help="Prefetch candidates for two-stage retrieval"
394
  )
 
 
 
 
 
395
  search_parser.add_argument(
396
  "--year", type=int,
397
  help="Filter by year"
@@ -408,6 +578,20 @@ Examples:
408
  "--show-text", action="store_true",
409
  help="Show text snippets in results"
410
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
411
  search_parser.set_defaults(func=cmd_search)
412
 
413
  # =========================================================================
@@ -421,6 +605,20 @@ Examples:
421
  "--collection", type=str, default="visual_documents",
422
  help="Qdrant collection name"
423
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
424
  info_parser.set_defaults(func=cmd_info)
425
 
426
  # Parse and execute
 
24
  import logging
25
  from pathlib import Path
26
  from typing import Optional
27
+ from urllib.parse import urlparse
28
 
29
  from dotenv import load_dotenv
30
 
 
95
  model_name = args.model or config.get("model", {}).get("name", "vidore/colSmol-500M")
96
  collection_name = args.collection or config.get("qdrant", {}).get("collection_name", "visual_documents")
97
 
98
+ torch_dtype = None
99
+ if args.torch_dtype != "auto":
100
+ import torch
101
+ torch_dtype = {
102
+ "float32": torch.float32,
103
+ "float16": torch.float16,
104
+ "bfloat16": torch.bfloat16,
105
+ }[args.torch_dtype]
106
+
107
  logger.info(f"🤖 Initializing embedder: {model_name}")
108
+ embedder = VisualEmbedder(
109
+ model_name=model_name,
110
+ batch_size=args.batch_size,
111
+ torch_dtype=torch_dtype,
112
+ processor_speed=str(getattr(args, "processor_speed", "fast")),
113
+ )
114
 
115
  # Initialize Qdrant indexer
116
+ qdrant_url = os.getenv("SIGIR_QDRANT_URL") or os.getenv("DEST_QDRANT_URL") or os.getenv("QDRANT_URL")
117
+ qdrant_api_key = (
118
+ os.getenv("SIGIR_QDRANT_KEY")
119
+ or os.getenv("SIGIR_QDRANT_API_KEY")
120
+ or os.getenv("DEST_QDRANT_API_KEY")
121
+ or os.getenv("QDRANT_API_KEY")
122
+ )
123
 
124
  if not qdrant_url:
125
  logger.error("❌ QDRANT_URL environment variable not set")
 
130
  url=qdrant_url,
131
  api_key=qdrant_api_key,
132
  collection_name=collection_name,
133
+ prefer_grpc=args.prefer_grpc,
134
+ vector_datatype=args.qdrant_vector_dtype,
135
  )
136
 
137
  # Create collection if needed
138
  indexer.create_collection(force_recreate=args.force_recreate)
139
+ inferred_fields = []
140
+ inferred_fields.append({"field": "filename", "type": "keyword"})
141
+ inferred_fields.append({"field": "page_number", "type": "integer"})
142
+ inferred_fields.append({"field": "has_text", "type": "bool"})
143
+
144
+ if metadata_mapping:
145
+ keys = set()
146
+ for _, meta in metadata_mapping.items():
147
+ if isinstance(meta, dict):
148
+ keys.update(meta.keys())
149
+ for k in sorted(keys):
150
+ if k in ("filename", "page_number", "has_text"):
151
+ continue
152
+ inferred_type = "keyword"
153
+ for _, meta in metadata_mapping.items():
154
+ if not isinstance(meta, dict):
155
+ continue
156
+ v = meta.get(k)
157
+ if isinstance(v, bool):
158
+ inferred_type = "bool"
159
+ break
160
+ if isinstance(v, int):
161
+ inferred_type = "integer"
162
+ break
163
+ if isinstance(v, float):
164
+ inferred_type = "float"
165
+ break
166
+ inferred_fields.append({"field": k, "type": inferred_type})
167
+
168
+ indexer.create_payload_indexes(fields=inferred_fields)
169
 
170
  # Initialize Cloudinary uploader (optional)
171
  cloudinary_uploader = None
 
185
  metadata_mapping=metadata_mapping,
186
  config=config,
187
  embedding_strategy=args.strategy,
188
+ crop_empty=bool(getattr(args, "crop_empty", False)),
189
+ crop_empty_percentage_to_remove=float(getattr(args, "crop_empty_percentage_to_remove", 0.9)),
190
+ crop_empty_remove_page_number=bool(getattr(args, "crop_empty_remove_page_number", False)),
191
  )
192
 
193
  # Process PDFs
 
230
  def cmd_search(args):
231
  """Search documents."""
232
  from visual_rag import VisualEmbedder
233
+ from visual_rag.retrieval import TwoStageRetriever, SingleStageRetriever
234
  from qdrant_client import QdrantClient
235
 
236
  load_dotenv()
237
 
238
+ qdrant_url = os.getenv("SIGIR_QDRANT_URL") or os.getenv("DEST_QDRANT_URL") or os.getenv("QDRANT_URL")
239
+ qdrant_api_key = (
240
+ os.getenv("SIGIR_QDRANT_KEY")
241
+ or os.getenv("SIGIR_QDRANT_API_KEY")
242
+ or os.getenv("DEST_QDRANT_API_KEY")
243
+ or os.getenv("QDRANT_API_KEY")
244
+ )
245
 
246
  if not qdrant_url:
247
  logger.error("❌ QDRANT_URL not set")
 
249
 
250
  # Initialize
251
  logger.info(f"🤖 Loading model: {args.model}")
252
+ embedder = VisualEmbedder(model_name=args.model, processor_speed=str(getattr(args, "processor_speed", "fast")))
253
+
254
  logger.info(f"🔌 Connecting to Qdrant")
255
+ grpc_port = 6334 if args.prefer_grpc and urlparse(qdrant_url).port == 6333 else None
256
+ client = QdrantClient(
257
+ url=qdrant_url,
258
+ api_key=qdrant_api_key,
259
+ prefer_grpc=args.prefer_grpc,
260
+ grpc_port=grpc_port,
261
+ check_compatibility=False,
262
+ )
263
+ two_stage = TwoStageRetriever(client, args.collection)
264
+ single_stage = SingleStageRetriever(client, args.collection)
265
 
266
  # Embed query
267
  logger.info(f"🔍 Query: {args.query}")
 
270
  # Build filter
271
  filter_obj = None
272
  if args.year or args.source or args.district:
273
+ filter_obj = two_stage.build_filter(
274
  year=args.year,
275
  source=args.source,
276
  district=args.district,
277
  )
278
 
279
  # Search
280
+ query_np = query_embedding.detach().cpu().numpy()
281
+ if args.strategy == "single_full":
282
+ results = single_stage.search(
283
+ query_embedding=query_np,
284
+ top_k=args.top_k,
285
+ strategy="multi_vector",
286
+ filter_obj=filter_obj,
287
+ )
288
+ elif args.strategy == "single_tiles":
289
+ results = single_stage.search(
290
+ query_embedding=query_np,
291
+ top_k=args.top_k,
292
+ strategy="tiles_maxsim",
293
+ filter_obj=filter_obj,
294
+ )
295
+ elif args.strategy == "single_global":
296
+ results = single_stage.search(
297
+ query_embedding=query_np,
298
+ top_k=args.top_k,
299
+ strategy="pooled_global",
300
+ filter_obj=filter_obj,
301
+ )
302
+ else:
303
+ results = two_stage.search(
304
+ query_embedding=query_np,
305
+ top_k=args.top_k,
306
+ prefetch_k=args.prefetch_k,
307
+ filter_obj=filter_obj,
308
+ stage1_mode=args.stage1_mode,
309
+ )
310
 
311
  # Display results
312
  logger.info(f"\n📊 Results ({len(results)}):")
 
335
 
336
  load_dotenv()
337
 
338
+ qdrant_url = os.getenv("SIGIR_QDRANT_URL") or os.getenv("DEST_QDRANT_URL") or os.getenv("QDRANT_URL")
339
+ qdrant_api_key = (
340
+ os.getenv("SIGIR_QDRANT_KEY")
341
+ or os.getenv("SIGIR_QDRANT_API_KEY")
342
+ or os.getenv("DEST_QDRANT_API_KEY")
343
+ or os.getenv("QDRANT_API_KEY")
344
+ )
345
 
346
  if not qdrant_url:
347
  logger.error("❌ QDRANT_URL not set")
348
  sys.exit(1)
349
 
350
+ grpc_port = 6334 if args.prefer_grpc and urlparse(qdrant_url).port == 6333 else None
351
+ client = QdrantClient(
352
+ url=qdrant_url,
353
+ api_key=qdrant_api_key,
354
+ prefer_grpc=args.prefer_grpc,
355
+ grpc_port=grpc_port,
356
+ check_compatibility=False,
357
+ )
358
 
359
  try:
360
  info = client.get_collection(args.collection)
 
447
  "--no-cloudinary", action="store_true",
448
  help="Skip Cloudinary uploads"
449
  )
450
+ process_parser.add_argument(
451
+ "--crop-empty",
452
+ action="store_true",
453
+ help="Crop empty whitespace from page images before embedding (default: off).",
454
+ )
455
+ process_parser.add_argument(
456
+ "--crop-empty-percentage-to-remove",
457
+ type=float,
458
+ default=0.9,
459
+ help="Kept for traceability; currently does not affect cropping behavior (default: 0.9).",
460
+ )
461
+ process_parser.add_argument(
462
+ "--crop-empty-remove-page-number",
463
+ action="store_true",
464
+ help="If set, attempts to crop away the bottom region that contains sparse page numbers (default: off).",
465
+ )
466
  process_parser.add_argument(
467
  "--no-skip-existing", action="store_true",
468
  help="Process all pages even if they exist in Qdrant"
 
481
  help="Embedding strategy: 'pooling' (NOVEL), 'standard' (BASELINE), "
482
  "'all' (embed once, store BOTH for comparison)"
483
  )
484
+ process_parser.add_argument(
485
+ "--torch-dtype",
486
+ type=str,
487
+ default="auto",
488
+ choices=["auto", "float32", "float16", "bfloat16"],
489
+ help="Torch dtype for model weights (default: auto; CUDA->bfloat16, else float32).",
490
+ )
491
+ process_parser.add_argument(
492
+ "--qdrant-vector-dtype",
493
+ type=str,
494
+ default="float16",
495
+ choices=["float16", "float32"],
496
+ help="Datatype for vectors stored in Qdrant (default: float16).",
497
+ )
498
+ process_parser.add_argument(
499
+ "--processor-speed",
500
+ type=str,
501
+ default="fast",
502
+ choices=["fast", "slow", "auto"],
503
+ help="Processor implementation: fast (default, with fallback to slow), slow, or auto.",
504
+ )
505
+ process_grpc_group = process_parser.add_mutually_exclusive_group()
506
+ process_grpc_group.add_argument(
507
+ "--prefer-grpc",
508
+ dest="prefer_grpc",
509
+ action="store_true",
510
+ default=True,
511
+ help="Use gRPC for Qdrant client (recommended).",
512
+ )
513
+ process_grpc_group.add_argument(
514
+ "--no-prefer-grpc",
515
+ dest="prefer_grpc",
516
+ action="store_false",
517
+ help="Disable gRPC for Qdrant client.",
518
+ )
519
  process_parser.set_defaults(func=cmd_process)
520
 
521
  # =========================================================================
 
537
  "--model", type=str, default="vidore/colSmol-500M",
538
  help="Model name"
539
  )
540
+ search_parser.add_argument(
541
+ "--processor-speed",
542
+ type=str,
543
+ default="fast",
544
+ choices=["fast", "slow", "auto"],
545
+ help="Processor implementation: fast (default, with fallback to slow), slow, or auto.",
546
+ )
547
  search_parser.add_argument(
548
  "--top-k", type=int, default=10,
549
  help="Number of results"
550
  )
551
+ search_parser.add_argument(
552
+ "--strategy", type=str, default="single_full",
553
+ choices=["single_full", "single_tiles", "single_global", "two_stage"],
554
+ help="Search strategy"
555
+ )
556
  search_parser.add_argument(
557
  "--prefetch-k", type=int, default=200,
558
  help="Prefetch candidates for two-stage retrieval"
559
  )
560
+ search_parser.add_argument(
561
+ "--stage1-mode", type=str, default="pooled_query_vs_tiles",
562
+ choices=["pooled_query_vs_tiles", "tokens_vs_tiles", "pooled_query_vs_global"],
563
+ help="Stage 1 mode for two-stage retrieval"
564
+ )
565
  search_parser.add_argument(
566
  "--year", type=int,
567
  help="Filter by year"
 
578
  "--show-text", action="store_true",
579
  help="Show text snippets in results"
580
  )
581
+ search_grpc_group = search_parser.add_mutually_exclusive_group()
582
+ search_grpc_group.add_argument(
583
+ "--prefer-grpc",
584
+ dest="prefer_grpc",
585
+ action="store_true",
586
+ default=True,
587
+ help="Use gRPC for Qdrant client (recommended).",
588
+ )
589
+ search_grpc_group.add_argument(
590
+ "--no-prefer-grpc",
591
+ dest="prefer_grpc",
592
+ action="store_false",
593
+ help="Disable gRPC for Qdrant client.",
594
+ )
595
  search_parser.set_defaults(func=cmd_search)
596
 
597
  # =========================================================================
 
605
  "--collection", type=str, default="visual_documents",
606
  help="Qdrant collection name"
607
  )
608
+ info_grpc_group = info_parser.add_mutually_exclusive_group()
609
+ info_grpc_group.add_argument(
610
+ "--prefer-grpc",
611
+ dest="prefer_grpc",
612
+ action="store_true",
613
+ default=True,
614
+ help="Use gRPC for Qdrant client (recommended).",
615
+ )
616
+ info_grpc_group.add_argument(
617
+ "--no-prefer-grpc",
618
+ dest="prefer_grpc",
619
+ action="store_false",
620
+ help="Disable gRPC for Qdrant client.",
621
+ )
622
  info_parser.set_defaults(func=cmd_info)
623
 
624
  # Parse and execute
visual_rag/config.py CHANGED
@@ -18,6 +18,19 @@ logger = logging.getLogger(__name__)
18
  _config_cache: Optional[Dict[str, Any]] = None
19
 
20
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  def load_config(
22
  config_path: Optional[str] = None,
23
  force_reload: bool = False,
@@ -171,8 +184,8 @@ def get_section(section: str) -> Dict[str, Any]:
171
  def get_qdrant_config() -> Dict[str, Any]:
172
  """Get Qdrant configuration with defaults."""
173
  return {
174
- "url": get("qdrant.url", os.getenv("QDRANT_URL")),
175
- "api_key": get("qdrant.api_key", os.getenv("QDRANT_API_KEY")),
176
  "collection": get("qdrant.collection", "visual_documents"),
177
  }
178
 
 
18
  _config_cache: Optional[Dict[str, Any]] = None
19
 
20
 
21
+ def _env_qdrant_url() -> Optional[str]:
22
+ return os.getenv("SIGIR_QDRANT_URL") or os.getenv("DEST_QDRANT_URL") or os.getenv("QDRANT_URL")
23
+
24
+
25
+ def _env_qdrant_api_key() -> Optional[str]:
26
+ return (
27
+ os.getenv("SIGIR_QDRANT_KEY")
28
+ or os.getenv("SIGIR_QDRANT_API_KEY")
29
+ or os.getenv("DEST_QDRANT_API_KEY")
30
+ or os.getenv("QDRANT_API_KEY")
31
+ )
32
+
33
+
34
  def load_config(
35
  config_path: Optional[str] = None,
36
  force_reload: bool = False,
 
184
  def get_qdrant_config() -> Dict[str, Any]:
185
  """Get Qdrant configuration with defaults."""
186
  return {
187
+ "url": get("qdrant.url", _env_qdrant_url()),
188
+ "api_key": get("qdrant.api_key", _env_qdrant_api_key()),
189
  "collection": get("qdrant.collection", "visual_documents"),
190
  }
191
 
visual_rag/embedding/pooling.py CHANGED
@@ -15,10 +15,27 @@ import logging
15
  logger = logging.getLogger(__name__)
16
 
17
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  def tile_level_mean_pooling(
19
  embedding: Union[torch.Tensor, np.ndarray],
20
  num_tiles: int,
21
  patches_per_tile: int = 64,
 
22
  ) -> np.ndarray:
23
  """
24
  Compute tile-level mean pooling for multi-vector embeddings.
@@ -35,6 +52,7 @@ def tile_level_mean_pooling(
35
  embedding: Visual token embeddings [num_visual_tokens, dim]
36
  num_tiles: Number of tiles (including global tile)
37
  patches_per_tile: Patches per tile (64 for ColSmol)
 
38
 
39
  Returns:
40
  Tile-level pooled embeddings [num_tiles, dim]
@@ -45,7 +63,7 @@ def tile_level_mean_pooling(
45
  >>> pooled = tile_level_mean_pooling(embedding, num_tiles=13)
46
  >>> print(pooled.shape) # (13, 128)
47
  """
48
- # Convert to numpy
49
  if isinstance(embedding, torch.Tensor):
50
  if embedding.dtype == torch.bfloat16:
51
  emb_np = embedding.cpu().float().numpy()
@@ -57,17 +75,15 @@ def tile_level_mean_pooling(
57
  num_visual_tokens = emb_np.shape[0]
58
  expected_tokens = num_tiles * patches_per_tile
59
 
60
- # Handle mismatch (can happen with global tile variations)
61
  if num_visual_tokens != expected_tokens:
62
  logger.debug(
63
  f"Token count mismatch: {num_visual_tokens} vs expected {expected_tokens}"
64
  )
65
  actual_tiles = num_visual_tokens // patches_per_tile
66
  if actual_tiles * patches_per_tile != num_visual_tokens:
67
- actual_tiles += 1 # Include partial tile
68
  num_tiles = actual_tiles
69
 
70
- # Compute mean per tile
71
  tile_embeddings = []
72
  for tile_idx in range(num_tiles):
73
  start_idx = tile_idx * patches_per_tile
@@ -80,11 +96,134 @@ def tile_level_mean_pooling(
80
  tile_mean = tile_patches.mean(axis=0)
81
  tile_embeddings.append(tile_mean)
82
 
83
- return np.array(tile_embeddings, dtype=np.float32)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
 
85
 
86
  def global_mean_pooling(
87
  embedding: Union[torch.Tensor, np.ndarray],
 
88
  ) -> np.ndarray:
89
  """
90
  Compute global mean pooling → single vector.
@@ -94,10 +233,12 @@ def global_mean_pooling(
94
 
95
  Args:
96
  embedding: Multi-vector embeddings [num_tokens, dim]
 
97
 
98
  Returns:
99
  Pooled vector [dim]
100
  """
 
101
  if isinstance(embedding, torch.Tensor):
102
  if embedding.dtype == torch.bfloat16:
103
  emb_np = embedding.cpu().float().numpy()
@@ -106,7 +247,7 @@ def global_mean_pooling(
106
  else:
107
  emb_np = np.array(embedding)
108
 
109
- return emb_np.mean(axis=0).astype(np.float32)
110
 
111
 
112
  def compute_maxsim_score(
@@ -198,5 +339,3 @@ def compute_maxsim_batch(
198
  scores.append(float(max_sims.sum()))
199
 
200
  return scores
201
-
202
-
 
15
  logger = logging.getLogger(__name__)
16
 
17
 
18
+ def _infer_output_dtype(
19
+ embedding: Union[torch.Tensor, np.ndarray],
20
+ output_dtype: Optional[np.dtype] = None,
21
+ ) -> np.dtype:
22
+ """Infer output dtype: use provided, else match input (fp16→fp16, bf16→fp32, fp32→fp32)."""
23
+ if output_dtype is not None:
24
+ return output_dtype
25
+ if isinstance(embedding, torch.Tensor):
26
+ if embedding.dtype == torch.float16:
27
+ return np.float16
28
+ return np.float32
29
+ if isinstance(embedding, np.ndarray) and embedding.dtype == np.float16:
30
+ return np.float16
31
+ return np.float32
32
+
33
+
34
  def tile_level_mean_pooling(
35
  embedding: Union[torch.Tensor, np.ndarray],
36
  num_tiles: int,
37
  patches_per_tile: int = 64,
38
+ output_dtype: Optional[np.dtype] = None,
39
  ) -> np.ndarray:
40
  """
41
  Compute tile-level mean pooling for multi-vector embeddings.
 
52
  embedding: Visual token embeddings [num_visual_tokens, dim]
53
  num_tiles: Number of tiles (including global tile)
54
  patches_per_tile: Patches per tile (64 for ColSmol)
55
+ output_dtype: Output dtype (default: infer from input, fp16→fp16, bf16→fp32)
56
 
57
  Returns:
58
  Tile-level pooled embeddings [num_tiles, dim]
 
63
  >>> pooled = tile_level_mean_pooling(embedding, num_tiles=13)
64
  >>> print(pooled.shape) # (13, 128)
65
  """
66
+ out_dtype = _infer_output_dtype(embedding, output_dtype)
67
  if isinstance(embedding, torch.Tensor):
68
  if embedding.dtype == torch.bfloat16:
69
  emb_np = embedding.cpu().float().numpy()
 
75
  num_visual_tokens = emb_np.shape[0]
76
  expected_tokens = num_tiles * patches_per_tile
77
 
 
78
  if num_visual_tokens != expected_tokens:
79
  logger.debug(
80
  f"Token count mismatch: {num_visual_tokens} vs expected {expected_tokens}"
81
  )
82
  actual_tiles = num_visual_tokens // patches_per_tile
83
  if actual_tiles * patches_per_tile != num_visual_tokens:
84
+ actual_tiles += 1
85
  num_tiles = actual_tiles
86
 
 
87
  tile_embeddings = []
88
  for tile_idx in range(num_tiles):
89
  start_idx = tile_idx * patches_per_tile
 
96
  tile_mean = tile_patches.mean(axis=0)
97
  tile_embeddings.append(tile_mean)
98
 
99
+ return np.array(tile_embeddings, dtype=out_dtype)
100
+
101
+
102
+ def colpali_row_mean_pooling(
103
+ embedding: Union[torch.Tensor, np.ndarray],
104
+ grid_size: int = 32,
105
+ output_dtype: Optional[np.dtype] = None,
106
+ ) -> np.ndarray:
107
+ out_dtype = _infer_output_dtype(embedding, output_dtype)
108
+ if isinstance(embedding, torch.Tensor):
109
+ if embedding.dtype == torch.bfloat16:
110
+ emb_np = embedding.cpu().float().numpy()
111
+ else:
112
+ emb_np = embedding.cpu().numpy().astype(np.float32)
113
+ else:
114
+ emb_np = np.array(embedding, dtype=np.float32)
115
+
116
+ num_tokens, dim = emb_np.shape
117
+ expected = int(grid_size) * int(grid_size)
118
+ if num_tokens != expected:
119
+ raise ValueError(f"Expected {expected} visual tokens for grid_size={grid_size}, got {num_tokens}")
120
+
121
+ grid = emb_np.reshape(int(grid_size), int(grid_size), int(dim))
122
+ pooled = grid.mean(axis=1)
123
+ return pooled.astype(out_dtype)
124
+
125
+
126
+ def colsmol_experimental_pooling(
127
+ embedding: Union[torch.Tensor, np.ndarray],
128
+ num_tiles: int,
129
+ patches_per_tile: int = 64,
130
+ output_dtype: Optional[np.dtype] = None,
131
+ ) -> np.ndarray:
132
+ out_dtype = _infer_output_dtype(embedding, output_dtype)
133
+ if isinstance(embedding, torch.Tensor):
134
+ if embedding.dtype == torch.bfloat16:
135
+ emb_np = embedding.cpu().float().numpy()
136
+ else:
137
+ emb_np = embedding.cpu().numpy().astype(np.float32)
138
+ else:
139
+ emb_np = np.array(embedding, dtype=np.float32)
140
+
141
+ num_visual_tokens, dim = emb_np.shape
142
+ if num_tiles <= 0:
143
+ raise ValueError("num_tiles must be > 0")
144
+ if patches_per_tile <= 0:
145
+ raise ValueError("patches_per_tile must be > 0")
146
+
147
+ last_tile_start = (int(num_tiles) - 1) * int(patches_per_tile)
148
+ if last_tile_start >= num_visual_tokens:
149
+ actual_tiles = int(num_visual_tokens) // int(patches_per_tile)
150
+ if actual_tiles * int(patches_per_tile) != int(num_visual_tokens):
151
+ actual_tiles += 1
152
+ if actual_tiles <= 0:
153
+ raise ValueError(
154
+ f"Not enough tokens for num_tiles={num_tiles}, patches_per_tile={patches_per_tile}: got {num_visual_tokens}"
155
+ )
156
+ num_tiles = actual_tiles
157
+ last_tile_start = (int(num_tiles) - 1) * int(patches_per_tile)
158
+
159
+ prefix = emb_np[:last_tile_start]
160
+ last_tile = emb_np[last_tile_start : min(last_tile_start + int(patches_per_tile), num_visual_tokens)]
161
+
162
+ if prefix.size:
163
+ prefix_tiles = prefix.reshape(-1, int(patches_per_tile), int(dim))
164
+ prefix_means = prefix_tiles.mean(axis=1)
165
+ else:
166
+ prefix_means = np.zeros((0, int(dim)), dtype=out_dtype)
167
+
168
+ return np.concatenate([prefix_means.astype(out_dtype), last_tile.astype(out_dtype)], axis=0)
169
+
170
+
171
+ def colpali_experimental_pooling_from_rows(
172
+ row_vectors: Union[torch.Tensor, np.ndarray],
173
+ output_dtype: Optional[np.dtype] = None,
174
+ ) -> np.ndarray:
175
+ """
176
+ Experimental "convolution-style" pooling with window size 3.
177
+
178
+ For N input rows, produces N + 2 output vectors:
179
+ - Position 0: row[0] alone (1 row)
180
+ - Position 1: mean(rows[0:2]) (2 rows)
181
+ - Position 2: mean(rows[0:3]) (3 rows)
182
+ - Positions 3 to N-1: sliding window of 3 (rows[i-2:i+1])
183
+ - Position N: mean(rows[N-2:N]) (last 2 rows)
184
+ - Position N+1: row[N-1] alone (last row)
185
+
186
+ For N=32 rows: produces 34 vectors.
187
+ """
188
+ out_dtype = _infer_output_dtype(row_vectors, output_dtype)
189
+ if isinstance(row_vectors, torch.Tensor):
190
+ if row_vectors.dtype == torch.bfloat16:
191
+ rows = row_vectors.cpu().float().numpy()
192
+ else:
193
+ rows = row_vectors.cpu().numpy().astype(np.float32)
194
+ else:
195
+ rows = np.array(row_vectors, dtype=np.float32)
196
+
197
+ n, dim = rows.shape
198
+ if n < 1:
199
+ raise ValueError("row_vectors must be non-empty")
200
+ if n == 1:
201
+ return rows.astype(out_dtype)
202
+ if n == 2:
203
+ return np.stack([rows[0], rows[:2].mean(axis=0), rows[1]], axis=0).astype(out_dtype)
204
+ if n == 3:
205
+ return np.stack([
206
+ rows[0],
207
+ rows[:2].mean(axis=0),
208
+ rows[:3].mean(axis=0),
209
+ rows[1:3].mean(axis=0),
210
+ rows[2],
211
+ ], axis=0).astype(out_dtype)
212
+
213
+ out = np.zeros((n + 2, dim), dtype=np.float32)
214
+ out[0] = rows[0]
215
+ out[1] = rows[:2].mean(axis=0)
216
+ out[2] = rows[:3].mean(axis=0)
217
+ for i in range(3, n):
218
+ out[i] = rows[i - 2 : i + 1].mean(axis=0)
219
+ out[n] = rows[n - 2 : n].mean(axis=0)
220
+ out[n + 1] = rows[n - 1]
221
+ return out.astype(out_dtype)
222
 
223
 
224
  def global_mean_pooling(
225
  embedding: Union[torch.Tensor, np.ndarray],
226
+ output_dtype: Optional[np.dtype] = None,
227
  ) -> np.ndarray:
228
  """
229
  Compute global mean pooling → single vector.
 
233
 
234
  Args:
235
  embedding: Multi-vector embeddings [num_tokens, dim]
236
+ output_dtype: Output dtype (default: infer from input, fp16→fp16, bf16→fp32)
237
 
238
  Returns:
239
  Pooled vector [dim]
240
  """
241
+ out_dtype = _infer_output_dtype(embedding, output_dtype)
242
  if isinstance(embedding, torch.Tensor):
243
  if embedding.dtype == torch.bfloat16:
244
  emb_np = embedding.cpu().float().numpy()
 
247
  else:
248
  emb_np = np.array(embedding)
249
 
250
+ return emb_np.mean(axis=0).astype(out_dtype)
251
 
252
 
253
  def compute_maxsim_score(
 
339
  scores.append(float(max_sims.sum()))
340
 
341
  return scores
 
 
visual_rag/embedding/visual_embedder.py CHANGED
@@ -70,24 +70,26 @@ class VisualEmbedder:
70
  backend: str = "auto",
71
  device: Optional[str] = None,
72
  torch_dtype: Optional[torch.dtype] = None,
 
73
  batch_size: int = 4,
74
  filter_special_tokens: bool = True,
 
75
  ):
76
  self.model_name = model_name
77
  self.batch_size = batch_size
78
  self.filter_special_tokens = filter_special_tokens
 
 
 
79
 
80
- # Allow override via environment variable
81
  if os.getenv("VISUALRAG_INCLUDE_SPECIAL_TOKENS"):
82
  self.filter_special_tokens = False
83
  logger.info("Special token filtering disabled via VISUALRAG_INCLUDE_SPECIAL_TOKENS")
84
 
85
- # Auto-detect backend from model name
86
  if backend == "auto":
87
  backend = self._detect_backend(model_name)
88
  self.backend = backend
89
 
90
- # Auto-detect device
91
  if device is None:
92
  if torch.cuda.is_available():
93
  device = "cuda"
@@ -97,7 +99,6 @@ class VisualEmbedder:
97
  device = "cpu"
98
  self.device = device
99
 
100
- # Auto-detect dtype
101
  if torch_dtype is None:
102
  if device == "cuda":
103
  torch_dtype = torch.bfloat16
@@ -105,7 +106,13 @@ class VisualEmbedder:
105
  torch_dtype = torch.float32
106
  self.torch_dtype = torch_dtype
107
 
108
- # Lazy model loading
 
 
 
 
 
 
109
  self._model = None
110
  self._processor = None
111
  self._image_token_id = None
@@ -113,7 +120,7 @@ class VisualEmbedder:
113
  logger.info(f"🤖 VisualEmbedder initialized")
114
  logger.info(f" Model: {model_name}")
115
  logger.info(f" Backend: {backend}")
116
- logger.info(f" Device: {device}, dtype: {torch_dtype}")
117
 
118
  def _detect_backend(self, model_name: str) -> str:
119
  """Auto-detect backend from model name."""
@@ -141,7 +148,14 @@ class VisualEmbedder:
141
  def _load_colpali_model(self):
142
  """Load ColPali-family model."""
143
  try:
144
- from colpali_engine.models import ColIdefics3, ColIdefics3Processor
 
 
 
 
 
 
 
145
  except ImportError:
146
  raise ImportError(
147
  "colpali_engine not installed. Install with: "
@@ -151,25 +165,80 @@ class VisualEmbedder:
151
 
152
  logger.info(f"🤖 Loading ColPali model: {self.model_name}")
153
  logger.info(f" Device: {self.device}, dtype: {self.torch_dtype}")
 
 
 
 
 
154
 
155
- # Determine attention implementation
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
  attn_implementation = "eager"
157
  if self.device != "cpu":
158
  try:
159
  import flash_attn # noqa
 
160
  attn_implementation = "flash_attention_2"
161
  logger.info(" Using FlashAttention2")
162
  except ImportError:
163
  pass
164
-
165
  self._model = ColIdefics3.from_pretrained(
166
  self.model_name,
167
  dtype=self.torch_dtype,
168
  device_map=self.device,
169
  attn_implementation=attn_implementation,
170
  ).eval()
171
-
172
- self._processor = ColIdefics3Processor.from_pretrained(self.model_name)
 
 
 
 
 
 
 
173
  self._image_token_id = self._processor.image_token_id
174
 
175
  logger.info("✅ Model loaded successfully")
@@ -244,6 +313,61 @@ class VisualEmbedder:
244
  logger.debug(f"Query embedding: {embedding.shape[0]} tokens (unfiltered)")
245
 
246
  return embedding
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
247
 
248
  def embed_images(
249
  self,
@@ -274,6 +398,8 @@ class VisualEmbedder:
274
  - num_tiles: Total tiles (n_rows × n_cols + 1 global)
275
  """
276
  batch_size = batch_size or self.batch_size
 
 
277
 
278
  embeddings = []
279
  token_infos = [] if return_token_info else None
@@ -325,6 +451,8 @@ class VisualEmbedder:
325
  gc.collect()
326
  if torch.cuda.is_available():
327
  torch.cuda.empty_cache()
 
 
328
 
329
  if return_token_info:
330
  return embeddings, token_infos
@@ -357,10 +485,92 @@ class VisualEmbedder:
357
  else:
358
  visual_emb = np.array(full_embedding)[visual_indices]
359
 
360
- return visual_emb.astype(np.float32)
361
 
 
 
 
 
 
 
 
 
362
 
363
- # Backward compatibility alias
364
- ColPaliEmbedder = VisualEmbedder
365
 
 
 
 
 
 
 
 
366
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  backend: str = "auto",
71
  device: Optional[str] = None,
72
  torch_dtype: Optional[torch.dtype] = None,
73
+ output_dtype: Optional[np.dtype] = None,
74
  batch_size: int = 4,
75
  filter_special_tokens: bool = True,
76
+ processor_speed: str = "fast",
77
  ):
78
  self.model_name = model_name
79
  self.batch_size = batch_size
80
  self.filter_special_tokens = filter_special_tokens
81
+ if processor_speed not in ("fast", "slow", "auto"):
82
+ raise ValueError("processor_speed must be one of: fast, slow, auto")
83
+ self.processor_speed = processor_speed
84
 
 
85
  if os.getenv("VISUALRAG_INCLUDE_SPECIAL_TOKENS"):
86
  self.filter_special_tokens = False
87
  logger.info("Special token filtering disabled via VISUALRAG_INCLUDE_SPECIAL_TOKENS")
88
 
 
89
  if backend == "auto":
90
  backend = self._detect_backend(model_name)
91
  self.backend = backend
92
 
 
93
  if device is None:
94
  if torch.cuda.is_available():
95
  device = "cuda"
 
99
  device = "cpu"
100
  self.device = device
101
 
 
102
  if torch_dtype is None:
103
  if device == "cuda":
104
  torch_dtype = torch.bfloat16
 
106
  torch_dtype = torch.float32
107
  self.torch_dtype = torch_dtype
108
 
109
+ if output_dtype is None:
110
+ if torch_dtype == torch.float16:
111
+ output_dtype = np.float16
112
+ else:
113
+ output_dtype = np.float32
114
+ self.output_dtype = output_dtype
115
+
116
  self._model = None
117
  self._processor = None
118
  self._image_token_id = None
 
120
  logger.info(f"🤖 VisualEmbedder initialized")
121
  logger.info(f" Model: {model_name}")
122
  logger.info(f" Backend: {backend}")
123
+ logger.info(f" Device: {device}, torch_dtype: {torch_dtype}, output_dtype: {output_dtype}")
124
 
125
  def _detect_backend(self, model_name: str) -> str:
126
  """Auto-detect backend from model name."""
 
148
  def _load_colpali_model(self):
149
  """Load ColPali-family model."""
150
  try:
151
+ from colpali_engine.models import (
152
+ ColIdefics3,
153
+ ColIdefics3Processor,
154
+ ColPali,
155
+ ColPaliProcessor,
156
+ ColQwen2,
157
+ ColQwen2Processor,
158
+ )
159
  except ImportError:
160
  raise ImportError(
161
  "colpali_engine not installed. Install with: "
 
165
 
166
  logger.info(f"🤖 Loading ColPali model: {self.model_name}")
167
  logger.info(f" Device: {self.device}, dtype: {self.torch_dtype}")
168
+
169
+ def _processor_kwargs():
170
+ if self.processor_speed == "auto":
171
+ return {}
172
+ return {"use_fast": self.processor_speed == "fast"}
173
 
174
+ from transformers import AutoConfig
175
+
176
+ cfg = AutoConfig.from_pretrained(self.model_name)
177
+ model_type = str(getattr(cfg, "model_type", "") or "").lower()
178
+
179
+ if model_type == "colpali" or "colpali" in (self.model_name or "").lower():
180
+ self._model = ColPali.from_pretrained(
181
+ self.model_name,
182
+ torch_dtype=self.torch_dtype,
183
+ device_map=self.device,
184
+ ).eval()
185
+ try:
186
+ self._processor = ColPaliProcessor.from_pretrained(self.model_name, **_processor_kwargs())
187
+ except TypeError:
188
+ self._processor = ColPaliProcessor.from_pretrained(self.model_name)
189
+ except Exception:
190
+ if self.processor_speed == "fast":
191
+ self._processor = ColPaliProcessor.from_pretrained(self.model_name, use_fast=False)
192
+ else:
193
+ raise
194
+ self._image_token_id = self._processor.image_token_id
195
+ logger.info("✅ Loaded ColPali backend")
196
+ return
197
+
198
+ if model_type.startswith("qwen2") or "colqwen" in (self.model_name or "").lower():
199
+ self._model = ColQwen2.from_pretrained(
200
+ self.model_name,
201
+ dtype=self.torch_dtype,
202
+ device_map=self.device,
203
+ ).eval()
204
+ try:
205
+ self._processor = ColQwen2Processor.from_pretrained(self.model_name, device_map=self.device, **_processor_kwargs())
206
+ except TypeError:
207
+ self._processor = ColQwen2Processor.from_pretrained(self.model_name, device_map=self.device)
208
+ except Exception:
209
+ if self.processor_speed == "fast":
210
+ self._processor = ColQwen2Processor.from_pretrained(self.model_name, device_map=self.device, use_fast=False)
211
+ else:
212
+ raise
213
+ self._image_token_id = self._processor.image_token_id
214
+ logger.info("✅ Loaded ColQwen2 backend")
215
+ return
216
+
217
  attn_implementation = "eager"
218
  if self.device != "cpu":
219
  try:
220
  import flash_attn # noqa
221
+
222
  attn_implementation = "flash_attention_2"
223
  logger.info(" Using FlashAttention2")
224
  except ImportError:
225
  pass
226
+
227
  self._model = ColIdefics3.from_pretrained(
228
  self.model_name,
229
  dtype=self.torch_dtype,
230
  device_map=self.device,
231
  attn_implementation=attn_implementation,
232
  ).eval()
233
+ try:
234
+ self._processor = ColIdefics3Processor.from_pretrained(self.model_name, **_processor_kwargs())
235
+ except TypeError:
236
+ self._processor = ColIdefics3Processor.from_pretrained(self.model_name)
237
+ except Exception:
238
+ if self.processor_speed == "fast":
239
+ self._processor = ColIdefics3Processor.from_pretrained(self.model_name, use_fast=False)
240
+ else:
241
+ raise
242
  self._image_token_id = self._processor.image_token_id
243
 
244
  logger.info("✅ Model loaded successfully")
 
313
  logger.debug(f"Query embedding: {embedding.shape[0]} tokens (unfiltered)")
314
 
315
  return embedding
316
+
317
+ def embed_queries(
318
+ self,
319
+ query_texts: List[str],
320
+ batch_size: Optional[int] = None,
321
+ filter_special_tokens: Optional[bool] = None,
322
+ show_progress: bool = True,
323
+ ) -> List[torch.Tensor]:
324
+ """
325
+ Generate embeddings for a list of text queries.
326
+
327
+ Returns a list of tensors, each of shape [num_tokens, embedding_dim].
328
+ """
329
+ should_filter = (
330
+ filter_special_tokens if filter_special_tokens is not None else self.filter_special_tokens
331
+ )
332
+ batch_size = batch_size or self.batch_size
333
+
334
+ outputs: List[torch.Tensor] = []
335
+ iterator = range(0, len(query_texts), batch_size)
336
+ if show_progress:
337
+ iterator = tqdm(iterator, desc="📝 Embedding queries", unit="batch")
338
+
339
+ for i in iterator:
340
+ batch = query_texts[i : i + batch_size]
341
+ with torch.no_grad():
342
+ processed = self.processor.process_queries(batch).to(self.model.device)
343
+ batch_embeddings = self.model(**processed)
344
+
345
+ if isinstance(batch_embeddings, torch.Tensor) and batch_embeddings.dim() == 3:
346
+ attn = processed.get("attention_mask") if should_filter else None
347
+ input_ids = processed.get("input_ids") if should_filter else None
348
+
349
+ for j in range(batch_embeddings.shape[0]):
350
+ emb = batch_embeddings[j]
351
+ if should_filter and attn is not None:
352
+ valid_mask = attn[j].bool()
353
+ emb = emb[valid_mask]
354
+ if input_ids is not None:
355
+ ids = input_ids[j][valid_mask]
356
+ non_special_mask = ids >= 4
357
+ if non_special_mask.any():
358
+ emb = emb[non_special_mask]
359
+ outputs.append(emb)
360
+ else:
361
+ outputs.extend(batch_embeddings)
362
+
363
+ del processed, batch_embeddings
364
+ gc.collect()
365
+ if torch.cuda.is_available():
366
+ torch.cuda.empty_cache()
367
+ elif torch.backends.mps.is_available():
368
+ torch.mps.empty_cache()
369
+
370
+ return outputs
371
 
372
  def embed_images(
373
  self,
 
398
  - num_tiles: Total tiles (n_rows × n_cols + 1 global)
399
  """
400
  batch_size = batch_size or self.batch_size
401
+ if self.device == "mps" and "colpali" in (self.model_name or "").lower() and int(batch_size) > 1:
402
+ batch_size = 1
403
 
404
  embeddings = []
405
  token_infos = [] if return_token_info else None
 
451
  gc.collect()
452
  if torch.cuda.is_available():
453
  torch.cuda.empty_cache()
454
+ elif torch.backends.mps.is_available():
455
+ torch.mps.empty_cache()
456
 
457
  if return_token_info:
458
  return embeddings, token_infos
 
485
  else:
486
  visual_emb = np.array(full_embedding)[visual_indices]
487
 
488
+ return visual_emb.astype(self.output_dtype)
489
 
490
+ def mean_pool_visual_embedding(
491
+ self,
492
+ visual_embedding: Union[torch.Tensor, np.ndarray],
493
+ token_info: Optional[Dict[str, Any]] = None,
494
+ *,
495
+ target_vectors: int = 32,
496
+ ) -> np.ndarray:
497
+ from visual_rag.embedding.pooling import colpali_row_mean_pooling, tile_level_mean_pooling
498
 
499
+ model_lower = (self.model_name or "").lower()
500
+ is_colsmol = "colsmol" in model_lower
501
 
502
+ if isinstance(visual_embedding, torch.Tensor):
503
+ if visual_embedding.dtype == torch.bfloat16:
504
+ visual_np = visual_embedding.cpu().float().numpy()
505
+ else:
506
+ visual_np = visual_embedding.cpu().numpy().astype(np.float32)
507
+ else:
508
+ visual_np = np.array(visual_embedding, dtype=np.float32)
509
 
510
+ if is_colsmol:
511
+ n_rows = (token_info or {}).get("n_rows")
512
+ n_cols = (token_info or {}).get("n_cols")
513
+ num_tiles = int(n_rows) * int(n_cols) + 1 if n_rows and n_cols else 13
514
+ return tile_level_mean_pooling(visual_np, num_tiles=num_tiles, patches_per_tile=64, output_dtype=self.output_dtype)
515
+
516
+ num_tokens = int(visual_np.shape[0])
517
+ grid = int(round(float(num_tokens) ** 0.5))
518
+ if grid * grid != num_tokens:
519
+ raise ValueError(f"Cannot infer square grid from num_visual_tokens={num_tokens} for model={self.model_name}")
520
+ if int(target_vectors) != int(grid):
521
+ raise ValueError(
522
+ f"target_vectors={target_vectors} does not match inferred grid_size={grid} for model={self.model_name}"
523
+ )
524
+ return colpali_row_mean_pooling(visual_np, grid_size=int(target_vectors), output_dtype=self.output_dtype)
525
+
526
+ def global_pool_from_mean_pool(self, mean_pool: np.ndarray) -> np.ndarray:
527
+ if mean_pool.size == 0:
528
+ return np.zeros((128,), dtype=self.output_dtype)
529
+ return mean_pool.mean(axis=0).astype(self.output_dtype)
530
+
531
+ def experimental_pool_visual_embedding(
532
+ self,
533
+ visual_embedding: Union[torch.Tensor, np.ndarray],
534
+ token_info: Optional[Dict[str, Any]] = None,
535
+ *,
536
+ target_vectors: int = 32,
537
+ mean_pool: Optional[np.ndarray] = None,
538
+ ) -> np.ndarray:
539
+ from visual_rag.embedding.pooling import colpali_experimental_pooling_from_rows, colsmol_experimental_pooling
540
+
541
+ model_lower = (self.model_name or "").lower()
542
+ is_colsmol = "colsmol" in model_lower
543
+
544
+ if isinstance(visual_embedding, torch.Tensor):
545
+ if visual_embedding.dtype == torch.bfloat16:
546
+ visual_np = visual_embedding.cpu().float().numpy()
547
+ else:
548
+ visual_np = visual_embedding.cpu().numpy().astype(np.float32)
549
+ else:
550
+ visual_np = np.array(visual_embedding, dtype=np.float32)
551
+
552
+ if is_colsmol:
553
+ if mean_pool is not None and getattr(mean_pool, "shape", None) is not None and int(mean_pool.shape[0]) > 0:
554
+ num_tiles = int(mean_pool.shape[0])
555
+ else:
556
+ num_tiles = (token_info or {}).get("num_tiles")
557
+ if num_tiles is None:
558
+ num_visual_tokens = (token_info or {}).get("num_visual_tokens")
559
+ if num_visual_tokens is None:
560
+ num_visual_tokens = int(visual_np.shape[0])
561
+ patches_per_tile = 64
562
+ num_tiles = int(num_visual_tokens) // patches_per_tile
563
+ if int(num_tiles) * patches_per_tile != int(num_visual_tokens):
564
+ num_tiles = int(num_tiles) + 1
565
+ num_tiles = int(num_tiles)
566
+ return colsmol_experimental_pooling(visual_np, num_tiles=num_tiles, patches_per_tile=64, output_dtype=self.output_dtype)
567
+
568
+ rows = mean_pool if mean_pool is not None else self.mean_pool_visual_embedding(visual_np, token_info, target_vectors=target_vectors)
569
+ if int(rows.shape[0]) != int(target_vectors):
570
+ raise ValueError(
571
+ f"experimental pooling expects mean_pool to have {target_vectors} rows, got {rows.shape[0]} for model={self.model_name}"
572
+ )
573
+ return colpali_experimental_pooling_from_rows(rows, output_dtype=self.output_dtype)
574
+
575
+ # Backward compatibility alias
576
+ ColPaliEmbedder = VisualEmbedder
visual_rag/indexing/cloudinary_uploader.py CHANGED
@@ -8,6 +8,10 @@ Features:
8
  - Retry logic with timeouts
9
  - Batch uploading
10
  - Automatic JPEG optimization
 
 
 
 
11
  """
12
 
13
  import io
@@ -16,12 +20,16 @@ import time
16
  import signal
17
  import logging
18
  import platform
 
19
  from typing import Optional
 
20
 
21
  from PIL import Image
22
 
23
  logger = logging.getLogger(__name__)
24
 
 
 
25
 
26
  class CloudinaryUploader:
27
  """
@@ -124,40 +132,74 @@ class CloudinaryUploader:
124
  if subfolder:
125
  folder_path = f"{self.folder}/{subfolder}"
126
 
127
- # Timeout handling (Unix/macOS only)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  use_timeout = platform.system() != "Windows"
129
 
130
- class TimeoutError(Exception):
131
  pass
132
 
133
  def timeout_handler(signum, frame):
134
- raise TimeoutError(f"Upload timed out after {self.timeout_seconds}s")
135
 
136
  for attempt in range(self.max_retries):
137
  try:
138
- buffer.seek(0)
139
-
140
- # Set timeout alarm
141
  if use_timeout:
142
  old_handler = signal.signal(signal.SIGALRM, timeout_handler)
143
  signal.alarm(self.timeout_seconds)
144
 
145
  try:
146
- result = cloudinary.uploader.upload(
147
- buffer,
148
- folder=folder_path,
149
- overwrite=True,
150
- public_id=public_id,
151
- resource_type="image",
152
- timeout=self.timeout_seconds,
153
- )
154
- return result["secure_url"]
155
  finally:
156
  if use_timeout:
157
  signal.alarm(0)
158
  signal.signal(signal.SIGALRM, old_handler)
159
 
160
- except TimeoutError:
161
  logger.warning(
162
  f"Upload timeout (attempt {attempt + 1}/{self.max_retries}): {public_id}"
163
  )
@@ -205,4 +247,33 @@ class CloudinaryUploader:
205
 
206
  return original_url, resized_url
207
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
208
 
 
8
  - Retry logic with timeouts
9
  - Batch uploading
10
  - Automatic JPEG optimization
11
+
12
+ Environment Variables:
13
+ - VISUAL_RAG_THREAD_SAFE: Set to "1" to use thread-safe timeouts
14
+ (required for Streamlit, Flask, or other threaded contexts)
15
  """
16
 
17
  import io
 
20
  import signal
21
  import logging
22
  import platform
23
+ import threading
24
  from typing import Optional
25
+ from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeoutError
26
 
27
  from PIL import Image
28
 
29
  logger = logging.getLogger(__name__)
30
 
31
+ THREAD_SAFE_MODE = os.getenv("VISUAL_RAG_THREAD_SAFE", "").lower() in ("1", "true", "yes")
32
+
33
 
34
  class CloudinaryUploader:
35
  """
 
132
  if subfolder:
133
  folder_path = f"{self.folder}/{subfolder}"
134
 
135
+ def do_upload():
136
+ buffer.seek(0)
137
+ result = cloudinary.uploader.upload(
138
+ buffer,
139
+ folder=folder_path,
140
+ overwrite=True,
141
+ public_id=public_id,
142
+ resource_type="image",
143
+ timeout=self.timeout_seconds,
144
+ )
145
+ return result["secure_url"]
146
+
147
+ # Use thread-safe mode for Streamlit/Flask/threaded contexts
148
+ # Set VISUAL_RAG_THREAD_SAFE=1 to enable
149
+ if THREAD_SAFE_MODE or threading.current_thread() is not threading.main_thread():
150
+ return self._upload_with_thread_timeout(do_upload, public_id)
151
+ else:
152
+ return self._upload_with_signal_timeout(do_upload, public_id)
153
+
154
+ def _upload_with_thread_timeout(self, do_upload, public_id: str) -> Optional[str]:
155
+ """Thread-safe upload with ThreadPoolExecutor timeout."""
156
+ for attempt in range(self.max_retries):
157
+ try:
158
+ with ThreadPoolExecutor(max_workers=1) as executor:
159
+ future = executor.submit(do_upload)
160
+ return future.result(timeout=self.timeout_seconds)
161
+
162
+ except FuturesTimeoutError:
163
+ logger.warning(
164
+ f"Upload timeout (attempt {attempt + 1}/{self.max_retries}): {public_id}"
165
+ )
166
+ if attempt < self.max_retries - 1:
167
+ time.sleep(2 ** attempt)
168
+
169
+ except Exception as e:
170
+ logger.warning(
171
+ f"Upload failed (attempt {attempt + 1}/{self.max_retries}): {e}"
172
+ )
173
+ if attempt < self.max_retries - 1:
174
+ time.sleep(2 ** attempt)
175
+
176
+ logger.error(f"❌ Upload failed after {self.max_retries} attempts: {public_id}")
177
+ return None
178
+
179
+ def _upload_with_signal_timeout(self, do_upload, public_id: str) -> Optional[str]:
180
+ """Signal-based upload timeout (main thread only, Unix/macOS)."""
181
  use_timeout = platform.system() != "Windows"
182
 
183
+ class SignalTimeoutError(Exception):
184
  pass
185
 
186
  def timeout_handler(signum, frame):
187
+ raise SignalTimeoutError(f"Upload timed out after {self.timeout_seconds}s")
188
 
189
  for attempt in range(self.max_retries):
190
  try:
 
 
 
191
  if use_timeout:
192
  old_handler = signal.signal(signal.SIGALRM, timeout_handler)
193
  signal.alarm(self.timeout_seconds)
194
 
195
  try:
196
+ return do_upload()
 
 
 
 
 
 
 
 
197
  finally:
198
  if use_timeout:
199
  signal.alarm(0)
200
  signal.signal(signal.SIGALRM, old_handler)
201
 
202
+ except SignalTimeoutError:
203
  logger.warning(
204
  f"Upload timeout (attempt {attempt + 1}/{self.max_retries}): {public_id}"
205
  )
 
247
 
248
  return original_url, resized_url
249
 
250
+ def upload_original_cropped_and_resized(
251
+ self,
252
+ original_image: Image.Image,
253
+ cropped_image: Optional[Image.Image],
254
+ resized_image: Image.Image,
255
+ base_public_id: str,
256
+ ) -> tuple:
257
+ original_url = self.upload(
258
+ original_image,
259
+ base_public_id,
260
+ subfolder="original",
261
+ )
262
+
263
+ cropped_url = None
264
+ if cropped_image is not None:
265
+ cropped_url = self.upload(
266
+ cropped_image,
267
+ base_public_id,
268
+ subfolder="cropped",
269
+ )
270
+
271
+ resized_url = self.upload(
272
+ resized_image,
273
+ base_public_id,
274
+ subfolder="resized",
275
+ )
276
+
277
+ return original_url, cropped_url, resized_url
278
+
279
 
visual_rag/indexing/pipeline.py CHANGED
@@ -89,6 +89,11 @@ class ProcessingPipeline:
89
  metadata_mapping: Optional[Dict[str, Dict[str, Any]]] = None,
90
  config: Optional[Dict[str, Any]] = None,
91
  embedding_strategy: str = "pooling",
 
 
 
 
 
92
  ):
93
  self.embedder = embedder
94
  self.indexer = indexer
@@ -103,6 +108,12 @@ class ProcessingPipeline:
103
  f"Must be one of: {self.STRATEGIES}"
104
  )
105
  self.embedding_strategy = embedding_strategy
 
 
 
 
 
 
106
 
107
  logger.info(f"📊 Embedding strategy: {embedding_strategy}")
108
  if embedding_strategy == "pooling":
@@ -128,6 +139,8 @@ class ProcessingPipeline:
128
  skip_existing: bool = True,
129
  upload_to_cloudinary: bool = True,
130
  upload_to_qdrant: bool = True,
 
 
131
  ) -> Dict[str, Any]:
132
  """
133
  Process a single PDF end-to-end.
@@ -137,6 +150,8 @@ class ProcessingPipeline:
137
  skip_existing: Skip pages that already exist in Qdrant
138
  upload_to_cloudinary: Upload images to Cloudinary
139
  upload_to_qdrant: Upload embeddings to Qdrant
 
 
140
 
141
  Returns:
142
  Dict with processing results:
@@ -150,23 +165,26 @@ class ProcessingPipeline:
150
  }
151
  """
152
  pdf_path = Path(pdf_path)
153
- logger.info(f"📚 Processing PDF: {pdf_path.name}")
 
154
 
155
  # Check existing pages
156
  existing_ids: Set[str] = set()
157
  if skip_existing and self.indexer:
158
- existing_ids = self.indexer.get_existing_ids(pdf_path.name)
159
  if existing_ids:
160
  logger.info(f" Found {len(existing_ids)} existing pages")
161
 
162
- # Convert PDF to images
163
  logger.info(f"🖼️ Converting PDF to images...")
 
 
164
  images, texts = self.pdf_processor.process_pdf(pdf_path)
165
  total_pages = len(images)
166
  logger.info(f" ✅ Converted {total_pages} pages")
 
 
167
 
168
- # Get extra metadata
169
- extra_metadata = self._get_extra_metadata(pdf_path.name)
170
  if extra_metadata:
171
  logger.info(f" 📋 Found extra metadata: {list(extra_metadata.keys())}")
172
 
@@ -183,12 +201,13 @@ class ProcessingPipeline:
183
  batch_texts = texts[batch_start:batch_end]
184
 
185
  logger.info(f"📦 Processing pages {batch_start + 1}-{batch_end}/{total_pages}")
 
 
186
 
187
- # Filter pages that need processing
188
  pages_to_process = []
189
  for i, (img, text) in enumerate(zip(batch_images, batch_texts)):
190
  page_num = batch_start + i + 1
191
- chunk_id = self.generate_chunk_id(pdf_path.name, page_num)
192
 
193
  if skip_existing and chunk_id in existing_ids:
194
  skipped += 1
@@ -198,7 +217,7 @@ class ProcessingPipeline:
198
  "index": i,
199
  "page_num": page_num,
200
  "chunk_id": chunk_id,
201
- "image": img,
202
  "text": text,
203
  })
204
 
@@ -208,36 +227,64 @@ class ProcessingPipeline:
208
 
209
  # Generate embeddings with token info
210
  logger.info(f"🤖 Generating embeddings for {len(pages_to_process)} pages...")
211
- images_to_embed = [p["image"] for p in pages_to_process]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212
 
213
  embeddings, token_infos = self.embedder.embed_images(
214
  images_to_embed,
215
  batch_size=self.embedding_batch_size,
216
  return_token_info=True,
217
- show_progress=True,
218
  )
219
 
220
- # Process each page
221
  for idx, page_info in enumerate(pages_to_process):
222
- orig_img = page_info["image"]
 
 
223
  page_num = page_info["page_num"]
224
  chunk_id = page_info["chunk_id"]
225
  text = page_info["text"]
226
  embedding = embeddings[idx]
227
  token_info = token_infos[idx]
228
 
 
 
 
229
  try:
230
  page_data = self._process_single_page(
231
- pdf_path=pdf_path,
 
232
  page_num=page_num,
233
  chunk_id=chunk_id,
234
  total_pages=total_pages,
235
- orig_img=orig_img,
 
236
  text=text,
237
  embedding=embedding,
238
  token_info=token_info,
239
  extra_metadata=extra_metadata,
240
  upload_to_cloudinary=upload_to_cloudinary,
 
241
  )
242
 
243
  all_pages.append(page_data)
@@ -265,10 +312,10 @@ class ProcessingPipeline:
265
  count = self._upload_batch(upload_queue)
266
  uploaded += count
267
 
268
- logger.info(f"✅ Completed {pdf_path.name}: {uploaded} uploaded, {skipped} skipped, {failed} failed")
269
 
270
  return {
271
- "filename": pdf_path.name,
272
  "total_pages": total_pages,
273
  "uploaded": uploaded,
274
  "skipped": skipped,
@@ -278,22 +325,25 @@ class ProcessingPipeline:
278
 
279
  def _process_single_page(
280
  self,
281
- pdf_path: Path,
 
282
  page_num: int,
283
  chunk_id: str,
284
  total_pages: int,
285
- orig_img,
 
286
  text: str,
287
  embedding: torch.Tensor,
288
  token_info: Dict[str, Any],
289
  extra_metadata: Dict[str, Any],
290
  upload_to_cloudinary: bool = True,
 
291
  ) -> Dict[str, Any]:
292
  """Process a single page with full metadata for saliency."""
293
- from visual_rag.embedding.pooling import tile_level_mean_pooling, global_mean_pooling
294
 
295
  # Resize image for ColPali
296
- resized_img, tile_rows, tile_cols = self.pdf_processor.resize_for_colpali(orig_img)
297
 
298
  # Use processor's tile info if available (more accurate)
299
  proc_n_rows = token_info.get("n_rows")
@@ -316,10 +366,6 @@ class ProcessingPipeline:
316
  visual_indices = token_info["visual_token_indices"]
317
  num_visual_tokens = token_info["num_visual_tokens"]
318
 
319
- # Compute tile structure
320
- num_tiles = tile_rows * tile_cols + 1 # +1 for global tile
321
- patches_per_tile = 64
322
-
323
  # =========================================================================
324
  # STRATEGY: "pooling" (NOVEL) vs "standard" (BASELINE) vs "all" (BOTH)
325
  # =========================================================================
@@ -327,27 +373,39 @@ class ProcessingPipeline:
327
  # Always compute visual-only embedding (needed for pooling and saliency)
328
  visual_embedding = full_embedding[visual_indices]
329
 
330
- # Compute tile-level mean pooling: [num_visual_tokens, 128] → [num_tiles, 128]
331
- tile_pooled = tile_level_mean_pooling(visual_embedding, num_tiles, patches_per_tile)
332
-
333
- # Compute global mean (baseline pooling)
334
  global_pooled = global_mean_pooling(full_embedding)
 
 
 
 
 
 
 
 
 
335
 
336
  if self.embedding_strategy == "pooling":
337
  # NOVEL APPROACH: Visual tokens only + tile-level pooling
338
  embedding_for_initial = visual_embedding
339
  embedding_for_pooling = tile_pooled
 
340
 
341
  elif self.embedding_strategy == "standard":
342
  # BASELINE: All tokens + global mean
343
  embedding_for_initial = full_embedding
344
  embedding_for_pooling = global_pooled.reshape(1, -1)
 
345
 
346
  else: # "all" - Push BOTH representations (efficient for comparison)
347
  # Embed once, store multiple vector representations
348
  # This allows comparing both strategies without re-embedding
349
  embedding_for_initial = visual_embedding # Use visual for search
350
  embedding_for_pooling = tile_pooled # Use tile-level for fast prefetch
 
351
 
352
  # ALSO store standard representations as additional vectors
353
  # These will be added to metadata for optional use
@@ -355,21 +413,25 @@ class ProcessingPipeline:
355
 
356
  # Upload to Cloudinary
357
  original_url = None
 
358
  resized_url = None
359
 
360
  if upload_to_cloudinary and self.cloudinary_uploader:
361
- base_filename = f"{pdf_path.stem}_page_{page_num}"
362
- original_url, resized_url = self.cloudinary_uploader.upload_original_and_resized(
363
- orig_img, resized_img, base_filename
364
- )
 
 
 
 
 
365
 
366
  # Sanitize text
367
  safe_text = self._sanitize_text(text[:10000]) if text else ""
368
 
369
- # Build metadata (everything needed for saliency)
370
  metadata = {
371
- # Document info
372
- "filename": pdf_path.name,
373
  "page_number": page_num,
374
  "total_pages": total_pages,
375
  "has_text": bool(text and text.strip()),
@@ -378,11 +440,14 @@ class ProcessingPipeline:
378
  # Image URLs
379
  "page": resized_url or "", # For display
380
  "original_url": original_url or "",
 
381
  "resized_url": resized_url or "",
382
 
383
  # Dimensions (needed for saliency overlay)
384
- "original_width": orig_img.width,
385
- "original_height": orig_img.height,
 
 
386
  "resized_width": resized_img.width,
387
  "resized_height": resized_img.height,
388
 
@@ -399,6 +464,15 @@ class ProcessingPipeline:
399
 
400
  # Strategy used (important for paper comparison)
401
  "embedding_strategy": self.embedding_strategy,
 
 
 
 
 
 
 
 
 
402
 
403
  # Extra metadata (year, district, etc.)
404
  **extra_metadata,
@@ -408,8 +482,10 @@ class ProcessingPipeline:
408
  "id": chunk_id,
409
  "visual_embedding": embedding_for_initial, # "initial" vector in Qdrant
410
  "tile_pooled_embedding": embedding_for_pooling, # "mean_pooling" vector in Qdrant
 
 
411
  "metadata": metadata,
412
- "image": orig_img,
413
  "resized_image": resized_img,
414
  }
415
 
 
89
  metadata_mapping: Optional[Dict[str, Dict[str, Any]]] = None,
90
  config: Optional[Dict[str, Any]] = None,
91
  embedding_strategy: str = "pooling",
92
+ crop_empty: bool = False,
93
+ crop_empty_percentage_to_remove: float = 0.9,
94
+ crop_empty_remove_page_number: bool = False,
95
+ crop_empty_preserve_border_px: int = 1,
96
+ crop_empty_uniform_rowcol_std_threshold: float = 0.0,
97
  ):
98
  self.embedder = embedder
99
  self.indexer = indexer
 
108
  f"Must be one of: {self.STRATEGIES}"
109
  )
110
  self.embedding_strategy = embedding_strategy
111
+
112
+ self.crop_empty = bool(crop_empty)
113
+ self.crop_empty_percentage_to_remove = float(crop_empty_percentage_to_remove)
114
+ self.crop_empty_remove_page_number = bool(crop_empty_remove_page_number)
115
+ self.crop_empty_preserve_border_px = int(crop_empty_preserve_border_px)
116
+ self.crop_empty_uniform_rowcol_std_threshold = float(crop_empty_uniform_rowcol_std_threshold)
117
 
118
  logger.info(f"📊 Embedding strategy: {embedding_strategy}")
119
  if embedding_strategy == "pooling":
 
139
  skip_existing: bool = True,
140
  upload_to_cloudinary: bool = True,
141
  upload_to_qdrant: bool = True,
142
+ original_filename: Optional[str] = None,
143
+ progress_callback: Optional[callable] = None,
144
  ) -> Dict[str, Any]:
145
  """
146
  Process a single PDF end-to-end.
 
150
  skip_existing: Skip pages that already exist in Qdrant
151
  upload_to_cloudinary: Upload images to Cloudinary
152
  upload_to_qdrant: Upload embeddings to Qdrant
153
+ original_filename: Original filename (use this instead of pdf_path.name for temp files)
154
+ progress_callback: Optional callback(stage, current, total, message) for progress updates
155
 
156
  Returns:
157
  Dict with processing results:
 
165
  }
166
  """
167
  pdf_path = Path(pdf_path)
168
+ filename = original_filename or pdf_path.name
169
+ logger.info(f"📚 Processing PDF: {filename}")
170
 
171
  # Check existing pages
172
  existing_ids: Set[str] = set()
173
  if skip_existing and self.indexer:
174
+ existing_ids = self.indexer.get_existing_ids(filename)
175
  if existing_ids:
176
  logger.info(f" Found {len(existing_ids)} existing pages")
177
 
 
178
  logger.info(f"🖼️ Converting PDF to images...")
179
+ if progress_callback:
180
+ progress_callback("convert", 0, 0, "Converting PDF to images...")
181
  images, texts = self.pdf_processor.process_pdf(pdf_path)
182
  total_pages = len(images)
183
  logger.info(f" ✅ Converted {total_pages} pages")
184
+ if progress_callback:
185
+ progress_callback("convert", total_pages, total_pages, f"Converted {total_pages} pages")
186
 
187
+ extra_metadata = self._get_extra_metadata(filename)
 
188
  if extra_metadata:
189
  logger.info(f" 📋 Found extra metadata: {list(extra_metadata.keys())}")
190
 
 
201
  batch_texts = texts[batch_start:batch_end]
202
 
203
  logger.info(f"📦 Processing pages {batch_start + 1}-{batch_end}/{total_pages}")
204
+ if progress_callback:
205
+ progress_callback("embed", batch_start, total_pages, f"Embedding pages {batch_start + 1}-{batch_end}")
206
 
 
207
  pages_to_process = []
208
  for i, (img, text) in enumerate(zip(batch_images, batch_texts)):
209
  page_num = batch_start + i + 1
210
+ chunk_id = self.generate_chunk_id(filename, page_num)
211
 
212
  if skip_existing and chunk_id in existing_ids:
213
  skipped += 1
 
217
  "index": i,
218
  "page_num": page_num,
219
  "chunk_id": chunk_id,
220
+ "raw_image": img,
221
  "text": text,
222
  })
223
 
 
227
 
228
  # Generate embeddings with token info
229
  logger.info(f"🤖 Generating embeddings for {len(pages_to_process)} pages...")
230
+ from visual_rag.preprocessing.crop_empty import CropEmptyConfig, crop_empty
231
+
232
+ images_to_embed = []
233
+ for p in pages_to_process:
234
+ raw_img = p["raw_image"]
235
+ if self.crop_empty:
236
+ cropped_img, crop_meta = crop_empty(
237
+ raw_img,
238
+ config=CropEmptyConfig(
239
+ percentage_to_remove=float(self.crop_empty_percentage_to_remove),
240
+ remove_page_number=bool(self.crop_empty_remove_page_number),
241
+ preserve_border_px=int(self.crop_empty_preserve_border_px),
242
+ uniform_rowcol_std_threshold=float(self.crop_empty_uniform_rowcol_std_threshold),
243
+ ),
244
+ )
245
+ p["embed_image"] = cropped_img
246
+ p["crop_meta"] = crop_meta
247
+ images_to_embed.append(cropped_img)
248
+ else:
249
+ p["embed_image"] = raw_img
250
+ p["crop_meta"] = None
251
+ images_to_embed.append(raw_img)
252
 
253
  embeddings, token_infos = self.embedder.embed_images(
254
  images_to_embed,
255
  batch_size=self.embedding_batch_size,
256
  return_token_info=True,
257
+ show_progress=False,
258
  )
259
 
 
260
  for idx, page_info in enumerate(pages_to_process):
261
+ raw_img = page_info["raw_image"]
262
+ embed_img = page_info["embed_image"]
263
+ crop_meta = page_info["crop_meta"]
264
  page_num = page_info["page_num"]
265
  chunk_id = page_info["chunk_id"]
266
  text = page_info["text"]
267
  embedding = embeddings[idx]
268
  token_info = token_infos[idx]
269
 
270
+ if progress_callback:
271
+ progress_callback("process", page_num, total_pages, f"Processing page {page_num}/{total_pages}")
272
+
273
  try:
274
  page_data = self._process_single_page(
275
+ filename=filename,
276
+ pdf_stem=pdf_path.stem,
277
  page_num=page_num,
278
  chunk_id=chunk_id,
279
  total_pages=total_pages,
280
+ raw_img=raw_img,
281
+ embed_img=embed_img,
282
  text=text,
283
  embedding=embedding,
284
  token_info=token_info,
285
  extra_metadata=extra_metadata,
286
  upload_to_cloudinary=upload_to_cloudinary,
287
+ crop_meta=crop_meta,
288
  )
289
 
290
  all_pages.append(page_data)
 
312
  count = self._upload_batch(upload_queue)
313
  uploaded += count
314
 
315
+ logger.info(f"✅ Completed {filename}: {uploaded} uploaded, {skipped} skipped, {failed} failed")
316
 
317
  return {
318
+ "filename": filename,
319
  "total_pages": total_pages,
320
  "uploaded": uploaded,
321
  "skipped": skipped,
 
325
 
326
  def _process_single_page(
327
  self,
328
+ filename: str,
329
+ pdf_stem: str,
330
  page_num: int,
331
  chunk_id: str,
332
  total_pages: int,
333
+ raw_img,
334
+ embed_img,
335
  text: str,
336
  embedding: torch.Tensor,
337
  token_info: Dict[str, Any],
338
  extra_metadata: Dict[str, Any],
339
  upload_to_cloudinary: bool = True,
340
+ crop_meta: Optional[Dict[str, Any]] = None,
341
  ) -> Dict[str, Any]:
342
  """Process a single page with full metadata for saliency."""
343
+ from visual_rag.embedding.pooling import global_mean_pooling
344
 
345
  # Resize image for ColPali
346
+ resized_img, tile_rows, tile_cols = self.pdf_processor.resize_for_colpali(embed_img)
347
 
348
  # Use processor's tile info if available (more accurate)
349
  proc_n_rows = token_info.get("n_rows")
 
366
  visual_indices = token_info["visual_token_indices"]
367
  num_visual_tokens = token_info["num_visual_tokens"]
368
 
 
 
 
 
369
  # =========================================================================
370
  # STRATEGY: "pooling" (NOVEL) vs "standard" (BASELINE) vs "all" (BOTH)
371
  # =========================================================================
 
373
  # Always compute visual-only embedding (needed for pooling and saliency)
374
  visual_embedding = full_embedding[visual_indices]
375
 
376
+ tile_pooled = self.embedder.mean_pool_visual_embedding(visual_embedding, token_info, target_vectors=32)
377
+ experimental_pooled = self.embedder.experimental_pool_visual_embedding(
378
+ visual_embedding, token_info, target_vectors=32, mean_pool=tile_pooled
379
+ )
380
  global_pooled = global_mean_pooling(full_embedding)
381
+ global_pooling = self.embedder.global_pool_from_mean_pool(tile_pooled) if tile_pooled.size else global_pooled
382
+
383
+ num_tiles = int(tile_pooled.shape[0])
384
+ patches_per_tile = int(visual_embedding.shape[0] // max(num_tiles, 1)) if num_tiles else 0
385
+ if tile_rows and tile_cols and int(tile_rows) * int(tile_cols) + 1 == num_tiles:
386
+ pass
387
+ else:
388
+ tile_rows = token_info.get("n_rows") or None
389
+ tile_cols = token_info.get("n_cols") or None
390
 
391
  if self.embedding_strategy == "pooling":
392
  # NOVEL APPROACH: Visual tokens only + tile-level pooling
393
  embedding_for_initial = visual_embedding
394
  embedding_for_pooling = tile_pooled
395
+ global_pooling = self.embedder.global_pool_from_mean_pool(tile_pooled) if tile_pooled.size else global_pooled
396
 
397
  elif self.embedding_strategy == "standard":
398
  # BASELINE: All tokens + global mean
399
  embedding_for_initial = full_embedding
400
  embedding_for_pooling = global_pooled.reshape(1, -1)
401
+ global_pooling = global_pooled
402
 
403
  else: # "all" - Push BOTH representations (efficient for comparison)
404
  # Embed once, store multiple vector representations
405
  # This allows comparing both strategies without re-embedding
406
  embedding_for_initial = visual_embedding # Use visual for search
407
  embedding_for_pooling = tile_pooled # Use tile-level for fast prefetch
408
+ global_pooling = self.embedder.global_pool_from_mean_pool(tile_pooled) if tile_pooled.size else global_pooled
409
 
410
  # ALSO store standard representations as additional vectors
411
  # These will be added to metadata for optional use
 
413
 
414
  # Upload to Cloudinary
415
  original_url = None
416
+ cropped_url = None
417
  resized_url = None
418
 
419
  if upload_to_cloudinary and self.cloudinary_uploader:
420
+ base_filename = f"{pdf_stem}_page_{page_num}"
421
+ if self.crop_empty:
422
+ original_url, cropped_url, resized_url = self.cloudinary_uploader.upload_original_cropped_and_resized(
423
+ raw_img, embed_img, resized_img, base_filename
424
+ )
425
+ else:
426
+ original_url, resized_url = self.cloudinary_uploader.upload_original_and_resized(
427
+ raw_img, resized_img, base_filename
428
+ )
429
 
430
  # Sanitize text
431
  safe_text = self._sanitize_text(text[:10000]) if text else ""
432
 
 
433
  metadata = {
434
+ "filename": filename,
 
435
  "page_number": page_num,
436
  "total_pages": total_pages,
437
  "has_text": bool(text and text.strip()),
 
440
  # Image URLs
441
  "page": resized_url or "", # For display
442
  "original_url": original_url or "",
443
+ "cropped_url": cropped_url or "",
444
  "resized_url": resized_url or "",
445
 
446
  # Dimensions (needed for saliency overlay)
447
+ "original_width": raw_img.width,
448
+ "original_height": raw_img.height,
449
+ "cropped_width": int(embed_img.width) if self.crop_empty else int(raw_img.width),
450
+ "cropped_height": int(embed_img.height) if self.crop_empty else int(raw_img.height),
451
  "resized_width": resized_img.width,
452
  "resized_height": resized_img.height,
453
 
 
464
 
465
  # Strategy used (important for paper comparison)
466
  "embedding_strategy": self.embedding_strategy,
467
+
468
+ "model_name": getattr(self.embedder, "model_name", None),
469
+
470
+ "crop_empty_enabled": bool(self.crop_empty),
471
+ "crop_empty_crop_box": (crop_meta or {}).get("crop_box"),
472
+ "crop_empty_remove_page_number": bool(self.crop_empty_remove_page_number),
473
+ "crop_empty_percentage_to_remove": float(self.crop_empty_percentage_to_remove),
474
+ "crop_empty_preserve_border_px": int(self.crop_empty_preserve_border_px),
475
+ "crop_empty_uniform_rowcol_std_threshold": float(self.crop_empty_uniform_rowcol_std_threshold),
476
 
477
  # Extra metadata (year, district, etc.)
478
  **extra_metadata,
 
482
  "id": chunk_id,
483
  "visual_embedding": embedding_for_initial, # "initial" vector in Qdrant
484
  "tile_pooled_embedding": embedding_for_pooling, # "mean_pooling" vector in Qdrant
485
+ "experimental_pooled_embedding": experimental_pooled, # "experimental_pooling" vector in Qdrant
486
+ "global_pooled_embedding": global_pooling, # "global_pooling" vector in Qdrant
487
  "metadata": metadata,
488
+ "image": raw_img,
489
  "resized_image": resized_img,
490
  }
491
 
visual_rag/indexing/qdrant_indexer.py CHANGED
@@ -15,6 +15,7 @@ import time
15
  import hashlib
16
  import logging
17
  from typing import List, Dict, Any, Optional, Set
 
18
  import numpy as np
19
 
20
  logger = logging.getLogger(__name__)
@@ -54,6 +55,7 @@ class QdrantIndexer:
54
  collection_name: str,
55
  timeout: int = 60,
56
  prefer_grpc: bool = False,
 
57
  ):
58
  try:
59
  from qdrant_client import QdrantClient
@@ -65,17 +67,45 @@ class QdrantIndexer:
65
 
66
  self.collection_name = collection_name
67
  self.timeout = timeout
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
 
69
- self.client = QdrantClient(
70
- url=url,
71
- api_key=api_key,
72
- timeout=timeout,
73
- prefer_grpc=prefer_grpc,
74
- check_compatibility=False,
75
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
76
 
77
  logger.info(f"🔌 Connected to Qdrant: {url}")
78
  logger.info(f" Collection: {collection_name}")
 
79
 
80
  def collection_exists(self) -> bool:
81
  """Check if collection exists."""
@@ -87,31 +117,29 @@ class QdrantIndexer:
87
  embedding_dim: int = 128,
88
  force_recreate: bool = False,
89
  enable_quantization: bool = False,
 
 
90
  ) -> bool:
91
  """
92
  Create collection with multi-vector support.
93
 
94
- Creates two named vectors:
95
  - initial: Full multi-vector embeddings (num_patches × dim)
96
  - mean_pooling: Tile-level pooled vectors (num_tiles × dim)
 
 
97
 
98
  Args:
99
  embedding_dim: Embedding dimension (128 for ColSmol)
100
  force_recreate: Delete and recreate if exists
101
  enable_quantization: Enable int8 quantization
 
102
 
103
  Returns:
104
  True if created, False if already existed
105
  """
106
  from qdrant_client.http import models
107
- from qdrant_client.http.models import (
108
- Distance,
109
- VectorParams,
110
- OptimizersConfigDiff,
111
- HnswConfigDiff,
112
- ScalarQuantizationConfig,
113
- ScalarType,
114
- )
115
 
116
  if self.collection_exists():
117
  if force_recreate:
@@ -128,54 +156,41 @@ class QdrantIndexer:
128
  comparator=models.MultiVectorComparator.MAX_SIM
129
  )
130
 
131
- # HNSW config for pooled vectors
132
- hnsw_config = HnswConfigDiff(
133
- m=32,
134
- ef_construct=100,
135
- full_scan_threshold=10000,
136
- on_disk=True,
137
- )
138
-
139
- # Optional quantization
140
- quantization_config = None
141
- if enable_quantization:
142
- logger.info(" Quantization: ENABLED (int8)")
143
- quantization_config = ScalarQuantizationConfig(
144
- type=ScalarType.INT8,
145
- quantile=0.99,
146
- always_ram=True,
147
- )
148
-
149
- # Vector configs
150
  vectors_config = {
151
  "initial": VectorParams(
152
  size=embedding_dim,
153
  distance=Distance.COSINE,
154
  on_disk=True,
155
  multivector_config=multivector_config,
156
- quantization_config=quantization_config,
157
  ),
158
  "mean_pooling": VectorParams(
159
  size=embedding_dim,
160
  distance=Distance.COSINE,
161
- on_disk=False, # Keep in RAM for fast prefetch
162
  multivector_config=multivector_config,
163
- hnsw_config=hnsw_config,
164
- quantization_config=quantization_config,
 
 
 
 
 
 
 
 
 
 
 
 
165
  ),
166
  }
167
 
168
- # Optimizer config for low-RAM clusters
169
- optimizer_config = OptimizersConfigDiff(
170
- indexing_threshold=20000, # High threshold to avoid indexing (saves RAM)
171
- memmap_threshold=0, # Use mmap immediately
172
- flush_interval_sec=5, # Flush WAL frequently
173
- )
174
-
175
  self.client.create_collection(
176
  collection_name=self.collection_name,
177
  vectors_config=vectors_config,
178
- optimizers_config=optimizer_config,
179
  )
180
 
181
  logger.info(f"✅ Collection created: {self.collection_name}")
@@ -202,16 +217,8 @@ class QdrantIndexer:
202
  "text": models.PayloadSchemaType.TEXT,
203
  }
204
 
205
- # Default fields
206
- if fields is None:
207
- fields = [
208
- {"field": "filename", "type": "keyword"},
209
- {"field": "page_number", "type": "integer"},
210
- {"field": "year", "type": "integer"},
211
- {"field": "source", "type": "keyword"},
212
- {"field": "district", "type": "keyword"},
213
- {"field": "has_text", "type": "bool"},
214
- ]
215
 
216
  logger.info("📇 Creating payload indexes...")
217
 
@@ -235,6 +242,8 @@ class QdrantIndexer:
235
  points: List[Dict[str, Any]],
236
  max_retries: int = 3,
237
  delay_between_batches: float = 0.5,
 
 
238
  ) -> int:
239
  """
240
  Upload a batch of points to Qdrant.
@@ -243,12 +252,16 @@ class QdrantIndexer:
243
  - id: Unique point ID (string or UUID)
244
  - visual_embedding: Full embedding [num_patches, dim]
245
  - tile_pooled_embedding: Pooled embedding [num_tiles, dim]
 
 
246
  - metadata: Payload dict
247
 
248
  Args:
249
  points: List of point dicts
250
  max_retries: Retry attempts on failure
251
  delay_between_batches: Delay after upload
 
 
252
 
253
  Returns:
254
  Number of successfully uploaded points
@@ -257,39 +270,115 @@ class QdrantIndexer:
257
 
258
  if not points:
259
  return 0
 
 
 
260
 
261
- # Build Qdrant points
262
- qdrant_points = []
263
- for point_data in points:
264
- point = models.PointStruct(
265
- id=point_data["id"],
266
- vector={
267
- "initial": point_data["visual_embedding"].tolist(),
268
- "mean_pooling": point_data["tile_pooled_embedding"].tolist(),
269
- },
270
- payload=point_data["metadata"],
271
- )
272
- qdrant_points.append(point)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
273
 
274
  # Upload with retry
275
  for attempt in range(max_retries):
276
  try:
 
 
 
277
  self.client.upsert(
278
  collection_name=self.collection_name,
279
  points=qdrant_points,
280
- wait=True,
281
  )
282
-
283
  if delay_between_batches > 0:
 
 
284
  time.sleep(delay_between_batches)
285
-
286
- return len(qdrant_points)
287
-
288
  except Exception as e:
289
- logger.warning(
290
- f"Upload attempt {attempt + 1}/{max_retries} failed: {e}"
291
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
292
  if attempt < max_retries - 1:
 
 
293
  time.sleep(2 ** attempt) # Exponential backoff
294
 
295
  logger.error(f"❌ Upload failed after {max_retries} attempts")
 
15
  import hashlib
16
  import logging
17
  from typing import List, Dict, Any, Optional, Set
18
+ from urllib.parse import urlparse
19
  import numpy as np
20
 
21
  logger = logging.getLogger(__name__)
 
55
  collection_name: str,
56
  timeout: int = 60,
57
  prefer_grpc: bool = False,
58
+ vector_datatype: str = "float32",
59
  ):
60
  try:
61
  from qdrant_client import QdrantClient
 
67
 
68
  self.collection_name = collection_name
69
  self.timeout = timeout
70
+ if vector_datatype not in ("float32", "float16"):
71
+ raise ValueError("vector_datatype must be 'float32' or 'float16'")
72
+ self.vector_datatype = vector_datatype
73
+ self._np_vector_dtype = np.float16 if vector_datatype == "float16" else np.float32
74
+
75
+ grpc_port = None
76
+ if prefer_grpc:
77
+ try:
78
+ parsed = urlparse(url)
79
+ port = parsed.port
80
+ if port == 6333:
81
+ grpc_port = 6334
82
+ except Exception:
83
+ grpc_port = None
84
 
85
+ def _make_client(use_grpc: bool):
86
+ return QdrantClient(
87
+ url=url,
88
+ api_key=api_key,
89
+ timeout=timeout,
90
+ prefer_grpc=bool(use_grpc),
91
+ grpc_port=grpc_port,
92
+ check_compatibility=False,
93
+ )
94
+
95
+ self.client = _make_client(prefer_grpc)
96
+ if prefer_grpc:
97
+ try:
98
+ _ = self.client.get_collections()
99
+ except Exception as e:
100
+ msg = str(e)
101
+ if "StatusCode.PERMISSION_DENIED" in msg or "http2 header with status: 403" in msg:
102
+ self.client = _make_client(False)
103
+ else:
104
+ raise
105
 
106
  logger.info(f"🔌 Connected to Qdrant: {url}")
107
  logger.info(f" Collection: {collection_name}")
108
+ logger.info(f" Vector datatype: {self.vector_datatype}")
109
 
110
  def collection_exists(self) -> bool:
111
  """Check if collection exists."""
 
117
  embedding_dim: int = 128,
118
  force_recreate: bool = False,
119
  enable_quantization: bool = False,
120
+ indexing_threshold: int = 20000,
121
+ full_scan_threshold: int = 0,
122
  ) -> bool:
123
  """
124
  Create collection with multi-vector support.
125
 
126
+ Creates named vectors:
127
  - initial: Full multi-vector embeddings (num_patches × dim)
128
  - mean_pooling: Tile-level pooled vectors (num_tiles × dim)
129
+ - experimental_pooling: Experimental multi-vector pooling (varies by model)
130
+ - global_pooling: Single vector pooled representation (dim)
131
 
132
  Args:
133
  embedding_dim: Embedding dimension (128 for ColSmol)
134
  force_recreate: Delete and recreate if exists
135
  enable_quantization: Enable int8 quantization
136
+ indexing_threshold: Qdrant optimizer indexing threshold (set 0 to always build ANN indexes)
137
 
138
  Returns:
139
  True if created, False if already existed
140
  """
141
  from qdrant_client.http import models
142
+ from qdrant_client.http.models import Distance, VectorParams
 
 
 
 
 
 
 
143
 
144
  if self.collection_exists():
145
  if force_recreate:
 
156
  comparator=models.MultiVectorComparator.MAX_SIM
157
  )
158
 
159
+ # Vector configs - simplified for compatibility
160
+ datatype = models.Datatype.FLOAT16 if self.vector_datatype == "float16" else models.Datatype.FLOAT32
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
  vectors_config = {
162
  "initial": VectorParams(
163
  size=embedding_dim,
164
  distance=Distance.COSINE,
165
  on_disk=True,
166
  multivector_config=multivector_config,
167
+ datatype=datatype,
168
  ),
169
  "mean_pooling": VectorParams(
170
  size=embedding_dim,
171
  distance=Distance.COSINE,
172
+ on_disk=False,
173
  multivector_config=multivector_config,
174
+ datatype=datatype,
175
+ ),
176
+ "experimental_pooling": VectorParams(
177
+ size=embedding_dim,
178
+ distance=Distance.COSINE,
179
+ on_disk=False,
180
+ multivector_config=multivector_config,
181
+ datatype=datatype,
182
+ ),
183
+ "global_pooling": VectorParams(
184
+ size=embedding_dim,
185
+ distance=Distance.COSINE,
186
+ on_disk=False,
187
+ datatype=datatype,
188
  ),
189
  }
190
 
 
 
 
 
 
 
 
191
  self.client.create_collection(
192
  collection_name=self.collection_name,
193
  vectors_config=vectors_config,
 
194
  )
195
 
196
  logger.info(f"✅ Collection created: {self.collection_name}")
 
217
  "text": models.PayloadSchemaType.TEXT,
218
  }
219
 
220
+ if not fields:
221
+ return
 
 
 
 
 
 
 
 
222
 
223
  logger.info("📇 Creating payload indexes...")
224
 
 
242
  points: List[Dict[str, Any]],
243
  max_retries: int = 3,
244
  delay_between_batches: float = 0.5,
245
+ wait: bool = True,
246
+ stop_event=None,
247
  ) -> int:
248
  """
249
  Upload a batch of points to Qdrant.
 
252
  - id: Unique point ID (string or UUID)
253
  - visual_embedding: Full embedding [num_patches, dim]
254
  - tile_pooled_embedding: Pooled embedding [num_tiles, dim]
255
+ - experimental_pooled_embedding: Experimental pooled embedding [*, dim]
256
+ - global_pooled_embedding: Pooled embedding [dim]
257
  - metadata: Payload dict
258
 
259
  Args:
260
  points: List of point dicts
261
  max_retries: Retry attempts on failure
262
  delay_between_batches: Delay after upload
263
+ wait: Wait for operation to complete on Qdrant server
264
+ stop_event: Optional threading.Event used to cancel uploads early
265
 
266
  Returns:
267
  Number of successfully uploaded points
 
270
 
271
  if not points:
272
  return 0
273
+
274
+ def _is_cancelled() -> bool:
275
+ return stop_event is not None and getattr(stop_event, "is_set", lambda: False)()
276
 
277
+ def _is_payload_too_large_error(e: Exception) -> bool:
278
+ msg = str(e)
279
+ if ("JSON payload" in msg and "larger than allowed" in msg) or ("Payload error:" in msg and "limit:" in msg):
280
+ return True
281
+ content = getattr(e, "content", None)
282
+ if content is not None:
283
+ try:
284
+ if isinstance(content, (bytes, bytearray)):
285
+ text = content.decode("utf-8", errors="ignore")
286
+ else:
287
+ text = str(content)
288
+ except Exception:
289
+ text = ""
290
+ if ("JSON payload" in text and "larger than allowed" in text) or ("Payload error" in text and "limit" in text):
291
+ return True
292
+ resp = getattr(e, "response", None)
293
+ if resp is not None:
294
+ try:
295
+ text = str(getattr(resp, "text", "") or "")
296
+ except Exception:
297
+ text = ""
298
+ if ("JSON payload" in text and "larger than allowed" in text) or ("Payload error" in text and "limit" in text):
299
+ return True
300
+ return False
301
+
302
+ def _to_list(val):
303
+ if isinstance(val, np.ndarray):
304
+ return val.tolist()
305
+ return val
306
+
307
+ def _build_qdrant_points(batch_points: List[Dict[str, Any]]) -> List[models.PointStruct]:
308
+ qdrant_points: List[models.PointStruct] = []
309
+ for p in batch_points:
310
+ global_pooled = p.get("global_pooled_embedding")
311
+ if global_pooled is None:
312
+ tile_pooled = np.array(p["tile_pooled_embedding"], dtype=np.float32)
313
+ global_pooled = tile_pooled.mean(axis=0)
314
+ global_pooled = np.array(global_pooled, dtype=np.float32).reshape(-1)
315
+
316
+ initial = np.array(p["visual_embedding"], dtype=np.float32).astype(self._np_vector_dtype, copy=False)
317
+ mean_pooling = np.array(p["tile_pooled_embedding"], dtype=np.float32).astype(self._np_vector_dtype, copy=False)
318
+ experimental_pooling = np.array(p["experimental_pooled_embedding"], dtype=np.float32).astype(
319
+ self._np_vector_dtype, copy=False
320
+ )
321
+ global_pooling = global_pooled.astype(self._np_vector_dtype, copy=False)
322
+
323
+ qdrant_points.append(
324
+ models.PointStruct(
325
+ id=p["id"],
326
+ vector={
327
+ "initial": _to_list(initial),
328
+ "mean_pooling": _to_list(mean_pooling),
329
+ "experimental_pooling": _to_list(experimental_pooling),
330
+ "global_pooling": _to_list(global_pooling),
331
+ },
332
+ payload=p["metadata"],
333
+ )
334
+ )
335
+ return qdrant_points
336
 
337
  # Upload with retry
338
  for attempt in range(max_retries):
339
  try:
340
+ if _is_cancelled():
341
+ return 0
342
+ qdrant_points = _build_qdrant_points(points)
343
  self.client.upsert(
344
  collection_name=self.collection_name,
345
  points=qdrant_points,
346
+ wait=wait,
347
  )
348
+
349
  if delay_between_batches > 0:
350
+ if _is_cancelled():
351
+ return 0
352
  time.sleep(delay_between_batches)
353
+
354
+ return len(points)
355
+
356
  except Exception as e:
357
+ if _is_payload_too_large_error(e) and len(points) > 1:
358
+ mid = len(points) // 2
359
+ left = points[:mid]
360
+ right = points[mid:]
361
+ logger.warning(
362
+ f"Upload payload too large for {len(points)} points; splitting into {len(left)} + {len(right)}"
363
+ )
364
+ return self.upload_batch(
365
+ left,
366
+ max_retries=max_retries,
367
+ delay_between_batches=delay_between_batches,
368
+ wait=wait,
369
+ stop_event=stop_event,
370
+ ) + self.upload_batch(
371
+ right,
372
+ max_retries=max_retries,
373
+ delay_between_batches=delay_between_batches,
374
+ wait=wait,
375
+ stop_event=stop_event,
376
+ )
377
+
378
+ logger.warning(f"Upload attempt {attempt + 1}/{max_retries} failed: {e}")
379
  if attempt < max_retries - 1:
380
+ if _is_cancelled():
381
+ return 0
382
  time.sleep(2 ** attempt) # Exponential backoff
383
 
384
  logger.error(f"❌ Upload failed after {max_retries} attempts")
visual_rag/preprocessing/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ from visual_rag.preprocessing.crop_empty import CropEmptyConfig, crop_empty
2
+
3
+ __all__ = ["CropEmptyConfig", "crop_empty"]
4
+
5
+
visual_rag/preprocessing/crop_empty.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Dict, Tuple
5
+
6
+ import numpy as np
7
+ from PIL import Image
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class CropEmptyConfig:
12
+ percentage_to_remove: float = 0.9
13
+ remove_page_number: bool = False
14
+ color_threshold: int = 240
15
+ min_white_fraction: float = 0.99
16
+ content_density_sides: float = 0.001
17
+ content_density_main_text: float = 0.05
18
+ content_density_any: float = 1e-6
19
+ preserve_border_px: int = 1
20
+ uniform_rowcol_std_threshold: float = 0.0
21
+
22
+
23
+ def crop_empty(image: Image.Image, *, config: CropEmptyConfig) -> Tuple[Image.Image, Dict[str, Any]]:
24
+ img = image.convert("RGB")
25
+ arr = np.array(img)
26
+ intensity = arr.mean(axis=2)
27
+
28
+ def _find_border_start(axis: int, *, min_content_density_threshold: float) -> int:
29
+ size = intensity.shape[axis]
30
+ for i in range(size):
31
+ pixels = intensity[i, :] if axis == 0 else intensity[:, i]
32
+ white = float(np.mean(pixels > config.color_threshold))
33
+ non_white = 1.0 - white
34
+ if float(config.uniform_rowcol_std_threshold) > 0.0 and float(np.std(pixels)) <= float(config.uniform_rowcol_std_threshold):
35
+ continue
36
+ if (white < config.min_white_fraction) and (non_white > min_content_density_threshold):
37
+ return int(i)
38
+ return int(size)
39
+
40
+ def _find_border_end(axis: int, *, min_content_density_threshold: float) -> int:
41
+ size = intensity.shape[axis]
42
+ for i in range(size - 1, -1, -1):
43
+ pixels = intensity[i, :] if axis == 0 else intensity[:, i]
44
+ white = float(np.mean(pixels > config.color_threshold))
45
+ non_white = 1.0 - white
46
+ if float(config.uniform_rowcol_std_threshold) > 0.0 and float(np.std(pixels)) <= float(config.uniform_rowcol_std_threshold):
47
+ continue
48
+ if (white < config.min_white_fraction) and (non_white > min_content_density_threshold):
49
+ return int(i + 1)
50
+ return 0
51
+
52
+ top = _find_border_start(0, min_content_density_threshold=float(config.content_density_sides))
53
+ left = _find_border_start(1, min_content_density_threshold=float(config.content_density_sides))
54
+ right = _find_border_end(1, min_content_density_threshold=float(config.content_density_sides))
55
+
56
+ main_text_end = _find_border_end(0, min_content_density_threshold=float(config.content_density_main_text))
57
+ last_content_end = _find_border_end(0, min_content_density_threshold=float(config.content_density_any))
58
+ bottom = main_text_end if config.remove_page_number else last_content_end
59
+
60
+ width, height = img.size
61
+ pad = max(int(getattr(config, "preserve_border_px", 0) or 0), 0)
62
+ if pad > 0:
63
+ left = max(int(left) - pad, 0)
64
+ top = max(int(top) - pad, 0)
65
+ right = min(int(right) + pad, int(width))
66
+ bottom = min(int(bottom) + pad, int(height))
67
+ crop_box = (int(left), int(top), int(right), int(bottom))
68
+ valid = 0 <= crop_box[0] < crop_box[2] <= width and 0 <= crop_box[1] < crop_box[3] <= height
69
+
70
+ if not valid:
71
+ return image, {
72
+ "applied": False,
73
+ "crop_box": None,
74
+ "original_width": int(width),
75
+ "original_height": int(height),
76
+ "cropped_width": int(width),
77
+ "cropped_height": int(height),
78
+ "config": {
79
+ "percentage_to_remove": float(config.percentage_to_remove),
80
+ "remove_page_number": bool(config.remove_page_number),
81
+ "color_threshold": int(config.color_threshold),
82
+ "min_white_fraction": float(config.min_white_fraction),
83
+ "content_density_sides": float(config.content_density_sides),
84
+ "content_density_main_text": float(config.content_density_main_text),
85
+ "content_density_any": float(config.content_density_any),
86
+ "preserve_border_px": int(config.preserve_border_px),
87
+ "uniform_rowcol_std_threshold": float(config.uniform_rowcol_std_threshold),
88
+ },
89
+ }
90
+
91
+ cropped = img.crop(crop_box)
92
+ return cropped, {
93
+ "applied": True,
94
+ "crop_box": [int(crop_box[0]), int(crop_box[1]), int(crop_box[2]), int(crop_box[3])],
95
+ "original_width": int(width),
96
+ "original_height": int(height),
97
+ "cropped_width": int(cropped.width),
98
+ "cropped_height": int(cropped.height),
99
+ "config": {
100
+ "percentage_to_remove": float(config.percentage_to_remove),
101
+ "remove_page_number": bool(config.remove_page_number),
102
+ "color_threshold": int(config.color_threshold),
103
+ "min_white_fraction": float(config.min_white_fraction),
104
+ "content_density_sides": float(config.content_density_sides),
105
+ "content_density_main_text": float(config.content_density_main_text),
106
+ "content_density_any": float(config.content_density_any),
107
+ "preserve_border_px": int(config.preserve_border_px),
108
+ "uniform_rowcol_std_threshold": float(config.uniform_rowcol_std_threshold),
109
+ },
110
+ }
111
+
112
+
visual_rag/qdrant_admin.py ADDED
@@ -0,0 +1,205 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Dict, Optional
5
+ from urllib.parse import urlparse
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class QdrantConnection:
10
+ url: str
11
+ api_key: Optional[str]
12
+
13
+
14
+ def _maybe_load_dotenv() -> None:
15
+ try:
16
+ from dotenv import load_dotenv
17
+ except Exception:
18
+ return
19
+ try:
20
+ from pathlib import Path
21
+
22
+ if Path(".env").exists():
23
+ load_dotenv(".env")
24
+ except Exception:
25
+ return
26
+
27
+
28
+ def _resolve_qdrant_connection(
29
+ *,
30
+ url: Optional[str] = None,
31
+ api_key: Optional[str] = None,
32
+ ) -> QdrantConnection:
33
+ import os
34
+
35
+ _maybe_load_dotenv()
36
+ resolved_url = url or os.getenv("SIGIR_QDRANT_URL") or os.getenv("DEST_QDRANT_URL") or os.getenv("QDRANT_URL")
37
+ if not resolved_url:
38
+ raise ValueError("Qdrant URL not set (pass url= or set SIGIR_QDRANT_URL/DEST_QDRANT_URL/QDRANT_URL).")
39
+ resolved_key = (
40
+ api_key
41
+ or os.getenv("SIGIR_QDRANT_KEY")
42
+ or os.getenv("SIGIR_QDRANT_API_KEY")
43
+ or os.getenv("DEST_QDRANT_API_KEY")
44
+ or os.getenv("QDRANT_API_KEY")
45
+ )
46
+ return QdrantConnection(url=str(resolved_url), api_key=resolved_key)
47
+
48
+
49
+ def _infer_grpc_port(url: str) -> Optional[int]:
50
+ try:
51
+ if urlparse(url).port == 6333:
52
+ return 6334
53
+ except Exception:
54
+ return None
55
+ return None
56
+
57
+
58
+ class QdrantAdmin:
59
+ def __init__(
60
+ self,
61
+ *,
62
+ url: Optional[str] = None,
63
+ api_key: Optional[str] = None,
64
+ prefer_grpc: bool = False,
65
+ timeout: int = 60,
66
+ ):
67
+ from qdrant_client import QdrantClient
68
+
69
+ conn = _resolve_qdrant_connection(url=url, api_key=api_key)
70
+ grpc_port = _infer_grpc_port(conn.url) if prefer_grpc else None
71
+ self.client = QdrantClient(
72
+ url=conn.url,
73
+ api_key=conn.api_key,
74
+ prefer_grpc=bool(prefer_grpc),
75
+ grpc_port=grpc_port,
76
+ timeout=int(timeout),
77
+ check_compatibility=False,
78
+ )
79
+
80
+ def get_collection_info(self, *, collection_name: str) -> Dict[str, Any]:
81
+ info = self.client.get_collection(collection_name)
82
+ try:
83
+ return info.model_dump()
84
+ except Exception:
85
+ try:
86
+ return info.dict()
87
+ except Exception:
88
+ return {"collection": str(collection_name), "raw": str(info)}
89
+
90
+ def modify_collection_config(
91
+ self,
92
+ *,
93
+ collection_name: str,
94
+ hnsw_config: Optional[Dict[str, Any]] = None,
95
+ collection_params: Optional[Dict[str, Any]] = None,
96
+ timeout: Optional[int] = None,
97
+ ) -> bool:
98
+ """
99
+ Patch collection-level config via Qdrant update_collection.
100
+
101
+ Supported keys:
102
+ - hnsw_config: dict for HnswConfigDiff (e.g. on_disk, m, ef_construct, full_scan_threshold)
103
+ - collection_params: dict for CollectionParamsDiff (e.g. on_disk_payload)
104
+ """
105
+ from qdrant_client.http import models as m
106
+
107
+ hnsw_diff = m.HnswConfigDiff(**hnsw_config) if isinstance(hnsw_config, dict) else None
108
+ params_diff = m.CollectionParamsDiff(**collection_params) if isinstance(collection_params, dict) else None
109
+ if hnsw_diff is None and params_diff is None:
110
+ raise ValueError("No changes provided (pass hnsw_config and/or collection_params).")
111
+ return bool(
112
+ self.client.update_collection(
113
+ collection_name=str(collection_name),
114
+ hnsw_config=hnsw_diff,
115
+ collection_params=params_diff,
116
+ timeout=int(timeout) if timeout is not None else None,
117
+ )
118
+ )
119
+
120
+ def modify_collection_vector_config(
121
+ self,
122
+ *,
123
+ collection_name: str,
124
+ vectors: Dict[str, Dict[str, Any]],
125
+ timeout: Optional[int] = None,
126
+ ) -> bool:
127
+ """
128
+ Patch vector params under params.vectors[vector_name] using Qdrant update_collection.
129
+
130
+ Supported keys per vector:
131
+ - on_disk: bool
132
+ - hnsw_config: dict with optional keys: m, ef_construct, full_scan_threshold, on_disk
133
+ """
134
+ from qdrant_client.http import models as m
135
+
136
+ collection_name = str(collection_name)
137
+ info = self.client.get_collection(collection_name)
138
+ existing = set()
139
+ try:
140
+ existing = set((info.config.params.vectors or {}).keys())
141
+ except Exception:
142
+ existing = set()
143
+
144
+ missing = [str(k) for k in (vectors or {}).keys() if existing and str(k) not in existing]
145
+ if missing:
146
+ raise ValueError(f"Vectors do not exist in collection '{collection_name}': {missing}. Existing: {sorted(existing)}")
147
+
148
+ ok = True
149
+ for name, cfg in (vectors or {}).items():
150
+ if not isinstance(cfg, dict):
151
+ raise ValueError(f"vectors['{name}'] must be a dict, got {type(cfg)}")
152
+ hnsw_cfg = cfg.get("hnsw_config")
153
+ hnsw_diff = m.HnswConfigDiff(**hnsw_cfg) if isinstance(hnsw_cfg, dict) else None
154
+ vectors_diff = {
155
+ str(name): m.VectorParamsDiff(
156
+ on_disk=cfg.get("on_disk", None),
157
+ hnsw_config=hnsw_diff,
158
+ )
159
+ }
160
+
161
+ ok = bool(
162
+ self.client.update_collection(
163
+ collection_name=collection_name,
164
+ vectors_config=vectors_diff,
165
+ timeout=int(timeout) if timeout is not None else None,
166
+ )
167
+ ) and ok
168
+
169
+ return ok
170
+
171
+ def ensure_collection_all_on_disk(
172
+ self,
173
+ *,
174
+ collection_name: str,
175
+ timeout: Optional[int] = None,
176
+ ) -> Dict[str, Any]:
177
+ """
178
+ Ensure:
179
+ - All existing named vectors have on_disk=True and hnsw_config.on_disk=True
180
+ - Collection hnsw_config.on_disk=True
181
+ - Collection params.on_disk_payload=True
182
+ Returns the post-update collection info (dict).
183
+ """
184
+ collection_name = str(collection_name)
185
+ info = self.client.get_collection(collection_name)
186
+ vectors = {}
187
+ try:
188
+ existing = list((info.config.params.vectors or {}).keys())
189
+ except Exception:
190
+ existing = []
191
+ for vname in existing:
192
+ vectors[str(vname)] = {"on_disk": True, "hnsw_config": {"on_disk": True}}
193
+
194
+ if vectors:
195
+ self.modify_collection_vector_config(collection_name=collection_name, vectors=vectors, timeout=timeout)
196
+
197
+ self.modify_collection_config(
198
+ collection_name=collection_name,
199
+ hnsw_config={"on_disk": True},
200
+ collection_params={"on_disk_payload": True},
201
+ timeout=timeout,
202
+ )
203
+
204
+ return self.get_collection_info(collection_name=collection_name)
205
+
visual_rag/retrieval/__init__.py CHANGED
@@ -7,7 +7,13 @@ Components:
7
  """
8
 
9
  from visual_rag.retrieval.two_stage import TwoStageRetriever
 
 
 
10
 
11
  __all__ = [
12
  "TwoStageRetriever",
 
 
 
13
  ]
 
7
  """
8
 
9
  from visual_rag.retrieval.two_stage import TwoStageRetriever
10
+ from visual_rag.retrieval.single_stage import SingleStageRetriever
11
+ from visual_rag.retrieval.multi_vector import MultiVectorRetriever
12
+ from visual_rag.retrieval.three_stage import ThreeStageRetriever
13
 
14
  __all__ = [
15
  "TwoStageRetriever",
16
+ "SingleStageRetriever",
17
+ "MultiVectorRetriever",
18
+ "ThreeStageRetriever",
19
  ]
visual_rag/retrieval/multi_vector.py ADDED
@@ -0,0 +1,220 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from typing import Any, Dict, List, Optional
3
+ from urllib.parse import urlparse
4
+
5
+ from visual_rag.embedding.visual_embedder import VisualEmbedder
6
+ from visual_rag.retrieval.single_stage import SingleStageRetriever
7
+ from visual_rag.retrieval.two_stage import TwoStageRetriever
8
+ from visual_rag.retrieval.three_stage import ThreeStageRetriever
9
+
10
+
11
+ class MultiVectorRetriever:
12
+ @staticmethod
13
+ def _maybe_load_dotenv() -> None:
14
+ try:
15
+ from dotenv import load_dotenv
16
+ except ImportError:
17
+ return
18
+ if os.path.exists(".env"):
19
+ load_dotenv(".env")
20
+
21
+ def __init__(
22
+ self,
23
+ collection_name: str,
24
+ model_name: str = "vidore/colSmol-500M",
25
+ qdrant_url: Optional[str] = None,
26
+ qdrant_api_key: Optional[str] = None,
27
+ prefer_grpc: bool = False,
28
+ request_timeout: int = 120,
29
+ max_retries: int = 3,
30
+ retry_sleep: float = 0.5,
31
+ qdrant_client=None,
32
+ embedder: Optional[VisualEmbedder] = None,
33
+ ):
34
+ if qdrant_client is None:
35
+ self._maybe_load_dotenv()
36
+ try:
37
+ from qdrant_client import QdrantClient
38
+ except ImportError as e:
39
+ raise ImportError(
40
+ "Qdrant client not installed. Install with: pip install visual-rag-toolkit[qdrant]"
41
+ ) from e
42
+
43
+ qdrant_url = (
44
+ qdrant_url
45
+ or os.getenv("SIGIR_QDRANT_URL")
46
+ or os.getenv("DEST_QDRANT_URL")
47
+ or os.getenv("QDRANT_URL")
48
+ )
49
+ if not qdrant_url:
50
+ raise ValueError(
51
+ "QDRANT_URL is required (pass qdrant_url or set env var). "
52
+ "You can also set DEST_QDRANT_URL to override."
53
+ )
54
+
55
+ qdrant_api_key = (
56
+ qdrant_api_key
57
+ or os.getenv("SIGIR_QDRANT_KEY")
58
+ or os.getenv("SIGIR_QDRANT_API_KEY")
59
+ or os.getenv("DEST_QDRANT_API_KEY")
60
+ or os.getenv("QDRANT_API_KEY")
61
+ )
62
+
63
+ grpc_port = None
64
+ if prefer_grpc:
65
+ try:
66
+ if urlparse(qdrant_url).port == 6333:
67
+ grpc_port = 6334
68
+ except Exception:
69
+ grpc_port = None
70
+ def _make_client(use_grpc: bool):
71
+ return QdrantClient(
72
+ url=qdrant_url,
73
+ api_key=qdrant_api_key,
74
+ prefer_grpc=bool(use_grpc),
75
+ grpc_port=grpc_port,
76
+ timeout=int(request_timeout),
77
+ check_compatibility=False,
78
+ )
79
+
80
+ qdrant_client = _make_client(prefer_grpc)
81
+ if prefer_grpc:
82
+ try:
83
+ _ = qdrant_client.get_collections()
84
+ except Exception as e:
85
+ msg = str(e)
86
+ if "StatusCode.PERMISSION_DENIED" in msg or "http2 header with status: 403" in msg:
87
+ qdrant_client = _make_client(False)
88
+ else:
89
+ raise
90
+
91
+ self.client = qdrant_client
92
+ self.collection_name = collection_name
93
+ self.embedder = embedder or VisualEmbedder(model_name=model_name)
94
+
95
+ self._two_stage = TwoStageRetriever(
96
+ self.client,
97
+ collection_name=self.collection_name,
98
+ request_timeout=int(request_timeout),
99
+ max_retries=int(max_retries),
100
+ retry_sleep=float(retry_sleep),
101
+ )
102
+ self._three_stage = ThreeStageRetriever(
103
+ self.client,
104
+ collection_name=self.collection_name,
105
+ request_timeout=int(request_timeout),
106
+ max_retries=int(max_retries),
107
+ retry_sleep=float(retry_sleep),
108
+ )
109
+ self._single_stage = SingleStageRetriever(
110
+ self.client,
111
+ collection_name=self.collection_name,
112
+ request_timeout=int(request_timeout),
113
+ )
114
+
115
+ def build_filter(
116
+ self,
117
+ year: Optional[Any] = None,
118
+ source: Optional[str] = None,
119
+ district: Optional[str] = None,
120
+ filename: Optional[str] = None,
121
+ has_text: Optional[bool] = None,
122
+ ):
123
+ return self._two_stage.build_filter(
124
+ year=year,
125
+ source=source,
126
+ district=district,
127
+ filename=filename,
128
+ has_text=has_text,
129
+ )
130
+
131
+ def search(
132
+ self,
133
+ query: str,
134
+ top_k: int = 10,
135
+ mode: str = "single_full",
136
+ prefetch_k: Optional[int] = None,
137
+ stage1_mode: str = "pooled_query_vs_tiles",
138
+ filter_obj=None,
139
+ return_embeddings: bool = False,
140
+ ) -> List[Dict[str, Any]]:
141
+ q = self.embedder.embed_query(query)
142
+ try:
143
+ import torch
144
+ except ImportError:
145
+ torch = None
146
+ if torch is not None and isinstance(q, torch.Tensor):
147
+ query_embedding = q.detach().cpu().numpy()
148
+ else:
149
+ query_embedding = q.numpy()
150
+
151
+ return self.search_embedded(
152
+ query_embedding=query_embedding,
153
+ top_k=top_k,
154
+ mode=mode,
155
+ prefetch_k=prefetch_k,
156
+ stage1_mode=stage1_mode,
157
+ filter_obj=filter_obj,
158
+ return_embeddings=return_embeddings,
159
+ )
160
+
161
+ def search_embedded(
162
+ self,
163
+ *,
164
+ query_embedding,
165
+ top_k: int = 10,
166
+ mode: str = "single_full",
167
+ prefetch_k: Optional[int] = None,
168
+ stage1_mode: str = "pooled_query_vs_tiles",
169
+ stage1_k: Optional[int] = None,
170
+ stage2_k: Optional[int] = None,
171
+ filter_obj=None,
172
+ return_embeddings: bool = False,
173
+ ) -> List[Dict[str, Any]]:
174
+ if mode == "single_full":
175
+ return self._single_stage.search(
176
+ query_embedding=query_embedding,
177
+ top_k=top_k,
178
+ strategy="multi_vector",
179
+ filter_obj=filter_obj,
180
+ )
181
+
182
+ if mode == "single_tiles":
183
+ return self._single_stage.search(
184
+ query_embedding=query_embedding,
185
+ top_k=top_k,
186
+ strategy="tiles_maxsim",
187
+ filter_obj=filter_obj,
188
+ )
189
+
190
+ if mode == "single_global":
191
+ return self._single_stage.search(
192
+ query_embedding=query_embedding,
193
+ top_k=top_k,
194
+ strategy="pooled_global",
195
+ filter_obj=filter_obj,
196
+ )
197
+
198
+ if mode == "two_stage":
199
+ return self._two_stage.search_server_side(
200
+ query_embedding=query_embedding,
201
+ top_k=top_k,
202
+ prefetch_k=prefetch_k,
203
+ filter_obj=filter_obj,
204
+ stage1_mode=stage1_mode,
205
+ )
206
+
207
+ if mode == "three_stage":
208
+ s1 = int(stage1_k) if stage1_k is not None else 1000
209
+ s2 = int(stage2_k) if stage2_k is not None else 300
210
+ return self._three_stage.search_server_side(
211
+ query_embedding=query_embedding,
212
+ top_k=top_k,
213
+ stage1_k=s1,
214
+ stage2_k=s2,
215
+ filter_obj=filter_obj,
216
+ )
217
+
218
+ raise ValueError(f"Unknown mode: {mode}")
219
+
220
+
visual_rag/retrieval/single_stage.py CHANGED
@@ -20,10 +20,11 @@ class SingleStageRetriever:
20
  """
21
  Single-stage visual document retrieval using native Qdrant search.
22
 
23
- Supports three strategies:
24
- 1. multi_vector (SOTA): Native MaxSim on full embeddings
25
- 2. pooled_tile: Search on tile-level pooled vectors
26
- 3. pooled_global: Search on globally pooled vectors
 
27
 
28
  Args:
29
  qdrant_client: Connected Qdrant client
@@ -38,9 +39,11 @@ class SingleStageRetriever:
38
  self,
39
  qdrant_client,
40
  collection_name: str,
 
41
  ):
42
  self.client = qdrant_client
43
  self.collection_name = collection_name
 
44
 
45
  def search(
46
  self,
@@ -55,7 +58,7 @@ class SingleStageRetriever:
55
  Args:
56
  query_embedding: Query embeddings [num_tokens, dim]
57
  top_k: Number of results
58
- strategy: "multi_vector", "pooled_tile", or "pooled_global"
59
  filter_obj: Qdrant filter
60
 
61
  Returns:
@@ -67,21 +70,27 @@ class SingleStageRetriever:
67
  # Native multi-vector MaxSim
68
  vector_name = "initial"
69
  query_vector = query_np.tolist()
70
- logger.info(f"🎯 Multi-vector search on '{vector_name}'")
 
 
 
 
 
 
71
 
72
  elif strategy == "pooled_tile":
73
  # Tile-level pooled
74
  vector_name = "mean_pooling"
75
  query_pooled = query_np.mean(axis=0)
76
  query_vector = query_pooled.tolist()
77
- logger.info(f"🔍 Tile-pooled search on '{vector_name}'")
78
 
79
  elif strategy == "pooled_global":
80
- # Global mean pooling (single vector)
81
- vector_name = "mean_pooling"
82
  query_pooled = query_np.mean(axis=0)
83
  query_vector = query_pooled.tolist()
84
- logger.info(f"🔍 Global-pooled search on '{vector_name}'")
85
 
86
  else:
87
  raise ValueError(f"Unknown strategy: {strategy}")
@@ -94,7 +103,7 @@ class SingleStageRetriever:
94
  limit=top_k,
95
  with_payload=True,
96
  with_vectors=False,
97
- timeout=120,
98
  ).points
99
 
100
  return [
 
20
  """
21
  Single-stage visual document retrieval using native Qdrant search.
22
 
23
+ Supports strategies:
24
+ - multi_vector: Native MaxSim on full embeddings (using="initial")
25
+ - tiles_maxsim: Native MaxSim between query tokens and tile vectors (using="mean_pooling")
26
+ - pooled_tile: Pooled query vs tile vectors (using="mean_pooling")
27
+ - pooled_global: Pooled query vs global pooled doc vector (using="global_pooling")
28
 
29
  Args:
30
  qdrant_client: Connected Qdrant client
 
39
  self,
40
  qdrant_client,
41
  collection_name: str,
42
+ request_timeout: int = 120,
43
  ):
44
  self.client = qdrant_client
45
  self.collection_name = collection_name
46
+ self.request_timeout = int(request_timeout)
47
 
48
  def search(
49
  self,
 
58
  Args:
59
  query_embedding: Query embeddings [num_tokens, dim]
60
  top_k: Number of results
61
+ strategy: "multi_vector", "tiles_maxsim", "pooled_tile", or "pooled_global"
62
  filter_obj: Qdrant filter
63
 
64
  Returns:
 
70
  # Native multi-vector MaxSim
71
  vector_name = "initial"
72
  query_vector = query_np.tolist()
73
+ logger.debug(f"🎯 Multi-vector search on '{vector_name}'")
74
+
75
+ elif strategy == "tiles_maxsim":
76
+ # Native multi-vector MaxSim against tile vectors
77
+ vector_name = "mean_pooling"
78
+ query_vector = query_np.tolist()
79
+ logger.debug(f"🎯 Tile MaxSim search on '{vector_name}'")
80
 
81
  elif strategy == "pooled_tile":
82
  # Tile-level pooled
83
  vector_name = "mean_pooling"
84
  query_pooled = query_np.mean(axis=0)
85
  query_vector = query_pooled.tolist()
86
+ logger.debug(f"🔍 Tile-pooled search on '{vector_name}'")
87
 
88
  elif strategy == "pooled_global":
89
+ # Global pooled vector (single vector)
90
+ vector_name = "global_pooling"
91
  query_pooled = query_np.mean(axis=0)
92
  query_vector = query_pooled.tolist()
93
+ logger.debug(f"🔍 Global-pooled search on '{vector_name}'")
94
 
95
  else:
96
  raise ValueError(f"Unknown strategy: {strategy}")
 
103
  limit=top_k,
104
  with_payload=True,
105
  with_vectors=False,
106
+ timeout=self.request_timeout,
107
  ).points
108
 
109
  return [
visual_rag/retrieval/three_stage.py ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from typing import Any, Dict, List, Optional, Union
3
+
4
+ import numpy as np
5
+ import torch
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ class ThreeStageRetriever:
11
+ def __init__(
12
+ self,
13
+ qdrant_client,
14
+ collection_name: str,
15
+ *,
16
+ full_vector_name: str = "initial",
17
+ experimental_vector_name: str = "experimental_pooling",
18
+ global_vector_name: str = "global_pooling",
19
+ request_timeout: int = 120,
20
+ max_retries: int = 3,
21
+ retry_sleep: float = 0.5,
22
+ ):
23
+ self.client = qdrant_client
24
+ self.collection_name = collection_name
25
+ self.full_vector_name = full_vector_name
26
+ self.experimental_vector_name = experimental_vector_name
27
+ self.global_vector_name = global_vector_name
28
+ self.request_timeout = int(request_timeout)
29
+ self.max_retries = int(max_retries)
30
+ self.retry_sleep = float(retry_sleep)
31
+
32
+ self._global_is_multivector: Optional[bool] = None
33
+ self._experimental_is_multivector: Optional[bool] = None
34
+
35
+ def _retry_call(self, fn):
36
+ import time
37
+
38
+ last_err = None
39
+ for attempt in range(self.max_retries):
40
+ try:
41
+ return fn()
42
+ except Exception as e:
43
+ last_err = e
44
+ if attempt >= self.max_retries - 1:
45
+ break
46
+ time.sleep(self.retry_sleep * (2 ** attempt))
47
+ if last_err is not None:
48
+ raise last_err
49
+
50
+ def _to_numpy(self, embedding: Union[torch.Tensor, np.ndarray]) -> np.ndarray:
51
+ if isinstance(embedding, torch.Tensor):
52
+ if embedding.dtype == torch.bfloat16:
53
+ return embedding.cpu().float().numpy()
54
+ return embedding.cpu().numpy()
55
+ return np.array(embedding, dtype=np.float32)
56
+
57
+ def _infer_vector_is_multivector(self, vector_name: str) -> bool:
58
+ info = self.client.get_collection(self.collection_name)
59
+ cfg = getattr(info, "config", None)
60
+ params = getattr(cfg, "params", None) if cfg is not None else None
61
+ vectors = getattr(params, "vectors", None) if params is not None else None
62
+ v = None
63
+ try:
64
+ if isinstance(vectors, dict):
65
+ v = vectors.get(vector_name)
66
+ else:
67
+ v = vectors[vector_name]
68
+ except Exception:
69
+ v = None
70
+ mv = getattr(v, "multivector_config", None) if v is not None else None
71
+ if mv is None and isinstance(v, dict):
72
+ mv = v.get("multivector_config")
73
+ return mv is not None
74
+
75
+ def _and_filter(self, base_filter, ids: List[Any]):
76
+ from qdrant_client.http import models as m
77
+
78
+ has_id = m.HasIdCondition(has_id=list(ids))
79
+ if base_filter is None:
80
+ return m.Filter(must=[has_id])
81
+ return m.Filter(must=[base_filter, has_id])
82
+
83
+ def search_server_side(
84
+ self,
85
+ *,
86
+ query_embedding: Union[torch.Tensor, np.ndarray],
87
+ top_k: int = 100,
88
+ stage1_k: int = 1000,
89
+ stage2_k: int = 300,
90
+ filter_obj=None,
91
+ ) -> List[Dict[str, Any]]:
92
+ from qdrant_client.http import models as m
93
+
94
+ query_np = self._to_numpy(query_embedding)
95
+
96
+ stage1_query = query_np.mean(axis=0).tolist()
97
+ stage2_query = query_np.tolist()
98
+ stage3_query = query_np.tolist()
99
+
100
+ logger.info(f"Stage 1: global prefetch {int(stage1_k)}")
101
+
102
+ def _do_stage1():
103
+ return self.client.query_points(
104
+ collection_name=self.collection_name,
105
+ query=stage1_query,
106
+ using=self.global_vector_name,
107
+ limit=int(stage1_k),
108
+ query_filter=filter_obj,
109
+ with_payload=False,
110
+ with_vectors=False,
111
+ timeout=self.request_timeout,
112
+ ).points
113
+
114
+ s1 = self._retry_call(_do_stage1)
115
+ if not s1:
116
+ return []
117
+ s1_ids = [p.id for p in s1]
118
+ s1_score = {str(p.id): float(p.score) for p in s1}
119
+
120
+ logger.info(f"Stage 2: experimental prefetch {int(stage2_k)} (restricted to stage1)")
121
+
122
+ stage2_filter = self._and_filter(filter_obj, s1_ids)
123
+
124
+ def _do_stage2():
125
+ return self.client.query_points(
126
+ collection_name=self.collection_name,
127
+ query=stage2_query,
128
+ using=self.experimental_vector_name,
129
+ limit=int(min(int(stage2_k), len(s1_ids))),
130
+ query_filter=stage2_filter,
131
+ with_payload=False,
132
+ with_vectors=False,
133
+ timeout=self.request_timeout,
134
+ ).points
135
+
136
+ s2 = self._retry_call(_do_stage2)
137
+ if not s2:
138
+ return []
139
+ s2_ids = [p.id for p in s2]
140
+ s2_score = {str(p.id): float(p.score) for p in s2}
141
+
142
+ logger.info(f"Stage 3: exact rerank on initial to top {int(top_k)} (restricted to stage2)")
143
+
144
+ stage3_filter = self._and_filter(filter_obj, s2_ids)
145
+
146
+ def _do_stage3():
147
+ return self.client.query_points(
148
+ collection_name=self.collection_name,
149
+ query=stage3_query,
150
+ using=self.full_vector_name,
151
+ limit=int(top_k),
152
+ query_filter=stage3_filter,
153
+ with_payload=True,
154
+ with_vectors=False,
155
+ search_params=m.SearchParams(exact=True),
156
+ timeout=self.request_timeout,
157
+ ).points
158
+
159
+ s3 = self._retry_call(_do_stage3)
160
+ out = []
161
+ for p in s3:
162
+ pid = str(p.id)
163
+ out.append(
164
+ {
165
+ "id": p.id,
166
+ "score_stage1": s1_score.get(pid),
167
+ "score_stage2": s2_score.get(pid),
168
+ "score_stage3": float(p.score),
169
+ "score_final": float(p.score),
170
+ "payload": p.payload,
171
+ }
172
+ )
173
+ return out
174
+
visual_rag/retrieval/two_stage.py CHANGED
@@ -64,11 +64,119 @@ class TwoStageRetriever:
64
  collection_name: str,
65
  full_vector_name: str = "initial",
66
  pooled_vector_name: str = "mean_pooling",
 
 
 
 
 
67
  ):
68
  self.client = qdrant_client
69
  self.collection_name = collection_name
70
  self.full_vector_name = full_vector_name
71
  self.pooled_vector_name = pooled_vector_name
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
 
73
  def search(
74
  self,
@@ -78,6 +186,7 @@ class TwoStageRetriever:
78
  filter_obj=None,
79
  use_reranking: bool = True,
80
  return_embeddings: bool = False,
 
81
  ) -> List[Dict[str, Any]]:
82
  """
83
  Two-stage retrieval: prefetch with pooling, rerank with MaxSim.
@@ -89,6 +198,10 @@ class TwoStageRetriever:
89
  filter_obj: Qdrant filter for metadata filtering
90
  use_reranking: Enable stage 2 reranking (default: True)
91
  return_embeddings: Include embeddings in results
 
 
 
 
92
 
93
  Returns:
94
  List of results with scores and metadata:
@@ -111,11 +224,12 @@ class TwoStageRetriever:
111
  prefetch_k = max(100, top_k * 10)
112
 
113
  # Stage 1: Prefetch with pooled vectors
114
- logger.info(f"🔍 Stage 1: Prefetching {prefetch_k} candidates with pooled search")
115
  candidates = self._stage1_prefetch(
116
  query_np=query_np,
117
  top_k=prefetch_k,
118
  filter_obj=filter_obj,
 
119
  )
120
 
121
  if not candidates:
@@ -202,21 +316,34 @@ class TwoStageRetriever:
202
  query_np: np.ndarray,
203
  top_k: int,
204
  filter_obj=None,
 
205
  ) -> List[Dict[str, Any]]:
206
- """Stage 1: Fast prefetch with pooled vectors."""
207
- # Pool query to single vector
208
- query_pooled = query_np.mean(axis=0).tolist()
 
 
 
 
 
 
 
 
 
209
 
210
- results = self.client.query_points(
211
- collection_name=self.collection_name,
212
- query=query_pooled,
213
- using=self.pooled_vector_name,
214
- query_filter=filter_obj,
215
- limit=top_k,
216
- with_payload=True,
217
- with_vectors=False, # Don't fetch vectors yet
218
- timeout=60,
219
- ).points
 
 
 
220
 
221
  return [
222
  {
@@ -241,12 +368,16 @@ class TwoStageRetriever:
241
  candidate_ids = [c["id"] for c in candidates]
242
 
243
  # Retrieve points with vectors
244
- points = self.client.retrieve(
245
- collection_name=self.collection_name,
246
- ids=candidate_ids,
247
- with_payload=False,
248
- with_vectors=[self.full_vector_name],
249
- )
 
 
 
 
250
 
251
  # Build ID to embedding map
252
  id_to_embedding = {}
 
64
  collection_name: str,
65
  full_vector_name: str = "initial",
66
  pooled_vector_name: str = "mean_pooling",
67
+ experimental_vector_name: str = "experimental_pooling",
68
+ global_vector_name: str = "global_pooling",
69
+ request_timeout: int = 120,
70
+ max_retries: int = 3,
71
+ retry_sleep: float = 0.5,
72
  ):
73
  self.client = qdrant_client
74
  self.collection_name = collection_name
75
  self.full_vector_name = full_vector_name
76
  self.pooled_vector_name = pooled_vector_name
77
+ self.experimental_vector_name = experimental_vector_name
78
+ self.global_vector_name = global_vector_name
79
+ self.request_timeout = int(request_timeout)
80
+ self.max_retries = int(max_retries)
81
+ self.retry_sleep = float(retry_sleep)
82
+
83
+ def _retry_call(self, fn):
84
+ import time
85
+
86
+ last_err = None
87
+ for attempt in range(self.max_retries):
88
+ try:
89
+ return fn()
90
+ except Exception as e:
91
+ last_err = e
92
+ if attempt >= self.max_retries - 1:
93
+ break
94
+ time.sleep(self.retry_sleep * (2 ** attempt))
95
+ if last_err is not None:
96
+ raise last_err
97
+
98
+ def search_server_side(
99
+ self,
100
+ query_embedding: Union[torch.Tensor, np.ndarray],
101
+ top_k: int = 10,
102
+ prefetch_k: Optional[int] = None,
103
+ filter_obj=None,
104
+ stage1_mode: str = "pooled_query_vs_tiles",
105
+ ) -> List[Dict[str, Any]]:
106
+ """
107
+ Two-stage retrieval using Qdrant's native prefetch (all server-side).
108
+
109
+ This is MUCH faster than search() because it avoids network transfer
110
+ of large multi-vector embeddings. All computation happens in Qdrant.
111
+
112
+ Args:
113
+ query_embedding: Query embeddings [num_tokens, dim]
114
+ top_k: Final number of results
115
+ prefetch_k: Candidates for stage 1 (default: 10x top_k)
116
+ filter_obj: Qdrant filter
117
+ stage1_mode: How to do stage 1 prefetch
118
+
119
+ Returns:
120
+ List of results with scores
121
+ """
122
+ from qdrant_client.http import models
123
+
124
+ query_np = self._to_numpy(query_embedding)
125
+
126
+ if prefetch_k is None:
127
+ prefetch_k = max(100, top_k * 10)
128
+
129
+ if stage1_mode == "pooled_query_vs_tiles":
130
+ prefetch_query = query_np.mean(axis=0).tolist()
131
+ prefetch_using = self.pooled_vector_name
132
+ elif stage1_mode == "tokens_vs_tiles":
133
+ prefetch_query = query_np.tolist()
134
+ prefetch_using = self.pooled_vector_name
135
+ elif stage1_mode == "pooled_query_vs_experimental":
136
+ prefetch_query = query_np.mean(axis=0).tolist()
137
+ prefetch_using = self.experimental_vector_name
138
+ elif stage1_mode == "tokens_vs_experimental":
139
+ prefetch_query = query_np.tolist()
140
+ prefetch_using = self.experimental_vector_name
141
+ elif stage1_mode == "pooled_query_vs_global":
142
+ prefetch_query = query_np.mean(axis=0).tolist()
143
+ prefetch_using = self.global_vector_name
144
+ else:
145
+ raise ValueError(f"Unknown stage1_mode: {stage1_mode}")
146
+
147
+ rerank_query = query_np.tolist()
148
+
149
+ def _do_query():
150
+ return self.client.query_points(
151
+ collection_name=self.collection_name,
152
+ query=rerank_query,
153
+ using=self.full_vector_name,
154
+ limit=top_k,
155
+ query_filter=filter_obj,
156
+ with_payload=True,
157
+ search_params=models.SearchParams(exact=True),
158
+ prefetch=[
159
+ models.Prefetch(
160
+ query=prefetch_query,
161
+ using=prefetch_using,
162
+ limit=prefetch_k,
163
+ )
164
+ ],
165
+ timeout=self.request_timeout,
166
+ ).points
167
+
168
+ results = self._retry_call(_do_query)
169
+
170
+ return [
171
+ {
172
+ "id": r.id,
173
+ "score_stage1": None,
174
+ "score_stage2": r.score,
175
+ "score_final": r.score,
176
+ "payload": r.payload,
177
+ }
178
+ for r in results
179
+ ]
180
 
181
  def search(
182
  self,
 
186
  filter_obj=None,
187
  use_reranking: bool = True,
188
  return_embeddings: bool = False,
189
+ stage1_mode: str = "pooled_query_vs_tiles",
190
  ) -> List[Dict[str, Any]]:
191
  """
192
  Two-stage retrieval: prefetch with pooling, rerank with MaxSim.
 
198
  filter_obj: Qdrant filter for metadata filtering
199
  use_reranking: Enable stage 2 reranking (default: True)
200
  return_embeddings: Include embeddings in results
201
+ stage1_mode:
202
+ - "pooled_query_vs_tiles": pool query to 1×dim and search tile vectors (using="mean_pooling")
203
+ - "tokens_vs_tiles": search tile vectors with full query tokens (using="mean_pooling")
204
+ - "pooled_query_vs_global": pool query to 1×dim and search global pooled doc vectors (using="global_pooling")
205
 
206
  Returns:
207
  List of results with scores and metadata:
 
224
  prefetch_k = max(100, top_k * 10)
225
 
226
  # Stage 1: Prefetch with pooled vectors
227
+ logger.info(f"🔍 Stage 1: Prefetching {prefetch_k} candidates ({stage1_mode})")
228
  candidates = self._stage1_prefetch(
229
  query_np=query_np,
230
  top_k=prefetch_k,
231
  filter_obj=filter_obj,
232
+ stage1_mode=stage1_mode,
233
  )
234
 
235
  if not candidates:
 
316
  query_np: np.ndarray,
317
  top_k: int,
318
  filter_obj=None,
319
+ stage1_mode: str = "pooled_query_vs_tiles",
320
  ) -> List[Dict[str, Any]]:
321
+ """Stage 1: Prefetch candidates."""
322
+ if stage1_mode == "pooled_query_vs_tiles":
323
+ query_vector = query_np.mean(axis=0).tolist()
324
+ vector_name = self.pooled_vector_name
325
+ elif stage1_mode == "tokens_vs_tiles":
326
+ query_vector = query_np.tolist()
327
+ vector_name = self.pooled_vector_name
328
+ elif stage1_mode == "pooled_query_vs_global":
329
+ query_vector = query_np.mean(axis=0).tolist()
330
+ vector_name = self.global_vector_name
331
+ else:
332
+ raise ValueError(f"Unknown stage1_mode: {stage1_mode}")
333
 
334
+ def _do_query():
335
+ return self.client.query_points(
336
+ collection_name=self.collection_name,
337
+ query=query_vector,
338
+ using=vector_name,
339
+ query_filter=filter_obj,
340
+ limit=top_k,
341
+ with_payload=True,
342
+ with_vectors=False,
343
+ timeout=self.request_timeout,
344
+ ).points
345
+
346
+ results = self._retry_call(_do_query)
347
 
348
  return [
349
  {
 
368
  candidate_ids = [c["id"] for c in candidates]
369
 
370
  # Retrieve points with vectors
371
+ def _do_retrieve():
372
+ return self.client.retrieve(
373
+ collection_name=self.collection_name,
374
+ ids=candidate_ids,
375
+ with_payload=False,
376
+ with_vectors=[self.full_vector_name],
377
+ timeout=self.request_timeout,
378
+ )
379
+
380
+ points = self._retry_call(_do_retrieve)
381
 
382
  # Build ID to embedding map
383
  id_to_embedding = {}