Yeroyan commited on
Commit
14a9e6f
Β·
1 Parent(s): f872124

finalize release 1 changes

Browse files
README.md CHANGED
@@ -21,6 +21,8 @@ This repo contains:
21
  - **Modular**: PDF β†’ images, embedding, Qdrant indexing, retrieval can be used independently.
22
  - **Multi-stage retrieval**: two-stage and three-stage retrieval modes built for Qdrant named vectors.
23
  - **Model-aware embedding**: ColSmol, ColPali, and ColQwen2/2.5 support behind a single `VisualEmbedder` interface.
 
 
24
  - **Token hygiene**: query special-token filtering by default for more stable MaxSim behavior.
25
  - **Practical pipelines**: robust indexing, retries, optional Cloudinary image URLs, evaluation reporting.
26
 
 
21
  - **Modular**: PDF β†’ images, embedding, Qdrant indexing, retrieval can be used independently.
22
  - **Multi-stage retrieval**: two-stage and three-stage retrieval modes built for Qdrant named vectors.
23
  - **Model-aware embedding**: ColSmol, ColPali, and ColQwen2/2.5 support behind a single `VisualEmbedder` interface.
24
+ - **Configurable pooling**: adaptive mean-pooling cap for ColQwen2.5 (`--max-mean-pool-vectors`), and experimental pooling stored as Qdrant named vectors (`experimental_pooling` (ColQwen Gaussian alias), `experimental_pooling_gaussian`, `experimental_pooling_triangular`, `experimental_pooling_{k}` (ColPali), `experimental_pooling_2d` (ColSmol)).
25
+ - **Single-stage ablations**: direct search modes over experimental pooled vectors (tokens-vs-doc and pooled-query-vs-doc) for fast storage-reduction experiments.
26
  - **Token hygiene**: query special-token filtering by default for more stable MaxSim behavior.
27
  - **Practical pipelines**: robust indexing, retries, optional Cloudinary image URLs, evaluation reporting.
28
 
benchmarks/vidore_beir_qdrant/run_qdrant_beir.py CHANGED
@@ -132,7 +132,24 @@ def _default_output_filename(*, args, datasets: List[str]) -> str:
132
  parts = [model_tag, mode_tag]
133
  if str(args.mode) == "two_stage":
134
  parts.append(_safe_filename(str(args.stage1_mode)))
 
 
 
 
 
 
 
 
 
 
 
 
135
  parts.append(f"pk{int(args.prefetch_k)}")
 
 
 
 
 
136
  if str(args.mode) == "three_stage":
137
  parts.append("tokens_vs_global")
138
  parts.append(f"s1k{int(args.stage1_k)}")
@@ -555,31 +572,47 @@ def _index_beir_corpus(
555
  is_colqwen25 = "colqwen2.5" in model_lower or "colqwen2_5" in model_lower
556
  is_colsmol = "colsmol" in model_lower
557
  kernel_arg = str(experimental_pooling_kernel or "auto").lower().strip()
558
- if kernel_arg == "auto":
559
- kernel = "gaussian" if is_colqwen25 else "legacy"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
560
  else:
561
- kernel = kernel_arg
 
 
 
 
562
 
563
- default_k = 5 if is_colqwen25 else 3
564
- if kernel != "legacy":
565
  default_k = 3
566
- ks = pooling_windows if pooling_windows else [default_k]
567
- seen_ks = set()
568
- ks_norm: List[int] = []
569
- for k in ks:
570
- try:
571
- ki = int(k)
572
- except Exception:
573
- continue
574
- if ki <= 0:
575
- continue
576
- if ki in seen_ks:
577
- continue
578
- seen_ks.add(ki)
579
- ks_norm.append(ki)
580
- if not ks_norm:
581
- ks_norm = [default_k]
582
- experimental_vector_names = [f"experimental_pooling_{int(k)}" for k in ks_norm]
583
  if is_colsmol and bool(colsmol_experimental_2d):
584
  experimental_vector_names.append("experimental_pooling_2d")
585
 
@@ -898,23 +931,48 @@ def _index_beir_corpus(
898
  )
899
 
900
  experimental_pooled_by_name: Dict[str, Any] = {}
901
- canonical_k = int(ks_norm[0]) if ks_norm else int(default_k)
902
- for k in ks_norm:
903
- exp = embedder.experimental_pool_visual_embedding(
 
 
 
 
 
 
 
904
  visual_embedding,
905
  token_info,
906
  target_vectors=tv,
907
  mean_pool=tile_pooled,
908
- window_size=int(k),
909
- kernel=kernel,
 
 
 
 
 
910
  )
911
- experimental_pooled_by_name[f"experimental_pooling_{int(k)}"] = exp
912
- if int(k) == int(canonical_k):
913
- experimental_pooled_by_name["experimental_pooling"] = exp
 
 
 
 
 
 
 
 
 
 
 
914
 
915
  if is_colsmol and bool(colsmol_experimental_2d):
916
  try:
917
- from visual_rag.embedding.pooling import colsmol_tile_4n_pooling_from_tiles
 
 
918
 
919
  n_rows = (token_info or {}).get("n_rows")
920
  n_cols = (token_info or {}).get("n_cols")
@@ -1073,7 +1131,9 @@ def _index_beir_corpus(
1073
  "experimental_pooling_windows": ks_norm,
1074
  "experimental_pooling_default_window": int(ks_norm[0]) if ks_norm else None,
1075
  "experimental_pooling_kernel": str(kernel),
1076
- "colsmol_experimental_2d": bool(colsmol_experimental_2d) if is_colsmol else None,
 
 
1077
  "max_mean_pool_vectors": (
1078
  int(max_mean_pool_vectors) if max_mean_pool_vectors is not None else None
1079
  ),
@@ -1217,7 +1277,7 @@ def main() -> None:
1217
  nargs="+",
1218
  default=None,
1219
  help=(
1220
- "Experimental pooling window size(s). Provide one int to override the default window, "
1221
  "or multiple ints to index/store multiple experimental vectors. "
1222
  "When multiple are provided, vectors are stored as 'experimental_pooling_{k}' and "
1223
  "the canonical 'experimental_pooling' aliases the first provided k."
@@ -1232,7 +1292,8 @@ def main() -> None:
1232
  help=(
1233
  "Experimental pooling kernel. "
1234
  "'legacy' uses the historical ColPali conv-style pooling (N->N+2r; default for ColPali). "
1235
- "'gaussian'/'triangular'/'uniform' use weighted same-length smoothing (N->N; default for ColQwen2.5)."
 
1236
  ),
1237
  )
1238
  parser.add_argument(
@@ -1303,7 +1364,15 @@ def main() -> None:
1303
  "--mode",
1304
  type=str,
1305
  default="single_full",
1306
- choices=["single_full", "single_tiles", "single_global", "two_stage", "three_stage"],
 
 
 
 
 
 
 
 
1307
  )
1308
  parser.add_argument(
1309
  "--stage1-mode",
@@ -1335,10 +1404,22 @@ def main() -> None:
1335
  type=int,
1336
  default=None,
1337
  help=(
1338
- "When using an experimental stage1-mode, select which indexed experimental vector to use "
1339
  "(Qdrant named vector: 'experimental_pooling_{k}'). If omitted, uses 'experimental_pooling'."
1340
  ),
1341
  )
 
 
 
 
 
 
 
 
 
 
 
 
1342
  parser.add_argument(
1343
  "--stage1-k", type=int, default=1000, help="Three-stage stage1 top_k (default: 1000)"
1344
  )
@@ -1366,14 +1447,14 @@ def main() -> None:
1366
  "--continue-on-error",
1367
  dest="continue_on_error",
1368
  action="store_true",
1369
- default=True,
1370
- help="Continue evaluating remaining datasets if one dataset fails (default: true).",
1371
  )
1372
  cont_group.add_argument(
1373
  "--no-continue-on-error",
1374
  dest="continue_on_error",
1375
  action="store_false",
1376
- help="Stop the run immediately on the first dataset evaluation failure.",
1377
  )
1378
  parser.add_argument("--output", type=str, default="auto")
1379
  parser.add_argument(
@@ -1684,23 +1765,25 @@ def main() -> None:
1684
  )
1685
  # Verify by printing current on_disk flags (what the UI reads)
1686
  try:
1687
- vectors = (
1688
- (((info_after or {}).get("config") or {}).get("params") or {}).get("vectors") or {}
1689
- )
1690
  if isinstance(vectors, dict):
1691
  vec_flags = {}
1692
  for name, cfg in vectors.items():
1693
  if not isinstance(cfg, dict):
1694
  continue
1695
- vec_flags[str(name)] = bool(cfg.get("on_disk")) if cfg.get("on_disk") is not None else None
 
 
1696
  else:
1697
  vec_flags = {}
1698
  hnsw_on_disk = (
1699
- (((info_after or {}).get("config") or {}).get("hnsw_config") or {}).get("on_disk")
1700
- )
1701
  on_disk_payload = (
1702
- (((info_after or {}).get("config") or {}).get("params") or {}).get("on_disk_payload")
1703
- )
1704
  except Exception:
1705
  vec_flags = {}
1706
  hnsw_on_disk = None
@@ -1719,21 +1802,45 @@ def main() -> None:
1719
  print(f"⚠️ ensure-in-ram failed: {type(e).__name__}: {e}")
1720
  sys.stdout.flush()
1721
 
 
 
 
1722
  exp_vector_name = "experimental_pooling"
1723
- if (
1724
- args.experimental_pooling_k is not None
1725
- and str(args.stage1_mode) in ("pooled_query_vs_experimental_pooling", "tokens_vs_experimental_pooling")
1726
  and str(args.mode) in ("two_stage", "three_stage")
 
 
 
 
 
1727
  ):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1728
  exp_vector_name = f"experimental_pooling_{int(args.experimental_pooling_k)}"
1729
 
1730
  retriever = MultiVectorRetriever(
1731
  collection_name=args.collection,
1732
  embedder=embedder,
1733
  qdrant_url=os.getenv("QDRANT_URL"),
1734
- qdrant_api_key=(
1735
- os.getenv("QDRANT_API_KEY")
1736
- ),
1737
  prefer_grpc=args.prefer_grpc,
1738
  request_timeout=int(args.qdrant_timeout),
1739
  max_retries=int(args.qdrant_retries),
@@ -1741,13 +1848,18 @@ def main() -> None:
1741
  experimental_vector_name=exp_vector_name,
1742
  )
1743
 
1744
- if (
1745
- str(args.stage1_mode) in ("pooled_query_vs_experimental_pooling", "tokens_vs_experimental_pooling")
 
1746
  and str(args.mode) in ("two_stage", "three_stage")
1747
  ):
1748
- existing_vectors = _collection_vector_names(client=retriever.client, collection_name=str(args.collection))
 
 
1749
  if exp_vector_name not in existing_vectors:
1750
- candidates = sorted([v for v in existing_vectors if str(v).startswith("experimental_pooling")])
 
 
1751
  raise ValueError(
1752
  f"Requested experimental vector '{exp_vector_name}' is not present in the collection. "
1753
  f"Available experimental vectors: {candidates or '[]'}. "
@@ -1798,11 +1910,12 @@ def main() -> None:
1798
  "qdrant_retries": int(args.qdrant_retries),
1799
  "qdrant_retry_sleep": float(args.qdrant_retry_sleep),
1800
  "full_scan_threshold": int(args.full_scan_threshold),
1801
- "max_mean_pool_vectors": int(args.max_mean_pool_vectors)
1802
- if args.max_mean_pool_vectors is not None
1803
- else None,
1804
  "pooling_windows": args.pooling_windows,
1805
  "experimental_pooling_k": args.experimental_pooling_k,
 
1806
  "eval_wall_time_s": float(max(time.time() - eval_started_at, 0.0)),
1807
  "metrics": single_metrics,
1808
  "metrics_by_dataset": metrics_by_dataset,
@@ -1918,6 +2031,18 @@ def main() -> None:
1918
  sys.stdout.flush()
1919
  except Exception as e:
1920
  dataset_errors[ds_name] = f"{type(e).__name__}: {e}"
 
 
 
 
 
 
 
 
 
 
 
 
1921
  if not bool(args.continue_on_error):
1922
  _write_json_atomic(out_path, _build_run_record())
1923
  raise
 
132
  parts = [model_tag, mode_tag]
133
  if str(args.mode) == "two_stage":
134
  parts.append(_safe_filename(str(args.stage1_mode)))
135
+ # Disambiguate which experimental named vector was used.
136
+ # Without this, `--experimental-pooling-k 3` overwrites the default `experimental_pooling` run.
137
+ if str(args.stage1_mode) in (
138
+ "pooled_query_vs_experimental_pooling",
139
+ "tokens_vs_experimental_pooling",
140
+ "pooled_query_vs_experimental",
141
+ "tokens_vs_experimental",
142
+ ):
143
+ if getattr(args, "experimental_pooling_technique", None):
144
+ parts.append(f"exptech{_safe_filename(str(args.experimental_pooling_technique))}")
145
+ if getattr(args, "experimental_pooling_k", None) is not None:
146
+ parts.append(f"expk{int(args.experimental_pooling_k)}")
147
  parts.append(f"pk{int(args.prefetch_k)}")
148
+ if str(args.mode) in ("single_experimental_tokens", "single_experimental_pooled"):
149
+ if getattr(args, "experimental_pooling_technique", None):
150
+ parts.append(f"exptech{_safe_filename(str(args.experimental_pooling_technique))}")
151
+ if getattr(args, "experimental_pooling_k", None) is not None:
152
+ parts.append(f"expk{int(args.experimental_pooling_k)}")
153
  if str(args.mode) == "three_stage":
154
  parts.append("tokens_vs_global")
155
  parts.append(f"s1k{int(args.stage1_k)}")
 
572
  is_colqwen25 = "colqwen2.5" in model_lower or "colqwen2_5" in model_lower
573
  is_colsmol = "colsmol" in model_lower
574
  kernel_arg = str(experimental_pooling_kernel or "auto").lower().strip()
575
+ if is_colqwen25:
576
+ # ColQwen2.5: store technique variants (k is fixed at 3)
577
+ if pooling_windows:
578
+ raise ValueError(
579
+ "ColQwen2.5 does not support --pooling-windows; it stores gaussian+triangular variants."
580
+ )
581
+ if kernel_arg not in ("auto", "gaussian", "triangular"):
582
+ raise ValueError(
583
+ "ColQwen2.5 experimental pooling kernel is fixed to gaussian+triangular variants (k=3)."
584
+ )
585
+ kernel = "gaussian"
586
+ ks_norm = [3]
587
+ experimental_vector_names = [
588
+ "experimental_pooling_gaussian",
589
+ "experimental_pooling_triangular",
590
+ ]
591
  else:
592
+ # ColPali-style experimental pooling supports different window sizes
593
+ if kernel_arg == "auto":
594
+ kernel = "legacy"
595
+ else:
596
+ kernel = kernel_arg
597
 
 
 
598
  default_k = 3
599
+ ks = pooling_windows if pooling_windows else [default_k]
600
+ seen_ks = set()
601
+ ks_norm = []
602
+ for k in ks:
603
+ try:
604
+ ki = int(k)
605
+ except Exception:
606
+ continue
607
+ if ki <= 0:
608
+ continue
609
+ if ki in seen_ks:
610
+ continue
611
+ seen_ks.add(ki)
612
+ ks_norm.append(ki)
613
+ if not ks_norm:
614
+ ks_norm = [default_k]
615
+ experimental_vector_names = [f"experimental_pooling_{int(k)}" for k in ks_norm]
616
  if is_colsmol and bool(colsmol_experimental_2d):
617
  experimental_vector_names.append("experimental_pooling_2d")
618
 
 
931
  )
932
 
933
  experimental_pooled_by_name: Dict[str, Any] = {}
934
+ if is_colqwen25:
935
+ exp_gaussian = embedder.experimental_pool_visual_embedding(
936
+ visual_embedding,
937
+ token_info,
938
+ target_vectors=tv,
939
+ mean_pool=tile_pooled,
940
+ window_size=3,
941
+ kernel="gaussian",
942
+ )
943
+ exp_triangular = embedder.experimental_pool_visual_embedding(
944
  visual_embedding,
945
  token_info,
946
  target_vectors=tv,
947
  mean_pool=tile_pooled,
948
+ window_size=3,
949
+ kernel="triangular",
950
+ )
951
+ experimental_pooled_by_name["experimental_pooling"] = exp_gaussian
952
+ experimental_pooled_by_name["experimental_pooling_gaussian"] = exp_gaussian
953
+ experimental_pooled_by_name["experimental_pooling_triangular"] = (
954
+ exp_triangular
955
  )
956
+ else:
957
+ canonical_k = int(ks_norm[0]) if ks_norm else 3
958
+ for k in ks_norm:
959
+ exp = embedder.experimental_pool_visual_embedding(
960
+ visual_embedding,
961
+ token_info,
962
+ target_vectors=tv,
963
+ mean_pool=tile_pooled,
964
+ window_size=int(k),
965
+ kernel=kernel,
966
+ )
967
+ experimental_pooled_by_name[f"experimental_pooling_{int(k)}"] = exp
968
+ if int(k) == int(canonical_k):
969
+ experimental_pooled_by_name["experimental_pooling"] = exp
970
 
971
  if is_colsmol and bool(colsmol_experimental_2d):
972
  try:
973
+ from visual_rag.embedding.pooling import (
974
+ colsmol_tile_4n_pooling_from_tiles,
975
+ )
976
 
977
  n_rows = (token_info or {}).get("n_rows")
978
  n_cols = (token_info or {}).get("n_cols")
 
1131
  "experimental_pooling_windows": ks_norm,
1132
  "experimental_pooling_default_window": int(ks_norm[0]) if ks_norm else None,
1133
  "experimental_pooling_kernel": str(kernel),
1134
+ "colsmol_experimental_2d": (
1135
+ bool(colsmol_experimental_2d) if is_colsmol else None
1136
+ ),
1137
  "max_mean_pool_vectors": (
1138
  int(max_mean_pool_vectors) if max_mean_pool_vectors is not None else None
1139
  ),
 
1277
  nargs="+",
1278
  default=None,
1279
  help=(
1280
+ "ColPali only: experimental pooling window size(s). Provide one int to override the default window, "
1281
  "or multiple ints to index/store multiple experimental vectors. "
1282
  "When multiple are provided, vectors are stored as 'experimental_pooling_{k}' and "
1283
  "the canonical 'experimental_pooling' aliases the first provided k."
 
1292
  help=(
1293
  "Experimental pooling kernel. "
1294
  "'legacy' uses the historical ColPali conv-style pooling (N->N+2r; default for ColPali). "
1295
+ "'gaussian'/'triangular'/'uniform' use weighted same-length smoothing (N->N). "
1296
+ "Ignored for ColQwen2.5 indexing (which stores gaussian+triangular variants with k=3)."
1297
  ),
1298
  )
1299
  parser.add_argument(
 
1364
  "--mode",
1365
  type=str,
1366
  default="single_full",
1367
+ choices=[
1368
+ "single_full",
1369
+ "single_tiles",
1370
+ "single_global",
1371
+ "single_experimental_tokens",
1372
+ "single_experimental_pooled",
1373
+ "two_stage",
1374
+ "three_stage",
1375
+ ],
1376
  )
1377
  parser.add_argument(
1378
  "--stage1-mode",
 
1404
  type=int,
1405
  default=None,
1406
  help=(
1407
+ "ColPali only: when using an experimental stage1-mode, select which indexed experimental vector to use "
1408
  "(Qdrant named vector: 'experimental_pooling_{k}'). If omitted, uses 'experimental_pooling'."
1409
  ),
1410
  )
1411
+ parser.add_argument(
1412
+ "--experimental-pooling-technique",
1413
+ "--experimental_pooling_technique",
1414
+ type=str,
1415
+ default=None,
1416
+ choices=["gaussian", "triangular"],
1417
+ help=(
1418
+ "ColQwen only: select which experimental pooling technique to use for stage-1/single-stage experimental "
1419
+ "modes. Maps to Qdrant named vectors: 'experimental_pooling_gaussian' or 'experimental_pooling_triangular'. "
1420
+ "If omitted, uses 'experimental_pooling' (Gaussian alias)."
1421
+ ),
1422
+ )
1423
  parser.add_argument(
1424
  "--stage1-k", type=int, default=1000, help="Three-stage stage1 top_k (default: 1000)"
1425
  )
 
1447
  "--continue-on-error",
1448
  dest="continue_on_error",
1449
  action="store_true",
1450
+ default=False,
1451
+ help="Continue evaluating remaining datasets if one dataset fails (default: false).",
1452
  )
1453
  cont_group.add_argument(
1454
  "--no-continue-on-error",
1455
  dest="continue_on_error",
1456
  action="store_false",
1457
+ help="Stop the run immediately on the first dataset evaluation failure (default).",
1458
  )
1459
  parser.add_argument("--output", type=str, default="auto")
1460
  parser.add_argument(
 
1765
  )
1766
  # Verify by printing current on_disk flags (what the UI reads)
1767
  try:
1768
+ vectors = (((info_after or {}).get("config") or {}).get("params") or {}).get(
1769
+ "vectors"
1770
+ ) or {}
1771
  if isinstance(vectors, dict):
1772
  vec_flags = {}
1773
  for name, cfg in vectors.items():
1774
  if not isinstance(cfg, dict):
1775
  continue
1776
+ vec_flags[str(name)] = (
1777
+ bool(cfg.get("on_disk")) if cfg.get("on_disk") is not None else None
1778
+ )
1779
  else:
1780
  vec_flags = {}
1781
  hnsw_on_disk = (
1782
+ ((info_after or {}).get("config") or {}).get("hnsw_config") or {}
1783
+ ).get("on_disk")
1784
  on_disk_payload = (
1785
+ ((info_after or {}).get("config") or {}).get("params") or {}
1786
+ ).get("on_disk_payload")
1787
  except Exception:
1788
  vec_flags = {}
1789
  hnsw_on_disk = None
 
1802
  print(f"⚠️ ensure-in-ram failed: {type(e).__name__}: {e}")
1803
  sys.stdout.flush()
1804
 
1805
+ def _is_colqwen_model(model_name: str) -> bool:
1806
+ return "colqwen" in str(model_name).lower()
1807
+
1808
  exp_vector_name = "experimental_pooling"
1809
+ uses_experimental_vector = (
1810
+ str(args.stage1_mode)
1811
+ in ("pooled_query_vs_experimental_pooling", "tokens_vs_experimental_pooling")
1812
  and str(args.mode) in ("two_stage", "three_stage")
1813
+ ) or str(args.mode) in ("single_experimental_tokens", "single_experimental_pooled")
1814
+
1815
+ if (
1816
+ getattr(args, "experimental_pooling_technique", None)
1817
+ and getattr(args, "experimental_pooling_k", None) is not None
1818
  ):
1819
+ raise ValueError(
1820
+ "Use only one of --experimental-pooling-technique or --experimental-pooling-k (not both)."
1821
+ )
1822
+
1823
+ if uses_experimental_vector and getattr(args, "experimental_pooling_technique", None):
1824
+ if not _is_colqwen_model(args.model):
1825
+ raise ValueError(
1826
+ "--experimental-pooling-technique is only supported for ColQwen models."
1827
+ )
1828
+ exp_vector_name = (
1829
+ f"experimental_pooling_{str(args.experimental_pooling_technique).strip().lower()}"
1830
+ )
1831
+
1832
+ if uses_experimental_vector and getattr(args, "experimental_pooling_k", None) is not None:
1833
+ if _is_colqwen_model(args.model):
1834
+ raise ValueError(
1835
+ "--experimental-pooling-k is intended for ColPali (experimental_pooling_{k}), not ColQwen."
1836
+ )
1837
  exp_vector_name = f"experimental_pooling_{int(args.experimental_pooling_k)}"
1838
 
1839
  retriever = MultiVectorRetriever(
1840
  collection_name=args.collection,
1841
  embedder=embedder,
1842
  qdrant_url=os.getenv("QDRANT_URL"),
1843
+ qdrant_api_key=(os.getenv("QDRANT_API_KEY")),
 
 
1844
  prefer_grpc=args.prefer_grpc,
1845
  request_timeout=int(args.qdrant_timeout),
1846
  max_retries=int(args.qdrant_retries),
 
1848
  experimental_vector_name=exp_vector_name,
1849
  )
1850
 
1851
+ if str(args.mode) in ("single_experimental_tokens", "single_experimental_pooled") or (
1852
+ str(args.stage1_mode)
1853
+ in ("pooled_query_vs_experimental_pooling", "tokens_vs_experimental_pooling")
1854
  and str(args.mode) in ("two_stage", "three_stage")
1855
  ):
1856
+ existing_vectors = _collection_vector_names(
1857
+ client=retriever.client, collection_name=str(args.collection)
1858
+ )
1859
  if exp_vector_name not in existing_vectors:
1860
+ candidates = sorted(
1861
+ [v for v in existing_vectors if str(v).startswith("experimental_pooling")]
1862
+ )
1863
  raise ValueError(
1864
  f"Requested experimental vector '{exp_vector_name}' is not present in the collection. "
1865
  f"Available experimental vectors: {candidates or '[]'}. "
 
1910
  "qdrant_retries": int(args.qdrant_retries),
1911
  "qdrant_retry_sleep": float(args.qdrant_retry_sleep),
1912
  "full_scan_threshold": int(args.full_scan_threshold),
1913
+ "max_mean_pool_vectors": (
1914
+ int(args.max_mean_pool_vectors) if args.max_mean_pool_vectors is not None else None
1915
+ ),
1916
  "pooling_windows": args.pooling_windows,
1917
  "experimental_pooling_k": args.experimental_pooling_k,
1918
+ "experimental_pooling_technique": getattr(args, "experimental_pooling_technique", None),
1919
  "eval_wall_time_s": float(max(time.time() - eval_started_at, 0.0)),
1920
  "metrics": single_metrics,
1921
  "metrics_by_dataset": metrics_by_dataset,
 
2031
  sys.stdout.flush()
2032
  except Exception as e:
2033
  dataset_errors[ds_name] = f"{type(e).__name__}: {e}"
2034
+ print(f"❌ Evaluation failed for dataset={ds_name}: {dataset_errors[ds_name]}")
2035
+ # Common cause for per_dataset: missing payload index for 'dataset'
2036
+ msg = str(e)
2037
+ if (
2038
+ 'Index required but not found for "dataset"' in msg
2039
+ or "Index required but not found for 'dataset'" in msg
2040
+ ):
2041
+ print(
2042
+ " Hint: Qdrant requires a payload index for key 'dataset' to use per-dataset filtering.\n"
2043
+ " Fix: create payload index (keyword) on the collection, or rerun indexing with metadata indexes enabled."
2044
+ )
2045
+ sys.stdout.flush()
2046
  if not bool(args.continue_on_error):
2047
  _write_json_atomic(out_path, _build_run_record())
2048
  raise
benchmarks/vidore_tatdqa_test/run_qdrant.py CHANGED
@@ -742,10 +742,22 @@ def main() -> None:
742
  type=int,
743
  default=None,
744
  help=(
745
- "When using an experimental stage1-mode, select which indexed experimental vector to use "
746
  "(Qdrant named vector: 'experimental_pooling_{k}'). If omitted, uses 'experimental_pooling'."
747
  ),
748
  )
 
 
 
 
 
 
 
 
 
 
 
 
749
  parser.add_argument("--output", type=str, default="results/qdrant_vidore_tatdqa_test.json")
750
 
751
  args = parser.parse_args()
@@ -816,13 +828,37 @@ def main() -> None:
816
  full_scan_threshold=args.full_scan_threshold,
817
  )
818
 
 
 
 
819
  exp_vector_name = "experimental_pooling"
 
 
 
 
 
820
  if (
821
- args.experimental_pooling_k is not None
822
- and str(args.stage1_mode)
823
- in ("pooled_query_vs_experimental_pooling", "tokens_vs_experimental_pooling")
824
- and str(args.mode) in ("two_stage", "three_stage")
825
  ):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
826
  exp_vector_name = f"experimental_pooling_{int(args.experimental_pooling_k)}"
827
 
828
  retriever = MultiVectorRetriever(
 
742
  type=int,
743
  default=None,
744
  help=(
745
+ "ColPali only: when using an experimental stage1-mode, select which indexed experimental vector to use "
746
  "(Qdrant named vector: 'experimental_pooling_{k}'). If omitted, uses 'experimental_pooling'."
747
  ),
748
  )
749
+ parser.add_argument(
750
+ "--experimental-pooling-technique",
751
+ "--experimental_pooling_technique",
752
+ type=str,
753
+ default=None,
754
+ choices=["gaussian", "triangular"],
755
+ help=(
756
+ "ColQwen only: choose experimental pooling named vector for experimental stage-1. "
757
+ "Maps to: 'experimental_pooling_gaussian' or 'experimental_pooling_triangular'. "
758
+ "If omitted, uses 'experimental_pooling' (Gaussian alias)."
759
+ ),
760
+ )
761
  parser.add_argument("--output", type=str, default="results/qdrant_vidore_tatdqa_test.json")
762
 
763
  args = parser.parse_args()
 
828
  full_scan_threshold=args.full_scan_threshold,
829
  )
830
 
831
+ def _is_colqwen_model(model_name: str) -> bool:
832
+ return "colqwen" in str(model_name).lower()
833
+
834
  exp_vector_name = "experimental_pooling"
835
+ uses_experimental = str(args.stage1_mode) in (
836
+ "pooled_query_vs_experimental_pooling",
837
+ "tokens_vs_experimental_pooling",
838
+ ) and str(args.mode) in ("two_stage", "three_stage")
839
+
840
  if (
841
+ getattr(args, "experimental_pooling_technique", None)
842
+ and getattr(args, "experimental_pooling_k", None) is not None
 
 
843
  ):
844
+ raise ValueError(
845
+ "Use only one of --experimental-pooling-technique or --experimental-pooling-k."
846
+ )
847
+
848
+ if uses_experimental and getattr(args, "experimental_pooling_technique", None):
849
+ if not _is_colqwen_model(args.model):
850
+ raise ValueError(
851
+ "--experimental-pooling-technique is only supported for ColQwen models."
852
+ )
853
+ exp_vector_name = (
854
+ f"experimental_pooling_{str(args.experimental_pooling_technique).strip().lower()}"
855
+ )
856
+
857
+ if uses_experimental and getattr(args, "experimental_pooling_k", None) is not None:
858
+ if _is_colqwen_model(args.model):
859
+ raise ValueError(
860
+ "--experimental-pooling-k is intended for ColPali (experimental_pooling_{k}), not ColQwen."
861
+ )
862
  exp_vector_name = f"experimental_pooling_{int(args.experimental_pooling_k)}"
863
 
864
  retriever = MultiVectorRetriever(
demo/qdrant_utils.py CHANGED
@@ -12,14 +12,8 @@ def get_qdrant_credentials() -> Tuple[Optional[str], Optional[str]]:
12
 
13
  Priority: session_state > QDRANT_URL/QDRANT_API_KEY
14
  """
15
- url = (
16
- st.session_state.get("qdrant_url_input")
17
- or os.getenv("QDRANT_URL")
18
- )
19
- api_key = (
20
- st.session_state.get("qdrant_key_input")
21
- or os.getenv("QDRANT_API_KEY")
22
- )
23
  return url, api_key
24
 
25
 
 
12
 
13
  Priority: session_state > QDRANT_URL/QDRANT_API_KEY
14
  """
15
+ url = st.session_state.get("qdrant_url_input") or os.getenv("QDRANT_URL")
16
+ api_key = st.session_state.get("qdrant_key_input") or os.getenv("QDRANT_API_KEY")
 
 
 
 
 
 
17
  return url, api_key
18
 
19
 
examples/COMMANDS.md CHANGED
@@ -11,6 +11,21 @@ export QDRANT_API_KEY="..." # optional
11
 
12
  Or create a `.env` file in `visual-rag-toolkit/` with the same variables.
13
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  ## Index + evaluate (single run)
15
 
16
  This is the β€œall-in-one” script (indexes, then evaluates once):
@@ -123,7 +138,7 @@ python -m benchmarks.vidore_beir_qdrant.run_qdrant_beir \
123
  --prefer-grpc \
124
  --torch-dtype float32 \
125
  --qdrant-vector-dtype float32 \
126
- --batch-size 1 \
127
  --upload-batch-size 4 \
128
  --upload-workers 0 \
129
  --no-cloudinary \
@@ -131,7 +146,7 @@ python -m benchmarks.vidore_beir_qdrant.run_qdrant_beir \
131
  ```
132
 
133
  Notes:
134
- - **`--batch-size 1`** is the safest starting point on Apple Silicon (MPS). Increase cautiously if stable.
135
  - This does **not** enable cropping (we do **not** pass `--crop-empty`).
136
 
137
  ## Evaluate later (optional)
@@ -174,4 +189,52 @@ python -m benchmarks.vidore_beir_qdrant.run_qdrant_beir \
174
  --evaluation-scope per_dataset
175
  ```
176
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
177
 
 
11
 
12
  Or create a `.env` file in `visual-rag-toolkit/` with the same variables.
13
 
14
+ ## Experimental pooling knobs (index-time)
15
+
16
+ When indexing, you can control:
17
+
18
+ - **Adaptive mean pooling cap** (ColQwen2.5): `--max-mean-pool-vectors 32` (default), or `<=0` for **no cap**
19
+ - **ColPali experimental pooling window(s)**: `--pooling-windows 3` or `--pooling-windows 1 3 5`
20
+ - Multiple windows are stored as named vectors: `experimental_pooling_{k}`
21
+ - The canonical `experimental_pooling` uses the **first** provided window
22
+ - **ColQwen experimental pooling variants (always written)**:
23
+ - `experimental_pooling` (Gaussian alias)
24
+ - `experimental_pooling_gaussian`
25
+ - `experimental_pooling_triangular`
26
+ - **Experimental pooling kernel** (ColPali): `--experimental-pooling-kernel auto|legacy|uniform|triangular|gaussian`
27
+ - **ColSmol 2D experimental pooling**: `--colsmol-experimental-2d` (stores `experimental_pooling_2d`)
28
+
29
  ## Index + evaluate (single run)
30
 
31
  This is the β€œall-in-one” script (indexes, then evaluates once):
 
138
  --prefer-grpc \
139
  --torch-dtype float32 \
140
  --qdrant-vector-dtype float32 \
141
+ --batch-size 6 \
142
  --upload-batch-size 4 \
143
  --upload-workers 0 \
144
  --no-cloudinary \
 
146
  ```
147
 
148
  Notes:
149
+ - On Apple Silicon (MPS), batched queries should be stable for ColQwen2.5; if you see NaNs, reduce `--batch-size` and/or set `VISUALRAG_SORT_QUERIES_BY_LENGTH=1`.
150
  - This does **not** enable cropping (we do **not** pass `--crop-empty`).
151
 
152
  ## Evaluate later (optional)
 
189
  --evaluation-scope per_dataset
190
  ```
191
 
192
+ Single-stage experiments on **experimental pooling** (no rerank):
193
+
194
+ - **Tokens vs experimental pooled vectors** (MaxSim query tokens vs `experimental_pooling`):
195
+
196
+ ```bash
197
+ python -m benchmarks.vidore_beir_qdrant.run_qdrant_beir \
198
+ --datasets vidore/esg_reports_v2 \
199
+ --collection vidore_v2__colqwen25_fp32 \
200
+ --model vidore/colqwen2.5-v0.2 \
201
+ --prefer-grpc \
202
+ --torch-dtype float32 \
203
+ --qdrant-vector-dtype float32 \
204
+ --mode single_experimental_tokens \
205
+ --top-k 100
206
+ ```
207
+
208
+ - **Pooled query vs experimental pooled vectors** (pooled query vs `experimental_pooling`):
209
+
210
+ ```bash
211
+ python -m benchmarks.vidore_beir_qdrant.run_qdrant_beir \
212
+ --datasets vidore/esg_reports_v2 \
213
+ --collection vidore_v2__colqwen25_fp32 \
214
+ --model vidore/colqwen2.5-v0.2 \
215
+ --prefer-grpc \
216
+ --torch-dtype float32 \
217
+ --qdrant-vector-dtype float32 \
218
+ --mode single_experimental_pooled \
219
+ --top-k 100
220
+ ```
221
+
222
+ If you indexed multiple windows (ColPali; e.g. `--pooling-windows 1 3 5`), select one via:
223
+
224
+ ```bash
225
+ --experimental-pooling-k 3
226
+ ```
227
+
228
+ If you’re using ColQwen and want the alternate variant, select via:
229
+
230
+ ```bash
231
+ --experimental-pooling-technique triangular
232
+ ```
233
+
234
+ Internal helper: update existing collections’ experimental vectors (no re-embedding)
235
+
236
+ ```bash
237
+ python -m scripts.qdrant_update_experimental_poolings
238
+ ```
239
+
240
 
scripts/qdrant_rebuild_collection_no_index.py CHANGED
@@ -232,6 +232,16 @@ def main() -> None:
232
  "('experimental_pooling_{k}') in the rebuilt collection schema."
233
  ),
234
  )
 
 
 
 
 
 
 
 
 
 
235
  parser.add_argument(
236
  "--keep-temp", action="store_true", help="Do not delete temp collection at the end"
237
  )
@@ -250,6 +260,10 @@ def main() -> None:
250
  seen.add(ki)
251
  ks_norm.append(ki)
252
  experimental_vector_names = [f"experimental_pooling_{k}" for k in ks_norm]
 
 
 
 
253
 
254
  if DOTENV_AVAILABLE:
255
  load_dotenv()
 
232
  "('experimental_pooling_{k}') in the rebuilt collection schema."
233
  ),
234
  )
235
+ parser.add_argument(
236
+ "--add-colqwen-techniques",
237
+ dest="add_colqwen_techniques",
238
+ action="store_true",
239
+ default=False,
240
+ help=(
241
+ "Include ColQwen experimental technique named vectors in the rebuilt schema: "
242
+ "'experimental_pooling_gaussian' and 'experimental_pooling_triangular'."
243
+ ),
244
+ )
245
  parser.add_argument(
246
  "--keep-temp", action="store_true", help="Do not delete temp collection at the end"
247
  )
 
260
  seen.add(ki)
261
  ks_norm.append(ki)
262
  experimental_vector_names = [f"experimental_pooling_{k}" for k in ks_norm]
263
+ if bool(getattr(args, "add_colqwen_techniques", False)):
264
+ experimental_vector_names.extend(
265
+ ["experimental_pooling_gaussian", "experimental_pooling_triangular"]
266
+ )
267
 
268
  if DOTENV_AVAILABLE:
269
  load_dotenv()
scripts/qdrant_recompute_colqwen_pooling_from_initial.py CHANGED
@@ -43,7 +43,7 @@ from qdrant_client.http import models as qm
43
 
44
  from visual_rag.embedding.pooling import (
45
  adaptive_row_mean_pooling_from_grid,
46
- colpali_experimental_pooling_from_rows,
47
  )
48
 
49
 
@@ -159,8 +159,9 @@ def main() -> None:
159
  nargs="+",
160
  default=None,
161
  help=(
162
- "Experimental pooling window size(s). If multiple are provided, vectors are updated as "
163
- "'experimental_pooling_{k}' and the canonical 'experimental_pooling' aliases the first provided k."
 
164
  ),
165
  )
166
  args = ap.parse_args()
@@ -180,22 +181,12 @@ def main() -> None:
180
  check_compatibility=False,
181
  )
182
 
183
- # Requested experimental window sizes and required named vectors
184
- ks = args.pooling_windows if args.pooling_windows else [5]
185
- seen = set()
186
- ks_norm = []
187
- for k in ks:
188
- try:
189
- ki = int(k)
190
- except Exception:
191
- continue
192
- if ki <= 0 or ki in seen:
193
- continue
194
- seen.add(ki)
195
- ks_norm.append(ki)
196
- if not ks_norm:
197
- ks_norm = [5]
198
- exp_names = ["experimental_pooling"] + [f"experimental_pooling_{k}" for k in ks_norm]
199
  try:
200
  info = client.get_collection(str(args.collection))
201
  vectors = info.config.params.vectors or {}
@@ -314,17 +305,12 @@ def main() -> None:
314
  ),
315
  output_dtype=np.float32,
316
  )
317
- exp_by_name = {}
318
- canonical_k = int(ks_norm[0])
319
- for k in ks_norm:
320
- exp = colpali_experimental_pooling_from_rows(
321
- mean_pool,
322
- window_size=int(k),
323
- output_dtype=np.float32,
324
- )
325
- exp_by_name[f"experimental_pooling_{int(k)}"] = exp
326
- if int(k) == canonical_k:
327
- exp_by_name["experimental_pooling"] = exp
328
  glob = mean_pool.mean(axis=0).astype(np.float32)
329
 
330
  pv_batch.append(
@@ -333,7 +319,9 @@ def main() -> None:
333
  vector={
334
  "mean_pooling": mean_pool.tolist(),
335
  "global_pooling": glob.tolist(),
336
- **{name: vec.tolist() for name, vec in exp_by_name.items()},
 
 
337
  },
338
  )
339
  )
 
43
 
44
  from visual_rag.embedding.pooling import (
45
  adaptive_row_mean_pooling_from_grid,
46
+ weighted_row_smoothing_same_length,
47
  )
48
 
49
 
 
159
  nargs="+",
160
  default=None,
161
  help=(
162
+ "Deprecated (ColQwen now uses technique variants). Ignored. "
163
+ "This script always writes: experimental_pooling (Gaussian alias), "
164
+ "experimental_pooling_gaussian and experimental_pooling_triangular (both k=3)."
165
  ),
166
  )
167
  args = ap.parse_args()
 
181
  check_compatibility=False,
182
  )
183
 
184
+ # Required named vectors for ColQwen experimental variants
185
+ exp_names = [
186
+ "experimental_pooling",
187
+ "experimental_pooling_gaussian",
188
+ "experimental_pooling_triangular",
189
+ ]
 
 
 
 
 
 
 
 
 
 
190
  try:
191
  info = client.get_collection(str(args.collection))
192
  vectors = info.config.params.vectors or {}
 
305
  ),
306
  output_dtype=np.float32,
307
  )
308
+ exp_gaussian = weighted_row_smoothing_same_length(
309
+ mean_pool, window_size=3, kernel="gaussian", output_dtype=np.float32
310
+ )
311
+ exp_triangular = weighted_row_smoothing_same_length(
312
+ mean_pool, window_size=3, kernel="triangular", output_dtype=np.float32
313
+ )
 
 
 
 
 
314
  glob = mean_pool.mean(axis=0).astype(np.float32)
315
 
316
  pv_batch.append(
 
319
  vector={
320
  "mean_pooling": mean_pool.tolist(),
321
  "global_pooling": glob.tolist(),
322
+ "experimental_pooling": exp_gaussian.tolist(),
323
+ "experimental_pooling_gaussian": exp_gaussian.tolist(),
324
+ "experimental_pooling_triangular": exp_triangular.tolist(),
325
  },
326
  )
327
  )
visual_rag/cli/main.py CHANGED
@@ -114,35 +114,46 @@ def cmd_process(args):
114
  processor_speed=str(getattr(args, "processor_speed", "fast")),
115
  )
116
 
117
- # Experimental pooling windows/kernel (for additional Qdrant named vectors)
118
  model_lower = (model_name or "").lower()
119
  is_colqwen25 = "colqwen2.5" in model_lower or "colqwen2_5" in model_lower
120
  is_colsmol = "colsmol" in model_lower
121
- kernel_arg = str(getattr(args, "experimental_pooling_kernel", "auto") or "auto").lower().strip()
122
- if kernel_arg == "auto":
123
- kernel = "gaussian" if is_colqwen25 else "legacy"
 
 
 
 
 
 
 
 
 
 
 
 
 
124
  else:
125
- kernel = kernel_arg
126
- default_k = 5 if is_colqwen25 else 3
127
- if kernel != "legacy":
128
  default_k = 3
129
- ks = args.pooling_windows if getattr(args, "pooling_windows", None) else [default_k]
130
- seen_ks = set()
131
- ks_norm = []
132
- for k in ks:
133
- try:
134
- ki = int(k)
135
- except Exception:
136
- continue
137
- if ki <= 0:
138
- continue
139
- if ki in seen_ks:
140
- continue
141
- seen_ks.add(ki)
142
- ks_norm.append(ki)
143
- if not ks_norm:
144
- ks_norm = [default_k]
145
- experimental_vector_names = [f"experimental_pooling_{int(k)}" for k in ks_norm]
146
  if is_colsmol and bool(getattr(args, "colsmol_experimental_2d", False)):
147
  experimental_vector_names.append("experimental_pooling_2d")
148
 
@@ -297,9 +308,53 @@ def cmd_search(args):
297
  check_compatibility=False,
298
  )
299
 
 
 
 
300
  exp_vector_name = "experimental_pooling"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
301
  if getattr(args, "experimental_pooling_k", None) is not None:
302
- if str(args.stage1_mode) in ("pooled_query_vs_experimental_pooling", "tokens_vs_experimental_pooling"):
 
 
 
 
 
 
 
 
 
 
 
303
  exp_vector_name = f"experimental_pooling_{int(args.experimental_pooling_k)}"
304
  else:
305
  logger.warning(
@@ -309,9 +364,14 @@ def cmd_search(args):
309
  two_stage = TwoStageRetriever(
310
  client, args.collection, experimental_vector_name=str(exp_vector_name)
311
  )
312
- single_stage = SingleStageRetriever(client, args.collection)
 
 
313
 
314
- if str(args.stage1_mode) in ("pooled_query_vs_experimental_pooling", "tokens_vs_experimental_pooling"):
 
 
 
315
  try:
316
  info = client.get_collection(str(args.collection))
317
  vectors = info.config.params.vectors or {}
@@ -323,7 +383,7 @@ def cmd_search(args):
323
  raise SystemExit(
324
  f"Requested experimental vector '{exp_vector_name}' is not present in the collection. "
325
  f"Available experimental vectors: {candidates or '[]'}. "
326
- "Re-index with --pooling-windows (and --force-recreate) to add it."
327
  )
328
 
329
  # Embed query
@@ -362,6 +422,20 @@ def cmd_search(args):
362
  strategy="pooled_global",
363
  filter_obj=filter_obj,
364
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
365
  else:
366
  results = two_stage.search(
367
  query_embedding=query_np,
@@ -564,9 +638,10 @@ Examples:
564
  nargs="+",
565
  default=None,
566
  help=(
567
- "Experimental pooling window size(s). Provide one int to override the default window, "
568
  "or multiple ints to index/store multiple experimental vectors as "
569
- "'experimental_pooling_{k}' (and 'experimental_pooling' aliases the first provided k)."
 
570
  ),
571
  )
572
  process_parser.add_argument(
@@ -578,7 +653,8 @@ Examples:
578
  help=(
579
  "Experimental pooling kernel. "
580
  "'legacy' uses the historical ColPali conv-style pooling (N->N+2r; default for ColPali). "
581
- "'gaussian'/'triangular'/'uniform' use weighted same-length smoothing (N->N; default for ColQwen2.5)."
 
582
  ),
583
  )
584
  process_parser.add_argument(
@@ -637,7 +713,14 @@ Examples:
637
  "--strategy",
638
  type=str,
639
  default="single_full",
640
- choices=["single_full", "single_tiles", "single_global", "two_stage"],
 
 
 
 
 
 
 
641
  help="Search strategy",
642
  )
643
  search_parser.add_argument(
@@ -667,10 +750,22 @@ Examples:
667
  type=int,
668
  default=None,
669
  help=(
670
- "When using an experimental stage1-mode, select which indexed experimental vector to use "
671
  "(Qdrant named vector: 'experimental_pooling_{k}'). If omitted, uses 'experimental_pooling'."
672
  ),
673
  )
 
 
 
 
 
 
 
 
 
 
 
 
674
  search_parser.add_argument("--year", type=int, help="Filter by year")
675
  search_parser.add_argument("--source", type=str, help="Filter by source")
676
  search_parser.add_argument("--district", type=str, help="Filter by district")
 
114
  processor_speed=str(getattr(args, "processor_speed", "fast")),
115
  )
116
 
117
+ # Experimental pooling vectors (for additional Qdrant named vectors)
118
  model_lower = (model_name or "").lower()
119
  is_colqwen25 = "colqwen2.5" in model_lower or "colqwen2_5" in model_lower
120
  is_colsmol = "colsmol" in model_lower
121
+ experimental_vector_names = []
122
+ if is_colqwen25:
123
+ # ColQwen2.5: always store both named vectors explicitly.
124
+ experimental_vector_names.extend(
125
+ ["experimental_pooling_gaussian", "experimental_pooling_triangular"]
126
+ )
127
+ if getattr(args, "pooling_windows", None):
128
+ logger.warning(
129
+ "⚠️ --pooling-windows is ignored for ColQwen2.5 (use technique variants instead)."
130
+ )
131
+ if str(
132
+ getattr(args, "experimental_pooling_kernel", "auto") or "auto"
133
+ ).lower().strip() not in ("auto", "gaussian", "triangular"):
134
+ logger.warning(
135
+ "⚠️ --experimental-pooling-kernel is ignored for ColQwen2.5 (fixed gaussian+triangular k=3)."
136
+ )
137
  else:
138
+ # ColPali-style: optional multiple ks stored as experimental_pooling_{k}
 
 
139
  default_k = 3
140
+ ks = args.pooling_windows if getattr(args, "pooling_windows", None) else [default_k]
141
+ seen_ks = set()
142
+ ks_norm = []
143
+ for k in ks:
144
+ try:
145
+ ki = int(k)
146
+ except Exception:
147
+ continue
148
+ if ki <= 0:
149
+ continue
150
+ if ki in seen_ks:
151
+ continue
152
+ seen_ks.add(ki)
153
+ ks_norm.append(ki)
154
+ if not ks_norm:
155
+ ks_norm = [default_k]
156
+ experimental_vector_names = [f"experimental_pooling_{int(k)}" for k in ks_norm]
157
  if is_colsmol and bool(getattr(args, "colsmol_experimental_2d", False)):
158
  experimental_vector_names.append("experimental_pooling_2d")
159
 
 
308
  check_compatibility=False,
309
  )
310
 
311
+ def _is_colqwen_model(model_name: str) -> bool:
312
+ return "colqwen" in str(model_name).lower()
313
+
314
  exp_vector_name = "experimental_pooling"
315
+ uses_experimental_vector = str(args.strategy) in (
316
+ "single_experimental_tokens",
317
+ "single_experimental_pooled",
318
+ ) or (
319
+ str(args.strategy) == "two_stage"
320
+ and str(args.stage1_mode)
321
+ in ("pooled_query_vs_experimental_pooling", "tokens_vs_experimental_pooling")
322
+ )
323
+ if (
324
+ getattr(args, "experimental_pooling_technique", None)
325
+ and getattr(args, "experimental_pooling_k", None) is not None
326
+ ):
327
+ raise SystemExit(
328
+ "Use only one of --experimental-pooling-technique or --experimental-pooling-k."
329
+ )
330
+
331
+ if getattr(args, "experimental_pooling_technique", None):
332
+ if not uses_experimental_vector:
333
+ logger.warning(
334
+ "--experimental-pooling-technique was provided but this strategy does not use experimental vectors; ignoring."
335
+ )
336
+ else:
337
+ if not _is_colqwen_model(args.model):
338
+ raise SystemExit(
339
+ "--experimental-pooling-technique is only supported for ColQwen models."
340
+ )
341
+ exp_vector_name = (
342
+ f"experimental_pooling_{str(args.experimental_pooling_technique).strip().lower()}"
343
+ )
344
+
345
  if getattr(args, "experimental_pooling_k", None) is not None:
346
+ if _is_colqwen_model(args.model):
347
+ raise SystemExit(
348
+ "--experimental-pooling-k is intended for ColPali (experimental_pooling_{k}), not ColQwen."
349
+ )
350
+ if not uses_experimental_vector:
351
+ logger.warning(
352
+ "--experimental-pooling-k was provided but this strategy does not use experimental vectors; ignoring."
353
+ )
354
+ elif str(args.stage1_mode) in (
355
+ "pooled_query_vs_experimental_pooling",
356
+ "tokens_vs_experimental_pooling",
357
+ ) or str(args.strategy) in ("single_experimental_tokens", "single_experimental_pooled"):
358
  exp_vector_name = f"experimental_pooling_{int(args.experimental_pooling_k)}"
359
  else:
360
  logger.warning(
 
364
  two_stage = TwoStageRetriever(
365
  client, args.collection, experimental_vector_name=str(exp_vector_name)
366
  )
367
+ single_stage = SingleStageRetriever(
368
+ client, args.collection, experimental_vector_name=str(exp_vector_name)
369
+ )
370
 
371
+ if str(args.stage1_mode) in (
372
+ "pooled_query_vs_experimental_pooling",
373
+ "tokens_vs_experimental_pooling",
374
+ ):
375
  try:
376
  info = client.get_collection(str(args.collection))
377
  vectors = info.config.params.vectors or {}
 
383
  raise SystemExit(
384
  f"Requested experimental vector '{exp_vector_name}' is not present in the collection. "
385
  f"Available experimental vectors: {candidates or '[]'}. "
386
+ "Re-index (and --force-recreate) to add it."
387
  )
388
 
389
  # Embed query
 
422
  strategy="pooled_global",
423
  filter_obj=filter_obj,
424
  )
425
+ elif args.strategy == "single_experimental_tokens":
426
+ results = single_stage.search(
427
+ query_embedding=query_np,
428
+ top_k=args.top_k,
429
+ strategy="experimental_maxsim",
430
+ filter_obj=filter_obj,
431
+ )
432
+ elif args.strategy == "single_experimental_pooled":
433
+ results = single_stage.search(
434
+ query_embedding=query_np,
435
+ top_k=args.top_k,
436
+ strategy="pooled_experimental",
437
+ filter_obj=filter_obj,
438
+ )
439
  else:
440
  results = two_stage.search(
441
  query_embedding=query_np,
 
638
  nargs="+",
639
  default=None,
640
  help=(
641
+ "ColPali only: experimental pooling window size(s). Provide one int to override the default window, "
642
  "or multiple ints to index/store multiple experimental vectors as "
643
+ "'experimental_pooling_{k}' (and 'experimental_pooling' aliases the first provided k). "
644
+ "Ignored for ColQwen2.5 (which stores gaussian+triangular variants)."
645
  ),
646
  )
647
  process_parser.add_argument(
 
653
  help=(
654
  "Experimental pooling kernel. "
655
  "'legacy' uses the historical ColPali conv-style pooling (N->N+2r; default for ColPali). "
656
+ "'gaussian'/'triangular'/'uniform' use weighted same-length smoothing (N->N). "
657
+ "Ignored for ColQwen2.5 (which stores gaussian+triangular variants with k=3)."
658
  ),
659
  )
660
  process_parser.add_argument(
 
713
  "--strategy",
714
  type=str,
715
  default="single_full",
716
+ choices=[
717
+ "single_full",
718
+ "single_tiles",
719
+ "single_global",
720
+ "single_experimental_tokens",
721
+ "single_experimental_pooled",
722
+ "two_stage",
723
+ ],
724
  help="Search strategy",
725
  )
726
  search_parser.add_argument(
 
750
  type=int,
751
  default=None,
752
  help=(
753
+ "ColPali only: when using an experimental stage1-mode, select which indexed experimental vector to use "
754
  "(Qdrant named vector: 'experimental_pooling_{k}'). If omitted, uses 'experimental_pooling'."
755
  ),
756
  )
757
+ search_parser.add_argument(
758
+ "--experimental-pooling-technique",
759
+ "--experimental_pooling_technique",
760
+ type=str,
761
+ default=None,
762
+ choices=["gaussian", "triangular"],
763
+ help=(
764
+ "ColQwen only: choose experimental pooling named vector for experimental strategies/stage-1. "
765
+ "Maps to: 'experimental_pooling_gaussian' or 'experimental_pooling_triangular'. "
766
+ "If omitted, uses 'experimental_pooling' (Gaussian alias)."
767
+ ),
768
+ )
769
  search_parser.add_argument("--year", type=int, help="Filter by year")
770
  search_parser.add_argument("--source", type=str, help="Filter by source")
771
  search_parser.add_argument("--district", type=str, help="Filter by district")
visual_rag/embedding/visual_embedder.py CHANGED
@@ -441,7 +441,8 @@ class VisualEmbedder:
441
  try:
442
  if tok is not None:
443
  lengths = [
444
- len(tok(q, add_special_tokens=True).get("input_ids", [])) for q in query_texts
 
445
  ]
446
  else:
447
  lengths = [len(str(q)) for q in query_texts]
@@ -804,7 +805,9 @@ class VisualEmbedder:
804
  if grid * grid == num_tokens:
805
  # For ColQwen2.5 with unset cap, keep all rows (grid) rather than defaulting to 32.
806
  effective_target_rows = (
807
- int(grid) if (is_colqwen25 and target_vectors_cap is None) else int(target_vectors_cap)
 
 
808
  )
809
  if int(grid) == int(effective_target_rows):
810
  return colpali_row_mean_pooling(
@@ -911,7 +914,11 @@ class VisualEmbedder:
911
  return weighted_row_smoothing_same_length(
912
  rows,
913
  window_size=window,
914
- kernel=("gaussian" if k == "gaussian" else ("triangular" if k == "triangular" else "uniform")),
 
 
 
 
915
  output_dtype=self.output_dtype,
916
  )
917
 
 
441
  try:
442
  if tok is not None:
443
  lengths = [
444
+ len(tok(q, add_special_tokens=True).get("input_ids", []))
445
+ for q in query_texts
446
  ]
447
  else:
448
  lengths = [len(str(q)) for q in query_texts]
 
805
  if grid * grid == num_tokens:
806
  # For ColQwen2.5 with unset cap, keep all rows (grid) rather than defaulting to 32.
807
  effective_target_rows = (
808
+ int(grid)
809
+ if (is_colqwen25 and target_vectors_cap is None)
810
+ else int(target_vectors_cap)
811
  )
812
  if int(grid) == int(effective_target_rows):
813
  return colpali_row_mean_pooling(
 
914
  return weighted_row_smoothing_same_length(
915
  rows,
916
  window_size=window,
917
+ kernel=(
918
+ "gaussian"
919
+ if k == "gaussian"
920
+ else ("triangular" if k == "triangular" else "uniform")
921
+ ),
922
  output_dtype=self.output_dtype,
923
  )
924
 
visual_rag/indexing/pipeline.py CHANGED
@@ -16,11 +16,10 @@ The metadata stored includes everything needed for saliency visualization:
16
  """
17
 
18
  import gc
19
- import time
20
  import hashlib
21
  import logging
22
  from pathlib import Path
23
- from typing import Dict, Any, List, Optional, Set, Tuple
24
 
25
  import numpy as np
26
  import torch
@@ -31,7 +30,7 @@ logger = logging.getLogger(__name__)
31
  class ProcessingPipeline:
32
  """
33
  End-to-end pipeline for PDF processing and indexing.
34
-
35
  This pipeline:
36
  1. Converts PDFs to images
37
  2. Resizes for ColPali processing
@@ -39,7 +38,7 @@ class ProcessingPipeline:
39
  4. Computes pooling (strategy-dependent)
40
  5. Uploads images to Cloudinary (optional)
41
  6. Stores in Qdrant with full saliency metadata
42
-
43
  Args:
44
  embedder: VisualEmbedder instance
45
  indexer: QdrantIndexer instance (optional)
@@ -52,34 +51,34 @@ class ProcessingPipeline:
52
  This is our NOVEL contribution - preserves spatial structure while reducing size.
53
  - "standard": Push ALL tokens as-is (including special tokens, padding)
54
  This is the baseline approach for comparison.
55
-
56
  Example:
57
  >>> from visual_rag import VisualEmbedder, QdrantIndexer, CloudinaryUploader
58
  >>> from visual_rag.indexing.pipeline import ProcessingPipeline
59
- >>>
60
  >>> # Our novel pooling strategy (default)
61
  >>> pipeline = ProcessingPipeline(
62
  ... embedder=VisualEmbedder(),
63
  ... indexer=QdrantIndexer(url, api_key, "my_collection"),
64
  ... embedding_strategy="pooling", # Visual tokens only + tile pooling
65
  ... )
66
- >>>
67
  >>> # Standard baseline (all tokens, no filtering)
68
  >>> pipeline_baseline = ProcessingPipeline(
69
  ... embedder=VisualEmbedder(),
70
  ... indexer=QdrantIndexer(url, api_key, "my_collection_baseline"),
71
  ... embedding_strategy="standard", # All tokens as-is
72
  ... )
73
- >>>
74
  >>> pipeline.process_pdf(Path("report.pdf"))
75
  """
76
-
77
  # Valid embedding strategies
78
  # - "pooling": Visual tokens only + tile-level pooling (NOVEL)
79
  # - "standard": All tokens + global mean (BASELINE)
80
  # - "all": Embed once, push BOTH representations (efficient comparison)
81
  STRATEGIES = ["pooling", "standard", "all"]
82
-
83
  def __init__(
84
  self,
85
  embedder=None,
@@ -104,7 +103,7 @@ class ProcessingPipeline:
104
  self.cloudinary_uploader = cloudinary_uploader
105
  self.metadata_mapping = metadata_mapping or {}
106
  self.config = config or {}
107
-
108
  # Validate and set embedding strategy
109
  if embedding_strategy not in self.STRATEGIES:
110
  raise ValueError(
@@ -117,31 +116,34 @@ class ProcessingPipeline:
117
  self.crop_empty_percentage_to_remove = float(crop_empty_percentage_to_remove)
118
  self.crop_empty_remove_page_number = bool(crop_empty_remove_page_number)
119
  self.crop_empty_preserve_border_px = int(crop_empty_preserve_border_px)
120
- self.crop_empty_uniform_rowcol_std_threshold = float(crop_empty_uniform_rowcol_std_threshold)
 
 
121
 
122
  self.max_mean_pool_vectors = max_mean_pool_vectors
123
  self.pooling_windows = pooling_windows
124
  self.experimental_pooling_kernel = str(experimental_pooling_kernel or "auto")
125
  self.colsmol_experimental_2d = bool(colsmol_experimental_2d)
126
-
127
  logger.info(f"πŸ“Š Embedding strategy: {embedding_strategy}")
128
  if embedding_strategy == "pooling":
129
  logger.info(" β†’ Visual tokens only + tile-level mean pooling (NOVEL)")
130
  else:
131
  logger.info(" β†’ All tokens as-is (BASELINE)")
132
-
133
  # Create PDF processor if not provided
134
  if pdf_processor is None:
135
  from visual_rag.indexing.pdf_processor import PDFProcessor
 
136
  dpi = self.config.get("processing", {}).get("dpi", 140)
137
  pdf_processor = PDFProcessor(dpi=dpi)
138
  self.pdf_processor = pdf_processor
139
-
140
  # Config defaults
141
  self.embedding_batch_size = self.config.get("batching", {}).get("embedding_batch_size", 8)
142
  self.upload_batch_size = self.config.get("batching", {}).get("upload_batch_size", 8)
143
  self.delay_between_uploads = self.config.get("delays", {}).get("between_uploads", 0.5)
144
-
145
  def process_pdf(
146
  self,
147
  pdf_path: Path,
@@ -153,7 +155,7 @@ class ProcessingPipeline:
153
  ) -> Dict[str, Any]:
154
  """
155
  Process a single PDF end-to-end.
156
-
157
  Args:
158
  pdf_path: Path to PDF file
159
  skip_existing: Skip pages that already exist in Qdrant
@@ -161,7 +163,7 @@ class ProcessingPipeline:
161
  upload_to_qdrant: Upload embeddings to Qdrant
162
  original_filename: Original filename (use this instead of pdf_path.name for temp files)
163
  progress_callback: Optional callback(stage, current, total, message) for progress updates
164
-
165
  Returns:
166
  Dict with processing results:
167
  {
@@ -176,15 +178,15 @@ class ProcessingPipeline:
176
  pdf_path = Path(pdf_path)
177
  filename = original_filename or pdf_path.name
178
  logger.info(f"πŸ“š Processing PDF: {filename}")
179
-
180
  # Check existing pages
181
  existing_ids: Set[str] = set()
182
  if skip_existing and self.indexer:
183
  existing_ids = self.indexer.get_existing_ids(filename)
184
  if existing_ids:
185
  logger.info(f" Found {len(existing_ids)} existing pages")
186
-
187
- logger.info(f"πŸ–ΌοΈ Converting PDF to images...")
188
  if progress_callback:
189
  progress_callback("convert", 0, 0, "Converting PDF to images...")
190
  images, texts = self.pdf_processor.process_pdf(pdf_path)
@@ -192,48 +194,55 @@ class ProcessingPipeline:
192
  logger.info(f" βœ… Converted {total_pages} pages")
193
  if progress_callback:
194
  progress_callback("convert", total_pages, total_pages, f"Converted {total_pages} pages")
195
-
196
  extra_metadata = self._get_extra_metadata(filename)
197
  if extra_metadata:
198
  logger.info(f" πŸ“‹ Found extra metadata: {list(extra_metadata.keys())}")
199
-
200
  # Process in batches
201
  uploaded = 0
202
  skipped = 0
203
  failed = 0
204
  all_pages = []
205
  upload_queue = []
206
-
207
  for batch_start in range(0, total_pages, self.embedding_batch_size):
208
  batch_end = min(batch_start + self.embedding_batch_size, total_pages)
209
  batch_images = images[batch_start:batch_end]
210
  batch_texts = texts[batch_start:batch_end]
211
-
212
  logger.info(f"πŸ“¦ Processing pages {batch_start + 1}-{batch_end}/{total_pages}")
213
  if progress_callback:
214
- progress_callback("embed", batch_start, total_pages, f"Embedding pages {batch_start + 1}-{batch_end}")
215
-
 
 
 
 
 
216
  pages_to_process = []
217
  for i, (img, text) in enumerate(zip(batch_images, batch_texts)):
218
  page_num = batch_start + i + 1
219
  chunk_id = self.generate_chunk_id(filename, page_num)
220
-
221
  if skip_existing and chunk_id in existing_ids:
222
  skipped += 1
223
  continue
224
-
225
- pages_to_process.append({
226
- "index": i,
227
- "page_num": page_num,
228
- "chunk_id": chunk_id,
229
- "raw_image": img,
230
- "text": text,
231
- })
232
-
 
 
233
  if not pages_to_process:
234
  logger.info(" All pages in batch exist, skipping...")
235
  continue
236
-
237
  # Generate embeddings with token info
238
  logger.info(f"πŸ€– Generating embeddings for {len(pages_to_process)} pages...")
239
  from visual_rag.preprocessing.crop_empty import CropEmptyConfig, crop_empty
@@ -248,7 +257,9 @@ class ProcessingPipeline:
248
  percentage_to_remove=float(self.crop_empty_percentage_to_remove),
249
  remove_page_number=bool(self.crop_empty_remove_page_number),
250
  preserve_border_px=int(self.crop_empty_preserve_border_px),
251
- uniform_rowcol_std_threshold=float(self.crop_empty_uniform_rowcol_std_threshold),
 
 
252
  ),
253
  )
254
  p["embed_image"] = cropped_img
@@ -258,14 +269,14 @@ class ProcessingPipeline:
258
  p["embed_image"] = raw_img
259
  p["crop_meta"] = None
260
  images_to_embed.append(raw_img)
261
-
262
  embeddings, token_infos = self.embedder.embed_images(
263
  images_to_embed,
264
  batch_size=self.embedding_batch_size,
265
  return_token_info=True,
266
  show_progress=True,
267
  )
268
-
269
  for idx, page_info in enumerate(pages_to_process):
270
  raw_img = page_info["raw_image"]
271
  embed_img = page_info["embed_image"]
@@ -275,10 +286,15 @@ class ProcessingPipeline:
275
  text = page_info["text"]
276
  embedding = embeddings[idx]
277
  token_info = token_infos[idx]
278
-
279
  if progress_callback:
280
- progress_callback("process", page_num, total_pages, f"Processing page {page_num}/{total_pages}")
281
-
 
 
 
 
 
282
  try:
283
  page_data = self._process_single_page(
284
  filename=filename,
@@ -295,34 +311,36 @@ class ProcessingPipeline:
295
  upload_to_cloudinary=upload_to_cloudinary,
296
  crop_meta=crop_meta,
297
  )
298
-
299
  all_pages.append(page_data)
300
-
301
  if upload_to_qdrant and self.indexer:
302
  upload_queue.append(page_data)
303
-
304
  # Upload in batches
305
  if len(upload_queue) >= self.upload_batch_size:
306
  count = self._upload_batch(upload_queue)
307
  uploaded += count
308
  upload_queue = []
309
-
310
  except Exception as e:
311
  logger.error(f" ❌ Failed page {page_num}: {e}")
312
  failed += 1
313
-
314
  # Memory cleanup
315
  gc.collect()
316
  if torch.cuda.is_available():
317
  torch.cuda.empty_cache()
318
-
319
  # Upload remaining pages
320
  if upload_queue and upload_to_qdrant and self.indexer:
321
  count = self._upload_batch(upload_queue)
322
  uploaded += count
323
-
324
- logger.info(f"βœ… Completed {filename}: {uploaded} uploaded, {skipped} skipped, {failed} failed")
325
-
 
 
326
  return {
327
  "filename": filename,
328
  "total_pages": total_pages,
@@ -331,7 +349,7 @@ class ProcessingPipeline:
331
  "failed": failed,
332
  "pages": all_pages,
333
  }
334
-
335
  def _process_single_page(
336
  self,
337
  filename: str,
@@ -350,17 +368,17 @@ class ProcessingPipeline:
350
  ) -> Dict[str, Any]:
351
  """Process a single page with full metadata for saliency."""
352
  from visual_rag.embedding.pooling import global_mean_pooling
353
-
354
  # Resize image for ColPali
355
  resized_img, tile_rows, tile_cols = self.pdf_processor.resize_for_colpali(embed_img)
356
-
357
  # Use processor's tile info if available (more accurate)
358
  proc_n_rows = token_info.get("n_rows")
359
  proc_n_cols = token_info.get("n_cols")
360
  if proc_n_rows and proc_n_cols:
361
  tile_rows = proc_n_rows
362
  tile_cols = proc_n_cols
363
-
364
  # Convert embedding to numpy
365
  if isinstance(embedding, torch.Tensor):
366
  if embedding.dtype == torch.bfloat16:
@@ -370,18 +388,18 @@ class ProcessingPipeline:
370
  else:
371
  full_embedding = np.array(embedding)
372
  full_embedding = full_embedding.astype(np.float32)
373
-
374
  # Token info for metadata
375
  visual_indices = token_info["visual_token_indices"]
376
  num_visual_tokens = token_info["num_visual_tokens"]
377
-
378
  # =========================================================================
379
  # STRATEGY: "pooling" (NOVEL) vs "standard" (BASELINE) vs "all" (BOTH)
380
  # =========================================================================
381
-
382
  # Always compute visual-only embedding (needed for pooling and saliency)
383
  visual_embedding = full_embedding[visual_indices]
384
-
385
  # Mean pooling cap: <=0 or None => no cap (ColQwen2.5 keeps all effective rows).
386
  tv = self.max_mean_pool_vectors
387
  if tv is not None:
@@ -397,46 +415,71 @@ class ProcessingPipeline:
397
  model_lower = (getattr(self.embedder, "model_name", "") or "").lower()
398
  is_colqwen25 = "colqwen2.5" in model_lower or "colqwen2_5" in model_lower
399
  is_colsmol = "colsmol" in model_lower
400
- kernel_arg = str(getattr(self, "experimental_pooling_kernel", "auto") or "auto").lower().strip()
401
- if kernel_arg == "auto":
402
- kernel = "gaussian" if is_colqwen25 else "legacy"
403
- else:
404
- kernel = kernel_arg
405
- default_k = 5 if is_colqwen25 else 3
406
- if kernel != "legacy":
407
- default_k = 3
408
- ks = self.pooling_windows if self.pooling_windows else [default_k]
409
- # Normalize + keep order, avoid duplicates.
410
- seen_ks = set()
411
- ks_norm: List[int] = []
412
- for k in ks:
413
- try:
414
- ki = int(k)
415
- except Exception:
416
- continue
417
- if ki <= 0:
418
- continue
419
- if ki in seen_ks:
420
- continue
421
- seen_ks.add(ki)
422
- ks_norm.append(ki)
423
- if not ks_norm:
424
- ks_norm = [default_k]
425
-
426
  experimental_pooled_by_name: Dict[str, Any] = {}
427
- canonical_k = int(ks_norm[0])
428
- for k in ks_norm:
429
- exp = self.embedder.experimental_pool_visual_embedding(
430
  visual_embedding,
431
  token_info,
432
  target_vectors=tv,
433
  mean_pool=tile_pooled,
434
- window_size=int(k),
435
- kernel=str(kernel),
436
  )
437
- experimental_pooled_by_name[f"experimental_pooling_{int(k)}"] = exp
438
- if int(k) == canonical_k:
439
- experimental_pooled_by_name["experimental_pooling"] = exp
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
440
 
441
  if is_colsmol and bool(getattr(self, "colsmol_experimental_2d", False)):
442
  try:
@@ -457,7 +500,11 @@ class ProcessingPipeline:
457
  except Exception:
458
  pass
459
  global_pooled = global_mean_pooling(full_embedding)
460
- global_pooling = self.embedder.global_pool_from_mean_pool(tile_pooled) if tile_pooled.size else global_pooled
 
 
 
 
461
 
462
  num_tiles = int(tile_pooled.shape[0])
463
  patches_per_tile = int(visual_embedding.shape[0] // max(num_tiles, 1)) if num_tiles else 0
@@ -466,62 +513,70 @@ class ProcessingPipeline:
466
  else:
467
  tile_rows = token_info.get("n_rows") or None
468
  tile_cols = token_info.get("n_cols") or None
469
-
470
  if self.embedding_strategy == "pooling":
471
  # NOVEL APPROACH: Visual tokens only + tile-level pooling
472
  embedding_for_initial = visual_embedding
473
  embedding_for_pooling = tile_pooled
474
- global_pooling = self.embedder.global_pool_from_mean_pool(tile_pooled) if tile_pooled.size else global_pooled
475
-
 
 
 
 
476
  elif self.embedding_strategy == "standard":
477
  # BASELINE: All tokens + global mean
478
  embedding_for_initial = full_embedding
479
  embedding_for_pooling = global_pooled.reshape(1, -1)
480
  global_pooling = global_pooled
481
-
482
  else: # "all" - Push BOTH representations (efficient for comparison)
483
  # Embed once, store multiple vector representations
484
  # This allows comparing both strategies without re-embedding
485
  embedding_for_initial = visual_embedding # Use visual for search
486
- embedding_for_pooling = tile_pooled # Use tile-level for fast prefetch
487
- global_pooling = self.embedder.global_pool_from_mean_pool(tile_pooled) if tile_pooled.size else global_pooled
488
-
 
 
 
 
489
  # ALSO store standard representations as additional vectors
490
  # These will be added to metadata for optional use
491
  pass # Extra vectors handled in return dict below
492
-
493
  # Upload to Cloudinary
494
  original_url = None
495
  cropped_url = None
496
  resized_url = None
497
-
498
  if upload_to_cloudinary and self.cloudinary_uploader:
499
  base_filename = f"{pdf_stem}_page_{page_num}"
500
  if self.crop_empty:
501
- original_url, cropped_url, resized_url = self.cloudinary_uploader.upload_original_cropped_and_resized(
502
- raw_img, embed_img, resized_img, base_filename
 
 
503
  )
504
  else:
505
  original_url, resized_url = self.cloudinary_uploader.upload_original_and_resized(
506
  raw_img, resized_img, base_filename
507
  )
508
-
509
  # Sanitize text
510
  safe_text = self._sanitize_text(text[:10000]) if text else ""
511
-
512
  metadata = {
513
  "filename": filename,
514
  "page_number": page_num,
515
  "total_pages": total_pages,
516
  "has_text": bool(text and text.strip()),
517
  "text": safe_text,
518
-
519
  # Image URLs
520
  "page": resized_url or "", # For display
521
  "original_url": original_url or "",
522
  "cropped_url": cropped_url or "",
523
  "resized_url": resized_url or "",
524
-
525
  # Dimensions (needed for saliency overlay)
526
  "original_width": raw_img.width,
527
  "original_height": raw_img.height,
@@ -529,117 +584,113 @@ class ProcessingPipeline:
529
  "cropped_height": int(embed_img.height) if self.crop_empty else int(raw_img.height),
530
  "resized_width": resized_img.width,
531
  "resized_height": resized_img.height,
532
-
533
  # Tile structure (needed for saliency)
534
  "num_tiles": num_tiles,
535
  "tile_rows": tile_rows,
536
  "tile_cols": tile_cols,
537
  "patches_per_tile": patches_per_tile,
538
-
539
  # Token info (needed for saliency)
540
  "num_visual_tokens": num_visual_tokens,
541
  "visual_token_indices": visual_indices,
542
  "total_tokens": len(full_embedding), # Total tokens in raw embedding
543
-
544
  # Strategy used (important for paper comparison)
545
  "embedding_strategy": self.embedding_strategy,
546
-
547
  "model_name": getattr(self.embedder, "model_name", None),
548
  "experimental_pooling_windows": ks_norm,
549
  "experimental_pooling_default_window": canonical_k,
550
  "experimental_pooling_kernel": str(kernel),
551
- "colsmol_experimental_2d": bool(getattr(self, "colsmol_experimental_2d", False))
552
- if is_colsmol
553
- else None,
554
  "max_mean_pool_vectors": (
555
  int(self.max_mean_pool_vectors) if self.max_mean_pool_vectors is not None else None
556
  ),
557
-
558
  "crop_empty_enabled": bool(self.crop_empty),
559
  "crop_empty_crop_box": (crop_meta or {}).get("crop_box"),
560
  "crop_empty_remove_page_number": bool(self.crop_empty_remove_page_number),
561
  "crop_empty_percentage_to_remove": float(self.crop_empty_percentage_to_remove),
562
  "crop_empty_preserve_border_px": int(self.crop_empty_preserve_border_px),
563
- "crop_empty_uniform_rowcol_std_threshold": float(self.crop_empty_uniform_rowcol_std_threshold),
564
-
 
565
  # Extra metadata (year, district, etc.)
566
  **extra_metadata,
567
  }
568
-
569
  result = {
570
  "id": chunk_id,
571
- "visual_embedding": embedding_for_initial, # "initial" vector in Qdrant
572
  "tile_pooled_embedding": embedding_for_pooling, # "mean_pooling" vector in Qdrant
573
- "experimental_pooled_embedding": experimental_pooled_by_name, # multiple "experimental_pooling_{k}"
574
  "global_pooled_embedding": global_pooling, # "global_pooling" vector in Qdrant
575
  "metadata": metadata,
576
  "image": raw_img,
577
  "resized_image": resized_img,
578
  }
579
-
580
  # For "all" strategy, include BOTH representations for comparison
581
  if self.embedding_strategy == "all":
582
  result["extra_vectors"] = {
583
  # Standard baseline vectors (for comparison)
584
- "full_embedding": full_embedding, # All tokens [total, 128]
585
- "global_pooled": global_pooled, # Global mean [128]
586
  # Pooling vectors (already in main result)
587
- "visual_embedding": visual_embedding, # Visual only [visual, 128]
588
- "tile_pooled": tile_pooled, # Tile-level [tiles, 128]
589
  }
590
-
591
  return result
592
-
593
  def _upload_batch(self, upload_queue: List[Dict[str, Any]]) -> int:
594
  """Upload batch to Qdrant."""
595
  if not upload_queue or not self.indexer:
596
  return 0
597
-
598
  logger.info(f"πŸ“€ Uploading batch of {len(upload_queue)} pages...")
599
-
600
  count = self.indexer.upload_batch(
601
  upload_queue,
602
  delay_between_batches=self.delay_between_uploads,
603
  )
604
-
605
  return count
606
-
607
  def _get_extra_metadata(self, filename: str) -> Dict[str, Any]:
608
  """Get extra metadata for a filename."""
609
  if not self.metadata_mapping:
610
  return {}
611
-
612
  # Normalize filename
613
  filename_clean = filename.replace(".pdf", "").replace(".PDF", "").strip().lower()
614
-
615
  # Try exact match
616
  if filename_clean in self.metadata_mapping:
617
  return self.metadata_mapping[filename_clean].copy()
618
-
619
  # Try fuzzy match
620
  from difflib import SequenceMatcher
621
-
622
  best_match = None
623
  best_score = 0.0
624
-
625
  for known_filename, metadata in self.metadata_mapping.items():
626
  score = SequenceMatcher(None, filename_clean, known_filename.lower()).ratio()
627
  if score > best_score and score > 0.75:
628
  best_score = score
629
  best_match = metadata
630
-
631
  if best_match:
632
  logger.debug(f"Fuzzy matched '{filename}' with score {best_score:.2f}")
633
  return best_match.copy()
634
-
635
  return {}
636
-
637
  def _sanitize_text(self, text: str) -> str:
638
  """Remove invalid Unicode characters."""
639
  if not text:
640
  return ""
641
  return text.encode("utf-8", errors="surrogatepass").decode("utf-8", errors="ignore")
642
-
643
  @staticmethod
644
  def generate_chunk_id(filename: str, page_number: int) -> str:
645
  """Generate deterministic chunk ID."""
@@ -647,12 +698,12 @@ class ProcessingPipeline:
647
  hash_obj = hashlib.sha256(content.encode())
648
  hex_str = hash_obj.hexdigest()[:32]
649
  return f"{hex_str[:8]}-{hex_str[8:12]}-{hex_str[12:16]}-{hex_str[16:20]}-{hex_str[20:32]}"
650
-
651
  @staticmethod
652
  def load_metadata_mapping(json_path: Path) -> Dict[str, Dict[str, Any]]:
653
  """
654
  Load metadata mapping from JSON file.
655
-
656
  Expected format:
657
  {
658
  "filenames": {
@@ -660,7 +711,7 @@ class ProcessingPipeline:
660
  ...
661
  }
662
  }
663
-
664
  Or simple format:
665
  {
666
  "Report Name 2023": {"year": 2023, "source": "Local Government", ...},
@@ -668,22 +719,21 @@ class ProcessingPipeline:
668
  }
669
  """
670
  import json
671
-
672
  with open(json_path, "r") as f:
673
  data = json.load(f)
674
-
675
  # Check if nested under "filenames"
676
  if "filenames" in data and isinstance(data["filenames"], dict):
677
  mapping = data["filenames"]
678
  else:
679
  mapping = data
680
-
681
  # Normalize keys to lowercase
682
  normalized = {}
683
  for filename, metadata in mapping.items():
684
  key = filename.lower().strip().replace(".pdf", "")
685
  normalized[key] = metadata
686
-
687
  logger.info(f"πŸ“– Loaded metadata for {len(normalized)} files")
688
  return normalized
689
-
 
16
  """
17
 
18
  import gc
 
19
  import hashlib
20
  import logging
21
  from pathlib import Path
22
+ from typing import Any, Dict, List, Optional, Set
23
 
24
  import numpy as np
25
  import torch
 
30
  class ProcessingPipeline:
31
  """
32
  End-to-end pipeline for PDF processing and indexing.
33
+
34
  This pipeline:
35
  1. Converts PDFs to images
36
  2. Resizes for ColPali processing
 
38
  4. Computes pooling (strategy-dependent)
39
  5. Uploads images to Cloudinary (optional)
40
  6. Stores in Qdrant with full saliency metadata
41
+
42
  Args:
43
  embedder: VisualEmbedder instance
44
  indexer: QdrantIndexer instance (optional)
 
51
  This is our NOVEL contribution - preserves spatial structure while reducing size.
52
  - "standard": Push ALL tokens as-is (including special tokens, padding)
53
  This is the baseline approach for comparison.
54
+
55
  Example:
56
  >>> from visual_rag import VisualEmbedder, QdrantIndexer, CloudinaryUploader
57
  >>> from visual_rag.indexing.pipeline import ProcessingPipeline
58
+ >>>
59
  >>> # Our novel pooling strategy (default)
60
  >>> pipeline = ProcessingPipeline(
61
  ... embedder=VisualEmbedder(),
62
  ... indexer=QdrantIndexer(url, api_key, "my_collection"),
63
  ... embedding_strategy="pooling", # Visual tokens only + tile pooling
64
  ... )
65
+ >>>
66
  >>> # Standard baseline (all tokens, no filtering)
67
  >>> pipeline_baseline = ProcessingPipeline(
68
  ... embedder=VisualEmbedder(),
69
  ... indexer=QdrantIndexer(url, api_key, "my_collection_baseline"),
70
  ... embedding_strategy="standard", # All tokens as-is
71
  ... )
72
+ >>>
73
  >>> pipeline.process_pdf(Path("report.pdf"))
74
  """
75
+
76
  # Valid embedding strategies
77
  # - "pooling": Visual tokens only + tile-level pooling (NOVEL)
78
  # - "standard": All tokens + global mean (BASELINE)
79
  # - "all": Embed once, push BOTH representations (efficient comparison)
80
  STRATEGIES = ["pooling", "standard", "all"]
81
+
82
  def __init__(
83
  self,
84
  embedder=None,
 
103
  self.cloudinary_uploader = cloudinary_uploader
104
  self.metadata_mapping = metadata_mapping or {}
105
  self.config = config or {}
106
+
107
  # Validate and set embedding strategy
108
  if embedding_strategy not in self.STRATEGIES:
109
  raise ValueError(
 
116
  self.crop_empty_percentage_to_remove = float(crop_empty_percentage_to_remove)
117
  self.crop_empty_remove_page_number = bool(crop_empty_remove_page_number)
118
  self.crop_empty_preserve_border_px = int(crop_empty_preserve_border_px)
119
+ self.crop_empty_uniform_rowcol_std_threshold = float(
120
+ crop_empty_uniform_rowcol_std_threshold
121
+ )
122
 
123
  self.max_mean_pool_vectors = max_mean_pool_vectors
124
  self.pooling_windows = pooling_windows
125
  self.experimental_pooling_kernel = str(experimental_pooling_kernel or "auto")
126
  self.colsmol_experimental_2d = bool(colsmol_experimental_2d)
127
+
128
  logger.info(f"πŸ“Š Embedding strategy: {embedding_strategy}")
129
  if embedding_strategy == "pooling":
130
  logger.info(" β†’ Visual tokens only + tile-level mean pooling (NOVEL)")
131
  else:
132
  logger.info(" β†’ All tokens as-is (BASELINE)")
133
+
134
  # Create PDF processor if not provided
135
  if pdf_processor is None:
136
  from visual_rag.indexing.pdf_processor import PDFProcessor
137
+
138
  dpi = self.config.get("processing", {}).get("dpi", 140)
139
  pdf_processor = PDFProcessor(dpi=dpi)
140
  self.pdf_processor = pdf_processor
141
+
142
  # Config defaults
143
  self.embedding_batch_size = self.config.get("batching", {}).get("embedding_batch_size", 8)
144
  self.upload_batch_size = self.config.get("batching", {}).get("upload_batch_size", 8)
145
  self.delay_between_uploads = self.config.get("delays", {}).get("between_uploads", 0.5)
146
+
147
  def process_pdf(
148
  self,
149
  pdf_path: Path,
 
155
  ) -> Dict[str, Any]:
156
  """
157
  Process a single PDF end-to-end.
158
+
159
  Args:
160
  pdf_path: Path to PDF file
161
  skip_existing: Skip pages that already exist in Qdrant
 
163
  upload_to_qdrant: Upload embeddings to Qdrant
164
  original_filename: Original filename (use this instead of pdf_path.name for temp files)
165
  progress_callback: Optional callback(stage, current, total, message) for progress updates
166
+
167
  Returns:
168
  Dict with processing results:
169
  {
 
178
  pdf_path = Path(pdf_path)
179
  filename = original_filename or pdf_path.name
180
  logger.info(f"πŸ“š Processing PDF: {filename}")
181
+
182
  # Check existing pages
183
  existing_ids: Set[str] = set()
184
  if skip_existing and self.indexer:
185
  existing_ids = self.indexer.get_existing_ids(filename)
186
  if existing_ids:
187
  logger.info(f" Found {len(existing_ids)} existing pages")
188
+
189
+ logger.info("πŸ–ΌοΈ Converting PDF to images...")
190
  if progress_callback:
191
  progress_callback("convert", 0, 0, "Converting PDF to images...")
192
  images, texts = self.pdf_processor.process_pdf(pdf_path)
 
194
  logger.info(f" βœ… Converted {total_pages} pages")
195
  if progress_callback:
196
  progress_callback("convert", total_pages, total_pages, f"Converted {total_pages} pages")
197
+
198
  extra_metadata = self._get_extra_metadata(filename)
199
  if extra_metadata:
200
  logger.info(f" πŸ“‹ Found extra metadata: {list(extra_metadata.keys())}")
201
+
202
  # Process in batches
203
  uploaded = 0
204
  skipped = 0
205
  failed = 0
206
  all_pages = []
207
  upload_queue = []
208
+
209
  for batch_start in range(0, total_pages, self.embedding_batch_size):
210
  batch_end = min(batch_start + self.embedding_batch_size, total_pages)
211
  batch_images = images[batch_start:batch_end]
212
  batch_texts = texts[batch_start:batch_end]
213
+
214
  logger.info(f"πŸ“¦ Processing pages {batch_start + 1}-{batch_end}/{total_pages}")
215
  if progress_callback:
216
+ progress_callback(
217
+ "embed",
218
+ batch_start,
219
+ total_pages,
220
+ f"Embedding pages {batch_start + 1}-{batch_end}",
221
+ )
222
+
223
  pages_to_process = []
224
  for i, (img, text) in enumerate(zip(batch_images, batch_texts)):
225
  page_num = batch_start + i + 1
226
  chunk_id = self.generate_chunk_id(filename, page_num)
227
+
228
  if skip_existing and chunk_id in existing_ids:
229
  skipped += 1
230
  continue
231
+
232
+ pages_to_process.append(
233
+ {
234
+ "index": i,
235
+ "page_num": page_num,
236
+ "chunk_id": chunk_id,
237
+ "raw_image": img,
238
+ "text": text,
239
+ }
240
+ )
241
+
242
  if not pages_to_process:
243
  logger.info(" All pages in batch exist, skipping...")
244
  continue
245
+
246
  # Generate embeddings with token info
247
  logger.info(f"πŸ€– Generating embeddings for {len(pages_to_process)} pages...")
248
  from visual_rag.preprocessing.crop_empty import CropEmptyConfig, crop_empty
 
257
  percentage_to_remove=float(self.crop_empty_percentage_to_remove),
258
  remove_page_number=bool(self.crop_empty_remove_page_number),
259
  preserve_border_px=int(self.crop_empty_preserve_border_px),
260
+ uniform_rowcol_std_threshold=float(
261
+ self.crop_empty_uniform_rowcol_std_threshold
262
+ ),
263
  ),
264
  )
265
  p["embed_image"] = cropped_img
 
269
  p["embed_image"] = raw_img
270
  p["crop_meta"] = None
271
  images_to_embed.append(raw_img)
272
+
273
  embeddings, token_infos = self.embedder.embed_images(
274
  images_to_embed,
275
  batch_size=self.embedding_batch_size,
276
  return_token_info=True,
277
  show_progress=True,
278
  )
279
+
280
  for idx, page_info in enumerate(pages_to_process):
281
  raw_img = page_info["raw_image"]
282
  embed_img = page_info["embed_image"]
 
286
  text = page_info["text"]
287
  embedding = embeddings[idx]
288
  token_info = token_infos[idx]
289
+
290
  if progress_callback:
291
+ progress_callback(
292
+ "process",
293
+ page_num,
294
+ total_pages,
295
+ f"Processing page {page_num}/{total_pages}",
296
+ )
297
+
298
  try:
299
  page_data = self._process_single_page(
300
  filename=filename,
 
311
  upload_to_cloudinary=upload_to_cloudinary,
312
  crop_meta=crop_meta,
313
  )
314
+
315
  all_pages.append(page_data)
316
+
317
  if upload_to_qdrant and self.indexer:
318
  upload_queue.append(page_data)
319
+
320
  # Upload in batches
321
  if len(upload_queue) >= self.upload_batch_size:
322
  count = self._upload_batch(upload_queue)
323
  uploaded += count
324
  upload_queue = []
325
+
326
  except Exception as e:
327
  logger.error(f" ❌ Failed page {page_num}: {e}")
328
  failed += 1
329
+
330
  # Memory cleanup
331
  gc.collect()
332
  if torch.cuda.is_available():
333
  torch.cuda.empty_cache()
334
+
335
  # Upload remaining pages
336
  if upload_queue and upload_to_qdrant and self.indexer:
337
  count = self._upload_batch(upload_queue)
338
  uploaded += count
339
+
340
+ logger.info(
341
+ f"βœ… Completed {filename}: {uploaded} uploaded, {skipped} skipped, {failed} failed"
342
+ )
343
+
344
  return {
345
  "filename": filename,
346
  "total_pages": total_pages,
 
349
  "failed": failed,
350
  "pages": all_pages,
351
  }
352
+
353
  def _process_single_page(
354
  self,
355
  filename: str,
 
368
  ) -> Dict[str, Any]:
369
  """Process a single page with full metadata for saliency."""
370
  from visual_rag.embedding.pooling import global_mean_pooling
371
+
372
  # Resize image for ColPali
373
  resized_img, tile_rows, tile_cols = self.pdf_processor.resize_for_colpali(embed_img)
374
+
375
  # Use processor's tile info if available (more accurate)
376
  proc_n_rows = token_info.get("n_rows")
377
  proc_n_cols = token_info.get("n_cols")
378
  if proc_n_rows and proc_n_cols:
379
  tile_rows = proc_n_rows
380
  tile_cols = proc_n_cols
381
+
382
  # Convert embedding to numpy
383
  if isinstance(embedding, torch.Tensor):
384
  if embedding.dtype == torch.bfloat16:
 
388
  else:
389
  full_embedding = np.array(embedding)
390
  full_embedding = full_embedding.astype(np.float32)
391
+
392
  # Token info for metadata
393
  visual_indices = token_info["visual_token_indices"]
394
  num_visual_tokens = token_info["num_visual_tokens"]
395
+
396
  # =========================================================================
397
  # STRATEGY: "pooling" (NOVEL) vs "standard" (BASELINE) vs "all" (BOTH)
398
  # =========================================================================
399
+
400
  # Always compute visual-only embedding (needed for pooling and saliency)
401
  visual_embedding = full_embedding[visual_indices]
402
+
403
  # Mean pooling cap: <=0 or None => no cap (ColQwen2.5 keeps all effective rows).
404
  tv = self.max_mean_pool_vectors
405
  if tv is not None:
 
415
  model_lower = (getattr(self.embedder, "model_name", "") or "").lower()
416
  is_colqwen25 = "colqwen2.5" in model_lower or "colqwen2_5" in model_lower
417
  is_colsmol = "colsmol" in model_lower
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
418
  experimental_pooled_by_name: Dict[str, Any] = {}
419
+ # ColQwen2.5: keep experimental_pooling as Gaussian alias, and always store both variants explicitly.
420
+ if is_colqwen25:
421
+ exp_gaussian = self.embedder.experimental_pool_visual_embedding(
422
  visual_embedding,
423
  token_info,
424
  target_vectors=tv,
425
  mean_pool=tile_pooled,
426
+ window_size=3,
427
+ kernel="gaussian",
428
  )
429
+ exp_triangular = self.embedder.experimental_pool_visual_embedding(
430
+ visual_embedding,
431
+ token_info,
432
+ target_vectors=tv,
433
+ mean_pool=tile_pooled,
434
+ window_size=3,
435
+ kernel="triangular",
436
+ )
437
+ experimental_pooled_by_name["experimental_pooling"] = exp_gaussian
438
+ experimental_pooled_by_name["experimental_pooling_gaussian"] = exp_gaussian
439
+ experimental_pooled_by_name["experimental_pooling_triangular"] = exp_triangular
440
+ ks_norm = [3]
441
+ canonical_k = 3
442
+ kernel = "gaussian"
443
+ else:
444
+ kernel_arg = (
445
+ str(getattr(self, "experimental_pooling_kernel", "auto") or "auto").lower().strip()
446
+ )
447
+ if kernel_arg == "auto":
448
+ kernel = "legacy"
449
+ else:
450
+ kernel = kernel_arg
451
+ default_k = 3
452
+ ks = self.pooling_windows if self.pooling_windows else [default_k]
453
+ # Normalize + keep order, avoid duplicates.
454
+ seen_ks = set()
455
+ ks_norm = []
456
+ for k in ks:
457
+ try:
458
+ ki = int(k)
459
+ except Exception:
460
+ continue
461
+ if ki <= 0:
462
+ continue
463
+ if ki in seen_ks:
464
+ continue
465
+ seen_ks.add(ki)
466
+ ks_norm.append(ki)
467
+ if not ks_norm:
468
+ ks_norm = [default_k]
469
+
470
+ canonical_k = int(ks_norm[0])
471
+ for k in ks_norm:
472
+ exp = self.embedder.experimental_pool_visual_embedding(
473
+ visual_embedding,
474
+ token_info,
475
+ target_vectors=tv,
476
+ mean_pool=tile_pooled,
477
+ window_size=int(k),
478
+ kernel=str(kernel),
479
+ )
480
+ experimental_pooled_by_name[f"experimental_pooling_{int(k)}"] = exp
481
+ if int(k) == canonical_k:
482
+ experimental_pooled_by_name["experimental_pooling"] = exp
483
 
484
  if is_colsmol and bool(getattr(self, "colsmol_experimental_2d", False)):
485
  try:
 
500
  except Exception:
501
  pass
502
  global_pooled = global_mean_pooling(full_embedding)
503
+ global_pooling = (
504
+ self.embedder.global_pool_from_mean_pool(tile_pooled)
505
+ if tile_pooled.size
506
+ else global_pooled
507
+ )
508
 
509
  num_tiles = int(tile_pooled.shape[0])
510
  patches_per_tile = int(visual_embedding.shape[0] // max(num_tiles, 1)) if num_tiles else 0
 
513
  else:
514
  tile_rows = token_info.get("n_rows") or None
515
  tile_cols = token_info.get("n_cols") or None
516
+
517
  if self.embedding_strategy == "pooling":
518
  # NOVEL APPROACH: Visual tokens only + tile-level pooling
519
  embedding_for_initial = visual_embedding
520
  embedding_for_pooling = tile_pooled
521
+ global_pooling = (
522
+ self.embedder.global_pool_from_mean_pool(tile_pooled)
523
+ if tile_pooled.size
524
+ else global_pooled
525
+ )
526
+
527
  elif self.embedding_strategy == "standard":
528
  # BASELINE: All tokens + global mean
529
  embedding_for_initial = full_embedding
530
  embedding_for_pooling = global_pooled.reshape(1, -1)
531
  global_pooling = global_pooled
532
+
533
  else: # "all" - Push BOTH representations (efficient for comparison)
534
  # Embed once, store multiple vector representations
535
  # This allows comparing both strategies without re-embedding
536
  embedding_for_initial = visual_embedding # Use visual for search
537
+ embedding_for_pooling = tile_pooled # Use tile-level for fast prefetch
538
+ global_pooling = (
539
+ self.embedder.global_pool_from_mean_pool(tile_pooled)
540
+ if tile_pooled.size
541
+ else global_pooled
542
+ )
543
+
544
  # ALSO store standard representations as additional vectors
545
  # These will be added to metadata for optional use
546
  pass # Extra vectors handled in return dict below
547
+
548
  # Upload to Cloudinary
549
  original_url = None
550
  cropped_url = None
551
  resized_url = None
552
+
553
  if upload_to_cloudinary and self.cloudinary_uploader:
554
  base_filename = f"{pdf_stem}_page_{page_num}"
555
  if self.crop_empty:
556
+ original_url, cropped_url, resized_url = (
557
+ self.cloudinary_uploader.upload_original_cropped_and_resized(
558
+ raw_img, embed_img, resized_img, base_filename
559
+ )
560
  )
561
  else:
562
  original_url, resized_url = self.cloudinary_uploader.upload_original_and_resized(
563
  raw_img, resized_img, base_filename
564
  )
565
+
566
  # Sanitize text
567
  safe_text = self._sanitize_text(text[:10000]) if text else ""
568
+
569
  metadata = {
570
  "filename": filename,
571
  "page_number": page_num,
572
  "total_pages": total_pages,
573
  "has_text": bool(text and text.strip()),
574
  "text": safe_text,
 
575
  # Image URLs
576
  "page": resized_url or "", # For display
577
  "original_url": original_url or "",
578
  "cropped_url": cropped_url or "",
579
  "resized_url": resized_url or "",
 
580
  # Dimensions (needed for saliency overlay)
581
  "original_width": raw_img.width,
582
  "original_height": raw_img.height,
 
584
  "cropped_height": int(embed_img.height) if self.crop_empty else int(raw_img.height),
585
  "resized_width": resized_img.width,
586
  "resized_height": resized_img.height,
 
587
  # Tile structure (needed for saliency)
588
  "num_tiles": num_tiles,
589
  "tile_rows": tile_rows,
590
  "tile_cols": tile_cols,
591
  "patches_per_tile": patches_per_tile,
 
592
  # Token info (needed for saliency)
593
  "num_visual_tokens": num_visual_tokens,
594
  "visual_token_indices": visual_indices,
595
  "total_tokens": len(full_embedding), # Total tokens in raw embedding
 
596
  # Strategy used (important for paper comparison)
597
  "embedding_strategy": self.embedding_strategy,
 
598
  "model_name": getattr(self.embedder, "model_name", None),
599
  "experimental_pooling_windows": ks_norm,
600
  "experimental_pooling_default_window": canonical_k,
601
  "experimental_pooling_kernel": str(kernel),
602
+ "colsmol_experimental_2d": (
603
+ bool(getattr(self, "colsmol_experimental_2d", False)) if is_colsmol else None
604
+ ),
605
  "max_mean_pool_vectors": (
606
  int(self.max_mean_pool_vectors) if self.max_mean_pool_vectors is not None else None
607
  ),
 
608
  "crop_empty_enabled": bool(self.crop_empty),
609
  "crop_empty_crop_box": (crop_meta or {}).get("crop_box"),
610
  "crop_empty_remove_page_number": bool(self.crop_empty_remove_page_number),
611
  "crop_empty_percentage_to_remove": float(self.crop_empty_percentage_to_remove),
612
  "crop_empty_preserve_border_px": int(self.crop_empty_preserve_border_px),
613
+ "crop_empty_uniform_rowcol_std_threshold": float(
614
+ self.crop_empty_uniform_rowcol_std_threshold
615
+ ),
616
  # Extra metadata (year, district, etc.)
617
  **extra_metadata,
618
  }
619
+
620
  result = {
621
  "id": chunk_id,
622
+ "visual_embedding": embedding_for_initial, # "initial" vector in Qdrant
623
  "tile_pooled_embedding": embedding_for_pooling, # "mean_pooling" vector in Qdrant
624
+ "experimental_pooled_embedding": experimental_pooled_by_name, # ColQwen: gaussian/triangular; ColPali: "experimental_pooling_{k}"
625
  "global_pooled_embedding": global_pooling, # "global_pooling" vector in Qdrant
626
  "metadata": metadata,
627
  "image": raw_img,
628
  "resized_image": resized_img,
629
  }
630
+
631
  # For "all" strategy, include BOTH representations for comparison
632
  if self.embedding_strategy == "all":
633
  result["extra_vectors"] = {
634
  # Standard baseline vectors (for comparison)
635
+ "full_embedding": full_embedding, # All tokens [total, 128]
636
+ "global_pooled": global_pooled, # Global mean [128]
637
  # Pooling vectors (already in main result)
638
+ "visual_embedding": visual_embedding, # Visual only [visual, 128]
639
+ "tile_pooled": tile_pooled, # Tile-level [tiles, 128]
640
  }
641
+
642
  return result
643
+
644
  def _upload_batch(self, upload_queue: List[Dict[str, Any]]) -> int:
645
  """Upload batch to Qdrant."""
646
  if not upload_queue or not self.indexer:
647
  return 0
648
+
649
  logger.info(f"πŸ“€ Uploading batch of {len(upload_queue)} pages...")
650
+
651
  count = self.indexer.upload_batch(
652
  upload_queue,
653
  delay_between_batches=self.delay_between_uploads,
654
  )
655
+
656
  return count
657
+
658
  def _get_extra_metadata(self, filename: str) -> Dict[str, Any]:
659
  """Get extra metadata for a filename."""
660
  if not self.metadata_mapping:
661
  return {}
662
+
663
  # Normalize filename
664
  filename_clean = filename.replace(".pdf", "").replace(".PDF", "").strip().lower()
665
+
666
  # Try exact match
667
  if filename_clean in self.metadata_mapping:
668
  return self.metadata_mapping[filename_clean].copy()
669
+
670
  # Try fuzzy match
671
  from difflib import SequenceMatcher
672
+
673
  best_match = None
674
  best_score = 0.0
675
+
676
  for known_filename, metadata in self.metadata_mapping.items():
677
  score = SequenceMatcher(None, filename_clean, known_filename.lower()).ratio()
678
  if score > best_score and score > 0.75:
679
  best_score = score
680
  best_match = metadata
681
+
682
  if best_match:
683
  logger.debug(f"Fuzzy matched '{filename}' with score {best_score:.2f}")
684
  return best_match.copy()
685
+
686
  return {}
687
+
688
  def _sanitize_text(self, text: str) -> str:
689
  """Remove invalid Unicode characters."""
690
  if not text:
691
  return ""
692
  return text.encode("utf-8", errors="surrogatepass").decode("utf-8", errors="ignore")
693
+
694
  @staticmethod
695
  def generate_chunk_id(filename: str, page_number: int) -> str:
696
  """Generate deterministic chunk ID."""
 
698
  hash_obj = hashlib.sha256(content.encode())
699
  hex_str = hash_obj.hexdigest()[:32]
700
  return f"{hex_str[:8]}-{hex_str[8:12]}-{hex_str[12:16]}-{hex_str[16:20]}-{hex_str[20:32]}"
701
+
702
  @staticmethod
703
  def load_metadata_mapping(json_path: Path) -> Dict[str, Dict[str, Any]]:
704
  """
705
  Load metadata mapping from JSON file.
706
+
707
  Expected format:
708
  {
709
  "filenames": {
 
711
  ...
712
  }
713
  }
714
+
715
  Or simple format:
716
  {
717
  "Report Name 2023": {"year": 2023, "source": "Local Government", ...},
 
719
  }
720
  """
721
  import json
722
+
723
  with open(json_path, "r") as f:
724
  data = json.load(f)
725
+
726
  # Check if nested under "filenames"
727
  if "filenames" in data and isinstance(data["filenames"], dict):
728
  mapping = data["filenames"]
729
  else:
730
  mapping = data
731
+
732
  # Normalize keys to lowercase
733
  normalized = {}
734
  for filename, metadata in mapping.items():
735
  key = filename.lower().strip().replace(".pdf", "")
736
  normalized[key] = metadata
737
+
738
  logger.info(f"πŸ“– Loaded metadata for {len(normalized)} files")
739
  return normalized
 
visual_rag/indexing/qdrant_indexer.py CHANGED
@@ -144,7 +144,8 @@ class QdrantIndexer:
144
  - initial: Full multi-vector embeddings (num_patches Γ— dim)
145
  - mean_pooling: Tile-level pooled vectors (num_tiles Γ— dim)
146
  - experimental_pooling: Experimental multi-vector pooling (varies by model)
147
- - experimental_pooling_{k}: Optional additional experimental poolings with different window sizes
 
148
  - global_pooling: Single vector pooled representation (dim)
149
 
150
  Args:
@@ -437,9 +438,9 @@ class QdrantIndexer:
437
  self._np_vector_dtype, copy=False
438
  )
439
  elif exp_val is not None:
440
- exp_vectors["experimental_pooling"] = np.array(exp_val, dtype=np.float32).astype(
441
- self._np_vector_dtype, copy=False
442
- )
443
 
444
  qdrant_points.append(
445
  qdrant_models.PointStruct(
 
144
  - initial: Full multi-vector embeddings (num_patches Γ— dim)
145
  - mean_pooling: Tile-level pooled vectors (num_tiles Γ— dim)
146
  - experimental_pooling: Experimental multi-vector pooling (varies by model)
147
+ - experimental_pooling_{k}: (ColPali) Optional additional experimental poolings with different window sizes
148
+ - experimental_pooling_gaussian / experimental_pooling_triangular: (ColQwen) Technique variants (k=3)
149
  - global_pooling: Single vector pooled representation (dim)
150
 
151
  Args:
 
438
  self._np_vector_dtype, copy=False
439
  )
440
  elif exp_val is not None:
441
+ exp_vectors["experimental_pooling"] = np.array(
442
+ exp_val, dtype=np.float32
443
+ ).astype(self._np_vector_dtype, copy=False)
444
 
445
  qdrant_points.append(
446
  qdrant_models.PointStruct(
visual_rag/qdrant_admin.py CHANGED
@@ -35,9 +35,7 @@ def _resolve_qdrant_connection(
35
  _maybe_load_dotenv()
36
  resolved_url = url or os.getenv("QDRANT_URL")
37
  if not resolved_url:
38
- raise ValueError(
39
- "Qdrant URL not set (pass url= or set QDRANT_URL)."
40
- )
41
  resolved_key = api_key or os.getenv("QDRANT_API_KEY")
42
  return QdrantConnection(url=str(resolved_url), api_key=resolved_key)
43
 
 
35
  _maybe_load_dotenv()
36
  resolved_url = url or os.getenv("QDRANT_URL")
37
  if not resolved_url:
38
+ raise ValueError("Qdrant URL not set (pass url= or set QDRANT_URL).")
 
 
39
  resolved_key = api_key or os.getenv("QDRANT_API_KEY")
40
  return QdrantConnection(url=str(resolved_url), api_key=resolved_key)
41
 
visual_rag/retrieval/multi_vector.py CHANGED
@@ -127,6 +127,7 @@ class MultiVectorRetriever:
127
  self._single_stage = SingleStageRetriever(
128
  qdrant_client=qdrant_client,
129
  collection_name=collection_name,
 
130
  request_timeout=request_timeout,
131
  max_retries=max_retries,
132
  retry_sleep=retry_sleep,
@@ -195,12 +196,35 @@ class MultiVectorRetriever:
195
  filter_obj=filter_obj,
196
  strategy="multi_vector",
197
  )
198
- elif mode == "single_pooled":
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
199
  return self._single_stage.search(
200
  query_embedding=query_embedding,
201
  top_k=top_k,
202
  filter_obj=filter_obj,
203
- strategy="pooled_tile",
204
  )
205
  elif mode == "two_stage":
206
  return self._two_stage.search_server_side(
 
127
  self._single_stage = SingleStageRetriever(
128
  qdrant_client=qdrant_client,
129
  collection_name=collection_name,
130
+ experimental_vector_name=str(experimental_vector_name),
131
  request_timeout=request_timeout,
132
  max_retries=max_retries,
133
  retry_sleep=retry_sleep,
 
196
  filter_obj=filter_obj,
197
  strategy="multi_vector",
198
  )
199
+ elif mode in ("single_tiles", "single_pooled"):
200
+ # single_tiles: MaxSim on pooled vectors (multi-vector)
201
+ # single_pooled: backward-compatible alias (pooled query vs pooled vectors)
202
+ return self._single_stage.search(
203
+ query_embedding=query_embedding,
204
+ top_k=top_k,
205
+ filter_obj=filter_obj,
206
+ strategy=("tiles_maxsim" if mode == "single_tiles" else "pooled_tile"),
207
+ )
208
+ elif mode == "single_global":
209
+ return self._single_stage.search(
210
+ query_embedding=query_embedding,
211
+ top_k=top_k,
212
+ filter_obj=filter_obj,
213
+ strategy="pooled_global",
214
+ )
215
+ elif mode == "single_experimental_tokens":
216
+ return self._single_stage.search(
217
+ query_embedding=query_embedding,
218
+ top_k=top_k,
219
+ filter_obj=filter_obj,
220
+ strategy="experimental_maxsim",
221
+ )
222
+ elif mode == "single_experimental_pooled":
223
  return self._single_stage.search(
224
  query_embedding=query_embedding,
225
  top_k=top_k,
226
  filter_obj=filter_obj,
227
+ strategy="pooled_experimental",
228
  )
229
  elif mode == "two_stage":
230
  return self._two_stage.search_server_side(
visual_rag/retrieval/single_stage.py CHANGED
@@ -26,6 +26,8 @@ class SingleStageRetriever:
26
  - tiles_maxsim: Native MaxSim between query tokens and tile vectors (using="mean_pooling")
27
  - pooled_tile: Pooled query vs tile vectors (using="mean_pooling")
28
  - pooled_global: Pooled query vs global pooled doc vector (using="global_pooling")
 
 
29
 
30
  Args:
31
  qdrant_client: Connected Qdrant client
@@ -43,12 +45,14 @@ class SingleStageRetriever:
43
  self,
44
  qdrant_client,
45
  collection_name: str,
 
46
  request_timeout: int = 120,
47
  max_retries: int = 3,
48
  retry_sleep: float = 1.0,
49
  ):
50
  self.client = qdrant_client
51
  self.collection_name = collection_name
 
52
  self.request_timeout = int(request_timeout)
53
  self.max_retries = max_retries
54
  self.retry_sleep = retry_sleep
@@ -100,6 +104,19 @@ class SingleStageRetriever:
100
  query_vector = query_pooled.tolist()
101
  logger.debug(f"πŸ” Global-pooled search on '{vector_name}'")
102
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  else:
104
  raise ValueError(f"Unknown strategy: {strategy}")
105
 
 
26
  - tiles_maxsim: Native MaxSim between query tokens and tile vectors (using="mean_pooling")
27
  - pooled_tile: Pooled query vs tile vectors (using="mean_pooling")
28
  - pooled_global: Pooled query vs global pooled doc vector (using="global_pooling")
29
+ - experimental_maxsim: Native MaxSim between query tokens and experimental vectors (using="experimental_pooling[_k]")
30
+ - pooled_experimental: Pooled query vs experimental vectors (using="experimental_pooling[_k]")
31
 
32
  Args:
33
  qdrant_client: Connected Qdrant client
 
45
  self,
46
  qdrant_client,
47
  collection_name: str,
48
+ experimental_vector_name: str = "experimental_pooling",
49
  request_timeout: int = 120,
50
  max_retries: int = 3,
51
  retry_sleep: float = 1.0,
52
  ):
53
  self.client = qdrant_client
54
  self.collection_name = collection_name
55
+ self.experimental_vector_name = str(experimental_vector_name)
56
  self.request_timeout = int(request_timeout)
57
  self.max_retries = max_retries
58
  self.retry_sleep = retry_sleep
 
104
  query_vector = query_pooled.tolist()
105
  logger.debug(f"πŸ” Global-pooled search on '{vector_name}'")
106
 
107
+ elif strategy == "experimental_maxsim":
108
+ # Native multi-vector MaxSim against experimental pooled vectors
109
+ vector_name = self.experimental_vector_name
110
+ query_vector = query_np.tolist()
111
+ logger.debug(f"🎯 Experimental MaxSim search on '{vector_name}'")
112
+
113
+ elif strategy == "pooled_experimental":
114
+ # Pooled query vs experimental pooled vectors
115
+ vector_name = self.experimental_vector_name
116
+ query_pooled = query_np.mean(axis=0)
117
+ query_vector = query_pooled.tolist()
118
+ logger.debug(f"πŸ” Experimental pooled search on '{vector_name}'")
119
+
120
  else:
121
  raise ValueError(f"Unknown strategy: {strategy}")
122