Spaces:
Paused
Paused
finalize release 1 changes
Browse files- README.md +2 -0
- benchmarks/vidore_beir_qdrant/run_qdrant_beir.py +185 -60
- benchmarks/vidore_tatdqa_test/run_qdrant.py +41 -5
- demo/qdrant_utils.py +2 -8
- examples/COMMANDS.md +65 -2
- scripts/qdrant_rebuild_collection_no_index.py +14 -0
- scripts/qdrant_recompute_colqwen_pooling_from_initial.py +19 -31
- visual_rag/cli/main.py +128 -33
- visual_rag/embedding/visual_embedder.py +10 -3
- visual_rag/indexing/pipeline.py +202 -152
- visual_rag/indexing/qdrant_indexer.py +5 -4
- visual_rag/qdrant_admin.py +1 -3
- visual_rag/retrieval/multi_vector.py +26 -2
- visual_rag/retrieval/single_stage.py +17 -0
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
|
| 559 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 560 |
else:
|
| 561 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 562 |
|
| 563 |
-
default_k = 5 if is_colqwen25 else 3
|
| 564 |
-
if kernel != "legacy":
|
| 565 |
default_k = 3
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 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 |
-
|
| 902 |
-
|
| 903 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 904 |
visual_embedding,
|
| 905 |
token_info,
|
| 906 |
target_vectors=tv,
|
| 907 |
mean_pool=tile_pooled,
|
| 908 |
-
window_size=
|
| 909 |
-
kernel=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 910 |
)
|
| 911 |
-
|
| 912 |
-
|
| 913 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 914 |
|
| 915 |
if is_colsmol and bool(colsmol_experimental_2d):
|
| 916 |
try:
|
| 917 |
-
from visual_rag.embedding.pooling import
|
|
|
|
|
|
|
| 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":
|
|
|
|
|
|
|
| 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 |
-
"
|
| 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
|
|
|
|
| 1236 |
),
|
| 1237 |
)
|
| 1238 |
parser.add_argument(
|
|
@@ -1303,7 +1364,15 @@ def main() -> None:
|
|
| 1303 |
"--mode",
|
| 1304 |
type=str,
|
| 1305 |
default="single_full",
|
| 1306 |
-
choices=[
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1307 |
)
|
| 1308 |
parser.add_argument(
|
| 1309 |
"--stage1-mode",
|
|
@@ -1335,10 +1404,22 @@ def main() -> None:
|
|
| 1335 |
type=int,
|
| 1336 |
default=None,
|
| 1337 |
help=(
|
| 1338 |
-
"
|
| 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=
|
| 1370 |
-
help="Continue evaluating remaining datasets if one dataset fails (default:
|
| 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 |
-
|
| 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)] =
|
|
|
|
|
|
|
| 1696 |
else:
|
| 1697 |
vec_flags = {}
|
| 1698 |
hnsw_on_disk = (
|
| 1699 |
-
((
|
| 1700 |
-
)
|
| 1701 |
on_disk_payload = (
|
| 1702 |
-
((
|
| 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 |
-
|
| 1724 |
-
args.
|
| 1725 |
-
|
| 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)
|
|
|
|
| 1746 |
and str(args.mode) in ("two_stage", "three_stage")
|
| 1747 |
):
|
| 1748 |
-
existing_vectors = _collection_vector_names(
|
|
|
|
|
|
|
| 1749 |
if exp_vector_name not in existing_vectors:
|
| 1750 |
-
candidates = sorted(
|
|
|
|
|
|
|
| 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":
|
| 1802 |
-
|
| 1803 |
-
|
| 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 |
-
"
|
| 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
|
| 822 |
-
and
|
| 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 |
-
|
| 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
|
| 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 |
-
-
|
| 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 |
-
|
| 47 |
)
|
| 48 |
|
| 49 |
|
|
@@ -159,8 +159,9 @@ def main() -> None:
|
|
| 159 |
nargs="+",
|
| 160 |
default=None,
|
| 161 |
help=(
|
| 162 |
-
"
|
| 163 |
-
"
|
|
|
|
| 164 |
),
|
| 165 |
)
|
| 166 |
args = ap.parse_args()
|
|
@@ -180,22 +181,12 @@ def main() -> None:
|
|
| 180 |
check_compatibility=False,
|
| 181 |
)
|
| 182 |
|
| 183 |
-
#
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 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 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 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 |
-
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
| 122 |
-
if
|
| 123 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
else:
|
| 125 |
-
|
| 126 |
-
default_k = 5 if is_colqwen25 else 3
|
| 127 |
-
if kernel != "legacy":
|
| 128 |
default_k = 3
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 313 |
|
| 314 |
-
if str(args.stage1_mode) in (
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
"
|
| 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
|
|
|
|
| 582 |
),
|
| 583 |
)
|
| 584 |
process_parser.add_argument(
|
|
@@ -637,7 +713,14 @@ Examples:
|
|
| 637 |
"--strategy",
|
| 638 |
type=str,
|
| 639 |
default="single_full",
|
| 640 |
-
choices=[
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 641 |
help="Search strategy",
|
| 642 |
)
|
| 643 |
search_parser.add_argument(
|
|
@@ -667,10 +750,22 @@ Examples:
|
|
| 667 |
type=int,
|
| 668 |
default=None,
|
| 669 |
help=(
|
| 670 |
-
"
|
| 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", []))
|
|
|
|
| 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)
|
|
|
|
|
|
|
| 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=(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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(
|
|
|
|
|
|
|
| 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(
|
| 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(
|
| 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 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 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(
|
|
|
|
|
|
|
| 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(
|
| 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(
|
| 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 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
visual_embedding,
|
| 431 |
token_info,
|
| 432 |
target_vectors=tv,
|
| 433 |
mean_pool=tile_pooled,
|
| 434 |
-
window_size=
|
| 435 |
-
kernel=
|
| 436 |
)
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 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
|
| 487 |
-
global_pooling =
|
| 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 =
|
| 502 |
-
|
|
|
|
|
|
|
| 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":
|
| 552 |
-
|
| 553 |
-
|
| 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(
|
| 564 |
-
|
|
|
|
| 565 |
# Extra metadata (year, district, etc.)
|
| 566 |
**extra_metadata,
|
| 567 |
}
|
| 568 |
-
|
| 569 |
result = {
|
| 570 |
"id": chunk_id,
|
| 571 |
-
"visual_embedding": embedding_for_initial,
|
| 572 |
"tile_pooled_embedding": embedding_for_pooling, # "mean_pooling" vector in Qdrant
|
| 573 |
-
"experimental_pooled_embedding": experimental_pooled_by_name, #
|
| 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,
|
| 585 |
-
"global_pooled": global_pooled,
|
| 586 |
# Pooling vectors (already in main result)
|
| 587 |
-
"visual_embedding": visual_embedding,
|
| 588 |
-
"tile_pooled": tile_pooled,
|
| 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(
|
| 441 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 199 |
return self._single_stage.search(
|
| 200 |
query_embedding=query_embedding,
|
| 201 |
top_k=top_k,
|
| 202 |
filter_obj=filter_obj,
|
| 203 |
-
strategy="
|
| 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 |
|