Spaces:
Running
Running
refactor pooling options
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- benchmarks/__init__.py +0 -1
- benchmarks/analyze_results.py +34 -51
- benchmarks/prepare_submission.py +34 -51
- benchmarks/quick_test.py +198 -172
- benchmarks/run_vidore.py +127 -127
- benchmarks/vidore_beir_qdrant/run_qdrant_beir.py +493 -95
- benchmarks/vidore_tatdqa_test/__init__.py +0 -5
- benchmarks/vidore_tatdqa_test/dataset_loader.py +23 -11
- benchmarks/vidore_tatdqa_test/metrics.py +0 -5
- benchmarks/vidore_tatdqa_test/run_qdrant.py +77 -25
- benchmarks/vidore_tatdqa_test/sweep_eval.py +40 -13
- demo/app.py +7 -8
- demo/commands.py +215 -185
- demo/config.py +5 -4
- demo/download_models.py +16 -7
- demo/evaluation.py +175 -122
- demo/indexing.py +6 -18
- demo/qdrant_utils.py +23 -9
- demo/test_qdrant_connection.py +23 -21
- demo/ui/__init__.py +2 -2
- demo/ui/benchmark.py +196 -119
- demo/ui/header.py +5 -2
- demo/ui/playground.py +73 -53
- demo/ui/sidebar.py +48 -28
- demo/ui/upload.py +140 -99
- demo_app.py +2068 -0
- examples/COMMANDS.md +95 -1
- examples/process_pdfs.py +45 -67
- examples/search_demo.py +33 -64
- scripts/colqwen25_probe.py +70 -0
- scripts/compare_eval_scopes.py +309 -0
- scripts/compare_models_sample_queries.py +290 -0
- scripts/create_qdrant_payload_indexes.py +103 -0
- scripts/debug_failed_docs.py +179 -0
- scripts/debug_vidore_qrels_alignment.py +189 -0
- scripts/dedupe_failure_logs.py +70 -0
- scripts/force_qdrant_reindex.py +119 -0
- scripts/inspect_qdrant_collection.py +94 -0
- scripts/qdrant_clone_collection_no_index.py +253 -0
- scripts/qdrant_debug_collection.py +257 -0
- scripts/qdrant_disable_hnsw.py +192 -0
- scripts/qdrant_modify_vectors_smoketest.py +29 -0
- scripts/qdrant_rebuild_collection_no_index.py +297 -0
- scripts/qdrant_recompute_colqwen_pooling_from_initial.py +312 -0
- scripts/query_token_stats.py +123 -0
- scripts/update_qdrant_indexing_threshold.py +171 -0
- tests/__init__.py +0 -7
- tests/test_config.py +23 -33
- tests/test_pdf_processor.py +40 -47
- tests/test_pooling.py +27 -28
benchmarks/__init__.py
CHANGED
|
@@ -8,4 +8,3 @@ work in Docker/Spaces environments.
|
|
| 8 |
"""
|
| 9 |
|
| 10 |
__all__ = []
|
| 11 |
-
|
|
|
|
| 8 |
"""
|
| 9 |
|
| 10 |
__all__ = []
|
|
|
benchmarks/analyze_results.py
CHANGED
|
@@ -5,7 +5,7 @@ Analyze and compare benchmark results.
|
|
| 5 |
Usage:
|
| 6 |
# Compare exhaustive vs two-stage
|
| 7 |
python analyze_results.py --results results/
|
| 8 |
-
|
| 9 |
# Compare multiple models
|
| 10 |
python analyze_results.py --dirs results_colsmol/ results_colpali/
|
| 11 |
"""
|
|
@@ -13,7 +13,7 @@ Usage:
|
|
| 13 |
import argparse
|
| 14 |
import json
|
| 15 |
from pathlib import Path
|
| 16 |
-
from typing import Dict
|
| 17 |
|
| 18 |
import numpy as np
|
| 19 |
|
|
@@ -24,12 +24,12 @@ def load_all_results(results_dir: Path) -> Dict[str, Dict]:
|
|
| 24 |
for f in results_dir.glob("*.json"):
|
| 25 |
with open(f) as fp:
|
| 26 |
data = json.load(fp)
|
| 27 |
-
|
| 28 |
# Key by dataset + method
|
| 29 |
dataset = data.get("dataset", f.stem).split("/")[-1]
|
| 30 |
method = "two_stage" if data.get("two_stage") else "exhaustive"
|
| 31 |
key = f"{dataset}_{method}"
|
| 32 |
-
|
| 33 |
results[key] = {
|
| 34 |
"dataset": dataset,
|
| 35 |
"method": method,
|
|
@@ -41,7 +41,7 @@ def load_all_results(results_dir: Path) -> Dict[str, Dict]:
|
|
| 41 |
|
| 42 |
def compare_methods(results: Dict[str, Dict]) -> None:
|
| 43 |
"""Compare exhaustive vs two-stage on same datasets."""
|
| 44 |
-
|
| 45 |
# Group by dataset
|
| 46 |
datasets = {}
|
| 47 |
for key, data in results.items():
|
|
@@ -49,45 +49,47 @@ def compare_methods(results: Dict[str, Dict]) -> None:
|
|
| 49 |
if ds not in datasets:
|
| 50 |
datasets[ds] = {}
|
| 51 |
datasets[ds][data["method"]] = data
|
| 52 |
-
|
| 53 |
print("\n" + "=" * 80)
|
| 54 |
print("EXHAUSTIVE vs TWO-STAGE COMPARISON")
|
| 55 |
print("=" * 80)
|
| 56 |
-
|
| 57 |
print(f"\n{'Dataset':<30} {'Method':<12} {'NDCG@10':>10} {'MRR@10':>10} {'Time(ms)':>10}")
|
| 58 |
print("-" * 72)
|
| 59 |
-
|
| 60 |
improvements = []
|
| 61 |
speedups = []
|
| 62 |
-
|
| 63 |
for dataset, methods in sorted(datasets.items()):
|
| 64 |
for method in ["exhaustive", "two_stage"]:
|
| 65 |
if method in methods:
|
| 66 |
m = methods[method]
|
| 67 |
time_ms = m.get("avg_search_time_ms", 0)
|
| 68 |
-
print(
|
| 69 |
-
|
|
|
|
|
|
|
| 70 |
# Calculate improvement
|
| 71 |
if "exhaustive" in methods and "two_stage" in methods:
|
| 72 |
ex = methods["exhaustive"]
|
| 73 |
ts = methods["two_stage"]
|
| 74 |
-
|
| 75 |
ndcg_diff = ts.get("ndcg@10", 0) - ex.get("ndcg@10", 0)
|
| 76 |
improvements.append(ndcg_diff)
|
| 77 |
-
|
| 78 |
ex_time = ex.get("avg_search_time_ms", 1)
|
| 79 |
ts_time = ts.get("avg_search_time_ms", 1)
|
| 80 |
if ts_time > 0:
|
| 81 |
speedups.append(ex_time / ts_time)
|
| 82 |
-
|
| 83 |
print()
|
| 84 |
-
|
| 85 |
# Summary
|
| 86 |
if improvements:
|
| 87 |
print("-" * 72)
|
| 88 |
print(f"Average NDCG@10 difference (two_stage - exhaustive): {np.mean(improvements):+.4f}")
|
| 89 |
print(f"Retention rate: {100 * (1 + np.mean(improvements)):.1f}%")
|
| 90 |
-
|
| 91 |
if speedups:
|
| 92 |
print(f"Average speedup: {np.mean(speedups):.1f}x")
|
| 93 |
|
|
@@ -106,7 +108,7 @@ def print_leaderboard(results: Dict[str, Dict]) -> None:
|
|
| 106 |
print("\n" + "=" * 80)
|
| 107 |
print("LEADERBOARD FORMAT")
|
| 108 |
print("=" * 80)
|
| 109 |
-
|
| 110 |
# Best result per dataset
|
| 111 |
best = {}
|
| 112 |
for key, data in results.items():
|
|
@@ -114,44 +116,32 @@ def print_leaderboard(results: Dict[str, Dict]) -> None:
|
|
| 114 |
ndcg = data.get("ndcg@10", 0)
|
| 115 |
if ds not in best or ndcg > best[ds].get("ndcg@10", 0):
|
| 116 |
best[ds] = data
|
| 117 |
-
|
| 118 |
# Compute average
|
| 119 |
ndcg_scores = [d.get("ndcg@10", 0) for d in best.values()]
|
| 120 |
avg = sum(ndcg_scores) / len(ndcg_scores) if ndcg_scores else 0
|
| 121 |
-
|
| 122 |
print(f"\nModel: {list(results.values())[0].get('model', 'unknown')}")
|
| 123 |
print(f"\n{'Dataset':<35} {'NDCG@10':>10}")
|
| 124 |
print("-" * 45)
|
| 125 |
-
|
| 126 |
for ds, data in sorted(best.items()):
|
| 127 |
method_tag = " (2-stage)" if data.get("method") == "two_stage" else ""
|
| 128 |
print(f"{ds + method_tag:<35} {data.get('ndcg@10', 0):>10.4f}")
|
| 129 |
-
|
| 130 |
print("-" * 45)
|
| 131 |
print(f"{'AVERAGE':<35} {avg:>10.4f}")
|
| 132 |
|
| 133 |
|
| 134 |
def main():
|
| 135 |
parser = argparse.ArgumentParser(description="Analyze benchmark results")
|
| 136 |
-
parser.add_argument(
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
)
|
| 140 |
-
|
| 141 |
-
"--dirs", nargs="+",
|
| 142 |
-
help="Multiple result directories to compare"
|
| 143 |
-
)
|
| 144 |
-
parser.add_argument(
|
| 145 |
-
"--compare", action="store_true",
|
| 146 |
-
help="Compare exhaustive vs two-stage"
|
| 147 |
-
)
|
| 148 |
-
parser.add_argument(
|
| 149 |
-
"--leaderboard", action="store_true",
|
| 150 |
-
help="Print in leaderboard format"
|
| 151 |
-
)
|
| 152 |
-
|
| 153 |
args = parser.parse_args()
|
| 154 |
-
|
| 155 |
if args.dirs:
|
| 156 |
# Compare multiple directories
|
| 157 |
all_results = {}
|
|
@@ -162,26 +152,19 @@ def main():
|
|
| 162 |
results = all_results
|
| 163 |
else:
|
| 164 |
results = load_all_results(Path(args.results))
|
| 165 |
-
|
| 166 |
if not results:
|
| 167 |
-
print(
|
| 168 |
return
|
| 169 |
-
|
| 170 |
print(f"📊 Loaded {len(results)} result files")
|
| 171 |
-
|
| 172 |
if args.compare or not args.leaderboard:
|
| 173 |
compare_methods(results)
|
| 174 |
-
|
| 175 |
if args.leaderboard or not args.compare:
|
| 176 |
print_leaderboard(results)
|
| 177 |
|
| 178 |
|
| 179 |
if __name__ == "__main__":
|
| 180 |
main()
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
|
|
|
| 5 |
Usage:
|
| 6 |
# Compare exhaustive vs two-stage
|
| 7 |
python analyze_results.py --results results/
|
| 8 |
+
|
| 9 |
# Compare multiple models
|
| 10 |
python analyze_results.py --dirs results_colsmol/ results_colpali/
|
| 11 |
"""
|
|
|
|
| 13 |
import argparse
|
| 14 |
import json
|
| 15 |
from pathlib import Path
|
| 16 |
+
from typing import Dict
|
| 17 |
|
| 18 |
import numpy as np
|
| 19 |
|
|
|
|
| 24 |
for f in results_dir.glob("*.json"):
|
| 25 |
with open(f) as fp:
|
| 26 |
data = json.load(fp)
|
| 27 |
+
|
| 28 |
# Key by dataset + method
|
| 29 |
dataset = data.get("dataset", f.stem).split("/")[-1]
|
| 30 |
method = "two_stage" if data.get("two_stage") else "exhaustive"
|
| 31 |
key = f"{dataset}_{method}"
|
| 32 |
+
|
| 33 |
results[key] = {
|
| 34 |
"dataset": dataset,
|
| 35 |
"method": method,
|
|
|
|
| 41 |
|
| 42 |
def compare_methods(results: Dict[str, Dict]) -> None:
|
| 43 |
"""Compare exhaustive vs two-stage on same datasets."""
|
| 44 |
+
|
| 45 |
# Group by dataset
|
| 46 |
datasets = {}
|
| 47 |
for key, data in results.items():
|
|
|
|
| 49 |
if ds not in datasets:
|
| 50 |
datasets[ds] = {}
|
| 51 |
datasets[ds][data["method"]] = data
|
| 52 |
+
|
| 53 |
print("\n" + "=" * 80)
|
| 54 |
print("EXHAUSTIVE vs TWO-STAGE COMPARISON")
|
| 55 |
print("=" * 80)
|
| 56 |
+
|
| 57 |
print(f"\n{'Dataset':<30} {'Method':<12} {'NDCG@10':>10} {'MRR@10':>10} {'Time(ms)':>10}")
|
| 58 |
print("-" * 72)
|
| 59 |
+
|
| 60 |
improvements = []
|
| 61 |
speedups = []
|
| 62 |
+
|
| 63 |
for dataset, methods in sorted(datasets.items()):
|
| 64 |
for method in ["exhaustive", "two_stage"]:
|
| 65 |
if method in methods:
|
| 66 |
m = methods[method]
|
| 67 |
time_ms = m.get("avg_search_time_ms", 0)
|
| 68 |
+
print(
|
| 69 |
+
f"{dataset:<30} {method:<12} {m.get('ndcg@10', 0):>10.4f} {m.get('mrr@10', 0):>10.4f} {time_ms:>10.2f}"
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
# Calculate improvement
|
| 73 |
if "exhaustive" in methods and "two_stage" in methods:
|
| 74 |
ex = methods["exhaustive"]
|
| 75 |
ts = methods["two_stage"]
|
| 76 |
+
|
| 77 |
ndcg_diff = ts.get("ndcg@10", 0) - ex.get("ndcg@10", 0)
|
| 78 |
improvements.append(ndcg_diff)
|
| 79 |
+
|
| 80 |
ex_time = ex.get("avg_search_time_ms", 1)
|
| 81 |
ts_time = ts.get("avg_search_time_ms", 1)
|
| 82 |
if ts_time > 0:
|
| 83 |
speedups.append(ex_time / ts_time)
|
| 84 |
+
|
| 85 |
print()
|
| 86 |
+
|
| 87 |
# Summary
|
| 88 |
if improvements:
|
| 89 |
print("-" * 72)
|
| 90 |
print(f"Average NDCG@10 difference (two_stage - exhaustive): {np.mean(improvements):+.4f}")
|
| 91 |
print(f"Retention rate: {100 * (1 + np.mean(improvements)):.1f}%")
|
| 92 |
+
|
| 93 |
if speedups:
|
| 94 |
print(f"Average speedup: {np.mean(speedups):.1f}x")
|
| 95 |
|
|
|
|
| 108 |
print("\n" + "=" * 80)
|
| 109 |
print("LEADERBOARD FORMAT")
|
| 110 |
print("=" * 80)
|
| 111 |
+
|
| 112 |
# Best result per dataset
|
| 113 |
best = {}
|
| 114 |
for key, data in results.items():
|
|
|
|
| 116 |
ndcg = data.get("ndcg@10", 0)
|
| 117 |
if ds not in best or ndcg > best[ds].get("ndcg@10", 0):
|
| 118 |
best[ds] = data
|
| 119 |
+
|
| 120 |
# Compute average
|
| 121 |
ndcg_scores = [d.get("ndcg@10", 0) for d in best.values()]
|
| 122 |
avg = sum(ndcg_scores) / len(ndcg_scores) if ndcg_scores else 0
|
| 123 |
+
|
| 124 |
print(f"\nModel: {list(results.values())[0].get('model', 'unknown')}")
|
| 125 |
print(f"\n{'Dataset':<35} {'NDCG@10':>10}")
|
| 126 |
print("-" * 45)
|
| 127 |
+
|
| 128 |
for ds, data in sorted(best.items()):
|
| 129 |
method_tag = " (2-stage)" if data.get("method") == "two_stage" else ""
|
| 130 |
print(f"{ds + method_tag:<35} {data.get('ndcg@10', 0):>10.4f}")
|
| 131 |
+
|
| 132 |
print("-" * 45)
|
| 133 |
print(f"{'AVERAGE':<35} {avg:>10.4f}")
|
| 134 |
|
| 135 |
|
| 136 |
def main():
|
| 137 |
parser = argparse.ArgumentParser(description="Analyze benchmark results")
|
| 138 |
+
parser.add_argument("--results", type=str, default="results", help="Results directory")
|
| 139 |
+
parser.add_argument("--dirs", nargs="+", help="Multiple result directories to compare")
|
| 140 |
+
parser.add_argument("--compare", action="store_true", help="Compare exhaustive vs two-stage")
|
| 141 |
+
parser.add_argument("--leaderboard", action="store_true", help="Print in leaderboard format")
|
| 142 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
args = parser.parse_args()
|
| 144 |
+
|
| 145 |
if args.dirs:
|
| 146 |
# Compare multiple directories
|
| 147 |
all_results = {}
|
|
|
|
| 152 |
results = all_results
|
| 153 |
else:
|
| 154 |
results = load_all_results(Path(args.results))
|
| 155 |
+
|
| 156 |
if not results:
|
| 157 |
+
print("❌ No results found")
|
| 158 |
return
|
| 159 |
+
|
| 160 |
print(f"📊 Loaded {len(results)} result files")
|
| 161 |
+
|
| 162 |
if args.compare or not args.leaderboard:
|
| 163 |
compare_methods(results)
|
| 164 |
+
|
| 165 |
if args.leaderboard or not args.compare:
|
| 166 |
print_leaderboard(results)
|
| 167 |
|
| 168 |
|
| 169 |
if __name__ == "__main__":
|
| 170 |
main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
benchmarks/prepare_submission.py
CHANGED
|
@@ -11,14 +11,14 @@ Usage:
|
|
| 11 |
|
| 12 |
import argparse
|
| 13 |
import json
|
| 14 |
-
from pathlib import Path
|
| 15 |
from datetime import datetime
|
| 16 |
-
from
|
|
|
|
| 17 |
|
| 18 |
# ViDoRe leaderboard expected datasets
|
| 19 |
VIDORE_DATASETS = {
|
| 20 |
"docvqa_test_subsampled": "DocVQA",
|
| 21 |
-
"infovqa_test_subsampled": "InfoVQA",
|
| 22 |
"tabfquad_test_subsampled": "TabFQuAD",
|
| 23 |
"tatdqa_test": "TAT-DQA",
|
| 24 |
"arxivqa_test_subsampled": "ArXivQA",
|
|
@@ -29,14 +29,14 @@ VIDORE_DATASETS = {
|
|
| 29 |
def load_results(results_dir: Path) -> Dict[str, Dict[str, float]]:
|
| 30 |
"""Load all result JSON files from directory."""
|
| 31 |
results = {}
|
| 32 |
-
|
| 33 |
for json_file in results_dir.glob("*.json"):
|
| 34 |
with open(json_file) as f:
|
| 35 |
data = json.load(f)
|
| 36 |
-
|
| 37 |
dataset = data.get("dataset", json_file.stem)
|
| 38 |
dataset_short = dataset.split("/")[-1].replace("_twostage", "")
|
| 39 |
-
|
| 40 |
results[dataset_short] = {
|
| 41 |
"ndcg@5": data["metrics"].get("ndcg@5", 0),
|
| 42 |
"ndcg@10": data["metrics"].get("ndcg@10", 0),
|
|
@@ -46,7 +46,7 @@ def load_results(results_dir: Path) -> Dict[str, Dict[str, float]]:
|
|
| 46 |
"two_stage": data.get("two_stage", False),
|
| 47 |
"model": data.get("model", "unknown"),
|
| 48 |
}
|
| 49 |
-
|
| 50 |
return results
|
| 51 |
|
| 52 |
|
|
@@ -57,11 +57,11 @@ def format_submission(
|
|
| 57 |
description: Optional[str] = None,
|
| 58 |
) -> Dict[str, Any]:
|
| 59 |
"""Format results for ViDoRe leaderboard submission."""
|
| 60 |
-
|
| 61 |
# Calculate average scores
|
| 62 |
ndcg10_scores = [r["ndcg@10"] for r in results.values()]
|
| 63 |
avg_ndcg10 = sum(ndcg10_scores) / len(ndcg10_scores) if ndcg10_scores else 0
|
| 64 |
-
|
| 65 |
submission = {
|
| 66 |
"model_name": model_name,
|
| 67 |
"model_url": model_url or "",
|
|
@@ -70,7 +70,7 @@ def format_submission(
|
|
| 70 |
"average_ndcg@10": avg_ndcg10,
|
| 71 |
"results": {},
|
| 72 |
}
|
| 73 |
-
|
| 74 |
# Add per-dataset results
|
| 75 |
for dataset_short, metrics in results.items():
|
| 76 |
display_name = VIDORE_DATASETS.get(dataset_short, dataset_short)
|
|
@@ -79,7 +79,7 @@ def format_submission(
|
|
| 79 |
"ndcg@10": metrics["ndcg@10"],
|
| 80 |
"mrr@10": metrics["mrr@10"],
|
| 81 |
}
|
| 82 |
-
|
| 83 |
return submission
|
| 84 |
|
| 85 |
|
|
@@ -88,14 +88,16 @@ def print_summary(results: Dict[str, Dict], submission: Dict[str, Any]):
|
|
| 88 |
print("\n" + "=" * 70)
|
| 89 |
print(f"MODEL: {submission['model_name']}")
|
| 90 |
print("=" * 70)
|
| 91 |
-
|
| 92 |
print(f"\n{'Dataset':<25} {'NDCG@5':>10} {'NDCG@10':>10} {'MRR@10':>10}")
|
| 93 |
print("-" * 55)
|
| 94 |
-
|
| 95 |
for dataset, metrics in results.items():
|
| 96 |
display = VIDORE_DATASETS.get(dataset, dataset)[:24]
|
| 97 |
-
print(
|
| 98 |
-
|
|
|
|
|
|
|
| 99 |
print("-" * 55)
|
| 100 |
print(f"{'AVERAGE':<25} {'':<10} {submission['average_ndcg@10']:>10.4f}")
|
| 101 |
print("=" * 70)
|
|
@@ -108,14 +110,14 @@ def upload_to_huggingface(submission: Dict[str, Any], repo_id: str = "vidore/res
|
|
| 108 |
except ImportError:
|
| 109 |
print("Install huggingface_hub: pip install huggingface_hub")
|
| 110 |
return False
|
| 111 |
-
|
| 112 |
api = HfApi()
|
| 113 |
-
|
| 114 |
# Save to temp file
|
| 115 |
temp_file = Path(f"/tmp/submission_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json")
|
| 116 |
with open(temp_file, "w") as f:
|
| 117 |
json.dump(submission, f, indent=2)
|
| 118 |
-
|
| 119 |
try:
|
| 120 |
api.upload_file(
|
| 121 |
path_or_fileobj=str(temp_file),
|
|
@@ -133,45 +135,33 @@ def upload_to_huggingface(submission: Dict[str, Any], repo_id: str = "vidore/res
|
|
| 133 |
def main():
|
| 134 |
parser = argparse.ArgumentParser(description="Prepare ViDoRe submission")
|
| 135 |
parser.add_argument(
|
| 136 |
-
"--results", type=str, default="results",
|
| 137 |
-
help="Directory with result JSON files"
|
| 138 |
-
)
|
| 139 |
-
parser.add_argument(
|
| 140 |
-
"--output", type=str, default="submission.json",
|
| 141 |
-
help="Output submission file"
|
| 142 |
)
|
| 143 |
parser.add_argument(
|
| 144 |
-
"--
|
| 145 |
-
help="Model name for leaderboard"
|
| 146 |
)
|
| 147 |
parser.add_argument(
|
| 148 |
-
"--model-
|
| 149 |
-
help="URL to model/paper"
|
| 150 |
)
|
| 151 |
-
parser.add_argument(
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
parser.add_argument(
|
| 156 |
-
"--upload", action="store_true",
|
| 157 |
-
help="Upload to HuggingFace"
|
| 158 |
-
)
|
| 159 |
-
|
| 160 |
args = parser.parse_args()
|
| 161 |
-
|
| 162 |
results_dir = Path(args.results)
|
| 163 |
if not results_dir.exists():
|
| 164 |
print(f"❌ Results directory not found: {results_dir}")
|
| 165 |
return
|
| 166 |
-
|
| 167 |
# Load results
|
| 168 |
results = load_results(results_dir)
|
| 169 |
if not results:
|
| 170 |
print(f"❌ No result files found in {results_dir}")
|
| 171 |
return
|
| 172 |
-
|
| 173 |
print(f"📊 Found {len(results)} dataset results")
|
| 174 |
-
|
| 175 |
# Format submission
|
| 176 |
submission = format_submission(
|
| 177 |
results,
|
|
@@ -179,16 +169,16 @@ def main():
|
|
| 179 |
model_url=args.model_url,
|
| 180 |
description=args.description,
|
| 181 |
)
|
| 182 |
-
|
| 183 |
# Print summary
|
| 184 |
print_summary(results, submission)
|
| 185 |
-
|
| 186 |
# Save
|
| 187 |
output_path = Path(args.output)
|
| 188 |
with open(output_path, "w") as f:
|
| 189 |
json.dump(submission, f, indent=2)
|
| 190 |
print(f"\n💾 Saved to: {output_path}")
|
| 191 |
-
|
| 192 |
# Upload if requested
|
| 193 |
if args.upload:
|
| 194 |
upload_to_huggingface(submission)
|
|
@@ -196,10 +186,3 @@ def main():
|
|
| 196 |
|
| 197 |
if __name__ == "__main__":
|
| 198 |
main()
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
|
|
|
| 11 |
|
| 12 |
import argparse
|
| 13 |
import json
|
|
|
|
| 14 |
from datetime import datetime
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
from typing import Any, Dict, Optional
|
| 17 |
|
| 18 |
# ViDoRe leaderboard expected datasets
|
| 19 |
VIDORE_DATASETS = {
|
| 20 |
"docvqa_test_subsampled": "DocVQA",
|
| 21 |
+
"infovqa_test_subsampled": "InfoVQA",
|
| 22 |
"tabfquad_test_subsampled": "TabFQuAD",
|
| 23 |
"tatdqa_test": "TAT-DQA",
|
| 24 |
"arxivqa_test_subsampled": "ArXivQA",
|
|
|
|
| 29 |
def load_results(results_dir: Path) -> Dict[str, Dict[str, float]]:
|
| 30 |
"""Load all result JSON files from directory."""
|
| 31 |
results = {}
|
| 32 |
+
|
| 33 |
for json_file in results_dir.glob("*.json"):
|
| 34 |
with open(json_file) as f:
|
| 35 |
data = json.load(f)
|
| 36 |
+
|
| 37 |
dataset = data.get("dataset", json_file.stem)
|
| 38 |
dataset_short = dataset.split("/")[-1].replace("_twostage", "")
|
| 39 |
+
|
| 40 |
results[dataset_short] = {
|
| 41 |
"ndcg@5": data["metrics"].get("ndcg@5", 0),
|
| 42 |
"ndcg@10": data["metrics"].get("ndcg@10", 0),
|
|
|
|
| 46 |
"two_stage": data.get("two_stage", False),
|
| 47 |
"model": data.get("model", "unknown"),
|
| 48 |
}
|
| 49 |
+
|
| 50 |
return results
|
| 51 |
|
| 52 |
|
|
|
|
| 57 |
description: Optional[str] = None,
|
| 58 |
) -> Dict[str, Any]:
|
| 59 |
"""Format results for ViDoRe leaderboard submission."""
|
| 60 |
+
|
| 61 |
# Calculate average scores
|
| 62 |
ndcg10_scores = [r["ndcg@10"] for r in results.values()]
|
| 63 |
avg_ndcg10 = sum(ndcg10_scores) / len(ndcg10_scores) if ndcg10_scores else 0
|
| 64 |
+
|
| 65 |
submission = {
|
| 66 |
"model_name": model_name,
|
| 67 |
"model_url": model_url or "",
|
|
|
|
| 70 |
"average_ndcg@10": avg_ndcg10,
|
| 71 |
"results": {},
|
| 72 |
}
|
| 73 |
+
|
| 74 |
# Add per-dataset results
|
| 75 |
for dataset_short, metrics in results.items():
|
| 76 |
display_name = VIDORE_DATASETS.get(dataset_short, dataset_short)
|
|
|
|
| 79 |
"ndcg@10": metrics["ndcg@10"],
|
| 80 |
"mrr@10": metrics["mrr@10"],
|
| 81 |
}
|
| 82 |
+
|
| 83 |
return submission
|
| 84 |
|
| 85 |
|
|
|
|
| 88 |
print("\n" + "=" * 70)
|
| 89 |
print(f"MODEL: {submission['model_name']}")
|
| 90 |
print("=" * 70)
|
| 91 |
+
|
| 92 |
print(f"\n{'Dataset':<25} {'NDCG@5':>10} {'NDCG@10':>10} {'MRR@10':>10}")
|
| 93 |
print("-" * 55)
|
| 94 |
+
|
| 95 |
for dataset, metrics in results.items():
|
| 96 |
display = VIDORE_DATASETS.get(dataset, dataset)[:24]
|
| 97 |
+
print(
|
| 98 |
+
f"{display:<25} {metrics['ndcg@5']:>10.4f} {metrics['ndcg@10']:>10.4f} {metrics['mrr@10']:>10.4f}"
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
print("-" * 55)
|
| 102 |
print(f"{'AVERAGE':<25} {'':<10} {submission['average_ndcg@10']:>10.4f}")
|
| 103 |
print("=" * 70)
|
|
|
|
| 110 |
except ImportError:
|
| 111 |
print("Install huggingface_hub: pip install huggingface_hub")
|
| 112 |
return False
|
| 113 |
+
|
| 114 |
api = HfApi()
|
| 115 |
+
|
| 116 |
# Save to temp file
|
| 117 |
temp_file = Path(f"/tmp/submission_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json")
|
| 118 |
with open(temp_file, "w") as f:
|
| 119 |
json.dump(submission, f, indent=2)
|
| 120 |
+
|
| 121 |
try:
|
| 122 |
api.upload_file(
|
| 123 |
path_or_fileobj=str(temp_file),
|
|
|
|
| 135 |
def main():
|
| 136 |
parser = argparse.ArgumentParser(description="Prepare ViDoRe submission")
|
| 137 |
parser.add_argument(
|
| 138 |
+
"--results", type=str, default="results", help="Directory with result JSON files"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
)
|
| 140 |
parser.add_argument(
|
| 141 |
+
"--output", type=str, default="submission.json", help="Output submission file"
|
|
|
|
| 142 |
)
|
| 143 |
parser.add_argument(
|
| 144 |
+
"--model-name", type=str, default="visual-rag-toolkit", help="Model name for leaderboard"
|
|
|
|
| 145 |
)
|
| 146 |
+
parser.add_argument("--model-url", type=str, help="URL to model/paper")
|
| 147 |
+
parser.add_argument("--description", type=str, help="Model description")
|
| 148 |
+
parser.add_argument("--upload", action="store_true", help="Upload to HuggingFace")
|
| 149 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
args = parser.parse_args()
|
| 151 |
+
|
| 152 |
results_dir = Path(args.results)
|
| 153 |
if not results_dir.exists():
|
| 154 |
print(f"❌ Results directory not found: {results_dir}")
|
| 155 |
return
|
| 156 |
+
|
| 157 |
# Load results
|
| 158 |
results = load_results(results_dir)
|
| 159 |
if not results:
|
| 160 |
print(f"❌ No result files found in {results_dir}")
|
| 161 |
return
|
| 162 |
+
|
| 163 |
print(f"📊 Found {len(results)} dataset results")
|
| 164 |
+
|
| 165 |
# Format submission
|
| 166 |
submission = format_submission(
|
| 167 |
results,
|
|
|
|
| 169 |
model_url=args.model_url,
|
| 170 |
description=args.description,
|
| 171 |
)
|
| 172 |
+
|
| 173 |
# Print summary
|
| 174 |
print_summary(results, submission)
|
| 175 |
+
|
| 176 |
# Save
|
| 177 |
output_path = Path(args.output)
|
| 178 |
with open(output_path, "w") as f:
|
| 179 |
json.dump(submission, f, indent=2)
|
| 180 |
print(f"\n💾 Saved to: {output_path}")
|
| 181 |
+
|
| 182 |
# Upload if requested
|
| 183 |
if args.upload:
|
| 184 |
upload_to_huggingface(submission)
|
|
|
|
| 186 |
|
| 187 |
if __name__ == "__main__":
|
| 188 |
main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
benchmarks/quick_test.py
CHANGED
|
@@ -14,12 +14,12 @@ Usage:
|
|
| 14 |
python quick_test.py --samples 500 --skip-exhaustive # Faster
|
| 15 |
"""
|
| 16 |
|
| 17 |
-
import sys
|
| 18 |
-
import time
|
| 19 |
import argparse
|
| 20 |
import logging
|
|
|
|
|
|
|
| 21 |
from pathlib import Path
|
| 22 |
-
from typing import
|
| 23 |
|
| 24 |
# Add parent directory to Python path (so we can import visual_rag)
|
| 25 |
# This allows running the script directly without pip install
|
|
@@ -28,57 +28,60 @@ _parent_dir = _script_dir.parent
|
|
| 28 |
if str(_parent_dir) not in sys.path:
|
| 29 |
sys.path.insert(0, str(_parent_dir))
|
| 30 |
|
| 31 |
-
import numpy as np
|
| 32 |
-
from tqdm import tqdm
|
| 33 |
|
| 34 |
# Visual RAG imports (now works without pip install)
|
| 35 |
-
from visual_rag.embedding import VisualEmbedder
|
| 36 |
-
from visual_rag.embedding.pooling import (
|
| 37 |
-
tile_level_mean_pooling,
|
| 38 |
compute_maxsim_score,
|
|
|
|
| 39 |
)
|
| 40 |
|
| 41 |
# Optional: datasets for ViDoRe
|
| 42 |
try:
|
| 43 |
from datasets import load_dataset as hf_load_dataset
|
|
|
|
| 44 |
HAS_DATASETS = True
|
| 45 |
except ImportError:
|
| 46 |
HAS_DATASETS = False
|
| 47 |
|
| 48 |
-
logging.basicConfig(level=logging.INFO, format=
|
| 49 |
logger = logging.getLogger(__name__)
|
| 50 |
|
| 51 |
|
| 52 |
def load_vidore_sample(num_samples: int = 100) -> List[Dict]:
|
| 53 |
"""
|
| 54 |
Load sample from ViDoRe DocVQA with ground truth.
|
| 55 |
-
|
| 56 |
Each sample has a query and its relevant document (1:1 mapping).
|
| 57 |
This allows computing retrieval metrics.
|
| 58 |
"""
|
| 59 |
if not HAS_DATASETS:
|
| 60 |
logger.error("Install datasets: pip install datasets")
|
| 61 |
sys.exit(1)
|
| 62 |
-
|
| 63 |
logger.info(f"📥 Loading {num_samples} samples from ViDoRe DocVQA...")
|
| 64 |
-
|
| 65 |
ds = hf_load_dataset("vidore/docvqa_test_subsampled", split="test")
|
| 66 |
-
|
| 67 |
samples = []
|
| 68 |
for i, example in enumerate(ds):
|
| 69 |
if i >= num_samples:
|
| 70 |
break
|
| 71 |
-
|
| 72 |
-
samples.append(
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
|
|
|
|
|
|
| 82 |
logger.info(f"✅ Loaded {len(samples)} samples with ground truth")
|
| 83 |
return samples
|
| 84 |
|
|
@@ -90,60 +93,58 @@ def embed_all(
|
|
| 90 |
"""Embed all documents and queries."""
|
| 91 |
logger.info(f"\n🤖 Loading model: {model_name}")
|
| 92 |
embedder = VisualEmbedder(model_name=model_name)
|
| 93 |
-
|
| 94 |
images = [s["image"] for s in samples]
|
| 95 |
queries = [s["query"] for s in samples if s["query"]]
|
| 96 |
-
|
| 97 |
# Embed images
|
| 98 |
logger.info(f"🎨 Embedding {len(images)} documents...")
|
| 99 |
start_time = time.time()
|
| 100 |
-
|
| 101 |
-
embeddings, token_infos = embedder.embed_images(
|
| 102 |
-
|
| 103 |
-
)
|
| 104 |
-
|
| 105 |
doc_embed_time = time.time() - start_time
|
| 106 |
logger.info(f" Time: {doc_embed_time:.2f}s ({doc_embed_time/len(images)*1000:.1f}ms/doc)")
|
| 107 |
-
|
| 108 |
# Process embeddings: extract visual tokens + tile-level pooling
|
| 109 |
doc_data = {}
|
| 110 |
for i, (emb, token_info) in enumerate(zip(embeddings, token_infos)):
|
| 111 |
-
if hasattr(emb,
|
| 112 |
emb = emb.cpu()
|
| 113 |
-
emb_np = emb.numpy() if hasattr(emb,
|
| 114 |
-
|
| 115 |
# Extract visual tokens only (filter special tokens)
|
| 116 |
visual_indices = token_info["visual_token_indices"]
|
| 117 |
visual_emb = emb_np[visual_indices].astype(np.float32)
|
| 118 |
-
|
| 119 |
# Tile-level pooling
|
| 120 |
n_rows = token_info.get("n_rows", 4)
|
| 121 |
n_cols = token_info.get("n_cols", 3)
|
| 122 |
num_tiles = n_rows * n_cols + 1 if n_rows and n_cols else 13
|
| 123 |
-
|
| 124 |
tile_pooled = tile_level_mean_pooling(visual_emb, num_tiles, patches_per_tile=64)
|
| 125 |
-
|
| 126 |
doc_data[f"doc_{i}"] = {
|
| 127 |
"embedding": visual_emb,
|
| 128 |
"pooled": tile_pooled,
|
| 129 |
"num_visual_tokens": len(visual_indices),
|
| 130 |
"num_tiles": tile_pooled.shape[0],
|
| 131 |
}
|
| 132 |
-
|
| 133 |
# Embed queries
|
| 134 |
logger.info(f"🔍 Embedding {len(queries)} queries...")
|
| 135 |
start_time = time.time()
|
| 136 |
-
|
| 137 |
query_data = {}
|
| 138 |
for i, query in enumerate(tqdm(queries, desc="Queries")):
|
| 139 |
q_emb = embedder.embed_query(query)
|
| 140 |
-
if hasattr(q_emb,
|
| 141 |
q_emb = q_emb.cpu()
|
| 142 |
-
q_np = q_emb.numpy() if hasattr(q_emb,
|
| 143 |
query_data[f"q_{i}"] = q_np.astype(np.float32)
|
| 144 |
-
|
| 145 |
query_embed_time = time.time() - start_time
|
| 146 |
-
|
| 147 |
return {
|
| 148 |
"docs": doc_data,
|
| 149 |
"queries": query_data,
|
|
@@ -160,7 +161,7 @@ def search_exhaustive(query_emb: np.ndarray, docs: Dict, top_k: int = 10) -> Lis
|
|
| 160 |
for doc_id, doc in docs.items():
|
| 161 |
score = compute_maxsim_score(query_emb, doc["embedding"])
|
| 162 |
scores.append({"id": doc_id, "score": score})
|
| 163 |
-
|
| 164 |
scores.sort(key=lambda x: x["score"], reverse=True)
|
| 165 |
return scores[:top_k]
|
| 166 |
|
|
@@ -173,14 +174,14 @@ def search_two_stage(
|
|
| 173 |
) -> List[Dict]:
|
| 174 |
"""
|
| 175 |
Two-stage retrieval with tile-level pooling.
|
| 176 |
-
|
| 177 |
Stage 1: Fast prefetch using tile-pooled vectors
|
| 178 |
Stage 2: Exact MaxSim reranking on candidates
|
| 179 |
"""
|
| 180 |
# Stage 1: Tile-level pooled search
|
| 181 |
query_pooled = query_emb.mean(axis=0)
|
| 182 |
query_pooled = query_pooled / (np.linalg.norm(query_pooled) + 1e-8)
|
| 183 |
-
|
| 184 |
stage1_scores = []
|
| 185 |
for doc_id, doc in docs.items():
|
| 186 |
doc_pooled = doc["pooled"]
|
|
@@ -188,17 +189,19 @@ def search_two_stage(
|
|
| 188 |
tile_sims = np.dot(doc_norm, query_pooled)
|
| 189 |
score = float(tile_sims.max())
|
| 190 |
stage1_scores.append({"id": doc_id, "score": score})
|
| 191 |
-
|
| 192 |
stage1_scores.sort(key=lambda x: x["score"], reverse=True)
|
| 193 |
candidates = stage1_scores[:prefetch_k]
|
| 194 |
-
|
| 195 |
# Stage 2: Exact MaxSim on candidates
|
| 196 |
reranked = []
|
| 197 |
for cand in candidates:
|
| 198 |
doc_id = cand["id"]
|
| 199 |
score = compute_maxsim_score(query_emb, docs[doc_id]["embedding"])
|
| 200 |
-
reranked.append(
|
| 201 |
-
|
|
|
|
|
|
|
| 202 |
reranked.sort(key=lambda x: x["score"], reverse=True)
|
| 203 |
return reranked[:top_k]
|
| 204 |
|
|
@@ -210,54 +213,54 @@ def compute_metrics(
|
|
| 210 |
) -> Dict[str, float]:
|
| 211 |
"""
|
| 212 |
Compute retrieval metrics.
|
| 213 |
-
|
| 214 |
Since ViDoRe has 1:1 query-doc mapping (1 relevant doc per query):
|
| 215 |
- Recall@K (Hit Rate): Is the relevant doc in top-K? (0 or 1)
|
| 216 |
-
- Precision@K: (# relevant in top-K) / K
|
| 217 |
- MRR@K: 1/rank if found in top-K, else 0
|
| 218 |
- NDCG@K: DCG / IDCG with binary relevance
|
| 219 |
"""
|
| 220 |
metrics = {}
|
| 221 |
-
|
| 222 |
# Also track per-query ranks for analysis
|
| 223 |
all_ranks = []
|
| 224 |
-
|
| 225 |
for k in k_values:
|
| 226 |
recalls = []
|
| 227 |
precisions = []
|
| 228 |
mrrs = []
|
| 229 |
ndcgs = []
|
| 230 |
-
|
| 231 |
for sample in samples:
|
| 232 |
query_id = sample["query_id"]
|
| 233 |
relevant_doc = sample["relevant_doc"]
|
| 234 |
-
|
| 235 |
if query_id not in results:
|
| 236 |
continue
|
| 237 |
-
|
| 238 |
ranking = results[query_id][:k]
|
| 239 |
ranked_ids = [r["id"] for r in ranking]
|
| 240 |
-
|
| 241 |
# Find rank of relevant doc (1-indexed, 0 if not found)
|
| 242 |
rank = 0
|
| 243 |
for i, doc_id in enumerate(ranked_ids):
|
| 244 |
if doc_id == relevant_doc:
|
| 245 |
rank = i + 1
|
| 246 |
break
|
| 247 |
-
|
| 248 |
# Recall@K (Hit Rate): 1 if found in top-K
|
| 249 |
found = 1.0 if rank > 0 else 0.0
|
| 250 |
recalls.append(found)
|
| 251 |
-
|
| 252 |
# Precision@K: (# relevant found) / K
|
| 253 |
# With 1 relevant doc: 1/K if found, 0 otherwise
|
| 254 |
precision = found / k
|
| 255 |
precisions.append(precision)
|
| 256 |
-
|
| 257 |
# MRR@K: 1/rank if found
|
| 258 |
mrr = 1.0 / rank if rank > 0 else 0.0
|
| 259 |
mrrs.append(mrr)
|
| 260 |
-
|
| 261 |
# NDCG@K (binary relevance)
|
| 262 |
# DCG = 1/log2(rank+1) if found, 0 otherwise
|
| 263 |
# IDCG = 1/log2(2) = 1 (best case: relevant at rank 1)
|
|
@@ -265,7 +268,7 @@ def compute_metrics(
|
|
| 265 |
idcg = 1.0
|
| 266 |
ndcg = dcg / idcg
|
| 267 |
ndcgs.append(ndcg)
|
| 268 |
-
|
| 269 |
# Track actual rank for analysis (only for k=10)
|
| 270 |
if k == max(k_values):
|
| 271 |
full_ranking = results[query_id]
|
|
@@ -275,19 +278,19 @@ def compute_metrics(
|
|
| 275 |
full_rank = i + 1
|
| 276 |
break
|
| 277 |
all_ranks.append(full_rank)
|
| 278 |
-
|
| 279 |
metrics[f"Recall@{k}"] = np.mean(recalls)
|
| 280 |
metrics[f"P@{k}"] = np.mean(precisions)
|
| 281 |
metrics[f"MRR@{k}"] = np.mean(mrrs)
|
| 282 |
metrics[f"NDCG@{k}"] = np.mean(ndcgs)
|
| 283 |
-
|
| 284 |
# Add summary stats
|
| 285 |
if all_ranks:
|
| 286 |
found_ranks = [r for r in all_ranks if r > 0]
|
| 287 |
-
metrics["avg_rank"] = np.mean(found_ranks) if found_ranks else float(
|
| 288 |
-
metrics["median_rank"] = np.median(found_ranks) if found_ranks else float(
|
| 289 |
metrics["not_found"] = sum(1 for r in all_ranks if r == 0)
|
| 290 |
-
|
| 291 |
return metrics
|
| 292 |
|
| 293 |
|
|
@@ -302,67 +305,71 @@ def run_benchmark(
|
|
| 302 |
queries = data["queries"]
|
| 303 |
samples = data["samples"]
|
| 304 |
num_docs = len(docs)
|
| 305 |
-
|
| 306 |
# Auto-set prefetch_k to be meaningful (default: 20, or 20% of docs if >100 docs)
|
| 307 |
if prefetch_k is None:
|
| 308 |
if num_docs <= 100:
|
| 309 |
prefetch_k = 20 # Default: prefetch 20, rerank to top-10
|
| 310 |
else:
|
| 311 |
prefetch_k = max(20, min(100, int(num_docs * 0.2))) # 20% for larger collections
|
| 312 |
-
|
| 313 |
# Ensure prefetch_k < num_docs for meaningful two-stage comparison
|
| 314 |
if prefetch_k >= num_docs:
|
| 315 |
logger.warning(f"⚠️ prefetch_k={prefetch_k} >= num_docs={num_docs}")
|
| 316 |
-
logger.warning(
|
| 317 |
logger.warning(f" Use --samples > {prefetch_k * 3} for meaningful comparison")
|
| 318 |
-
|
| 319 |
logger.info(f"📊 Benchmark config: {num_docs} docs, prefetch_k={prefetch_k}, top_k={top_k}")
|
| 320 |
logger.info(f" (Both methods return top-{top_k} results - realistic retrieval scenario)")
|
| 321 |
-
|
| 322 |
results = {}
|
| 323 |
-
|
| 324 |
# Two-stage retrieval (NOVEL)
|
| 325 |
-
logger.info(
|
|
|
|
|
|
|
| 326 |
two_stage_results = {}
|
| 327 |
two_stage_times = []
|
| 328 |
-
|
| 329 |
for sample in tqdm(samples, desc="Two-Stage"):
|
| 330 |
query_id = sample["query_id"]
|
| 331 |
query_emb = queries[query_id]
|
| 332 |
-
|
| 333 |
start = time.time()
|
| 334 |
ranking = search_two_stage(query_emb, docs, prefetch_k=prefetch_k, top_k=top_k)
|
| 335 |
two_stage_times.append(time.time() - start)
|
| 336 |
-
|
| 337 |
two_stage_results[query_id] = ranking
|
| 338 |
-
|
| 339 |
two_stage_metrics = compute_metrics(two_stage_results, samples)
|
| 340 |
two_stage_metrics["avg_time_ms"] = np.mean(two_stage_times) * 1000
|
| 341 |
two_stage_metrics["prefetch_k"] = prefetch_k
|
| 342 |
two_stage_metrics["top_k"] = top_k
|
| 343 |
results["two_stage"] = two_stage_metrics
|
| 344 |
-
|
| 345 |
# Exhaustive search (baseline)
|
| 346 |
if not skip_exhaustive:
|
| 347 |
-
logger.info(
|
|
|
|
|
|
|
| 348 |
exhaustive_results = {}
|
| 349 |
exhaustive_times = []
|
| 350 |
-
|
| 351 |
for sample in tqdm(samples, desc="Exhaustive"):
|
| 352 |
query_id = sample["query_id"]
|
| 353 |
query_emb = queries[query_id]
|
| 354 |
-
|
| 355 |
start = time.time()
|
| 356 |
ranking = search_exhaustive(query_emb, docs, top_k=top_k)
|
| 357 |
exhaustive_times.append(time.time() - start)
|
| 358 |
-
|
| 359 |
exhaustive_results[query_id] = ranking
|
| 360 |
-
|
| 361 |
exhaustive_metrics = compute_metrics(exhaustive_results, samples)
|
| 362 |
exhaustive_metrics["avg_time_ms"] = np.mean(exhaustive_times) * 1000
|
| 363 |
exhaustive_metrics["top_k"] = top_k
|
| 364 |
results["exhaustive"] = exhaustive_metrics
|
| 365 |
-
|
| 366 |
return results
|
| 367 |
|
| 368 |
|
|
@@ -371,88 +378,98 @@ def print_results(data: Dict, benchmark_results: Dict, show_precision: bool = Fa
|
|
| 371 |
print("\n" + "=" * 80)
|
| 372 |
print("📊 BENCHMARK RESULTS")
|
| 373 |
print("=" * 80)
|
| 374 |
-
|
| 375 |
-
num_docs = len(data[
|
| 376 |
print(f"\n🤖 Model: {data['model']}")
|
| 377 |
print(f"📄 Documents: {num_docs}")
|
| 378 |
print(f"🔍 Queries: {len(data['queries'])}")
|
| 379 |
-
|
| 380 |
# Embedding stats
|
| 381 |
-
sample_doc = list(data[
|
| 382 |
-
print(
|
| 383 |
print(f" Visual tokens per doc: {sample_doc['num_visual_tokens']}")
|
| 384 |
print(f" Tile-pooled vectors: {sample_doc['num_tiles']}")
|
| 385 |
-
|
| 386 |
if "two_stage" in benchmark_results:
|
| 387 |
prefetch_k = benchmark_results["two_stage"].get("prefetch_k", "?")
|
| 388 |
print(f" Two-stage prefetch_k: {prefetch_k} (of {num_docs} docs)")
|
| 389 |
-
|
| 390 |
# Method labels - clearer naming
|
| 391 |
def get_label(method):
|
| 392 |
if method == "two_stage":
|
| 393 |
return "Pooled+Rerank" # Tile-pooled prefetch + MaxSim rerank
|
| 394 |
else:
|
| 395 |
-
return "Full MaxSim"
|
| 396 |
-
|
| 397 |
# Recall / Hit Rate table
|
| 398 |
-
print(
|
| 399 |
print(f" {'Method':<20} {'@1':>8} {'@3':>8} {'@5':>8} {'@7':>8} {'@10':>8}")
|
| 400 |
print(f" {'-'*60}")
|
| 401 |
-
|
| 402 |
for method, metrics in benchmark_results.items():
|
| 403 |
-
print(
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
|
|
|
|
|
|
| 410 |
# Precision table (optional)
|
| 411 |
if show_precision:
|
| 412 |
-
print(
|
| 413 |
print(f" {'Method':<20} {'@1':>8} {'@3':>8} {'@5':>8} {'@7':>8} {'@10':>8}")
|
| 414 |
print(f" {'-'*60}")
|
| 415 |
-
|
| 416 |
for method, metrics in benchmark_results.items():
|
| 417 |
-
print(
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
|
|
|
|
|
|
| 424 |
# NDCG table
|
| 425 |
-
print(
|
| 426 |
print(f" {'Method':<20} {'@1':>8} {'@3':>8} {'@5':>8} {'@7':>8} {'@10':>8}")
|
| 427 |
print(f" {'-'*60}")
|
| 428 |
-
|
| 429 |
for method, metrics in benchmark_results.items():
|
| 430 |
-
print(
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
|
|
|
|
|
|
| 437 |
# MRR table
|
| 438 |
-
print(
|
| 439 |
print(f" {'Method':<20} {'@1':>8} {'@3':>8} {'@5':>8} {'@7':>8} {'@10':>8}")
|
| 440 |
print(f" {'-'*60}")
|
| 441 |
-
|
| 442 |
for method, metrics in benchmark_results.items():
|
| 443 |
-
print(
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
|
|
|
|
|
|
| 450 |
# Speed comparison
|
| 451 |
-
top_k = benchmark_results.get("two_stage", benchmark_results.get("exhaustive", {})).get(
|
|
|
|
|
|
|
| 452 |
print(f"\n⏱️ SPEED (both return top-{top_k} results):")
|
| 453 |
print(f" {'Method':<20} {'Time (ms)':>12} {'Docs searched':>15}")
|
| 454 |
print(f" {'-'*50}")
|
| 455 |
-
|
| 456 |
for method, metrics in benchmark_results.items():
|
| 457 |
if method == "two_stage":
|
| 458 |
searched = metrics.get("prefetch_k", "?")
|
|
@@ -461,45 +478,53 @@ def print_results(data: Dict, benchmark_results: Dict, show_precision: bool = Fa
|
|
| 461 |
searched = num_docs
|
| 462 |
label = f"{searched} (all)"
|
| 463 |
print(f" {get_label(method):<20} {metrics.get('avg_time_ms', 0):>12.2f} {label:>15}")
|
| 464 |
-
|
| 465 |
# Comparison summary
|
| 466 |
if "exhaustive" in benchmark_results and "two_stage" in benchmark_results:
|
| 467 |
ex = benchmark_results["exhaustive"]
|
| 468 |
ts = benchmark_results["two_stage"]
|
| 469 |
-
|
| 470 |
-
print(
|
| 471 |
-
|
| 472 |
for k in [1, 5, 10]:
|
| 473 |
ex_recall = ex.get(f"Recall@{k}", 0)
|
| 474 |
ts_recall = ts.get(f"Recall@{k}", 0)
|
| 475 |
if ex_recall > 0:
|
| 476 |
retention = ts_recall / ex_recall * 100
|
| 477 |
-
print(
|
| 478 |
-
|
|
|
|
|
|
|
| 479 |
speedup = ex["avg_time_ms"] / ts["avg_time_ms"] if ts["avg_time_ms"] > 0 else 0
|
| 480 |
print(f" • Speedup: {speedup:.1f}x")
|
| 481 |
-
|
| 482 |
# Rank stats with explanation
|
| 483 |
if "avg_rank" in ts:
|
| 484 |
prefetch_k = ts.get("prefetch_k", "?")
|
| 485 |
top_k = ts.get("top_k", 10)
|
| 486 |
not_found = ts.get("not_found", 0)
|
| 487 |
total = len(data["queries"])
|
| 488 |
-
|
| 489 |
-
print(
|
| 490 |
-
print(
|
| 491 |
print(f" • Searches top-{prefetch_k} candidates using tile-pooled vectors")
|
| 492 |
-
print(
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
print(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 496 |
print(f" • Returns final top-{top_k} results")
|
| 497 |
-
if ts[
|
| 498 |
print(f" • Avg rank of relevant doc (when found): {ts['avg_rank']:.1f}")
|
| 499 |
print(f" • Median rank: {ts['median_rank']:.1f}")
|
| 500 |
print(f"\n 💡 The {not_found/total*100:.1f}% miss rate is for stage-1 prefetch.")
|
| 501 |
-
print(
|
| 502 |
-
|
|
|
|
|
|
|
| 503 |
print("\n" + "=" * 80)
|
| 504 |
print("✅ Benchmark complete!")
|
| 505 |
|
|
@@ -509,47 +534,48 @@ def main():
|
|
| 509 |
description="Quick benchmark for visual-rag-toolkit",
|
| 510 |
formatter_class=argparse.RawDescriptionHelpFormatter,
|
| 511 |
)
|
|
|
|
| 512 |
parser.add_argument(
|
| 513 |
-
"--
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
"--model", type=str, default="vidore/colSmol-500M",
|
| 518 |
-
help="Model: vidore/colSmol-500M (default), vidore/colpali-v1.3"
|
| 519 |
)
|
| 520 |
parser.add_argument(
|
| 521 |
-
"--prefetch-k",
|
| 522 |
-
|
|
|
|
|
|
|
| 523 |
)
|
| 524 |
parser.add_argument(
|
| 525 |
-
"--skip-exhaustive", action="store_true",
|
| 526 |
-
help="Skip exhaustive baseline (faster)"
|
| 527 |
)
|
| 528 |
parser.add_argument(
|
| 529 |
-
"--show-precision", action="store_true",
|
| 530 |
-
help="Show Precision@K metrics (hidden by default)"
|
| 531 |
)
|
| 532 |
parser.add_argument(
|
| 533 |
-
"--top-k",
|
| 534 |
-
|
|
|
|
|
|
|
| 535 |
)
|
| 536 |
-
|
| 537 |
args = parser.parse_args()
|
| 538 |
-
|
| 539 |
print("\n" + "=" * 70)
|
| 540 |
print("🧪 VISUAL RAG TOOLKIT - RETRIEVAL BENCHMARK")
|
| 541 |
print("=" * 70)
|
| 542 |
-
|
| 543 |
# Load samples
|
| 544 |
samples = load_vidore_sample(args.samples)
|
| 545 |
-
|
| 546 |
if not samples:
|
| 547 |
logger.error("No samples loaded!")
|
| 548 |
sys.exit(1)
|
| 549 |
-
|
| 550 |
# Embed all
|
| 551 |
data = embed_all(samples, args.model)
|
| 552 |
-
|
| 553 |
# Run benchmark
|
| 554 |
benchmark_results = run_benchmark(
|
| 555 |
data,
|
|
@@ -557,7 +583,7 @@ def main():
|
|
| 557 |
prefetch_k=args.prefetch_k,
|
| 558 |
top_k=args.top_k,
|
| 559 |
)
|
| 560 |
-
|
| 561 |
# Print results
|
| 562 |
print_results(data, benchmark_results, show_precision=args.show_precision)
|
| 563 |
|
|
|
|
| 14 |
python quick_test.py --samples 500 --skip-exhaustive # Faster
|
| 15 |
"""
|
| 16 |
|
|
|
|
|
|
|
| 17 |
import argparse
|
| 18 |
import logging
|
| 19 |
+
import sys
|
| 20 |
+
import time
|
| 21 |
from pathlib import Path
|
| 22 |
+
from typing import Any, Dict, List
|
| 23 |
|
| 24 |
# Add parent directory to Python path (so we can import visual_rag)
|
| 25 |
# This allows running the script directly without pip install
|
|
|
|
| 28 |
if str(_parent_dir) not in sys.path:
|
| 29 |
sys.path.insert(0, str(_parent_dir))
|
| 30 |
|
| 31 |
+
import numpy as np # noqa: E402
|
| 32 |
+
from tqdm import tqdm # noqa: E402
|
| 33 |
|
| 34 |
# Visual RAG imports (now works without pip install)
|
| 35 |
+
from visual_rag.embedding import VisualEmbedder # noqa: E402
|
| 36 |
+
from visual_rag.embedding.pooling import ( # noqa: E402
|
|
|
|
| 37 |
compute_maxsim_score,
|
| 38 |
+
tile_level_mean_pooling,
|
| 39 |
)
|
| 40 |
|
| 41 |
# Optional: datasets for ViDoRe
|
| 42 |
try:
|
| 43 |
from datasets import load_dataset as hf_load_dataset
|
| 44 |
+
|
| 45 |
HAS_DATASETS = True
|
| 46 |
except ImportError:
|
| 47 |
HAS_DATASETS = False
|
| 48 |
|
| 49 |
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
|
| 50 |
logger = logging.getLogger(__name__)
|
| 51 |
|
| 52 |
|
| 53 |
def load_vidore_sample(num_samples: int = 100) -> List[Dict]:
|
| 54 |
"""
|
| 55 |
Load sample from ViDoRe DocVQA with ground truth.
|
| 56 |
+
|
| 57 |
Each sample has a query and its relevant document (1:1 mapping).
|
| 58 |
This allows computing retrieval metrics.
|
| 59 |
"""
|
| 60 |
if not HAS_DATASETS:
|
| 61 |
logger.error("Install datasets: pip install datasets")
|
| 62 |
sys.exit(1)
|
| 63 |
+
|
| 64 |
logger.info(f"📥 Loading {num_samples} samples from ViDoRe DocVQA...")
|
| 65 |
+
|
| 66 |
ds = hf_load_dataset("vidore/docvqa_test_subsampled", split="test")
|
| 67 |
+
|
| 68 |
samples = []
|
| 69 |
for i, example in enumerate(ds):
|
| 70 |
if i >= num_samples:
|
| 71 |
break
|
| 72 |
+
|
| 73 |
+
samples.append(
|
| 74 |
+
{
|
| 75 |
+
"id": i,
|
| 76 |
+
"doc_id": f"doc_{i}",
|
| 77 |
+
"query_id": f"q_{i}",
|
| 78 |
+
"image": example.get("image", example.get("page_image")),
|
| 79 |
+
"query": example.get("query", example.get("question", "")),
|
| 80 |
+
# Ground truth: query i is relevant to doc i
|
| 81 |
+
"relevant_doc": f"doc_{i}",
|
| 82 |
+
}
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
logger.info(f"✅ Loaded {len(samples)} samples with ground truth")
|
| 86 |
return samples
|
| 87 |
|
|
|
|
| 93 |
"""Embed all documents and queries."""
|
| 94 |
logger.info(f"\n🤖 Loading model: {model_name}")
|
| 95 |
embedder = VisualEmbedder(model_name=model_name)
|
| 96 |
+
|
| 97 |
images = [s["image"] for s in samples]
|
| 98 |
queries = [s["query"] for s in samples if s["query"]]
|
| 99 |
+
|
| 100 |
# Embed images
|
| 101 |
logger.info(f"🎨 Embedding {len(images)} documents...")
|
| 102 |
start_time = time.time()
|
| 103 |
+
|
| 104 |
+
embeddings, token_infos = embedder.embed_images(images, batch_size=4, return_token_info=True)
|
| 105 |
+
|
|
|
|
|
|
|
| 106 |
doc_embed_time = time.time() - start_time
|
| 107 |
logger.info(f" Time: {doc_embed_time:.2f}s ({doc_embed_time/len(images)*1000:.1f}ms/doc)")
|
| 108 |
+
|
| 109 |
# Process embeddings: extract visual tokens + tile-level pooling
|
| 110 |
doc_data = {}
|
| 111 |
for i, (emb, token_info) in enumerate(zip(embeddings, token_infos)):
|
| 112 |
+
if hasattr(emb, "cpu"):
|
| 113 |
emb = emb.cpu()
|
| 114 |
+
emb_np = emb.numpy() if hasattr(emb, "numpy") else np.array(emb)
|
| 115 |
+
|
| 116 |
# Extract visual tokens only (filter special tokens)
|
| 117 |
visual_indices = token_info["visual_token_indices"]
|
| 118 |
visual_emb = emb_np[visual_indices].astype(np.float32)
|
| 119 |
+
|
| 120 |
# Tile-level pooling
|
| 121 |
n_rows = token_info.get("n_rows", 4)
|
| 122 |
n_cols = token_info.get("n_cols", 3)
|
| 123 |
num_tiles = n_rows * n_cols + 1 if n_rows and n_cols else 13
|
| 124 |
+
|
| 125 |
tile_pooled = tile_level_mean_pooling(visual_emb, num_tiles, patches_per_tile=64)
|
| 126 |
+
|
| 127 |
doc_data[f"doc_{i}"] = {
|
| 128 |
"embedding": visual_emb,
|
| 129 |
"pooled": tile_pooled,
|
| 130 |
"num_visual_tokens": len(visual_indices),
|
| 131 |
"num_tiles": tile_pooled.shape[0],
|
| 132 |
}
|
| 133 |
+
|
| 134 |
# Embed queries
|
| 135 |
logger.info(f"🔍 Embedding {len(queries)} queries...")
|
| 136 |
start_time = time.time()
|
| 137 |
+
|
| 138 |
query_data = {}
|
| 139 |
for i, query in enumerate(tqdm(queries, desc="Queries")):
|
| 140 |
q_emb = embedder.embed_query(query)
|
| 141 |
+
if hasattr(q_emb, "cpu"):
|
| 142 |
q_emb = q_emb.cpu()
|
| 143 |
+
q_np = q_emb.numpy() if hasattr(q_emb, "numpy") else np.array(q_emb)
|
| 144 |
query_data[f"q_{i}"] = q_np.astype(np.float32)
|
| 145 |
+
|
| 146 |
query_embed_time = time.time() - start_time
|
| 147 |
+
|
| 148 |
return {
|
| 149 |
"docs": doc_data,
|
| 150 |
"queries": query_data,
|
|
|
|
| 161 |
for doc_id, doc in docs.items():
|
| 162 |
score = compute_maxsim_score(query_emb, doc["embedding"])
|
| 163 |
scores.append({"id": doc_id, "score": score})
|
| 164 |
+
|
| 165 |
scores.sort(key=lambda x: x["score"], reverse=True)
|
| 166 |
return scores[:top_k]
|
| 167 |
|
|
|
|
| 174 |
) -> List[Dict]:
|
| 175 |
"""
|
| 176 |
Two-stage retrieval with tile-level pooling.
|
| 177 |
+
|
| 178 |
Stage 1: Fast prefetch using tile-pooled vectors
|
| 179 |
Stage 2: Exact MaxSim reranking on candidates
|
| 180 |
"""
|
| 181 |
# Stage 1: Tile-level pooled search
|
| 182 |
query_pooled = query_emb.mean(axis=0)
|
| 183 |
query_pooled = query_pooled / (np.linalg.norm(query_pooled) + 1e-8)
|
| 184 |
+
|
| 185 |
stage1_scores = []
|
| 186 |
for doc_id, doc in docs.items():
|
| 187 |
doc_pooled = doc["pooled"]
|
|
|
|
| 189 |
tile_sims = np.dot(doc_norm, query_pooled)
|
| 190 |
score = float(tile_sims.max())
|
| 191 |
stage1_scores.append({"id": doc_id, "score": score})
|
| 192 |
+
|
| 193 |
stage1_scores.sort(key=lambda x: x["score"], reverse=True)
|
| 194 |
candidates = stage1_scores[:prefetch_k]
|
| 195 |
+
|
| 196 |
# Stage 2: Exact MaxSim on candidates
|
| 197 |
reranked = []
|
| 198 |
for cand in candidates:
|
| 199 |
doc_id = cand["id"]
|
| 200 |
score = compute_maxsim_score(query_emb, docs[doc_id]["embedding"])
|
| 201 |
+
reranked.append(
|
| 202 |
+
{"id": doc_id, "score": score, "stage1_rank": stage1_scores.index(cand) + 1}
|
| 203 |
+
)
|
| 204 |
+
|
| 205 |
reranked.sort(key=lambda x: x["score"], reverse=True)
|
| 206 |
return reranked[:top_k]
|
| 207 |
|
|
|
|
| 213 |
) -> Dict[str, float]:
|
| 214 |
"""
|
| 215 |
Compute retrieval metrics.
|
| 216 |
+
|
| 217 |
Since ViDoRe has 1:1 query-doc mapping (1 relevant doc per query):
|
| 218 |
- Recall@K (Hit Rate): Is the relevant doc in top-K? (0 or 1)
|
| 219 |
+
- Precision@K: (# relevant in top-K) / K
|
| 220 |
- MRR@K: 1/rank if found in top-K, else 0
|
| 221 |
- NDCG@K: DCG / IDCG with binary relevance
|
| 222 |
"""
|
| 223 |
metrics = {}
|
| 224 |
+
|
| 225 |
# Also track per-query ranks for analysis
|
| 226 |
all_ranks = []
|
| 227 |
+
|
| 228 |
for k in k_values:
|
| 229 |
recalls = []
|
| 230 |
precisions = []
|
| 231 |
mrrs = []
|
| 232 |
ndcgs = []
|
| 233 |
+
|
| 234 |
for sample in samples:
|
| 235 |
query_id = sample["query_id"]
|
| 236 |
relevant_doc = sample["relevant_doc"]
|
| 237 |
+
|
| 238 |
if query_id not in results:
|
| 239 |
continue
|
| 240 |
+
|
| 241 |
ranking = results[query_id][:k]
|
| 242 |
ranked_ids = [r["id"] for r in ranking]
|
| 243 |
+
|
| 244 |
# Find rank of relevant doc (1-indexed, 0 if not found)
|
| 245 |
rank = 0
|
| 246 |
for i, doc_id in enumerate(ranked_ids):
|
| 247 |
if doc_id == relevant_doc:
|
| 248 |
rank = i + 1
|
| 249 |
break
|
| 250 |
+
|
| 251 |
# Recall@K (Hit Rate): 1 if found in top-K
|
| 252 |
found = 1.0 if rank > 0 else 0.0
|
| 253 |
recalls.append(found)
|
| 254 |
+
|
| 255 |
# Precision@K: (# relevant found) / K
|
| 256 |
# With 1 relevant doc: 1/K if found, 0 otherwise
|
| 257 |
precision = found / k
|
| 258 |
precisions.append(precision)
|
| 259 |
+
|
| 260 |
# MRR@K: 1/rank if found
|
| 261 |
mrr = 1.0 / rank if rank > 0 else 0.0
|
| 262 |
mrrs.append(mrr)
|
| 263 |
+
|
| 264 |
# NDCG@K (binary relevance)
|
| 265 |
# DCG = 1/log2(rank+1) if found, 0 otherwise
|
| 266 |
# IDCG = 1/log2(2) = 1 (best case: relevant at rank 1)
|
|
|
|
| 268 |
idcg = 1.0
|
| 269 |
ndcg = dcg / idcg
|
| 270 |
ndcgs.append(ndcg)
|
| 271 |
+
|
| 272 |
# Track actual rank for analysis (only for k=10)
|
| 273 |
if k == max(k_values):
|
| 274 |
full_ranking = results[query_id]
|
|
|
|
| 278 |
full_rank = i + 1
|
| 279 |
break
|
| 280 |
all_ranks.append(full_rank)
|
| 281 |
+
|
| 282 |
metrics[f"Recall@{k}"] = np.mean(recalls)
|
| 283 |
metrics[f"P@{k}"] = np.mean(precisions)
|
| 284 |
metrics[f"MRR@{k}"] = np.mean(mrrs)
|
| 285 |
metrics[f"NDCG@{k}"] = np.mean(ndcgs)
|
| 286 |
+
|
| 287 |
# Add summary stats
|
| 288 |
if all_ranks:
|
| 289 |
found_ranks = [r for r in all_ranks if r > 0]
|
| 290 |
+
metrics["avg_rank"] = np.mean(found_ranks) if found_ranks else float("inf")
|
| 291 |
+
metrics["median_rank"] = np.median(found_ranks) if found_ranks else float("inf")
|
| 292 |
metrics["not_found"] = sum(1 for r in all_ranks if r == 0)
|
| 293 |
+
|
| 294 |
return metrics
|
| 295 |
|
| 296 |
|
|
|
|
| 305 |
queries = data["queries"]
|
| 306 |
samples = data["samples"]
|
| 307 |
num_docs = len(docs)
|
| 308 |
+
|
| 309 |
# Auto-set prefetch_k to be meaningful (default: 20, or 20% of docs if >100 docs)
|
| 310 |
if prefetch_k is None:
|
| 311 |
if num_docs <= 100:
|
| 312 |
prefetch_k = 20 # Default: prefetch 20, rerank to top-10
|
| 313 |
else:
|
| 314 |
prefetch_k = max(20, min(100, int(num_docs * 0.2))) # 20% for larger collections
|
| 315 |
+
|
| 316 |
# Ensure prefetch_k < num_docs for meaningful two-stage comparison
|
| 317 |
if prefetch_k >= num_docs:
|
| 318 |
logger.warning(f"⚠️ prefetch_k={prefetch_k} >= num_docs={num_docs}")
|
| 319 |
+
logger.warning(" Two-stage will fetch ALL docs (same as exhaustive)")
|
| 320 |
logger.warning(f" Use --samples > {prefetch_k * 3} for meaningful comparison")
|
| 321 |
+
|
| 322 |
logger.info(f"📊 Benchmark config: {num_docs} docs, prefetch_k={prefetch_k}, top_k={top_k}")
|
| 323 |
logger.info(f" (Both methods return top-{top_k} results - realistic retrieval scenario)")
|
| 324 |
+
|
| 325 |
results = {}
|
| 326 |
+
|
| 327 |
# Two-stage retrieval (NOVEL)
|
| 328 |
+
logger.info(
|
| 329 |
+
f"\n🔬 Running Two-Stage retrieval (prefetch top-{prefetch_k}, rerank to top-{top_k})..."
|
| 330 |
+
)
|
| 331 |
two_stage_results = {}
|
| 332 |
two_stage_times = []
|
| 333 |
+
|
| 334 |
for sample in tqdm(samples, desc="Two-Stage"):
|
| 335 |
query_id = sample["query_id"]
|
| 336 |
query_emb = queries[query_id]
|
| 337 |
+
|
| 338 |
start = time.time()
|
| 339 |
ranking = search_two_stage(query_emb, docs, prefetch_k=prefetch_k, top_k=top_k)
|
| 340 |
two_stage_times.append(time.time() - start)
|
| 341 |
+
|
| 342 |
two_stage_results[query_id] = ranking
|
| 343 |
+
|
| 344 |
two_stage_metrics = compute_metrics(two_stage_results, samples)
|
| 345 |
two_stage_metrics["avg_time_ms"] = np.mean(two_stage_times) * 1000
|
| 346 |
two_stage_metrics["prefetch_k"] = prefetch_k
|
| 347 |
two_stage_metrics["top_k"] = top_k
|
| 348 |
results["two_stage"] = two_stage_metrics
|
| 349 |
+
|
| 350 |
# Exhaustive search (baseline)
|
| 351 |
if not skip_exhaustive:
|
| 352 |
+
logger.info(
|
| 353 |
+
f"🔬 Running Exhaustive MaxSim (searches ALL {num_docs} docs, returns top-{top_k})..."
|
| 354 |
+
)
|
| 355 |
exhaustive_results = {}
|
| 356 |
exhaustive_times = []
|
| 357 |
+
|
| 358 |
for sample in tqdm(samples, desc="Exhaustive"):
|
| 359 |
query_id = sample["query_id"]
|
| 360 |
query_emb = queries[query_id]
|
| 361 |
+
|
| 362 |
start = time.time()
|
| 363 |
ranking = search_exhaustive(query_emb, docs, top_k=top_k)
|
| 364 |
exhaustive_times.append(time.time() - start)
|
| 365 |
+
|
| 366 |
exhaustive_results[query_id] = ranking
|
| 367 |
+
|
| 368 |
exhaustive_metrics = compute_metrics(exhaustive_results, samples)
|
| 369 |
exhaustive_metrics["avg_time_ms"] = np.mean(exhaustive_times) * 1000
|
| 370 |
exhaustive_metrics["top_k"] = top_k
|
| 371 |
results["exhaustive"] = exhaustive_metrics
|
| 372 |
+
|
| 373 |
return results
|
| 374 |
|
| 375 |
|
|
|
|
| 378 |
print("\n" + "=" * 80)
|
| 379 |
print("📊 BENCHMARK RESULTS")
|
| 380 |
print("=" * 80)
|
| 381 |
+
|
| 382 |
+
num_docs = len(data["docs"])
|
| 383 |
print(f"\n🤖 Model: {data['model']}")
|
| 384 |
print(f"📄 Documents: {num_docs}")
|
| 385 |
print(f"🔍 Queries: {len(data['queries'])}")
|
| 386 |
+
|
| 387 |
# Embedding stats
|
| 388 |
+
sample_doc = list(data["docs"].values())[0]
|
| 389 |
+
print("\n📏 Embedding (after visual token filtering):")
|
| 390 |
print(f" Visual tokens per doc: {sample_doc['num_visual_tokens']}")
|
| 391 |
print(f" Tile-pooled vectors: {sample_doc['num_tiles']}")
|
| 392 |
+
|
| 393 |
if "two_stage" in benchmark_results:
|
| 394 |
prefetch_k = benchmark_results["two_stage"].get("prefetch_k", "?")
|
| 395 |
print(f" Two-stage prefetch_k: {prefetch_k} (of {num_docs} docs)")
|
| 396 |
+
|
| 397 |
# Method labels - clearer naming
|
| 398 |
def get_label(method):
|
| 399 |
if method == "two_stage":
|
| 400 |
return "Pooled+Rerank" # Tile-pooled prefetch + MaxSim rerank
|
| 401 |
else:
|
| 402 |
+
return "Full MaxSim" # Exhaustive MaxSim on all docs
|
| 403 |
+
|
| 404 |
# Recall / Hit Rate table
|
| 405 |
+
print("\n🎯 RECALL (Hit Rate) @ K:")
|
| 406 |
print(f" {'Method':<20} {'@1':>8} {'@3':>8} {'@5':>8} {'@7':>8} {'@10':>8}")
|
| 407 |
print(f" {'-'*60}")
|
| 408 |
+
|
| 409 |
for method, metrics in benchmark_results.items():
|
| 410 |
+
print(
|
| 411 |
+
f" {get_label(method):<20} "
|
| 412 |
+
f"{metrics.get('Recall@1', 0):>8.3f} "
|
| 413 |
+
f"{metrics.get('Recall@3', 0):>8.3f} "
|
| 414 |
+
f"{metrics.get('Recall@5', 0):>8.3f} "
|
| 415 |
+
f"{metrics.get('Recall@7', 0):>8.3f} "
|
| 416 |
+
f"{metrics.get('Recall@10', 0):>8.3f}"
|
| 417 |
+
)
|
| 418 |
+
|
| 419 |
# Precision table (optional)
|
| 420 |
if show_precision:
|
| 421 |
+
print("\n📐 PRECISION @ K:")
|
| 422 |
print(f" {'Method':<20} {'@1':>8} {'@3':>8} {'@5':>8} {'@7':>8} {'@10':>8}")
|
| 423 |
print(f" {'-'*60}")
|
| 424 |
+
|
| 425 |
for method, metrics in benchmark_results.items():
|
| 426 |
+
print(
|
| 427 |
+
f" {get_label(method):<20} "
|
| 428 |
+
f"{metrics.get('P@1', 0):>8.3f} "
|
| 429 |
+
f"{metrics.get('P@3', 0):>8.3f} "
|
| 430 |
+
f"{metrics.get('P@5', 0):>8.3f} "
|
| 431 |
+
f"{metrics.get('P@7', 0):>8.3f} "
|
| 432 |
+
f"{metrics.get('P@10', 0):>8.3f}"
|
| 433 |
+
)
|
| 434 |
+
|
| 435 |
# NDCG table
|
| 436 |
+
print("\n📈 NDCG @ K:")
|
| 437 |
print(f" {'Method':<20} {'@1':>8} {'@3':>8} {'@5':>8} {'@7':>8} {'@10':>8}")
|
| 438 |
print(f" {'-'*60}")
|
| 439 |
+
|
| 440 |
for method, metrics in benchmark_results.items():
|
| 441 |
+
print(
|
| 442 |
+
f" {get_label(method):<20} "
|
| 443 |
+
f"{metrics.get('NDCG@1', 0):>8.3f} "
|
| 444 |
+
f"{metrics.get('NDCG@3', 0):>8.3f} "
|
| 445 |
+
f"{metrics.get('NDCG@5', 0):>8.3f} "
|
| 446 |
+
f"{metrics.get('NDCG@7', 0):>8.3f} "
|
| 447 |
+
f"{metrics.get('NDCG@10', 0):>8.3f}"
|
| 448 |
+
)
|
| 449 |
+
|
| 450 |
# MRR table
|
| 451 |
+
print("\n🔍 MRR @ K:")
|
| 452 |
print(f" {'Method':<20} {'@1':>8} {'@3':>8} {'@5':>8} {'@7':>8} {'@10':>8}")
|
| 453 |
print(f" {'-'*60}")
|
| 454 |
+
|
| 455 |
for method, metrics in benchmark_results.items():
|
| 456 |
+
print(
|
| 457 |
+
f" {get_label(method):<20} "
|
| 458 |
+
f"{metrics.get('MRR@1', 0):>8.3f} "
|
| 459 |
+
f"{metrics.get('MRR@3', 0):>8.3f} "
|
| 460 |
+
f"{metrics.get('MRR@5', 0):>8.3f} "
|
| 461 |
+
f"{metrics.get('MRR@7', 0):>8.3f} "
|
| 462 |
+
f"{metrics.get('MRR@10', 0):>8.3f}"
|
| 463 |
+
)
|
| 464 |
+
|
| 465 |
# Speed comparison
|
| 466 |
+
top_k = benchmark_results.get("two_stage", benchmark_results.get("exhaustive", {})).get(
|
| 467 |
+
"top_k", 10
|
| 468 |
+
)
|
| 469 |
print(f"\n⏱️ SPEED (both return top-{top_k} results):")
|
| 470 |
print(f" {'Method':<20} {'Time (ms)':>12} {'Docs searched':>15}")
|
| 471 |
print(f" {'-'*50}")
|
| 472 |
+
|
| 473 |
for method, metrics in benchmark_results.items():
|
| 474 |
if method == "two_stage":
|
| 475 |
searched = metrics.get("prefetch_k", "?")
|
|
|
|
| 478 |
searched = num_docs
|
| 479 |
label = f"{searched} (all)"
|
| 480 |
print(f" {get_label(method):<20} {metrics.get('avg_time_ms', 0):>12.2f} {label:>15}")
|
| 481 |
+
|
| 482 |
# Comparison summary
|
| 483 |
if "exhaustive" in benchmark_results and "two_stage" in benchmark_results:
|
| 484 |
ex = benchmark_results["exhaustive"]
|
| 485 |
ts = benchmark_results["two_stage"]
|
| 486 |
+
|
| 487 |
+
print("\n💡 POOLED+RERANK vs FULL MAXSIM:")
|
| 488 |
+
|
| 489 |
for k in [1, 5, 10]:
|
| 490 |
ex_recall = ex.get(f"Recall@{k}", 0)
|
| 491 |
ts_recall = ts.get(f"Recall@{k}", 0)
|
| 492 |
if ex_recall > 0:
|
| 493 |
retention = ts_recall / ex_recall * 100
|
| 494 |
+
print(
|
| 495 |
+
f" • Recall@{k} retention: {retention:.1f}% ({ts_recall:.3f} vs {ex_recall:.3f})"
|
| 496 |
+
)
|
| 497 |
+
|
| 498 |
speedup = ex["avg_time_ms"] / ts["avg_time_ms"] if ts["avg_time_ms"] > 0 else 0
|
| 499 |
print(f" • Speedup: {speedup:.1f}x")
|
| 500 |
+
|
| 501 |
# Rank stats with explanation
|
| 502 |
if "avg_rank" in ts:
|
| 503 |
prefetch_k = ts.get("prefetch_k", "?")
|
| 504 |
top_k = ts.get("top_k", 10)
|
| 505 |
not_found = ts.get("not_found", 0)
|
| 506 |
total = len(data["queries"])
|
| 507 |
+
|
| 508 |
+
print("\n📊 POOLED+RERANK STATISTICS:")
|
| 509 |
+
print(" Stage-1 (pooled prefetch):")
|
| 510 |
print(f" • Searches top-{prefetch_k} candidates using tile-pooled vectors")
|
| 511 |
+
print(
|
| 512 |
+
f" • {total - not_found}/{total} queries ({100 - not_found/total*100:.1f}%) had relevant doc in prefetch"
|
| 513 |
+
)
|
| 514 |
+
print(
|
| 515 |
+
f" • {not_found}/{total} queries ({not_found/total*100:.1f}%) missed (relevant doc ranked >{prefetch_k})"
|
| 516 |
+
)
|
| 517 |
+
print(" Stage-2 (MaxSim reranking):")
|
| 518 |
+
print(" • Reranks prefetch candidates with exact MaxSim")
|
| 519 |
print(f" • Returns final top-{top_k} results")
|
| 520 |
+
if ts["avg_rank"] < float("inf"):
|
| 521 |
print(f" • Avg rank of relevant doc (when found): {ts['avg_rank']:.1f}")
|
| 522 |
print(f" • Median rank: {ts['median_rank']:.1f}")
|
| 523 |
print(f"\n 💡 The {not_found/total*100:.1f}% miss rate is for stage-1 prefetch.")
|
| 524 |
+
print(
|
| 525 |
+
f" Final Recall@{top_k} shows how many relevant docs ARE in top-{top_k} results."
|
| 526 |
+
)
|
| 527 |
+
|
| 528 |
print("\n" + "=" * 80)
|
| 529 |
print("✅ Benchmark complete!")
|
| 530 |
|
|
|
|
| 534 |
description="Quick benchmark for visual-rag-toolkit",
|
| 535 |
formatter_class=argparse.RawDescriptionHelpFormatter,
|
| 536 |
)
|
| 537 |
+
parser.add_argument("--samples", type=int, default=100, help="Number of samples (default: 100)")
|
| 538 |
parser.add_argument(
|
| 539 |
+
"--model",
|
| 540 |
+
type=str,
|
| 541 |
+
default="vidore/colSmol-500M",
|
| 542 |
+
help="Model: vidore/colSmol-500M (default), vidore/colpali-v1.3",
|
|
|
|
|
|
|
| 543 |
)
|
| 544 |
parser.add_argument(
|
| 545 |
+
"--prefetch-k",
|
| 546 |
+
type=int,
|
| 547 |
+
default=None,
|
| 548 |
+
help="Stage 1 candidates for two-stage (default: 20 for <=100 docs, auto for larger)",
|
| 549 |
)
|
| 550 |
parser.add_argument(
|
| 551 |
+
"--skip-exhaustive", action="store_true", help="Skip exhaustive baseline (faster)"
|
|
|
|
| 552 |
)
|
| 553 |
parser.add_argument(
|
| 554 |
+
"--show-precision", action="store_true", help="Show Precision@K metrics (hidden by default)"
|
|
|
|
| 555 |
)
|
| 556 |
parser.add_argument(
|
| 557 |
+
"--top-k",
|
| 558 |
+
type=int,
|
| 559 |
+
default=10,
|
| 560 |
+
help="Number of results to return (default: 10, realistic retrieval scenario)",
|
| 561 |
)
|
| 562 |
+
|
| 563 |
args = parser.parse_args()
|
| 564 |
+
|
| 565 |
print("\n" + "=" * 70)
|
| 566 |
print("🧪 VISUAL RAG TOOLKIT - RETRIEVAL BENCHMARK")
|
| 567 |
print("=" * 70)
|
| 568 |
+
|
| 569 |
# Load samples
|
| 570 |
samples = load_vidore_sample(args.samples)
|
| 571 |
+
|
| 572 |
if not samples:
|
| 573 |
logger.error("No samples loaded!")
|
| 574 |
sys.exit(1)
|
| 575 |
+
|
| 576 |
# Embed all
|
| 577 |
data = embed_all(samples, args.model)
|
| 578 |
+
|
| 579 |
# Run benchmark
|
| 580 |
benchmark_results = run_benchmark(
|
| 581 |
data,
|
|
|
|
| 583 |
prefetch_k=args.prefetch_k,
|
| 584 |
top_k=args.top_k,
|
| 585 |
)
|
| 586 |
+
|
| 587 |
# Print results
|
| 588 |
print_results(data, benchmark_results, show_precision=args.show_precision)
|
| 589 |
|
benchmarks/run_vidore.py
CHANGED
|
@@ -17,10 +17,10 @@ Usage:
|
|
| 17 |
|
| 18 |
import argparse
|
| 19 |
import json
|
| 20 |
-
import time
|
| 21 |
import logging
|
|
|
|
| 22 |
from pathlib import Path
|
| 23 |
-
from typing import
|
| 24 |
|
| 25 |
import numpy as np
|
| 26 |
from tqdm import tqdm
|
|
@@ -33,14 +33,13 @@ logger = logging.getLogger(__name__)
|
|
| 33 |
# Official leaderboard: https://huggingface.co/spaces/vidore/vidore-leaderboard
|
| 34 |
VIDORE_DATASETS = {
|
| 35 |
# === RECOMMENDED FOR QUICK TESTING (smaller, faster) ===
|
| 36 |
-
"docvqa": "vidore/docvqa_test_subsampled",
|
| 37 |
-
"infovqa": "vidore/infovqa_test_subsampled",
|
| 38 |
"tabfquad": "vidore/tabfquad_test_subsampled", # ~500 queries, Tables
|
| 39 |
-
|
| 40 |
# === FULL EVALUATION ===
|
| 41 |
-
"tatdqa": "vidore/tatdqa_test",
|
| 42 |
-
"arxivqa": "vidore/arxivqa_test_subsampled",
|
| 43 |
-
"shift": "vidore/shiftproject_test",
|
| 44 |
}
|
| 45 |
|
| 46 |
# Aliases for convenience
|
|
@@ -54,41 +53,45 @@ def load_dataset(dataset_name: str) -> Dict[str, Any]:
|
|
| 54 |
from datasets import load_dataset
|
| 55 |
except ImportError:
|
| 56 |
raise ImportError("datasets library required. Install with: pip install datasets")
|
| 57 |
-
|
| 58 |
logger.info(f"Loading dataset: {dataset_name}")
|
| 59 |
-
|
| 60 |
# Load dataset
|
| 61 |
ds = load_dataset(dataset_name, split="test")
|
| 62 |
-
|
| 63 |
# Extract queries and documents
|
| 64 |
# ViDoRe format: each example has query, image, and relevant doc info
|
| 65 |
queries = []
|
| 66 |
documents = []
|
| 67 |
qrels = {} # query_id -> {doc_id: relevance}
|
| 68 |
-
|
| 69 |
for idx, example in enumerate(tqdm(ds, desc="Loading data")):
|
| 70 |
query_id = f"q_{idx}"
|
| 71 |
doc_id = f"d_{idx}"
|
| 72 |
-
|
| 73 |
# Get query text
|
| 74 |
query_text = example.get("query", example.get("question", ""))
|
| 75 |
-
queries.append(
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
|
|
|
|
|
|
| 80 |
# Get document image
|
| 81 |
image = example.get("image", example.get("page_image"))
|
| 82 |
-
documents.append(
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
|
|
|
|
|
|
| 87 |
# Relevance (self-document is relevant)
|
| 88 |
qrels[query_id] = {doc_id: 1}
|
| 89 |
-
|
| 90 |
logger.info(f"Loaded {len(queries)} queries and {len(documents)} documents")
|
| 91 |
-
|
| 92 |
return {
|
| 93 |
"queries": queries,
|
| 94 |
"documents": documents,
|
|
@@ -104,30 +107,30 @@ def embed_documents(
|
|
| 104 |
) -> Dict[str, np.ndarray]:
|
| 105 |
"""
|
| 106 |
Embed all documents.
|
| 107 |
-
|
| 108 |
Args:
|
| 109 |
documents: List of {id, image} dicts
|
| 110 |
embedder: VisualEmbedder instance
|
| 111 |
batch_size: Batch size for embedding
|
| 112 |
return_pooled: Also return tile-level pooled embeddings (for two-stage)
|
| 113 |
-
|
| 114 |
Returns:
|
| 115 |
doc_embeddings dict, and optionally pooled_embeddings dict
|
| 116 |
"""
|
| 117 |
from visual_rag.embedding.pooling import tile_level_mean_pooling
|
| 118 |
-
|
| 119 |
logger.info(f"Embedding {len(documents)} documents...")
|
| 120 |
-
|
| 121 |
images = [doc["image"] for doc in documents]
|
| 122 |
-
|
| 123 |
# Get embeddings with token info for proper pooling
|
| 124 |
embeddings, token_infos = embedder.embed_images(
|
| 125 |
images, batch_size=batch_size, return_token_info=True
|
| 126 |
)
|
| 127 |
-
|
| 128 |
doc_embeddings = {}
|
| 129 |
pooled_embeddings = {} if return_pooled else None
|
| 130 |
-
|
| 131 |
for doc, emb, token_info in zip(documents, embeddings, token_infos):
|
| 132 |
if hasattr(emb, "numpy"):
|
| 133 |
emb_np = emb.numpy()
|
|
@@ -135,18 +138,18 @@ def embed_documents(
|
|
| 135 |
emb_np = emb.cpu().numpy()
|
| 136 |
else:
|
| 137 |
emb_np = np.array(emb)
|
| 138 |
-
|
| 139 |
doc_embeddings[doc["id"]] = emb_np.astype(np.float32)
|
| 140 |
-
|
| 141 |
# Compute tile-level pooling (NOVEL approach)
|
| 142 |
if return_pooled:
|
| 143 |
n_rows = token_info.get("n_rows", 4)
|
| 144 |
n_cols = token_info.get("n_cols", 3)
|
| 145 |
num_tiles = n_rows * n_cols + 1 if n_rows and n_cols else 13
|
| 146 |
-
|
| 147 |
pooled = tile_level_mean_pooling(emb_np, num_tiles, patches_per_tile=64)
|
| 148 |
pooled_embeddings[doc["id"]] = pooled.astype(np.float32)
|
| 149 |
-
|
| 150 |
if return_pooled:
|
| 151 |
return doc_embeddings, pooled_embeddings
|
| 152 |
return doc_embeddings
|
|
@@ -158,7 +161,7 @@ def embed_queries(
|
|
| 158 |
) -> Dict[str, np.ndarray]:
|
| 159 |
"""Embed all queries."""
|
| 160 |
logger.info(f"Embedding {len(queries)} queries...")
|
| 161 |
-
|
| 162 |
query_embeddings = {}
|
| 163 |
for query in tqdm(queries, desc="Embedding queries"):
|
| 164 |
emb = embedder.embed_query(query["text"])
|
|
@@ -167,7 +170,7 @@ def embed_queries(
|
|
| 167 |
elif hasattr(emb, "cpu"):
|
| 168 |
emb = emb.cpu().numpy()
|
| 169 |
query_embeddings[query["id"]] = np.array(emb, dtype=np.float32)
|
| 170 |
-
|
| 171 |
return query_embeddings
|
| 172 |
|
| 173 |
|
|
@@ -176,10 +179,10 @@ def compute_maxsim(query_emb: np.ndarray, doc_emb: np.ndarray) -> float:
|
|
| 176 |
# Normalize
|
| 177 |
query_norm = query_emb / (np.linalg.norm(query_emb, axis=1, keepdims=True) + 1e-8)
|
| 178 |
doc_norm = doc_emb / (np.linalg.norm(doc_emb, axis=1, keepdims=True) + 1e-8)
|
| 179 |
-
|
| 180 |
# Compute similarity matrix
|
| 181 |
sim_matrix = np.dot(query_norm, doc_norm.T)
|
| 182 |
-
|
| 183 |
# MaxSim: max per query token, then sum
|
| 184 |
max_sims = sim_matrix.max(axis=1)
|
| 185 |
return float(max_sims.sum())
|
|
@@ -195,7 +198,7 @@ def search_exhaustive(
|
|
| 195 |
for doc_id, doc_emb in doc_embeddings.items():
|
| 196 |
score = compute_maxsim(query_emb, doc_emb)
|
| 197 |
scores.append({"id": doc_id, "score": score})
|
| 198 |
-
|
| 199 |
# Sort by score
|
| 200 |
scores.sort(key=lambda x: x["score"], reverse=True)
|
| 201 |
return scores[:top_k]
|
|
@@ -210,11 +213,11 @@ def search_two_stage(
|
|
| 210 |
) -> List[Dict]:
|
| 211 |
"""
|
| 212 |
Two-stage retrieval: tile-level pooled prefetch + MaxSim rerank.
|
| 213 |
-
|
| 214 |
Stage 1: Use tile-level pooled vectors for fast retrieval
|
| 215 |
Each doc has [num_tiles, 128] pooled representation
|
| 216 |
Compute MaxSim on pooled vectors (much faster)
|
| 217 |
-
|
| 218 |
Stage 2: Exact MaxSim reranking on top candidates
|
| 219 |
Use full multi-vector embeddings for precision
|
| 220 |
"""
|
|
@@ -222,7 +225,7 @@ def search_two_stage(
|
|
| 222 |
# Query pooled: mean across query tokens → [128]
|
| 223 |
query_pooled = query_emb.mean(axis=0)
|
| 224 |
query_pooled = query_pooled / (np.linalg.norm(query_pooled) + 1e-8)
|
| 225 |
-
|
| 226 |
stage1_scores = []
|
| 227 |
for doc_id, doc_pooled in pooled_embeddings.items():
|
| 228 |
# doc_pooled shape: [num_tiles, 128] from tile-level pooling
|
|
@@ -231,23 +234,25 @@ def search_two_stage(
|
|
| 231 |
tile_sims = np.dot(doc_norm, query_pooled)
|
| 232 |
score = float(tile_sims.max()) # Max tile similarity
|
| 233 |
stage1_scores.append({"id": doc_id, "score": score})
|
| 234 |
-
|
| 235 |
stage1_scores.sort(key=lambda x: x["score"], reverse=True)
|
| 236 |
candidates = stage1_scores[:prefetch_k]
|
| 237 |
-
|
| 238 |
# Stage 2: Exact MaxSim rerank on candidates
|
| 239 |
reranked = []
|
| 240 |
for cand in candidates:
|
| 241 |
doc_id = cand["id"]
|
| 242 |
doc_emb = doc_embeddings[doc_id]
|
| 243 |
score = compute_maxsim(query_emb, doc_emb)
|
| 244 |
-
reranked.append(
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
|
|
|
|
|
|
| 251 |
reranked.sort(key=lambda x: x["score"], reverse=True)
|
| 252 |
return reranked[:top_k]
|
| 253 |
|
|
@@ -262,10 +267,10 @@ def compute_metrics(
|
|
| 262 |
mrr_10 = []
|
| 263 |
recall_5 = []
|
| 264 |
recall_10 = []
|
| 265 |
-
|
| 266 |
for query_id, ranking in results.items():
|
| 267 |
relevant = set(qrels.get(query_id, {}).keys())
|
| 268 |
-
|
| 269 |
# MRR@10
|
| 270 |
rr = 0.0
|
| 271 |
for i, doc in enumerate(ranking[:10]):
|
|
@@ -273,32 +278,30 @@ def compute_metrics(
|
|
| 273 |
rr = 1.0 / (i + 1)
|
| 274 |
break
|
| 275 |
mrr_10.append(rr)
|
| 276 |
-
|
| 277 |
# Recall@5, Recall@10
|
| 278 |
retrieved_5 = set(d["id"] for d in ranking[:5])
|
| 279 |
retrieved_10 = set(d["id"] for d in ranking[:10])
|
| 280 |
-
|
| 281 |
if relevant:
|
| 282 |
recall_5.append(len(retrieved_5 & relevant) / len(relevant))
|
| 283 |
recall_10.append(len(retrieved_10 & relevant) / len(relevant))
|
| 284 |
-
|
| 285 |
# NDCG@5, NDCG@10
|
| 286 |
-
dcg_5 = sum(
|
| 287 |
-
1.0 / np.log2(i + 2) for i, d in enumerate(ranking[:5]) if d["id"] in relevant
|
| 288 |
-
)
|
| 289 |
dcg_10 = sum(
|
| 290 |
1.0 / np.log2(i + 2) for i, d in enumerate(ranking[:10]) if d["id"] in relevant
|
| 291 |
)
|
| 292 |
-
|
| 293 |
# Ideal DCG
|
| 294 |
k_rel = min(len(relevant), 5)
|
| 295 |
idcg_5 = sum(1.0 / np.log2(i + 2) for i in range(k_rel))
|
| 296 |
k_rel = min(len(relevant), 10)
|
| 297 |
idcg_10 = sum(1.0 / np.log2(i + 2) for i in range(k_rel))
|
| 298 |
-
|
| 299 |
ndcg_5.append(dcg_5 / idcg_5 if idcg_5 > 0 else 0.0)
|
| 300 |
ndcg_10.append(dcg_10 / idcg_10 if idcg_10 > 0 else 0.0)
|
| 301 |
-
|
| 302 |
return {
|
| 303 |
"ndcg@5": float(np.mean(ndcg_5)),
|
| 304 |
"ndcg@10": float(np.mean(ndcg_10)),
|
|
@@ -318,87 +321,90 @@ def run_evaluation(
|
|
| 318 |
) -> Dict[str, Any]:
|
| 319 |
"""Run full evaluation on a dataset."""
|
| 320 |
from visual_rag.embedding import VisualEmbedder
|
| 321 |
-
|
| 322 |
-
logger.info(
|
| 323 |
logger.info(f"Evaluating: {dataset_name}")
|
| 324 |
logger.info(f"Model: {model_name}")
|
| 325 |
logger.info(f"Two-stage: {two_stage}")
|
| 326 |
-
logger.info(
|
| 327 |
-
|
| 328 |
# Load dataset
|
| 329 |
data = load_dataset(dataset_name)
|
| 330 |
-
|
| 331 |
# Initialize embedder
|
| 332 |
embedder = VisualEmbedder(model_name=model_name)
|
| 333 |
-
|
| 334 |
# Embed documents (with tile-level pooling if two-stage)
|
| 335 |
start_time = time.time()
|
| 336 |
if two_stage:
|
| 337 |
doc_embeddings, pooled_embeddings = embed_documents(
|
| 338 |
data["documents"], embedder, return_pooled=True
|
| 339 |
)
|
| 340 |
-
logger.info(
|
| 341 |
else:
|
| 342 |
doc_embeddings = embed_documents(data["documents"], embedder)
|
| 343 |
pooled_embeddings = None
|
| 344 |
embed_time = time.time() - start_time
|
| 345 |
logger.info(f"Document embedding time: {embed_time:.2f}s")
|
| 346 |
-
|
| 347 |
# Embed queries
|
| 348 |
query_embeddings = embed_queries(data["queries"], embedder)
|
| 349 |
-
|
| 350 |
# Run search
|
| 351 |
logger.info("Running search...")
|
| 352 |
results = {}
|
| 353 |
search_times = []
|
| 354 |
-
|
| 355 |
for query in tqdm(data["queries"], desc="Searching"):
|
| 356 |
query_id = query["id"]
|
| 357 |
query_emb = query_embeddings[query_id]
|
| 358 |
-
|
| 359 |
start = time.time()
|
| 360 |
if two_stage:
|
| 361 |
ranking = search_two_stage(
|
| 362 |
-
query_emb, doc_embeddings, pooled_embeddings,
|
| 363 |
-
prefetch_k=prefetch_k, top_k=top_k
|
| 364 |
)
|
| 365 |
else:
|
| 366 |
ranking = search_exhaustive(query_emb, doc_embeddings, top_k=top_k)
|
| 367 |
search_times.append(time.time() - start)
|
| 368 |
-
|
| 369 |
results[query_id] = ranking
|
| 370 |
-
|
| 371 |
avg_search_time = np.mean(search_times)
|
| 372 |
logger.info(f"Average search time: {avg_search_time * 1000:.2f}ms")
|
| 373 |
-
|
| 374 |
# Compute metrics
|
| 375 |
metrics = compute_metrics(results, data["qrels"])
|
| 376 |
metrics["avg_search_time_ms"] = avg_search_time * 1000
|
| 377 |
metrics["embed_time_s"] = embed_time
|
| 378 |
-
|
| 379 |
-
logger.info(
|
| 380 |
for k, v in metrics.items():
|
| 381 |
logger.info(f" {k}: {v:.4f}")
|
| 382 |
-
|
| 383 |
# Save results
|
| 384 |
if output_dir:
|
| 385 |
output_path = Path(output_dir)
|
| 386 |
output_path.mkdir(parents=True, exist_ok=True)
|
| 387 |
-
|
| 388 |
dataset_short = dataset_name.split("/")[-1]
|
| 389 |
suffix = "_twostage" if two_stage else ""
|
| 390 |
result_file = output_path / f"{dataset_short}{suffix}.json"
|
| 391 |
-
|
| 392 |
with open(result_file, "w") as f:
|
| 393 |
-
json.dump(
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 400 |
logger.info(f"Saved results to: {result_file}")
|
| 401 |
-
|
| 402 |
return metrics
|
| 403 |
|
| 404 |
|
|
@@ -413,57 +419,53 @@ Available datasets:
|
|
| 413 |
Examples:
|
| 414 |
# Quick test on DocVQA
|
| 415 |
python run_vidore.py --dataset docvqa
|
| 416 |
-
|
| 417 |
# Quick test with two-stage (your novel approach)
|
| 418 |
python run_vidore.py --dataset docvqa --two-stage
|
| 419 |
-
|
| 420 |
# Run on recommended quick datasets
|
| 421 |
python run_vidore.py --quick
|
| 422 |
-
|
| 423 |
# Full evaluation on all datasets
|
| 424 |
python run_vidore.py --all
|
| 425 |
-
|
| 426 |
# Compare exhaustive vs two-stage
|
| 427 |
python run_vidore.py --dataset docvqa
|
| 428 |
python run_vidore.py --dataset docvqa --two-stage
|
| 429 |
python analyze_results.py --results results/ --compare
|
| 430 |
-
"""
|
| 431 |
-
)
|
| 432 |
-
parser.add_argument(
|
| 433 |
-
"--dataset", type=str, choices=list(VIDORE_DATASETS.keys()),
|
| 434 |
-
help=f"Dataset to evaluate: {', '.join(VIDORE_DATASETS.keys())}"
|
| 435 |
-
)
|
| 436 |
-
parser.add_argument(
|
| 437 |
-
"--quick", action="store_true",
|
| 438 |
-
help=f"Run on quick datasets: {QUICK_DATASETS}"
|
| 439 |
)
|
| 440 |
parser.add_argument(
|
| 441 |
-
"--
|
| 442 |
-
|
|
|
|
|
|
|
| 443 |
)
|
| 444 |
parser.add_argument(
|
| 445 |
-
"--
|
| 446 |
-
help="Model: vidore/colSmol-500M (default), vidore/colpali-v1.3, vidore/colqwen2-v1.0"
|
| 447 |
)
|
|
|
|
| 448 |
parser.add_argument(
|
| 449 |
-
"--
|
| 450 |
-
|
|
|
|
|
|
|
| 451 |
)
|
| 452 |
parser.add_argument(
|
| 453 |
-
"--
|
| 454 |
-
|
|
|
|
| 455 |
)
|
| 456 |
parser.add_argument(
|
| 457 |
-
"--
|
| 458 |
-
help="Final results (default: 10)"
|
| 459 |
)
|
|
|
|
| 460 |
parser.add_argument(
|
| 461 |
-
"--output-dir", type=str, default="results",
|
| 462 |
-
help="Output directory (default: results)"
|
| 463 |
)
|
| 464 |
-
|
| 465 |
args = parser.parse_args()
|
| 466 |
-
|
| 467 |
# Determine which datasets to run
|
| 468 |
if args.all:
|
| 469 |
dataset_keys = ALL_DATASETS
|
|
@@ -473,11 +475,11 @@ Examples:
|
|
| 473 |
dataset_keys = [args.dataset]
|
| 474 |
else:
|
| 475 |
parser.error("Specify --dataset, --quick, or --all")
|
| 476 |
-
|
| 477 |
# Convert keys to full HuggingFace paths
|
| 478 |
datasets = [VIDORE_DATASETS[k] for k in dataset_keys]
|
| 479 |
logger.info(f"Running on {len(datasets)} dataset(s): {dataset_keys}")
|
| 480 |
-
|
| 481 |
all_results = {}
|
| 482 |
for dataset in datasets:
|
| 483 |
try:
|
|
@@ -493,21 +495,19 @@ Examples:
|
|
| 493 |
except Exception as e:
|
| 494 |
logger.error(f"Failed on {dataset}: {e}")
|
| 495 |
continue
|
| 496 |
-
|
| 497 |
# Summary
|
| 498 |
if len(all_results) > 1:
|
| 499 |
logger.info("\n" + "=" * 60)
|
| 500 |
logger.info("SUMMARY")
|
| 501 |
logger.info("=" * 60)
|
| 502 |
-
|
| 503 |
avg_ndcg10 = np.mean([m["ndcg@10"] for m in all_results.values()])
|
| 504 |
avg_mrr10 = np.mean([m["mrr@10"] for m in all_results.values()])
|
| 505 |
-
|
| 506 |
logger.info(f"Average NDCG@10: {avg_ndcg10:.4f}")
|
| 507 |
logger.info(f"Average MRR@10: {avg_mrr10:.4f}")
|
| 508 |
|
| 509 |
|
| 510 |
if __name__ == "__main__":
|
| 511 |
main()
|
| 512 |
-
|
| 513 |
-
|
|
|
|
| 17 |
|
| 18 |
import argparse
|
| 19 |
import json
|
|
|
|
| 20 |
import logging
|
| 21 |
+
import time
|
| 22 |
from pathlib import Path
|
| 23 |
+
from typing import Any, Dict, List, Optional
|
| 24 |
|
| 25 |
import numpy as np
|
| 26 |
from tqdm import tqdm
|
|
|
|
| 33 |
# Official leaderboard: https://huggingface.co/spaces/vidore/vidore-leaderboard
|
| 34 |
VIDORE_DATASETS = {
|
| 35 |
# === RECOMMENDED FOR QUICK TESTING (smaller, faster) ===
|
| 36 |
+
"docvqa": "vidore/docvqa_test_subsampled", # ~500 queries, Document VQA
|
| 37 |
+
"infovqa": "vidore/infovqa_test_subsampled", # ~500 queries, Infographics
|
| 38 |
"tabfquad": "vidore/tabfquad_test_subsampled", # ~500 queries, Tables
|
|
|
|
| 39 |
# === FULL EVALUATION ===
|
| 40 |
+
"tatdqa": "vidore/tatdqa_test", # ~1500 queries, Financial tables
|
| 41 |
+
"arxivqa": "vidore/arxivqa_test_subsampled", # ~500 queries, Scientific papers
|
| 42 |
+
"shift": "vidore/shiftproject_test", # ~500 queries, Sustainability reports
|
| 43 |
}
|
| 44 |
|
| 45 |
# Aliases for convenience
|
|
|
|
| 53 |
from datasets import load_dataset
|
| 54 |
except ImportError:
|
| 55 |
raise ImportError("datasets library required. Install with: pip install datasets")
|
| 56 |
+
|
| 57 |
logger.info(f"Loading dataset: {dataset_name}")
|
| 58 |
+
|
| 59 |
# Load dataset
|
| 60 |
ds = load_dataset(dataset_name, split="test")
|
| 61 |
+
|
| 62 |
# Extract queries and documents
|
| 63 |
# ViDoRe format: each example has query, image, and relevant doc info
|
| 64 |
queries = []
|
| 65 |
documents = []
|
| 66 |
qrels = {} # query_id -> {doc_id: relevance}
|
| 67 |
+
|
| 68 |
for idx, example in enumerate(tqdm(ds, desc="Loading data")):
|
| 69 |
query_id = f"q_{idx}"
|
| 70 |
doc_id = f"d_{idx}"
|
| 71 |
+
|
| 72 |
# Get query text
|
| 73 |
query_text = example.get("query", example.get("question", ""))
|
| 74 |
+
queries.append(
|
| 75 |
+
{
|
| 76 |
+
"id": query_id,
|
| 77 |
+
"text": query_text,
|
| 78 |
+
}
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
# Get document image
|
| 82 |
image = example.get("image", example.get("page_image"))
|
| 83 |
+
documents.append(
|
| 84 |
+
{
|
| 85 |
+
"id": doc_id,
|
| 86 |
+
"image": image,
|
| 87 |
+
}
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
# Relevance (self-document is relevant)
|
| 91 |
qrels[query_id] = {doc_id: 1}
|
| 92 |
+
|
| 93 |
logger.info(f"Loaded {len(queries)} queries and {len(documents)} documents")
|
| 94 |
+
|
| 95 |
return {
|
| 96 |
"queries": queries,
|
| 97 |
"documents": documents,
|
|
|
|
| 107 |
) -> Dict[str, np.ndarray]:
|
| 108 |
"""
|
| 109 |
Embed all documents.
|
| 110 |
+
|
| 111 |
Args:
|
| 112 |
documents: List of {id, image} dicts
|
| 113 |
embedder: VisualEmbedder instance
|
| 114 |
batch_size: Batch size for embedding
|
| 115 |
return_pooled: Also return tile-level pooled embeddings (for two-stage)
|
| 116 |
+
|
| 117 |
Returns:
|
| 118 |
doc_embeddings dict, and optionally pooled_embeddings dict
|
| 119 |
"""
|
| 120 |
from visual_rag.embedding.pooling import tile_level_mean_pooling
|
| 121 |
+
|
| 122 |
logger.info(f"Embedding {len(documents)} documents...")
|
| 123 |
+
|
| 124 |
images = [doc["image"] for doc in documents]
|
| 125 |
+
|
| 126 |
# Get embeddings with token info for proper pooling
|
| 127 |
embeddings, token_infos = embedder.embed_images(
|
| 128 |
images, batch_size=batch_size, return_token_info=True
|
| 129 |
)
|
| 130 |
+
|
| 131 |
doc_embeddings = {}
|
| 132 |
pooled_embeddings = {} if return_pooled else None
|
| 133 |
+
|
| 134 |
for doc, emb, token_info in zip(documents, embeddings, token_infos):
|
| 135 |
if hasattr(emb, "numpy"):
|
| 136 |
emb_np = emb.numpy()
|
|
|
|
| 138 |
emb_np = emb.cpu().numpy()
|
| 139 |
else:
|
| 140 |
emb_np = np.array(emb)
|
| 141 |
+
|
| 142 |
doc_embeddings[doc["id"]] = emb_np.astype(np.float32)
|
| 143 |
+
|
| 144 |
# Compute tile-level pooling (NOVEL approach)
|
| 145 |
if return_pooled:
|
| 146 |
n_rows = token_info.get("n_rows", 4)
|
| 147 |
n_cols = token_info.get("n_cols", 3)
|
| 148 |
num_tiles = n_rows * n_cols + 1 if n_rows and n_cols else 13
|
| 149 |
+
|
| 150 |
pooled = tile_level_mean_pooling(emb_np, num_tiles, patches_per_tile=64)
|
| 151 |
pooled_embeddings[doc["id"]] = pooled.astype(np.float32)
|
| 152 |
+
|
| 153 |
if return_pooled:
|
| 154 |
return doc_embeddings, pooled_embeddings
|
| 155 |
return doc_embeddings
|
|
|
|
| 161 |
) -> Dict[str, np.ndarray]:
|
| 162 |
"""Embed all queries."""
|
| 163 |
logger.info(f"Embedding {len(queries)} queries...")
|
| 164 |
+
|
| 165 |
query_embeddings = {}
|
| 166 |
for query in tqdm(queries, desc="Embedding queries"):
|
| 167 |
emb = embedder.embed_query(query["text"])
|
|
|
|
| 170 |
elif hasattr(emb, "cpu"):
|
| 171 |
emb = emb.cpu().numpy()
|
| 172 |
query_embeddings[query["id"]] = np.array(emb, dtype=np.float32)
|
| 173 |
+
|
| 174 |
return query_embeddings
|
| 175 |
|
| 176 |
|
|
|
|
| 179 |
# Normalize
|
| 180 |
query_norm = query_emb / (np.linalg.norm(query_emb, axis=1, keepdims=True) + 1e-8)
|
| 181 |
doc_norm = doc_emb / (np.linalg.norm(doc_emb, axis=1, keepdims=True) + 1e-8)
|
| 182 |
+
|
| 183 |
# Compute similarity matrix
|
| 184 |
sim_matrix = np.dot(query_norm, doc_norm.T)
|
| 185 |
+
|
| 186 |
# MaxSim: max per query token, then sum
|
| 187 |
max_sims = sim_matrix.max(axis=1)
|
| 188 |
return float(max_sims.sum())
|
|
|
|
| 198 |
for doc_id, doc_emb in doc_embeddings.items():
|
| 199 |
score = compute_maxsim(query_emb, doc_emb)
|
| 200 |
scores.append({"id": doc_id, "score": score})
|
| 201 |
+
|
| 202 |
# Sort by score
|
| 203 |
scores.sort(key=lambda x: x["score"], reverse=True)
|
| 204 |
return scores[:top_k]
|
|
|
|
| 213 |
) -> List[Dict]:
|
| 214 |
"""
|
| 215 |
Two-stage retrieval: tile-level pooled prefetch + MaxSim rerank.
|
| 216 |
+
|
| 217 |
Stage 1: Use tile-level pooled vectors for fast retrieval
|
| 218 |
Each doc has [num_tiles, 128] pooled representation
|
| 219 |
Compute MaxSim on pooled vectors (much faster)
|
| 220 |
+
|
| 221 |
Stage 2: Exact MaxSim reranking on top candidates
|
| 222 |
Use full multi-vector embeddings for precision
|
| 223 |
"""
|
|
|
|
| 225 |
# Query pooled: mean across query tokens → [128]
|
| 226 |
query_pooled = query_emb.mean(axis=0)
|
| 227 |
query_pooled = query_pooled / (np.linalg.norm(query_pooled) + 1e-8)
|
| 228 |
+
|
| 229 |
stage1_scores = []
|
| 230 |
for doc_id, doc_pooled in pooled_embeddings.items():
|
| 231 |
# doc_pooled shape: [num_tiles, 128] from tile-level pooling
|
|
|
|
| 234 |
tile_sims = np.dot(doc_norm, query_pooled)
|
| 235 |
score = float(tile_sims.max()) # Max tile similarity
|
| 236 |
stage1_scores.append({"id": doc_id, "score": score})
|
| 237 |
+
|
| 238 |
stage1_scores.sort(key=lambda x: x["score"], reverse=True)
|
| 239 |
candidates = stage1_scores[:prefetch_k]
|
| 240 |
+
|
| 241 |
# Stage 2: Exact MaxSim rerank on candidates
|
| 242 |
reranked = []
|
| 243 |
for cand in candidates:
|
| 244 |
doc_id = cand["id"]
|
| 245 |
doc_emb = doc_embeddings[doc_id]
|
| 246 |
score = compute_maxsim(query_emb, doc_emb)
|
| 247 |
+
reranked.append(
|
| 248 |
+
{
|
| 249 |
+
"id": doc_id,
|
| 250 |
+
"score": score,
|
| 251 |
+
"stage1_score": cand["score"],
|
| 252 |
+
"stage1_rank": stage1_scores.index(cand) + 1,
|
| 253 |
+
}
|
| 254 |
+
)
|
| 255 |
+
|
| 256 |
reranked.sort(key=lambda x: x["score"], reverse=True)
|
| 257 |
return reranked[:top_k]
|
| 258 |
|
|
|
|
| 267 |
mrr_10 = []
|
| 268 |
recall_5 = []
|
| 269 |
recall_10 = []
|
| 270 |
+
|
| 271 |
for query_id, ranking in results.items():
|
| 272 |
relevant = set(qrels.get(query_id, {}).keys())
|
| 273 |
+
|
| 274 |
# MRR@10
|
| 275 |
rr = 0.0
|
| 276 |
for i, doc in enumerate(ranking[:10]):
|
|
|
|
| 278 |
rr = 1.0 / (i + 1)
|
| 279 |
break
|
| 280 |
mrr_10.append(rr)
|
| 281 |
+
|
| 282 |
# Recall@5, Recall@10
|
| 283 |
retrieved_5 = set(d["id"] for d in ranking[:5])
|
| 284 |
retrieved_10 = set(d["id"] for d in ranking[:10])
|
| 285 |
+
|
| 286 |
if relevant:
|
| 287 |
recall_5.append(len(retrieved_5 & relevant) / len(relevant))
|
| 288 |
recall_10.append(len(retrieved_10 & relevant) / len(relevant))
|
| 289 |
+
|
| 290 |
# NDCG@5, NDCG@10
|
| 291 |
+
dcg_5 = sum(1.0 / np.log2(i + 2) for i, d in enumerate(ranking[:5]) if d["id"] in relevant)
|
|
|
|
|
|
|
| 292 |
dcg_10 = sum(
|
| 293 |
1.0 / np.log2(i + 2) for i, d in enumerate(ranking[:10]) if d["id"] in relevant
|
| 294 |
)
|
| 295 |
+
|
| 296 |
# Ideal DCG
|
| 297 |
k_rel = min(len(relevant), 5)
|
| 298 |
idcg_5 = sum(1.0 / np.log2(i + 2) for i in range(k_rel))
|
| 299 |
k_rel = min(len(relevant), 10)
|
| 300 |
idcg_10 = sum(1.0 / np.log2(i + 2) for i in range(k_rel))
|
| 301 |
+
|
| 302 |
ndcg_5.append(dcg_5 / idcg_5 if idcg_5 > 0 else 0.0)
|
| 303 |
ndcg_10.append(dcg_10 / idcg_10 if idcg_10 > 0 else 0.0)
|
| 304 |
+
|
| 305 |
return {
|
| 306 |
"ndcg@5": float(np.mean(ndcg_5)),
|
| 307 |
"ndcg@10": float(np.mean(ndcg_10)),
|
|
|
|
| 321 |
) -> Dict[str, Any]:
|
| 322 |
"""Run full evaluation on a dataset."""
|
| 323 |
from visual_rag.embedding import VisualEmbedder
|
| 324 |
+
|
| 325 |
+
logger.info("=" * 60)
|
| 326 |
logger.info(f"Evaluating: {dataset_name}")
|
| 327 |
logger.info(f"Model: {model_name}")
|
| 328 |
logger.info(f"Two-stage: {two_stage}")
|
| 329 |
+
logger.info("=" * 60)
|
| 330 |
+
|
| 331 |
# Load dataset
|
| 332 |
data = load_dataset(dataset_name)
|
| 333 |
+
|
| 334 |
# Initialize embedder
|
| 335 |
embedder = VisualEmbedder(model_name=model_name)
|
| 336 |
+
|
| 337 |
# Embed documents (with tile-level pooling if two-stage)
|
| 338 |
start_time = time.time()
|
| 339 |
if two_stage:
|
| 340 |
doc_embeddings, pooled_embeddings = embed_documents(
|
| 341 |
data["documents"], embedder, return_pooled=True
|
| 342 |
)
|
| 343 |
+
logger.info("Using tile-level pooling for two-stage retrieval")
|
| 344 |
else:
|
| 345 |
doc_embeddings = embed_documents(data["documents"], embedder)
|
| 346 |
pooled_embeddings = None
|
| 347 |
embed_time = time.time() - start_time
|
| 348 |
logger.info(f"Document embedding time: {embed_time:.2f}s")
|
| 349 |
+
|
| 350 |
# Embed queries
|
| 351 |
query_embeddings = embed_queries(data["queries"], embedder)
|
| 352 |
+
|
| 353 |
# Run search
|
| 354 |
logger.info("Running search...")
|
| 355 |
results = {}
|
| 356 |
search_times = []
|
| 357 |
+
|
| 358 |
for query in tqdm(data["queries"], desc="Searching"):
|
| 359 |
query_id = query["id"]
|
| 360 |
query_emb = query_embeddings[query_id]
|
| 361 |
+
|
| 362 |
start = time.time()
|
| 363 |
if two_stage:
|
| 364 |
ranking = search_two_stage(
|
| 365 |
+
query_emb, doc_embeddings, pooled_embeddings, prefetch_k=prefetch_k, top_k=top_k
|
|
|
|
| 366 |
)
|
| 367 |
else:
|
| 368 |
ranking = search_exhaustive(query_emb, doc_embeddings, top_k=top_k)
|
| 369 |
search_times.append(time.time() - start)
|
| 370 |
+
|
| 371 |
results[query_id] = ranking
|
| 372 |
+
|
| 373 |
avg_search_time = np.mean(search_times)
|
| 374 |
logger.info(f"Average search time: {avg_search_time * 1000:.2f}ms")
|
| 375 |
+
|
| 376 |
# Compute metrics
|
| 377 |
metrics = compute_metrics(results, data["qrels"])
|
| 378 |
metrics["avg_search_time_ms"] = avg_search_time * 1000
|
| 379 |
metrics["embed_time_s"] = embed_time
|
| 380 |
+
|
| 381 |
+
logger.info("\nResults:")
|
| 382 |
for k, v in metrics.items():
|
| 383 |
logger.info(f" {k}: {v:.4f}")
|
| 384 |
+
|
| 385 |
# Save results
|
| 386 |
if output_dir:
|
| 387 |
output_path = Path(output_dir)
|
| 388 |
output_path.mkdir(parents=True, exist_ok=True)
|
| 389 |
+
|
| 390 |
dataset_short = dataset_name.split("/")[-1]
|
| 391 |
suffix = "_twostage" if two_stage else ""
|
| 392 |
result_file = output_path / f"{dataset_short}{suffix}.json"
|
| 393 |
+
|
| 394 |
with open(result_file, "w") as f:
|
| 395 |
+
json.dump(
|
| 396 |
+
{
|
| 397 |
+
"dataset": dataset_name,
|
| 398 |
+
"model": model_name,
|
| 399 |
+
"two_stage": two_stage,
|
| 400 |
+
"metrics": metrics,
|
| 401 |
+
},
|
| 402 |
+
f,
|
| 403 |
+
indent=2,
|
| 404 |
+
)
|
| 405 |
+
|
| 406 |
logger.info(f"Saved results to: {result_file}")
|
| 407 |
+
|
| 408 |
return metrics
|
| 409 |
|
| 410 |
|
|
|
|
| 419 |
Examples:
|
| 420 |
# Quick test on DocVQA
|
| 421 |
python run_vidore.py --dataset docvqa
|
| 422 |
+
|
| 423 |
# Quick test with two-stage (your novel approach)
|
| 424 |
python run_vidore.py --dataset docvqa --two-stage
|
| 425 |
+
|
| 426 |
# Run on recommended quick datasets
|
| 427 |
python run_vidore.py --quick
|
| 428 |
+
|
| 429 |
# Full evaluation on all datasets
|
| 430 |
python run_vidore.py --all
|
| 431 |
+
|
| 432 |
# Compare exhaustive vs two-stage
|
| 433 |
python run_vidore.py --dataset docvqa
|
| 434 |
python run_vidore.py --dataset docvqa --two-stage
|
| 435 |
python analyze_results.py --results results/ --compare
|
| 436 |
+
""",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 437 |
)
|
| 438 |
parser.add_argument(
|
| 439 |
+
"--dataset",
|
| 440 |
+
type=str,
|
| 441 |
+
choices=list(VIDORE_DATASETS.keys()),
|
| 442 |
+
help=f"Dataset to evaluate: {', '.join(VIDORE_DATASETS.keys())}",
|
| 443 |
)
|
| 444 |
parser.add_argument(
|
| 445 |
+
"--quick", action="store_true", help=f"Run on quick datasets: {QUICK_DATASETS}"
|
|
|
|
| 446 |
)
|
| 447 |
+
parser.add_argument("--all", action="store_true", help="Evaluate on all ViDoRe datasets")
|
| 448 |
parser.add_argument(
|
| 449 |
+
"--model",
|
| 450 |
+
type=str,
|
| 451 |
+
default="vidore/colSmol-500M",
|
| 452 |
+
help="Model: vidore/colSmol-500M (default), vidore/colpali-v1.3, vidore/colqwen2-v1.0",
|
| 453 |
)
|
| 454 |
parser.add_argument(
|
| 455 |
+
"--two-stage",
|
| 456 |
+
action="store_true",
|
| 457 |
+
help="Use two-stage retrieval (tile-level pooled prefetch + MaxSim rerank)",
|
| 458 |
)
|
| 459 |
parser.add_argument(
|
| 460 |
+
"--prefetch-k", type=int, default=100, help="Stage 1 candidates (default: 100)"
|
|
|
|
| 461 |
)
|
| 462 |
+
parser.add_argument("--top-k", type=int, default=10, help="Final results (default: 10)")
|
| 463 |
parser.add_argument(
|
| 464 |
+
"--output-dir", type=str, default="results", help="Output directory (default: results)"
|
|
|
|
| 465 |
)
|
| 466 |
+
|
| 467 |
args = parser.parse_args()
|
| 468 |
+
|
| 469 |
# Determine which datasets to run
|
| 470 |
if args.all:
|
| 471 |
dataset_keys = ALL_DATASETS
|
|
|
|
| 475 |
dataset_keys = [args.dataset]
|
| 476 |
else:
|
| 477 |
parser.error("Specify --dataset, --quick, or --all")
|
| 478 |
+
|
| 479 |
# Convert keys to full HuggingFace paths
|
| 480 |
datasets = [VIDORE_DATASETS[k] for k in dataset_keys]
|
| 481 |
logger.info(f"Running on {len(datasets)} dataset(s): {dataset_keys}")
|
| 482 |
+
|
| 483 |
all_results = {}
|
| 484 |
for dataset in datasets:
|
| 485 |
try:
|
|
|
|
| 495 |
except Exception as e:
|
| 496 |
logger.error(f"Failed on {dataset}: {e}")
|
| 497 |
continue
|
| 498 |
+
|
| 499 |
# Summary
|
| 500 |
if len(all_results) > 1:
|
| 501 |
logger.info("\n" + "=" * 60)
|
| 502 |
logger.info("SUMMARY")
|
| 503 |
logger.info("=" * 60)
|
| 504 |
+
|
| 505 |
avg_ndcg10 = np.mean([m["ndcg@10"] for m in all_results.values()])
|
| 506 |
avg_mrr10 = np.mean([m["mrr@10"] for m in all_results.values()])
|
| 507 |
+
|
| 508 |
logger.info(f"Average NDCG@10: {avg_ndcg10:.4f}")
|
| 509 |
logger.info(f"Average MRR@10: {avg_mrr10:.4f}")
|
| 510 |
|
| 511 |
|
| 512 |
if __name__ == "__main__":
|
| 513 |
main()
|
|
|
|
|
|
benchmarks/vidore_beir_qdrant/run_qdrant_beir.py
CHANGED
|
@@ -4,13 +4,14 @@ import os
|
|
| 4 |
import sys
|
| 5 |
import tempfile
|
| 6 |
import time
|
|
|
|
| 7 |
from pathlib import Path
|
| 8 |
from typing import Any, Dict, List, Optional, Tuple
|
| 9 |
|
| 10 |
import numpy as np
|
| 11 |
|
| 12 |
from benchmarks.vidore_tatdqa_test.dataset_loader import load_vidore_beir_dataset
|
| 13 |
-
from benchmarks.vidore_tatdqa_test.metrics import
|
| 14 |
from visual_rag import VisualEmbedder
|
| 15 |
from visual_rag.indexing.cloudinary_uploader import CloudinaryUploader
|
| 16 |
from visual_rag.indexing.qdrant_indexer import QdrantIndexer
|
|
@@ -83,15 +84,20 @@ def _parse_payload_indexes(values: List[str]) -> List[Dict[str, str]]:
|
|
| 83 |
return indexes
|
| 84 |
|
| 85 |
|
| 86 |
-
def _union_point_id(
|
|
|
|
|
|
|
| 87 |
ns = f"{union_namespace}::{dataset_name}" if union_namespace else dataset_name
|
| 88 |
return _stable_uuid(f"{ns}::{source_doc_id}")
|
| 89 |
|
| 90 |
|
| 91 |
-
def _filter_qrels(
|
|
|
|
|
|
|
| 92 |
keep = set(query_ids)
|
| 93 |
return {qid: rels for qid, rels in qrels.items() if qid in keep}
|
| 94 |
|
|
|
|
| 95 |
def _failed_log_path(*, collection_name: str, dataset_name: str) -> Path:
|
| 96 |
dir_name = _safe_filename(collection_name)
|
| 97 |
return Path("results") / dir_name / f"index_failures__{_safe_filename(dataset_name)}.jsonl"
|
|
@@ -130,7 +136,7 @@ def _default_output_filename(*, args, datasets: List[str]) -> str:
|
|
| 130 |
if str(args.mode) == "three_stage":
|
| 131 |
parts.append("tokens_vs_global")
|
| 132 |
parts.append(f"s1k{int(args.stage1_k)}")
|
| 133 |
-
parts.append("
|
| 134 |
parts.append(f"s2k{int(args.stage2_k)}")
|
| 135 |
parts.extend([topk_tag, scope_tag, ds_tag])
|
| 136 |
|
|
@@ -207,7 +213,9 @@ def _load_failed_union_ids(
|
|
| 207 |
return out
|
| 208 |
|
| 209 |
|
| 210 |
-
def _remove_failed_from_qrels(
|
|
|
|
|
|
|
| 211 |
removed = 0
|
| 212 |
if not failed_ids:
|
| 213 |
return qrels, 0
|
|
@@ -223,6 +231,45 @@ def _remove_failed_from_qrels(qrels: Dict[str, Dict[str, int]], failed_ids: set)
|
|
| 223 |
return out, removed
|
| 224 |
|
| 225 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
def _evaluate(
|
| 227 |
*,
|
| 228 |
queries,
|
|
@@ -283,11 +330,25 @@ def _evaluate(
|
|
| 283 |
retrieve_k = max(100, top_k)
|
| 284 |
|
| 285 |
query_texts = [q.text for q in queries]
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 291 |
|
| 292 |
iterator = queries
|
| 293 |
try:
|
|
@@ -304,7 +365,10 @@ def _evaluate(
|
|
| 304 |
except ImportError:
|
| 305 |
torch = None
|
| 306 |
if torch is not None and isinstance(qemb, torch.Tensor):
|
| 307 |
-
|
|
|
|
|
|
|
|
|
|
| 308 |
else:
|
| 309 |
qemb_np = qemb.numpy()
|
| 310 |
|
|
@@ -361,6 +425,41 @@ def _evaluate(
|
|
| 361 |
}
|
| 362 |
|
| 363 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 364 |
def _write_json_atomic(path: Path, data: Dict[str, Any]) -> None:
|
| 365 |
path.parent.mkdir(parents=True, exist_ok=True)
|
| 366 |
fd, tmp_path = tempfile.mkstemp(prefix=path.name + ".", dir=str(path.parent))
|
|
@@ -415,12 +514,15 @@ def _index_beir_corpus(
|
|
| 415 |
crop_empty_remove_page_number: bool,
|
| 416 |
crop_empty_preserve_border_px: int,
|
| 417 |
crop_empty_uniform_std_threshold: float,
|
|
|
|
| 418 |
no_cloudinary: bool,
|
| 419 |
cloudinary_folder: str,
|
| 420 |
retry_failures: bool,
|
| 421 |
only_failures: bool,
|
| 422 |
) -> None:
|
| 423 |
-
qdrant_url =
|
|
|
|
|
|
|
| 424 |
if not qdrant_url:
|
| 425 |
raise ValueError("QDRANT_URL not set")
|
| 426 |
qdrant_api_key = (
|
|
@@ -453,7 +555,9 @@ def _index_beir_corpus(
|
|
| 453 |
cloudinary_uploader = None
|
| 454 |
|
| 455 |
failure_log = _failed_log_path(collection_name=collection_name, dataset_name=dataset_name)
|
| 456 |
-
failed_ids = _load_failed_union_ids(
|
|
|
|
|
|
|
| 457 |
previously_failed_ids = set(failed_ids)
|
| 458 |
|
| 459 |
existing_ids = set()
|
|
@@ -520,6 +624,7 @@ def _index_beir_corpus(
|
|
| 520 |
out = img.copy()
|
| 521 |
out.thumbnail((1024, 1024), Image.BICUBIC)
|
| 522 |
return out
|
|
|
|
| 523 |
uploaded_docs = 0
|
| 524 |
skipped_docs = 0
|
| 525 |
start_time = time.time()
|
|
@@ -536,14 +641,24 @@ def _index_beir_corpus(
|
|
| 536 |
pass
|
| 537 |
|
| 538 |
import threading
|
| 539 |
-
from concurrent.futures import
|
|
|
|
| 540 |
|
| 541 |
stop_event = threading.Event()
|
| 542 |
-
executor =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 543 |
futures = []
|
| 544 |
|
| 545 |
def _upload(points: List[Dict[str, Any]]) -> int:
|
| 546 |
-
uploaded = int(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 547 |
if uploaded <= 0 and points:
|
| 548 |
for p in points:
|
| 549 |
pid = str(p.get("id") or "")
|
|
@@ -554,7 +669,9 @@ def _index_beir_corpus(
|
|
| 554 |
"dataset": dataset_name,
|
| 555 |
"collection": collection_name,
|
| 556 |
"model": model_name,
|
| 557 |
-
"source_doc_id": str(
|
|
|
|
|
|
|
| 558 |
"doc_id": str((p.get("metadata") or {}).get("doc_id") or ""),
|
| 559 |
"union_doc_id": pid,
|
| 560 |
"error": "Qdrant upsert failed (all retries exhausted)",
|
|
@@ -632,7 +749,8 @@ def _index_beir_corpus(
|
|
| 632 |
continue
|
| 633 |
|
| 634 |
if crop_empty:
|
| 635 |
-
from visual_rag.preprocessing.crop_empty import CropEmptyConfig
|
|
|
|
| 636 |
|
| 637 |
crop_cfg = CropEmptyConfig(
|
| 638 |
percentage_to_remove=float(crop_empty_percentage_to_remove),
|
|
@@ -660,7 +778,7 @@ def _index_beir_corpus(
|
|
| 660 |
return_token_info=True,
|
| 661 |
show_progress=False,
|
| 662 |
)
|
| 663 |
-
except Exception
|
| 664 |
# Retry per-doc to isolate flaky backend / corrupted sample issues.
|
| 665 |
embeddings = []
|
| 666 |
token_infos = []
|
|
@@ -675,7 +793,9 @@ def _index_beir_corpus(
|
|
| 675 |
embeddings.append(e1[0])
|
| 676 |
token_infos.append(t1[0])
|
| 677 |
except Exception as e_single:
|
| 678 |
-
source_doc_id_i = str(
|
|
|
|
|
|
|
| 679 |
union_doc_id_i = _union_point_id(
|
| 680 |
dataset_name=dataset_name,
|
| 681 |
source_doc_id=source_doc_id_i,
|
|
@@ -704,63 +824,107 @@ def _index_beir_corpus(
|
|
| 704 |
for doc, emb, token_info, crop_meta, original_img, embed_img in zip(
|
| 705 |
batch, embeddings, token_infos, crop_metas, original_images, images
|
| 706 |
):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 707 |
try:
|
| 708 |
-
emb_np =
|
| 709 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 710 |
visual_embedding = emb_np[visual_indices].astype(np.float32)
|
| 711 |
-
tile_pooled = embedder.mean_pool_visual_embedding(
|
|
|
|
|
|
|
| 712 |
experimental_pooled = embedder.experimental_pool_visual_embedding(
|
| 713 |
-
visual_embedding,
|
|
|
|
|
|
|
|
|
|
| 714 |
)
|
| 715 |
global_pooled = embedder.global_pool_from_mean_pool(tile_pooled)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 716 |
except Exception as e_single:
|
| 717 |
-
|
| 718 |
-
union_doc_id_i = _union_point_id(
|
| 719 |
-
dataset_name=dataset_name,
|
| 720 |
-
source_doc_id=source_doc_id_i,
|
| 721 |
-
union_namespace=union_namespace,
|
| 722 |
-
)
|
| 723 |
-
if str(union_doc_id_i) not in failed_ids:
|
| 724 |
_append_jsonl(
|
| 725 |
failure_log,
|
| 726 |
{
|
| 727 |
"dataset": dataset_name,
|
| 728 |
"collection": collection_name,
|
| 729 |
"model": model_name,
|
| 730 |
-
"source_doc_id": str(
|
| 731 |
"doc_id": str(getattr(doc, "doc_id", "")),
|
| 732 |
-
"union_doc_id": str(
|
| 733 |
"error": str(e_single),
|
| 734 |
},
|
| 735 |
)
|
| 736 |
-
failed_ids.add(str(
|
| 737 |
-
existing_ids.add(str(
|
| 738 |
skipped_docs += 1
|
| 739 |
continue
|
| 740 |
|
| 741 |
num_tiles = int(tile_pooled.shape[0])
|
| 742 |
-
patches_per_tile =
|
| 743 |
-
|
| 744 |
-
source_doc_id = str((doc.payload or {}).get("source_doc_id") or doc.doc_id)
|
| 745 |
-
union_doc_id = _union_point_id(
|
| 746 |
-
dataset_name=dataset_name,
|
| 747 |
-
source_doc_id=source_doc_id,
|
| 748 |
-
union_namespace=union_namespace,
|
| 749 |
)
|
| 750 |
|
| 751 |
resized_img = _resized_for_display(embed_img) or embed_img
|
| 752 |
original_url = ""
|
| 753 |
cropped_url = ""
|
| 754 |
resized_url = ""
|
| 755 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
| 756 |
base_public_id = _safe_public_id(f"{dataset_name}__{union_doc_id}")
|
| 757 |
try:
|
| 758 |
if crop_empty:
|
| 759 |
-
o_url, c_url, r_url =
|
| 760 |
-
|
| 761 |
-
|
| 762 |
-
|
| 763 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 764 |
)
|
| 765 |
original_url = o_url or ""
|
| 766 |
cropped_url = c_url or ""
|
|
@@ -782,12 +946,18 @@ def _index_beir_corpus(
|
|
| 782 |
"union_doc_id": union_doc_id,
|
| 783 |
"page": resized_url or original_url or "",
|
| 784 |
"original_url": original_url,
|
| 785 |
-
"cropped_url": cropped_url,
|
| 786 |
"resized_url": resized_url,
|
| 787 |
"original_width": int(original_img.width) if original_img is not None else None,
|
| 788 |
-
"original_height":
|
| 789 |
-
|
| 790 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 791 |
"resized_width": int(resized_img.width) if resized_img is not None else None,
|
| 792 |
"resized_height": int(resized_img.height) if resized_img is not None else None,
|
| 793 |
"num_tiles": int(num_tiles),
|
|
@@ -795,15 +965,25 @@ def _index_beir_corpus(
|
|
| 795 |
"torch_dtype": _torch_dtype_to_str(embedder.torch_dtype),
|
| 796 |
"model_name": model_name,
|
| 797 |
"crop_empty_enabled": bool(crop_empty),
|
| 798 |
-
"crop_empty_crop_box": (
|
| 799 |
-
|
| 800 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 801 |
"index_recovery_previously_failed": bool(union_doc_id in previously_failed_ids),
|
| 802 |
"index_recovery_mode": (
|
| 803 |
-
"only_failures"
|
|
|
|
|
|
|
| 804 |
),
|
| 805 |
"index_recovery_pooling_inferred_tiles": bool(
|
| 806 |
-
(token_info or {}).get("num_tiles") is None
|
|
|
|
|
|
|
| 807 |
),
|
| 808 |
"index_recovery_num_visual_tokens": int(visual_embedding.shape[0]),
|
| 809 |
**(doc.payload or {}),
|
|
@@ -894,8 +1074,8 @@ def main() -> None:
|
|
| 894 |
parser.add_argument(
|
| 895 |
"--qdrant-vector-dtype",
|
| 896 |
type=str,
|
| 897 |
-
default="
|
| 898 |
-
choices=["float16", "float32"],
|
| 899 |
)
|
| 900 |
grpc_group = parser.add_mutually_exclusive_group()
|
| 901 |
grpc_group.add_argument("--prefer-grpc", dest="prefer_grpc", action="store_true", default=True)
|
|
@@ -910,10 +1090,14 @@ def main() -> None:
|
|
| 910 |
parser.add_argument("--upsert-wait", action="store_true")
|
| 911 |
parser.add_argument("--max-corpus-docs", type=int, default=0)
|
| 912 |
parser.add_argument("--sample-corpus-docs", type=int, default=0)
|
| 913 |
-
parser.add_argument(
|
|
|
|
|
|
|
| 914 |
parser.add_argument("--sample-seed", type=int, default=0)
|
| 915 |
parser.add_argument("--sample-queries", type=int, default=0)
|
| 916 |
-
parser.add_argument(
|
|
|
|
|
|
|
| 917 |
parser.add_argument("--sample-query-seed", type=int, default=0)
|
| 918 |
parser.add_argument("--index-from-queries", action="store_true", default=False)
|
| 919 |
parser.add_argument("--resume", action="store_true", default=False)
|
|
@@ -925,14 +1109,35 @@ def main() -> None:
|
|
| 925 |
parser.add_argument("--crop-empty-remove-page-number", action="store_true", default=False)
|
| 926 |
parser.add_argument("--crop-empty-preserve-border-px", type=int, default=1)
|
| 927 |
parser.add_argument("--crop-empty-uniform-std-threshold", type=float, default=0.0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 928 |
payload_group = parser.add_mutually_exclusive_group()
|
| 929 |
payload_group.add_argument("--index-common-metadata", action="store_true", default=True)
|
| 930 |
-
payload_group.add_argument(
|
|
|
|
|
|
|
| 931 |
parser.add_argument("--payload-index", action="append", default=[])
|
| 932 |
-
parser.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 933 |
"--no-cloudinary",
|
|
|
|
| 934 |
action="store_true",
|
| 935 |
-
help="Disable Cloudinary uploads during indexing (default
|
| 936 |
)
|
| 937 |
parser.add_argument(
|
| 938 |
"--cloudinary-folder",
|
|
@@ -953,8 +1158,18 @@ def main() -> None:
|
|
| 953 |
help="Index only documents listed in index_failures__<collection>__<dataset>.jsonl.",
|
| 954 |
)
|
| 955 |
|
| 956 |
-
parser.add_argument(
|
| 957 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 958 |
parser.add_argument(
|
| 959 |
"--no-eval",
|
| 960 |
action="store_true",
|
|
@@ -970,21 +1185,41 @@ def main() -> None:
|
|
| 970 |
parser.add_argument(
|
| 971 |
"--stage1-mode",
|
| 972 |
type=str,
|
| 973 |
-
default="
|
| 974 |
choices=[
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 975 |
"pooled_query_vs_tiles",
|
| 976 |
"tokens_vs_tiles",
|
| 977 |
"pooled_query_vs_experimental",
|
| 978 |
"tokens_vs_experimental",
|
| 979 |
-
"pooled_query_vs_global",
|
| 980 |
],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 981 |
)
|
| 982 |
-
parser.add_argument("--stage1-k", type=int, default=1000, help="Three-stage stage1 top_k (default: 1000)")
|
| 983 |
-
parser.add_argument("--stage2-k", type=int, default=300, help="Three-stage stage2 top_k (default: 300)")
|
| 984 |
parser.add_argument("--max-queries", type=int, default=0)
|
| 985 |
drop_group = parser.add_mutually_exclusive_group()
|
| 986 |
-
drop_group.add_argument(
|
| 987 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 988 |
parser.add_argument(
|
| 989 |
"--evaluation-scope",
|
| 990 |
type=str,
|
|
@@ -1008,25 +1243,60 @@ def main() -> None:
|
|
| 1008 |
help="Stop the run immediately on the first dataset evaluation failure.",
|
| 1009 |
)
|
| 1010 |
parser.add_argument("--output", type=str, default="auto")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1011 |
|
| 1012 |
args = parser.parse_args()
|
| 1013 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1014 |
_maybe_load_dotenv()
|
| 1015 |
|
| 1016 |
if args.recreate:
|
| 1017 |
args.index = True
|
| 1018 |
|
| 1019 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1020 |
raise ValueError("Use only one of --sample-corpus-docs or --max-corpus-docs (not both).")
|
| 1021 |
if args.sample_queries and int(args.sample_queries) > 0 and args.index_from_queries:
|
| 1022 |
-
if (args.sample_corpus_docs and int(args.sample_corpus_docs) > 0) or (
|
| 1023 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1024 |
|
| 1025 |
if args.upsert_wait:
|
| 1026 |
print("Qdrant upserts wait for completion (wait=True).")
|
| 1027 |
else:
|
| 1028 |
print("Qdrant upserts are async (wait=False).")
|
| 1029 |
-
print(
|
|
|
|
|
|
|
| 1030 |
|
| 1031 |
datasets: List[str] = []
|
| 1032 |
if args.datasets:
|
|
@@ -1044,7 +1314,46 @@ def main() -> None:
|
|
| 1044 |
corpus, queries, qrels = load_vidore_beir_dataset(ds_name)
|
| 1045 |
loaded.append((ds_name, corpus, queries, qrels))
|
| 1046 |
|
| 1047 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1048 |
embedder = VisualEmbedder(
|
| 1049 |
model_name=args.model,
|
| 1050 |
batch_size=args.batch_size,
|
|
@@ -1105,9 +1414,6 @@ def main() -> None:
|
|
| 1105 |
{"field": "original_height", "type": "integer"},
|
| 1106 |
{"field": "resized_width", "type": "integer"},
|
| 1107 |
{"field": "resized_height", "type": "integer"},
|
| 1108 |
-
{"field": "crop_empty_enabled", "type": "bool"},
|
| 1109 |
-
{"field": "crop_empty_remove_page_number", "type": "bool"},
|
| 1110 |
-
{"field": "crop_empty_percentage_to_remove", "type": "float"},
|
| 1111 |
{"field": "num_tiles", "type": "integer"},
|
| 1112 |
{"field": "tile_rows", "type": "integer"},
|
| 1113 |
{"field": "tile_cols", "type": "integer"},
|
|
@@ -1118,6 +1424,28 @@ def main() -> None:
|
|
| 1118 |
{"field": "source", "type": "keyword"},
|
| 1119 |
]
|
| 1120 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1121 |
for i, (ds_name, corpus, queries, _qrels) in enumerate(selected):
|
| 1122 |
print(f"Indexing {ds_name}: corpus_docs={len(corpus)} queries={len(queries)}")
|
| 1123 |
_index_beir_corpus(
|
|
@@ -1126,7 +1454,7 @@ def main() -> None:
|
|
| 1126 |
embedder=embedder,
|
| 1127 |
collection_name=args.collection,
|
| 1128 |
prefer_grpc=args.prefer_grpc,
|
| 1129 |
-
qdrant_vector_dtype=
|
| 1130 |
recreate=bool(args.recreate and i == 0),
|
| 1131 |
indexing_threshold=args.indexing_threshold,
|
| 1132 |
batch_size=args.batch_size,
|
|
@@ -1148,6 +1476,7 @@ def main() -> None:
|
|
| 1148 |
crop_empty_remove_page_number=bool(args.crop_empty_remove_page_number),
|
| 1149 |
crop_empty_preserve_border_px=int(args.crop_empty_preserve_border_px),
|
| 1150 |
crop_empty_uniform_std_threshold=float(args.crop_empty_uniform_std_threshold),
|
|
|
|
| 1151 |
no_cloudinary=bool(args.no_cloudinary),
|
| 1152 |
cloudinary_folder=str(args.cloudinary_folder),
|
| 1153 |
retry_failures=bool(args.retry_failures),
|
|
@@ -1160,9 +1489,15 @@ def main() -> None:
|
|
| 1160 |
dataset_index_failures: Dict[str, Dict[str, Any]] = {}
|
| 1161 |
dataset_counts: Dict[str, Dict[str, int]] = {}
|
| 1162 |
for ds_name, corpus, queries, _qrels in selected:
|
| 1163 |
-
dataset_counts[ds_name] = {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1164 |
failed_path = _failed_log_path(collection_name=args.collection, dataset_name=ds_name)
|
| 1165 |
-
failed_ids = _load_failed_union_ids(
|
|
|
|
|
|
|
| 1166 |
dataset_index_failures[ds_name] = {
|
| 1167 |
"failed_log_path": str(failed_path),
|
| 1168 |
"failed_ids_count": int(len(failed_ids)),
|
|
@@ -1178,7 +1513,7 @@ def main() -> None:
|
|
| 1178 |
"collection": args.collection,
|
| 1179 |
"model": args.model,
|
| 1180 |
"torch_dtype": _torch_dtype_to_str(embedder.torch_dtype),
|
| 1181 |
-
"qdrant_vector_dtype":
|
| 1182 |
"mode": args.mode,
|
| 1183 |
"stage1_mode": args.stage1_mode if args.mode == "two_stage" else None,
|
| 1184 |
"prefetch_k": args.prefetch_k if args.mode == "two_stage" else None,
|
|
@@ -1200,10 +1535,45 @@ def main() -> None:
|
|
| 1200 |
print(f"Wrote index-only report: {out_path}")
|
| 1201 |
return
|
| 1202 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1203 |
retriever = MultiVectorRetriever(
|
| 1204 |
collection_name=args.collection,
|
| 1205 |
embedder=embedder,
|
| 1206 |
-
qdrant_url=os.getenv("SIGIR_QDRANT_URL")
|
|
|
|
|
|
|
| 1207 |
qdrant_api_key=(
|
| 1208 |
os.getenv("SIGIR_QDRANT_KEY")
|
| 1209 |
or os.getenv("SIGIR_QDRANT_API_KEY")
|
|
@@ -1234,7 +1604,7 @@ def main() -> None:
|
|
| 1234 |
"collection": args.collection,
|
| 1235 |
"model": args.model,
|
| 1236 |
"torch_dtype": _torch_dtype_to_str(embedder.torch_dtype),
|
| 1237 |
-
"qdrant_vector_dtype":
|
| 1238 |
"mode": args.mode,
|
| 1239 |
"stage1_mode": args.stage1_mode if args.mode == "two_stage" else None,
|
| 1240 |
"prefetch_k": args.prefetch_k if args.mode == "two_stage" else None,
|
|
@@ -1271,13 +1641,25 @@ def main() -> None:
|
|
| 1271 |
f"(corpus_docs={len(corpus)}, queries={len(queries)}) "
|
| 1272 |
f"scope={args.evaluation_scope} "
|
| 1273 |
f"mode={args.mode}"
|
| 1274 |
-
+ (
|
| 1275 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1276 |
+ f", top_k={int(args.top_k)}"
|
| 1277 |
)
|
| 1278 |
sys.stdout.flush()
|
| 1279 |
|
| 1280 |
-
dataset_counts[ds_name] = {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1281 |
id_map: Dict[str, str] = {}
|
| 1282 |
for doc in corpus:
|
| 1283 |
source_doc_id = str((doc.payload or {}).get("source_doc_id") or doc.doc_id)
|
|
@@ -1298,11 +1680,21 @@ def main() -> None:
|
|
| 1298 |
remapped_qrels[qid] = out_rels
|
| 1299 |
|
| 1300 |
failed_path = _failed_log_path(collection_name=args.collection, dataset_name=ds_name)
|
| 1301 |
-
|
| 1302 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1303 |
dataset_index_failures[ds_name] = {
|
| 1304 |
"failed_log_path": str(failed_path),
|
| 1305 |
-
"failed_ids_count": int(len(
|
|
|
|
| 1306 |
"qrels_removed": int(removed_rels),
|
| 1307 |
}
|
| 1308 |
|
|
@@ -1311,7 +1703,11 @@ def main() -> None:
|
|
| 1311 |
from qdrant_client.http import models as qmodels
|
| 1312 |
|
| 1313 |
filter_obj = qmodels.Filter(
|
| 1314 |
-
must=[
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1315 |
)
|
| 1316 |
|
| 1317 |
try:
|
|
@@ -1330,7 +1726,9 @@ def main() -> None:
|
|
| 1330 |
drop_empty_queries=bool(args.drop_empty_queries),
|
| 1331 |
filter_obj=filter_obj,
|
| 1332 |
)
|
| 1333 |
-
dataset_counts[ds_name]["queries_eval"] = int(
|
|
|
|
|
|
|
| 1334 |
ds_only_out = {
|
| 1335 |
**_build_run_record(),
|
| 1336 |
"dataset": str(ds_name),
|
|
@@ -1362,4 +1760,4 @@ def main() -> None:
|
|
| 1362 |
|
| 1363 |
|
| 1364 |
if __name__ == "__main__":
|
| 1365 |
-
main()
|
|
|
|
| 4 |
import sys
|
| 5 |
import tempfile
|
| 6 |
import time
|
| 7 |
+
import warnings
|
| 8 |
from pathlib import Path
|
| 9 |
from typing import Any, Dict, List, Optional, Tuple
|
| 10 |
|
| 11 |
import numpy as np
|
| 12 |
|
| 13 |
from benchmarks.vidore_tatdqa_test.dataset_loader import load_vidore_beir_dataset
|
| 14 |
+
from benchmarks.vidore_tatdqa_test.metrics import mrr_at_k, ndcg_at_k, recall_at_k
|
| 15 |
from visual_rag import VisualEmbedder
|
| 16 |
from visual_rag.indexing.cloudinary_uploader import CloudinaryUploader
|
| 17 |
from visual_rag.indexing.qdrant_indexer import QdrantIndexer
|
|
|
|
| 84 |
return indexes
|
| 85 |
|
| 86 |
|
| 87 |
+
def _union_point_id(
|
| 88 |
+
*, dataset_name: str, source_doc_id: str, union_namespace: Optional[str]
|
| 89 |
+
) -> str:
|
| 90 |
ns = f"{union_namespace}::{dataset_name}" if union_namespace else dataset_name
|
| 91 |
return _stable_uuid(f"{ns}::{source_doc_id}")
|
| 92 |
|
| 93 |
|
| 94 |
+
def _filter_qrels(
|
| 95 |
+
qrels: Dict[str, Dict[str, int]], query_ids: List[str]
|
| 96 |
+
) -> Dict[str, Dict[str, int]]:
|
| 97 |
keep = set(query_ids)
|
| 98 |
return {qid: rels for qid, rels in qrels.items() if qid in keep}
|
| 99 |
|
| 100 |
+
|
| 101 |
def _failed_log_path(*, collection_name: str, dataset_name: str) -> Path:
|
| 102 |
dir_name = _safe_filename(collection_name)
|
| 103 |
return Path("results") / dir_name / f"index_failures__{_safe_filename(dataset_name)}.jsonl"
|
|
|
|
| 136 |
if str(args.mode) == "three_stage":
|
| 137 |
parts.append("tokens_vs_global")
|
| 138 |
parts.append(f"s1k{int(args.stage1_k)}")
|
| 139 |
+
parts.append("tokens_vs_experimental_pooling")
|
| 140 |
parts.append(f"s2k{int(args.stage2_k)}")
|
| 141 |
parts.extend([topk_tag, scope_tag, ds_tag])
|
| 142 |
|
|
|
|
| 213 |
return out
|
| 214 |
|
| 215 |
|
| 216 |
+
def _remove_failed_from_qrels(
|
| 217 |
+
qrels: Dict[str, Dict[str, int]], failed_ids: set
|
| 218 |
+
) -> Tuple[Dict[str, Dict[str, int]], int]:
|
| 219 |
removed = 0
|
| 220 |
if not failed_ids:
|
| 221 |
return qrels, 0
|
|
|
|
| 231 |
return out, removed
|
| 232 |
|
| 233 |
|
| 234 |
+
def _filter_failed_ids_to_missing(
|
| 235 |
+
*,
|
| 236 |
+
qdrant_client,
|
| 237 |
+
collection_name: str,
|
| 238 |
+
failed_ids: set,
|
| 239 |
+
timeout: int,
|
| 240 |
+
batch_size: int = 128,
|
| 241 |
+
) -> set:
|
| 242 |
+
"""
|
| 243 |
+
Failure logs are append-only and can contain historical IDs that may have been
|
| 244 |
+
successfully retried later. To avoid poisoning evaluation, keep only IDs that
|
| 245 |
+
are *still missing* in Qdrant.
|
| 246 |
+
"""
|
| 247 |
+
failed_ids = set(str(x) for x in (failed_ids or set()) if x)
|
| 248 |
+
if not failed_ids:
|
| 249 |
+
return set()
|
| 250 |
+
|
| 251 |
+
missing = set()
|
| 252 |
+
ids_list = list(failed_ids)
|
| 253 |
+
for i in range(0, len(ids_list), int(batch_size)):
|
| 254 |
+
chunk = ids_list[i : i + int(batch_size)]
|
| 255 |
+
try:
|
| 256 |
+
recs = qdrant_client.retrieve(
|
| 257 |
+
collection_name=str(collection_name),
|
| 258 |
+
ids=chunk,
|
| 259 |
+
with_payload=False,
|
| 260 |
+
with_vectors=False,
|
| 261 |
+
timeout=int(timeout),
|
| 262 |
+
)
|
| 263 |
+
present = set(str(r.id) for r in (recs or []))
|
| 264 |
+
for cid in chunk:
|
| 265 |
+
if str(cid) not in present:
|
| 266 |
+
missing.add(str(cid))
|
| 267 |
+
except Exception:
|
| 268 |
+
# If retrieve fails (e.g. transient network), be conservative: treat as missing.
|
| 269 |
+
missing.update(str(cid) for cid in chunk)
|
| 270 |
+
return missing
|
| 271 |
+
|
| 272 |
+
|
| 273 |
def _evaluate(
|
| 274 |
*,
|
| 275 |
queries,
|
|
|
|
| 330 |
retrieve_k = max(100, top_k)
|
| 331 |
|
| 332 |
query_texts = [q.text for q in queries]
|
| 333 |
+
embed_started_at = time.time()
|
| 334 |
+
print(f"📝 Embedding {len(query_texts)} queries…")
|
| 335 |
+
sys.stdout.flush()
|
| 336 |
+
# Always try to show a progress bar during query embedding.
|
| 337 |
+
# If the installed VisualEmbedder version doesn't support show_progress, fall back gracefully.
|
| 338 |
+
try:
|
| 339 |
+
query_embeddings = embedder.embed_queries(
|
| 340 |
+
query_texts,
|
| 341 |
+
batch_size=getattr(embedder, "batch_size", None),
|
| 342 |
+
show_progress=True,
|
| 343 |
+
)
|
| 344 |
+
except TypeError:
|
| 345 |
+
query_embeddings = embedder.embed_queries(
|
| 346 |
+
query_texts,
|
| 347 |
+
batch_size=getattr(embedder, "batch_size", None),
|
| 348 |
+
)
|
| 349 |
+
embed_s = float(max(time.time() - embed_started_at, 0.0))
|
| 350 |
+
print(f"✅ Embedded queries in {embed_s:.2f}s")
|
| 351 |
+
sys.stdout.flush()
|
| 352 |
|
| 353 |
iterator = queries
|
| 354 |
try:
|
|
|
|
| 365 |
except ImportError:
|
| 366 |
torch = None
|
| 367 |
if torch is not None and isinstance(qemb, torch.Tensor):
|
| 368 |
+
# Keep evaluation stable across dtypes/devices:
|
| 369 |
+
# - numpy doesn't support bfloat16
|
| 370 |
+
# - float16 queries can cause large quality drops on some backends
|
| 371 |
+
qemb_np = qemb.detach().float().cpu().numpy()
|
| 372 |
else:
|
| 373 |
qemb_np = qemb.numpy()
|
| 374 |
|
|
|
|
| 425 |
}
|
| 426 |
|
| 427 |
|
| 428 |
+
def _detect_collection_vector_dtype(*, client, collection_name: str) -> Optional[str]:
|
| 429 |
+
"""
|
| 430 |
+
Best-effort detection of the stored vector datatype for a Qdrant collection.
|
| 431 |
+
|
| 432 |
+
Returns:
|
| 433 |
+
"float16", "float32", or None if unavailable.
|
| 434 |
+
"""
|
| 435 |
+
try:
|
| 436 |
+
info = client.get_collection(str(collection_name))
|
| 437 |
+
except Exception:
|
| 438 |
+
return None
|
| 439 |
+
|
| 440 |
+
try:
|
| 441 |
+
vectors = info.config.params.vectors or {}
|
| 442 |
+
except Exception:
|
| 443 |
+
vectors = {}
|
| 444 |
+
|
| 445 |
+
vp = None
|
| 446 |
+
if isinstance(vectors, dict):
|
| 447 |
+
vp = vectors.get("initial") or (next(iter(vectors.values())) if vectors else None)
|
| 448 |
+
if vp is None:
|
| 449 |
+
return None
|
| 450 |
+
|
| 451 |
+
dt = getattr(vp, "datatype", None)
|
| 452 |
+
if dt is None:
|
| 453 |
+
return None
|
| 454 |
+
|
| 455 |
+
s = str(dt).lower()
|
| 456 |
+
if "float16" in s:
|
| 457 |
+
return "float16"
|
| 458 |
+
if "float32" in s:
|
| 459 |
+
return "float32"
|
| 460 |
+
return None
|
| 461 |
+
|
| 462 |
+
|
| 463 |
def _write_json_atomic(path: Path, data: Dict[str, Any]) -> None:
|
| 464 |
path.parent.mkdir(parents=True, exist_ok=True)
|
| 465 |
fd, tmp_path = tempfile.mkstemp(prefix=path.name + ".", dir=str(path.parent))
|
|
|
|
| 514 |
crop_empty_remove_page_number: bool,
|
| 515 |
crop_empty_preserve_border_px: int,
|
| 516 |
crop_empty_uniform_std_threshold: float,
|
| 517 |
+
max_mean_pool_vectors: Optional[int],
|
| 518 |
no_cloudinary: bool,
|
| 519 |
cloudinary_folder: str,
|
| 520 |
retry_failures: bool,
|
| 521 |
only_failures: bool,
|
| 522 |
) -> None:
|
| 523 |
+
qdrant_url = (
|
| 524 |
+
os.getenv("SIGIR_QDRANT_URL") or os.getenv("DEST_QDRANT_URL") or os.getenv("QDRANT_URL")
|
| 525 |
+
)
|
| 526 |
if not qdrant_url:
|
| 527 |
raise ValueError("QDRANT_URL not set")
|
| 528 |
qdrant_api_key = (
|
|
|
|
| 555 |
cloudinary_uploader = None
|
| 556 |
|
| 557 |
failure_log = _failed_log_path(collection_name=collection_name, dataset_name=dataset_name)
|
| 558 |
+
failed_ids = _load_failed_union_ids(
|
| 559 |
+
failure_log, dataset_name=dataset_name, union_namespace=union_namespace
|
| 560 |
+
)
|
| 561 |
previously_failed_ids = set(failed_ids)
|
| 562 |
|
| 563 |
existing_ids = set()
|
|
|
|
| 624 |
out = img.copy()
|
| 625 |
out.thumbnail((1024, 1024), Image.BICUBIC)
|
| 626 |
return out
|
| 627 |
+
|
| 628 |
uploaded_docs = 0
|
| 629 |
skipped_docs = 0
|
| 630 |
start_time = time.time()
|
|
|
|
| 641 |
pass
|
| 642 |
|
| 643 |
import threading
|
| 644 |
+
from concurrent.futures import FIRST_EXCEPTION, ThreadPoolExecutor
|
| 645 |
+
from concurrent.futures import wait as futures_wait
|
| 646 |
|
| 647 |
stop_event = threading.Event()
|
| 648 |
+
executor = (
|
| 649 |
+
ThreadPoolExecutor(max_workers=int(upload_workers))
|
| 650 |
+
if upload_workers and upload_workers > 0
|
| 651 |
+
else None
|
| 652 |
+
)
|
| 653 |
futures = []
|
| 654 |
|
| 655 |
def _upload(points: List[Dict[str, Any]]) -> int:
|
| 656 |
+
uploaded = int(
|
| 657 |
+
indexer.upload_batch(
|
| 658 |
+
points, delay_between_batches=0.0, wait=upsert_wait, stop_event=stop_event
|
| 659 |
+
)
|
| 660 |
+
or 0
|
| 661 |
+
)
|
| 662 |
if uploaded <= 0 and points:
|
| 663 |
for p in points:
|
| 664 |
pid = str(p.get("id") or "")
|
|
|
|
| 669 |
"dataset": dataset_name,
|
| 670 |
"collection": collection_name,
|
| 671 |
"model": model_name,
|
| 672 |
+
"source_doc_id": str(
|
| 673 |
+
(p.get("metadata") or {}).get("source_doc_id") or ""
|
| 674 |
+
),
|
| 675 |
"doc_id": str((p.get("metadata") or {}).get("doc_id") or ""),
|
| 676 |
"union_doc_id": pid,
|
| 677 |
"error": "Qdrant upsert failed (all retries exhausted)",
|
|
|
|
| 749 |
continue
|
| 750 |
|
| 751 |
if crop_empty:
|
| 752 |
+
from visual_rag.preprocessing.crop_empty import CropEmptyConfig
|
| 753 |
+
from visual_rag.preprocessing.crop_empty import crop_empty as _crop_empty
|
| 754 |
|
| 755 |
crop_cfg = CropEmptyConfig(
|
| 756 |
percentage_to_remove=float(crop_empty_percentage_to_remove),
|
|
|
|
| 778 |
return_token_info=True,
|
| 779 |
show_progress=False,
|
| 780 |
)
|
| 781 |
+
except Exception:
|
| 782 |
# Retry per-doc to isolate flaky backend / corrupted sample issues.
|
| 783 |
embeddings = []
|
| 784 |
token_infos = []
|
|
|
|
| 793 |
embeddings.append(e1[0])
|
| 794 |
token_infos.append(t1[0])
|
| 795 |
except Exception as e_single:
|
| 796 |
+
source_doc_id_i = str(
|
| 797 |
+
(doc_i.payload or {}).get("source_doc_id") or doc_i.doc_id
|
| 798 |
+
)
|
| 799 |
union_doc_id_i = _union_point_id(
|
| 800 |
dataset_name=dataset_name,
|
| 801 |
source_doc_id=source_doc_id_i,
|
|
|
|
| 824 |
for doc, emb, token_info, crop_meta, original_img, embed_img in zip(
|
| 825 |
batch, embeddings, token_infos, crop_metas, original_images, images
|
| 826 |
):
|
| 827 |
+
source_doc_id = str((doc.payload or {}).get("source_doc_id") or doc.doc_id)
|
| 828 |
+
union_doc_id = _union_point_id(
|
| 829 |
+
dataset_name=dataset_name,
|
| 830 |
+
source_doc_id=source_doc_id,
|
| 831 |
+
union_namespace=union_namespace,
|
| 832 |
+
)
|
| 833 |
+
|
| 834 |
try:
|
| 835 |
+
emb_np = (
|
| 836 |
+
emb.cpu().float().numpy()
|
| 837 |
+
if hasattr(emb, "cpu")
|
| 838 |
+
else np.array(emb, dtype=np.float32)
|
| 839 |
+
)
|
| 840 |
+
visual_indices = token_info.get("visual_token_indices") or list(
|
| 841 |
+
range(emb_np.shape[0])
|
| 842 |
+
)
|
| 843 |
visual_embedding = emb_np[visual_indices].astype(np.float32)
|
| 844 |
+
tile_pooled = embedder.mean_pool_visual_embedding(
|
| 845 |
+
visual_embedding, token_info, target_vectors=max_mean_pool_vectors
|
| 846 |
+
)
|
| 847 |
experimental_pooled = embedder.experimental_pool_visual_embedding(
|
| 848 |
+
visual_embedding,
|
| 849 |
+
token_info,
|
| 850 |
+
target_vectors=max_mean_pool_vectors,
|
| 851 |
+
mean_pool=tile_pooled,
|
| 852 |
)
|
| 853 |
global_pooled = embedder.global_pool_from_mean_pool(tile_pooled)
|
| 854 |
+
|
| 855 |
+
# Log whenever ColQwen2.5 adaptive mean pooling actually downsamples rows.
|
| 856 |
+
model_lower = (model_name or "").lower()
|
| 857 |
+
is_colqwen25 = "colqwen2.5" in model_lower or "colqwen2_5" in model_lower
|
| 858 |
+
if is_colqwen25:
|
| 859 |
+
grid_h_eff = (token_info or {}).get("grid_h_eff")
|
| 860 |
+
if grid_h_eff is not None:
|
| 861 |
+
try:
|
| 862 |
+
h_eff = int(grid_h_eff)
|
| 863 |
+
out_rows = int(getattr(tile_pooled, "shape", [0])[0])
|
| 864 |
+
except Exception:
|
| 865 |
+
h_eff = 0
|
| 866 |
+
out_rows = 0
|
| 867 |
+
if h_eff > 0 and out_rows > 0 and out_rows < h_eff:
|
| 868 |
+
msg = (
|
| 869 |
+
"Downsampled ColQwen mean-pool rows for "
|
| 870 |
+
f"union_doc_id={union_doc_id} (source_doc_id={source_doc_id}): "
|
| 871 |
+
f"grid_h_eff={h_eff} -> {out_rows} "
|
| 872 |
+
f"(--max-mean-pool-vectors={max_mean_pool_vectors})"
|
| 873 |
+
)
|
| 874 |
+
if pbar is not None:
|
| 875 |
+
try:
|
| 876 |
+
pbar.write(msg)
|
| 877 |
+
except Exception:
|
| 878 |
+
print(msg)
|
| 879 |
+
else:
|
| 880 |
+
print(msg)
|
| 881 |
except Exception as e_single:
|
| 882 |
+
if str(union_doc_id) not in failed_ids:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 883 |
_append_jsonl(
|
| 884 |
failure_log,
|
| 885 |
{
|
| 886 |
"dataset": dataset_name,
|
| 887 |
"collection": collection_name,
|
| 888 |
"model": model_name,
|
| 889 |
+
"source_doc_id": str(source_doc_id),
|
| 890 |
"doc_id": str(getattr(doc, "doc_id", "")),
|
| 891 |
+
"union_doc_id": str(union_doc_id),
|
| 892 |
"error": str(e_single),
|
| 893 |
},
|
| 894 |
)
|
| 895 |
+
failed_ids.add(str(union_doc_id))
|
| 896 |
+
existing_ids.add(str(union_doc_id))
|
| 897 |
skipped_docs += 1
|
| 898 |
continue
|
| 899 |
|
| 900 |
num_tiles = int(tile_pooled.shape[0])
|
| 901 |
+
patches_per_tile = (
|
| 902 |
+
int(visual_embedding.shape[0] // max(num_tiles, 1)) if num_tiles else 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 903 |
)
|
| 904 |
|
| 905 |
resized_img = _resized_for_display(embed_img) or embed_img
|
| 906 |
original_url = ""
|
| 907 |
cropped_url = ""
|
| 908 |
resized_url = ""
|
| 909 |
+
if (
|
| 910 |
+
cloudinary_uploader is not None
|
| 911 |
+
and original_img is not None
|
| 912 |
+
and resized_img is not None
|
| 913 |
+
):
|
| 914 |
base_public_id = _safe_public_id(f"{dataset_name}__{union_doc_id}")
|
| 915 |
try:
|
| 916 |
if crop_empty:
|
| 917 |
+
o_url, c_url, r_url = (
|
| 918 |
+
cloudinary_uploader.upload_original_cropped_and_resized(
|
| 919 |
+
original_img,
|
| 920 |
+
(
|
| 921 |
+
embed_img
|
| 922 |
+
if embed_img is not None and embed_img is not original_img
|
| 923 |
+
else None
|
| 924 |
+
),
|
| 925 |
+
resized_img,
|
| 926 |
+
base_public_id,
|
| 927 |
+
)
|
| 928 |
)
|
| 929 |
original_url = o_url or ""
|
| 930 |
cropped_url = c_url or ""
|
|
|
|
| 946 |
"union_doc_id": union_doc_id,
|
| 947 |
"page": resized_url or original_url or "",
|
| 948 |
"original_url": original_url,
|
| 949 |
+
"cropped_url": cropped_url if crop_empty else "",
|
| 950 |
"resized_url": resized_url,
|
| 951 |
"original_width": int(original_img.width) if original_img is not None else None,
|
| 952 |
+
"original_height": (
|
| 953 |
+
int(original_img.height) if original_img is not None else None
|
| 954 |
+
),
|
| 955 |
+
"cropped_width": (
|
| 956 |
+
int(embed_img.width) if (crop_empty and embed_img is not None) else None
|
| 957 |
+
),
|
| 958 |
+
"cropped_height": (
|
| 959 |
+
int(embed_img.height) if (crop_empty and embed_img is not None) else None
|
| 960 |
+
),
|
| 961 |
"resized_width": int(resized_img.width) if resized_img is not None else None,
|
| 962 |
"resized_height": int(resized_img.height) if resized_img is not None else None,
|
| 963 |
"num_tiles": int(num_tiles),
|
|
|
|
| 965 |
"torch_dtype": _torch_dtype_to_str(embedder.torch_dtype),
|
| 966 |
"model_name": model_name,
|
| 967 |
"crop_empty_enabled": bool(crop_empty),
|
| 968 |
+
"crop_empty_crop_box": (
|
| 969 |
+
(crop_meta or {}).get("crop_box") if crop_empty else None
|
| 970 |
+
),
|
| 971 |
+
"crop_empty_remove_page_number": (
|
| 972 |
+
bool(crop_empty_remove_page_number) if crop_empty else None
|
| 973 |
+
),
|
| 974 |
+
"crop_empty_percentage_to_remove": (
|
| 975 |
+
float(crop_empty_percentage_to_remove) if crop_empty else None
|
| 976 |
+
),
|
| 977 |
"index_recovery_previously_failed": bool(union_doc_id in previously_failed_ids),
|
| 978 |
"index_recovery_mode": (
|
| 979 |
+
"only_failures"
|
| 980 |
+
if bool(only_failures)
|
| 981 |
+
else ("retry_failures" if bool(retry_failures) else None)
|
| 982 |
),
|
| 983 |
"index_recovery_pooling_inferred_tiles": bool(
|
| 984 |
+
(token_info or {}).get("num_tiles") is None
|
| 985 |
+
and (token_info or {}).get("n_rows") is None
|
| 986 |
+
and (token_info or {}).get("n_cols") is None
|
| 987 |
),
|
| 988 |
"index_recovery_num_visual_tokens": int(visual_embedding.shape[0]),
|
| 989 |
**(doc.payload or {}),
|
|
|
|
| 1074 |
parser.add_argument(
|
| 1075 |
"--qdrant-vector-dtype",
|
| 1076 |
type=str,
|
| 1077 |
+
default="auto",
|
| 1078 |
+
choices=["auto", "float16", "float32"],
|
| 1079 |
)
|
| 1080 |
grpc_group = parser.add_mutually_exclusive_group()
|
| 1081 |
grpc_group.add_argument("--prefer-grpc", dest="prefer_grpc", action="store_true", default=True)
|
|
|
|
| 1090 |
parser.add_argument("--upsert-wait", action="store_true")
|
| 1091 |
parser.add_argument("--max-corpus-docs", type=int, default=0)
|
| 1092 |
parser.add_argument("--sample-corpus-docs", type=int, default=0)
|
| 1093 |
+
parser.add_argument(
|
| 1094 |
+
"--sample-corpus-strategy", type=str, default="first", choices=["first", "random"]
|
| 1095 |
+
)
|
| 1096 |
parser.add_argument("--sample-seed", type=int, default=0)
|
| 1097 |
parser.add_argument("--sample-queries", type=int, default=0)
|
| 1098 |
+
parser.add_argument(
|
| 1099 |
+
"--sample-query-strategy", type=str, default="first", choices=["first", "random"]
|
| 1100 |
+
)
|
| 1101 |
parser.add_argument("--sample-query-seed", type=int, default=0)
|
| 1102 |
parser.add_argument("--index-from-queries", action="store_true", default=False)
|
| 1103 |
parser.add_argument("--resume", action="store_true", default=False)
|
|
|
|
| 1109 |
parser.add_argument("--crop-empty-remove-page-number", action="store_true", default=False)
|
| 1110 |
parser.add_argument("--crop-empty-preserve-border-px", type=int, default=1)
|
| 1111 |
parser.add_argument("--crop-empty-uniform-std-threshold", type=float, default=0.0)
|
| 1112 |
+
parser.add_argument(
|
| 1113 |
+
"--max-mean-pool-vectors",
|
| 1114 |
+
type=int,
|
| 1115 |
+
default=None,
|
| 1116 |
+
help=(
|
| 1117 |
+
"Cap ColQwen2.5 adaptive row-mean pooling to at most this many vectors. "
|
| 1118 |
+
"If omitted (default), no cap is applied (use all effective rows). "
|
| 1119 |
+
"If <= 0, treated as no cap."
|
| 1120 |
+
),
|
| 1121 |
+
)
|
| 1122 |
payload_group = parser.add_mutually_exclusive_group()
|
| 1123 |
payload_group.add_argument("--index-common-metadata", action="store_true", default=True)
|
| 1124 |
+
payload_group.add_argument(
|
| 1125 |
+
"--no-index-common-metadata", dest="index_common_metadata", action="store_false"
|
| 1126 |
+
)
|
| 1127 |
parser.add_argument("--payload-index", action="append", default=[])
|
| 1128 |
+
cloud_group = parser.add_mutually_exclusive_group()
|
| 1129 |
+
cloud_group.add_argument(
|
| 1130 |
+
"--cloudinary",
|
| 1131 |
+
dest="no_cloudinary",
|
| 1132 |
+
action="store_false",
|
| 1133 |
+
default=True,
|
| 1134 |
+
help="Enable Cloudinary uploads during indexing (default: disabled).",
|
| 1135 |
+
)
|
| 1136 |
+
cloud_group.add_argument(
|
| 1137 |
"--no-cloudinary",
|
| 1138 |
+
dest="no_cloudinary",
|
| 1139 |
action="store_true",
|
| 1140 |
+
help="Disable Cloudinary uploads during indexing (default).",
|
| 1141 |
)
|
| 1142 |
parser.add_argument(
|
| 1143 |
"--cloudinary-folder",
|
|
|
|
| 1158 |
help="Index only documents listed in index_failures__<collection>__<dataset>.jsonl.",
|
| 1159 |
)
|
| 1160 |
|
| 1161 |
+
parser.add_argument(
|
| 1162 |
+
"--top-k",
|
| 1163 |
+
type=int,
|
| 1164 |
+
default=100,
|
| 1165 |
+
help="Retrieve top-k results (default: 100 to calculate metrics at all cutoffs)",
|
| 1166 |
+
)
|
| 1167 |
+
parser.add_argument(
|
| 1168 |
+
"--prefetch-k",
|
| 1169 |
+
type=int,
|
| 1170 |
+
default=200,
|
| 1171 |
+
help="Prefetch candidates for two-stage (default: 200)",
|
| 1172 |
+
)
|
| 1173 |
parser.add_argument(
|
| 1174 |
"--no-eval",
|
| 1175 |
action="store_true",
|
|
|
|
| 1185 |
parser.add_argument(
|
| 1186 |
"--stage1-mode",
|
| 1187 |
type=str,
|
| 1188 |
+
default="tokens_vs_standard_pooling",
|
| 1189 |
choices=[
|
| 1190 |
+
# New naming (preferred)
|
| 1191 |
+
"pooled_query_vs_standard_pooling",
|
| 1192 |
+
"tokens_vs_standard_pooling",
|
| 1193 |
+
"pooled_query_vs_experimental_pooling",
|
| 1194 |
+
"tokens_vs_experimental_pooling",
|
| 1195 |
+
"pooled_query_vs_global",
|
| 1196 |
+
# Backwards-compatible aliases (deprecated)
|
| 1197 |
"pooled_query_vs_tiles",
|
| 1198 |
"tokens_vs_tiles",
|
| 1199 |
"pooled_query_vs_experimental",
|
| 1200 |
"tokens_vs_experimental",
|
|
|
|
| 1201 |
],
|
| 1202 |
+
help=(
|
| 1203 |
+
"Two-stage stage1 prefetch mode. "
|
| 1204 |
+
"standard_pooling uses Qdrant named vector 'mean_pooling'. "
|
| 1205 |
+
"experimental_pooling uses Qdrant named vector 'experimental_pooling'. "
|
| 1206 |
+
"global uses Qdrant named vector 'global_pooling'."
|
| 1207 |
+
),
|
| 1208 |
+
)
|
| 1209 |
+
parser.add_argument(
|
| 1210 |
+
"--stage1-k", type=int, default=1000, help="Three-stage stage1 top_k (default: 1000)"
|
| 1211 |
+
)
|
| 1212 |
+
parser.add_argument(
|
| 1213 |
+
"--stage2-k", type=int, default=300, help="Three-stage stage2 top_k (default: 300)"
|
| 1214 |
)
|
|
|
|
|
|
|
| 1215 |
parser.add_argument("--max-queries", type=int, default=0)
|
| 1216 |
drop_group = parser.add_mutually_exclusive_group()
|
| 1217 |
+
drop_group.add_argument(
|
| 1218 |
+
"--drop-empty-queries", dest="drop_empty_queries", action="store_true", default=True
|
| 1219 |
+
)
|
| 1220 |
+
drop_group.add_argument(
|
| 1221 |
+
"--no-drop-empty-queries", dest="drop_empty_queries", action="store_false"
|
| 1222 |
+
)
|
| 1223 |
parser.add_argument(
|
| 1224 |
"--evaluation-scope",
|
| 1225 |
type=str,
|
|
|
|
| 1243 |
help="Stop the run immediately on the first dataset evaluation failure.",
|
| 1244 |
)
|
| 1245 |
parser.add_argument("--output", type=str, default="auto")
|
| 1246 |
+
parser.add_argument(
|
| 1247 |
+
"--ensure-in-ram",
|
| 1248 |
+
dest="ensure_in_ram",
|
| 1249 |
+
action="store_true",
|
| 1250 |
+
default=False,
|
| 1251 |
+
help="Best-effort: patch collection config so vectors/indexes are stored in RAM (on_disk=false).",
|
| 1252 |
+
)
|
| 1253 |
|
| 1254 |
args = parser.parse_args()
|
| 1255 |
|
| 1256 |
+
# Backwards-compatible stage1_mode mapping (deprecated names)
|
| 1257 |
+
stage1_map = {
|
| 1258 |
+
"pooled_query_vs_tiles": "pooled_query_vs_standard_pooling",
|
| 1259 |
+
"tokens_vs_tiles": "tokens_vs_standard_pooling",
|
| 1260 |
+
"pooled_query_vs_experimental": "pooled_query_vs_experimental_pooling",
|
| 1261 |
+
"tokens_vs_experimental": "tokens_vs_experimental_pooling",
|
| 1262 |
+
}
|
| 1263 |
+
if str(args.stage1_mode) in stage1_map:
|
| 1264 |
+
old = str(args.stage1_mode)
|
| 1265 |
+
new = stage1_map[old]
|
| 1266 |
+
warnings.warn(
|
| 1267 |
+
f"--stage1-mode {old} is deprecated; use {new} instead.",
|
| 1268 |
+
category=DeprecationWarning,
|
| 1269 |
+
stacklevel=2,
|
| 1270 |
+
)
|
| 1271 |
+
args.stage1_mode = new
|
| 1272 |
+
|
| 1273 |
_maybe_load_dotenv()
|
| 1274 |
|
| 1275 |
if args.recreate:
|
| 1276 |
args.index = True
|
| 1277 |
|
| 1278 |
+
if (
|
| 1279 |
+
args.sample_corpus_docs
|
| 1280 |
+
and int(args.sample_corpus_docs) > 0
|
| 1281 |
+
and args.max_corpus_docs
|
| 1282 |
+
and int(args.max_corpus_docs) > 0
|
| 1283 |
+
):
|
| 1284 |
raise ValueError("Use only one of --sample-corpus-docs or --max-corpus-docs (not both).")
|
| 1285 |
if args.sample_queries and int(args.sample_queries) > 0 and args.index_from_queries:
|
| 1286 |
+
if (args.sample_corpus_docs and int(args.sample_corpus_docs) > 0) or (
|
| 1287 |
+
args.max_corpus_docs and int(args.max_corpus_docs) > 0
|
| 1288 |
+
):
|
| 1289 |
+
raise ValueError(
|
| 1290 |
+
"Use --index-from-queries with --sample-queries only (do not combine with corpus sampling)."
|
| 1291 |
+
)
|
| 1292 |
|
| 1293 |
if args.upsert_wait:
|
| 1294 |
print("Qdrant upserts wait for completion (wait=True).")
|
| 1295 |
else:
|
| 1296 |
print("Qdrant upserts are async (wait=False).")
|
| 1297 |
+
print(
|
| 1298 |
+
f"Qdrant request timeout: {int(args.qdrant_timeout)}s, retries: {int(args.qdrant_retries)}."
|
| 1299 |
+
)
|
| 1300 |
|
| 1301 |
datasets: List[str] = []
|
| 1302 |
if args.datasets:
|
|
|
|
| 1314 |
corpus, queries, qrels = load_vidore_beir_dataset(ds_name)
|
| 1315 |
loaded.append((ds_name, corpus, queries, qrels))
|
| 1316 |
|
| 1317 |
+
# Resolve the dtype used for query embeddings:
|
| 1318 |
+
# - If user sets float16/float32 explicitly, respect it.
|
| 1319 |
+
# - If auto: try to detect from the existing Qdrant collection (prevents silent score drops),
|
| 1320 |
+
# otherwise fall back to float16 (preserves legacy default for new collections).
|
| 1321 |
+
effective_qdrant_vector_dtype = str(args.qdrant_vector_dtype)
|
| 1322 |
+
if effective_qdrant_vector_dtype == "auto":
|
| 1323 |
+
qdrant_url = (
|
| 1324 |
+
os.getenv("SIGIR_QDRANT_URL") or os.getenv("DEST_QDRANT_URL") or os.getenv("QDRANT_URL")
|
| 1325 |
+
)
|
| 1326 |
+
qdrant_api_key = (
|
| 1327 |
+
os.getenv("SIGIR_QDRANT_KEY")
|
| 1328 |
+
or os.getenv("SIGIR_QDRANT_API_KEY")
|
| 1329 |
+
or os.getenv("DEST_QDRANT_API_KEY")
|
| 1330 |
+
or os.getenv("QDRANT_API_KEY")
|
| 1331 |
+
)
|
| 1332 |
+
detected = None
|
| 1333 |
+
if qdrant_url:
|
| 1334 |
+
try:
|
| 1335 |
+
from qdrant_client import QdrantClient
|
| 1336 |
+
|
| 1337 |
+
client = QdrantClient(
|
| 1338 |
+
url=qdrant_url,
|
| 1339 |
+
api_key=qdrant_api_key,
|
| 1340 |
+
prefer_grpc=bool(args.prefer_grpc),
|
| 1341 |
+
timeout=int(args.qdrant_timeout),
|
| 1342 |
+
check_compatibility=False,
|
| 1343 |
+
)
|
| 1344 |
+
detected = _detect_collection_vector_dtype(
|
| 1345 |
+
client=client, collection_name=str(args.collection)
|
| 1346 |
+
)
|
| 1347 |
+
except Exception:
|
| 1348 |
+
detected = None
|
| 1349 |
+
effective_qdrant_vector_dtype = detected or "float16"
|
| 1350 |
+
if detected:
|
| 1351 |
+
print(f"🔎 Detected Qdrant vector dtype for collection: {detected}")
|
| 1352 |
+
else:
|
| 1353 |
+
print("🔎 Could not detect Qdrant vector dtype; defaulting to float16")
|
| 1354 |
+
sys.stdout.flush()
|
| 1355 |
+
|
| 1356 |
+
output_dtype = np.float16 if effective_qdrant_vector_dtype == "float16" else np.float32
|
| 1357 |
embedder = VisualEmbedder(
|
| 1358 |
model_name=args.model,
|
| 1359 |
batch_size=args.batch_size,
|
|
|
|
| 1414 |
{"field": "original_height", "type": "integer"},
|
| 1415 |
{"field": "resized_width", "type": "integer"},
|
| 1416 |
{"field": "resized_height", "type": "integer"},
|
|
|
|
|
|
|
|
|
|
| 1417 |
{"field": "num_tiles", "type": "integer"},
|
| 1418 |
{"field": "tile_rows", "type": "integer"},
|
| 1419 |
{"field": "tile_cols", "type": "integer"},
|
|
|
|
| 1424 |
{"field": "source", "type": "keyword"},
|
| 1425 |
]
|
| 1426 |
)
|
| 1427 |
+
# Keep schema minimal: only add crop-related indexes when cropping is enabled.
|
| 1428 |
+
if bool(args.crop_empty):
|
| 1429 |
+
payload_indexes.extend(
|
| 1430 |
+
[
|
| 1431 |
+
{"field": "crop_empty_enabled", "type": "bool"},
|
| 1432 |
+
{"field": "crop_empty_remove_page_number", "type": "bool"},
|
| 1433 |
+
{"field": "crop_empty_percentage_to_remove", "type": "float"},
|
| 1434 |
+
{"field": "cropped_url", "type": "keyword"},
|
| 1435 |
+
{"field": "cropped_width", "type": "integer"},
|
| 1436 |
+
{"field": "cropped_height", "type": "integer"},
|
| 1437 |
+
]
|
| 1438 |
+
)
|
| 1439 |
+
# If we are recreating the collection, clear historical failure logs so they don't
|
| 1440 |
+
# remove valid qrels during evaluation.
|
| 1441 |
+
if bool(args.recreate):
|
| 1442 |
+
for ds_name, _corpus, _queries, _qrels in selected:
|
| 1443 |
+
p = _failed_log_path(collection_name=args.collection, dataset_name=ds_name)
|
| 1444 |
+
try:
|
| 1445 |
+
if p.exists():
|
| 1446 |
+
p.unlink()
|
| 1447 |
+
except Exception:
|
| 1448 |
+
pass
|
| 1449 |
for i, (ds_name, corpus, queries, _qrels) in enumerate(selected):
|
| 1450 |
print(f"Indexing {ds_name}: corpus_docs={len(corpus)} queries={len(queries)}")
|
| 1451 |
_index_beir_corpus(
|
|
|
|
| 1454 |
embedder=embedder,
|
| 1455 |
collection_name=args.collection,
|
| 1456 |
prefer_grpc=args.prefer_grpc,
|
| 1457 |
+
qdrant_vector_dtype=effective_qdrant_vector_dtype,
|
| 1458 |
recreate=bool(args.recreate and i == 0),
|
| 1459 |
indexing_threshold=args.indexing_threshold,
|
| 1460 |
batch_size=args.batch_size,
|
|
|
|
| 1476 |
crop_empty_remove_page_number=bool(args.crop_empty_remove_page_number),
|
| 1477 |
crop_empty_preserve_border_px=int(args.crop_empty_preserve_border_px),
|
| 1478 |
crop_empty_uniform_std_threshold=float(args.crop_empty_uniform_std_threshold),
|
| 1479 |
+
max_mean_pool_vectors=args.max_mean_pool_vectors,
|
| 1480 |
no_cloudinary=bool(args.no_cloudinary),
|
| 1481 |
cloudinary_folder=str(args.cloudinary_folder),
|
| 1482 |
retry_failures=bool(args.retry_failures),
|
|
|
|
| 1489 |
dataset_index_failures: Dict[str, Dict[str, Any]] = {}
|
| 1490 |
dataset_counts: Dict[str, Dict[str, int]] = {}
|
| 1491 |
for ds_name, corpus, queries, _qrels in selected:
|
| 1492 |
+
dataset_counts[ds_name] = {
|
| 1493 |
+
"corpus_docs": int(len(corpus)),
|
| 1494 |
+
"queries": int(len(queries)),
|
| 1495 |
+
"queries_eval": 0,
|
| 1496 |
+
}
|
| 1497 |
failed_path = _failed_log_path(collection_name=args.collection, dataset_name=ds_name)
|
| 1498 |
+
failed_ids = _load_failed_union_ids(
|
| 1499 |
+
failed_path, dataset_name=ds_name, union_namespace=args.collection
|
| 1500 |
+
)
|
| 1501 |
dataset_index_failures[ds_name] = {
|
| 1502 |
"failed_log_path": str(failed_path),
|
| 1503 |
"failed_ids_count": int(len(failed_ids)),
|
|
|
|
| 1513 |
"collection": args.collection,
|
| 1514 |
"model": args.model,
|
| 1515 |
"torch_dtype": _torch_dtype_to_str(embedder.torch_dtype),
|
| 1516 |
+
"qdrant_vector_dtype": effective_qdrant_vector_dtype,
|
| 1517 |
"mode": args.mode,
|
| 1518 |
"stage1_mode": args.stage1_mode if args.mode == "two_stage" else None,
|
| 1519 |
"prefetch_k": args.prefetch_k if args.mode == "two_stage" else None,
|
|
|
|
| 1535 |
print(f"Wrote index-only report: {out_path}")
|
| 1536 |
return
|
| 1537 |
|
| 1538 |
+
if bool(args.ensure_in_ram):
|
| 1539 |
+
try:
|
| 1540 |
+
from visual_rag.qdrant_admin import QdrantAdmin
|
| 1541 |
+
|
| 1542 |
+
qdrant_url = (
|
| 1543 |
+
os.getenv("SIGIR_QDRANT_URL")
|
| 1544 |
+
or os.getenv("DEST_QDRANT_URL")
|
| 1545 |
+
or os.getenv("QDRANT_URL")
|
| 1546 |
+
)
|
| 1547 |
+
qdrant_api_key = (
|
| 1548 |
+
os.getenv("SIGIR_QDRANT_KEY")
|
| 1549 |
+
or os.getenv("SIGIR_QDRANT_API_KEY")
|
| 1550 |
+
or os.getenv("DEST_QDRANT_API_KEY")
|
| 1551 |
+
or os.getenv("QDRANT_API_KEY")
|
| 1552 |
+
)
|
| 1553 |
+
admin = QdrantAdmin(
|
| 1554 |
+
url=qdrant_url,
|
| 1555 |
+
api_key=qdrant_api_key,
|
| 1556 |
+
prefer_grpc=bool(args.prefer_grpc),
|
| 1557 |
+
timeout=int(args.qdrant_timeout),
|
| 1558 |
+
)
|
| 1559 |
+
print(f"🧠 Ensuring collection in RAM (config): {args.collection}")
|
| 1560 |
+
sys.stdout.flush()
|
| 1561 |
+
_ = admin.ensure_collection_all_in_ram(
|
| 1562 |
+
collection_name=str(args.collection),
|
| 1563 |
+
timeout=int(args.qdrant_timeout),
|
| 1564 |
+
)
|
| 1565 |
+
print("✅ ensure-in-ram config applied")
|
| 1566 |
+
sys.stdout.flush()
|
| 1567 |
+
except Exception as e:
|
| 1568 |
+
print(f"⚠️ ensure-in-ram failed: {type(e).__name__}: {e}")
|
| 1569 |
+
sys.stdout.flush()
|
| 1570 |
+
|
| 1571 |
retriever = MultiVectorRetriever(
|
| 1572 |
collection_name=args.collection,
|
| 1573 |
embedder=embedder,
|
| 1574 |
+
qdrant_url=os.getenv("SIGIR_QDRANT_URL")
|
| 1575 |
+
or os.getenv("DEST_QDRANT_URL")
|
| 1576 |
+
or os.getenv("QDRANT_URL"),
|
| 1577 |
qdrant_api_key=(
|
| 1578 |
os.getenv("SIGIR_QDRANT_KEY")
|
| 1579 |
or os.getenv("SIGIR_QDRANT_API_KEY")
|
|
|
|
| 1604 |
"collection": args.collection,
|
| 1605 |
"model": args.model,
|
| 1606 |
"torch_dtype": _torch_dtype_to_str(embedder.torch_dtype),
|
| 1607 |
+
"qdrant_vector_dtype": effective_qdrant_vector_dtype,
|
| 1608 |
"mode": args.mode,
|
| 1609 |
"stage1_mode": args.stage1_mode if args.mode == "two_stage" else None,
|
| 1610 |
"prefetch_k": args.prefetch_k if args.mode == "two_stage" else None,
|
|
|
|
| 1641 |
f"(corpus_docs={len(corpus)}, queries={len(queries)}) "
|
| 1642 |
f"scope={args.evaluation_scope} "
|
| 1643 |
f"mode={args.mode}"
|
| 1644 |
+
+ (
|
| 1645 |
+
f", stage1_mode={args.stage1_mode}, prefetch_k={int(args.prefetch_k)}"
|
| 1646 |
+
if args.mode == "two_stage"
|
| 1647 |
+
else ""
|
| 1648 |
+
)
|
| 1649 |
+
+ (
|
| 1650 |
+
f", stage1_k={int(args.stage1_k)}, stage2_k={int(args.stage2_k)}"
|
| 1651 |
+
if args.mode == "three_stage"
|
| 1652 |
+
else ""
|
| 1653 |
+
)
|
| 1654 |
+ f", top_k={int(args.top_k)}"
|
| 1655 |
)
|
| 1656 |
sys.stdout.flush()
|
| 1657 |
|
| 1658 |
+
dataset_counts[ds_name] = {
|
| 1659 |
+
"corpus_docs": int(len(corpus)),
|
| 1660 |
+
"queries": int(len(queries)),
|
| 1661 |
+
"queries_eval": 0,
|
| 1662 |
+
}
|
| 1663 |
id_map: Dict[str, str] = {}
|
| 1664 |
for doc in corpus:
|
| 1665 |
source_doc_id = str((doc.payload or {}).get("source_doc_id") or doc.doc_id)
|
|
|
|
| 1680 |
remapped_qrels[qid] = out_rels
|
| 1681 |
|
| 1682 |
failed_path = _failed_log_path(collection_name=args.collection, dataset_name=ds_name)
|
| 1683 |
+
failed_ids_all = _load_failed_union_ids(
|
| 1684 |
+
failed_path, dataset_name=ds_name, union_namespace=args.collection
|
| 1685 |
+
)
|
| 1686 |
+
# Only remove failed IDs that are actually missing in the current collection.
|
| 1687 |
+
failed_ids_missing = _filter_failed_ids_to_missing(
|
| 1688 |
+
qdrant_client=retriever.client,
|
| 1689 |
+
collection_name=str(args.collection),
|
| 1690 |
+
failed_ids=failed_ids_all,
|
| 1691 |
+
timeout=int(args.qdrant_timeout),
|
| 1692 |
+
)
|
| 1693 |
+
remapped_qrels, removed_rels = _remove_failed_from_qrels(remapped_qrels, failed_ids_missing)
|
| 1694 |
dataset_index_failures[ds_name] = {
|
| 1695 |
"failed_log_path": str(failed_path),
|
| 1696 |
+
"failed_ids_count": int(len(failed_ids_all)),
|
| 1697 |
+
"failed_ids_missing_count": int(len(failed_ids_missing)),
|
| 1698 |
"qrels_removed": int(removed_rels),
|
| 1699 |
}
|
| 1700 |
|
|
|
|
| 1703 |
from qdrant_client.http import models as qmodels
|
| 1704 |
|
| 1705 |
filter_obj = qmodels.Filter(
|
| 1706 |
+
must=[
|
| 1707 |
+
qmodels.FieldCondition(
|
| 1708 |
+
key="dataset", match=qmodels.MatchValue(value=str(ds_name))
|
| 1709 |
+
)
|
| 1710 |
+
]
|
| 1711 |
)
|
| 1712 |
|
| 1713 |
try:
|
|
|
|
| 1726 |
drop_empty_queries=bool(args.drop_empty_queries),
|
| 1727 |
filter_obj=filter_obj,
|
| 1728 |
)
|
| 1729 |
+
dataset_counts[ds_name]["queries_eval"] = int(
|
| 1730 |
+
metrics_by_dataset[ds_name].get("num_queries_eval", 0)
|
| 1731 |
+
)
|
| 1732 |
ds_only_out = {
|
| 1733 |
**_build_run_record(),
|
| 1734 |
"dataset": str(ds_name),
|
|
|
|
| 1760 |
|
| 1761 |
|
| 1762 |
if __name__ == "__main__":
|
| 1763 |
+
main()
|
benchmarks/vidore_tatdqa_test/__init__.py
CHANGED
|
@@ -1,6 +1 @@
|
|
| 1 |
__all__ = []
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
|
|
|
| 1 |
__all__ = []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
benchmarks/vidore_tatdqa_test/dataset_loader.py
CHANGED
|
@@ -1,8 +1,8 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
-
from dataclasses import dataclass
|
| 4 |
import hashlib
|
| 5 |
import re
|
|
|
|
| 6 |
from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple
|
| 7 |
|
| 8 |
|
|
@@ -29,6 +29,7 @@ def _stable_uuid(text: str) -> str:
|
|
| 29 |
hex_str = hashlib.sha256(text.encode("utf-8")).hexdigest()[:32]
|
| 30 |
return f"{hex_str[:8]}-{hex_str[8:12]}-{hex_str[12:16]}-{hex_str[16:20]}-{hex_str[20:32]}"
|
| 31 |
|
|
|
|
| 32 |
def paired_source_doc_id(row: Mapping[str, Any], idx: int) -> str:
|
| 33 |
source_doc_id = _as_str(row.get("_id"))
|
| 34 |
if source_doc_id:
|
|
@@ -55,7 +56,9 @@ def _normalize_qrels(qrels_rows: Iterable[Mapping[str, Any]]) -> Dict[str, Dict[
|
|
| 55 |
qrels: Dict[str, Dict[str, int]] = {}
|
| 56 |
for row in qrels_rows:
|
| 57 |
qid = _as_str(row.get("query-id") or row.get("query_id") or row.get("qid"))
|
| 58 |
-
did = _as_str(
|
|
|
|
|
|
|
| 59 |
score = row.get("score") or row.get("relevance") or row.get("label") or 0
|
| 60 |
try:
|
| 61 |
score_int = int(score)
|
|
@@ -63,6 +66,9 @@ def _normalize_qrels(qrels_rows: Iterable[Mapping[str, Any]]) -> Dict[str, Dict[
|
|
| 63 |
score_int = 0
|
| 64 |
if not qid or not did:
|
| 65 |
continue
|
|
|
|
|
|
|
|
|
|
| 66 |
qrels.setdefault(qid, {})[_stable_uuid(did)] = score_int
|
| 67 |
return qrels
|
| 68 |
|
|
@@ -70,7 +76,9 @@ def _normalize_qrels(qrels_rows: Iterable[Mapping[str, Any]]) -> Dict[str, Dict[
|
|
| 70 |
def _expect_fields(obj: Any, required: List[str], context: str) -> None:
|
| 71 |
missing = [k for k in required if k not in obj]
|
| 72 |
if missing:
|
| 73 |
-
raise ValueError(
|
|
|
|
|
|
|
| 74 |
|
| 75 |
|
| 76 |
def _extract_beir_splits(ds: Any):
|
|
@@ -194,7 +202,9 @@ def _load_beir_from_separate_configs(dataset_name: str, config_names: List[str])
|
|
| 194 |
return _first_split(corpus_ds), _first_split(queries_ds), _first_split(qrels_ds)
|
| 195 |
|
| 196 |
|
| 197 |
-
def load_vidore_beir_dataset(
|
|
|
|
|
|
|
| 198 |
try:
|
| 199 |
from datasets import load_dataset
|
| 200 |
except ImportError as e:
|
|
@@ -220,7 +230,6 @@ def load_vidore_beir_dataset(dataset_name: str) -> Tuple[List[CorpusDoc], List[Q
|
|
| 220 |
|
| 221 |
last_err: Optional[Exception] = None
|
| 222 |
extracted = None
|
| 223 |
-
used_name = None
|
| 224 |
used_configs: List[str] = []
|
| 225 |
for name_try in candidates:
|
| 226 |
config_names = _get_config_names(name_try)
|
|
@@ -241,7 +250,6 @@ def load_vidore_beir_dataset(dataset_name: str) -> Tuple[List[CorpusDoc], List[Q
|
|
| 241 |
if extracted is None:
|
| 242 |
extracted = _load_beir_from_separate_configs(name_try, config_names)
|
| 243 |
if extracted is not None:
|
| 244 |
-
used_name = name_try
|
| 245 |
break
|
| 246 |
|
| 247 |
if extracted is None:
|
|
@@ -271,7 +279,9 @@ def load_vidore_beir_dataset(dataset_name: str) -> Tuple[List[CorpusDoc], List[Q
|
|
| 271 |
doc_id = _stable_uuid(source_doc_id)
|
| 272 |
image = row.get("image") or row.get("page_image") or row.get("document") or row.get("img")
|
| 273 |
if image is None:
|
| 274 |
-
raise ValueError(
|
|
|
|
|
|
|
| 275 |
payload = {
|
| 276 |
**{
|
| 277 |
k: v
|
|
@@ -305,7 +315,9 @@ def load_vidore_beir_dataset(dataset_name: str) -> Tuple[List[CorpusDoc], List[Q
|
|
| 305 |
return corpus_docs, queries, qrels
|
| 306 |
|
| 307 |
|
| 308 |
-
def load_vidore_paired_dataset(
|
|
|
|
|
|
|
| 309 |
"""
|
| 310 |
Load ViDoRe v1-style paired QA datasets.
|
| 311 |
|
|
@@ -347,7 +359,9 @@ def load_vidore_paired_dataset(dataset_name: str) -> Tuple[List[CorpusDoc], List
|
|
| 347 |
return corpus_docs, queries, qrels
|
| 348 |
|
| 349 |
|
| 350 |
-
def load_vidore_dataset_auto(
|
|
|
|
|
|
|
| 351 |
"""
|
| 352 |
Auto-detect ViDoRe dataset format.
|
| 353 |
Returns: (corpus, queries, qrels, protocol)
|
|
@@ -359,5 +373,3 @@ def load_vidore_dataset_auto(dataset_name: str) -> Tuple[List[CorpusDoc], List[Q
|
|
| 359 |
except ValueError:
|
| 360 |
corpus, queries, qrels = load_vidore_paired_dataset(dataset_name)
|
| 361 |
return corpus, queries, qrels, "paired"
|
| 362 |
-
|
| 363 |
-
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
|
|
|
| 3 |
import hashlib
|
| 4 |
import re
|
| 5 |
+
from dataclasses import dataclass
|
| 6 |
from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple
|
| 7 |
|
| 8 |
|
|
|
|
| 29 |
hex_str = hashlib.sha256(text.encode("utf-8")).hexdigest()[:32]
|
| 30 |
return f"{hex_str[:8]}-{hex_str[8:12]}-{hex_str[12:16]}-{hex_str[16:20]}-{hex_str[20:32]}"
|
| 31 |
|
| 32 |
+
|
| 33 |
def paired_source_doc_id(row: Mapping[str, Any], idx: int) -> str:
|
| 34 |
source_doc_id = _as_str(row.get("_id"))
|
| 35 |
if source_doc_id:
|
|
|
|
| 56 |
qrels: Dict[str, Dict[str, int]] = {}
|
| 57 |
for row in qrels_rows:
|
| 58 |
qid = _as_str(row.get("query-id") or row.get("query_id") or row.get("qid"))
|
| 59 |
+
did = _as_str(
|
| 60 |
+
row.get("corpus-id") or row.get("corpus_id") or row.get("doc_id") or row.get("did")
|
| 61 |
+
)
|
| 62 |
score = row.get("score") or row.get("relevance") or row.get("label") or 0
|
| 63 |
try:
|
| 64 |
score_int = int(score)
|
|
|
|
| 66 |
score_int = 0
|
| 67 |
if not qid or not did:
|
| 68 |
continue
|
| 69 |
+
# Keep qrels compact and correct: score<=0 is non-relevant.
|
| 70 |
+
if score_int <= 0:
|
| 71 |
+
continue
|
| 72 |
qrels.setdefault(qid, {})[_stable_uuid(did)] = score_int
|
| 73 |
return qrels
|
| 74 |
|
|
|
|
| 76 |
def _expect_fields(obj: Any, required: List[str], context: str) -> None:
|
| 77 |
missing = [k for k in required if k not in obj]
|
| 78 |
if missing:
|
| 79 |
+
raise ValueError(
|
| 80 |
+
f"{context}: missing required field(s): {missing}. Available: {list(obj.keys())}"
|
| 81 |
+
)
|
| 82 |
|
| 83 |
|
| 84 |
def _extract_beir_splits(ds: Any):
|
|
|
|
| 202 |
return _first_split(corpus_ds), _first_split(queries_ds), _first_split(qrels_ds)
|
| 203 |
|
| 204 |
|
| 205 |
+
def load_vidore_beir_dataset(
|
| 206 |
+
dataset_name: str,
|
| 207 |
+
) -> Tuple[List[CorpusDoc], List[Query], Dict[str, Dict[str, int]]]:
|
| 208 |
try:
|
| 209 |
from datasets import load_dataset
|
| 210 |
except ImportError as e:
|
|
|
|
| 230 |
|
| 231 |
last_err: Optional[Exception] = None
|
| 232 |
extracted = None
|
|
|
|
| 233 |
used_configs: List[str] = []
|
| 234 |
for name_try in candidates:
|
| 235 |
config_names = _get_config_names(name_try)
|
|
|
|
| 250 |
if extracted is None:
|
| 251 |
extracted = _load_beir_from_separate_configs(name_try, config_names)
|
| 252 |
if extracted is not None:
|
|
|
|
| 253 |
break
|
| 254 |
|
| 255 |
if extracted is None:
|
|
|
|
| 279 |
doc_id = _stable_uuid(source_doc_id)
|
| 280 |
image = row.get("image") or row.get("page_image") or row.get("document") or row.get("img")
|
| 281 |
if image is None:
|
| 282 |
+
raise ValueError(
|
| 283 |
+
"corpus row: missing image field (tried image/page_image/document/img)"
|
| 284 |
+
)
|
| 285 |
payload = {
|
| 286 |
**{
|
| 287 |
k: v
|
|
|
|
| 315 |
return corpus_docs, queries, qrels
|
| 316 |
|
| 317 |
|
| 318 |
+
def load_vidore_paired_dataset(
|
| 319 |
+
dataset_name: str,
|
| 320 |
+
) -> Tuple[List[CorpusDoc], List[Query], Dict[str, Dict[str, int]]]:
|
| 321 |
"""
|
| 322 |
Load ViDoRe v1-style paired QA datasets.
|
| 323 |
|
|
|
|
| 359 |
return corpus_docs, queries, qrels
|
| 360 |
|
| 361 |
|
| 362 |
+
def load_vidore_dataset_auto(
|
| 363 |
+
dataset_name: str,
|
| 364 |
+
) -> Tuple[List[CorpusDoc], List[Query], Dict[str, Dict[str, int]], str]:
|
| 365 |
"""
|
| 366 |
Auto-detect ViDoRe dataset format.
|
| 367 |
Returns: (corpus, queries, qrels, protocol)
|
|
|
|
| 373 |
except ValueError:
|
| 374 |
corpus, queries, qrels = load_vidore_paired_dataset(dataset_name)
|
| 375 |
return corpus, queries, qrels, "paired"
|
|
|
|
|
|
benchmarks/vidore_tatdqa_test/metrics.py
CHANGED
|
@@ -37,8 +37,3 @@ def recall_at_k(ranking: List[str], qrels: Dict[str, int], k: int) -> float:
|
|
| 37 |
return 0.0
|
| 38 |
retrieved = set(ranking[:k])
|
| 39 |
return len(retrieved & relevant) / len(relevant)
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
|
|
|
| 37 |
return 0.0
|
| 38 |
retrieved = set(ranking[:k])
|
| 39 |
return len(retrieved & relevant) / len(relevant)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
benchmarks/vidore_tatdqa_test/run_qdrant.py
CHANGED
|
@@ -7,14 +7,17 @@ from typing import Any, Dict, List, Optional
|
|
| 7 |
|
| 8 |
import numpy as np
|
| 9 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
from visual_rag import VisualEmbedder
|
| 11 |
from visual_rag.embedding.pooling import tile_level_mean_pooling
|
| 12 |
from visual_rag.indexing.qdrant_indexer import QdrantIndexer
|
| 13 |
from visual_rag.retrieval import MultiVectorRetriever
|
| 14 |
|
| 15 |
-
from benchmarks.vidore_tatdqa_test.dataset_loader import load_vidore_dataset_auto, paired_doc_id, paired_payload
|
| 16 |
-
from benchmarks.vidore_tatdqa_test.metrics import ndcg_at_k, mrr_at_k, recall_at_k
|
| 17 |
-
|
| 18 |
|
| 19 |
def _torch_dtype_to_str(dtype) -> str:
|
| 20 |
if dtype is None:
|
|
@@ -144,7 +147,9 @@ def _index_corpus(
|
|
| 144 |
pass
|
| 145 |
|
| 146 |
def _upload(points: List[Dict[str, Any]]) -> int:
|
| 147 |
-
return indexer.upload_batch(
|
|
|
|
|
|
|
| 148 |
|
| 149 |
executor = None
|
| 150 |
futures = []
|
|
@@ -152,7 +157,8 @@ def _index_corpus(
|
|
| 152 |
|
| 153 |
stop_event = threading.Event()
|
| 154 |
if upload_workers and upload_workers > 0:
|
| 155 |
-
from concurrent.futures import
|
|
|
|
| 156 |
|
| 157 |
executor = ThreadPoolExecutor(max_workers=upload_workers)
|
| 158 |
|
|
@@ -226,9 +232,17 @@ def _index_corpus(
|
|
| 226 |
|
| 227 |
for doc, emb, token_info in zip(batch, embeddings, token_infos):
|
| 228 |
if doc.image is None:
|
| 229 |
-
raise ValueError(
|
| 230 |
-
|
| 231 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
visual_embedding = emb_np[visual_indices].astype(np.float32)
|
| 233 |
|
| 234 |
n_rows = token_info.get("n_rows")
|
|
@@ -238,7 +252,9 @@ def _index_corpus(
|
|
| 238 |
else:
|
| 239 |
num_tiles = 13
|
| 240 |
|
| 241 |
-
tile_pooled = tile_level_mean_pooling(
|
|
|
|
|
|
|
| 242 |
global_pooled = tile_pooled.mean(axis=0).astype(np.float32)
|
| 243 |
|
| 244 |
payload = {
|
|
@@ -270,8 +286,8 @@ def _index_corpus(
|
|
| 270 |
if pbar is not None:
|
| 271 |
pbar.set_postfix(
|
| 272 |
{
|
| 273 |
-
|
| 274 |
-
|
| 275 |
"buffer": len(points_buffer),
|
| 276 |
"enq": enqueued_docs,
|
| 277 |
"upl": uploaded_docs,
|
|
@@ -343,7 +359,9 @@ def _index_paired_dataset(
|
|
| 343 |
import torch
|
| 344 |
from torch.utils.data import DataLoader
|
| 345 |
except ImportError as e:
|
| 346 |
-
raise ImportError(
|
|
|
|
|
|
|
| 347 |
|
| 348 |
ds0 = load_dataset(dataset_name, split="test")
|
| 349 |
cols = set(ds0.column_names)
|
|
@@ -389,7 +407,9 @@ def _index_paired_dataset(
|
|
| 389 |
pass
|
| 390 |
|
| 391 |
def _upload(points: List[Dict[str, Any]]) -> int:
|
| 392 |
-
return indexer.upload_batch(
|
|
|
|
|
|
|
| 393 |
|
| 394 |
executor = None
|
| 395 |
futures = []
|
|
@@ -397,7 +417,8 @@ def _index_paired_dataset(
|
|
| 397 |
|
| 398 |
stop_event = threading.Event()
|
| 399 |
if upload_workers and upload_workers > 0:
|
| 400 |
-
from concurrent.futures import
|
|
|
|
| 401 |
|
| 402 |
executor = ThreadPoolExecutor(max_workers=upload_workers)
|
| 403 |
|
|
@@ -446,7 +467,12 @@ def _index_paired_dataset(
|
|
| 446 |
dl_kwargs["pin_memory"] = bool(pin_memory and torch.cuda.is_available())
|
| 447 |
|
| 448 |
data_loader = DataLoader(
|
| 449 |
-
_PairedHFDataset(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 450 |
**dl_kwargs,
|
| 451 |
)
|
| 452 |
iterable = ((idxs, images, metas) for (idxs, images, metas) in data_loader)
|
|
@@ -457,7 +483,10 @@ def _index_paired_dataset(
|
|
| 457 |
for start in range(0, total_docs, batch_size):
|
| 458 |
batch = ds[start : start + batch_size]
|
| 459 |
images = batch[image_col]
|
| 460 |
-
metas = [
|
|
|
|
|
|
|
|
|
|
| 461 |
idxs = list(range(start, start + len(images)))
|
| 462 |
yield idxs, images, metas
|
| 463 |
|
|
@@ -501,15 +530,23 @@ def _index_paired_dataset(
|
|
| 501 |
**paired_payload(meta, int(idx)),
|
| 502 |
}
|
| 503 |
|
| 504 |
-
emb_np =
|
| 505 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 506 |
visual_embedding = emb_np[visual_indices].astype(np.float32)
|
| 507 |
|
| 508 |
n_rows = token_info.get("n_rows")
|
| 509 |
n_cols = token_info.get("n_cols")
|
| 510 |
num_tiles = int(n_rows) * int(n_cols) + 1 if n_rows and n_cols else 13
|
| 511 |
|
| 512 |
-
tile_pooled = tile_level_mean_pooling(
|
|
|
|
|
|
|
| 513 |
global_pooled = tile_pooled.mean(axis=0).astype(np.float32)
|
| 514 |
|
| 515 |
points_buffer.append(
|
|
@@ -654,8 +691,14 @@ def main() -> None:
|
|
| 654 |
grpc_group = parser.add_mutually_exclusive_group()
|
| 655 |
grpc_group.add_argument("--prefer-grpc", dest="prefer_grpc", action="store_true", default=True)
|
| 656 |
grpc_group.add_argument("--no-prefer-grpc", dest="prefer_grpc", action="store_false")
|
| 657 |
-
parser.add_argument(
|
| 658 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 659 |
parser.add_argument(
|
| 660 |
"--indexing-threshold",
|
| 661 |
type=int,
|
|
@@ -679,8 +722,19 @@ def main() -> None:
|
|
| 679 |
parser.add_argument(
|
| 680 |
"--stage1-mode",
|
| 681 |
type=str,
|
| 682 |
-
default="
|
| 683 |
-
choices=[
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 684 |
)
|
| 685 |
parser.add_argument("--output", type=str, default="results/qdrant_vidore_tatdqa_test.json")
|
| 686 |
|
|
@@ -795,5 +849,3 @@ def main() -> None:
|
|
| 795 |
|
| 796 |
if __name__ == "__main__":
|
| 797 |
main()
|
| 798 |
-
|
| 799 |
-
|
|
|
|
| 7 |
|
| 8 |
import numpy as np
|
| 9 |
|
| 10 |
+
from benchmarks.vidore_tatdqa_test.dataset_loader import (
|
| 11 |
+
load_vidore_dataset_auto,
|
| 12 |
+
paired_doc_id,
|
| 13 |
+
paired_payload,
|
| 14 |
+
)
|
| 15 |
+
from benchmarks.vidore_tatdqa_test.metrics import mrr_at_k, ndcg_at_k, recall_at_k
|
| 16 |
from visual_rag import VisualEmbedder
|
| 17 |
from visual_rag.embedding.pooling import tile_level_mean_pooling
|
| 18 |
from visual_rag.indexing.qdrant_indexer import QdrantIndexer
|
| 19 |
from visual_rag.retrieval import MultiVectorRetriever
|
| 20 |
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
def _torch_dtype_to_str(dtype) -> str:
|
| 23 |
if dtype is None:
|
|
|
|
| 147 |
pass
|
| 148 |
|
| 149 |
def _upload(points: List[Dict[str, Any]]) -> int:
|
| 150 |
+
return indexer.upload_batch(
|
| 151 |
+
points, delay_between_batches=0.0, wait=upsert_wait, stop_event=stop_event
|
| 152 |
+
)
|
| 153 |
|
| 154 |
executor = None
|
| 155 |
futures = []
|
|
|
|
| 157 |
|
| 158 |
stop_event = threading.Event()
|
| 159 |
if upload_workers and upload_workers > 0:
|
| 160 |
+
from concurrent.futures import FIRST_EXCEPTION, ThreadPoolExecutor
|
| 161 |
+
from concurrent.futures import wait as futures_wait
|
| 162 |
|
| 163 |
executor = ThreadPoolExecutor(max_workers=upload_workers)
|
| 164 |
|
|
|
|
| 232 |
|
| 233 |
for doc, emb, token_info in zip(batch, embeddings, token_infos):
|
| 234 |
if doc.image is None:
|
| 235 |
+
raise ValueError(
|
| 236 |
+
"CorpusDoc.image is None. For paired datasets, use _index_paired_dataset()."
|
| 237 |
+
)
|
| 238 |
+
emb_np = (
|
| 239 |
+
emb.cpu().float().numpy()
|
| 240 |
+
if hasattr(emb, "cpu")
|
| 241 |
+
else np.array(emb, dtype=np.float32)
|
| 242 |
+
)
|
| 243 |
+
visual_indices = token_info.get("visual_token_indices") or list(
|
| 244 |
+
range(emb_np.shape[0])
|
| 245 |
+
)
|
| 246 |
visual_embedding = emb_np[visual_indices].astype(np.float32)
|
| 247 |
|
| 248 |
n_rows = token_info.get("n_rows")
|
|
|
|
| 252 |
else:
|
| 253 |
num_tiles = 13
|
| 254 |
|
| 255 |
+
tile_pooled = tile_level_mean_pooling(
|
| 256 |
+
visual_embedding, num_tiles=num_tiles, patches_per_tile=64
|
| 257 |
+
)
|
| 258 |
global_pooled = tile_pooled.mean(axis=0).astype(np.float32)
|
| 259 |
|
| 260 |
payload = {
|
|
|
|
| 286 |
if pbar is not None:
|
| 287 |
pbar.set_postfix(
|
| 288 |
{
|
| 289 |
+
"avg_s/doc": f"{avg_s_per_doc:.2f}",
|
| 290 |
+
"last_s/doc": f"{last_s_per_doc:.2f}",
|
| 291 |
"buffer": len(points_buffer),
|
| 292 |
"enq": enqueued_docs,
|
| 293 |
"upl": uploaded_docs,
|
|
|
|
| 359 |
import torch
|
| 360 |
from torch.utils.data import DataLoader
|
| 361 |
except ImportError as e:
|
| 362 |
+
raise ImportError(
|
| 363 |
+
"torch is required. Install with: pip install visual-rag-toolkit[embedding]"
|
| 364 |
+
) from e
|
| 365 |
|
| 366 |
ds0 = load_dataset(dataset_name, split="test")
|
| 367 |
cols = set(ds0.column_names)
|
|
|
|
| 407 |
pass
|
| 408 |
|
| 409 |
def _upload(points: List[Dict[str, Any]]) -> int:
|
| 410 |
+
return indexer.upload_batch(
|
| 411 |
+
points, delay_between_batches=0.0, wait=upsert_wait, stop_event=stop_event
|
| 412 |
+
)
|
| 413 |
|
| 414 |
executor = None
|
| 415 |
futures = []
|
|
|
|
| 417 |
|
| 418 |
stop_event = threading.Event()
|
| 419 |
if upload_workers and upload_workers > 0:
|
| 420 |
+
from concurrent.futures import FIRST_EXCEPTION, ThreadPoolExecutor
|
| 421 |
+
from concurrent.futures import wait as futures_wait
|
| 422 |
|
| 423 |
executor = ThreadPoolExecutor(max_workers=upload_workers)
|
| 424 |
|
|
|
|
| 467 |
dl_kwargs["pin_memory"] = bool(pin_memory and torch.cuda.is_available())
|
| 468 |
|
| 469 |
data_loader = DataLoader(
|
| 470 |
+
_PairedHFDataset(
|
| 471 |
+
dataset_name=dataset_name,
|
| 472 |
+
split="test",
|
| 473 |
+
total_docs=total_docs,
|
| 474 |
+
image_col=image_col,
|
| 475 |
+
),
|
| 476 |
**dl_kwargs,
|
| 477 |
)
|
| 478 |
iterable = ((idxs, images, metas) for (idxs, images, metas) in data_loader)
|
|
|
|
| 483 |
for start in range(0, total_docs, batch_size):
|
| 484 |
batch = ds[start : start + batch_size]
|
| 485 |
images = batch[image_col]
|
| 486 |
+
metas = [
|
| 487 |
+
{k: batch[k][i] for k in batch.keys() if k != image_col}
|
| 488 |
+
for i in range(len(images))
|
| 489 |
+
]
|
| 490 |
idxs = list(range(start, start + len(images)))
|
| 491 |
yield idxs, images, metas
|
| 492 |
|
|
|
|
| 530 |
**paired_payload(meta, int(idx)),
|
| 531 |
}
|
| 532 |
|
| 533 |
+
emb_np = (
|
| 534 |
+
emb.cpu().float().numpy()
|
| 535 |
+
if hasattr(emb, "cpu")
|
| 536 |
+
else np.array(emb, dtype=np.float32)
|
| 537 |
+
)
|
| 538 |
+
visual_indices = token_info.get("visual_token_indices") or list(
|
| 539 |
+
range(emb_np.shape[0])
|
| 540 |
+
)
|
| 541 |
visual_embedding = emb_np[visual_indices].astype(np.float32)
|
| 542 |
|
| 543 |
n_rows = token_info.get("n_rows")
|
| 544 |
n_cols = token_info.get("n_cols")
|
| 545 |
num_tiles = int(n_rows) * int(n_cols) + 1 if n_rows and n_cols else 13
|
| 546 |
|
| 547 |
+
tile_pooled = tile_level_mean_pooling(
|
| 548 |
+
visual_embedding, num_tiles=num_tiles, patches_per_tile=64
|
| 549 |
+
)
|
| 550 |
global_pooled = tile_pooled.mean(axis=0).astype(np.float32)
|
| 551 |
|
| 552 |
points_buffer.append(
|
|
|
|
| 691 |
grpc_group = parser.add_mutually_exclusive_group()
|
| 692 |
grpc_group.add_argument("--prefer-grpc", dest="prefer_grpc", action="store_true", default=True)
|
| 693 |
grpc_group.add_argument("--no-prefer-grpc", dest="prefer_grpc", action="store_false")
|
| 694 |
+
parser.add_argument(
|
| 695 |
+
"--index", action="store_true", help="Index corpus into Qdrant before evaluating"
|
| 696 |
+
)
|
| 697 |
+
parser.add_argument(
|
| 698 |
+
"--recreate",
|
| 699 |
+
action="store_true",
|
| 700 |
+
help="Delete and recreate the collection (implies --index)",
|
| 701 |
+
)
|
| 702 |
parser.add_argument(
|
| 703 |
"--indexing-threshold",
|
| 704 |
type=int,
|
|
|
|
| 722 |
parser.add_argument(
|
| 723 |
"--stage1-mode",
|
| 724 |
type=str,
|
| 725 |
+
default="tokens_vs_standard_pooling",
|
| 726 |
+
choices=[
|
| 727 |
+
"pooled_query_vs_standard_pooling",
|
| 728 |
+
"tokens_vs_standard_pooling",
|
| 729 |
+
"pooled_query_vs_experimental_pooling",
|
| 730 |
+
"tokens_vs_experimental_pooling",
|
| 731 |
+
"pooled_query_vs_global",
|
| 732 |
+
# Backwards-compatible aliases
|
| 733 |
+
"pooled_query_vs_tiles",
|
| 734 |
+
"tokens_vs_tiles",
|
| 735 |
+
"pooled_query_vs_experimental",
|
| 736 |
+
"tokens_vs_experimental",
|
| 737 |
+
],
|
| 738 |
)
|
| 739 |
parser.add_argument("--output", type=str, default="results/qdrant_vidore_tatdqa_test.json")
|
| 740 |
|
|
|
|
| 849 |
|
| 850 |
if __name__ == "__main__":
|
| 851 |
main()
|
|
|
|
|
|
benchmarks/vidore_tatdqa_test/sweep_eval.py
CHANGED
|
@@ -1,15 +1,15 @@
|
|
| 1 |
import argparse
|
| 2 |
import json
|
|
|
|
| 3 |
import os
|
| 4 |
import time
|
| 5 |
-
import logging
|
| 6 |
from pathlib import Path
|
| 7 |
-
from typing import Dict, List, Optional
|
| 8 |
|
| 9 |
import numpy as np
|
| 10 |
|
| 11 |
from benchmarks.vidore_tatdqa_test.dataset_loader import load_vidore_dataset_auto
|
| 12 |
-
from benchmarks.vidore_tatdqa_test.metrics import
|
| 13 |
from visual_rag import VisualEmbedder
|
| 14 |
from visual_rag.retrieval import MultiVectorRetriever
|
| 15 |
|
|
@@ -24,6 +24,7 @@ def _maybe_load_dotenv() -> None:
|
|
| 24 |
if Path(".env").exists():
|
| 25 |
load_dotenv(".env")
|
| 26 |
|
|
|
|
| 27 |
def _torch_dtype_to_str(dtype) -> str:
|
| 28 |
if dtype is None:
|
| 29 |
return "auto"
|
|
@@ -151,7 +152,9 @@ def _evaluate(
|
|
| 151 |
|
| 152 |
|
| 153 |
def main() -> None:
|
| 154 |
-
logging.basicConfig(
|
|
|
|
|
|
|
| 155 |
|
| 156 |
parser = argparse.ArgumentParser()
|
| 157 |
parser.add_argument("--dataset", type=str, default="vidore/tatdqa_test")
|
|
@@ -165,12 +168,25 @@ def main() -> None:
|
|
| 165 |
help="Torch dtype for model weights (default: auto; inferred from collection vector dtype when possible).",
|
| 166 |
)
|
| 167 |
parser.add_argument("--top-k", type=int, default=10)
|
| 168 |
-
parser.add_argument(
|
|
|
|
|
|
|
| 169 |
parser.add_argument(
|
| 170 |
"--stage1-mode",
|
| 171 |
type=str,
|
| 172 |
-
default="
|
| 173 |
-
choices=[
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
)
|
| 175 |
parser.add_argument(
|
| 176 |
"--prefetch-ks",
|
|
@@ -278,7 +294,9 @@ def main() -> None:
|
|
| 278 |
if args.query_batch_size and args.query_batch_size > 0:
|
| 279 |
texts = [q.text for q in queries]
|
| 280 |
logger.info(f"Pre-embedding {len(texts)} queries (batch={args.query_batch_size})...")
|
| 281 |
-
q_tensors = embedder.embed_queries(
|
|
|
|
|
|
|
| 282 |
precomputed_query_embeddings = [t.detach().cpu().float().numpy() for t in q_tensors]
|
| 283 |
try:
|
| 284 |
import torch
|
|
@@ -317,7 +335,11 @@ def main() -> None:
|
|
| 317 |
"max_queries": args.max_queries,
|
| 318 |
"sample_queries": args.sample_queries,
|
| 319 |
"sample_strategy": args.sample_strategy if args.sample_queries else None,
|
| 320 |
-
"sample_seed":
|
|
|
|
|
|
|
|
|
|
|
|
|
| 321 |
"metrics": metrics,
|
| 322 |
},
|
| 323 |
f,
|
|
@@ -340,7 +362,10 @@ def main() -> None:
|
|
| 340 |
max_queries=args.max_queries,
|
| 341 |
precomputed_query_embeddings=precomputed_query_embeddings,
|
| 342 |
)
|
| 343 |
-
out_path =
|
|
|
|
|
|
|
|
|
|
| 344 |
with open(out_path, "w") as f:
|
| 345 |
json.dump(
|
| 346 |
{
|
|
@@ -356,7 +381,11 @@ def main() -> None:
|
|
| 356 |
"max_queries": args.max_queries,
|
| 357 |
"sample_queries": args.sample_queries,
|
| 358 |
"sample_strategy": args.sample_strategy if args.sample_queries else None,
|
| 359 |
-
"sample_seed":
|
|
|
|
|
|
|
|
|
|
|
|
|
| 360 |
"metrics": metrics,
|
| 361 |
},
|
| 362 |
f,
|
|
@@ -368,5 +397,3 @@ def main() -> None:
|
|
| 368 |
|
| 369 |
if __name__ == "__main__":
|
| 370 |
main()
|
| 371 |
-
|
| 372 |
-
|
|
|
|
| 1 |
import argparse
|
| 2 |
import json
|
| 3 |
+
import logging
|
| 4 |
import os
|
| 5 |
import time
|
|
|
|
| 6 |
from pathlib import Path
|
| 7 |
+
from typing import Dict, List, Optional
|
| 8 |
|
| 9 |
import numpy as np
|
| 10 |
|
| 11 |
from benchmarks.vidore_tatdqa_test.dataset_loader import load_vidore_dataset_auto
|
| 12 |
+
from benchmarks.vidore_tatdqa_test.metrics import mrr_at_k, ndcg_at_k, recall_at_k
|
| 13 |
from visual_rag import VisualEmbedder
|
| 14 |
from visual_rag.retrieval import MultiVectorRetriever
|
| 15 |
|
|
|
|
| 24 |
if Path(".env").exists():
|
| 25 |
load_dotenv(".env")
|
| 26 |
|
| 27 |
+
|
| 28 |
def _torch_dtype_to_str(dtype) -> str:
|
| 29 |
if dtype is None:
|
| 30 |
return "auto"
|
|
|
|
| 152 |
|
| 153 |
|
| 154 |
def main() -> None:
|
| 155 |
+
logging.basicConfig(
|
| 156 |
+
level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s", force=True
|
| 157 |
+
)
|
| 158 |
|
| 159 |
parser = argparse.ArgumentParser()
|
| 160 |
parser.add_argument("--dataset", type=str, default="vidore/tatdqa_test")
|
|
|
|
| 168 |
help="Torch dtype for model weights (default: auto; inferred from collection vector dtype when possible).",
|
| 169 |
)
|
| 170 |
parser.add_argument("--top-k", type=int, default=10)
|
| 171 |
+
parser.add_argument(
|
| 172 |
+
"--mode", type=str, default="two_stage", choices=["single_full", "two_stage"]
|
| 173 |
+
)
|
| 174 |
parser.add_argument(
|
| 175 |
"--stage1-mode",
|
| 176 |
type=str,
|
| 177 |
+
default="tokens_vs_standard_pooling",
|
| 178 |
+
choices=[
|
| 179 |
+
"pooled_query_vs_standard_pooling",
|
| 180 |
+
"tokens_vs_standard_pooling",
|
| 181 |
+
"pooled_query_vs_experimental_pooling",
|
| 182 |
+
"tokens_vs_experimental_pooling",
|
| 183 |
+
"pooled_query_vs_global",
|
| 184 |
+
# Backwards-compatible aliases
|
| 185 |
+
"pooled_query_vs_tiles",
|
| 186 |
+
"tokens_vs_tiles",
|
| 187 |
+
"pooled_query_vs_experimental",
|
| 188 |
+
"tokens_vs_experimental",
|
| 189 |
+
],
|
| 190 |
)
|
| 191 |
parser.add_argument(
|
| 192 |
"--prefetch-ks",
|
|
|
|
| 294 |
if args.query_batch_size and args.query_batch_size > 0:
|
| 295 |
texts = [q.text for q in queries]
|
| 296 |
logger.info(f"Pre-embedding {len(texts)} queries (batch={args.query_batch_size})...")
|
| 297 |
+
q_tensors = embedder.embed_queries(
|
| 298 |
+
texts, batch_size=args.query_batch_size, show_progress=True
|
| 299 |
+
)
|
| 300 |
precomputed_query_embeddings = [t.detach().cpu().float().numpy() for t in q_tensors]
|
| 301 |
try:
|
| 302 |
import torch
|
|
|
|
| 335 |
"max_queries": args.max_queries,
|
| 336 |
"sample_queries": args.sample_queries,
|
| 337 |
"sample_strategy": args.sample_strategy if args.sample_queries else None,
|
| 338 |
+
"sample_seed": (
|
| 339 |
+
args.sample_seed
|
| 340 |
+
if args.sample_queries and args.sample_strategy == "random"
|
| 341 |
+
else None
|
| 342 |
+
),
|
| 343 |
"metrics": metrics,
|
| 344 |
},
|
| 345 |
f,
|
|
|
|
| 362 |
max_queries=args.max_queries,
|
| 363 |
precomputed_query_embeddings=precomputed_query_embeddings,
|
| 364 |
)
|
| 365 |
+
out_path = (
|
| 366 |
+
out_dir
|
| 367 |
+
/ f"{protocol}__two_stage__{args.stage1_mode}__prefetch{k}__top{args.top_k}.json"
|
| 368 |
+
)
|
| 369 |
with open(out_path, "w") as f:
|
| 370 |
json.dump(
|
| 371 |
{
|
|
|
|
| 381 |
"max_queries": args.max_queries,
|
| 382 |
"sample_queries": args.sample_queries,
|
| 383 |
"sample_strategy": args.sample_strategy if args.sample_queries else None,
|
| 384 |
+
"sample_seed": (
|
| 385 |
+
args.sample_seed
|
| 386 |
+
if args.sample_queries and args.sample_strategy == "random"
|
| 387 |
+
else None
|
| 388 |
+
),
|
| 389 |
"metrics": metrics,
|
| 390 |
},
|
| 391 |
f,
|
|
|
|
| 397 |
|
| 398 |
if __name__ == "__main__":
|
| 399 |
main()
|
|
|
|
|
|
demo/app.py
CHANGED
|
@@ -1,6 +1,5 @@
|
|
| 1 |
"""Main entry point for the Visual RAG Toolkit demo application."""
|
| 2 |
|
| 3 |
-
import os
|
| 4 |
import sys
|
| 5 |
from pathlib import Path
|
| 6 |
|
|
@@ -11,7 +10,7 @@ _repo_root = _app_dir.parent
|
|
| 11 |
if str(_repo_root) not in sys.path:
|
| 12 |
sys.path.insert(0, str(_repo_root))
|
| 13 |
|
| 14 |
-
from dotenv import load_dotenv
|
| 15 |
|
| 16 |
# Load .env from the repo root (works both locally and in Docker)
|
| 17 |
if (_repo_root / ".env").exists():
|
|
@@ -19,7 +18,7 @@ if (_repo_root / ".env").exists():
|
|
| 19 |
if (_app_dir / ".env").exists():
|
| 20 |
load_dotenv(_app_dir / ".env")
|
| 21 |
|
| 22 |
-
import streamlit as st
|
| 23 |
|
| 24 |
st.set_page_config(
|
| 25 |
page_title="Visual RAG Toolkit",
|
|
@@ -28,11 +27,11 @@ st.set_page_config(
|
|
| 28 |
initial_sidebar_state="expanded",
|
| 29 |
)
|
| 30 |
|
| 31 |
-
from demo.ui.
|
| 32 |
-
from demo.ui.
|
| 33 |
-
from demo.ui.
|
| 34 |
-
from demo.ui.
|
| 35 |
-
from demo.ui.
|
| 36 |
|
| 37 |
|
| 38 |
def main():
|
|
|
|
| 1 |
"""Main entry point for the Visual RAG Toolkit demo application."""
|
| 2 |
|
|
|
|
| 3 |
import sys
|
| 4 |
from pathlib import Path
|
| 5 |
|
|
|
|
| 10 |
if str(_repo_root) not in sys.path:
|
| 11 |
sys.path.insert(0, str(_repo_root))
|
| 12 |
|
| 13 |
+
from dotenv import load_dotenv # noqa: E402
|
| 14 |
|
| 15 |
# Load .env from the repo root (works both locally and in Docker)
|
| 16 |
if (_repo_root / ".env").exists():
|
|
|
|
| 18 |
if (_app_dir / ".env").exists():
|
| 19 |
load_dotenv(_app_dir / ".env")
|
| 20 |
|
| 21 |
+
import streamlit as st # noqa: E402
|
| 22 |
|
| 23 |
st.set_page_config(
|
| 24 |
page_title="Visual RAG Toolkit",
|
|
|
|
| 27 |
initial_sidebar_state="expanded",
|
| 28 |
)
|
| 29 |
|
| 30 |
+
from demo.ui.benchmark import render_benchmark_tab # noqa: E402
|
| 31 |
+
from demo.ui.header import render_header # noqa: E402
|
| 32 |
+
from demo.ui.playground import render_playground_tab # noqa: E402
|
| 33 |
+
from demo.ui.sidebar import render_sidebar # noqa: E402
|
| 34 |
+
from demo.ui.upload import render_upload_tab # noqa: E402
|
| 35 |
|
| 36 |
|
| 37 |
def main():
|
demo/commands.py
CHANGED
|
@@ -43,13 +43,17 @@ def generate_python_index_code(config: Dict[str, Any]) -> str:
|
|
| 43 |
prefer_grpc = config.get("prefer_grpc", True)
|
| 44 |
crop_empty = config.get("crop_empty", False)
|
| 45 |
max_docs = config.get("max_docs")
|
| 46 |
-
|
| 47 |
torch_dtype = config.get("torch_dtype", "float16")
|
| 48 |
qdrant_dtype = config.get("qdrant_vector_dtype", "float16")
|
| 49 |
-
|
| 50 |
-
torch_dtype_map = {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
torch_dtype_val = torch_dtype_map.get(torch_dtype, "torch.float16")
|
| 52 |
-
|
| 53 |
code_lines = [
|
| 54 |
"import os",
|
| 55 |
"import torch",
|
|
@@ -61,96 +65,104 @@ def generate_python_index_code(config: Dict[str, Any]) -> str:
|
|
| 61 |
f'COLLECTION = "{collection}"',
|
| 62 |
f'MODEL = "{model}"',
|
| 63 |
f"BATCH_SIZE = {batch_size}",
|
| 64 |
-
f
|
| 65 |
-
f
|
| 66 |
f'QDRANT_DTYPE = "{qdrant_dtype}"',
|
| 67 |
]
|
| 68 |
-
|
| 69 |
if max_docs:
|
| 70 |
code_lines.append(f"MAX_DOCS = {max_docs} # Limit docs per dataset")
|
| 71 |
-
|
| 72 |
-
code_lines.extend(
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
|
|
|
|
|
|
| 103 |
if max_docs:
|
| 104 |
code_lines.append(" corpus = corpus[:MAX_DOCS] # Limit")
|
| 105 |
-
|
| 106 |
-
code_lines.extend(
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
"
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
"
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
"
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
|
|
|
|
|
|
| 150 |
if crop_empty:
|
| 151 |
-
code_lines.insert(
|
| 152 |
-
|
| 153 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
return "\n".join(code_lines)
|
| 155 |
|
| 156 |
|
|
@@ -189,10 +201,14 @@ def generate_python_eval_code(config: Dict[str, Any]) -> str:
|
|
| 189 |
scope = config.get("evaluation_scope", "union")
|
| 190 |
prefer_grpc = config.get("prefer_grpc", True)
|
| 191 |
torch_dtype = config.get("torch_dtype", "float16")
|
| 192 |
-
|
| 193 |
-
torch_dtype_map = {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
torch_dtype_val = torch_dtype_map.get(torch_dtype, "torch.float16")
|
| 195 |
-
|
| 196 |
code_lines = [
|
| 197 |
"import os",
|
| 198 |
"import torch",
|
|
@@ -204,7 +220,7 @@ def generate_python_eval_code(config: Dict[str, Any]) -> str:
|
|
| 204 |
f'COLLECTION = "{collection}"',
|
| 205 |
f'MODEL = "{model}"',
|
| 206 |
f"TOP_K = {top_k}",
|
| 207 |
-
f
|
| 208 |
f"TORCH_DTYPE = {torch_dtype_val}",
|
| 209 |
"",
|
| 210 |
"# Initialize clients",
|
|
@@ -227,108 +243,122 @@ def generate_python_eval_code(config: Dict[str, Any]) -> str:
|
|
| 227 |
")",
|
| 228 |
"",
|
| 229 |
]
|
| 230 |
-
|
| 231 |
if mode == "single_full":
|
| 232 |
-
code_lines.extend(
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
|
|
|
|
|
|
| 242 |
elif mode == "single_tiles":
|
| 243 |
-
code_lines.extend(
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
|
|
|
|
|
|
| 253 |
elif mode == "single_global":
|
| 254 |
-
code_lines.extend(
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
|
|
|
|
|
|
| 264 |
elif mode == "two_stage":
|
| 265 |
prefetch_k = config.get("prefetch_k", 256)
|
| 266 |
-
stage1_mode = config.get("stage1_mode", "
|
| 267 |
-
code_lines.extend(
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
|
|
|
|
|
|
| 286 |
elif mode == "three_stage":
|
| 287 |
stage1_k = config.get("stage1_k", 1000)
|
| 288 |
stage2_k = config.get("stage2_k", 300)
|
| 289 |
-
code_lines.extend(
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
|
|
|
|
|
|
| 309 |
if scope == "per_dataset":
|
| 310 |
-
code_lines.extend(
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 314 |
"",
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
"
|
| 318 |
-
"
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
" )",
|
| 323 |
-
" # Add filter to your search call",
|
| 324 |
-
])
|
| 325 |
-
|
| 326 |
-
code_lines.extend([
|
| 327 |
-
"",
|
| 328 |
-
'# Example usage',
|
| 329 |
-
'results = search("What is the company revenue?")',
|
| 330 |
-
'for r in results:',
|
| 331 |
-
' print(f"Score: {r.score:.4f}, Doc: {r.payload.get(\'doc_id\')}")',
|
| 332 |
-
])
|
| 333 |
-
|
| 334 |
return "\n".join(code_lines)
|
|
|
|
| 43 |
prefer_grpc = config.get("prefer_grpc", True)
|
| 44 |
crop_empty = config.get("crop_empty", False)
|
| 45 |
max_docs = config.get("max_docs")
|
| 46 |
+
|
| 47 |
torch_dtype = config.get("torch_dtype", "float16")
|
| 48 |
qdrant_dtype = config.get("qdrant_vector_dtype", "float16")
|
| 49 |
+
|
| 50 |
+
torch_dtype_map = {
|
| 51 |
+
"float16": "torch.float16",
|
| 52 |
+
"float32": "torch.float32",
|
| 53 |
+
"bfloat16": "torch.bfloat16",
|
| 54 |
+
}
|
| 55 |
torch_dtype_val = torch_dtype_map.get(torch_dtype, "torch.float16")
|
| 56 |
+
|
| 57 |
code_lines = [
|
| 58 |
"import os",
|
| 59 |
"import torch",
|
|
|
|
| 65 |
f'COLLECTION = "{collection}"',
|
| 66 |
f'MODEL = "{model}"',
|
| 67 |
f"BATCH_SIZE = {batch_size}",
|
| 68 |
+
f"DATASETS = [{datasets_str}]",
|
| 69 |
+
f"TORCH_DTYPE = {torch_dtype_val}",
|
| 70 |
f'QDRANT_DTYPE = "{qdrant_dtype}"',
|
| 71 |
]
|
| 72 |
+
|
| 73 |
if max_docs:
|
| 74 |
code_lines.append(f"MAX_DOCS = {max_docs} # Limit docs per dataset")
|
| 75 |
+
|
| 76 |
+
code_lines.extend(
|
| 77 |
+
[
|
| 78 |
+
"",
|
| 79 |
+
"# Initialize embedder",
|
| 80 |
+
"embedder = VisualEmbedder(",
|
| 81 |
+
" model_name=MODEL,",
|
| 82 |
+
" torch_dtype=TORCH_DTYPE,",
|
| 83 |
+
")",
|
| 84 |
+
"",
|
| 85 |
+
"# Initialize indexer",
|
| 86 |
+
"indexer = QdrantIndexer(",
|
| 87 |
+
' url=os.getenv("QDRANT_URL"),',
|
| 88 |
+
' api_key=os.getenv("QDRANT_API_KEY"),',
|
| 89 |
+
" collection_name=COLLECTION,",
|
| 90 |
+
f" prefer_grpc={prefer_grpc},",
|
| 91 |
+
" vector_datatype=QDRANT_DTYPE,",
|
| 92 |
+
")",
|
| 93 |
+
"",
|
| 94 |
+
"# Create collection",
|
| 95 |
+
f"indexer.create_collection(force_recreate={config.get('recreate', False)})",
|
| 96 |
+
"indexer.create_payload_indexes(fields=[",
|
| 97 |
+
' {"field": "dataset", "type": "keyword"},',
|
| 98 |
+
' {"field": "doc_id", "type": "keyword"},',
|
| 99 |
+
' {"field": "source_doc_id", "type": "keyword"},',
|
| 100 |
+
"])",
|
| 101 |
+
"",
|
| 102 |
+
"# Index each dataset",
|
| 103 |
+
"for ds_name in DATASETS:",
|
| 104 |
+
" print(f'Loading {ds_name}...')",
|
| 105 |
+
" corpus, queries, qrels = load_vidore_beir_dataset(ds_name)",
|
| 106 |
+
]
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
if max_docs:
|
| 110 |
code_lines.append(" corpus = corpus[:MAX_DOCS] # Limit")
|
| 111 |
+
|
| 112 |
+
code_lines.extend(
|
| 113 |
+
[
|
| 114 |
+
" print(f'Indexing {len(corpus)} documents...')",
|
| 115 |
+
"",
|
| 116 |
+
" for i in range(0, len(corpus), BATCH_SIZE):",
|
| 117 |
+
" batch = corpus[i:i + BATCH_SIZE]",
|
| 118 |
+
" images = [doc.image for doc in batch]",
|
| 119 |
+
"",
|
| 120 |
+
" # Embed images",
|
| 121 |
+
" embeddings, token_infos = embedder.embed_images(",
|
| 122 |
+
" images, return_token_info=True",
|
| 123 |
+
" )",
|
| 124 |
+
"",
|
| 125 |
+
" # Build points with multi-vector representations",
|
| 126 |
+
" points = []",
|
| 127 |
+
" for doc, emb, info in zip(batch, embeddings, token_infos):",
|
| 128 |
+
" emb_np = emb.cpu().numpy()",
|
| 129 |
+
" visual_idx = info.get('visual_token_indices', range(len(emb_np)))",
|
| 130 |
+
" visual_emb = emb_np[visual_idx]",
|
| 131 |
+
"",
|
| 132 |
+
" tile_pooled = embedder.mean_pool_visual_embedding(visual_emb, info)",
|
| 133 |
+
" experimental = embedder.experimental_pool_visual_embedding(",
|
| 134 |
+
" visual_emb, info, mean_pool=tile_pooled",
|
| 135 |
+
" )",
|
| 136 |
+
" global_pooled = embedder.global_pool_from_mean_pool(tile_pooled)",
|
| 137 |
+
"",
|
| 138 |
+
" points.append({",
|
| 139 |
+
' "id": f"{ds_name}_{doc.doc_id}",',
|
| 140 |
+
' "visual_embedding": visual_emb,',
|
| 141 |
+
' "tile_pooled_embedding": tile_pooled,',
|
| 142 |
+
' "experimental_pooled_embedding": experimental,',
|
| 143 |
+
' "global_pooled_embedding": global_pooled,',
|
| 144 |
+
' "metadata": {',
|
| 145 |
+
' "dataset": ds_name,',
|
| 146 |
+
' "doc_id": doc.doc_id,',
|
| 147 |
+
' "source_doc_id": doc.payload.get("source_doc_id"),',
|
| 148 |
+
" },",
|
| 149 |
+
" })",
|
| 150 |
+
"",
|
| 151 |
+
" indexer.upload_batch(points)",
|
| 152 |
+
" print(f' Batch {i//BATCH_SIZE + 1}: {len(points)} uploaded')",
|
| 153 |
+
"",
|
| 154 |
+
' print(f"Done: {ds_name}")',
|
| 155 |
+
]
|
| 156 |
+
)
|
| 157 |
+
|
| 158 |
if crop_empty:
|
| 159 |
+
code_lines.insert(
|
| 160 |
+
3, "from visual_rag.preprocessing.crop_empty import crop_empty, CropEmptyConfig"
|
| 161 |
+
)
|
| 162 |
+
code_lines.insert(
|
| 163 |
+
len(code_lines) - 20, " # Note: Add crop_empty() preprocessing before embedding"
|
| 164 |
+
)
|
| 165 |
+
|
| 166 |
return "\n".join(code_lines)
|
| 167 |
|
| 168 |
|
|
|
|
| 201 |
scope = config.get("evaluation_scope", "union")
|
| 202 |
prefer_grpc = config.get("prefer_grpc", True)
|
| 203 |
torch_dtype = config.get("torch_dtype", "float16")
|
| 204 |
+
|
| 205 |
+
torch_dtype_map = {
|
| 206 |
+
"float16": "torch.float16",
|
| 207 |
+
"float32": "torch.float32",
|
| 208 |
+
"bfloat16": "torch.bfloat16",
|
| 209 |
+
}
|
| 210 |
torch_dtype_val = torch_dtype_map.get(torch_dtype, "torch.float16")
|
| 211 |
+
|
| 212 |
code_lines = [
|
| 213 |
"import os",
|
| 214 |
"import torch",
|
|
|
|
| 220 |
f'COLLECTION = "{collection}"',
|
| 221 |
f'MODEL = "{model}"',
|
| 222 |
f"TOP_K = {top_k}",
|
| 223 |
+
f"DATASETS = [{datasets_str}]",
|
| 224 |
f"TORCH_DTYPE = {torch_dtype_val}",
|
| 225 |
"",
|
| 226 |
"# Initialize clients",
|
|
|
|
| 243 |
")",
|
| 244 |
"",
|
| 245 |
]
|
| 246 |
+
|
| 247 |
if mode == "single_full":
|
| 248 |
+
code_lines.extend(
|
| 249 |
+
[
|
| 250 |
+
"# Single-stage full retrieval",
|
| 251 |
+
"def search(query: str):",
|
| 252 |
+
" query_embedding = embedder.embed_query(query)",
|
| 253 |
+
" return retriever.search_single_stage(",
|
| 254 |
+
" query_embedding=query_embedding,",
|
| 255 |
+
f" limit={top_k},",
|
| 256 |
+
' vector_name="initial",',
|
| 257 |
+
" )",
|
| 258 |
+
]
|
| 259 |
+
)
|
| 260 |
elif mode == "single_tiles":
|
| 261 |
+
code_lines.extend(
|
| 262 |
+
[
|
| 263 |
+
"# Single-stage tiles retrieval",
|
| 264 |
+
"def search(query: str):",
|
| 265 |
+
" query_embedding = embedder.embed_query(query)",
|
| 266 |
+
" return retriever.search_single_stage(",
|
| 267 |
+
" query_embedding=query_embedding,",
|
| 268 |
+
f" limit={top_k},",
|
| 269 |
+
' vector_name="mean_pooling",',
|
| 270 |
+
" )",
|
| 271 |
+
]
|
| 272 |
+
)
|
| 273 |
elif mode == "single_global":
|
| 274 |
+
code_lines.extend(
|
| 275 |
+
[
|
| 276 |
+
"# Single-stage global retrieval",
|
| 277 |
+
"def search(query: str):",
|
| 278 |
+
" query_embedding = embedder.embed_query(query)",
|
| 279 |
+
" return retriever.search_single_stage(",
|
| 280 |
+
" query_embedding=query_embedding,",
|
| 281 |
+
f" limit={top_k},",
|
| 282 |
+
' vector_name="global_pooling",',
|
| 283 |
+
" )",
|
| 284 |
+
]
|
| 285 |
+
)
|
| 286 |
elif mode == "two_stage":
|
| 287 |
prefetch_k = config.get("prefetch_k", 256)
|
| 288 |
+
stage1_mode = config.get("stage1_mode", "tokens_vs_standard_pooling")
|
| 289 |
+
code_lines.extend(
|
| 290 |
+
[
|
| 291 |
+
"# Two-stage retrieval",
|
| 292 |
+
"from visual_rag.retrieval import TwoStageRetriever",
|
| 293 |
+
"",
|
| 294 |
+
"two_stage = TwoStageRetriever(",
|
| 295 |
+
" client=client,",
|
| 296 |
+
" collection_name=COLLECTION,",
|
| 297 |
+
" embedder=embedder,",
|
| 298 |
+
")",
|
| 299 |
+
"",
|
| 300 |
+
"def search(query: str):",
|
| 301 |
+
" query_embedding = embedder.embed_query(query)",
|
| 302 |
+
" return two_stage.search(",
|
| 303 |
+
" query_embedding=query_embedding,",
|
| 304 |
+
f" prefetch_limit={prefetch_k},",
|
| 305 |
+
f" limit={top_k},",
|
| 306 |
+
f' stage1_mode="{stage1_mode}",',
|
| 307 |
+
" )",
|
| 308 |
+
]
|
| 309 |
+
)
|
| 310 |
elif mode == "three_stage":
|
| 311 |
stage1_k = config.get("stage1_k", 1000)
|
| 312 |
stage2_k = config.get("stage2_k", 300)
|
| 313 |
+
code_lines.extend(
|
| 314 |
+
[
|
| 315 |
+
"# Three-stage retrieval",
|
| 316 |
+
"from visual_rag.retrieval import ThreeStageRetriever",
|
| 317 |
+
"",
|
| 318 |
+
"three_stage = ThreeStageRetriever(",
|
| 319 |
+
" client=client,",
|
| 320 |
+
" collection_name=COLLECTION,",
|
| 321 |
+
" embedder=embedder,",
|
| 322 |
+
")",
|
| 323 |
+
"",
|
| 324 |
+
"def search(query: str):",
|
| 325 |
+
" query_embedding = embedder.embed_query(query)",
|
| 326 |
+
" return three_stage.search(",
|
| 327 |
+
" query_embedding=query_embedding,",
|
| 328 |
+
f" stage1_limit={stage1_k},",
|
| 329 |
+
f" stage2_limit={stage2_k},",
|
| 330 |
+
f" limit={top_k},",
|
| 331 |
+
" )",
|
| 332 |
+
]
|
| 333 |
+
)
|
| 334 |
+
|
| 335 |
if scope == "per_dataset":
|
| 336 |
+
code_lines.extend(
|
| 337 |
+
[
|
| 338 |
+
"",
|
| 339 |
+
"# Per-dataset filtering",
|
| 340 |
+
"from qdrant_client.models import Filter, FieldCondition, MatchValue",
|
| 341 |
+
"",
|
| 342 |
+
'def search_dataset(query: str, dataset: str = "vidore/esg_reports_v2"):',
|
| 343 |
+
" query_embedding = embedder.embed_query(query)",
|
| 344 |
+
" dataset_filter = Filter(",
|
| 345 |
+
" must=[FieldCondition(",
|
| 346 |
+
' key="dataset",',
|
| 347 |
+
" match=MatchValue(value=dataset),",
|
| 348 |
+
" )]",
|
| 349 |
+
" )",
|
| 350 |
+
" # Add filter to your search call",
|
| 351 |
+
]
|
| 352 |
+
)
|
| 353 |
+
|
| 354 |
+
code_lines.extend(
|
| 355 |
+
[
|
| 356 |
"",
|
| 357 |
+
"# Example usage",
|
| 358 |
+
'results = search("What is the company revenue?")',
|
| 359 |
+
"for r in results:",
|
| 360 |
+
" print(f\"Score: {r.score:.4f}, Doc: {r.payload.get('doc_id')}\")",
|
| 361 |
+
]
|
| 362 |
+
)
|
| 363 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 364 |
return "\n".join(code_lines)
|
demo/config.py
CHANGED
|
@@ -3,6 +3,7 @@
|
|
| 3 |
AVAILABLE_MODELS = [
|
| 4 |
"vidore/colpali-v1.3",
|
| 5 |
"vidore/colSmol-500M",
|
|
|
|
| 6 |
]
|
| 7 |
|
| 8 |
BENCHMARK_DATASETS = [
|
|
@@ -26,9 +27,9 @@ RETRIEVAL_MODES = [
|
|
| 26 |
]
|
| 27 |
|
| 28 |
STAGE1_MODES = [
|
| 29 |
-
"
|
| 30 |
-
"
|
| 31 |
-
"
|
| 32 |
-
"
|
| 33 |
"pooled_query_vs_global",
|
| 34 |
]
|
|
|
|
| 3 |
AVAILABLE_MODELS = [
|
| 4 |
"vidore/colpali-v1.3",
|
| 5 |
"vidore/colSmol-500M",
|
| 6 |
+
"vidore/colqwen2.5-v0.2",
|
| 7 |
]
|
| 8 |
|
| 9 |
BENCHMARK_DATASETS = [
|
|
|
|
| 27 |
]
|
| 28 |
|
| 29 |
STAGE1_MODES = [
|
| 30 |
+
"tokens_vs_standard_pooling",
|
| 31 |
+
"tokens_vs_experimental_pooling",
|
| 32 |
+
"pooled_query_vs_standard_pooling",
|
| 33 |
+
"pooled_query_vs_experimental_pooling",
|
| 34 |
"pooled_query_vs_global",
|
| 35 |
]
|
demo/download_models.py
CHANGED
|
@@ -6,7 +6,6 @@ avoiding download delays during container startup.
|
|
| 6 |
"""
|
| 7 |
|
| 8 |
import os
|
| 9 |
-
import sys
|
| 10 |
|
| 11 |
os.environ.setdefault("HF_HOME", "/app/.cache/huggingface")
|
| 12 |
os.environ.setdefault("TRANSFORMERS_CACHE", "/app/.cache/huggingface")
|
|
@@ -14,20 +13,22 @@ os.environ.setdefault("TRANSFORMERS_CACHE", "/app/.cache/huggingface")
|
|
| 14 |
MODELS_TO_DOWNLOAD = [
|
| 15 |
"vidore/colpali-v1.3",
|
| 16 |
"vidore/colSmol-500M",
|
|
|
|
| 17 |
]
|
| 18 |
|
|
|
|
| 19 |
def download_colpali_models():
|
| 20 |
"""Download ColPali models and their processors."""
|
| 21 |
print("=" * 60)
|
| 22 |
print("Downloading ColPali models for Visual RAG Toolkit")
|
| 23 |
print("=" * 60)
|
| 24 |
-
|
| 25 |
try:
|
| 26 |
from colpali_engine.models import ColPali, ColPaliProcessor
|
| 27 |
except ImportError:
|
| 28 |
print("[WARN] colpali-engine not installed, trying transformers directly")
|
| 29 |
from transformers import AutoModel, AutoProcessor
|
| 30 |
-
|
| 31 |
for model_name in MODELS_TO_DOWNLOAD:
|
| 32 |
print(f"\n[INFO] Downloading model: {model_name}")
|
| 33 |
try:
|
|
@@ -37,12 +38,19 @@ def download_colpali_models():
|
|
| 37 |
except Exception as e:
|
| 38 |
print(f"[WARN] Could not download {model_name}: {e}")
|
| 39 |
return
|
| 40 |
-
|
| 41 |
for model_name in MODELS_TO_DOWNLOAD:
|
| 42 |
print(f"\n[INFO] Downloading model: {model_name}")
|
| 43 |
try:
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
from colpali_engine.models import ColQwen2, ColQwen2Processor
|
|
|
|
| 46 |
ColQwen2.from_pretrained(model_name, trust_remote_code=True)
|
| 47 |
ColQwen2Processor.from_pretrained(model_name, trust_remote_code=True)
|
| 48 |
else:
|
|
@@ -53,6 +61,7 @@ def download_colpali_models():
|
|
| 53 |
print(f"[WARN] Could not download {model_name} with colpali-engine: {e}")
|
| 54 |
try:
|
| 55 |
from transformers import AutoModel, AutoProcessor
|
|
|
|
| 56 |
AutoModel.from_pretrained(model_name, trust_remote_code=True)
|
| 57 |
AutoProcessor.from_pretrained(model_name, trust_remote_code=True)
|
| 58 |
print(f"[OK] Downloaded via transformers: {model_name}")
|
|
@@ -63,9 +72,9 @@ def download_colpali_models():
|
|
| 63 |
def main():
|
| 64 |
print(f"[INFO] HF_HOME: {os.environ.get('HF_HOME', 'not set')}")
|
| 65 |
print(f"[INFO] Cache dir: {os.environ.get('TRANSFORMERS_CACHE', 'not set')}")
|
| 66 |
-
|
| 67 |
download_colpali_models()
|
| 68 |
-
|
| 69 |
print("\n" + "=" * 60)
|
| 70 |
print("Model download complete!")
|
| 71 |
print("=" * 60)
|
|
|
|
| 6 |
"""
|
| 7 |
|
| 8 |
import os
|
|
|
|
| 9 |
|
| 10 |
os.environ.setdefault("HF_HOME", "/app/.cache/huggingface")
|
| 11 |
os.environ.setdefault("TRANSFORMERS_CACHE", "/app/.cache/huggingface")
|
|
|
|
| 13 |
MODELS_TO_DOWNLOAD = [
|
| 14 |
"vidore/colpali-v1.3",
|
| 15 |
"vidore/colSmol-500M",
|
| 16 |
+
"vidore/colqwen2.5-v0.2",
|
| 17 |
]
|
| 18 |
|
| 19 |
+
|
| 20 |
def download_colpali_models():
|
| 21 |
"""Download ColPali models and their processors."""
|
| 22 |
print("=" * 60)
|
| 23 |
print("Downloading ColPali models for Visual RAG Toolkit")
|
| 24 |
print("=" * 60)
|
| 25 |
+
|
| 26 |
try:
|
| 27 |
from colpali_engine.models import ColPali, ColPaliProcessor
|
| 28 |
except ImportError:
|
| 29 |
print("[WARN] colpali-engine not installed, trying transformers directly")
|
| 30 |
from transformers import AutoModel, AutoProcessor
|
| 31 |
+
|
| 32 |
for model_name in MODELS_TO_DOWNLOAD:
|
| 33 |
print(f"\n[INFO] Downloading model: {model_name}")
|
| 34 |
try:
|
|
|
|
| 38 |
except Exception as e:
|
| 39 |
print(f"[WARN] Could not download {model_name}: {e}")
|
| 40 |
return
|
| 41 |
+
|
| 42 |
for model_name in MODELS_TO_DOWNLOAD:
|
| 43 |
print(f"\n[INFO] Downloading model: {model_name}")
|
| 44 |
try:
|
| 45 |
+
model_lower = model_name.lower()
|
| 46 |
+
if "colqwen2.5" in model_lower or "colqwen2_5" in model_lower:
|
| 47 |
+
from colpali_engine.models import ColQwen2_5, ColQwen2_5_Processor
|
| 48 |
+
|
| 49 |
+
ColQwen2_5.from_pretrained(model_name, trust_remote_code=True)
|
| 50 |
+
ColQwen2_5_Processor.from_pretrained(model_name, trust_remote_code=True)
|
| 51 |
+
elif "colqwen" in model_lower:
|
| 52 |
from colpali_engine.models import ColQwen2, ColQwen2Processor
|
| 53 |
+
|
| 54 |
ColQwen2.from_pretrained(model_name, trust_remote_code=True)
|
| 55 |
ColQwen2Processor.from_pretrained(model_name, trust_remote_code=True)
|
| 56 |
else:
|
|
|
|
| 61 |
print(f"[WARN] Could not download {model_name} with colpali-engine: {e}")
|
| 62 |
try:
|
| 63 |
from transformers import AutoModel, AutoProcessor
|
| 64 |
+
|
| 65 |
AutoModel.from_pretrained(model_name, trust_remote_code=True)
|
| 66 |
AutoProcessor.from_pretrained(model_name, trust_remote_code=True)
|
| 67 |
print(f"[OK] Downloaded via transformers: {model_name}")
|
|
|
|
| 72 |
def main():
|
| 73 |
print(f"[INFO] HF_HOME: {os.environ.get('HF_HOME', 'not set')}")
|
| 74 |
print(f"[INFO] Cache dir: {os.environ.get('TRANSFORMERS_CACHE', 'not set')}")
|
| 75 |
+
|
| 76 |
download_colpali_models()
|
| 77 |
+
|
| 78 |
print("\n" + "=" * 60)
|
| 79 |
print("Model download complete!")
|
| 80 |
print("=" * 60)
|
demo/evaluation.py
CHANGED
|
@@ -13,12 +13,11 @@ import streamlit as st
|
|
| 13 |
import torch
|
| 14 |
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
| 15 |
|
| 16 |
-
from visual_rag import VisualEmbedder
|
| 17 |
-
from visual_rag.retrieval import MultiVectorRetriever
|
| 18 |
from benchmarks.vidore_tatdqa_test.dataset_loader import load_vidore_beir_dataset
|
| 19 |
-
from benchmarks.vidore_tatdqa_test.metrics import
|
| 20 |
from demo.qdrant_utils import get_qdrant_credentials
|
| 21 |
-
|
|
|
|
| 22 |
|
| 23 |
TORCH_DTYPE_MAP = {
|
| 24 |
"float16": torch.float16,
|
|
@@ -36,7 +35,9 @@ def _stable_uuid(text: str) -> str:
|
|
| 36 |
return f"{hex_str[:8]}-{hex_str[8:12]}-{hex_str[12:16]}-{hex_str[16:20]}-{hex_str[20:32]}"
|
| 37 |
|
| 38 |
|
| 39 |
-
def _union_point_id(
|
|
|
|
|
|
|
| 40 |
"""Generate union point ID (same as benchmark script)."""
|
| 41 |
ns = f"{union_namespace}::{dataset_name}" if union_namespace else dataset_name
|
| 42 |
return _stable_uuid(f"{ns}::{source_doc_id}")
|
|
@@ -57,7 +58,7 @@ def _remap_qrels_to_union_ids(
|
|
| 57 |
source_doc_id=source_doc_id,
|
| 58 |
union_namespace=collection_name,
|
| 59 |
)
|
| 60 |
-
|
| 61 |
remapped: Dict[str, Dict[str, int]] = {}
|
| 62 |
for qid, rels in qrels.items():
|
| 63 |
out_rels: Dict[str, int] = {}
|
|
@@ -72,7 +73,7 @@ def _remap_qrels_to_union_ids(
|
|
| 72 |
|
| 73 |
def get_doc_id_from_result(r: Dict[str, Any], use_original: bool = True) -> str:
|
| 74 |
"""Extract document ID from search result.
|
| 75 |
-
|
| 76 |
Args:
|
| 77 |
r: Search result dict with 'id' and 'payload'
|
| 78 |
use_original: If True, prefer original doc_id for matching with qrels.
|
|
@@ -88,58 +89,54 @@ def get_doc_id_from_result(r: Dict[str, Any], use_original: bool = True) -> str:
|
|
| 88 |
or str(r.get("id", ""))
|
| 89 |
)
|
| 90 |
else:
|
| 91 |
-
doc_id = (
|
| 92 |
-
payload.get("union_doc_id")
|
| 93 |
-
or str(r.get("id", ""))
|
| 94 |
-
or payload.get("doc_id")
|
| 95 |
-
)
|
| 96 |
return str(doc_id)
|
| 97 |
|
| 98 |
|
| 99 |
def run_evaluation_with_ui(config: Dict[str, Any]):
|
| 100 |
st.divider()
|
| 101 |
-
|
| 102 |
print("=" * 60)
|
| 103 |
print("[EVAL] Starting evaluation via UI")
|
| 104 |
print("=" * 60)
|
| 105 |
-
|
| 106 |
url, api_key = get_qdrant_credentials()
|
| 107 |
if not url:
|
| 108 |
st.error("QDRANT_URL not configured")
|
| 109 |
return
|
| 110 |
-
|
| 111 |
datasets = config.get("datasets", [])
|
| 112 |
collection = config["collection"]
|
| 113 |
model = config.get("model", "vidore/colpali-v1.3")
|
| 114 |
mode = config.get("mode", "single_full")
|
| 115 |
top_k = config.get("top_k", 100)
|
| 116 |
prefetch_k = config.get("prefetch_k", 256)
|
| 117 |
-
stage1_mode = config.get("stage1_mode", "
|
| 118 |
stage1_k = config.get("stage1_k", 1000)
|
| 119 |
stage2_k = config.get("stage2_k", 300)
|
| 120 |
prefer_grpc = config.get("prefer_grpc", True)
|
| 121 |
torch_dtype = config.get("torch_dtype", "float16")
|
| 122 |
evaluation_scope = config.get("evaluation_scope", "union")
|
| 123 |
-
|
| 124 |
-
print(
|
| 125 |
print(f"[EVAL] Collection: {collection}")
|
| 126 |
print(f"[EVAL] Model: {model}")
|
| 127 |
print(f"[EVAL] Mode: {mode}, Scope: {evaluation_scope}")
|
| 128 |
print(f"[EVAL] Datasets: {datasets}")
|
| 129 |
print(f"[EVAL] Query embedding dtype: {torch_dtype} (vectors already indexed)")
|
| 130 |
-
print(
|
| 131 |
-
|
| 132 |
phase1_container = st.container()
|
| 133 |
phase2_container = st.container()
|
| 134 |
phase3_container = st.container()
|
| 135 |
results_container = st.container()
|
| 136 |
-
|
| 137 |
try:
|
| 138 |
with phase1_container:
|
| 139 |
st.markdown("##### 🤖 Phase 1: Loading Model")
|
| 140 |
model_status = st.empty()
|
| 141 |
model_status.info(f"Loading `{model.split('/')[-1]}`...")
|
| 142 |
-
|
| 143 |
print(f"[EVAL] Loading embedder: {model}")
|
| 144 |
torch_dtype_obj = TORCH_DTYPE_MAP.get(torch_dtype, torch.float16)
|
| 145 |
qdrant_dtype = config.get("qdrant_vector_dtype", "float16")
|
|
@@ -150,13 +147,15 @@ def run_evaluation_with_ui(config: Dict[str, Any]):
|
|
| 150 |
output_dtype=output_dtype_obj,
|
| 151 |
)
|
| 152 |
embedder._load_model()
|
| 153 |
-
print(
|
| 154 |
-
|
|
|
|
|
|
|
| 155 |
model_status.success(f"✅ Model `{model.split('/')[-1]}` loaded")
|
| 156 |
-
|
| 157 |
retriever_status = st.empty()
|
| 158 |
retriever_status.info(f"Connecting to collection `{collection}`...")
|
| 159 |
-
|
| 160 |
print(f"[EVAL] Connecting to Qdrant collection: {collection}")
|
| 161 |
retriever = MultiVectorRetriever(
|
| 162 |
collection_name=collection,
|
|
@@ -166,31 +165,33 @@ def run_evaluation_with_ui(config: Dict[str, Any]):
|
|
| 166 |
prefer_grpc=prefer_grpc,
|
| 167 |
embedder=embedder,
|
| 168 |
)
|
| 169 |
-
print(
|
| 170 |
retriever_status.success(f"✅ Connected to `{collection}`")
|
| 171 |
-
|
| 172 |
with phase2_container:
|
| 173 |
st.markdown("##### 📚 Phase 2: Loading Datasets")
|
| 174 |
-
|
| 175 |
dataset_data = {}
|
| 176 |
total_queries = 0
|
| 177 |
max_queries_per_ds = config.get("max_queries")
|
| 178 |
-
|
| 179 |
for ds_name in datasets:
|
| 180 |
ds_status = st.empty()
|
| 181 |
ds_short = ds_name.split("/")[-1]
|
| 182 |
ds_status.info(f"Loading `{ds_short}`...")
|
| 183 |
-
|
| 184 |
print(f"[EVAL] Loading dataset: {ds_name}")
|
| 185 |
corpus, queries, qrels = load_vidore_beir_dataset(ds_name)
|
| 186 |
-
|
| 187 |
print(f"[EVAL] Remapping qrels to union_doc_id format for collection={collection}")
|
| 188 |
remapped_qrels = _remap_qrels_to_union_ids(qrels, corpus, ds_name, collection)
|
| 189 |
-
print(
|
| 190 |
-
|
|
|
|
|
|
|
| 191 |
if evaluation_scope == "per_dataset" and max_queries_per_ds:
|
| 192 |
queries = queries[:max_queries_per_ds]
|
| 193 |
-
|
| 194 |
dataset_data[ds_name] = {
|
| 195 |
"queries": queries,
|
| 196 |
"qrels": remapped_qrels,
|
|
@@ -199,69 +200,85 @@ def run_evaluation_with_ui(config: Dict[str, Any]):
|
|
| 199 |
total_queries += len(queries)
|
| 200 |
print(f"[EVAL] Loaded {ds_name}: {len(corpus)} docs, {len(queries)} queries")
|
| 201 |
ds_status.success(f"✅ `{ds_short}`: {len(corpus)} docs, {len(queries)} queries")
|
| 202 |
-
|
| 203 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
total_queries = max_queries_per_ds
|
| 205 |
print(f"[EVAL] Will limit to {total_queries} total queries (union mode)")
|
| 206 |
-
|
| 207 |
embed_status = st.empty()
|
| 208 |
-
embed_status.info(
|
| 209 |
-
|
| 210 |
with phase3_container:
|
| 211 |
st.markdown("##### 🎯 Phase 3: Running Evaluation")
|
| 212 |
-
|
| 213 |
metrics_collectors = {
|
| 214 |
-
"ndcg@5": [],
|
| 215 |
-
"
|
| 216 |
-
"
|
|
|
|
|
|
|
|
|
|
| 217 |
}
|
| 218 |
latencies = []
|
| 219 |
log_lines = []
|
| 220 |
metrics_by_dataset = {}
|
| 221 |
-
|
| 222 |
if evaluation_scope == "per_dataset":
|
| 223 |
overall_progress = st.progress(0.0)
|
| 224 |
datasets_done = 0
|
| 225 |
-
|
| 226 |
for ds_name, ds_info in dataset_data.items():
|
| 227 |
ds_short = ds_name.split("/")[-1]
|
| 228 |
st.markdown(f"**Evaluating `{ds_short}`**")
|
| 229 |
-
|
| 230 |
queries = ds_info["queries"]
|
| 231 |
qrels = ds_info["qrels"]
|
| 232 |
-
|
| 233 |
if not queries:
|
| 234 |
continue
|
| 235 |
-
|
| 236 |
print(f"[EVAL] Embedding {len(queries)} queries for {ds_short}...")
|
| 237 |
query_texts = [q.text for q in queries]
|
| 238 |
query_embeddings = embedder.embed_queries(query_texts, show_progress=False)
|
| 239 |
print(f"[EVAL] Queries embedded for {ds_short}")
|
| 240 |
-
|
| 241 |
ds_filter = Filter(
|
| 242 |
must=[FieldCondition(key="dataset", match=MatchValue(value=ds_name))]
|
| 243 |
)
|
| 244 |
print(f"[EVAL] Using filter: dataset={ds_name}")
|
| 245 |
-
|
| 246 |
progress_bar = st.progress(0.0)
|
| 247 |
eval_status = st.empty()
|
| 248 |
log_area = st.empty()
|
| 249 |
-
|
| 250 |
-
ds_metrics = {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
ds_latencies = []
|
| 252 |
ds_log_lines = []
|
| 253 |
-
|
| 254 |
eval_status.info(f"Evaluating {len(queries)} queries...")
|
| 255 |
-
print(
|
| 256 |
-
|
|
|
|
|
|
|
| 257 |
for i, (q, qemb) in enumerate(zip(queries, query_embeddings)):
|
| 258 |
start = time.time()
|
| 259 |
-
|
| 260 |
if isinstance(qemb, torch.Tensor):
|
| 261 |
qemb_np = qemb.detach().cpu().numpy()
|
| 262 |
else:
|
| 263 |
-
qemb_np = qemb.numpy() if hasattr(qemb,
|
| 264 |
-
|
| 265 |
results = retriever.search_embedded(
|
| 266 |
query_embedding=qemb_np,
|
| 267 |
top_k=max(100, top_k),
|
|
@@ -274,61 +291,79 @@ def run_evaluation_with_ui(config: Dict[str, Any]):
|
|
| 274 |
)
|
| 275 |
ds_latencies.append((time.time() - start) * 1000)
|
| 276 |
latencies.append(ds_latencies[-1])
|
| 277 |
-
|
| 278 |
ranking = [str(r["id"]) for r in results]
|
| 279 |
rels = qrels.get(q.query_id, {})
|
| 280 |
-
|
| 281 |
if i == 0:
|
| 282 |
print(f"[EVAL] First query for {ds_short} - query_id: {q.query_id}")
|
| 283 |
print(f"[EVAL] Top 3 retrieved doc_ids: {ranking[:3]}")
|
| 284 |
print(f"[EVAL] Expected doc_ids (qrels): {list(rels.keys())[:3]}")
|
| 285 |
-
print(
|
|
|
|
|
|
|
| 286 |
if results:
|
| 287 |
r0 = results[0]
|
| 288 |
-
print(
|
|
|
|
|
|
|
| 289 |
p = r0.get("payload", {})
|
| 290 |
-
print(
|
|
|
|
|
|
|
| 291 |
has_match = any(rid in rels for rid in ranking[:10])
|
| 292 |
print(f"[EVAL] Any match in top-10? {has_match}")
|
| 293 |
-
|
| 294 |
for k_name, k_val in [("ndcg@5", 5), ("ndcg@10", 10)]:
|
| 295 |
ds_metrics[k_name].append(ndcg_at_k(ranking, rels, k=k_val))
|
| 296 |
for k_name, k_val in [("recall@5", 5), ("recall@10", 10)]:
|
| 297 |
ds_metrics[k_name].append(recall_at_k(ranking, rels, k=k_val))
|
| 298 |
for k_name, k_val in [("mrr@5", 5), ("mrr@10", 10)]:
|
| 299 |
ds_metrics[k_name].append(mrr_at_k(ranking, rels, k=k_val))
|
| 300 |
-
|
| 301 |
progress = (i + 1) / len(queries)
|
| 302 |
progress_bar.progress(progress)
|
| 303 |
-
eval_status.info(
|
| 304 |
-
|
|
|
|
|
|
|
| 305 |
log_interval = max(5, len(queries) // 10)
|
| 306 |
if (i + 1) % log_interval == 0 and i > 0:
|
| 307 |
cur_ndcg = np.mean(ds_metrics["ndcg@10"])
|
| 308 |
-
cur_lat =
|
| 309 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 310 |
log_area.code("\n".join(ds_log_lines[-5:]), language="text")
|
| 311 |
-
print(
|
| 312 |
-
|
|
|
|
|
|
|
| 313 |
progress_bar.progress(1.0)
|
| 314 |
ds_final = {k: float(np.mean(v)) for k, v in ds_metrics.items()}
|
| 315 |
ds_final["avg_latency_ms"] = float(np.mean(ds_latencies))
|
| 316 |
ds_final["num_queries"] = len(queries)
|
| 317 |
metrics_by_dataset[ds_name] = ds_final
|
| 318 |
-
|
| 319 |
for k, v in ds_metrics.items():
|
| 320 |
metrics_collectors[k].extend(v)
|
| 321 |
-
|
| 322 |
-
eval_status.success(
|
|
|
|
|
|
|
| 323 |
print(f"[EVAL] {ds_short} DONE: NDCG@10={ds_final['ndcg@10']:.4f}")
|
| 324 |
-
|
| 325 |
datasets_done += 1
|
| 326 |
overall_progress.progress(datasets_done / len(datasets))
|
| 327 |
-
|
| 328 |
overall_progress.progress(1.0)
|
| 329 |
-
embed_status.success(
|
| 330 |
total_queries = sum(d["num_queries"] for d in metrics_by_dataset.values())
|
| 331 |
-
|
| 332 |
else:
|
| 333 |
all_queries = []
|
| 334 |
all_qrels = {}
|
|
@@ -336,7 +371,7 @@ def run_evaluation_with_ui(config: Dict[str, Any]):
|
|
| 336 |
all_queries.extend(ds_info["queries"])
|
| 337 |
for qid, rels in ds_info["qrels"].items():
|
| 338 |
all_qrels[qid] = rels
|
| 339 |
-
|
| 340 |
sample_qrel_keys = list(all_qrels.keys())[:3]
|
| 341 |
sample_doc_ids = []
|
| 342 |
for qid in sample_qrel_keys:
|
|
@@ -344,33 +379,33 @@ def run_evaluation_with_ui(config: Dict[str, Any]):
|
|
| 344 |
print(f"[EVAL] all_qrels built: {len(all_qrels)} queries")
|
| 345 |
print(f"[EVAL] Sample qrel query_ids: {sample_qrel_keys}")
|
| 346 |
print(f"[EVAL] Sample qrel doc_ids: {sample_doc_ids[:5]}")
|
| 347 |
-
|
| 348 |
max_q = config.get("max_queries")
|
| 349 |
if max_q and max_q < len(all_queries):
|
| 350 |
all_queries = all_queries[:max_q]
|
| 351 |
total_queries = len(all_queries)
|
| 352 |
-
|
| 353 |
print(f"[EVAL] Embedding {total_queries} queries (union mode)...")
|
| 354 |
query_texts = [q.text for q in all_queries]
|
| 355 |
query_embeddings = embedder.embed_queries(query_texts, show_progress=False)
|
| 356 |
-
print(
|
| 357 |
embed_status.success(f"✅ {total_queries} queries embedded")
|
| 358 |
-
|
| 359 |
progress_bar = st.progress(0.0)
|
| 360 |
eval_status = st.empty()
|
| 361 |
log_area = st.empty()
|
| 362 |
-
|
| 363 |
eval_status.info(f"Evaluating {total_queries} queries in `{mode}` mode...")
|
| 364 |
print(f"[EVAL] Starting union evaluation: {total_queries} queries, mode={mode}")
|
| 365 |
-
|
| 366 |
for i, (q, qemb) in enumerate(zip(all_queries, query_embeddings)):
|
| 367 |
start = time.time()
|
| 368 |
-
|
| 369 |
if isinstance(qemb, torch.Tensor):
|
| 370 |
qemb_np = qemb.detach().cpu().numpy()
|
| 371 |
else:
|
| 372 |
-
qemb_np = qemb.numpy() if hasattr(qemb,
|
| 373 |
-
|
| 374 |
results = retriever.search_embedded(
|
| 375 |
query_embedding=qemb_np,
|
| 376 |
top_k=max(100, top_k),
|
|
@@ -381,52 +416,64 @@ def run_evaluation_with_ui(config: Dict[str, Any]):
|
|
| 381 |
stage2_k=stage2_k,
|
| 382 |
)
|
| 383 |
latencies.append((time.time() - start) * 1000)
|
| 384 |
-
|
| 385 |
ranking = [str(r["id"]) for r in results]
|
| 386 |
rels = all_qrels.get(q.query_id, {})
|
| 387 |
-
|
| 388 |
if i == 0:
|
| 389 |
print(f"[EVAL] First query - query_id: {q.query_id}")
|
| 390 |
print(f"[EVAL] Top 3 retrieved doc_ids: {ranking[:3]}")
|
| 391 |
print(f"[EVAL] Expected doc_ids (qrels): {list(rels.keys())[:3]}")
|
| 392 |
-
print(
|
|
|
|
|
|
|
| 393 |
if results:
|
| 394 |
r0 = results[0]
|
| 395 |
-
print(
|
|
|
|
|
|
|
| 396 |
p = r0.get("payload", {})
|
| 397 |
-
print(
|
|
|
|
|
|
|
| 398 |
has_match = any(rid in rels for rid in ranking[:10])
|
| 399 |
print(f"[EVAL] Any match in top-10? {has_match}")
|
| 400 |
-
|
| 401 |
metrics_collectors["ndcg@5"].append(ndcg_at_k(ranking, rels, k=5))
|
| 402 |
metrics_collectors["ndcg@10"].append(ndcg_at_k(ranking, rels, k=10))
|
| 403 |
metrics_collectors["recall@5"].append(recall_at_k(ranking, rels, k=5))
|
| 404 |
metrics_collectors["recall@10"].append(recall_at_k(ranking, rels, k=10))
|
| 405 |
metrics_collectors["mrr@5"].append(mrr_at_k(ranking, rels, k=5))
|
| 406 |
metrics_collectors["mrr@10"].append(mrr_at_k(ranking, rels, k=10))
|
| 407 |
-
|
| 408 |
progress = (i + 1) / total_queries
|
| 409 |
progress_bar.progress(progress)
|
| 410 |
-
eval_status.info(
|
| 411 |
-
|
|
|
|
|
|
|
| 412 |
log_interval = max(10, total_queries // 10)
|
| 413 |
if (i + 1) % log_interval == 0 and i > 0:
|
| 414 |
cur_ndcg = np.mean(metrics_collectors["ndcg@10"])
|
| 415 |
cur_lat = np.mean(latencies[1:]) if len(latencies) > 1 else latencies[0]
|
| 416 |
-
log_lines.append(
|
|
|
|
|
|
|
| 417 |
log_area.code("\n".join(log_lines[-10:]), language="text")
|
| 418 |
-
print(
|
| 419 |
-
|
|
|
|
|
|
|
| 420 |
progress_bar.progress(1.0)
|
| 421 |
eval_status.success(f"✅ Evaluation complete! ({total_queries} queries)")
|
| 422 |
-
|
| 423 |
with results_container:
|
| 424 |
st.markdown("##### 📊 Results")
|
| 425 |
-
|
| 426 |
p95_latency = float(np.percentile(latencies, 95))
|
| 427 |
eval_time_s = sum(latencies) / 1000
|
| 428 |
qps = total_queries / eval_time_s if eval_time_s > 0 else 0
|
| 429 |
-
|
| 430 |
final_metrics = {
|
| 431 |
"ndcg@5": float(np.mean(metrics_collectors["ndcg@5"])),
|
| 432 |
"ndcg@10": float(np.mean(metrics_collectors["ndcg@10"])),
|
|
@@ -440,7 +487,7 @@ def run_evaluation_with_ui(config: Dict[str, Any]):
|
|
| 440 |
"eval_time_s": eval_time_s,
|
| 441 |
"num_queries": total_queries,
|
| 442 |
}
|
| 443 |
-
|
| 444 |
print("=" * 60)
|
| 445 |
print("[EVAL] FINAL RESULTS:")
|
| 446 |
print(f"[EVAL] NDCG@5: {final_metrics['ndcg@5']:.4f}")
|
|
@@ -454,7 +501,7 @@ def run_evaluation_with_ui(config: Dict[str, Any]):
|
|
| 454 |
print(f"[EVAL] QPS: {final_metrics['qps']:.2f}")
|
| 455 |
print(f"[EVAL] Queries: {final_metrics['num_queries']}")
|
| 456 |
print("=" * 60)
|
| 457 |
-
|
| 458 |
st.markdown("**Retrieval Metrics**")
|
| 459 |
c1, c2, c3 = st.columns(3)
|
| 460 |
with c1:
|
|
@@ -466,17 +513,17 @@ def run_evaluation_with_ui(config: Dict[str, Any]):
|
|
| 466 |
with c3:
|
| 467 |
st.metric("MRR@5", f"{final_metrics['mrr@5']:.4f}")
|
| 468 |
st.metric("MRR@10", f"{final_metrics['mrr@10']:.4f}")
|
| 469 |
-
|
| 470 |
st.markdown("**Performance**")
|
| 471 |
c4, c5, c6, c7 = st.columns(4)
|
| 472 |
c4.metric("Avg Latency", f"{final_metrics['avg_latency_ms']:.0f}ms")
|
| 473 |
c5.metric("P95 Latency", f"{final_metrics['p95_latency_ms']:.0f}ms")
|
| 474 |
c6.metric("QPS", f"{final_metrics['qps']:.2f}")
|
| 475 |
c7.metric("Eval Time", f"{final_metrics['eval_time_s']:.1f}s")
|
| 476 |
-
|
| 477 |
with st.expander("📋 Full Results JSON"):
|
| 478 |
st.json(final_metrics)
|
| 479 |
-
|
| 480 |
detailed_report = {
|
| 481 |
"generated_at": datetime.now().isoformat(),
|
| 482 |
"config": {
|
|
@@ -506,17 +553,17 @@ def run_evaluation_with_ui(config: Dict[str, Any]):
|
|
| 506 |
"num_queries": final_metrics["num_queries"],
|
| 507 |
},
|
| 508 |
}
|
| 509 |
-
|
| 510 |
if mode == "two_stage":
|
| 511 |
detailed_report["config"]["stage1_mode"] = stage1_mode
|
| 512 |
detailed_report["config"]["prefetch_k"] = prefetch_k
|
| 513 |
elif mode == "three_stage":
|
| 514 |
detailed_report["config"]["stage1_k"] = stage1_k
|
| 515 |
detailed_report["config"]["stage2_k"] = stage2_k
|
| 516 |
-
|
| 517 |
if evaluation_scope == "per_dataset" and metrics_by_dataset:
|
| 518 |
detailed_report["metrics_by_dataset"] = metrics_by_dataset
|
| 519 |
-
|
| 520 |
st.markdown("**Per-Dataset Results**")
|
| 521 |
for ds_name, ds_metrics in metrics_by_dataset.items():
|
| 522 |
ds_short = ds_name.split("/")[-1]
|
|
@@ -526,11 +573,11 @@ def run_evaluation_with_ui(config: Dict[str, Any]):
|
|
| 526 |
dc2.metric("Recall@10", f"{ds_metrics['recall@10']:.4f}")
|
| 527 |
dc3.metric("MRR@10", f"{ds_metrics['mrr@10']:.4f}")
|
| 528 |
dc4.metric("Latency", f"{ds_metrics['avg_latency_ms']:.0f}ms")
|
| 529 |
-
|
| 530 |
report_json = json.dumps(detailed_report, indent=2)
|
| 531 |
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 532 |
filename = f"eval_report__{collection}__{mode}__{timestamp}.json"
|
| 533 |
-
|
| 534 |
st.download_button(
|
| 535 |
label="📥 Download Detailed Report",
|
| 536 |
data=report_json,
|
|
@@ -538,25 +585,31 @@ def run_evaluation_with_ui(config: Dict[str, Any]):
|
|
| 538 |
mime="application/json",
|
| 539 |
use_container_width=True,
|
| 540 |
)
|
| 541 |
-
|
| 542 |
st.session_state["last_eval_metrics"] = final_metrics
|
| 543 |
-
|
| 544 |
except Exception as e:
|
| 545 |
error_msg = str(e)
|
| 546 |
-
|
| 547 |
if "not configured in this collection" in error_msg:
|
| 548 |
-
vector_name =
|
| 549 |
-
|
| 550 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 551 |
**The selected mode `{mode}` requires vectors that don't exist in this collection.**
|
| 552 |
|
| 553 |
**Suggestions:**
|
| 554 |
- Try `single_full` mode (works with basic collections)
|
| 555 |
- Use a collection indexed with the Visual RAG Toolkit
|
| 556 |
- Check that the collection has the required vector types for `{mode}` mode
|
| 557 |
-
"""
|
|
|
|
| 558 |
else:
|
| 559 |
st.error(f"❌ Error: {e}")
|
| 560 |
-
|
| 561 |
with st.expander("🔍 Full Error Details"):
|
| 562 |
st.code(traceback.format_exc(), language="text")
|
|
|
|
| 13 |
import torch
|
| 14 |
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
| 15 |
|
|
|
|
|
|
|
| 16 |
from benchmarks.vidore_tatdqa_test.dataset_loader import load_vidore_beir_dataset
|
| 17 |
+
from benchmarks.vidore_tatdqa_test.metrics import mrr_at_k, ndcg_at_k, recall_at_k
|
| 18 |
from demo.qdrant_utils import get_qdrant_credentials
|
| 19 |
+
from visual_rag import VisualEmbedder
|
| 20 |
+
from visual_rag.retrieval import MultiVectorRetriever
|
| 21 |
|
| 22 |
TORCH_DTYPE_MAP = {
|
| 23 |
"float16": torch.float16,
|
|
|
|
| 35 |
return f"{hex_str[:8]}-{hex_str[8:12]}-{hex_str[12:16]}-{hex_str[16:20]}-{hex_str[20:32]}"
|
| 36 |
|
| 37 |
|
| 38 |
+
def _union_point_id(
|
| 39 |
+
*, dataset_name: str, source_doc_id: str, union_namespace: Optional[str]
|
| 40 |
+
) -> str:
|
| 41 |
"""Generate union point ID (same as benchmark script)."""
|
| 42 |
ns = f"{union_namespace}::{dataset_name}" if union_namespace else dataset_name
|
| 43 |
return _stable_uuid(f"{ns}::{source_doc_id}")
|
|
|
|
| 58 |
source_doc_id=source_doc_id,
|
| 59 |
union_namespace=collection_name,
|
| 60 |
)
|
| 61 |
+
|
| 62 |
remapped: Dict[str, Dict[str, int]] = {}
|
| 63 |
for qid, rels in qrels.items():
|
| 64 |
out_rels: Dict[str, int] = {}
|
|
|
|
| 73 |
|
| 74 |
def get_doc_id_from_result(r: Dict[str, Any], use_original: bool = True) -> str:
|
| 75 |
"""Extract document ID from search result.
|
| 76 |
+
|
| 77 |
Args:
|
| 78 |
r: Search result dict with 'id' and 'payload'
|
| 79 |
use_original: If True, prefer original doc_id for matching with qrels.
|
|
|
|
| 89 |
or str(r.get("id", ""))
|
| 90 |
)
|
| 91 |
else:
|
| 92 |
+
doc_id = payload.get("union_doc_id") or str(r.get("id", "")) or payload.get("doc_id")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
return str(doc_id)
|
| 94 |
|
| 95 |
|
| 96 |
def run_evaluation_with_ui(config: Dict[str, Any]):
|
| 97 |
st.divider()
|
| 98 |
+
|
| 99 |
print("=" * 60)
|
| 100 |
print("[EVAL] Starting evaluation via UI")
|
| 101 |
print("=" * 60)
|
| 102 |
+
|
| 103 |
url, api_key = get_qdrant_credentials()
|
| 104 |
if not url:
|
| 105 |
st.error("QDRANT_URL not configured")
|
| 106 |
return
|
| 107 |
+
|
| 108 |
datasets = config.get("datasets", [])
|
| 109 |
collection = config["collection"]
|
| 110 |
model = config.get("model", "vidore/colpali-v1.3")
|
| 111 |
mode = config.get("mode", "single_full")
|
| 112 |
top_k = config.get("top_k", 100)
|
| 113 |
prefetch_k = config.get("prefetch_k", 256)
|
| 114 |
+
stage1_mode = config.get("stage1_mode", "tokens_vs_standard_pooling")
|
| 115 |
stage1_k = config.get("stage1_k", 1000)
|
| 116 |
stage2_k = config.get("stage2_k", 300)
|
| 117 |
prefer_grpc = config.get("prefer_grpc", True)
|
| 118 |
torch_dtype = config.get("torch_dtype", "float16")
|
| 119 |
evaluation_scope = config.get("evaluation_scope", "union")
|
| 120 |
+
|
| 121 |
+
print("[EVAL] ═══════════════════════════════════════════════════")
|
| 122 |
print(f"[EVAL] Collection: {collection}")
|
| 123 |
print(f"[EVAL] Model: {model}")
|
| 124 |
print(f"[EVAL] Mode: {mode}, Scope: {evaluation_scope}")
|
| 125 |
print(f"[EVAL] Datasets: {datasets}")
|
| 126 |
print(f"[EVAL] Query embedding dtype: {torch_dtype} (vectors already indexed)")
|
| 127 |
+
print("[EVAL] ═══════════════════════════════════════════════════")
|
| 128 |
+
|
| 129 |
phase1_container = st.container()
|
| 130 |
phase2_container = st.container()
|
| 131 |
phase3_container = st.container()
|
| 132 |
results_container = st.container()
|
| 133 |
+
|
| 134 |
try:
|
| 135 |
with phase1_container:
|
| 136 |
st.markdown("##### 🤖 Phase 1: Loading Model")
|
| 137 |
model_status = st.empty()
|
| 138 |
model_status.info(f"Loading `{model.split('/')[-1]}`...")
|
| 139 |
+
|
| 140 |
print(f"[EVAL] Loading embedder: {model}")
|
| 141 |
torch_dtype_obj = TORCH_DTYPE_MAP.get(torch_dtype, torch.float16)
|
| 142 |
qdrant_dtype = config.get("qdrant_vector_dtype", "float16")
|
|
|
|
| 147 |
output_dtype=output_dtype_obj,
|
| 148 |
)
|
| 149 |
embedder._load_model()
|
| 150 |
+
print(
|
| 151 |
+
f"[EVAL] Embedder loaded (torch_dtype={torch_dtype}, output_dtype={qdrant_dtype})"
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
model_status.success(f"✅ Model `{model.split('/')[-1]}` loaded")
|
| 155 |
+
|
| 156 |
retriever_status = st.empty()
|
| 157 |
retriever_status.info(f"Connecting to collection `{collection}`...")
|
| 158 |
+
|
| 159 |
print(f"[EVAL] Connecting to Qdrant collection: {collection}")
|
| 160 |
retriever = MultiVectorRetriever(
|
| 161 |
collection_name=collection,
|
|
|
|
| 165 |
prefer_grpc=prefer_grpc,
|
| 166 |
embedder=embedder,
|
| 167 |
)
|
| 168 |
+
print("[EVAL] Connected to Qdrant")
|
| 169 |
retriever_status.success(f"✅ Connected to `{collection}`")
|
| 170 |
+
|
| 171 |
with phase2_container:
|
| 172 |
st.markdown("##### 📚 Phase 2: Loading Datasets")
|
| 173 |
+
|
| 174 |
dataset_data = {}
|
| 175 |
total_queries = 0
|
| 176 |
max_queries_per_ds = config.get("max_queries")
|
| 177 |
+
|
| 178 |
for ds_name in datasets:
|
| 179 |
ds_status = st.empty()
|
| 180 |
ds_short = ds_name.split("/")[-1]
|
| 181 |
ds_status.info(f"Loading `{ds_short}`...")
|
| 182 |
+
|
| 183 |
print(f"[EVAL] Loading dataset: {ds_name}")
|
| 184 |
corpus, queries, qrels = load_vidore_beir_dataset(ds_name)
|
| 185 |
+
|
| 186 |
print(f"[EVAL] Remapping qrels to union_doc_id format for collection={collection}")
|
| 187 |
remapped_qrels = _remap_qrels_to_union_ids(qrels, corpus, ds_name, collection)
|
| 188 |
+
print(
|
| 189 |
+
f"[EVAL] Remapped {len(qrels)} -> {len(remapped_qrels)} queries with valid rels"
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
if evaluation_scope == "per_dataset" and max_queries_per_ds:
|
| 193 |
queries = queries[:max_queries_per_ds]
|
| 194 |
+
|
| 195 |
dataset_data[ds_name] = {
|
| 196 |
"queries": queries,
|
| 197 |
"qrels": remapped_qrels,
|
|
|
|
| 200 |
total_queries += len(queries)
|
| 201 |
print(f"[EVAL] Loaded {ds_name}: {len(corpus)} docs, {len(queries)} queries")
|
| 202 |
ds_status.success(f"✅ `{ds_short}`: {len(corpus)} docs, {len(queries)} queries")
|
| 203 |
+
|
| 204 |
+
if (
|
| 205 |
+
evaluation_scope == "union"
|
| 206 |
+
and max_queries_per_ds
|
| 207 |
+
and max_queries_per_ds < total_queries
|
| 208 |
+
):
|
| 209 |
total_queries = max_queries_per_ds
|
| 210 |
print(f"[EVAL] Will limit to {total_queries} total queries (union mode)")
|
| 211 |
+
|
| 212 |
embed_status = st.empty()
|
| 213 |
+
embed_status.info("Embedding queries...")
|
| 214 |
+
|
| 215 |
with phase3_container:
|
| 216 |
st.markdown("##### 🎯 Phase 3: Running Evaluation")
|
| 217 |
+
|
| 218 |
metrics_collectors = {
|
| 219 |
+
"ndcg@5": [],
|
| 220 |
+
"ndcg@10": [],
|
| 221 |
+
"recall@5": [],
|
| 222 |
+
"recall@10": [],
|
| 223 |
+
"mrr@5": [],
|
| 224 |
+
"mrr@10": [],
|
| 225 |
}
|
| 226 |
latencies = []
|
| 227 |
log_lines = []
|
| 228 |
metrics_by_dataset = {}
|
| 229 |
+
|
| 230 |
if evaluation_scope == "per_dataset":
|
| 231 |
overall_progress = st.progress(0.0)
|
| 232 |
datasets_done = 0
|
| 233 |
+
|
| 234 |
for ds_name, ds_info in dataset_data.items():
|
| 235 |
ds_short = ds_name.split("/")[-1]
|
| 236 |
st.markdown(f"**Evaluating `{ds_short}`**")
|
| 237 |
+
|
| 238 |
queries = ds_info["queries"]
|
| 239 |
qrels = ds_info["qrels"]
|
| 240 |
+
|
| 241 |
if not queries:
|
| 242 |
continue
|
| 243 |
+
|
| 244 |
print(f"[EVAL] Embedding {len(queries)} queries for {ds_short}...")
|
| 245 |
query_texts = [q.text for q in queries]
|
| 246 |
query_embeddings = embedder.embed_queries(query_texts, show_progress=False)
|
| 247 |
print(f"[EVAL] Queries embedded for {ds_short}")
|
| 248 |
+
|
| 249 |
ds_filter = Filter(
|
| 250 |
must=[FieldCondition(key="dataset", match=MatchValue(value=ds_name))]
|
| 251 |
)
|
| 252 |
print(f"[EVAL] Using filter: dataset={ds_name}")
|
| 253 |
+
|
| 254 |
progress_bar = st.progress(0.0)
|
| 255 |
eval_status = st.empty()
|
| 256 |
log_area = st.empty()
|
| 257 |
+
|
| 258 |
+
ds_metrics = {
|
| 259 |
+
"ndcg@5": [],
|
| 260 |
+
"ndcg@10": [],
|
| 261 |
+
"recall@5": [],
|
| 262 |
+
"recall@10": [],
|
| 263 |
+
"mrr@5": [],
|
| 264 |
+
"mrr@10": [],
|
| 265 |
+
}
|
| 266 |
ds_latencies = []
|
| 267 |
ds_log_lines = []
|
| 268 |
+
|
| 269 |
eval_status.info(f"Evaluating {len(queries)} queries...")
|
| 270 |
+
print(
|
| 271 |
+
f"[EVAL] Starting per-dataset evaluation: {ds_short}, {len(queries)} queries"
|
| 272 |
+
)
|
| 273 |
+
|
| 274 |
for i, (q, qemb) in enumerate(zip(queries, query_embeddings)):
|
| 275 |
start = time.time()
|
| 276 |
+
|
| 277 |
if isinstance(qemb, torch.Tensor):
|
| 278 |
qemb_np = qemb.detach().cpu().numpy()
|
| 279 |
else:
|
| 280 |
+
qemb_np = qemb.numpy() if hasattr(qemb, "numpy") else np.array(qemb)
|
| 281 |
+
|
| 282 |
results = retriever.search_embedded(
|
| 283 |
query_embedding=qemb_np,
|
| 284 |
top_k=max(100, top_k),
|
|
|
|
| 291 |
)
|
| 292 |
ds_latencies.append((time.time() - start) * 1000)
|
| 293 |
latencies.append(ds_latencies[-1])
|
| 294 |
+
|
| 295 |
ranking = [str(r["id"]) for r in results]
|
| 296 |
rels = qrels.get(q.query_id, {})
|
| 297 |
+
|
| 298 |
if i == 0:
|
| 299 |
print(f"[EVAL] First query for {ds_short} - query_id: {q.query_id}")
|
| 300 |
print(f"[EVAL] Top 3 retrieved doc_ids: {ranking[:3]}")
|
| 301 |
print(f"[EVAL] Expected doc_ids (qrels): {list(rels.keys())[:3]}")
|
| 302 |
+
print(
|
| 303 |
+
f"[EVAL] qrels has {len(qrels)} queries, this query in qrels: {q.query_id in qrels}"
|
| 304 |
+
)
|
| 305 |
if results:
|
| 306 |
r0 = results[0]
|
| 307 |
+
print(
|
| 308 |
+
f"[EVAL] Sample result payload keys: {list(r0.get('payload', {}).keys())}"
|
| 309 |
+
)
|
| 310 |
p = r0.get("payload", {})
|
| 311 |
+
print(
|
| 312 |
+
f"[EVAL] Sample payload doc_id={p.get('doc_id')}, union_doc_id={p.get('union_doc_id')}, source_doc_id={p.get('source_doc_id')}"
|
| 313 |
+
)
|
| 314 |
has_match = any(rid in rels for rid in ranking[:10])
|
| 315 |
print(f"[EVAL] Any match in top-10? {has_match}")
|
| 316 |
+
|
| 317 |
for k_name, k_val in [("ndcg@5", 5), ("ndcg@10", 10)]:
|
| 318 |
ds_metrics[k_name].append(ndcg_at_k(ranking, rels, k=k_val))
|
| 319 |
for k_name, k_val in [("recall@5", 5), ("recall@10", 10)]:
|
| 320 |
ds_metrics[k_name].append(recall_at_k(ranking, rels, k=k_val))
|
| 321 |
for k_name, k_val in [("mrr@5", 5), ("mrr@10", 10)]:
|
| 322 |
ds_metrics[k_name].append(mrr_at_k(ranking, rels, k=k_val))
|
| 323 |
+
|
| 324 |
progress = (i + 1) / len(queries)
|
| 325 |
progress_bar.progress(progress)
|
| 326 |
+
eval_status.info(
|
| 327 |
+
f"🎯 {i+1}/{len(queries)} ({int(progress*100)}%) — latency: {np.mean(ds_latencies):.0f}ms"
|
| 328 |
+
)
|
| 329 |
+
|
| 330 |
log_interval = max(5, len(queries) // 10)
|
| 331 |
if (i + 1) % log_interval == 0 and i > 0:
|
| 332 |
cur_ndcg = np.mean(ds_metrics["ndcg@10"])
|
| 333 |
+
cur_lat = (
|
| 334 |
+
np.mean(ds_latencies[1:])
|
| 335 |
+
if len(ds_latencies) > 1
|
| 336 |
+
else ds_latencies[0]
|
| 337 |
+
)
|
| 338 |
+
ds_log_lines.append(
|
| 339 |
+
f"[{i+1}/{len(queries)}] NDCG@10={cur_ndcg:.4f}, lat={cur_lat:.0f}ms"
|
| 340 |
+
)
|
| 341 |
log_area.code("\n".join(ds_log_lines[-5:]), language="text")
|
| 342 |
+
print(
|
| 343 |
+
f"[EVAL] {ds_short} {i+1}/{len(queries)}: NDCG@10={cur_ndcg:.4f}, lat={cur_lat:.0f}ms"
|
| 344 |
+
)
|
| 345 |
+
|
| 346 |
progress_bar.progress(1.0)
|
| 347 |
ds_final = {k: float(np.mean(v)) for k, v in ds_metrics.items()}
|
| 348 |
ds_final["avg_latency_ms"] = float(np.mean(ds_latencies))
|
| 349 |
ds_final["num_queries"] = len(queries)
|
| 350 |
metrics_by_dataset[ds_name] = ds_final
|
| 351 |
+
|
| 352 |
for k, v in ds_metrics.items():
|
| 353 |
metrics_collectors[k].extend(v)
|
| 354 |
+
|
| 355 |
+
eval_status.success(
|
| 356 |
+
f"✅ `{ds_short}`: NDCG@10={ds_final['ndcg@10']:.4f}, latency={ds_final['avg_latency_ms']:.0f}ms"
|
| 357 |
+
)
|
| 358 |
print(f"[EVAL] {ds_short} DONE: NDCG@10={ds_final['ndcg@10']:.4f}")
|
| 359 |
+
|
| 360 |
datasets_done += 1
|
| 361 |
overall_progress.progress(datasets_done / len(datasets))
|
| 362 |
+
|
| 363 |
overall_progress.progress(1.0)
|
| 364 |
+
embed_status.success("✅ All queries embedded")
|
| 365 |
total_queries = sum(d["num_queries"] for d in metrics_by_dataset.values())
|
| 366 |
+
|
| 367 |
else:
|
| 368 |
all_queries = []
|
| 369 |
all_qrels = {}
|
|
|
|
| 371 |
all_queries.extend(ds_info["queries"])
|
| 372 |
for qid, rels in ds_info["qrels"].items():
|
| 373 |
all_qrels[qid] = rels
|
| 374 |
+
|
| 375 |
sample_qrel_keys = list(all_qrels.keys())[:3]
|
| 376 |
sample_doc_ids = []
|
| 377 |
for qid in sample_qrel_keys:
|
|
|
|
| 379 |
print(f"[EVAL] all_qrels built: {len(all_qrels)} queries")
|
| 380 |
print(f"[EVAL] Sample qrel query_ids: {sample_qrel_keys}")
|
| 381 |
print(f"[EVAL] Sample qrel doc_ids: {sample_doc_ids[:5]}")
|
| 382 |
+
|
| 383 |
max_q = config.get("max_queries")
|
| 384 |
if max_q and max_q < len(all_queries):
|
| 385 |
all_queries = all_queries[:max_q]
|
| 386 |
total_queries = len(all_queries)
|
| 387 |
+
|
| 388 |
print(f"[EVAL] Embedding {total_queries} queries (union mode)...")
|
| 389 |
query_texts = [q.text for q in all_queries]
|
| 390 |
query_embeddings = embedder.embed_queries(query_texts, show_progress=False)
|
| 391 |
+
print("[EVAL] Queries embedded")
|
| 392 |
embed_status.success(f"✅ {total_queries} queries embedded")
|
| 393 |
+
|
| 394 |
progress_bar = st.progress(0.0)
|
| 395 |
eval_status = st.empty()
|
| 396 |
log_area = st.empty()
|
| 397 |
+
|
| 398 |
eval_status.info(f"Evaluating {total_queries} queries in `{mode}` mode...")
|
| 399 |
print(f"[EVAL] Starting union evaluation: {total_queries} queries, mode={mode}")
|
| 400 |
+
|
| 401 |
for i, (q, qemb) in enumerate(zip(all_queries, query_embeddings)):
|
| 402 |
start = time.time()
|
| 403 |
+
|
| 404 |
if isinstance(qemb, torch.Tensor):
|
| 405 |
qemb_np = qemb.detach().cpu().numpy()
|
| 406 |
else:
|
| 407 |
+
qemb_np = qemb.numpy() if hasattr(qemb, "numpy") else np.array(qemb)
|
| 408 |
+
|
| 409 |
results = retriever.search_embedded(
|
| 410 |
query_embedding=qemb_np,
|
| 411 |
top_k=max(100, top_k),
|
|
|
|
| 416 |
stage2_k=stage2_k,
|
| 417 |
)
|
| 418 |
latencies.append((time.time() - start) * 1000)
|
| 419 |
+
|
| 420 |
ranking = [str(r["id"]) for r in results]
|
| 421 |
rels = all_qrels.get(q.query_id, {})
|
| 422 |
+
|
| 423 |
if i == 0:
|
| 424 |
print(f"[EVAL] First query - query_id: {q.query_id}")
|
| 425 |
print(f"[EVAL] Top 3 retrieved doc_ids: {ranking[:3]}")
|
| 426 |
print(f"[EVAL] Expected doc_ids (qrels): {list(rels.keys())[:3]}")
|
| 427 |
+
print(
|
| 428 |
+
f"[EVAL] all_qrels has {len(all_qrels)} queries, this query in qrels: {q.query_id in all_qrels}"
|
| 429 |
+
)
|
| 430 |
if results:
|
| 431 |
r0 = results[0]
|
| 432 |
+
print(
|
| 433 |
+
f"[EVAL] Sample result payload keys: {list(r0.get('payload', {}).keys())}"
|
| 434 |
+
)
|
| 435 |
p = r0.get("payload", {})
|
| 436 |
+
print(
|
| 437 |
+
f"[EVAL] Sample payload doc_id={p.get('doc_id')}, union_doc_id={p.get('union_doc_id')}, source_doc_id={p.get('source_doc_id')}"
|
| 438 |
+
)
|
| 439 |
has_match = any(rid in rels for rid in ranking[:10])
|
| 440 |
print(f"[EVAL] Any match in top-10? {has_match}")
|
| 441 |
+
|
| 442 |
metrics_collectors["ndcg@5"].append(ndcg_at_k(ranking, rels, k=5))
|
| 443 |
metrics_collectors["ndcg@10"].append(ndcg_at_k(ranking, rels, k=10))
|
| 444 |
metrics_collectors["recall@5"].append(recall_at_k(ranking, rels, k=5))
|
| 445 |
metrics_collectors["recall@10"].append(recall_at_k(ranking, rels, k=10))
|
| 446 |
metrics_collectors["mrr@5"].append(mrr_at_k(ranking, rels, k=5))
|
| 447 |
metrics_collectors["mrr@10"].append(mrr_at_k(ranking, rels, k=10))
|
| 448 |
+
|
| 449 |
progress = (i + 1) / total_queries
|
| 450 |
progress_bar.progress(progress)
|
| 451 |
+
eval_status.info(
|
| 452 |
+
f"🎯 {i+1}/{total_queries} ({int(progress*100)}%) — latency: {np.mean(latencies):.0f}ms"
|
| 453 |
+
)
|
| 454 |
+
|
| 455 |
log_interval = max(10, total_queries // 10)
|
| 456 |
if (i + 1) % log_interval == 0 and i > 0:
|
| 457 |
cur_ndcg = np.mean(metrics_collectors["ndcg@10"])
|
| 458 |
cur_lat = np.mean(latencies[1:]) if len(latencies) > 1 else latencies[0]
|
| 459 |
+
log_lines.append(
|
| 460 |
+
f"[{i+1}/{total_queries}] NDCG@10={cur_ndcg:.4f}, lat={cur_lat:.0f}ms"
|
| 461 |
+
)
|
| 462 |
log_area.code("\n".join(log_lines[-10:]), language="text")
|
| 463 |
+
print(
|
| 464 |
+
f"[EVAL] Progress {i+1}/{total_queries}: NDCG@10={cur_ndcg:.4f}, lat={cur_lat:.0f}ms"
|
| 465 |
+
)
|
| 466 |
+
|
| 467 |
progress_bar.progress(1.0)
|
| 468 |
eval_status.success(f"✅ Evaluation complete! ({total_queries} queries)")
|
| 469 |
+
|
| 470 |
with results_container:
|
| 471 |
st.markdown("##### 📊 Results")
|
| 472 |
+
|
| 473 |
p95_latency = float(np.percentile(latencies, 95))
|
| 474 |
eval_time_s = sum(latencies) / 1000
|
| 475 |
qps = total_queries / eval_time_s if eval_time_s > 0 else 0
|
| 476 |
+
|
| 477 |
final_metrics = {
|
| 478 |
"ndcg@5": float(np.mean(metrics_collectors["ndcg@5"])),
|
| 479 |
"ndcg@10": float(np.mean(metrics_collectors["ndcg@10"])),
|
|
|
|
| 487 |
"eval_time_s": eval_time_s,
|
| 488 |
"num_queries": total_queries,
|
| 489 |
}
|
| 490 |
+
|
| 491 |
print("=" * 60)
|
| 492 |
print("[EVAL] FINAL RESULTS:")
|
| 493 |
print(f"[EVAL] NDCG@5: {final_metrics['ndcg@5']:.4f}")
|
|
|
|
| 501 |
print(f"[EVAL] QPS: {final_metrics['qps']:.2f}")
|
| 502 |
print(f"[EVAL] Queries: {final_metrics['num_queries']}")
|
| 503 |
print("=" * 60)
|
| 504 |
+
|
| 505 |
st.markdown("**Retrieval Metrics**")
|
| 506 |
c1, c2, c3 = st.columns(3)
|
| 507 |
with c1:
|
|
|
|
| 513 |
with c3:
|
| 514 |
st.metric("MRR@5", f"{final_metrics['mrr@5']:.4f}")
|
| 515 |
st.metric("MRR@10", f"{final_metrics['mrr@10']:.4f}")
|
| 516 |
+
|
| 517 |
st.markdown("**Performance**")
|
| 518 |
c4, c5, c6, c7 = st.columns(4)
|
| 519 |
c4.metric("Avg Latency", f"{final_metrics['avg_latency_ms']:.0f}ms")
|
| 520 |
c5.metric("P95 Latency", f"{final_metrics['p95_latency_ms']:.0f}ms")
|
| 521 |
c6.metric("QPS", f"{final_metrics['qps']:.2f}")
|
| 522 |
c7.metric("Eval Time", f"{final_metrics['eval_time_s']:.1f}s")
|
| 523 |
+
|
| 524 |
with st.expander("📋 Full Results JSON"):
|
| 525 |
st.json(final_metrics)
|
| 526 |
+
|
| 527 |
detailed_report = {
|
| 528 |
"generated_at": datetime.now().isoformat(),
|
| 529 |
"config": {
|
|
|
|
| 553 |
"num_queries": final_metrics["num_queries"],
|
| 554 |
},
|
| 555 |
}
|
| 556 |
+
|
| 557 |
if mode == "two_stage":
|
| 558 |
detailed_report["config"]["stage1_mode"] = stage1_mode
|
| 559 |
detailed_report["config"]["prefetch_k"] = prefetch_k
|
| 560 |
elif mode == "three_stage":
|
| 561 |
detailed_report["config"]["stage1_k"] = stage1_k
|
| 562 |
detailed_report["config"]["stage2_k"] = stage2_k
|
| 563 |
+
|
| 564 |
if evaluation_scope == "per_dataset" and metrics_by_dataset:
|
| 565 |
detailed_report["metrics_by_dataset"] = metrics_by_dataset
|
| 566 |
+
|
| 567 |
st.markdown("**Per-Dataset Results**")
|
| 568 |
for ds_name, ds_metrics in metrics_by_dataset.items():
|
| 569 |
ds_short = ds_name.split("/")[-1]
|
|
|
|
| 573 |
dc2.metric("Recall@10", f"{ds_metrics['recall@10']:.4f}")
|
| 574 |
dc3.metric("MRR@10", f"{ds_metrics['mrr@10']:.4f}")
|
| 575 |
dc4.metric("Latency", f"{ds_metrics['avg_latency_ms']:.0f}ms")
|
| 576 |
+
|
| 577 |
report_json = json.dumps(detailed_report, indent=2)
|
| 578 |
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 579 |
filename = f"eval_report__{collection}__{mode}__{timestamp}.json"
|
| 580 |
+
|
| 581 |
st.download_button(
|
| 582 |
label="📥 Download Detailed Report",
|
| 583 |
data=report_json,
|
|
|
|
| 585 |
mime="application/json",
|
| 586 |
use_container_width=True,
|
| 587 |
)
|
| 588 |
+
|
| 589 |
st.session_state["last_eval_metrics"] = final_metrics
|
| 590 |
+
|
| 591 |
except Exception as e:
|
| 592 |
error_msg = str(e)
|
| 593 |
+
|
| 594 |
if "not configured in this collection" in error_msg:
|
| 595 |
+
vector_name = (
|
| 596 |
+
error_msg.split("name ")[-1].split(" is")[0] if "name " in error_msg else "unknown"
|
| 597 |
+
)
|
| 598 |
+
st.error(
|
| 599 |
+
f"❌ **Collection Mismatch**: Vector `{vector_name}` not found in collection `{collection}`"
|
| 600 |
+
)
|
| 601 |
+
st.warning(
|
| 602 |
+
f"""
|
| 603 |
**The selected mode `{mode}` requires vectors that don't exist in this collection.**
|
| 604 |
|
| 605 |
**Suggestions:**
|
| 606 |
- Try `single_full` mode (works with basic collections)
|
| 607 |
- Use a collection indexed with the Visual RAG Toolkit
|
| 608 |
- Check that the collection has the required vector types for `{mode}` mode
|
| 609 |
+
"""
|
| 610 |
+
)
|
| 611 |
else:
|
| 612 |
st.error(f"❌ Error: {e}")
|
| 613 |
+
|
| 614 |
with st.expander("🔍 Full Error Details"):
|
| 615 |
st.code(traceback.format_exc(), language="text")
|
demo/indexing.py
CHANGED
|
@@ -1,22 +1,18 @@
|
|
| 1 |
"""Indexing runner with UI updates."""
|
| 2 |
|
| 3 |
import hashlib
|
| 4 |
-
import json
|
| 5 |
-
import time
|
| 6 |
import traceback
|
| 7 |
-
from datetime import datetime
|
| 8 |
from typing import Any, Dict, Optional
|
| 9 |
|
| 10 |
import numpy as np
|
| 11 |
import streamlit as st
|
| 12 |
import torch
|
| 13 |
|
|
|
|
|
|
|
| 14 |
from visual_rag import VisualEmbedder
|
| 15 |
from visual_rag.embedding.pooling import tile_level_mean_pooling
|
| 16 |
from visual_rag.indexing.qdrant_indexer import QdrantIndexer
|
| 17 |
-
from benchmarks.vidore_tatdqa_test.dataset_loader import load_vidore_beir_dataset
|
| 18 |
-
from demo.qdrant_utils import get_qdrant_credentials
|
| 19 |
-
|
| 20 |
|
| 21 |
TORCH_DTYPE_MAP = {
|
| 22 |
"float16": torch.float16,
|
|
@@ -63,9 +59,7 @@ def run_indexing_with_ui(config: Dict[str, Any]):
|
|
| 63 |
|
| 64 |
print(f"[INDEX] Config: collection={collection}, model={model}")
|
| 65 |
print(f"[INDEX] Datasets: {datasets}")
|
| 66 |
-
print(
|
| 67 |
-
f"[INDEX] max_docs={max_docs}, batch_size={batch_size}, recreate={recreate}"
|
| 68 |
-
)
|
| 69 |
print(
|
| 70 |
f"[INDEX] torch_dtype={torch_dtype}, qdrant_dtype={qdrant_vector_dtype}, grpc={prefer_grpc}"
|
| 71 |
)
|
|
@@ -83,9 +77,7 @@ def run_indexing_with_ui(config: Dict[str, Any]):
|
|
| 83 |
|
| 84 |
print(f"[INDEX] Loading embedder: {model}")
|
| 85 |
torch_dtype_obj = TORCH_DTYPE_MAP.get(torch_dtype, torch.float16)
|
| 86 |
-
output_dtype_obj =
|
| 87 |
-
np.float16 if qdrant_vector_dtype == "float16" else np.float32
|
| 88 |
-
)
|
| 89 |
embedder = VisualEmbedder(
|
| 90 |
model_name=model,
|
| 91 |
torch_dtype=torch_dtype_obj,
|
|
@@ -140,9 +132,7 @@ def run_indexing_with_ui(config: Dict[str, Any]):
|
|
| 140 |
ds_container = st.container()
|
| 141 |
|
| 142 |
with ds_container:
|
| 143 |
-
st.markdown(
|
| 144 |
-
f"**Dataset {ds_idx + 1}/{len(datasets)}: `{ds_short}`**"
|
| 145 |
-
)
|
| 146 |
|
| 147 |
load_status = st.empty()
|
| 148 |
load_status.info(f"Loading dataset `{ds_short}`...")
|
|
@@ -172,9 +162,7 @@ def run_indexing_with_ui(config: Dict[str, Any]):
|
|
| 172 |
failed += 1
|
| 173 |
continue
|
| 174 |
|
| 175 |
-
status_text.text(
|
| 176 |
-
f"Processing {i + 1}/{total}: {doc_id[:30]}..."
|
| 177 |
-
)
|
| 178 |
|
| 179 |
embeddings, token_infos = embedder.embed_images(
|
| 180 |
[image],
|
|
|
|
| 1 |
"""Indexing runner with UI updates."""
|
| 2 |
|
| 3 |
import hashlib
|
|
|
|
|
|
|
| 4 |
import traceback
|
|
|
|
| 5 |
from typing import Any, Dict, Optional
|
| 6 |
|
| 7 |
import numpy as np
|
| 8 |
import streamlit as st
|
| 9 |
import torch
|
| 10 |
|
| 11 |
+
from benchmarks.vidore_tatdqa_test.dataset_loader import load_vidore_beir_dataset
|
| 12 |
+
from demo.qdrant_utils import get_qdrant_credentials
|
| 13 |
from visual_rag import VisualEmbedder
|
| 14 |
from visual_rag.embedding.pooling import tile_level_mean_pooling
|
| 15 |
from visual_rag.indexing.qdrant_indexer import QdrantIndexer
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
TORCH_DTYPE_MAP = {
|
| 18 |
"float16": torch.float16,
|
|
|
|
| 59 |
|
| 60 |
print(f"[INDEX] Config: collection={collection}, model={model}")
|
| 61 |
print(f"[INDEX] Datasets: {datasets}")
|
| 62 |
+
print(f"[INDEX] max_docs={max_docs}, batch_size={batch_size}, recreate={recreate}")
|
|
|
|
|
|
|
| 63 |
print(
|
| 64 |
f"[INDEX] torch_dtype={torch_dtype}, qdrant_dtype={qdrant_vector_dtype}, grpc={prefer_grpc}"
|
| 65 |
)
|
|
|
|
| 77 |
|
| 78 |
print(f"[INDEX] Loading embedder: {model}")
|
| 79 |
torch_dtype_obj = TORCH_DTYPE_MAP.get(torch_dtype, torch.float16)
|
| 80 |
+
output_dtype_obj = np.float16 if qdrant_vector_dtype == "float16" else np.float32
|
|
|
|
|
|
|
| 81 |
embedder = VisualEmbedder(
|
| 82 |
model_name=model,
|
| 83 |
torch_dtype=torch_dtype_obj,
|
|
|
|
| 132 |
ds_container = st.container()
|
| 133 |
|
| 134 |
with ds_container:
|
| 135 |
+
st.markdown(f"**Dataset {ds_idx + 1}/{len(datasets)}: `{ds_short}`**")
|
|
|
|
|
|
|
| 136 |
|
| 137 |
load_status = st.empty()
|
| 138 |
load_status.info(f"Loading dataset `{ds_short}`...")
|
|
|
|
| 162 |
failed += 1
|
| 163 |
continue
|
| 164 |
|
| 165 |
+
status_text.text(f"Processing {i + 1}/{total}: {doc_id[:30]}...")
|
|
|
|
|
|
|
| 166 |
|
| 167 |
embeddings, token_infos = embedder.embed_images(
|
| 168 |
[image],
|
demo/qdrant_utils.py
CHANGED
|
@@ -9,7 +9,7 @@ import streamlit as st
|
|
| 9 |
|
| 10 |
def get_qdrant_credentials() -> Tuple[Optional[str], Optional[str]]:
|
| 11 |
"""Get Qdrant credentials from session state or environment variables.
|
| 12 |
-
|
| 13 |
Priority: session_state > QDRANT_URL/QDRANT_API_KEY > legacy env vars
|
| 14 |
"""
|
| 15 |
url = (
|
|
@@ -28,6 +28,7 @@ def get_qdrant_credentials() -> Tuple[Optional[str], Optional[str]]:
|
|
| 28 |
def init_qdrant_client_with_creds(url: str, api_key: str):
|
| 29 |
try:
|
| 30 |
from qdrant_client import QdrantClient
|
|
|
|
| 31 |
if not url:
|
| 32 |
return None, "QDRANT_URL not configured"
|
| 33 |
client = QdrantClient(url=url, api_key=api_key, timeout=60)
|
|
@@ -47,6 +48,7 @@ def init_qdrant_client():
|
|
| 47 |
def init_embedder(model_name: str):
|
| 48 |
try:
|
| 49 |
from visual_rag import VisualEmbedder
|
|
|
|
| 50 |
return VisualEmbedder(model_name=model_name), None
|
| 51 |
except Exception as e:
|
| 52 |
return None, f"{e}\n\n{traceback.format_exc()}"
|
|
@@ -72,7 +74,9 @@ def get_collection_stats(collection_name: str) -> Dict[str, Any]:
|
|
| 72 |
return {"error": err}
|
| 73 |
try:
|
| 74 |
info = client.get_collection(collection_name)
|
| 75 |
-
vectors_config = getattr(
|
|
|
|
|
|
|
| 76 |
vector_info = {}
|
| 77 |
if vectors_config is not None:
|
| 78 |
if hasattr(vectors_config, "items"):
|
|
@@ -96,7 +100,9 @@ def get_collection_stats(collection_name: str) -> Dict[str, Any]:
|
|
| 96 |
}
|
| 97 |
elif hasattr(vectors_config, "size"):
|
| 98 |
on_disk = getattr(vectors_config, "on_disk", None)
|
| 99 |
-
datatype = str(getattr(vectors_config, "datatype", "Float32")).replace(
|
|
|
|
|
|
|
| 100 |
multivec = getattr(vectors_config, "multivector_config", None)
|
| 101 |
vector_info["default"] = {
|
| 102 |
"size": getattr(vectors_config, "size", None),
|
|
@@ -117,12 +123,15 @@ def get_collection_stats(collection_name: str) -> Dict[str, Any]:
|
|
| 117 |
|
| 118 |
|
| 119 |
@st.cache_data(ttl=60)
|
| 120 |
-
def sample_points_cached(
|
|
|
|
|
|
|
| 121 |
client, err = init_qdrant_client_with_creds(_url, _api_key)
|
| 122 |
if client is None:
|
| 123 |
return []
|
| 124 |
try:
|
| 125 |
import random
|
|
|
|
| 126 |
rng = random.Random(seed)
|
| 127 |
points, _ = client.scroll(
|
| 128 |
collection_name=collection_name,
|
|
@@ -136,10 +145,12 @@ def sample_points_cached(collection_name: str, n: int, seed: int, _url: str, _ap
|
|
| 136 |
results = []
|
| 137 |
for p in sampled:
|
| 138 |
payload = dict(p.payload) if p.payload else {}
|
| 139 |
-
results.append(
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
|
|
|
|
|
|
| 143 |
return results
|
| 144 |
except Exception:
|
| 145 |
return []
|
|
@@ -181,14 +192,16 @@ def search_collection(
|
|
| 181 |
top_k: int = 10,
|
| 182 |
mode: str = "single_full",
|
| 183 |
prefetch_k: int = 256,
|
| 184 |
-
stage1_mode: str = "
|
| 185 |
stage1_k: int = 1000,
|
| 186 |
stage2_k: int = 300,
|
| 187 |
model_name: str = "vidore/colSmol-500M",
|
| 188 |
) -> Tuple[List[Dict[str, Any]], Optional[str]]:
|
| 189 |
try:
|
| 190 |
import traceback
|
|
|
|
| 191 |
from visual_rag.retrieval import MultiVectorRetriever
|
|
|
|
| 192 |
retriever = MultiVectorRetriever(
|
| 193 |
collection_name=collection_name,
|
| 194 |
model_name=model_name,
|
|
@@ -215,4 +228,5 @@ def search_collection(
|
|
| 215 |
return results, None
|
| 216 |
except Exception as e:
|
| 217 |
import traceback
|
|
|
|
| 218 |
return [], f"{e}\n\n{traceback.format_exc()}"
|
|
|
|
| 9 |
|
| 10 |
def get_qdrant_credentials() -> Tuple[Optional[str], Optional[str]]:
|
| 11 |
"""Get Qdrant credentials from session state or environment variables.
|
| 12 |
+
|
| 13 |
Priority: session_state > QDRANT_URL/QDRANT_API_KEY > legacy env vars
|
| 14 |
"""
|
| 15 |
url = (
|
|
|
|
| 28 |
def init_qdrant_client_with_creds(url: str, api_key: str):
|
| 29 |
try:
|
| 30 |
from qdrant_client import QdrantClient
|
| 31 |
+
|
| 32 |
if not url:
|
| 33 |
return None, "QDRANT_URL not configured"
|
| 34 |
client = QdrantClient(url=url, api_key=api_key, timeout=60)
|
|
|
|
| 48 |
def init_embedder(model_name: str):
|
| 49 |
try:
|
| 50 |
from visual_rag import VisualEmbedder
|
| 51 |
+
|
| 52 |
return VisualEmbedder(model_name=model_name), None
|
| 53 |
except Exception as e:
|
| 54 |
return None, f"{e}\n\n{traceback.format_exc()}"
|
|
|
|
| 74 |
return {"error": err}
|
| 75 |
try:
|
| 76 |
info = client.get_collection(collection_name)
|
| 77 |
+
vectors_config = getattr(
|
| 78 |
+
getattr(getattr(info, "config", None), "params", None), "vectors", None
|
| 79 |
+
)
|
| 80 |
vector_info = {}
|
| 81 |
if vectors_config is not None:
|
| 82 |
if hasattr(vectors_config, "items"):
|
|
|
|
| 100 |
}
|
| 101 |
elif hasattr(vectors_config, "size"):
|
| 102 |
on_disk = getattr(vectors_config, "on_disk", None)
|
| 103 |
+
datatype = str(getattr(vectors_config, "datatype", "Float32")).replace(
|
| 104 |
+
"Datatype.", ""
|
| 105 |
+
)
|
| 106 |
multivec = getattr(vectors_config, "multivector_config", None)
|
| 107 |
vector_info["default"] = {
|
| 108 |
"size": getattr(vectors_config, "size", None),
|
|
|
|
| 123 |
|
| 124 |
|
| 125 |
@st.cache_data(ttl=60)
|
| 126 |
+
def sample_points_cached(
|
| 127 |
+
collection_name: str, n: int, seed: int, _url: str, _api_key: str
|
| 128 |
+
) -> List[Dict[str, Any]]:
|
| 129 |
client, err = init_qdrant_client_with_creds(_url, _api_key)
|
| 130 |
if client is None:
|
| 131 |
return []
|
| 132 |
try:
|
| 133 |
import random
|
| 134 |
+
|
| 135 |
rng = random.Random(seed)
|
| 136 |
points, _ = client.scroll(
|
| 137 |
collection_name=collection_name,
|
|
|
|
| 145 |
results = []
|
| 146 |
for p in sampled:
|
| 147 |
payload = dict(p.payload) if p.payload else {}
|
| 148 |
+
results.append(
|
| 149 |
+
{
|
| 150 |
+
"id": str(p.id),
|
| 151 |
+
"payload": payload,
|
| 152 |
+
}
|
| 153 |
+
)
|
| 154 |
return results
|
| 155 |
except Exception:
|
| 156 |
return []
|
|
|
|
| 192 |
top_k: int = 10,
|
| 193 |
mode: str = "single_full",
|
| 194 |
prefetch_k: int = 256,
|
| 195 |
+
stage1_mode: str = "tokens_vs_standard_pooling",
|
| 196 |
stage1_k: int = 1000,
|
| 197 |
stage2_k: int = 300,
|
| 198 |
model_name: str = "vidore/colSmol-500M",
|
| 199 |
) -> Tuple[List[Dict[str, Any]], Optional[str]]:
|
| 200 |
try:
|
| 201 |
import traceback
|
| 202 |
+
|
| 203 |
from visual_rag.retrieval import MultiVectorRetriever
|
| 204 |
+
|
| 205 |
retriever = MultiVectorRetriever(
|
| 206 |
collection_name=collection_name,
|
| 207 |
model_name=model_name,
|
|
|
|
| 228 |
return results, None
|
| 229 |
except Exception as e:
|
| 230 |
import traceback
|
| 231 |
+
|
| 232 |
return [], f"{e}\n\n{traceback.format_exc()}"
|
demo/test_qdrant_connection.py
CHANGED
|
@@ -7,27 +7,29 @@ from pathlib import Path
|
|
| 7 |
|
| 8 |
sys.path.insert(0, str(Path(__file__).parent.parent))
|
| 9 |
|
| 10 |
-
from dotenv import load_dotenv
|
|
|
|
| 11 |
load_dotenv(Path(__file__).parent.parent / ".env")
|
| 12 |
load_dotenv(Path(__file__).parent.parent.parent / ".env")
|
| 13 |
|
|
|
|
| 14 |
def test_connection():
|
| 15 |
from qdrant_client import QdrantClient
|
| 16 |
from qdrant_client.http import models
|
| 17 |
-
|
| 18 |
url = os.getenv("QDRANT_URL")
|
| 19 |
api_key = os.getenv("QDRANT_API_KEY")
|
| 20 |
-
|
| 21 |
print(f"URL: {url}")
|
| 22 |
print(f"API Key: {'***' + api_key[-4:] if api_key else 'NOT SET'}")
|
| 23 |
-
|
| 24 |
if not url or not api_key:
|
| 25 |
print("ERROR: QDRANT_URL or QDRANT_API_KEY not set")
|
| 26 |
return
|
| 27 |
-
|
| 28 |
print("\n1. Creating client...")
|
| 29 |
client = QdrantClient(url=url, api_key=api_key, timeout=60)
|
| 30 |
-
|
| 31 |
print("\n2. Getting collections...")
|
| 32 |
try:
|
| 33 |
collections = client.get_collections()
|
|
@@ -37,22 +39,22 @@ def test_connection():
|
|
| 37 |
except Exception as e:
|
| 38 |
print(f" ERROR: {e}")
|
| 39 |
return
|
| 40 |
-
|
| 41 |
test_collection = "_test_visual_rag_toolkit"
|
| 42 |
-
|
| 43 |
print(f"\n3. Checking if '{test_collection}' exists...")
|
| 44 |
exists = any(c.name == test_collection for c in collections.collections)
|
| 45 |
print(f" Exists: {exists}")
|
| 46 |
-
|
| 47 |
if exists:
|
| 48 |
-
print(
|
| 49 |
try:
|
| 50 |
client.delete_collection(test_collection)
|
| 51 |
print(" Deleted")
|
| 52 |
except Exception as e:
|
| 53 |
print(f" ERROR: {e}")
|
| 54 |
-
|
| 55 |
-
print(
|
| 56 |
try:
|
| 57 |
client.create_collection(
|
| 58 |
collection_name=test_collection,
|
|
@@ -67,15 +69,15 @@ def test_connection():
|
|
| 67 |
print("\n This means basic collection creation is failing.")
|
| 68 |
print(" Check your Qdrant Cloud cluster status/limits.")
|
| 69 |
return
|
| 70 |
-
|
| 71 |
-
print(
|
| 72 |
try:
|
| 73 |
client.delete_collection(test_collection)
|
| 74 |
print(" Deleted")
|
| 75 |
except Exception as e:
|
| 76 |
print(f" ERROR: {e}")
|
| 77 |
-
|
| 78 |
-
print(
|
| 79 |
try:
|
| 80 |
client.create_collection(
|
| 81 |
collection_name=test_collection,
|
|
@@ -102,17 +104,17 @@ def test_connection():
|
|
| 102 |
print("\n Multi-vector collection failed but simple worked.")
|
| 103 |
print(" Your Qdrant version may not support multi-vector.")
|
| 104 |
return
|
| 105 |
-
|
| 106 |
-
print(
|
| 107 |
try:
|
| 108 |
client.delete_collection(test_collection)
|
| 109 |
print(" Deleted")
|
| 110 |
except Exception as e:
|
| 111 |
print(f" ERROR: {e}")
|
| 112 |
-
|
| 113 |
-
print("\n" + "="*50)
|
| 114 |
print("ALL TESTS PASSED - Qdrant connection is working!")
|
| 115 |
-
print("="*50)
|
| 116 |
|
| 117 |
|
| 118 |
if __name__ == "__main__":
|
|
|
|
| 7 |
|
| 8 |
sys.path.insert(0, str(Path(__file__).parent.parent))
|
| 9 |
|
| 10 |
+
from dotenv import load_dotenv # noqa: E402
|
| 11 |
+
|
| 12 |
load_dotenv(Path(__file__).parent.parent / ".env")
|
| 13 |
load_dotenv(Path(__file__).parent.parent.parent / ".env")
|
| 14 |
|
| 15 |
+
|
| 16 |
def test_connection():
|
| 17 |
from qdrant_client import QdrantClient
|
| 18 |
from qdrant_client.http import models
|
| 19 |
+
|
| 20 |
url = os.getenv("QDRANT_URL")
|
| 21 |
api_key = os.getenv("QDRANT_API_KEY")
|
| 22 |
+
|
| 23 |
print(f"URL: {url}")
|
| 24 |
print(f"API Key: {'***' + api_key[-4:] if api_key else 'NOT SET'}")
|
| 25 |
+
|
| 26 |
if not url or not api_key:
|
| 27 |
print("ERROR: QDRANT_URL or QDRANT_API_KEY not set")
|
| 28 |
return
|
| 29 |
+
|
| 30 |
print("\n1. Creating client...")
|
| 31 |
client = QdrantClient(url=url, api_key=api_key, timeout=60)
|
| 32 |
+
|
| 33 |
print("\n2. Getting collections...")
|
| 34 |
try:
|
| 35 |
collections = client.get_collections()
|
|
|
|
| 39 |
except Exception as e:
|
| 40 |
print(f" ERROR: {e}")
|
| 41 |
return
|
| 42 |
+
|
| 43 |
test_collection = "_test_visual_rag_toolkit"
|
| 44 |
+
|
| 45 |
print(f"\n3. Checking if '{test_collection}' exists...")
|
| 46 |
exists = any(c.name == test_collection for c in collections.collections)
|
| 47 |
print(f" Exists: {exists}")
|
| 48 |
+
|
| 49 |
if exists:
|
| 50 |
+
print("\n4. Deleting test collection...")
|
| 51 |
try:
|
| 52 |
client.delete_collection(test_collection)
|
| 53 |
print(" Deleted")
|
| 54 |
except Exception as e:
|
| 55 |
print(f" ERROR: {e}")
|
| 56 |
+
|
| 57 |
+
print("\n5. Creating SIMPLE collection (single vector)...")
|
| 58 |
try:
|
| 59 |
client.create_collection(
|
| 60 |
collection_name=test_collection,
|
|
|
|
| 69 |
print("\n This means basic collection creation is failing.")
|
| 70 |
print(" Check your Qdrant Cloud cluster status/limits.")
|
| 71 |
return
|
| 72 |
+
|
| 73 |
+
print("\n6. Deleting test collection...")
|
| 74 |
try:
|
| 75 |
client.delete_collection(test_collection)
|
| 76 |
print(" Deleted")
|
| 77 |
except Exception as e:
|
| 78 |
print(f" ERROR: {e}")
|
| 79 |
+
|
| 80 |
+
print("\n7. Creating MULTI-VECTOR collection (like visual-rag)...")
|
| 81 |
try:
|
| 82 |
client.create_collection(
|
| 83 |
collection_name=test_collection,
|
|
|
|
| 104 |
print("\n Multi-vector collection failed but simple worked.")
|
| 105 |
print(" Your Qdrant version may not support multi-vector.")
|
| 106 |
return
|
| 107 |
+
|
| 108 |
+
print("\n8. Final cleanup...")
|
| 109 |
try:
|
| 110 |
client.delete_collection(test_collection)
|
| 111 |
print(" Deleted")
|
| 112 |
except Exception as e:
|
| 113 |
print(f" ERROR: {e}")
|
| 114 |
+
|
| 115 |
+
print("\n" + "=" * 50)
|
| 116 |
print("ALL TESTS PASSED - Qdrant connection is working!")
|
| 117 |
+
print("=" * 50)
|
| 118 |
|
| 119 |
|
| 120 |
if __name__ == "__main__":
|
demo/ui/__init__.py
CHANGED
|
@@ -1,10 +1,10 @@
|
|
| 1 |
"""UI components for the demo app."""
|
| 2 |
|
|
|
|
| 3 |
from demo.ui.header import render_header
|
|
|
|
| 4 |
from demo.ui.sidebar import render_sidebar
|
| 5 |
from demo.ui.upload import render_upload_tab
|
| 6 |
-
from demo.ui.playground import render_playground_tab
|
| 7 |
-
from demo.ui.benchmark import render_benchmark_tab
|
| 8 |
|
| 9 |
__all__ = [
|
| 10 |
"render_header",
|
|
|
|
| 1 |
"""UI components for the demo app."""
|
| 2 |
|
| 3 |
+
from demo.ui.benchmark import render_benchmark_tab
|
| 4 |
from demo.ui.header import render_header
|
| 5 |
+
from demo.ui.playground import render_playground_tab
|
| 6 |
from demo.ui.sidebar import render_sidebar
|
| 7 |
from demo.ui.upload import render_upload_tab
|
|
|
|
|
|
|
| 8 |
|
| 9 |
__all__ = [
|
| 10 |
"render_header",
|
demo/ui/benchmark.py
CHANGED
|
@@ -7,6 +7,12 @@ import altair as alt
|
|
| 7 |
import pandas as pd
|
| 8 |
import streamlit as st
|
| 9 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
from demo.config import (
|
| 11 |
AVAILABLE_MODELS,
|
| 12 |
BENCHMARK_DATASETS,
|
|
@@ -14,47 +20,52 @@ from demo.config import (
|
|
| 14 |
RETRIEVAL_MODES,
|
| 15 |
STAGE1_MODES,
|
| 16 |
)
|
| 17 |
-
from demo.qdrant_utils import get_qdrant_credentials, get_collections
|
| 18 |
-
from demo.commands import build_index_command, build_eval_command, generate_python_eval_code, generate_python_index_code
|
| 19 |
-
from demo.results import get_available_results, load_results_file
|
| 20 |
from demo.evaluation import run_evaluation_with_ui
|
| 21 |
from demo.indexing import run_indexing_with_ui
|
|
|
|
|
|
|
| 22 |
|
| 23 |
|
| 24 |
def render_benchmark_tab():
|
| 25 |
st.subheader("📊 Benchmarking")
|
| 26 |
-
|
| 27 |
tab_index, tab_eval, tab_results = st.tabs(["Indexing", "Evaluation", "Results"])
|
| 28 |
-
|
| 29 |
url, api_key = get_qdrant_credentials()
|
| 30 |
collections = get_collections(url, api_key)
|
| 31 |
-
|
| 32 |
with tab_index:
|
| 33 |
render_benchmark_indexing(collections)
|
| 34 |
-
|
| 35 |
with tab_eval:
|
| 36 |
render_benchmark_evaluation(collections)
|
| 37 |
-
|
| 38 |
with tab_results:
|
| 39 |
render_benchmark_results()
|
| 40 |
|
| 41 |
|
| 42 |
def render_benchmark_indexing(collections: List[str]):
|
| 43 |
st.caption("Create a new collection with benchmark datasets")
|
| 44 |
-
|
| 45 |
c1, c2, c3 = st.columns(3)
|
| 46 |
with c1:
|
| 47 |
-
datasets = st.multiselect(
|
|
|
|
|
|
|
| 48 |
with c2:
|
| 49 |
model = st.selectbox("Model", AVAILABLE_MODELS, key="bi_model")
|
| 50 |
with c3:
|
| 51 |
model_short = model.split("/")[-1].replace("-", "_").replace(".", "_")
|
| 52 |
-
collection = st.text_input(
|
| 53 |
-
|
|
|
|
|
|
|
| 54 |
sel_docs = sum(DATASET_STATS.get(d, {}).get("docs", 0) for d in datasets)
|
| 55 |
sel_queries = sum(DATASET_STATS.get(d, {}).get("queries", 0) for d in datasets)
|
| 56 |
-
st.markdown(
|
| 57 |
-
|
|
|
|
|
|
|
| 58 |
c4, c5, c6, c7 = st.columns(4)
|
| 59 |
with c4:
|
| 60 |
crop = st.toggle("Crop", value=True, key="bi_crop")
|
|
@@ -64,11 +75,11 @@ def render_benchmark_indexing(collections: List[str]):
|
|
| 64 |
grpc = st.toggle("gRPC", value=True, key="bi_grpc")
|
| 65 |
with c7:
|
| 66 |
recreate = st.toggle("Recreate", value=False, key="bi_recreate")
|
| 67 |
-
|
| 68 |
crop_pct = st.slider("Crop %", 0.8, 0.99, 0.99, 0.01, key="bi_crop_pct") if crop else 0.99
|
| 69 |
-
|
| 70 |
st.markdown("---")
|
| 71 |
-
|
| 72 |
col_max, col_batch, col_torch, col_qdrant = st.columns([2, 2, 1, 1])
|
| 73 |
with col_max:
|
| 74 |
max_docs_val = max(sel_docs, 1)
|
|
@@ -78,30 +89,47 @@ def render_benchmark_indexing(collections: List[str]):
|
|
| 78 |
max_value=max_docs_val,
|
| 79 |
value=max_docs_val,
|
| 80 |
key="bi_max_docs",
|
| 81 |
-
help="Limit docs per dataset. Useful for quick tests."
|
| 82 |
)
|
| 83 |
with col_batch:
|
| 84 |
-
batch_size = st.number_input(
|
|
|
|
|
|
|
| 85 |
with col_torch:
|
| 86 |
-
torch_dtype = st.selectbox(
|
|
|
|
|
|
|
| 87 |
with col_qdrant:
|
| 88 |
-
qdrant_dtype = st.selectbox(
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
config = {
|
| 93 |
-
"datasets": datasets,
|
| 94 |
-
"
|
| 95 |
-
"
|
| 96 |
-
"
|
| 97 |
-
"
|
| 98 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
"max_docs": max_docs if max_docs < max_docs_val else None,
|
| 100 |
}
|
| 101 |
-
|
| 102 |
cmd = build_index_command(config)
|
| 103 |
python_code = generate_python_index_code(config)
|
| 104 |
-
|
| 105 |
col_cmd, col_info = st.columns([2, 1])
|
| 106 |
with col_cmd:
|
| 107 |
code_tab1, code_tab2 = st.tabs(["🐚 Bash", "🐍 Python"])
|
|
@@ -111,14 +139,16 @@ def render_benchmark_indexing(collections: List[str]):
|
|
| 111 |
st.code(python_code, language="python")
|
| 112 |
with col_info:
|
| 113 |
st.markdown("<br><br><br>", unsafe_allow_html=True)
|
| 114 |
-
|
| 115 |
st.metric("Docs to Index", f"{effective_docs:,}")
|
| 116 |
st.metric("Model", model.split("/")[-1])
|
| 117 |
if effective_docs < sel_docs:
|
| 118 |
st.caption(f"Limited from {sel_docs:,} total")
|
| 119 |
st.divider()
|
| 120 |
-
run_index = st.button(
|
| 121 |
-
|
|
|
|
|
|
|
| 122 |
if run_index:
|
| 123 |
if not collection:
|
| 124 |
st.error("Please provide a collection name")
|
|
@@ -130,38 +160,42 @@ def render_benchmark_indexing(collections: List[str]):
|
|
| 130 |
|
| 131 |
def render_benchmark_evaluation(collections: List[str]):
|
| 132 |
collection = st.session_state.get("active_collection")
|
| 133 |
-
|
| 134 |
if not collection:
|
| 135 |
st.warning("⚠️ Select a collection from the sidebar first")
|
| 136 |
return
|
| 137 |
-
|
| 138 |
st.info(f"**Collection:** `{collection}` (from sidebar)")
|
| 139 |
-
|
| 140 |
all_docs = sum(DATASET_STATS.get(d, {}).get("docs", 0) for d in BENCHMARK_DATASETS)
|
| 141 |
all_queries = sum(DATASET_STATS.get(d, {}).get("queries", 0) for d in BENCHMARK_DATASETS)
|
| 142 |
-
st.markdown(
|
| 143 |
-
|
|
|
|
|
|
|
| 144 |
c1, c2 = st.columns([3, 1])
|
| 145 |
with c1:
|
| 146 |
st.multiselect("Datasets", BENCHMARK_DATASETS, default=BENCHMARK_DATASETS, key="be_ds")
|
| 147 |
with c2:
|
| 148 |
model = st.selectbox("Model", AVAILABLE_MODELS, key="be_model")
|
| 149 |
-
|
| 150 |
datasets = st.session_state.get("be_ds", BENCHMARK_DATASETS)
|
| 151 |
sel_docs = sum(DATASET_STATS.get(d, {}).get("docs", 0) for d in datasets)
|
| 152 |
sel_queries = sum(DATASET_STATS.get(d, {}).get("queries", 0) for d in datasets)
|
| 153 |
-
st.markdown(
|
| 154 |
-
|
|
|
|
|
|
|
| 155 |
st.markdown("---")
|
| 156 |
-
|
| 157 |
col_mode, col_topk = st.columns([2, 1])
|
| 158 |
with col_mode:
|
| 159 |
mode = st.selectbox("Mode", RETRIEVAL_MODES, key="be_mode")
|
| 160 |
with col_topk:
|
| 161 |
top_k = st.slider("Top K", 10, 100, 100, key="be_topk")
|
| 162 |
-
|
| 163 |
-
stage1_mode, prefetch_k, stage1_k, stage2_k = "
|
| 164 |
-
|
| 165 |
if mode == "two_stage":
|
| 166 |
cc1, cc2 = st.columns(2)
|
| 167 |
with cc1:
|
|
@@ -174,9 +208,9 @@ def render_benchmark_evaluation(collections: List[str]):
|
|
| 174 |
stage1_k = st.number_input("Stage1 K", 100, 5000, 1000, key="be_s1k")
|
| 175 |
with cc2:
|
| 176 |
stage2_k = st.number_input("Stage2 K", 50, 1000, 300, key="be_s2k")
|
| 177 |
-
|
| 178 |
st.markdown("---")
|
| 179 |
-
|
| 180 |
col_scope, _, col_grpc, col_nq = st.columns([2, 0.5, 1, 2])
|
| 181 |
with col_scope:
|
| 182 |
scope = st.selectbox("Scope", ["union", "per_dataset"], key="be_scope")
|
|
@@ -187,33 +221,39 @@ def render_benchmark_evaluation(collections: List[str]):
|
|
| 187 |
with col_nq:
|
| 188 |
max_q_val = max(sel_queries, 1)
|
| 189 |
max_queries = st.number_input(
|
| 190 |
-
"Max Queries",
|
| 191 |
-
min_value=1,
|
| 192 |
-
max_value=max_q_val,
|
| 193 |
-
value=max_q_val,
|
| 194 |
key="be_max_queries",
|
| 195 |
-
help="Limit number of queries to evaluate (useful for quick tests)"
|
| 196 |
)
|
| 197 |
-
|
| 198 |
result_prefix_val = st.session_state.get("be_prefix", "")
|
| 199 |
-
|
| 200 |
config = {
|
| 201 |
-
"datasets": datasets,
|
| 202 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
"prefer_grpc": grpc,
|
| 204 |
"torch_dtype": "float16",
|
| 205 |
"qdrant_vector_dtype": "float16",
|
| 206 |
"qdrant_timeout": 180,
|
| 207 |
-
"stage1_mode": stage1_mode,
|
| 208 |
-
"
|
|
|
|
|
|
|
| 209 |
"result_prefix": result_prefix_val,
|
| 210 |
"max_queries": max_queries,
|
| 211 |
}
|
| 212 |
-
|
| 213 |
cmd = build_eval_command(config)
|
| 214 |
-
|
| 215 |
python_code = generate_python_eval_code(config)
|
| 216 |
-
|
| 217 |
col_cmd, col_info = st.columns([2, 1])
|
| 218 |
with col_cmd:
|
| 219 |
code_tab1, code_tab2 = st.tabs(["🐚 Bash", "🐍 Python"])
|
|
@@ -223,7 +263,7 @@ def render_benchmark_evaluation(collections: List[str]):
|
|
| 223 |
st.code(python_code, language="python")
|
| 224 |
with col_info:
|
| 225 |
st.markdown("<br><br><br>", unsafe_allow_html=True)
|
| 226 |
-
|
| 227 |
mode_desc = {
|
| 228 |
"single_full": "🔹 **Single Full**: Query all visual tokens against full document embeddings in one pass.",
|
| 229 |
"single_tiles": "🔸 **Single Tiles**: Query against tile-level embeddings only.",
|
|
@@ -239,9 +279,9 @@ def render_benchmark_evaluation(collections: List[str]):
|
|
| 239 |
st.markdown(scope_desc.get(scope, ""))
|
| 240 |
st.divider()
|
| 241 |
st.text_input("Result Prefix", placeholder="optional prefix for output", key="be_prefix")
|
| 242 |
-
|
| 243 |
run_eval = st.button("🚀 Run Eval", type="primary", key="be_run", use_container_width=True)
|
| 244 |
-
|
| 245 |
if run_eval:
|
| 246 |
if not collection:
|
| 247 |
st.error("Please select a collection first")
|
|
@@ -251,19 +291,19 @@ def render_benchmark_evaluation(collections: List[str]):
|
|
| 251 |
|
| 252 |
def render_benchmark_results():
|
| 253 |
st.markdown("##### Load Results")
|
| 254 |
-
|
| 255 |
available = get_available_results()
|
| 256 |
-
|
| 257 |
if not available:
|
| 258 |
st.info("No results found")
|
| 259 |
return
|
| 260 |
-
|
| 261 |
default_select = []
|
| 262 |
if st.session_state.get("auto_select_result"):
|
| 263 |
auto = st.session_state.pop("auto_select_result")
|
| 264 |
if auto in [str(p) for p in available]:
|
| 265 |
default_select = [auto]
|
| 266 |
-
|
| 267 |
selected = st.multiselect(
|
| 268 |
"Result files",
|
| 269 |
options=[str(p) for p in available],
|
|
@@ -271,7 +311,7 @@ def render_benchmark_results():
|
|
| 271 |
default=default_select,
|
| 272 |
key="br_files",
|
| 273 |
)
|
| 274 |
-
|
| 275 |
for path in selected:
|
| 276 |
data = load_results_file(Path(path))
|
| 277 |
if data:
|
|
@@ -285,71 +325,108 @@ def render_result_card(data: Dict[str, Any], filename: str):
|
|
| 285 |
c2.metric("Mode", data.get("mode", "?"))
|
| 286 |
c3.metric("Top K", data.get("top_k", "?"))
|
| 287 |
c4.metric("Time", f"{data.get('eval_wall_time_s', 0):.0f}s")
|
| 288 |
-
|
| 289 |
metrics = data.get("metrics_by_dataset", {})
|
| 290 |
if not metrics:
|
| 291 |
st.warning("No metrics data")
|
| 292 |
return
|
| 293 |
-
|
| 294 |
rows = []
|
| 295 |
for ds, m in metrics.items():
|
| 296 |
-
rows.append(
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
|
|
|
|
|
|
| 307 |
df = pd.DataFrame(rows)
|
| 308 |
-
|
| 309 |
st.dataframe(
|
| 310 |
-
df.style.format(
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 316 |
)
|
| 317 |
-
|
| 318 |
chart_data = []
|
| 319 |
for ds, m in metrics.items():
|
| 320 |
ds_short = ds.split("/")[-1].replace("_v2", "").replace("_", " ").title()
|
| 321 |
-
chart_data.append(
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 325 |
chart_df = pd.DataFrame(chart_data)
|
| 326 |
-
|
| 327 |
-
chart =
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 335 |
st.altair_chart(chart, use_container_width=True)
|
| 336 |
-
|
| 337 |
-
latency_data = [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 338 |
latency_df = pd.DataFrame(latency_data)
|
| 339 |
-
|
| 340 |
c1, c2 = st.columns(2)
|
| 341 |
with c1:
|
| 342 |
-
lat_chart =
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 347 |
st.altair_chart(lat_chart, use_container_width=True)
|
| 348 |
-
|
| 349 |
with c2:
|
| 350 |
-
qps_chart =
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 355 |
st.altair_chart(qps_chart, use_container_width=True)
|
|
|
|
| 7 |
import pandas as pd
|
| 8 |
import streamlit as st
|
| 9 |
|
| 10 |
+
from demo.commands import (
|
| 11 |
+
build_eval_command,
|
| 12 |
+
build_index_command,
|
| 13 |
+
generate_python_eval_code,
|
| 14 |
+
generate_python_index_code,
|
| 15 |
+
)
|
| 16 |
from demo.config import (
|
| 17 |
AVAILABLE_MODELS,
|
| 18 |
BENCHMARK_DATASETS,
|
|
|
|
| 20 |
RETRIEVAL_MODES,
|
| 21 |
STAGE1_MODES,
|
| 22 |
)
|
|
|
|
|
|
|
|
|
|
| 23 |
from demo.evaluation import run_evaluation_with_ui
|
| 24 |
from demo.indexing import run_indexing_with_ui
|
| 25 |
+
from demo.qdrant_utils import get_collections, get_qdrant_credentials
|
| 26 |
+
from demo.results import get_available_results, load_results_file
|
| 27 |
|
| 28 |
|
| 29 |
def render_benchmark_tab():
|
| 30 |
st.subheader("📊 Benchmarking")
|
| 31 |
+
|
| 32 |
tab_index, tab_eval, tab_results = st.tabs(["Indexing", "Evaluation", "Results"])
|
| 33 |
+
|
| 34 |
url, api_key = get_qdrant_credentials()
|
| 35 |
collections = get_collections(url, api_key)
|
| 36 |
+
|
| 37 |
with tab_index:
|
| 38 |
render_benchmark_indexing(collections)
|
| 39 |
+
|
| 40 |
with tab_eval:
|
| 41 |
render_benchmark_evaluation(collections)
|
| 42 |
+
|
| 43 |
with tab_results:
|
| 44 |
render_benchmark_results()
|
| 45 |
|
| 46 |
|
| 47 |
def render_benchmark_indexing(collections: List[str]):
|
| 48 |
st.caption("Create a new collection with benchmark datasets")
|
| 49 |
+
|
| 50 |
c1, c2, c3 = st.columns(3)
|
| 51 |
with c1:
|
| 52 |
+
datasets = st.multiselect(
|
| 53 |
+
"Datasets", BENCHMARK_DATASETS, default=BENCHMARK_DATASETS, key="bi_ds"
|
| 54 |
+
)
|
| 55 |
with c2:
|
| 56 |
model = st.selectbox("Model", AVAILABLE_MODELS, key="bi_model")
|
| 57 |
with c3:
|
| 58 |
model_short = model.split("/")[-1].replace("-", "_").replace(".", "_")
|
| 59 |
+
collection = st.text_input(
|
| 60 |
+
"New Collection Name", value=f"vidore_{len(datasets)}ds__{model_short}", key="bi_coll"
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
sel_docs = sum(DATASET_STATS.get(d, {}).get("docs", 0) for d in datasets)
|
| 64 |
sel_queries = sum(DATASET_STATS.get(d, {}).get("queries", 0) for d in datasets)
|
| 65 |
+
st.markdown(
|
| 66 |
+
f"🎯 **Selected:** {len(datasets)} dataset(s) — **{sel_docs:,}** docs, **{sel_queries:,}** queries"
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
c4, c5, c6, c7 = st.columns(4)
|
| 70 |
with c4:
|
| 71 |
crop = st.toggle("Crop", value=True, key="bi_crop")
|
|
|
|
| 75 |
grpc = st.toggle("gRPC", value=True, key="bi_grpc")
|
| 76 |
with c7:
|
| 77 |
recreate = st.toggle("Recreate", value=False, key="bi_recreate")
|
| 78 |
+
|
| 79 |
crop_pct = st.slider("Crop %", 0.8, 0.99, 0.99, 0.01, key="bi_crop_pct") if crop else 0.99
|
| 80 |
+
|
| 81 |
st.markdown("---")
|
| 82 |
+
|
| 83 |
col_max, col_batch, col_torch, col_qdrant = st.columns([2, 2, 1, 1])
|
| 84 |
with col_max:
|
| 85 |
max_docs_val = max(sel_docs, 1)
|
|
|
|
| 89 |
max_value=max_docs_val,
|
| 90 |
value=max_docs_val,
|
| 91 |
key="bi_max_docs",
|
| 92 |
+
help="Limit docs per dataset. Useful for quick tests.",
|
| 93 |
)
|
| 94 |
with col_batch:
|
| 95 |
+
batch_size = st.number_input(
|
| 96 |
+
"Batch Size", min_value=1, max_value=16, value=4, key="bi_batch"
|
| 97 |
+
)
|
| 98 |
with col_torch:
|
| 99 |
+
torch_dtype = st.selectbox(
|
| 100 |
+
"Torch dtype", ["float16", "float32"], index=0, key="bi_torch_dtype"
|
| 101 |
+
)
|
| 102 |
with col_qdrant:
|
| 103 |
+
qdrant_dtype = st.selectbox(
|
| 104 |
+
"Qdrant dtype", ["float16", "float32"], index=0, key="bi_qdrant_dtype"
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
effective_docs = (
|
| 108 |
+
min(max_docs * len(datasets), sel_docs) if max_docs < max_docs_val else sel_docs
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
config = {
|
| 112 |
+
"datasets": datasets,
|
| 113 |
+
"model": model,
|
| 114 |
+
"collection": collection,
|
| 115 |
+
"crop_empty": crop,
|
| 116 |
+
"crop_percentage": crop_pct,
|
| 117 |
+
"no_cloudinary": not cloudinary,
|
| 118 |
+
"recreate": recreate,
|
| 119 |
+
"resume": False,
|
| 120 |
+
"prefer_grpc": grpc,
|
| 121 |
+
"batch_size": batch_size,
|
| 122 |
+
"upload_batch_size": 8,
|
| 123 |
+
"qdrant_timeout": 180,
|
| 124 |
+
"qdrant_retries": 5,
|
| 125 |
+
"torch_dtype": torch_dtype,
|
| 126 |
+
"qdrant_vector_dtype": qdrant_dtype,
|
| 127 |
"max_docs": max_docs if max_docs < max_docs_val else None,
|
| 128 |
}
|
| 129 |
+
|
| 130 |
cmd = build_index_command(config)
|
| 131 |
python_code = generate_python_index_code(config)
|
| 132 |
+
|
| 133 |
col_cmd, col_info = st.columns([2, 1])
|
| 134 |
with col_cmd:
|
| 135 |
code_tab1, code_tab2 = st.tabs(["🐚 Bash", "🐍 Python"])
|
|
|
|
| 139 |
st.code(python_code, language="python")
|
| 140 |
with col_info:
|
| 141 |
st.markdown("<br><br><br>", unsafe_allow_html=True)
|
| 142 |
+
|
| 143 |
st.metric("Docs to Index", f"{effective_docs:,}")
|
| 144 |
st.metric("Model", model.split("/")[-1])
|
| 145 |
if effective_docs < sel_docs:
|
| 146 |
st.caption(f"Limited from {sel_docs:,} total")
|
| 147 |
st.divider()
|
| 148 |
+
run_index = st.button(
|
| 149 |
+
"🚀 Run Index", type="primary", key="bi_run", use_container_width=True
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
if run_index:
|
| 153 |
if not collection:
|
| 154 |
st.error("Please provide a collection name")
|
|
|
|
| 160 |
|
| 161 |
def render_benchmark_evaluation(collections: List[str]):
|
| 162 |
collection = st.session_state.get("active_collection")
|
| 163 |
+
|
| 164 |
if not collection:
|
| 165 |
st.warning("⚠️ Select a collection from the sidebar first")
|
| 166 |
return
|
| 167 |
+
|
| 168 |
st.info(f"**Collection:** `{collection}` (from sidebar)")
|
| 169 |
+
|
| 170 |
all_docs = sum(DATASET_STATS.get(d, {}).get("docs", 0) for d in BENCHMARK_DATASETS)
|
| 171 |
all_queries = sum(DATASET_STATS.get(d, {}).get("queries", 0) for d in BENCHMARK_DATASETS)
|
| 172 |
+
st.markdown(
|
| 173 |
+
f"📊 **Available:** {len(BENCHMARK_DATASETS)} datasets — **{all_docs:,}** docs, **{all_queries:,}** queries"
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
c1, c2 = st.columns([3, 1])
|
| 177 |
with c1:
|
| 178 |
st.multiselect("Datasets", BENCHMARK_DATASETS, default=BENCHMARK_DATASETS, key="be_ds")
|
| 179 |
with c2:
|
| 180 |
model = st.selectbox("Model", AVAILABLE_MODELS, key="be_model")
|
| 181 |
+
|
| 182 |
datasets = st.session_state.get("be_ds", BENCHMARK_DATASETS)
|
| 183 |
sel_docs = sum(DATASET_STATS.get(d, {}).get("docs", 0) for d in datasets)
|
| 184 |
sel_queries = sum(DATASET_STATS.get(d, {}).get("queries", 0) for d in datasets)
|
| 185 |
+
st.markdown(
|
| 186 |
+
f"🎯 **Selected:** {len(datasets)} dataset(s) — **{sel_docs:,}** docs, **{sel_queries:,}** queries"
|
| 187 |
+
)
|
| 188 |
+
|
| 189 |
st.markdown("---")
|
| 190 |
+
|
| 191 |
col_mode, col_topk = st.columns([2, 1])
|
| 192 |
with col_mode:
|
| 193 |
mode = st.selectbox("Mode", RETRIEVAL_MODES, key="be_mode")
|
| 194 |
with col_topk:
|
| 195 |
top_k = st.slider("Top K", 10, 100, 100, key="be_topk")
|
| 196 |
+
|
| 197 |
+
stage1_mode, prefetch_k, stage1_k, stage2_k = "tokens_vs_standard_pooling", 256, 1000, 300
|
| 198 |
+
|
| 199 |
if mode == "two_stage":
|
| 200 |
cc1, cc2 = st.columns(2)
|
| 201 |
with cc1:
|
|
|
|
| 208 |
stage1_k = st.number_input("Stage1 K", 100, 5000, 1000, key="be_s1k")
|
| 209 |
with cc2:
|
| 210 |
stage2_k = st.number_input("Stage2 K", 50, 1000, 300, key="be_s2k")
|
| 211 |
+
|
| 212 |
st.markdown("---")
|
| 213 |
+
|
| 214 |
col_scope, _, col_grpc, col_nq = st.columns([2, 0.5, 1, 2])
|
| 215 |
with col_scope:
|
| 216 |
scope = st.selectbox("Scope", ["union", "per_dataset"], key="be_scope")
|
|
|
|
| 221 |
with col_nq:
|
| 222 |
max_q_val = max(sel_queries, 1)
|
| 223 |
max_queries = st.number_input(
|
| 224 |
+
"Max Queries",
|
| 225 |
+
min_value=1,
|
| 226 |
+
max_value=max_q_val,
|
| 227 |
+
value=max_q_val,
|
| 228 |
key="be_max_queries",
|
| 229 |
+
help="Limit number of queries to evaluate (useful for quick tests)",
|
| 230 |
)
|
| 231 |
+
|
| 232 |
result_prefix_val = st.session_state.get("be_prefix", "")
|
| 233 |
+
|
| 234 |
config = {
|
| 235 |
+
"datasets": datasets,
|
| 236 |
+
"model": model,
|
| 237 |
+
"collection": collection,
|
| 238 |
+
"mode": mode,
|
| 239 |
+
"top_k": top_k,
|
| 240 |
+
"evaluation_scope": scope,
|
| 241 |
"prefer_grpc": grpc,
|
| 242 |
"torch_dtype": "float16",
|
| 243 |
"qdrant_vector_dtype": "float16",
|
| 244 |
"qdrant_timeout": 180,
|
| 245 |
+
"stage1_mode": stage1_mode,
|
| 246 |
+
"prefetch_k": prefetch_k,
|
| 247 |
+
"stage1_k": stage1_k,
|
| 248 |
+
"stage2_k": stage2_k,
|
| 249 |
"result_prefix": result_prefix_val,
|
| 250 |
"max_queries": max_queries,
|
| 251 |
}
|
| 252 |
+
|
| 253 |
cmd = build_eval_command(config)
|
| 254 |
+
|
| 255 |
python_code = generate_python_eval_code(config)
|
| 256 |
+
|
| 257 |
col_cmd, col_info = st.columns([2, 1])
|
| 258 |
with col_cmd:
|
| 259 |
code_tab1, code_tab2 = st.tabs(["🐚 Bash", "🐍 Python"])
|
|
|
|
| 263 |
st.code(python_code, language="python")
|
| 264 |
with col_info:
|
| 265 |
st.markdown("<br><br><br>", unsafe_allow_html=True)
|
| 266 |
+
|
| 267 |
mode_desc = {
|
| 268 |
"single_full": "🔹 **Single Full**: Query all visual tokens against full document embeddings in one pass.",
|
| 269 |
"single_tiles": "🔸 **Single Tiles**: Query against tile-level embeddings only.",
|
|
|
|
| 279 |
st.markdown(scope_desc.get(scope, ""))
|
| 280 |
st.divider()
|
| 281 |
st.text_input("Result Prefix", placeholder="optional prefix for output", key="be_prefix")
|
| 282 |
+
|
| 283 |
run_eval = st.button("🚀 Run Eval", type="primary", key="be_run", use_container_width=True)
|
| 284 |
+
|
| 285 |
if run_eval:
|
| 286 |
if not collection:
|
| 287 |
st.error("Please select a collection first")
|
|
|
|
| 291 |
|
| 292 |
def render_benchmark_results():
|
| 293 |
st.markdown("##### Load Results")
|
| 294 |
+
|
| 295 |
available = get_available_results()
|
| 296 |
+
|
| 297 |
if not available:
|
| 298 |
st.info("No results found")
|
| 299 |
return
|
| 300 |
+
|
| 301 |
default_select = []
|
| 302 |
if st.session_state.get("auto_select_result"):
|
| 303 |
auto = st.session_state.pop("auto_select_result")
|
| 304 |
if auto in [str(p) for p in available]:
|
| 305 |
default_select = [auto]
|
| 306 |
+
|
| 307 |
selected = st.multiselect(
|
| 308 |
"Result files",
|
| 309 |
options=[str(p) for p in available],
|
|
|
|
| 311 |
default=default_select,
|
| 312 |
key="br_files",
|
| 313 |
)
|
| 314 |
+
|
| 315 |
for path in selected:
|
| 316 |
data = load_results_file(Path(path))
|
| 317 |
if data:
|
|
|
|
| 325 |
c2.metric("Mode", data.get("mode", "?"))
|
| 326 |
c3.metric("Top K", data.get("top_k", "?"))
|
| 327 |
c4.metric("Time", f"{data.get('eval_wall_time_s', 0):.0f}s")
|
| 328 |
+
|
| 329 |
metrics = data.get("metrics_by_dataset", {})
|
| 330 |
if not metrics:
|
| 331 |
st.warning("No metrics data")
|
| 332 |
return
|
| 333 |
+
|
| 334 |
rows = []
|
| 335 |
for ds, m in metrics.items():
|
| 336 |
+
rows.append(
|
| 337 |
+
{
|
| 338 |
+
"Dataset": ds.split("/")[-1].replace("_v2", ""),
|
| 339 |
+
"NDCG@5": m.get("ndcg@5", 0),
|
| 340 |
+
"NDCG@10": m.get("ndcg@10", 0),
|
| 341 |
+
"Recall@5": m.get("recall@5", 0),
|
| 342 |
+
"Recall@10": m.get("recall@10", 0),
|
| 343 |
+
"MRR@10": m.get("mrr@10", 0),
|
| 344 |
+
"Latency": m.get("avg_latency_ms", 0),
|
| 345 |
+
"QPS": m.get("qps", 0),
|
| 346 |
+
}
|
| 347 |
+
)
|
| 348 |
+
|
| 349 |
df = pd.DataFrame(rows)
|
| 350 |
+
|
| 351 |
st.dataframe(
|
| 352 |
+
df.style.format(
|
| 353 |
+
{
|
| 354 |
+
"NDCG@5": "{:.4f}",
|
| 355 |
+
"NDCG@10": "{:.4f}",
|
| 356 |
+
"Recall@5": "{:.4f}",
|
| 357 |
+
"Recall@10": "{:.4f}",
|
| 358 |
+
"MRR@10": "{:.4f}",
|
| 359 |
+
"Latency": "{:.1f}",
|
| 360 |
+
"QPS": "{:.2f}",
|
| 361 |
+
}
|
| 362 |
+
),
|
| 363 |
+
hide_index=True,
|
| 364 |
+
use_container_width=True,
|
| 365 |
)
|
| 366 |
+
|
| 367 |
chart_data = []
|
| 368 |
for ds, m in metrics.items():
|
| 369 |
ds_short = ds.split("/")[-1].replace("_v2", "").replace("_", " ").title()
|
| 370 |
+
chart_data.append(
|
| 371 |
+
{"Dataset": ds_short, "Metric": "NDCG@10", "Value": m.get("ndcg@10", 0)}
|
| 372 |
+
)
|
| 373 |
+
chart_data.append(
|
| 374 |
+
{"Dataset": ds_short, "Metric": "Recall@10", "Value": m.get("recall@10", 0)}
|
| 375 |
+
)
|
| 376 |
+
chart_data.append(
|
| 377 |
+
{"Dataset": ds_short, "Metric": "MRR@10", "Value": m.get("mrr@10", 0)}
|
| 378 |
+
)
|
| 379 |
+
|
| 380 |
chart_df = pd.DataFrame(chart_data)
|
| 381 |
+
|
| 382 |
+
chart = (
|
| 383 |
+
alt.Chart(chart_df)
|
| 384 |
+
.mark_bar()
|
| 385 |
+
.encode(
|
| 386 |
+
x=alt.X("Dataset:N", title=None),
|
| 387 |
+
y=alt.Y("Value:Q", scale=alt.Scale(domain=[0, 1]), title="Score"),
|
| 388 |
+
color=alt.Color("Metric:N", scale=alt.Scale(scheme="tableau10")),
|
| 389 |
+
xOffset="Metric:N",
|
| 390 |
+
tooltip=["Dataset", "Metric", alt.Tooltip("Value:Q", format=".4f")],
|
| 391 |
+
)
|
| 392 |
+
.properties(height=300, title="Metrics by Dataset")
|
| 393 |
+
)
|
| 394 |
+
|
| 395 |
st.altair_chart(chart, use_container_width=True)
|
| 396 |
+
|
| 397 |
+
latency_data = [
|
| 398 |
+
{
|
| 399 |
+
"Dataset": ds.split("/")[-1].replace("_v2", ""),
|
| 400 |
+
"Latency (ms)": m.get("avg_latency_ms", 0),
|
| 401 |
+
"QPS": m.get("qps", 0),
|
| 402 |
+
}
|
| 403 |
+
for ds, m in metrics.items()
|
| 404 |
+
]
|
| 405 |
latency_df = pd.DataFrame(latency_data)
|
| 406 |
+
|
| 407 |
c1, c2 = st.columns(2)
|
| 408 |
with c1:
|
| 409 |
+
lat_chart = (
|
| 410 |
+
alt.Chart(latency_df)
|
| 411 |
+
.mark_bar(color="#ff6b6b")
|
| 412 |
+
.encode(
|
| 413 |
+
x=alt.X("Dataset:N"),
|
| 414 |
+
y=alt.Y("Latency (ms):Q"),
|
| 415 |
+
tooltip=["Dataset", alt.Tooltip("Latency (ms):Q", format=".1f")],
|
| 416 |
+
)
|
| 417 |
+
.properties(height=200, title="Avg Latency")
|
| 418 |
+
)
|
| 419 |
st.altair_chart(lat_chart, use_container_width=True)
|
| 420 |
+
|
| 421 |
with c2:
|
| 422 |
+
qps_chart = (
|
| 423 |
+
alt.Chart(latency_df)
|
| 424 |
+
.mark_bar(color="#4ecdc4")
|
| 425 |
+
.encode(
|
| 426 |
+
x=alt.X("Dataset:N"),
|
| 427 |
+
y=alt.Y("QPS:Q"),
|
| 428 |
+
tooltip=["Dataset", alt.Tooltip("QPS:Q", format=".2f")],
|
| 429 |
+
)
|
| 430 |
+
.properties(height=200, title="QPS (Queries/sec)")
|
| 431 |
+
)
|
| 432 |
st.altair_chart(qps_chart, use_container_width=True)
|
demo/ui/header.py
CHANGED
|
@@ -4,7 +4,8 @@ import streamlit as st
|
|
| 4 |
|
| 5 |
|
| 6 |
def render_header():
|
| 7 |
-
st.markdown(
|
|
|
|
| 8 |
<div style="text-align: center; padding: 10px 0 15px 0;">
|
| 9 |
<h1 style="
|
| 10 |
font-family: 'Georgia', serif;
|
|
@@ -35,4 +36,6 @@ def render_header():
|
|
| 35 |
</a>
|
| 36 |
</p>
|
| 37 |
</div>
|
| 38 |
-
""",
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
|
| 6 |
def render_header():
|
| 7 |
+
st.markdown(
|
| 8 |
+
"""
|
| 9 |
<div style="text-align: center; padding: 10px 0 15px 0;">
|
| 10 |
<h1 style="
|
| 11 |
font-family: 'Georgia', serif;
|
|
|
|
| 36 |
</a>
|
| 37 |
</p>
|
| 38 |
</div>
|
| 39 |
+
""",
|
| 40 |
+
unsafe_allow_html=True,
|
| 41 |
+
)
|
demo/ui/playground.py
CHANGED
|
@@ -4,8 +4,8 @@ import streamlit as st
|
|
| 4 |
|
| 5 |
from demo.config import AVAILABLE_MODELS, RETRIEVAL_MODES, STAGE1_MODES
|
| 6 |
from demo.qdrant_utils import (
|
| 7 |
-
get_qdrant_credentials,
|
| 8 |
get_collections,
|
|
|
|
| 9 |
sample_points_cached,
|
| 10 |
search_collection,
|
| 11 |
)
|
|
@@ -14,32 +14,32 @@ from visual_rag.retrieval import MultiVectorRetriever
|
|
| 14 |
|
| 15 |
def render_playground_tab():
|
| 16 |
st.subheader("🎮 Playground")
|
| 17 |
-
|
| 18 |
active_collection = st.session_state.get("active_collection")
|
| 19 |
url, api_key = get_qdrant_credentials()
|
| 20 |
-
|
| 21 |
if not active_collection:
|
| 22 |
collections = get_collections(url, api_key)
|
| 23 |
if collections:
|
| 24 |
active_collection = collections[0]
|
| 25 |
-
|
| 26 |
if not active_collection:
|
| 27 |
st.warning("No collection available. Upload documents or select a collection.")
|
| 28 |
return
|
| 29 |
-
|
| 30 |
points_for_model = sample_points_cached(active_collection, 1, 0, url, api_key)
|
| 31 |
model_name = None
|
| 32 |
if points_for_model:
|
| 33 |
model_name = points_for_model[0].get("payload", {}).get("model_name")
|
| 34 |
if not model_name:
|
| 35 |
model_name = AVAILABLE_MODELS[1]
|
| 36 |
-
|
| 37 |
model_short = model_name.split("/")[-1] if model_name else "unknown"
|
| 38 |
cache_key = f"{active_collection}_{model_name}"
|
| 39 |
-
|
| 40 |
if st.session_state.get("loaded_model_key") != cache_key:
|
| 41 |
st.session_state["model_loaded"] = False
|
| 42 |
-
|
| 43 |
col_info, col_model = st.columns([2, 1])
|
| 44 |
with col_info:
|
| 45 |
st.info(f"**Collection:** `{active_collection}`")
|
|
@@ -47,21 +47,26 @@ def render_playground_tab():
|
|
| 47 |
if not st.session_state.get("model_loaded"):
|
| 48 |
with st.spinner(f"Loading {model_short}..."):
|
| 49 |
try:
|
| 50 |
-
_ = MultiVectorRetriever(
|
|
|
|
|
|
|
| 51 |
st.session_state["model_loaded"] = True
|
| 52 |
st.session_state["loaded_model_key"] = cache_key
|
| 53 |
st.session_state["loaded_model_name"] = model_name
|
| 54 |
-
except Exception
|
| 55 |
st.warning(f"Failed: {model_short}")
|
| 56 |
-
|
| 57 |
if st.session_state.get("model_loaded"):
|
| 58 |
-
st.markdown(
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
| 60 |
with st.expander("📦 Sample Points Explorer", expanded=True):
|
| 61 |
render_sample_explorer(active_collection, url, api_key)
|
| 62 |
-
|
| 63 |
st.divider()
|
| 64 |
-
|
| 65 |
st.subheader("🔍 RAG Query")
|
| 66 |
render_rag_query_interface(active_collection, model_name)
|
| 67 |
|
|
@@ -80,15 +85,15 @@ def render_document_details(pt: dict, p: dict, score: float = None, rel_pct: flo
|
|
| 80 |
doc_id = p.get("doc_id") or p.get("union_doc_id") or p.get("source_doc_id") or "?"
|
| 81 |
corpus_id = p.get("corpus-id") or p.get("source_doc_id") or "?"
|
| 82 |
dataset = p.get("dataset") or p.get("source") or None
|
| 83 |
-
model =
|
| 84 |
model = model.split("/")[-1] if isinstance(model, str) else None
|
| 85 |
doc_name = p.get("doc-id") or p.get("filename") or "Unknown"
|
| 86 |
-
|
| 87 |
num_tiles = p.get("num_tiles")
|
| 88 |
visual_tokens = p.get("index_recovery_num_visual_tokens") or p.get("num_visual_tokens")
|
| 89 |
patches_per_tile = p.get("patches_per_tile")
|
| 90 |
torch_dtype = p.get("torch_dtype")
|
| 91 |
-
|
| 92 |
orig_w = p.get("original_width")
|
| 93 |
orig_h = p.get("original_height")
|
| 94 |
crop_w = p.get("cropped_width")
|
|
@@ -97,9 +102,9 @@ def render_document_details(pt: dict, p: dict, score: float = None, rel_pct: flo
|
|
| 97 |
resize_h = p.get("resized_height")
|
| 98 |
crop_pct = p.get("crop_empty_percentage_to_remove")
|
| 99 |
crop_enabled = bool(p.get("crop_empty_enabled", False))
|
| 100 |
-
|
| 101 |
col_meta, col_img = st.columns([1, 2])
|
| 102 |
-
|
| 103 |
with col_meta:
|
| 104 |
st.markdown("##### 📄 Document Info")
|
| 105 |
st.markdown(f"**📁 Doc:** {doc_name}")
|
|
@@ -109,7 +114,7 @@ def render_document_details(pt: dict, p: dict, score: float = None, rel_pct: flo
|
|
| 109 |
st.markdown(f"**🔑 Doc ID:** `{str(doc_id)[:20]}...`")
|
| 110 |
if not _is_missing(corpus_id) and str(corpus_id) != "?":
|
| 111 |
st.markdown(f"**📋 Corpus ID:** {corpus_id}")
|
| 112 |
-
|
| 113 |
if score is not None:
|
| 114 |
st.divider()
|
| 115 |
st.markdown("##### 🎯 Relevance")
|
|
@@ -117,7 +122,7 @@ def render_document_details(pt: dict, p: dict, score: float = None, rel_pct: flo
|
|
| 117 |
st.markdown(f"**Relative:** 🟢 {rel_pct:.1f}%")
|
| 118 |
st.progress(rel_pct / 100)
|
| 119 |
st.caption(f"Raw score: {score:.4f}")
|
| 120 |
-
|
| 121 |
st.divider()
|
| 122 |
visual_rows = []
|
| 123 |
if not _is_missing(model):
|
|
@@ -134,7 +139,7 @@ def render_document_details(pt: dict, p: dict, score: float = None, rel_pct: flo
|
|
| 134 |
st.markdown("##### 🎨 Visual Metadata")
|
| 135 |
for k, v in visual_rows:
|
| 136 |
st.markdown(f"**{k}:** {v}")
|
| 137 |
-
|
| 138 |
st.divider()
|
| 139 |
dim_rows = []
|
| 140 |
if not _is_missing(orig_w) and not _is_missing(orig_h):
|
|
@@ -152,33 +157,37 @@ def render_document_details(pt: dict, p: dict, score: float = None, rel_pct: flo
|
|
| 152 |
st.markdown(f"**Crop %:** {int(float(crop_pct) * 100)}%")
|
| 153 |
except Exception:
|
| 154 |
pass
|
| 155 |
-
|
| 156 |
with col_img:
|
| 157 |
st.markdown("##### 📷 Document Page")
|
| 158 |
tabs = st.tabs(["🖼️ Original", "📷 Resized", "✂️ Cropped"])
|
| 159 |
-
|
| 160 |
url_o = p.get("original_url")
|
| 161 |
url_r = p.get("resized_url") or p.get("page")
|
| 162 |
url_c = p.get("cropped_url")
|
| 163 |
-
|
| 164 |
with tabs[0]:
|
| 165 |
if url_o:
|
| 166 |
st.image(url_o, width=600)
|
| 167 |
st.caption(f"📐 **{orig_w}×{orig_h}**")
|
| 168 |
else:
|
| 169 |
st.info("No original image available")
|
| 170 |
-
|
| 171 |
with tabs[1]:
|
| 172 |
if url_r:
|
| 173 |
st.image(url_r, width=600)
|
| 174 |
st.caption(f"📐 **{resize_w}×{resize_h}**")
|
| 175 |
else:
|
| 176 |
st.info("No resized image available")
|
| 177 |
-
|
| 178 |
with tabs[2]:
|
| 179 |
if url_c:
|
| 180 |
# Display on a checkerboard background to make the crop boundary obvious.
|
| 181 |
-
w_caption =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
pct_caption = None
|
| 183 |
if not _is_missing(crop_pct):
|
| 184 |
try:
|
|
@@ -215,7 +224,7 @@ def render_document_details(pt: dict, p: dict, score: float = None, rel_pct: flo
|
|
| 215 |
st.caption(" | ".join(cap))
|
| 216 |
else:
|
| 217 |
st.info("No cropped image available")
|
| 218 |
-
|
| 219 |
with st.expander("🔗 Image URLs"):
|
| 220 |
if url_o:
|
| 221 |
st.code(url_o, language=None)
|
|
@@ -235,7 +244,7 @@ def render_sample_explorer(collection_name: str, url: str, api_key: str):
|
|
| 235 |
datasets.add(ds)
|
| 236 |
if did := (p.get("doc-id") or p.get("filename")):
|
| 237 |
doc_ids.add(did)
|
| 238 |
-
|
| 239 |
c1, c2, c3, c4 = st.columns([1, 1, 2, 1])
|
| 240 |
with c1:
|
| 241 |
n_samples = st.slider("Samples", 1, 20, 3, key="pg_n")
|
|
@@ -246,28 +255,28 @@ def render_sample_explorer(collection_name: str, url: str, api_key: str):
|
|
| 246 |
with c4:
|
| 247 |
st.write("")
|
| 248 |
do_sample = st.button("🎲 Sample", type="primary", key="pg_sample_btn")
|
| 249 |
-
|
| 250 |
if do_sample:
|
| 251 |
points = sample_points_cached(collection_name, n_samples * 5, seed, url, api_key)
|
| 252 |
if filter_ds != "All":
|
| 253 |
points = [p for p in points if p.get("payload", {}).get("dataset") == filter_ds]
|
| 254 |
points = points[:n_samples]
|
| 255 |
st.session_state["pg_points"] = points
|
| 256 |
-
|
| 257 |
points = st.session_state.get("pg_points", [])
|
| 258 |
-
|
| 259 |
if not points:
|
| 260 |
st.caption("Click 'Sample' to load documents")
|
| 261 |
return
|
| 262 |
-
|
| 263 |
st.success(f"**{len(points)} points loaded**")
|
| 264 |
-
|
| 265 |
for i, pt in enumerate(points):
|
| 266 |
p = pt.get("payload", {})
|
| 267 |
-
|
| 268 |
filename = p.get("filename") or p.get("doc_id") or p.get("source_doc_id") or "Unknown"
|
| 269 |
page_num = p.get("page_number") or p.get("page") or "?"
|
| 270 |
-
|
| 271 |
with st.expander(f"**{i+1}.** {str(filename)[:40]} - Page {page_num}", expanded=(i == 0)):
|
| 272 |
render_document_details(pt, p)
|
| 273 |
|
|
@@ -275,26 +284,26 @@ def render_sample_explorer(collection_name: str, url: str, api_key: str):
|
|
| 275 |
def render_rag_query_interface(collection_name: str, model_name: str = None):
|
| 276 |
if not collection_name:
|
| 277 |
return
|
| 278 |
-
|
| 279 |
url, api_key = get_qdrant_credentials()
|
| 280 |
-
|
| 281 |
if not model_name:
|
| 282 |
points = sample_points_cached(collection_name, 1, 0, url, api_key)
|
| 283 |
if points:
|
| 284 |
model_name = points[0].get("payload", {}).get("model_name")
|
| 285 |
if not model_name:
|
| 286 |
model_name = AVAILABLE_MODELS[1]
|
| 287 |
-
|
| 288 |
st.caption(f"Model: **{model_name.split('/')[-1] if model_name else 'auto'}**")
|
| 289 |
-
|
| 290 |
c1, c2, c3 = st.columns([2, 1, 1])
|
| 291 |
with c2:
|
| 292 |
mode = st.selectbox("Mode", RETRIEVAL_MODES, index=0, key="q_mode")
|
| 293 |
with c3:
|
| 294 |
top_k = st.slider("Top K", 1, 30, 10, key="q_topk")
|
| 295 |
-
|
| 296 |
-
prefetch_k, stage1_mode, stage1_k, stage2_k = 256, "
|
| 297 |
-
|
| 298 |
if mode == "two_stage":
|
| 299 |
cc1, cc2 = st.columns(2)
|
| 300 |
with cc1:
|
|
@@ -307,33 +316,44 @@ def render_rag_query_interface(collection_name: str, model_name: str = None):
|
|
| 307 |
stage1_k = st.number_input("Stage1 K", 100, 5000, 1000, key="q_s1k")
|
| 308 |
with cc2:
|
| 309 |
stage2_k = st.number_input("Stage2 K", 50, 1000, 300, key="q_s2k")
|
| 310 |
-
|
| 311 |
with c1:
|
| 312 |
query = st.text_input("Query", placeholder="Enter your search query...", key="q_text")
|
| 313 |
-
|
| 314 |
if st.button("🔍 Search", type="primary", disabled=not query, key="q_search"):
|
| 315 |
with st.spinner("Searching..."):
|
| 316 |
results, err = search_collection(
|
| 317 |
-
collection_name,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 318 |
)
|
| 319 |
if err:
|
| 320 |
st.error("Search failed")
|
| 321 |
st.code(err)
|
| 322 |
else:
|
| 323 |
st.session_state["q_results"] = results
|
| 324 |
-
|
| 325 |
results = st.session_state.get("q_results", [])
|
| 326 |
if results:
|
| 327 |
st.success(f"**{len(results)} results**")
|
| 328 |
max_score = max(r.get("score_final", r.get("score_stage1", 0)) for r in results) or 1
|
| 329 |
-
|
| 330 |
for i, r in enumerate(results):
|
| 331 |
p = r.get("payload", {})
|
| 332 |
score = r.get("score_final", r.get("score_stage1", 0))
|
| 333 |
rel = score / max_score * 100
|
| 334 |
-
|
| 335 |
filename = p.get("filename") or p.get("doc_id") or p.get("source_doc_id") or "Unknown"
|
| 336 |
page_num = p.get("page_number") or p.get("page") or "?"
|
| 337 |
-
|
| 338 |
-
with st.expander(
|
|
|
|
|
|
|
|
|
|
| 339 |
render_document_details(r, p, score=score, rel_pct=rel)
|
|
|
|
| 4 |
|
| 5 |
from demo.config import AVAILABLE_MODELS, RETRIEVAL_MODES, STAGE1_MODES
|
| 6 |
from demo.qdrant_utils import (
|
|
|
|
| 7 |
get_collections,
|
| 8 |
+
get_qdrant_credentials,
|
| 9 |
sample_points_cached,
|
| 10 |
search_collection,
|
| 11 |
)
|
|
|
|
| 14 |
|
| 15 |
def render_playground_tab():
|
| 16 |
st.subheader("🎮 Playground")
|
| 17 |
+
|
| 18 |
active_collection = st.session_state.get("active_collection")
|
| 19 |
url, api_key = get_qdrant_credentials()
|
| 20 |
+
|
| 21 |
if not active_collection:
|
| 22 |
collections = get_collections(url, api_key)
|
| 23 |
if collections:
|
| 24 |
active_collection = collections[0]
|
| 25 |
+
|
| 26 |
if not active_collection:
|
| 27 |
st.warning("No collection available. Upload documents or select a collection.")
|
| 28 |
return
|
| 29 |
+
|
| 30 |
points_for_model = sample_points_cached(active_collection, 1, 0, url, api_key)
|
| 31 |
model_name = None
|
| 32 |
if points_for_model:
|
| 33 |
model_name = points_for_model[0].get("payload", {}).get("model_name")
|
| 34 |
if not model_name:
|
| 35 |
model_name = AVAILABLE_MODELS[1]
|
| 36 |
+
|
| 37 |
model_short = model_name.split("/")[-1] if model_name else "unknown"
|
| 38 |
cache_key = f"{active_collection}_{model_name}"
|
| 39 |
+
|
| 40 |
if st.session_state.get("loaded_model_key") != cache_key:
|
| 41 |
st.session_state["model_loaded"] = False
|
| 42 |
+
|
| 43 |
col_info, col_model = st.columns([2, 1])
|
| 44 |
with col_info:
|
| 45 |
st.info(f"**Collection:** `{active_collection}`")
|
|
|
|
| 47 |
if not st.session_state.get("model_loaded"):
|
| 48 |
with st.spinner(f"Loading {model_short}..."):
|
| 49 |
try:
|
| 50 |
+
_ = MultiVectorRetriever(
|
| 51 |
+
collection_name=active_collection, model_name=model_name
|
| 52 |
+
)
|
| 53 |
st.session_state["model_loaded"] = True
|
| 54 |
st.session_state["loaded_model_key"] = cache_key
|
| 55 |
st.session_state["loaded_model_name"] = model_name
|
| 56 |
+
except Exception:
|
| 57 |
st.warning(f"Failed: {model_short}")
|
| 58 |
+
|
| 59 |
if st.session_state.get("model_loaded"):
|
| 60 |
+
st.markdown(
|
| 61 |
+
f"✅ Found <span style='color:#e74c3c;font-weight:bold;'>{model_short}</span> model",
|
| 62 |
+
unsafe_allow_html=True,
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
with st.expander("📦 Sample Points Explorer", expanded=True):
|
| 66 |
render_sample_explorer(active_collection, url, api_key)
|
| 67 |
+
|
| 68 |
st.divider()
|
| 69 |
+
|
| 70 |
st.subheader("🔍 RAG Query")
|
| 71 |
render_rag_query_interface(active_collection, model_name)
|
| 72 |
|
|
|
|
| 85 |
doc_id = p.get("doc_id") or p.get("union_doc_id") or p.get("source_doc_id") or "?"
|
| 86 |
corpus_id = p.get("corpus-id") or p.get("source_doc_id") or "?"
|
| 87 |
dataset = p.get("dataset") or p.get("source") or None
|
| 88 |
+
model = p.get("model_name") or p.get("model") or None
|
| 89 |
model = model.split("/")[-1] if isinstance(model, str) else None
|
| 90 |
doc_name = p.get("doc-id") or p.get("filename") or "Unknown"
|
| 91 |
+
|
| 92 |
num_tiles = p.get("num_tiles")
|
| 93 |
visual_tokens = p.get("index_recovery_num_visual_tokens") or p.get("num_visual_tokens")
|
| 94 |
patches_per_tile = p.get("patches_per_tile")
|
| 95 |
torch_dtype = p.get("torch_dtype")
|
| 96 |
+
|
| 97 |
orig_w = p.get("original_width")
|
| 98 |
orig_h = p.get("original_height")
|
| 99 |
crop_w = p.get("cropped_width")
|
|
|
|
| 102 |
resize_h = p.get("resized_height")
|
| 103 |
crop_pct = p.get("crop_empty_percentage_to_remove")
|
| 104 |
crop_enabled = bool(p.get("crop_empty_enabled", False))
|
| 105 |
+
|
| 106 |
col_meta, col_img = st.columns([1, 2])
|
| 107 |
+
|
| 108 |
with col_meta:
|
| 109 |
st.markdown("##### 📄 Document Info")
|
| 110 |
st.markdown(f"**📁 Doc:** {doc_name}")
|
|
|
|
| 114 |
st.markdown(f"**🔑 Doc ID:** `{str(doc_id)[:20]}...`")
|
| 115 |
if not _is_missing(corpus_id) and str(corpus_id) != "?":
|
| 116 |
st.markdown(f"**📋 Corpus ID:** {corpus_id}")
|
| 117 |
+
|
| 118 |
if score is not None:
|
| 119 |
st.divider()
|
| 120 |
st.markdown("##### 🎯 Relevance")
|
|
|
|
| 122 |
st.markdown(f"**Relative:** 🟢 {rel_pct:.1f}%")
|
| 123 |
st.progress(rel_pct / 100)
|
| 124 |
st.caption(f"Raw score: {score:.4f}")
|
| 125 |
+
|
| 126 |
st.divider()
|
| 127 |
visual_rows = []
|
| 128 |
if not _is_missing(model):
|
|
|
|
| 139 |
st.markdown("##### 🎨 Visual Metadata")
|
| 140 |
for k, v in visual_rows:
|
| 141 |
st.markdown(f"**{k}:** {v}")
|
| 142 |
+
|
| 143 |
st.divider()
|
| 144 |
dim_rows = []
|
| 145 |
if not _is_missing(orig_w) and not _is_missing(orig_h):
|
|
|
|
| 157 |
st.markdown(f"**Crop %:** {int(float(crop_pct) * 100)}%")
|
| 158 |
except Exception:
|
| 159 |
pass
|
| 160 |
+
|
| 161 |
with col_img:
|
| 162 |
st.markdown("##### 📷 Document Page")
|
| 163 |
tabs = st.tabs(["🖼️ Original", "📷 Resized", "✂️ Cropped"])
|
| 164 |
+
|
| 165 |
url_o = p.get("original_url")
|
| 166 |
url_r = p.get("resized_url") or p.get("page")
|
| 167 |
url_c = p.get("cropped_url")
|
| 168 |
+
|
| 169 |
with tabs[0]:
|
| 170 |
if url_o:
|
| 171 |
st.image(url_o, width=600)
|
| 172 |
st.caption(f"📐 **{orig_w}×{orig_h}**")
|
| 173 |
else:
|
| 174 |
st.info("No original image available")
|
| 175 |
+
|
| 176 |
with tabs[1]:
|
| 177 |
if url_r:
|
| 178 |
st.image(url_r, width=600)
|
| 179 |
st.caption(f"📐 **{resize_w}×{resize_h}**")
|
| 180 |
else:
|
| 181 |
st.info("No resized image available")
|
| 182 |
+
|
| 183 |
with tabs[2]:
|
| 184 |
if url_c:
|
| 185 |
# Display on a checkerboard background to make the crop boundary obvious.
|
| 186 |
+
w_caption = (
|
| 187 |
+
f"{crop_w}×{crop_h}"
|
| 188 |
+
if (not _is_missing(crop_w) and not _is_missing(crop_h))
|
| 189 |
+
else None
|
| 190 |
+
)
|
| 191 |
pct_caption = None
|
| 192 |
if not _is_missing(crop_pct):
|
| 193 |
try:
|
|
|
|
| 224 |
st.caption(" | ".join(cap))
|
| 225 |
else:
|
| 226 |
st.info("No cropped image available")
|
| 227 |
+
|
| 228 |
with st.expander("🔗 Image URLs"):
|
| 229 |
if url_o:
|
| 230 |
st.code(url_o, language=None)
|
|
|
|
| 244 |
datasets.add(ds)
|
| 245 |
if did := (p.get("doc-id") or p.get("filename")):
|
| 246 |
doc_ids.add(did)
|
| 247 |
+
|
| 248 |
c1, c2, c3, c4 = st.columns([1, 1, 2, 1])
|
| 249 |
with c1:
|
| 250 |
n_samples = st.slider("Samples", 1, 20, 3, key="pg_n")
|
|
|
|
| 255 |
with c4:
|
| 256 |
st.write("")
|
| 257 |
do_sample = st.button("🎲 Sample", type="primary", key="pg_sample_btn")
|
| 258 |
+
|
| 259 |
if do_sample:
|
| 260 |
points = sample_points_cached(collection_name, n_samples * 5, seed, url, api_key)
|
| 261 |
if filter_ds != "All":
|
| 262 |
points = [p for p in points if p.get("payload", {}).get("dataset") == filter_ds]
|
| 263 |
points = points[:n_samples]
|
| 264 |
st.session_state["pg_points"] = points
|
| 265 |
+
|
| 266 |
points = st.session_state.get("pg_points", [])
|
| 267 |
+
|
| 268 |
if not points:
|
| 269 |
st.caption("Click 'Sample' to load documents")
|
| 270 |
return
|
| 271 |
+
|
| 272 |
st.success(f"**{len(points)} points loaded**")
|
| 273 |
+
|
| 274 |
for i, pt in enumerate(points):
|
| 275 |
p = pt.get("payload", {})
|
| 276 |
+
|
| 277 |
filename = p.get("filename") or p.get("doc_id") or p.get("source_doc_id") or "Unknown"
|
| 278 |
page_num = p.get("page_number") or p.get("page") or "?"
|
| 279 |
+
|
| 280 |
with st.expander(f"**{i+1}.** {str(filename)[:40]} - Page {page_num}", expanded=(i == 0)):
|
| 281 |
render_document_details(pt, p)
|
| 282 |
|
|
|
|
| 284 |
def render_rag_query_interface(collection_name: str, model_name: str = None):
|
| 285 |
if not collection_name:
|
| 286 |
return
|
| 287 |
+
|
| 288 |
url, api_key = get_qdrant_credentials()
|
| 289 |
+
|
| 290 |
if not model_name:
|
| 291 |
points = sample_points_cached(collection_name, 1, 0, url, api_key)
|
| 292 |
if points:
|
| 293 |
model_name = points[0].get("payload", {}).get("model_name")
|
| 294 |
if not model_name:
|
| 295 |
model_name = AVAILABLE_MODELS[1]
|
| 296 |
+
|
| 297 |
st.caption(f"Model: **{model_name.split('/')[-1] if model_name else 'auto'}**")
|
| 298 |
+
|
| 299 |
c1, c2, c3 = st.columns([2, 1, 1])
|
| 300 |
with c2:
|
| 301 |
mode = st.selectbox("Mode", RETRIEVAL_MODES, index=0, key="q_mode")
|
| 302 |
with c3:
|
| 303 |
top_k = st.slider("Top K", 1, 30, 10, key="q_topk")
|
| 304 |
+
|
| 305 |
+
prefetch_k, stage1_mode, stage1_k, stage2_k = 256, "tokens_vs_standard_pooling", 1000, 300
|
| 306 |
+
|
| 307 |
if mode == "two_stage":
|
| 308 |
cc1, cc2 = st.columns(2)
|
| 309 |
with cc1:
|
|
|
|
| 316 |
stage1_k = st.number_input("Stage1 K", 100, 5000, 1000, key="q_s1k")
|
| 317 |
with cc2:
|
| 318 |
stage2_k = st.number_input("Stage2 K", 50, 1000, 300, key="q_s2k")
|
| 319 |
+
|
| 320 |
with c1:
|
| 321 |
query = st.text_input("Query", placeholder="Enter your search query...", key="q_text")
|
| 322 |
+
|
| 323 |
if st.button("🔍 Search", type="primary", disabled=not query, key="q_search"):
|
| 324 |
with st.spinner("Searching..."):
|
| 325 |
results, err = search_collection(
|
| 326 |
+
collection_name,
|
| 327 |
+
query,
|
| 328 |
+
top_k,
|
| 329 |
+
mode,
|
| 330 |
+
prefetch_k,
|
| 331 |
+
stage1_mode,
|
| 332 |
+
stage1_k,
|
| 333 |
+
stage2_k,
|
| 334 |
+
model_name,
|
| 335 |
)
|
| 336 |
if err:
|
| 337 |
st.error("Search failed")
|
| 338 |
st.code(err)
|
| 339 |
else:
|
| 340 |
st.session_state["q_results"] = results
|
| 341 |
+
|
| 342 |
results = st.session_state.get("q_results", [])
|
| 343 |
if results:
|
| 344 |
st.success(f"**{len(results)} results**")
|
| 345 |
max_score = max(r.get("score_final", r.get("score_stage1", 0)) for r in results) or 1
|
| 346 |
+
|
| 347 |
for i, r in enumerate(results):
|
| 348 |
p = r.get("payload", {})
|
| 349 |
score = r.get("score_final", r.get("score_stage1", 0))
|
| 350 |
rel = score / max_score * 100
|
| 351 |
+
|
| 352 |
filename = p.get("filename") or p.get("doc_id") or p.get("source_doc_id") or "Unknown"
|
| 353 |
page_num = p.get("page_number") or p.get("page") or "?"
|
| 354 |
+
|
| 355 |
+
with st.expander(
|
| 356 |
+
f"**#{i+1}** {str(filename)[:35]} - Page {page_num} | 🎯 {rel:.0f}%",
|
| 357 |
+
expanded=(i < 3),
|
| 358 |
+
):
|
| 359 |
render_document_details(r, p, score=score, rel_pct=rel)
|
demo/ui/sidebar.py
CHANGED
|
@@ -1,23 +1,24 @@
|
|
| 1 |
"""Sidebar component."""
|
| 2 |
|
| 3 |
import os
|
| 4 |
-
import streamlit as st
|
| 5 |
|
|
|
|
| 6 |
from qdrant_client.models import VectorParamsDiff
|
| 7 |
|
| 8 |
from demo.qdrant_utils import (
|
|
|
|
|
|
|
| 9 |
get_qdrant_credentials,
|
|
|
|
| 10 |
init_qdrant_client_with_creds,
|
| 11 |
-
get_collections,
|
| 12 |
-
get_collection_stats,
|
| 13 |
sample_points_cached,
|
| 14 |
-
get_vector_sizes,
|
| 15 |
)
|
| 16 |
|
| 17 |
|
| 18 |
def render_sidebar():
|
| 19 |
# CSS to make sidebar metrics smaller
|
| 20 |
-
st.markdown(
|
|
|
|
| 21 |
<style>
|
| 22 |
/* Smaller metrics in sidebar */
|
| 23 |
[data-testid="stSidebar"] [data-testid="stMetricValue"] {
|
|
@@ -36,19 +37,21 @@ def render_sidebar():
|
|
| 36 |
margin-bottom: 0.5rem !important;
|
| 37 |
}
|
| 38 |
</style>
|
| 39 |
-
""",
|
| 40 |
-
|
|
|
|
|
|
|
| 41 |
with st.sidebar:
|
| 42 |
st.subheader("🔑 Qdrant Credentials")
|
| 43 |
-
|
| 44 |
env_url = os.getenv("QDRANT_URL") or os.getenv("SIGIR_QDRANT_URL") or ""
|
| 45 |
env_key = os.getenv("QDRANT_API_KEY") or os.getenv("SIGIR_QDRANT_KEY") or ""
|
| 46 |
-
|
| 47 |
if "qdrant_url_input" not in st.session_state:
|
| 48 |
st.session_state["qdrant_url_input"] = env_url
|
| 49 |
if "qdrant_key_input" not in st.session_state:
|
| 50 |
st.session_state["qdrant_key_input"] = env_key
|
| 51 |
-
|
| 52 |
qdrant_url = st.text_input(
|
| 53 |
"Qdrant URL",
|
| 54 |
value=st.session_state["qdrant_url_input"],
|
|
@@ -61,20 +64,23 @@ def render_sidebar():
|
|
| 61 |
key="qdrant_key_widget",
|
| 62 |
type="password",
|
| 63 |
)
|
| 64 |
-
|
| 65 |
-
if
|
|
|
|
|
|
|
|
|
|
| 66 |
st.session_state["qdrant_url_input"] = qdrant_url
|
| 67 |
st.session_state["qdrant_key_input"] = qdrant_key
|
| 68 |
get_collections.clear()
|
| 69 |
get_collection_stats.clear()
|
| 70 |
sample_points_cached.clear()
|
| 71 |
-
|
| 72 |
st.divider()
|
| 73 |
-
|
| 74 |
st.subheader("📡 Status")
|
| 75 |
url, api_key = get_qdrant_credentials()
|
| 76 |
client, err = init_qdrant_client_with_creds(url, api_key)
|
| 77 |
-
|
| 78 |
col_s1, col_s2 = st.columns(2)
|
| 79 |
with col_s1:
|
| 80 |
if client:
|
|
@@ -82,14 +88,16 @@ def render_sidebar():
|
|
| 82 |
else:
|
| 83 |
st.error("Qdrant ✗", icon="❌")
|
| 84 |
with col_s2:
|
| 85 |
-
cloudinary_ok = all(
|
|
|
|
|
|
|
| 86 |
if cloudinary_ok:
|
| 87 |
st.success("Cloudinary ✓", icon="✅")
|
| 88 |
else:
|
| 89 |
st.warning("Cloudinary ✗", icon="⚠️")
|
| 90 |
-
|
| 91 |
st.divider()
|
| 92 |
-
|
| 93 |
with st.expander("📦 Collection", expanded=True):
|
| 94 |
collections = get_collections(url, api_key)
|
| 95 |
if collections:
|
|
@@ -109,17 +117,23 @@ def render_sidebar():
|
|
| 109 |
if "error" not in stats:
|
| 110 |
col1, col2 = st.columns(2)
|
| 111 |
col1.metric("Points", f"{stats.get('points_count', 0):,}")
|
| 112 |
-
status_raw =
|
| 113 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
col2.metric("Status", status_icon)
|
| 115 |
-
|
| 116 |
points = stats.get("points_count", 0)
|
| 117 |
indexed = stats.get("indexed_vectors_count", 0) or 0
|
| 118 |
is_indexed = indexed >= points and points > 0
|
| 119 |
col3, col4 = st.columns(2)
|
| 120 |
col3.metric("Indexed", f"{indexed:,}")
|
| 121 |
col4.metric("HNSW", "✅" if is_indexed else "⏳")
|
| 122 |
-
|
| 123 |
vector_info = stats.get("vector_info", {})
|
| 124 |
if vector_info:
|
| 125 |
st.markdown("---")
|
|
@@ -135,14 +149,16 @@ def render_sidebar():
|
|
| 135 |
on_disk = vinfo.get("on_disk", False)
|
| 136 |
disk_icon = "💾" if on_disk else "🧠"
|
| 137 |
dim_str = f"{num_vec}×{dim}"
|
| 138 |
-
rows.append(
|
|
|
|
|
|
|
| 139 |
table_html = f"<table style='width:100%;font-size:0.85em;'>{''.join(rows)}</table>"
|
| 140 |
st.markdown(table_html, unsafe_allow_html=True)
|
| 141 |
else:
|
| 142 |
st.error("Error loading stats")
|
| 143 |
else:
|
| 144 |
st.info("No collections")
|
| 145 |
-
|
| 146 |
with st.expander("⚙️ Admin", expanded=False):
|
| 147 |
active = st.session_state.get("active_collection")
|
| 148 |
if active and client:
|
|
@@ -156,13 +172,17 @@ def render_sidebar():
|
|
| 156 |
current_on_disk = vector_info.get(sel_vec, {}).get("on_disk", False)
|
| 157 |
current_in_ram = not current_on_disk
|
| 158 |
st.caption(f"Current: {'🧠 RAM' if current_in_ram else '💾 Disk'}")
|
| 159 |
-
target_in_ram = st.toggle(
|
|
|
|
|
|
|
| 160 |
if target_in_ram != current_in_ram:
|
| 161 |
if st.button("💾 Apply Change", key="admin_apply"):
|
| 162 |
try:
|
| 163 |
client.update_collection(
|
| 164 |
collection_name=active,
|
| 165 |
-
vectors_config={
|
|
|
|
|
|
|
| 166 |
)
|
| 167 |
get_collection_stats.clear()
|
| 168 |
st.success(f"Updated {sel_vec}")
|
|
@@ -175,9 +195,9 @@ def render_sidebar():
|
|
| 175 |
st.info("No vectors")
|
| 176 |
else:
|
| 177 |
st.info("Select a collection")
|
| 178 |
-
|
| 179 |
st.divider()
|
| 180 |
-
|
| 181 |
if st.button("🔄 Refresh", type="secondary", use_container_width=True):
|
| 182 |
get_collections.clear()
|
| 183 |
get_collection_stats.clear()
|
|
|
|
| 1 |
"""Sidebar component."""
|
| 2 |
|
| 3 |
import os
|
|
|
|
| 4 |
|
| 5 |
+
import streamlit as st
|
| 6 |
from qdrant_client.models import VectorParamsDiff
|
| 7 |
|
| 8 |
from demo.qdrant_utils import (
|
| 9 |
+
get_collection_stats,
|
| 10 |
+
get_collections,
|
| 11 |
get_qdrant_credentials,
|
| 12 |
+
get_vector_sizes,
|
| 13 |
init_qdrant_client_with_creds,
|
|
|
|
|
|
|
| 14 |
sample_points_cached,
|
|
|
|
| 15 |
)
|
| 16 |
|
| 17 |
|
| 18 |
def render_sidebar():
|
| 19 |
# CSS to make sidebar metrics smaller
|
| 20 |
+
st.markdown(
|
| 21 |
+
"""
|
| 22 |
<style>
|
| 23 |
/* Smaller metrics in sidebar */
|
| 24 |
[data-testid="stSidebar"] [data-testid="stMetricValue"] {
|
|
|
|
| 37 |
margin-bottom: 0.5rem !important;
|
| 38 |
}
|
| 39 |
</style>
|
| 40 |
+
""",
|
| 41 |
+
unsafe_allow_html=True,
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
with st.sidebar:
|
| 45 |
st.subheader("🔑 Qdrant Credentials")
|
| 46 |
+
|
| 47 |
env_url = os.getenv("QDRANT_URL") or os.getenv("SIGIR_QDRANT_URL") or ""
|
| 48 |
env_key = os.getenv("QDRANT_API_KEY") or os.getenv("SIGIR_QDRANT_KEY") or ""
|
| 49 |
+
|
| 50 |
if "qdrant_url_input" not in st.session_state:
|
| 51 |
st.session_state["qdrant_url_input"] = env_url
|
| 52 |
if "qdrant_key_input" not in st.session_state:
|
| 53 |
st.session_state["qdrant_key_input"] = env_key
|
| 54 |
+
|
| 55 |
qdrant_url = st.text_input(
|
| 56 |
"Qdrant URL",
|
| 57 |
value=st.session_state["qdrant_url_input"],
|
|
|
|
| 64 |
key="qdrant_key_widget",
|
| 65 |
type="password",
|
| 66 |
)
|
| 67 |
+
|
| 68 |
+
if (
|
| 69 |
+
qdrant_url != st.session_state["qdrant_url_input"]
|
| 70 |
+
or qdrant_key != st.session_state["qdrant_key_input"]
|
| 71 |
+
):
|
| 72 |
st.session_state["qdrant_url_input"] = qdrant_url
|
| 73 |
st.session_state["qdrant_key_input"] = qdrant_key
|
| 74 |
get_collections.clear()
|
| 75 |
get_collection_stats.clear()
|
| 76 |
sample_points_cached.clear()
|
| 77 |
+
|
| 78 |
st.divider()
|
| 79 |
+
|
| 80 |
st.subheader("📡 Status")
|
| 81 |
url, api_key = get_qdrant_credentials()
|
| 82 |
client, err = init_qdrant_client_with_creds(url, api_key)
|
| 83 |
+
|
| 84 |
col_s1, col_s2 = st.columns(2)
|
| 85 |
with col_s1:
|
| 86 |
if client:
|
|
|
|
| 88 |
else:
|
| 89 |
st.error("Qdrant ✗", icon="❌")
|
| 90 |
with col_s2:
|
| 91 |
+
cloudinary_ok = all(
|
| 92 |
+
[os.getenv("CLOUDINARY_CLOUD_NAME"), os.getenv("CLOUDINARY_API_KEY")]
|
| 93 |
+
)
|
| 94 |
if cloudinary_ok:
|
| 95 |
st.success("Cloudinary ✓", icon="✅")
|
| 96 |
else:
|
| 97 |
st.warning("Cloudinary ✗", icon="⚠️")
|
| 98 |
+
|
| 99 |
st.divider()
|
| 100 |
+
|
| 101 |
with st.expander("📦 Collection", expanded=True):
|
| 102 |
collections = get_collections(url, api_key)
|
| 103 |
if collections:
|
|
|
|
| 117 |
if "error" not in stats:
|
| 118 |
col1, col2 = st.columns(2)
|
| 119 |
col1.metric("Points", f"{stats.get('points_count', 0):,}")
|
| 120 |
+
status_raw = (
|
| 121 |
+
stats.get("status", "unknown").replace("CollectionStatus.", "").lower()
|
| 122 |
+
)
|
| 123 |
+
status_icon = (
|
| 124 |
+
"🟢"
|
| 125 |
+
if status_raw == "green"
|
| 126 |
+
else "🟡" if status_raw == "yellow" else "🔴"
|
| 127 |
+
)
|
| 128 |
col2.metric("Status", status_icon)
|
| 129 |
+
|
| 130 |
points = stats.get("points_count", 0)
|
| 131 |
indexed = stats.get("indexed_vectors_count", 0) or 0
|
| 132 |
is_indexed = indexed >= points and points > 0
|
| 133 |
col3, col4 = st.columns(2)
|
| 134 |
col3.metric("Indexed", f"{indexed:,}")
|
| 135 |
col4.metric("HNSW", "✅" if is_indexed else "⏳")
|
| 136 |
+
|
| 137 |
vector_info = stats.get("vector_info", {})
|
| 138 |
if vector_info:
|
| 139 |
st.markdown("---")
|
|
|
|
| 149 |
on_disk = vinfo.get("on_disk", False)
|
| 150 |
disk_icon = "💾" if on_disk else "🧠"
|
| 151 |
dim_str = f"{num_vec}×{dim}"
|
| 152 |
+
rows.append(
|
| 153 |
+
f"<tr><td style='text-align:left;padding-right:12px;'><code>{vname}</code></td><td style='text-align:right;'>{dim_str}, {dtype}, {disk_icon}</td></tr>"
|
| 154 |
+
)
|
| 155 |
table_html = f"<table style='width:100%;font-size:0.85em;'>{''.join(rows)}</table>"
|
| 156 |
st.markdown(table_html, unsafe_allow_html=True)
|
| 157 |
else:
|
| 158 |
st.error("Error loading stats")
|
| 159 |
else:
|
| 160 |
st.info("No collections")
|
| 161 |
+
|
| 162 |
with st.expander("⚙️ Admin", expanded=False):
|
| 163 |
active = st.session_state.get("active_collection")
|
| 164 |
if active and client:
|
|
|
|
| 172 |
current_on_disk = vector_info.get(sel_vec, {}).get("on_disk", False)
|
| 173 |
current_in_ram = not current_on_disk
|
| 174 |
st.caption(f"Current: {'🧠 RAM' if current_in_ram else '💾 Disk'}")
|
| 175 |
+
target_in_ram = st.toggle(
|
| 176 |
+
"Move to RAM", value=current_in_ram, key=f"admin_ram_{sel_vec}"
|
| 177 |
+
)
|
| 178 |
if target_in_ram != current_in_ram:
|
| 179 |
if st.button("💾 Apply Change", key="admin_apply"):
|
| 180 |
try:
|
| 181 |
client.update_collection(
|
| 182 |
collection_name=active,
|
| 183 |
+
vectors_config={
|
| 184 |
+
sel_vec: VectorParamsDiff(on_disk=not target_in_ram)
|
| 185 |
+
},
|
| 186 |
)
|
| 187 |
get_collection_stats.clear()
|
| 188 |
st.success(f"Updated {sel_vec}")
|
|
|
|
| 195 |
st.info("No vectors")
|
| 196 |
else:
|
| 197 |
st.info("Select a collection")
|
| 198 |
+
|
| 199 |
st.divider()
|
| 200 |
+
|
| 201 |
if st.button("🔄 Refresh", type="secondary", use_container_width=True):
|
| 202 |
get_collections.clear()
|
| 203 |
get_collection_stats.clear()
|
demo/ui/upload.py
CHANGED
|
@@ -1,12 +1,11 @@
|
|
| 1 |
"""Upload tab component."""
|
| 2 |
|
|
|
|
|
|
|
| 3 |
import os
|
| 4 |
import tempfile
|
| 5 |
import time
|
| 6 |
import traceback
|
| 7 |
-
import json
|
| 8 |
-
import inspect
|
| 9 |
-
from datetime import datetime
|
| 10 |
from pathlib import Path
|
| 11 |
|
| 12 |
import numpy as np
|
|
@@ -14,33 +13,33 @@ import streamlit as st
|
|
| 14 |
|
| 15 |
from demo.config import AVAILABLE_MODELS
|
| 16 |
from demo.qdrant_utils import (
|
| 17 |
-
get_qdrant_credentials,
|
| 18 |
get_collection_stats,
|
|
|
|
| 19 |
sample_points_cached,
|
| 20 |
)
|
| 21 |
from visual_rag.embedding.visual_embedder import VisualEmbedder
|
| 22 |
-
from visual_rag.indexing.qdrant_indexer import QdrantIndexer
|
| 23 |
from visual_rag.indexing.cloudinary_uploader import CloudinaryUploader
|
| 24 |
from visual_rag.indexing.pipeline import ProcessingPipeline
|
| 25 |
-
|
| 26 |
|
| 27 |
VECTOR_TYPES = ["initial", "mean_pooling", "experimental_pooling", "global_pooling"]
|
| 28 |
|
|
|
|
| 29 |
def _load_metadata_mapping_from_uploaded_json(uploaded_json_file) -> tuple[dict, str]:
|
| 30 |
"""
|
| 31 |
Load a filename->metadata mapping from an uploaded JSON file.
|
| 32 |
-
|
| 33 |
Supported formats:
|
| 34 |
- Flat dict:
|
| 35 |
{ "Some Report 2023": {"year": 2023, "source": "...", ...}, ... }
|
| 36 |
- Nested dict:
|
| 37 |
{ "filenames": { "Some Report 2023": {...}, ... }, ... }
|
| 38 |
-
|
| 39 |
Keys are normalized to: lowercase, trimmed, without ".pdf".
|
| 40 |
"""
|
| 41 |
if uploaded_json_file is None:
|
| 42 |
return {}, ""
|
| 43 |
-
|
| 44 |
try:
|
| 45 |
raw = uploaded_json_file.getvalue()
|
| 46 |
if not raw:
|
|
@@ -48,12 +47,12 @@ def _load_metadata_mapping_from_uploaded_json(uploaded_json_file) -> tuple[dict,
|
|
| 48 |
data = json.loads(raw.decode("utf-8"))
|
| 49 |
if not isinstance(data, dict):
|
| 50 |
return {}, "Metadata file must be a JSON object"
|
| 51 |
-
|
| 52 |
mapping = data.get("filenames") if isinstance(data.get("filenames"), dict) else data
|
| 53 |
-
|
| 54 |
# Drop non-mapping keys (common pattern: _description, _usage)
|
| 55 |
mapping = {k: v for k, v in mapping.items() if isinstance(k, str) and not k.startswith("_")}
|
| 56 |
-
|
| 57 |
normalized: dict[str, dict] = {}
|
| 58 |
bad = 0
|
| 59 |
for k, v in mapping.items():
|
|
@@ -67,7 +66,7 @@ def _load_metadata_mapping_from_uploaded_json(uploaded_json_file) -> tuple[dict,
|
|
| 67 |
bad += 1
|
| 68 |
continue
|
| 69 |
normalized[key] = v
|
| 70 |
-
|
| 71 |
msg = f"Loaded {len(normalized):,} filename metadata mappings"
|
| 72 |
if bad:
|
| 73 |
msg += f" (ignored {bad:,} non-mapping entries)"
|
|
@@ -81,26 +80,30 @@ def render_upload_tab():
|
|
| 81 |
msg = st.session_state.pop("upload_success")
|
| 82 |
st.toast(f"✅ {msg}", icon="🎉")
|
| 83 |
st.balloons()
|
| 84 |
-
|
| 85 |
st.subheader("📤 PDF Upload & Processing")
|
| 86 |
-
|
| 87 |
col_upload, col_config = st.columns([3, 2])
|
| 88 |
-
|
| 89 |
with col_config:
|
| 90 |
st.markdown("##### Configuration")
|
| 91 |
-
|
| 92 |
c1, c2 = st.columns(2)
|
| 93 |
with c1:
|
| 94 |
model_name = st.selectbox("Model", AVAILABLE_MODELS, index=1, key="upload_model")
|
| 95 |
with c2:
|
| 96 |
-
collection_name = st.text_input(
|
| 97 |
-
|
|
|
|
|
|
|
| 98 |
c3, c4 = st.columns(2)
|
| 99 |
with c3:
|
| 100 |
-
vector_dtype = st.selectbox(
|
|
|
|
|
|
|
| 101 |
with c4:
|
| 102 |
use_cloudinary = st.toggle("Cloudinary", value=True, key="upload_cloudinary")
|
| 103 |
-
|
| 104 |
st.markdown("**Performance**")
|
| 105 |
p1, p2, p3 = st.columns(3)
|
| 106 |
with p1:
|
|
@@ -139,9 +142,9 @@ def render_upload_tab():
|
|
| 139 |
VECTOR_TYPES,
|
| 140 |
default=VECTOR_TYPES,
|
| 141 |
key="upload_vectors",
|
| 142 |
-
help="Which vector types to store in Qdrant"
|
| 143 |
)
|
| 144 |
-
|
| 145 |
st.markdown("**Crop Settings**")
|
| 146 |
cc1, cc2 = st.columns(2)
|
| 147 |
with cc1:
|
|
@@ -154,7 +157,9 @@ def render_upload_tab():
|
|
| 154 |
uniform_rowcol_std_threshold = st.select_slider(
|
| 155 |
"Uniform row/col threshold (any color)",
|
| 156 |
options=[0.0, 1.0, 2.0, 3.0, 5.0, 8.0, 12.0, 16.0],
|
| 157 |
-
value=float(
|
|
|
|
|
|
|
| 158 |
key="upload_uniform_rowcol_std_threshold",
|
| 159 |
help=(
|
| 160 |
"0 = off (default). Higher values remove more uniform borders, even if they are gray/black. "
|
|
@@ -165,13 +170,20 @@ def render_upload_tab():
|
|
| 165 |
"- 8+: aggressive (may remove faint content)"
|
| 166 |
),
|
| 167 |
)
|
| 168 |
-
|
| 169 |
if crop_empty:
|
| 170 |
-
crop_pct = st.slider(
|
| 171 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
else:
|
| 173 |
crop_pct = 0.99
|
| 174 |
-
|
| 175 |
st.markdown("**File Metadata (optional)**")
|
| 176 |
meta_file = st.file_uploader(
|
| 177 |
"Metadata mapping (JSON)",
|
|
@@ -195,7 +207,7 @@ def render_upload_tab():
|
|
| 195 |
st.warning(meta_msg or "No mappings loaded")
|
| 196 |
else:
|
| 197 |
metadata_mapping = {}
|
| 198 |
-
|
| 199 |
with col_upload:
|
| 200 |
uploaded_files = st.file_uploader(
|
| 201 |
"Select PDF files",
|
|
@@ -203,10 +215,10 @@ def render_upload_tab():
|
|
| 203 |
accept_multiple_files=True,
|
| 204 |
key="pdf_uploader",
|
| 205 |
)
|
| 206 |
-
|
| 207 |
if uploaded_files:
|
| 208 |
st.success(f"**{len(uploaded_files)} file(s) selected**")
|
| 209 |
-
|
| 210 |
if st.button("🚀 Process PDFs", type="primary", key="process_btn"):
|
| 211 |
config = {
|
| 212 |
"model_name": model_name,
|
|
@@ -223,7 +235,7 @@ def render_upload_tab():
|
|
| 223 |
"upload_batch_size": int(upload_batch_size),
|
| 224 |
}
|
| 225 |
process_pdfs(uploaded_files, config)
|
| 226 |
-
|
| 227 |
if st.session_state.get("last_upload_result"):
|
| 228 |
st.divider()
|
| 229 |
render_upload_results()
|
|
@@ -241,21 +253,21 @@ def process_pdfs(uploaded_files, config):
|
|
| 241 |
dpi = int(config.get("dpi") or 140)
|
| 242 |
embed_batch_size = int(config.get("embed_batch_size") or 8)
|
| 243 |
upload_batch_size = int(config.get("upload_batch_size") or 8)
|
| 244 |
-
|
| 245 |
st.divider()
|
| 246 |
-
|
| 247 |
phase1 = st.container()
|
| 248 |
phase2 = st.container()
|
| 249 |
phase3 = st.container()
|
| 250 |
results_container = st.container()
|
| 251 |
-
|
| 252 |
try:
|
| 253 |
with phase1:
|
| 254 |
st.markdown("##### 🤖 Phase 1: Loading Model")
|
| 255 |
model_status = st.empty()
|
| 256 |
model_short = model_name.split("/")[-1]
|
| 257 |
model_status.info(f"Loading `{model_short}`...")
|
| 258 |
-
|
| 259 |
output_dtype = np.float16 if vector_dtype == "float16" else np.float32
|
| 260 |
embedder_key = f"{model_name}::{vector_dtype}"
|
| 261 |
embedder = None
|
|
@@ -267,18 +279,18 @@ def process_pdfs(uploaded_files, config):
|
|
| 267 |
st.session_state["upload_embedder_key"] = embedder_key
|
| 268 |
st.session_state["upload_embedder"] = embedder
|
| 269 |
model_status.success(f"✅ Model `{model_short}` loaded ({vector_dtype})")
|
| 270 |
-
|
| 271 |
with phase2:
|
| 272 |
st.markdown("##### 📦 Phase 2: Setting Up Collection")
|
| 273 |
-
|
| 274 |
url, api_key = get_qdrant_credentials()
|
| 275 |
if not url or not api_key:
|
| 276 |
st.error("Qdrant credentials not configured")
|
| 277 |
return
|
| 278 |
-
|
| 279 |
qdrant_status = st.empty()
|
| 280 |
-
qdrant_status.info(
|
| 281 |
-
|
| 282 |
indexer = QdrantIndexer(
|
| 283 |
url=url,
|
| 284 |
api_key=api_key,
|
|
@@ -287,15 +299,15 @@ def process_pdfs(uploaded_files, config):
|
|
| 287 |
vector_datatype=vector_dtype,
|
| 288 |
timeout=180,
|
| 289 |
)
|
| 290 |
-
qdrant_status.success(
|
| 291 |
-
|
| 292 |
coll_status = st.empty()
|
| 293 |
collection_exists = False
|
| 294 |
try:
|
| 295 |
collection_exists = indexer.collection_exists()
|
| 296 |
except Exception:
|
| 297 |
pass
|
| 298 |
-
|
| 299 |
if collection_exists:
|
| 300 |
coll_status.success(f"✅ Collection `{collection_name}` exists (will append)")
|
| 301 |
else:
|
|
@@ -304,24 +316,26 @@ def process_pdfs(uploaded_files, config):
|
|
| 304 |
try:
|
| 305 |
indexer.create_collection(force_recreate=False)
|
| 306 |
break
|
| 307 |
-
except Exception
|
| 308 |
if attempt < 2:
|
| 309 |
time.sleep(2)
|
| 310 |
else:
|
| 311 |
raise
|
| 312 |
coll_status.success(f"✅ Collection `{collection_name}` created")
|
| 313 |
-
|
| 314 |
idx_status = st.empty()
|
| 315 |
idx_status.info("Setting up indexes...")
|
| 316 |
try:
|
| 317 |
-
indexer.create_payload_indexes(
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
|
|
|
|
|
|
| 321 |
except Exception:
|
| 322 |
pass
|
| 323 |
idx_status.success("✅ Indexes ready")
|
| 324 |
-
|
| 325 |
cloud_status = st.empty()
|
| 326 |
cloudinary_uploader = None
|
| 327 |
if use_cloudinary:
|
|
@@ -333,9 +347,11 @@ def process_pdfs(uploaded_files, config):
|
|
| 333 |
cloud_status.warning(f"⚠️ Cloudinary unavailable: {str(e)[:30]}")
|
| 334 |
else:
|
| 335 |
cloud_status.info("☁️ Cloudinary disabled")
|
| 336 |
-
|
| 337 |
pipeline = ProcessingPipeline(
|
| 338 |
-
embedder=embedder,
|
|
|
|
|
|
|
| 339 |
metadata_mapping=metadata_mapping,
|
| 340 |
config={
|
| 341 |
"processing": {"dpi": dpi},
|
|
@@ -344,46 +360,54 @@ def process_pdfs(uploaded_files, config):
|
|
| 344 |
"upload_batch_size": upload_batch_size,
|
| 345 |
},
|
| 346 |
},
|
| 347 |
-
crop_empty=crop_empty,
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 351 |
)
|
| 352 |
-
|
| 353 |
with phase3:
|
| 354 |
st.markdown("##### 📄 Phase 3: Processing PDFs")
|
| 355 |
-
|
| 356 |
overall_progress = st.progress(0.0)
|
| 357 |
file_status = st.empty()
|
| 358 |
log_area = st.empty()
|
| 359 |
log_lines = []
|
| 360 |
-
|
| 361 |
total_uploaded, total_skipped, total_failed = 0, 0, 0
|
| 362 |
file_results = []
|
| 363 |
-
|
| 364 |
page_status = st.empty()
|
| 365 |
-
|
| 366 |
for i, f in enumerate(uploaded_files):
|
| 367 |
original_filename = f.name
|
| 368 |
-
file_status.info(
|
|
|
|
|
|
|
| 369 |
t0 = time.perf_counter()
|
| 370 |
-
|
| 371 |
with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as tmp:
|
| 372 |
tmp.write(f.getvalue())
|
| 373 |
tmp_path = Path(tmp.name)
|
| 374 |
-
|
| 375 |
def progress_cb(stage, current, total, message):
|
| 376 |
if stage == "process" and total > 0:
|
| 377 |
page_status.caption(f" └─ Page {current}/{total}")
|
| 378 |
elif stage == "embed" and total > 0:
|
| 379 |
# Never show internal function names; keep this human-friendly.
|
| 380 |
-
page_status.caption(
|
|
|
|
|
|
|
| 381 |
elif stage == "convert" and total > 0:
|
| 382 |
page_status.caption(f" └─ {total} pages found")
|
| 383 |
-
|
| 384 |
try:
|
| 385 |
result = pipeline.process_pdf(
|
| 386 |
-
tmp_path,
|
| 387 |
original_filename=original_filename,
|
| 388 |
progress_callback=progress_cb,
|
| 389 |
)
|
|
@@ -394,36 +418,46 @@ def process_pdfs(uploaded_files, config):
|
|
| 394 |
total_skipped += skipped
|
| 395 |
total_pages = int(result.get("total_pages") or 0)
|
| 396 |
sec_per_page = (elapsed_s / total_pages) if total_pages > 0 else None
|
| 397 |
-
file_results.append(
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 407 |
except Exception as e:
|
| 408 |
total_failed += 1
|
| 409 |
log_lines.append(f"✗ {original_filename}: {str(e)[:50]}")
|
| 410 |
finally:
|
| 411 |
os.unlink(tmp_path)
|
| 412 |
-
|
| 413 |
page_status.empty()
|
| 414 |
overall_progress.progress((i + 1) / len(uploaded_files))
|
| 415 |
log_area.code("\n".join(log_lines[-10:]), language="text")
|
| 416 |
-
|
| 417 |
overall_progress.progress(1.0)
|
| 418 |
file_status.success(f"✅ Processed {len(uploaded_files)} file(s)")
|
| 419 |
-
|
| 420 |
with results_container:
|
| 421 |
st.markdown("##### 📊 Results")
|
| 422 |
-
|
| 423 |
-
st.success(
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
|
|
|
|
|
|
| 427 |
if file_results:
|
| 428 |
with st.expander("📋 File Details", expanded=False):
|
| 429 |
for fr in file_results:
|
|
@@ -438,19 +472,24 @@ def process_pdfs(uploaded_files, config):
|
|
| 438 |
+ (f", {fr['total_pages']} pages" if fr.get("total_pages") else "")
|
| 439 |
+ timing
|
| 440 |
)
|
| 441 |
-
|
| 442 |
st.session_state["last_upload_result"] = {
|
| 443 |
-
"total_uploaded": total_uploaded,
|
| 444 |
-
"
|
|
|
|
|
|
|
|
|
|
| 445 |
}
|
| 446 |
-
|
| 447 |
get_collection_stats.clear()
|
| 448 |
sample_points_cached.clear()
|
| 449 |
-
|
| 450 |
if total_uploaded > 0:
|
| 451 |
-
st.session_state["upload_success"] =
|
|
|
|
|
|
|
| 452 |
st.rerun() # Immediately refresh to show success toast + balloons
|
| 453 |
-
|
| 454 |
except Exception as e:
|
| 455 |
st.error(f"❌ Processing error: {e}")
|
| 456 |
with st.expander("Traceback"):
|
|
@@ -461,17 +500,19 @@ def render_upload_results():
|
|
| 461 |
result = st.session_state.get("last_upload_result", {})
|
| 462 |
if not result:
|
| 463 |
return
|
| 464 |
-
|
| 465 |
uploaded = result.get("total_uploaded", 0)
|
| 466 |
skipped = result.get("total_skipped", 0)
|
| 467 |
failed = result.get("total_failed", 0)
|
| 468 |
collection = result.get("collection", "")
|
| 469 |
file_results = result.get("file_results", [])
|
| 470 |
-
|
| 471 |
-
st.success(
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
|
|
|
|
|
|
| 475 |
if file_results:
|
| 476 |
with st.expander("📋 Details", expanded=False):
|
| 477 |
for fr in file_results:
|
|
|
|
| 1 |
"""Upload tab component."""
|
| 2 |
|
| 3 |
+
import inspect
|
| 4 |
+
import json
|
| 5 |
import os
|
| 6 |
import tempfile
|
| 7 |
import time
|
| 8 |
import traceback
|
|
|
|
|
|
|
|
|
|
| 9 |
from pathlib import Path
|
| 10 |
|
| 11 |
import numpy as np
|
|
|
|
| 13 |
|
| 14 |
from demo.config import AVAILABLE_MODELS
|
| 15 |
from demo.qdrant_utils import (
|
|
|
|
| 16 |
get_collection_stats,
|
| 17 |
+
get_qdrant_credentials,
|
| 18 |
sample_points_cached,
|
| 19 |
)
|
| 20 |
from visual_rag.embedding.visual_embedder import VisualEmbedder
|
|
|
|
| 21 |
from visual_rag.indexing.cloudinary_uploader import CloudinaryUploader
|
| 22 |
from visual_rag.indexing.pipeline import ProcessingPipeline
|
| 23 |
+
from visual_rag.indexing.qdrant_indexer import QdrantIndexer
|
| 24 |
|
| 25 |
VECTOR_TYPES = ["initial", "mean_pooling", "experimental_pooling", "global_pooling"]
|
| 26 |
|
| 27 |
+
|
| 28 |
def _load_metadata_mapping_from_uploaded_json(uploaded_json_file) -> tuple[dict, str]:
|
| 29 |
"""
|
| 30 |
Load a filename->metadata mapping from an uploaded JSON file.
|
| 31 |
+
|
| 32 |
Supported formats:
|
| 33 |
- Flat dict:
|
| 34 |
{ "Some Report 2023": {"year": 2023, "source": "...", ...}, ... }
|
| 35 |
- Nested dict:
|
| 36 |
{ "filenames": { "Some Report 2023": {...}, ... }, ... }
|
| 37 |
+
|
| 38 |
Keys are normalized to: lowercase, trimmed, without ".pdf".
|
| 39 |
"""
|
| 40 |
if uploaded_json_file is None:
|
| 41 |
return {}, ""
|
| 42 |
+
|
| 43 |
try:
|
| 44 |
raw = uploaded_json_file.getvalue()
|
| 45 |
if not raw:
|
|
|
|
| 47 |
data = json.loads(raw.decode("utf-8"))
|
| 48 |
if not isinstance(data, dict):
|
| 49 |
return {}, "Metadata file must be a JSON object"
|
| 50 |
+
|
| 51 |
mapping = data.get("filenames") if isinstance(data.get("filenames"), dict) else data
|
| 52 |
+
|
| 53 |
# Drop non-mapping keys (common pattern: _description, _usage)
|
| 54 |
mapping = {k: v for k, v in mapping.items() if isinstance(k, str) and not k.startswith("_")}
|
| 55 |
+
|
| 56 |
normalized: dict[str, dict] = {}
|
| 57 |
bad = 0
|
| 58 |
for k, v in mapping.items():
|
|
|
|
| 66 |
bad += 1
|
| 67 |
continue
|
| 68 |
normalized[key] = v
|
| 69 |
+
|
| 70 |
msg = f"Loaded {len(normalized):,} filename metadata mappings"
|
| 71 |
if bad:
|
| 72 |
msg += f" (ignored {bad:,} non-mapping entries)"
|
|
|
|
| 80 |
msg = st.session_state.pop("upload_success")
|
| 81 |
st.toast(f"✅ {msg}", icon="🎉")
|
| 82 |
st.balloons()
|
| 83 |
+
|
| 84 |
st.subheader("📤 PDF Upload & Processing")
|
| 85 |
+
|
| 86 |
col_upload, col_config = st.columns([3, 2])
|
| 87 |
+
|
| 88 |
with col_config:
|
| 89 |
st.markdown("##### Configuration")
|
| 90 |
+
|
| 91 |
c1, c2 = st.columns(2)
|
| 92 |
with c1:
|
| 93 |
model_name = st.selectbox("Model", AVAILABLE_MODELS, index=1, key="upload_model")
|
| 94 |
with c2:
|
| 95 |
+
collection_name = st.text_input(
|
| 96 |
+
"Collection", value="my_collection", key="upload_collection_input"
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
c3, c4 = st.columns(2)
|
| 100 |
with c3:
|
| 101 |
+
vector_dtype = st.selectbox(
|
| 102 |
+
"Vector Dtype", ["float16", "float32"], index=0, key="upload_dtype"
|
| 103 |
+
)
|
| 104 |
with c4:
|
| 105 |
use_cloudinary = st.toggle("Cloudinary", value=True, key="upload_cloudinary")
|
| 106 |
+
|
| 107 |
st.markdown("**Performance**")
|
| 108 |
p1, p2, p3 = st.columns(3)
|
| 109 |
with p1:
|
|
|
|
| 142 |
VECTOR_TYPES,
|
| 143 |
default=VECTOR_TYPES,
|
| 144 |
key="upload_vectors",
|
| 145 |
+
help="Which vector types to store in Qdrant",
|
| 146 |
)
|
| 147 |
+
|
| 148 |
st.markdown("**Crop Settings**")
|
| 149 |
cc1, cc2 = st.columns(2)
|
| 150 |
with cc1:
|
|
|
|
| 157 |
uniform_rowcol_std_threshold = st.select_slider(
|
| 158 |
"Uniform row/col threshold (any color)",
|
| 159 |
options=[0.0, 1.0, 2.0, 3.0, 5.0, 8.0, 12.0, 16.0],
|
| 160 |
+
value=float(
|
| 161 |
+
st.session_state.get("upload_uniform_rowcol_std_threshold", 0.0) or 0.0
|
| 162 |
+
),
|
| 163 |
key="upload_uniform_rowcol_std_threshold",
|
| 164 |
help=(
|
| 165 |
"0 = off (default). Higher values remove more uniform borders, even if they are gray/black. "
|
|
|
|
| 170 |
"- 8+: aggressive (may remove faint content)"
|
| 171 |
),
|
| 172 |
)
|
| 173 |
+
|
| 174 |
if crop_empty:
|
| 175 |
+
crop_pct = st.slider(
|
| 176 |
+
"Crop %",
|
| 177 |
+
0.90,
|
| 178 |
+
0.99,
|
| 179 |
+
0.99,
|
| 180 |
+
0.01,
|
| 181 |
+
key="upload_crop_pct",
|
| 182 |
+
help="Remove margins with this % empty space",
|
| 183 |
+
)
|
| 184 |
else:
|
| 185 |
crop_pct = 0.99
|
| 186 |
+
|
| 187 |
st.markdown("**File Metadata (optional)**")
|
| 188 |
meta_file = st.file_uploader(
|
| 189 |
"Metadata mapping (JSON)",
|
|
|
|
| 207 |
st.warning(meta_msg or "No mappings loaded")
|
| 208 |
else:
|
| 209 |
metadata_mapping = {}
|
| 210 |
+
|
| 211 |
with col_upload:
|
| 212 |
uploaded_files = st.file_uploader(
|
| 213 |
"Select PDF files",
|
|
|
|
| 215 |
accept_multiple_files=True,
|
| 216 |
key="pdf_uploader",
|
| 217 |
)
|
| 218 |
+
|
| 219 |
if uploaded_files:
|
| 220 |
st.success(f"**{len(uploaded_files)} file(s) selected**")
|
| 221 |
+
|
| 222 |
if st.button("🚀 Process PDFs", type="primary", key="process_btn"):
|
| 223 |
config = {
|
| 224 |
"model_name": model_name,
|
|
|
|
| 235 |
"upload_batch_size": int(upload_batch_size),
|
| 236 |
}
|
| 237 |
process_pdfs(uploaded_files, config)
|
| 238 |
+
|
| 239 |
if st.session_state.get("last_upload_result"):
|
| 240 |
st.divider()
|
| 241 |
render_upload_results()
|
|
|
|
| 253 |
dpi = int(config.get("dpi") or 140)
|
| 254 |
embed_batch_size = int(config.get("embed_batch_size") or 8)
|
| 255 |
upload_batch_size = int(config.get("upload_batch_size") or 8)
|
| 256 |
+
|
| 257 |
st.divider()
|
| 258 |
+
|
| 259 |
phase1 = st.container()
|
| 260 |
phase2 = st.container()
|
| 261 |
phase3 = st.container()
|
| 262 |
results_container = st.container()
|
| 263 |
+
|
| 264 |
try:
|
| 265 |
with phase1:
|
| 266 |
st.markdown("##### 🤖 Phase 1: Loading Model")
|
| 267 |
model_status = st.empty()
|
| 268 |
model_short = model_name.split("/")[-1]
|
| 269 |
model_status.info(f"Loading `{model_short}`...")
|
| 270 |
+
|
| 271 |
output_dtype = np.float16 if vector_dtype == "float16" else np.float32
|
| 272 |
embedder_key = f"{model_name}::{vector_dtype}"
|
| 273 |
embedder = None
|
|
|
|
| 279 |
st.session_state["upload_embedder_key"] = embedder_key
|
| 280 |
st.session_state["upload_embedder"] = embedder
|
| 281 |
model_status.success(f"✅ Model `{model_short}` loaded ({vector_dtype})")
|
| 282 |
+
|
| 283 |
with phase2:
|
| 284 |
st.markdown("##### 📦 Phase 2: Setting Up Collection")
|
| 285 |
+
|
| 286 |
url, api_key = get_qdrant_credentials()
|
| 287 |
if not url or not api_key:
|
| 288 |
st.error("Qdrant credentials not configured")
|
| 289 |
return
|
| 290 |
+
|
| 291 |
qdrant_status = st.empty()
|
| 292 |
+
qdrant_status.info("Connecting to Qdrant...")
|
| 293 |
+
|
| 294 |
indexer = QdrantIndexer(
|
| 295 |
url=url,
|
| 296 |
api_key=api_key,
|
|
|
|
| 299 |
vector_datatype=vector_dtype,
|
| 300 |
timeout=180,
|
| 301 |
)
|
| 302 |
+
qdrant_status.success("✅ Connected to Qdrant")
|
| 303 |
+
|
| 304 |
coll_status = st.empty()
|
| 305 |
collection_exists = False
|
| 306 |
try:
|
| 307 |
collection_exists = indexer.collection_exists()
|
| 308 |
except Exception:
|
| 309 |
pass
|
| 310 |
+
|
| 311 |
if collection_exists:
|
| 312 |
coll_status.success(f"✅ Collection `{collection_name}` exists (will append)")
|
| 313 |
else:
|
|
|
|
| 316 |
try:
|
| 317 |
indexer.create_collection(force_recreate=False)
|
| 318 |
break
|
| 319 |
+
except Exception:
|
| 320 |
if attempt < 2:
|
| 321 |
time.sleep(2)
|
| 322 |
else:
|
| 323 |
raise
|
| 324 |
coll_status.success(f"✅ Collection `{collection_name}` created")
|
| 325 |
+
|
| 326 |
idx_status = st.empty()
|
| 327 |
idx_status.info("Setting up indexes...")
|
| 328 |
try:
|
| 329 |
+
indexer.create_payload_indexes(
|
| 330 |
+
fields=[
|
| 331 |
+
{"field": "filename", "type": "keyword"},
|
| 332 |
+
{"field": "page_number", "type": "integer"},
|
| 333 |
+
]
|
| 334 |
+
)
|
| 335 |
except Exception:
|
| 336 |
pass
|
| 337 |
idx_status.success("✅ Indexes ready")
|
| 338 |
+
|
| 339 |
cloud_status = st.empty()
|
| 340 |
cloudinary_uploader = None
|
| 341 |
if use_cloudinary:
|
|
|
|
| 347 |
cloud_status.warning(f"⚠️ Cloudinary unavailable: {str(e)[:30]}")
|
| 348 |
else:
|
| 349 |
cloud_status.info("☁️ Cloudinary disabled")
|
| 350 |
+
|
| 351 |
pipeline = ProcessingPipeline(
|
| 352 |
+
embedder=embedder,
|
| 353 |
+
indexer=indexer,
|
| 354 |
+
cloudinary_uploader=cloudinary_uploader,
|
| 355 |
metadata_mapping=metadata_mapping,
|
| 356 |
config={
|
| 357 |
"processing": {"dpi": dpi},
|
|
|
|
| 360 |
"upload_batch_size": upload_batch_size,
|
| 361 |
},
|
| 362 |
},
|
| 363 |
+
crop_empty=crop_empty,
|
| 364 |
+
crop_empty_percentage_to_remove=crop_pct,
|
| 365 |
+
**(
|
| 366 |
+
{"crop_empty_uniform_rowcol_std_threshold": uniform_rowcol_std_threshold}
|
| 367 |
+
if "crop_empty_uniform_rowcol_std_threshold"
|
| 368 |
+
in inspect.signature(ProcessingPipeline.__init__).parameters
|
| 369 |
+
else {}
|
| 370 |
+
),
|
| 371 |
)
|
| 372 |
+
|
| 373 |
with phase3:
|
| 374 |
st.markdown("##### 📄 Phase 3: Processing PDFs")
|
| 375 |
+
|
| 376 |
overall_progress = st.progress(0.0)
|
| 377 |
file_status = st.empty()
|
| 378 |
log_area = st.empty()
|
| 379 |
log_lines = []
|
| 380 |
+
|
| 381 |
total_uploaded, total_skipped, total_failed = 0, 0, 0
|
| 382 |
file_results = []
|
| 383 |
+
|
| 384 |
page_status = st.empty()
|
| 385 |
+
|
| 386 |
for i, f in enumerate(uploaded_files):
|
| 387 |
original_filename = f.name
|
| 388 |
+
file_status.info(
|
| 389 |
+
f"📄 Processing `{original_filename}` ({i+1}/{len(uploaded_files)})"
|
| 390 |
+
)
|
| 391 |
t0 = time.perf_counter()
|
| 392 |
+
|
| 393 |
with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as tmp:
|
| 394 |
tmp.write(f.getvalue())
|
| 395 |
tmp_path = Path(tmp.name)
|
| 396 |
+
|
| 397 |
def progress_cb(stage, current, total, message):
|
| 398 |
if stage == "process" and total > 0:
|
| 399 |
page_status.caption(f" └─ Page {current}/{total}")
|
| 400 |
elif stage == "embed" and total > 0:
|
| 401 |
# Never show internal function names; keep this human-friendly.
|
| 402 |
+
page_status.caption(
|
| 403 |
+
f" └─ Embedding pages… ({current+1}-{min(current + 1 + (pipeline.embedding_batch_size - 1), total)}/{total})"
|
| 404 |
+
)
|
| 405 |
elif stage == "convert" and total > 0:
|
| 406 |
page_status.caption(f" └─ {total} pages found")
|
| 407 |
+
|
| 408 |
try:
|
| 409 |
result = pipeline.process_pdf(
|
| 410 |
+
tmp_path,
|
| 411 |
original_filename=original_filename,
|
| 412 |
progress_callback=progress_cb,
|
| 413 |
)
|
|
|
|
| 418 |
total_skipped += skipped
|
| 419 |
total_pages = int(result.get("total_pages") or 0)
|
| 420 |
sec_per_page = (elapsed_s / total_pages) if total_pages > 0 else None
|
| 421 |
+
file_results.append(
|
| 422 |
+
{
|
| 423 |
+
"file": original_filename,
|
| 424 |
+
"uploaded": uploaded,
|
| 425 |
+
"skipped": skipped,
|
| 426 |
+
"total_pages": total_pages,
|
| 427 |
+
"elapsed_s": float(elapsed_s),
|
| 428 |
+
"sec_per_page": (
|
| 429 |
+
float(sec_per_page) if sec_per_page is not None else None
|
| 430 |
+
),
|
| 431 |
+
}
|
| 432 |
+
)
|
| 433 |
+
timing_str = f"{elapsed_s:.1f}s" + (
|
| 434 |
+
f" ({sec_per_page:.2f}s/page)" if sec_per_page is not None else ""
|
| 435 |
+
)
|
| 436 |
+
log_lines.append(
|
| 437 |
+
f"✓ {original_filename}: {uploaded} uploaded, {skipped} skipped | {timing_str}"
|
| 438 |
+
)
|
| 439 |
except Exception as e:
|
| 440 |
total_failed += 1
|
| 441 |
log_lines.append(f"✗ {original_filename}: {str(e)[:50]}")
|
| 442 |
finally:
|
| 443 |
os.unlink(tmp_path)
|
| 444 |
+
|
| 445 |
page_status.empty()
|
| 446 |
overall_progress.progress((i + 1) / len(uploaded_files))
|
| 447 |
log_area.code("\n".join(log_lines[-10:]), language="text")
|
| 448 |
+
|
| 449 |
overall_progress.progress(1.0)
|
| 450 |
file_status.success(f"✅ Processed {len(uploaded_files)} file(s)")
|
| 451 |
+
|
| 452 |
with results_container:
|
| 453 |
st.markdown("##### 📊 Results")
|
| 454 |
+
|
| 455 |
+
st.success(
|
| 456 |
+
f"✅ **{total_uploaded} pages** uploaded to `{collection_name}`"
|
| 457 |
+
+ (f" ({total_skipped} skipped)" if total_skipped else "")
|
| 458 |
+
+ (f" ({total_failed} failed)" if total_failed else "")
|
| 459 |
+
)
|
| 460 |
+
|
| 461 |
if file_results:
|
| 462 |
with st.expander("📋 File Details", expanded=False):
|
| 463 |
for fr in file_results:
|
|
|
|
| 472 |
+ (f", {fr['total_pages']} pages" if fr.get("total_pages") else "")
|
| 473 |
+ timing
|
| 474 |
)
|
| 475 |
+
|
| 476 |
st.session_state["last_upload_result"] = {
|
| 477 |
+
"total_uploaded": total_uploaded,
|
| 478 |
+
"total_skipped": total_skipped,
|
| 479 |
+
"total_failed": total_failed,
|
| 480 |
+
"file_results": file_results,
|
| 481 |
+
"collection": collection_name,
|
| 482 |
}
|
| 483 |
+
|
| 484 |
get_collection_stats.clear()
|
| 485 |
sample_points_cached.clear()
|
| 486 |
+
|
| 487 |
if total_uploaded > 0:
|
| 488 |
+
st.session_state["upload_success"] = (
|
| 489 |
+
f"Uploaded {total_uploaded} pages to {collection_name}"
|
| 490 |
+
)
|
| 491 |
st.rerun() # Immediately refresh to show success toast + balloons
|
| 492 |
+
|
| 493 |
except Exception as e:
|
| 494 |
st.error(f"❌ Processing error: {e}")
|
| 495 |
with st.expander("Traceback"):
|
|
|
|
| 500 |
result = st.session_state.get("last_upload_result", {})
|
| 501 |
if not result:
|
| 502 |
return
|
| 503 |
+
|
| 504 |
uploaded = result.get("total_uploaded", 0)
|
| 505 |
skipped = result.get("total_skipped", 0)
|
| 506 |
failed = result.get("total_failed", 0)
|
| 507 |
collection = result.get("collection", "")
|
| 508 |
file_results = result.get("file_results", [])
|
| 509 |
+
|
| 510 |
+
st.success(
|
| 511 |
+
f"✅ **{uploaded} pages** uploaded to `{collection}`"
|
| 512 |
+
+ (f" ({skipped} skipped)" if skipped else "")
|
| 513 |
+
+ (f" ({failed} failed)" if failed else "")
|
| 514 |
+
)
|
| 515 |
+
|
| 516 |
if file_results:
|
| 517 |
with st.expander("📋 Details", expanded=False):
|
| 518 |
for fr in file_results:
|
demo_app.py
ADDED
|
@@ -0,0 +1,2068 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import logging
|
| 3 |
+
import os
|
| 4 |
+
import tempfile
|
| 5 |
+
import time
|
| 6 |
+
import traceback
|
| 7 |
+
import warnings
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 11 |
+
|
| 12 |
+
logging.getLogger("streamlit").setLevel(logging.ERROR)
|
| 13 |
+
logging.getLogger("streamlit.runtime.scriptrunner_utils.script_run_context").setLevel(
|
| 14 |
+
logging.CRITICAL
|
| 15 |
+
)
|
| 16 |
+
warnings.filterwarnings("ignore", message=".*ScriptRunContext.*")
|
| 17 |
+
|
| 18 |
+
os.environ.setdefault("STREAMLIT_SERVER_ENABLE_XSRF_PROTECTION", "false")
|
| 19 |
+
os.environ.setdefault("STREAMLIT_SERVER_ENABLE_CORS", "false")
|
| 20 |
+
os.environ.setdefault("STREAMLIT_SERVER_MAX_UPLOAD_SIZE", "500")
|
| 21 |
+
os.environ.setdefault("STREAMLIT_BROWSER_GATHER_USAGE_STATS", "false")
|
| 22 |
+
|
| 23 |
+
import altair as alt # noqa: E402
|
| 24 |
+
import numpy as np # noqa: E402
|
| 25 |
+
import pandas as pd # noqa: E402
|
| 26 |
+
import streamlit as st # noqa: E402
|
| 27 |
+
from dotenv import load_dotenv # noqa: E402
|
| 28 |
+
|
| 29 |
+
try:
|
| 30 |
+
from benchmarks.vidore_tatdqa_test.dataset_loader import load_vidore_beir_dataset
|
| 31 |
+
from benchmarks.vidore_tatdqa_test.metrics import mrr_at_k, ndcg_at_k, recall_at_k
|
| 32 |
+
from visual_rag import VisualEmbedder
|
| 33 |
+
from visual_rag.indexing import QdrantIndexer
|
| 34 |
+
from visual_rag.retrieval import MultiVectorRetriever
|
| 35 |
+
|
| 36 |
+
VISUAL_RAG_AVAILABLE = True
|
| 37 |
+
except ImportError:
|
| 38 |
+
VISUAL_RAG_AVAILABLE = False
|
| 39 |
+
|
| 40 |
+
load_dotenv(Path(__file__).parent / ".env")
|
| 41 |
+
if (Path(__file__).parent.parent / ".env").exists():
|
| 42 |
+
load_dotenv(Path(__file__).parent.parent / ".env")
|
| 43 |
+
|
| 44 |
+
st.set_page_config(
|
| 45 |
+
page_title="Visual RAG Toolkit - Demo",
|
| 46 |
+
page_icon="🔬",
|
| 47 |
+
layout="wide",
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
AVAILABLE_MODELS = [
|
| 51 |
+
"vidore/colpali-v1.3",
|
| 52 |
+
"vidore/colSmol-500M",
|
| 53 |
+
]
|
| 54 |
+
|
| 55 |
+
BENCHMARK_DATASETS = [
|
| 56 |
+
"vidore/esg_reports_v2",
|
| 57 |
+
"vidore/biomedical_lectures_v2",
|
| 58 |
+
"vidore/economics_reports_v2",
|
| 59 |
+
]
|
| 60 |
+
|
| 61 |
+
DATASET_STATS = {
|
| 62 |
+
"vidore/esg_reports_v2": {"docs": 1538, "queries": 228},
|
| 63 |
+
"vidore/biomedical_lectures_v2": {"docs": 1016, "queries": 640},
|
| 64 |
+
"vidore/economics_reports_v2": {"docs": 452, "queries": 232},
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
RETRIEVAL_MODES = [
|
| 68 |
+
"single_full",
|
| 69 |
+
"single_tiles",
|
| 70 |
+
"single_global",
|
| 71 |
+
"two_stage",
|
| 72 |
+
"three_stage",
|
| 73 |
+
]
|
| 74 |
+
|
| 75 |
+
STAGE1_MODES = [
|
| 76 |
+
"tokens_vs_standard_pooling",
|
| 77 |
+
"tokens_vs_experimental_pooling",
|
| 78 |
+
"pooled_query_vs_standard_pooling",
|
| 79 |
+
"pooled_query_vs_experimental_pooling",
|
| 80 |
+
"pooled_query_vs_global",
|
| 81 |
+
]
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def get_qdrant_credentials():
|
| 85 |
+
url = (
|
| 86 |
+
st.session_state.get("qdrant_url_input")
|
| 87 |
+
or os.getenv("SIGIR_QDRANT_URL")
|
| 88 |
+
or os.getenv("DEST_QDRANT_URL")
|
| 89 |
+
or os.getenv("QDRANT_URL")
|
| 90 |
+
)
|
| 91 |
+
api_key = st.session_state.get("qdrant_key_input") or (
|
| 92 |
+
os.getenv("SIGIR_QDRANT_KEY")
|
| 93 |
+
or os.getenv("SIGIR_QDRANT_API_KEY")
|
| 94 |
+
or os.getenv("DEST_QDRANT_API_KEY")
|
| 95 |
+
or os.getenv("QDRANT_API_KEY")
|
| 96 |
+
)
|
| 97 |
+
return url, api_key
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def init_qdrant_client_with_creds(url: str, api_key: str):
|
| 101 |
+
try:
|
| 102 |
+
from qdrant_client import QdrantClient
|
| 103 |
+
|
| 104 |
+
if not url:
|
| 105 |
+
return None, "QDRANT_URL not configured"
|
| 106 |
+
client = QdrantClient(url=url, api_key=api_key, timeout=60)
|
| 107 |
+
client.get_collections()
|
| 108 |
+
return client, None
|
| 109 |
+
except Exception as e:
|
| 110 |
+
return None, str(e)
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
@st.cache_resource(show_spinner="Connecting to Qdrant...")
|
| 114 |
+
def init_qdrant_client():
|
| 115 |
+
url, api_key = get_qdrant_credentials()
|
| 116 |
+
return init_qdrant_client_with_creds(url, api_key)
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
@st.cache_resource(show_spinner="Loading embedding model...")
|
| 120 |
+
def init_embedder(model_name: str):
|
| 121 |
+
try:
|
| 122 |
+
from visual_rag import VisualEmbedder
|
| 123 |
+
|
| 124 |
+
return VisualEmbedder(model_name=model_name), None
|
| 125 |
+
except Exception as e:
|
| 126 |
+
return None, f"{e}\n\n{traceback.format_exc()}"
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
@st.cache_data(ttl=300, show_spinner="Fetching collections...")
|
| 130 |
+
def get_collections(_url: str, _api_key: str) -> List[str]:
|
| 131 |
+
client, err = init_qdrant_client_with_creds(_url, _api_key)
|
| 132 |
+
if client is None:
|
| 133 |
+
return []
|
| 134 |
+
try:
|
| 135 |
+
collections = client.get_collections().collections
|
| 136 |
+
return sorted([c.name for c in collections])
|
| 137 |
+
except Exception:
|
| 138 |
+
return []
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
@st.cache_data(ttl=120, show_spinner="Loading collection stats...")
|
| 142 |
+
def get_collection_stats(collection_name: str) -> Dict[str, Any]:
|
| 143 |
+
url, api_key = get_qdrant_credentials()
|
| 144 |
+
client, err = init_qdrant_client_with_creds(url, api_key)
|
| 145 |
+
if client is None:
|
| 146 |
+
return {"error": err}
|
| 147 |
+
try:
|
| 148 |
+
info = client.get_collection(collection_name)
|
| 149 |
+
vectors_config = getattr(
|
| 150 |
+
getattr(getattr(info, "config", None), "params", None), "vectors", None
|
| 151 |
+
)
|
| 152 |
+
vector_info = {}
|
| 153 |
+
if vectors_config is not None:
|
| 154 |
+
if hasattr(vectors_config, "items"):
|
| 155 |
+
for name, cfg in vectors_config.items():
|
| 156 |
+
size = getattr(cfg, "size", None)
|
| 157 |
+
multivec = getattr(cfg, "multivector_config", None)
|
| 158 |
+
on_disk = getattr(cfg, "on_disk", None)
|
| 159 |
+
datatype = str(getattr(cfg, "datatype", "Float32")).replace("Datatype.", "")
|
| 160 |
+
quantization = getattr(cfg, "quantization_config", None)
|
| 161 |
+
num_vectors = 1
|
| 162 |
+
if multivec is not None:
|
| 163 |
+
comparator = getattr(multivec, "comparator", None)
|
| 164 |
+
num_vectors = "N" if comparator else 1
|
| 165 |
+
vector_info[name] = {
|
| 166 |
+
"size": size,
|
| 167 |
+
"num_vectors": num_vectors,
|
| 168 |
+
"is_multivector": multivec is not None,
|
| 169 |
+
"on_disk": on_disk,
|
| 170 |
+
"datatype": datatype,
|
| 171 |
+
"quantization": quantization is not None,
|
| 172 |
+
}
|
| 173 |
+
elif hasattr(vectors_config, "size"):
|
| 174 |
+
on_disk = getattr(vectors_config, "on_disk", None)
|
| 175 |
+
datatype = str(getattr(vectors_config, "datatype", "Float32")).replace(
|
| 176 |
+
"Datatype.", ""
|
| 177 |
+
)
|
| 178 |
+
multivec = getattr(vectors_config, "multivector_config", None)
|
| 179 |
+
vector_info["default"] = {
|
| 180 |
+
"size": getattr(vectors_config, "size", None),
|
| 181 |
+
"num_vectors": "N" if multivec else 1,
|
| 182 |
+
"is_multivector": multivec is not None,
|
| 183 |
+
"on_disk": on_disk,
|
| 184 |
+
"datatype": datatype,
|
| 185 |
+
}
|
| 186 |
+
return {
|
| 187 |
+
"points_count": getattr(info, "points_count", 0),
|
| 188 |
+
"vectors_count": getattr(info, "vectors_count", getattr(info, "points_count", 0)),
|
| 189 |
+
"status": str(getattr(info, "status", "unknown")),
|
| 190 |
+
"vector_info": vector_info,
|
| 191 |
+
"indexed_vectors_count": getattr(info, "indexed_vectors_count", None),
|
| 192 |
+
}
|
| 193 |
+
except Exception as e:
|
| 194 |
+
return {"error": f"{e}\n\n{traceback.format_exc()}"}
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
@st.cache_data(ttl=60)
|
| 198 |
+
def sample_points_cached(
|
| 199 |
+
collection_name: str, n: int, seed: int, _url: str, _api_key: str
|
| 200 |
+
) -> List[Dict[str, Any]]:
|
| 201 |
+
client, err = init_qdrant_client_with_creds(_url, _api_key)
|
| 202 |
+
if client is None:
|
| 203 |
+
return []
|
| 204 |
+
try:
|
| 205 |
+
import random
|
| 206 |
+
|
| 207 |
+
rng = random.Random(seed)
|
| 208 |
+
points, _ = client.scroll(
|
| 209 |
+
collection_name=collection_name,
|
| 210 |
+
limit=min(n * 10, 100),
|
| 211 |
+
with_payload=True,
|
| 212 |
+
with_vectors=False,
|
| 213 |
+
)
|
| 214 |
+
if not points:
|
| 215 |
+
return []
|
| 216 |
+
sampled = rng.sample(points, min(n, len(points)))
|
| 217 |
+
results = []
|
| 218 |
+
for p in sampled:
|
| 219 |
+
payload = dict(p.payload) if p.payload else {}
|
| 220 |
+
results.append(
|
| 221 |
+
{
|
| 222 |
+
"id": str(p.id),
|
| 223 |
+
"payload": payload,
|
| 224 |
+
}
|
| 225 |
+
)
|
| 226 |
+
return results
|
| 227 |
+
except Exception:
|
| 228 |
+
return []
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
@st.cache_data(ttl=300)
|
| 232 |
+
def get_vector_sizes(collection_name: str, _url: str, _api_key: str) -> Dict[str, int]:
|
| 233 |
+
client, err = init_qdrant_client_with_creds(_url, _api_key)
|
| 234 |
+
if client is None:
|
| 235 |
+
return {}
|
| 236 |
+
try:
|
| 237 |
+
points, _ = client.scroll(
|
| 238 |
+
collection_name=collection_name,
|
| 239 |
+
limit=1,
|
| 240 |
+
with_payload=False,
|
| 241 |
+
with_vectors=True,
|
| 242 |
+
)
|
| 243 |
+
if not points:
|
| 244 |
+
return {}
|
| 245 |
+
vectors = points[0].vector
|
| 246 |
+
sizes = {}
|
| 247 |
+
if isinstance(vectors, dict):
|
| 248 |
+
for name, vec in vectors.items():
|
| 249 |
+
if isinstance(vec, list):
|
| 250 |
+
if vec and isinstance(vec[0], list):
|
| 251 |
+
sizes[name] = len(vec)
|
| 252 |
+
else:
|
| 253 |
+
sizes[name] = 1
|
| 254 |
+
else:
|
| 255 |
+
sizes[name] = 1
|
| 256 |
+
return sizes
|
| 257 |
+
except Exception:
|
| 258 |
+
return {}
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
def search_collection(
|
| 262 |
+
collection_name: str,
|
| 263 |
+
query: str,
|
| 264 |
+
top_k: int = 10,
|
| 265 |
+
mode: str = "single_full",
|
| 266 |
+
prefetch_k: int = 256,
|
| 267 |
+
stage1_mode: str = "tokens_vs_standard_pooling",
|
| 268 |
+
stage1_k: int = 1000,
|
| 269 |
+
stage2_k: int = 300,
|
| 270 |
+
model_name: str = "vidore/colSmol-500M",
|
| 271 |
+
) -> Tuple[List[Dict[str, Any]], Optional[str]]:
|
| 272 |
+
try:
|
| 273 |
+
from visual_rag.retrieval import MultiVectorRetriever
|
| 274 |
+
|
| 275 |
+
retriever = MultiVectorRetriever(
|
| 276 |
+
collection_name=collection_name,
|
| 277 |
+
model_name=model_name,
|
| 278 |
+
)
|
| 279 |
+
if mode == "three_stage":
|
| 280 |
+
q_emb = retriever.embedder.embed_query(query)
|
| 281 |
+
if hasattr(q_emb, "cpu"):
|
| 282 |
+
q_emb = q_emb.cpu().numpy()
|
| 283 |
+
results = retriever.search_embedded(
|
| 284 |
+
query_embedding=q_emb,
|
| 285 |
+
top_k=top_k,
|
| 286 |
+
mode=mode,
|
| 287 |
+
stage1_k=stage1_k,
|
| 288 |
+
stage2_k=stage2_k,
|
| 289 |
+
)
|
| 290 |
+
else:
|
| 291 |
+
results = retriever.search(
|
| 292 |
+
query=query,
|
| 293 |
+
top_k=top_k,
|
| 294 |
+
mode=mode,
|
| 295 |
+
prefetch_k=prefetch_k,
|
| 296 |
+
stage1_mode=stage1_mode,
|
| 297 |
+
)
|
| 298 |
+
return results, None
|
| 299 |
+
except Exception as e:
|
| 300 |
+
return [], f"{e}\n\n{traceback.format_exc()}"
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
def load_results_file(path: Path) -> Optional[Dict[str, Any]]:
|
| 304 |
+
try:
|
| 305 |
+
with open(path, "r") as f:
|
| 306 |
+
return json.load(f)
|
| 307 |
+
except Exception:
|
| 308 |
+
return None
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
def get_available_results() -> List[Path]:
|
| 312 |
+
results_dir = Path(__file__).parent / "results"
|
| 313 |
+
if not results_dir.exists():
|
| 314 |
+
return []
|
| 315 |
+
results = []
|
| 316 |
+
for subdir in results_dir.iterdir():
|
| 317 |
+
if subdir.is_dir():
|
| 318 |
+
for f in subdir.glob("*.json"):
|
| 319 |
+
if "index_failures" not in f.name:
|
| 320 |
+
results.append(f)
|
| 321 |
+
return sorted(results, key=lambda x: x.stat().st_mtime, reverse=True)
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
def find_main_result_file(collection: str, mode: str) -> Optional[Path]:
|
| 325 |
+
results = get_available_results()
|
| 326 |
+
for r in results:
|
| 327 |
+
if collection in str(r) and mode in r.name:
|
| 328 |
+
if "__vidore_" not in r.name:
|
| 329 |
+
return r
|
| 330 |
+
return results[0] if results else None
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
def build_index_command(config: Dict[str, Any]) -> str:
|
| 334 |
+
cmd_parts = ["python -m benchmarks.vidore_beir_qdrant.run_qdrant_beir"]
|
| 335 |
+
cmd_parts.append(f"--datasets {' '.join(config['datasets'])}")
|
| 336 |
+
cmd_parts.append(f"--collection {config['collection']}")
|
| 337 |
+
cmd_parts.append(f"--model {config['model']}")
|
| 338 |
+
cmd_parts.append("--index")
|
| 339 |
+
if config.get("recreate"):
|
| 340 |
+
cmd_parts.append("--recreate")
|
| 341 |
+
if config.get("resume"):
|
| 342 |
+
cmd_parts.append("--resume")
|
| 343 |
+
if config.get("prefer_grpc"):
|
| 344 |
+
cmd_parts.append("--prefer-grpc")
|
| 345 |
+
else:
|
| 346 |
+
cmd_parts.append("--no-prefer-grpc")
|
| 347 |
+
cmd_parts.append(f"--torch-dtype {config.get('torch_dtype', 'float16')}")
|
| 348 |
+
cmd_parts.append(f"--qdrant-vector-dtype {config.get('qdrant_vector_dtype', 'float16')}")
|
| 349 |
+
cmd_parts.append(f"--batch-size {config.get('batch_size', 4)}")
|
| 350 |
+
cmd_parts.append(f"--upload-batch-size {config.get('upload_batch_size', 8)}")
|
| 351 |
+
cmd_parts.append(f"--qdrant-timeout {config.get('qdrant_timeout', 180)}")
|
| 352 |
+
cmd_parts.append(f"--qdrant-retries {config.get('qdrant_retries', 5)}")
|
| 353 |
+
if config.get("crop_empty"):
|
| 354 |
+
cmd_parts.append("--crop-empty")
|
| 355 |
+
cmd_parts.append(f"--crop-empty-percentage-to-remove {config.get('crop_percentage', 0.99)}")
|
| 356 |
+
if config.get("no_cloudinary"):
|
| 357 |
+
cmd_parts.append("--no-cloudinary")
|
| 358 |
+
cmd_parts.append("--no-eval")
|
| 359 |
+
return " \\\n ".join(cmd_parts)
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
def build_eval_command(config: Dict[str, Any]) -> str:
|
| 363 |
+
cmd_parts = ["python -m benchmarks.vidore_beir_qdrant.run_qdrant_beir"]
|
| 364 |
+
cmd_parts.append(f"--datasets {' '.join(config['datasets'])}")
|
| 365 |
+
cmd_parts.append(f"--collection {config['collection']}")
|
| 366 |
+
cmd_parts.append(f"--model {config['model']}")
|
| 367 |
+
cmd_parts.append(f"--mode {config['mode']}")
|
| 368 |
+
if config["mode"] == "two_stage":
|
| 369 |
+
cmd_parts.append(f"--stage1-mode {config.get('stage1_mode', 'tokens_vs_standard_pooling')}")
|
| 370 |
+
cmd_parts.append(f"--prefetch-k {config.get('prefetch_k', 256)}")
|
| 371 |
+
elif config["mode"] == "three_stage":
|
| 372 |
+
cmd_parts.append(f"--stage1-k {config.get('stage1_k', 1000)}")
|
| 373 |
+
cmd_parts.append(f"--stage2-k {config.get('stage2_k', 300)}")
|
| 374 |
+
cmd_parts.append(f"--top-k {config.get('top_k', 100)}")
|
| 375 |
+
cmd_parts.append(f"--evaluation-scope {config.get('evaluation_scope', 'union')}")
|
| 376 |
+
if config.get("prefer_grpc"):
|
| 377 |
+
cmd_parts.append("--prefer-grpc")
|
| 378 |
+
else:
|
| 379 |
+
cmd_parts.append("--no-prefer-grpc")
|
| 380 |
+
cmd_parts.append(f"--torch-dtype {config.get('torch_dtype', 'float16')}")
|
| 381 |
+
cmd_parts.append(f"--qdrant-vector-dtype {config.get('qdrant_vector_dtype', 'float16')}")
|
| 382 |
+
cmd_parts.append(f"--qdrant-timeout {config.get('qdrant_timeout', 180)}")
|
| 383 |
+
if config.get("result_prefix"):
|
| 384 |
+
cmd_parts.append(f"--output {config['result_prefix']}")
|
| 385 |
+
return " \\\n ".join(cmd_parts)
|
| 386 |
+
|
| 387 |
+
|
| 388 |
+
def generate_python_eval_code(config: Dict[str, Any]) -> str:
|
| 389 |
+
datasets_str = ", ".join([f'"{ds}"' for ds in config.get("datasets", [])])
|
| 390 |
+
mode = config.get("mode", "single_full")
|
| 391 |
+
model = config.get("model", "vidore/colpali-v1.3")
|
| 392 |
+
collection = config.get("collection", "")
|
| 393 |
+
top_k = config.get("top_k", 100)
|
| 394 |
+
scope = config.get("evaluation_scope", "union")
|
| 395 |
+
prefer_grpc = config.get("prefer_grpc", True)
|
| 396 |
+
|
| 397 |
+
code_lines = [
|
| 398 |
+
"import os",
|
| 399 |
+
"from qdrant_client import QdrantClient",
|
| 400 |
+
"from visual_rag import VisualEmbedder",
|
| 401 |
+
"from visual_rag.retrieval import MultiVectorRetriever",
|
| 402 |
+
"",
|
| 403 |
+
"# Configuration",
|
| 404 |
+
f'COLLECTION = "{collection}"',
|
| 405 |
+
f'MODEL = "{model}"',
|
| 406 |
+
f"TOP_K = {top_k}",
|
| 407 |
+
f"DATASETS = [{datasets_str}]",
|
| 408 |
+
"",
|
| 409 |
+
"# Initialize clients",
|
| 410 |
+
"client = QdrantClient(",
|
| 411 |
+
' url=os.getenv("QDRANT_URL"),',
|
| 412 |
+
' api_key=os.getenv("QDRANT_API_KEY"),',
|
| 413 |
+
f" prefer_grpc={prefer_grpc},",
|
| 414 |
+
")",
|
| 415 |
+
"",
|
| 416 |
+
"embedder = VisualEmbedder(",
|
| 417 |
+
" model_name=MODEL,",
|
| 418 |
+
f' torch_dtype="{config.get("torch_dtype", "float16")}",',
|
| 419 |
+
")",
|
| 420 |
+
"",
|
| 421 |
+
"# Initialize retriever",
|
| 422 |
+
"retriever = MultiVectorRetriever(",
|
| 423 |
+
" client=client,",
|
| 424 |
+
" collection_name=COLLECTION,",
|
| 425 |
+
" embedder=embedder,",
|
| 426 |
+
")",
|
| 427 |
+
"",
|
| 428 |
+
]
|
| 429 |
+
|
| 430 |
+
if mode == "single_full":
|
| 431 |
+
code_lines.extend(
|
| 432 |
+
[
|
| 433 |
+
"# Single-stage full retrieval",
|
| 434 |
+
"def search(query: str):",
|
| 435 |
+
" query_embedding = embedder.embed_query(query)",
|
| 436 |
+
" return retriever.search_single_stage(",
|
| 437 |
+
" query_embedding=query_embedding,",
|
| 438 |
+
f" limit={top_k},",
|
| 439 |
+
' vector_name="initial",',
|
| 440 |
+
" )",
|
| 441 |
+
]
|
| 442 |
+
)
|
| 443 |
+
elif mode == "single_tiles":
|
| 444 |
+
code_lines.extend(
|
| 445 |
+
[
|
| 446 |
+
"# Single-stage tiles retrieval",
|
| 447 |
+
"def search(query: str):",
|
| 448 |
+
" query_embedding = embedder.embed_query(query)",
|
| 449 |
+
" return retriever.search_single_stage(",
|
| 450 |
+
" query_embedding=query_embedding,",
|
| 451 |
+
f" limit={top_k},",
|
| 452 |
+
' vector_name="mean_pooling",',
|
| 453 |
+
" )",
|
| 454 |
+
]
|
| 455 |
+
)
|
| 456 |
+
elif mode == "single_global":
|
| 457 |
+
code_lines.extend(
|
| 458 |
+
[
|
| 459 |
+
"# Single-stage global retrieval",
|
| 460 |
+
"def search(query: str):",
|
| 461 |
+
" query_embedding = embedder.embed_query(query)",
|
| 462 |
+
" return retriever.search_single_stage(",
|
| 463 |
+
" query_embedding=query_embedding,",
|
| 464 |
+
f" limit={top_k},",
|
| 465 |
+
' vector_name="global_pooling",',
|
| 466 |
+
" )",
|
| 467 |
+
]
|
| 468 |
+
)
|
| 469 |
+
elif mode == "two_stage":
|
| 470 |
+
prefetch_k = config.get("prefetch_k", 256)
|
| 471 |
+
stage1_mode = config.get("stage1_mode", "tokens_vs_standard_pooling")
|
| 472 |
+
code_lines.extend(
|
| 473 |
+
[
|
| 474 |
+
"# Two-stage retrieval",
|
| 475 |
+
"from visual_rag.retrieval import TwoStageRetriever",
|
| 476 |
+
"",
|
| 477 |
+
"two_stage = TwoStageRetriever(",
|
| 478 |
+
" client=client,",
|
| 479 |
+
" collection_name=COLLECTION,",
|
| 480 |
+
" embedder=embedder,",
|
| 481 |
+
")",
|
| 482 |
+
"",
|
| 483 |
+
"def search(query: str):",
|
| 484 |
+
" query_embedding = embedder.embed_query(query)",
|
| 485 |
+
" return two_stage.search(",
|
| 486 |
+
" query_embedding=query_embedding,",
|
| 487 |
+
f" prefetch_limit={prefetch_k},",
|
| 488 |
+
f" limit={top_k},",
|
| 489 |
+
f' stage1_mode="{stage1_mode}",',
|
| 490 |
+
" )",
|
| 491 |
+
]
|
| 492 |
+
)
|
| 493 |
+
elif mode == "three_stage":
|
| 494 |
+
stage1_k = config.get("stage1_k", 1000)
|
| 495 |
+
stage2_k = config.get("stage2_k", 300)
|
| 496 |
+
code_lines.extend(
|
| 497 |
+
[
|
| 498 |
+
"# Three-stage retrieval",
|
| 499 |
+
"from visual_rag.retrieval import ThreeStageRetriever",
|
| 500 |
+
"",
|
| 501 |
+
"three_stage = ThreeStageRetriever(",
|
| 502 |
+
" client=client,",
|
| 503 |
+
" collection_name=COLLECTION,",
|
| 504 |
+
" embedder=embedder,",
|
| 505 |
+
")",
|
| 506 |
+
"",
|
| 507 |
+
"def search(query: str):",
|
| 508 |
+
" query_embedding = embedder.embed_query(query)",
|
| 509 |
+
" return three_stage.search(",
|
| 510 |
+
" query_embedding=query_embedding,",
|
| 511 |
+
f" stage1_limit={stage1_k},",
|
| 512 |
+
f" stage2_limit={stage2_k},",
|
| 513 |
+
f" limit={top_k},",
|
| 514 |
+
" )",
|
| 515 |
+
]
|
| 516 |
+
)
|
| 517 |
+
|
| 518 |
+
if scope == "per_dataset":
|
| 519 |
+
code_lines.extend(
|
| 520 |
+
[
|
| 521 |
+
"",
|
| 522 |
+
"# Per-dataset filtering",
|
| 523 |
+
"from qdrant_client.models import Filter, FieldCondition, MatchValue",
|
| 524 |
+
"",
|
| 525 |
+
'def search_dataset(query: str, dataset: str = "vidore/esg_reports_v2"):',
|
| 526 |
+
" query_embedding = embedder.embed_query(query)",
|
| 527 |
+
" dataset_filter = Filter(",
|
| 528 |
+
" must=[FieldCondition(",
|
| 529 |
+
' key="dataset",',
|
| 530 |
+
" match=MatchValue(value=dataset),",
|
| 531 |
+
" )]",
|
| 532 |
+
" )",
|
| 533 |
+
" # Add filter to your search call",
|
| 534 |
+
]
|
| 535 |
+
)
|
| 536 |
+
|
| 537 |
+
code_lines.extend(
|
| 538 |
+
[
|
| 539 |
+
"",
|
| 540 |
+
"# Example usage",
|
| 541 |
+
'results = search("What is the company revenue?")',
|
| 542 |
+
"for r in results:",
|
| 543 |
+
" print(f\"Score: {r.score:.4f}, Doc: {r.payload.get('doc_id')}\")",
|
| 544 |
+
]
|
| 545 |
+
)
|
| 546 |
+
|
| 547 |
+
return "\n".join(code_lines)
|
| 548 |
+
|
| 549 |
+
|
| 550 |
+
def run_pythonic_evaluation(config: Dict[str, Any], progress_callback=None) -> Dict[str, Any]:
|
| 551 |
+
if not VISUAL_RAG_AVAILABLE:
|
| 552 |
+
raise ImportError("visual_rag package not available")
|
| 553 |
+
|
| 554 |
+
url, api_key = get_qdrant_credentials()
|
| 555 |
+
if not url:
|
| 556 |
+
raise ValueError("QDRANT_URL not configured")
|
| 557 |
+
|
| 558 |
+
datasets = config.get("datasets", [])
|
| 559 |
+
collection = config["collection"]
|
| 560 |
+
model = config.get("model", "vidore/colpali-v1.3")
|
| 561 |
+
mode = config.get("mode", "single_full")
|
| 562 |
+
top_k = config.get("top_k", 100)
|
| 563 |
+
prefetch_k = config.get("prefetch_k", 256)
|
| 564 |
+
stage1_mode = config.get("stage1_mode", "tokens_vs_standard_pooling")
|
| 565 |
+
stage1_k = config.get("stage1_k", 1000)
|
| 566 |
+
stage2_k = config.get("stage2_k", 300)
|
| 567 |
+
evaluation_scope = config.get("evaluation_scope", "union")
|
| 568 |
+
prefer_grpc = config.get("prefer_grpc", True)
|
| 569 |
+
torch_dtype = config.get("torch_dtype", "float16")
|
| 570 |
+
|
| 571 |
+
output_lines = []
|
| 572 |
+
|
| 573 |
+
def log(msg):
|
| 574 |
+
output_lines.append(msg)
|
| 575 |
+
if progress_callback:
|
| 576 |
+
progress_callback("\n".join(output_lines), None)
|
| 577 |
+
|
| 578 |
+
log(f"[Pythonic Eval] Initializing embedder: {model}")
|
| 579 |
+
embedder = VisualEmbedder(model_name=model, torch_dtype=torch_dtype)
|
| 580 |
+
|
| 581 |
+
log(f"[Pythonic Eval] Connecting to Qdrant collection: {collection}")
|
| 582 |
+
retriever = MultiVectorRetriever(
|
| 583 |
+
collection_name=collection,
|
| 584 |
+
model_name=model,
|
| 585 |
+
qdrant_url=url,
|
| 586 |
+
qdrant_api_key=api_key,
|
| 587 |
+
prefer_grpc=prefer_grpc,
|
| 588 |
+
embedder=embedder,
|
| 589 |
+
)
|
| 590 |
+
|
| 591 |
+
all_queries = []
|
| 592 |
+
all_qrels: Dict[str, Dict[str, int]] = {}
|
| 593 |
+
dataset_queries: Dict[str, List] = {}
|
| 594 |
+
dataset_qrels: Dict[str, Dict[str, Dict[str, int]]] = {}
|
| 595 |
+
|
| 596 |
+
for ds_name in datasets:
|
| 597 |
+
log(f"[Pythonic Eval] Loading dataset: {ds_name}")
|
| 598 |
+
corpus, queries, qrels = load_vidore_beir_dataset(ds_name)
|
| 599 |
+
dataset_queries[ds_name] = queries
|
| 600 |
+
dataset_qrels[ds_name] = qrels
|
| 601 |
+
all_queries.extend(queries)
|
| 602 |
+
for qid, rels in qrels.items():
|
| 603 |
+
all_qrels[qid] = rels
|
| 604 |
+
log(f" → {len(corpus)} docs, {len(queries)} queries")
|
| 605 |
+
|
| 606 |
+
def evaluate_queries(queries, qrels, filter_obj=None):
|
| 607 |
+
if not queries:
|
| 608 |
+
return {"ndcg@10": 0.0, "recall@10": 0.0, "mrr@10": 0.0, "num_queries": 0}
|
| 609 |
+
|
| 610 |
+
ndcg10_vals = []
|
| 611 |
+
recall10_vals = []
|
| 612 |
+
mrr10_vals = []
|
| 613 |
+
latencies = []
|
| 614 |
+
|
| 615 |
+
query_texts = [q.text for q in queries]
|
| 616 |
+
log(f"[Pythonic Eval] Embedding {len(query_texts)} queries...")
|
| 617 |
+
query_embeddings = embedder.embed_queries(query_texts, show_progress=False)
|
| 618 |
+
|
| 619 |
+
for i, (q, qemb) in enumerate(zip(queries, query_embeddings)):
|
| 620 |
+
start = time.time()
|
| 621 |
+
|
| 622 |
+
try:
|
| 623 |
+
import torch
|
| 624 |
+
|
| 625 |
+
if isinstance(qemb, torch.Tensor):
|
| 626 |
+
qemb_np = qemb.detach().cpu().numpy()
|
| 627 |
+
else:
|
| 628 |
+
qemb_np = qemb.numpy()
|
| 629 |
+
except ImportError:
|
| 630 |
+
qemb_np = qemb.numpy()
|
| 631 |
+
|
| 632 |
+
results = retriever.search_embedded(
|
| 633 |
+
query_embedding=qemb_np,
|
| 634 |
+
top_k=max(100, top_k),
|
| 635 |
+
mode=mode,
|
| 636 |
+
prefetch_k=prefetch_k,
|
| 637 |
+
stage1_mode=stage1_mode,
|
| 638 |
+
stage1_k=stage1_k,
|
| 639 |
+
stage2_k=stage2_k,
|
| 640 |
+
filter_obj=filter_obj,
|
| 641 |
+
)
|
| 642 |
+
latencies.append((time.time() - start) * 1000)
|
| 643 |
+
|
| 644 |
+
ranking = [str(r["id"]) for r in results]
|
| 645 |
+
rels = qrels.get(q.query_id, {})
|
| 646 |
+
|
| 647 |
+
ndcg10_vals.append(ndcg_at_k(ranking, rels, k=10))
|
| 648 |
+
recall10_vals.append(recall_at_k(ranking, rels, k=10))
|
| 649 |
+
mrr10_vals.append(mrr_at_k(ranking, rels, k=10))
|
| 650 |
+
|
| 651 |
+
if (i + 1) % 50 == 0:
|
| 652 |
+
log(f" → Processed {i+1}/{len(queries)} queries")
|
| 653 |
+
if progress_callback:
|
| 654 |
+
progress_callback("\n".join(output_lines), (i + 1) / len(queries))
|
| 655 |
+
|
| 656 |
+
return {
|
| 657 |
+
"ndcg@10": float(np.mean(ndcg10_vals)),
|
| 658 |
+
"recall@10": float(np.mean(recall10_vals)),
|
| 659 |
+
"mrr@10": float(np.mean(mrr10_vals)),
|
| 660 |
+
"avg_latency_ms": float(np.mean(latencies)),
|
| 661 |
+
"num_queries": len(queries),
|
| 662 |
+
}
|
| 663 |
+
|
| 664 |
+
results = {}
|
| 665 |
+
|
| 666 |
+
if evaluation_scope == "union":
|
| 667 |
+
log(f"\n[Pythonic Eval] Evaluating UNION ({len(all_queries)} queries)...")
|
| 668 |
+
union_metrics = evaluate_queries(all_queries, all_qrels)
|
| 669 |
+
results["union"] = union_metrics
|
| 670 |
+
log(f" → NDCG@10: {union_metrics['ndcg@10']:.4f}")
|
| 671 |
+
log(f" → Recall@10: {union_metrics['recall@10']:.4f}")
|
| 672 |
+
log(f" → MRR@10: {union_metrics['mrr@10']:.4f}")
|
| 673 |
+
else:
|
| 674 |
+
for ds_name in datasets:
|
| 675 |
+
log(f"\n[Pythonic Eval] Evaluating {ds_name}...")
|
| 676 |
+
queries = dataset_queries[ds_name]
|
| 677 |
+
qrels = dataset_qrels[ds_name]
|
| 678 |
+
metrics = evaluate_queries(queries, qrels)
|
| 679 |
+
results[ds_name] = metrics
|
| 680 |
+
log(f" → NDCG@10: {metrics['ndcg@10']:.4f}")
|
| 681 |
+
log(f" → Recall@10: {metrics['recall@10']:.4f}")
|
| 682 |
+
|
| 683 |
+
log("\n" + "=" * 50)
|
| 684 |
+
log("[Pythonic Eval] COMPLETE!")
|
| 685 |
+
|
| 686 |
+
final_output = {
|
| 687 |
+
"config": {
|
| 688 |
+
"collection": collection,
|
| 689 |
+
"model": model,
|
| 690 |
+
"mode": mode,
|
| 691 |
+
"datasets": datasets,
|
| 692 |
+
"evaluation_scope": evaluation_scope,
|
| 693 |
+
},
|
| 694 |
+
"results": results,
|
| 695 |
+
}
|
| 696 |
+
|
| 697 |
+
return {"output": "\n".join(output_lines), "metrics": final_output}
|
| 698 |
+
|
| 699 |
+
|
| 700 |
+
def run_pythonic_indexing(config: Dict[str, Any], progress_callback=None) -> Dict[str, Any]:
|
| 701 |
+
if not VISUAL_RAG_AVAILABLE:
|
| 702 |
+
raise ImportError("visual_rag package not available")
|
| 703 |
+
|
| 704 |
+
url, api_key = get_qdrant_credentials()
|
| 705 |
+
if not url:
|
| 706 |
+
raise ValueError("QDRANT_URL not configured")
|
| 707 |
+
|
| 708 |
+
datasets = config.get("datasets", [])
|
| 709 |
+
collection = config["collection"]
|
| 710 |
+
model = config.get("model", "vidore/colpali-v1.3")
|
| 711 |
+
recreate = config.get("recreate", False)
|
| 712 |
+
batch_size = config.get("batch_size", 4)
|
| 713 |
+
torch_dtype = config.get("torch_dtype", "float16")
|
| 714 |
+
qdrant_vector_dtype = config.get("qdrant_vector_dtype", "float16")
|
| 715 |
+
prefer_grpc = config.get("prefer_grpc", True)
|
| 716 |
+
|
| 717 |
+
output_lines = []
|
| 718 |
+
|
| 719 |
+
def log(msg):
|
| 720 |
+
output_lines.append(msg)
|
| 721 |
+
if progress_callback:
|
| 722 |
+
progress_callback("\n".join(output_lines), None)
|
| 723 |
+
|
| 724 |
+
log(f"[Pythonic Index] Initializing embedder: {model}")
|
| 725 |
+
embedder = VisualEmbedder(model_name=model, torch_dtype=torch_dtype)
|
| 726 |
+
|
| 727 |
+
log("[Pythonic Index] Connecting to Qdrant...")
|
| 728 |
+
indexer = QdrantIndexer(
|
| 729 |
+
url=url,
|
| 730 |
+
api_key=api_key,
|
| 731 |
+
collection_name=collection,
|
| 732 |
+
prefer_grpc=prefer_grpc,
|
| 733 |
+
vector_datatype=qdrant_vector_dtype,
|
| 734 |
+
)
|
| 735 |
+
|
| 736 |
+
log(f"[Pythonic Index] Creating collection: {collection}")
|
| 737 |
+
indexer.create_collection(force_recreate=recreate)
|
| 738 |
+
|
| 739 |
+
payload_fields = [
|
| 740 |
+
{"field": "dataset", "type": "keyword"},
|
| 741 |
+
{"field": "doc_id", "type": "keyword"},
|
| 742 |
+
{"field": "source_doc_id", "type": "keyword"},
|
| 743 |
+
]
|
| 744 |
+
indexer.create_payload_indexes(fields=payload_fields)
|
| 745 |
+
|
| 746 |
+
total_uploaded = 0
|
| 747 |
+
|
| 748 |
+
for ds_name in datasets:
|
| 749 |
+
log(f"\n[Pythonic Index] Loading dataset: {ds_name}")
|
| 750 |
+
corpus, queries, qrels = load_vidore_beir_dataset(ds_name)
|
| 751 |
+
log(f" → {len(corpus)} documents to index")
|
| 752 |
+
|
| 753 |
+
for i in range(0, len(corpus), batch_size):
|
| 754 |
+
batch = corpus[i : i + batch_size]
|
| 755 |
+
|
| 756 |
+
images = []
|
| 757 |
+
for doc in batch:
|
| 758 |
+
img = doc.image if hasattr(doc, "image") else doc.get("image")
|
| 759 |
+
if img is not None:
|
| 760 |
+
images.append(img)
|
| 761 |
+
|
| 762 |
+
if not images:
|
| 763 |
+
continue
|
| 764 |
+
|
| 765 |
+
log(
|
| 766 |
+
f" → Embedding batch {i//batch_size + 1}/{(len(corpus) + batch_size - 1)//batch_size}..."
|
| 767 |
+
)
|
| 768 |
+
embeddings = embedder.embed_images(images)
|
| 769 |
+
|
| 770 |
+
points = []
|
| 771 |
+
for j, (doc, emb) in enumerate(zip(batch, embeddings)):
|
| 772 |
+
doc_id = doc.doc_id if hasattr(doc, "doc_id") else doc.get("doc_id", str(i + j))
|
| 773 |
+
|
| 774 |
+
if hasattr(emb, "cpu"):
|
| 775 |
+
emb_np = emb.cpu().numpy()
|
| 776 |
+
else:
|
| 777 |
+
emb_np = np.array(emb)
|
| 778 |
+
|
| 779 |
+
tile_pooled = emb_np.reshape(-1, 4, emb_np.shape[-1]).mean(axis=1)
|
| 780 |
+
global_pooled = emb_np.mean(axis=0)
|
| 781 |
+
|
| 782 |
+
points.append(
|
| 783 |
+
{
|
| 784 |
+
"id": f"{ds_name}_{doc_id}".replace("/", "_"),
|
| 785 |
+
"visual_embedding": emb_np,
|
| 786 |
+
"tile_pooled_embedding": tile_pooled,
|
| 787 |
+
"experimental_pooled_embedding": tile_pooled,
|
| 788 |
+
"global_pooled_embedding": global_pooled,
|
| 789 |
+
"metadata": {
|
| 790 |
+
"dataset": ds_name,
|
| 791 |
+
"doc_id": doc_id,
|
| 792 |
+
"source_doc_id": doc_id,
|
| 793 |
+
},
|
| 794 |
+
}
|
| 795 |
+
)
|
| 796 |
+
|
| 797 |
+
uploaded = indexer.upload_batch(points)
|
| 798 |
+
total_uploaded += uploaded
|
| 799 |
+
|
| 800 |
+
if progress_callback:
|
| 801 |
+
prog = (i + len(batch)) / len(corpus)
|
| 802 |
+
progress_callback("\n".join(output_lines), prog)
|
| 803 |
+
|
| 804 |
+
log(f" → Finished {ds_name}: {total_uploaded} points uploaded")
|
| 805 |
+
|
| 806 |
+
log("\n" + "=" * 50)
|
| 807 |
+
log(f"[Pythonic Index] COMPLETE! Total: {total_uploaded} points")
|
| 808 |
+
|
| 809 |
+
return {"output": "\n".join(output_lines), "total_uploaded": total_uploaded}
|
| 810 |
+
|
| 811 |
+
|
| 812 |
+
def render_header():
|
| 813 |
+
st.markdown(
|
| 814 |
+
"""
|
| 815 |
+
<div style="text-align: center; padding: 10px 0 15px 0;">
|
| 816 |
+
<h1 style="
|
| 817 |
+
font-family: 'Georgia', serif;
|
| 818 |
+
font-size: 2.5rem;
|
| 819 |
+
font-weight: 700;
|
| 820 |
+
color: #1a1a2e;
|
| 821 |
+
letter-spacing: 3px;
|
| 822 |
+
margin: 0;
|
| 823 |
+
text-shadow: 2px 2px 4px rgba(0,0,0,0.1);
|
| 824 |
+
">
|
| 825 |
+
🔬 Visual RAG Toolkit
|
| 826 |
+
</h1>
|
| 827 |
+
<p style="
|
| 828 |
+
font-family: 'Helvetica Neue', sans-serif;
|
| 829 |
+
font-size: 0.95rem;
|
| 830 |
+
color: #666;
|
| 831 |
+
margin-top: 5px;
|
| 832 |
+
letter-spacing: 1px;
|
| 833 |
+
">
|
| 834 |
+
SIGIR 2026 Demo - Multi-Vector Visual Document Retrieval
|
| 835 |
+
</p>
|
| 836 |
+
</div>
|
| 837 |
+
""",
|
| 838 |
+
unsafe_allow_html=True,
|
| 839 |
+
)
|
| 840 |
+
|
| 841 |
+
|
| 842 |
+
def render_sidebar():
|
| 843 |
+
with st.sidebar:
|
| 844 |
+
st.subheader("🔑 Qdrant Credentials")
|
| 845 |
+
|
| 846 |
+
env_url = (
|
| 847 |
+
os.getenv("SIGIR_QDRANT_URL")
|
| 848 |
+
or os.getenv("DEST_QDRANT_URL")
|
| 849 |
+
or os.getenv("QDRANT_URL")
|
| 850 |
+
or ""
|
| 851 |
+
)
|
| 852 |
+
env_key = (
|
| 853 |
+
os.getenv("SIGIR_QDRANT_KEY")
|
| 854 |
+
or os.getenv("SIGIR_QDRANT_API_KEY")
|
| 855 |
+
or os.getenv("DEST_QDRANT_API_KEY")
|
| 856 |
+
or os.getenv("QDRANT_API_KEY")
|
| 857 |
+
or ""
|
| 858 |
+
)
|
| 859 |
+
|
| 860 |
+
qdrant_url = st.text_input(
|
| 861 |
+
"Qdrant URL",
|
| 862 |
+
value=st.session_state.get("qdrant_url_input", env_url),
|
| 863 |
+
key="qdrant_url_widget",
|
| 864 |
+
placeholder="https://xxx.cloud.qdrant.io:6333",
|
| 865 |
+
)
|
| 866 |
+
qdrant_key = st.text_input(
|
| 867 |
+
"API Key",
|
| 868 |
+
value=st.session_state.get("qdrant_key_input", env_key),
|
| 869 |
+
key="qdrant_key_widget",
|
| 870 |
+
type="password",
|
| 871 |
+
)
|
| 872 |
+
|
| 873 |
+
if qdrant_url != st.session_state.get(
|
| 874 |
+
"qdrant_url_input"
|
| 875 |
+
) or qdrant_key != st.session_state.get("qdrant_key_input"):
|
| 876 |
+
st.session_state["qdrant_url_input"] = qdrant_url
|
| 877 |
+
st.session_state["qdrant_key_input"] = qdrant_key
|
| 878 |
+
get_collections.clear()
|
| 879 |
+
get_collection_stats.clear()
|
| 880 |
+
sample_points_cached.clear()
|
| 881 |
+
|
| 882 |
+
st.divider()
|
| 883 |
+
|
| 884 |
+
st.subheader("📡 Status")
|
| 885 |
+
url, api_key = get_qdrant_credentials()
|
| 886 |
+
client, err = init_qdrant_client_with_creds(url, api_key)
|
| 887 |
+
|
| 888 |
+
col_s1, col_s2 = st.columns(2)
|
| 889 |
+
with col_s1:
|
| 890 |
+
if client:
|
| 891 |
+
st.success("Qdrant ✓", icon="✅")
|
| 892 |
+
else:
|
| 893 |
+
st.error("Qdrant ✗", icon="❌")
|
| 894 |
+
with col_s2:
|
| 895 |
+
cloudinary_ok = all(
|
| 896 |
+
[os.getenv("CLOUDINARY_CLOUD_NAME"), os.getenv("CLOUDINARY_API_KEY")]
|
| 897 |
+
)
|
| 898 |
+
if cloudinary_ok:
|
| 899 |
+
st.success("Cloudinary ✓", icon="✅")
|
| 900 |
+
else:
|
| 901 |
+
st.warning("Cloudinary ✗", icon="⚠️")
|
| 902 |
+
|
| 903 |
+
st.divider()
|
| 904 |
+
|
| 905 |
+
with st.expander("📦 Collection", expanded=True):
|
| 906 |
+
collections = get_collections(url, api_key)
|
| 907 |
+
if collections:
|
| 908 |
+
prev_collection = st.session_state.get("active_collection")
|
| 909 |
+
selected = st.selectbox(
|
| 910 |
+
"Select Collection",
|
| 911 |
+
options=collections,
|
| 912 |
+
key="sidebar_collection",
|
| 913 |
+
label_visibility="collapsed",
|
| 914 |
+
)
|
| 915 |
+
if selected:
|
| 916 |
+
if selected != prev_collection:
|
| 917 |
+
st.session_state["model_loaded"] = False
|
| 918 |
+
st.session_state["loaded_model_key"] = None
|
| 919 |
+
st.session_state["active_collection"] = selected
|
| 920 |
+
stats = get_collection_stats(selected)
|
| 921 |
+
if "error" not in stats:
|
| 922 |
+
col1, col2 = st.columns(2)
|
| 923 |
+
col1.metric("Points", f"{stats.get('points_count', 0):,}")
|
| 924 |
+
status_raw = (
|
| 925 |
+
stats.get("status", "unknown").replace("CollectionStatus.", "").lower()
|
| 926 |
+
)
|
| 927 |
+
status_icon = (
|
| 928 |
+
"🟢"
|
| 929 |
+
if status_raw == "green"
|
| 930 |
+
else "🟡" if status_raw == "yellow" else "🔴"
|
| 931 |
+
)
|
| 932 |
+
col2.metric("Status", status_icon)
|
| 933 |
+
|
| 934 |
+
points = stats.get("points_count", 0)
|
| 935 |
+
indexed = stats.get("indexed_vectors_count", 0) or 0
|
| 936 |
+
is_indexed = indexed >= points and points > 0
|
| 937 |
+
col3, col4 = st.columns(2)
|
| 938 |
+
col3.metric("Indexed", f"{indexed:,}")
|
| 939 |
+
col4.metric("HNSW", "✅" if is_indexed else "⏳")
|
| 940 |
+
|
| 941 |
+
vector_info = stats.get("vector_info", {})
|
| 942 |
+
if vector_info:
|
| 943 |
+
st.markdown("---")
|
| 944 |
+
st.markdown("**🔢 Vectors**")
|
| 945 |
+
vec_sizes = get_vector_sizes(selected, url, api_key)
|
| 946 |
+
rows = []
|
| 947 |
+
sorted_names = sorted(vector_info.keys(), key=lambda x: len(x))
|
| 948 |
+
for vname in sorted_names:
|
| 949 |
+
vinfo = vector_info[vname]
|
| 950 |
+
dim = vinfo.get("size", "?")
|
| 951 |
+
num_vec = vec_sizes.get(vname, vinfo.get("num_vectors", 1))
|
| 952 |
+
dtype = vinfo.get("datatype", "?").upper()
|
| 953 |
+
on_disk = vinfo.get("on_disk", False)
|
| 954 |
+
disk_icon = "💾" if on_disk else "🧠"
|
| 955 |
+
dim_str = f"{num_vec}×{dim}"
|
| 956 |
+
rows.append(
|
| 957 |
+
f"<tr><td style='text-align:left;padding-right:12px;'><code>{vname}</code></td><td style='text-align:right;'>{dim_str}, {dtype}, {disk_icon}</td></tr>"
|
| 958 |
+
)
|
| 959 |
+
table_html = f"<table style='width:100%;font-size:0.85em;'>{''.join(rows)}</table>"
|
| 960 |
+
st.markdown(table_html, unsafe_allow_html=True)
|
| 961 |
+
else:
|
| 962 |
+
st.error("Error loading stats")
|
| 963 |
+
else:
|
| 964 |
+
st.info("No collections")
|
| 965 |
+
|
| 966 |
+
with st.expander("⚙️ Admin", expanded=False):
|
| 967 |
+
active = st.session_state.get("active_collection")
|
| 968 |
+
if active and client:
|
| 969 |
+
stats = get_collection_stats(active)
|
| 970 |
+
vector_info = stats.get("vector_info", {})
|
| 971 |
+
if vector_info:
|
| 972 |
+
st.markdown("**Change Storage**")
|
| 973 |
+
vector_names = sorted(vector_info.keys())
|
| 974 |
+
sel_vec = st.selectbox("Vector", vector_names, key="admin_vec")
|
| 975 |
+
if sel_vec:
|
| 976 |
+
current_on_disk = vector_info.get(sel_vec, {}).get("on_disk", False)
|
| 977 |
+
current_in_ram = not current_on_disk
|
| 978 |
+
st.caption(f"Current: {'🧠 RAM' if current_in_ram else '💾 Disk'}")
|
| 979 |
+
target_in_ram = st.toggle(
|
| 980 |
+
"Move to RAM", value=current_in_ram, key=f"admin_ram_{sel_vec}"
|
| 981 |
+
)
|
| 982 |
+
if target_in_ram != current_in_ram:
|
| 983 |
+
if st.button("💾 Apply Change", key="admin_apply"):
|
| 984 |
+
try:
|
| 985 |
+
from qdrant_client.models import VectorParamsDiff
|
| 986 |
+
|
| 987 |
+
client.update_collection(
|
| 988 |
+
collection_name=active,
|
| 989 |
+
vectors_config={
|
| 990 |
+
sel_vec: VectorParamsDiff(on_disk=not target_in_ram)
|
| 991 |
+
},
|
| 992 |
+
)
|
| 993 |
+
get_collection_stats.clear()
|
| 994 |
+
st.success(f"Updated {sel_vec}")
|
| 995 |
+
st.rerun()
|
| 996 |
+
except Exception as e:
|
| 997 |
+
st.error(f"Failed: {e}")
|
| 998 |
+
else:
|
| 999 |
+
st.caption("Toggle to change storage location")
|
| 1000 |
+
else:
|
| 1001 |
+
st.info("No vectors")
|
| 1002 |
+
else:
|
| 1003 |
+
st.info("Select a collection")
|
| 1004 |
+
|
| 1005 |
+
st.divider()
|
| 1006 |
+
|
| 1007 |
+
if st.button("🔄 Refresh", type="secondary", use_container_width=True):
|
| 1008 |
+
get_collections.clear()
|
| 1009 |
+
get_collection_stats.clear()
|
| 1010 |
+
sample_points_cached.clear()
|
| 1011 |
+
st.rerun()
|
| 1012 |
+
|
| 1013 |
+
|
| 1014 |
+
def render_upload_tab():
|
| 1015 |
+
if "upload_success" in st.session_state:
|
| 1016 |
+
msg = st.session_state.pop("upload_success")
|
| 1017 |
+
st.toast(f"✅ {msg}", icon="🎉")
|
| 1018 |
+
st.balloons()
|
| 1019 |
+
|
| 1020 |
+
st.subheader("📤 PDF Upload & Processing")
|
| 1021 |
+
|
| 1022 |
+
col_upload, col_config = st.columns([3, 2])
|
| 1023 |
+
|
| 1024 |
+
with col_config:
|
| 1025 |
+
st.markdown("##### Configuration")
|
| 1026 |
+
|
| 1027 |
+
c1, c2 = st.columns(2)
|
| 1028 |
+
with c1:
|
| 1029 |
+
model_name = st.selectbox("Model", AVAILABLE_MODELS, index=1, key="upload_model")
|
| 1030 |
+
with c2:
|
| 1031 |
+
collection_name = st.text_input(
|
| 1032 |
+
"Collection", value="my_collection", key="upload_collection_input"
|
| 1033 |
+
)
|
| 1034 |
+
|
| 1035 |
+
c3, c4 = st.columns(2)
|
| 1036 |
+
with c3:
|
| 1037 |
+
crop_empty = st.toggle("Crop Margins", value=True, key="upload_crop")
|
| 1038 |
+
with c4:
|
| 1039 |
+
use_cloudinary = st.toggle("Cloudinary", value=True, key="upload_cloudinary")
|
| 1040 |
+
|
| 1041 |
+
if crop_empty:
|
| 1042 |
+
crop_pct = st.slider("Crop %", 0.5, 0.99, 0.9, 0.01, key="upload_crop_pct")
|
| 1043 |
+
else:
|
| 1044 |
+
crop_pct = 0.9
|
| 1045 |
+
|
| 1046 |
+
with col_upload:
|
| 1047 |
+
uploaded_files = st.file_uploader(
|
| 1048 |
+
"Select PDF files",
|
| 1049 |
+
type=["pdf"],
|
| 1050 |
+
accept_multiple_files=True,
|
| 1051 |
+
key="pdf_uploader",
|
| 1052 |
+
)
|
| 1053 |
+
|
| 1054 |
+
if uploaded_files:
|
| 1055 |
+
st.success(f"**{len(uploaded_files)} file(s) selected**")
|
| 1056 |
+
|
| 1057 |
+
if st.button("🚀 Process PDFs", type="primary", key="process_btn"):
|
| 1058 |
+
process_pdfs(
|
| 1059 |
+
uploaded_files,
|
| 1060 |
+
model_name,
|
| 1061 |
+
collection_name,
|
| 1062 |
+
crop_empty,
|
| 1063 |
+
crop_pct,
|
| 1064 |
+
use_cloudinary,
|
| 1065 |
+
)
|
| 1066 |
+
|
| 1067 |
+
if st.session_state.get("last_upload_result"):
|
| 1068 |
+
st.divider()
|
| 1069 |
+
render_upload_results()
|
| 1070 |
+
|
| 1071 |
+
|
| 1072 |
+
def process_pdfs(uploaded_files, model_name, collection_name, crop_empty, crop_pct, use_cloudinary):
|
| 1073 |
+
logs = []
|
| 1074 |
+
log_container = st.empty()
|
| 1075 |
+
progress = st.progress(0)
|
| 1076 |
+
status = st.empty()
|
| 1077 |
+
|
| 1078 |
+
def log(msg):
|
| 1079 |
+
logs.append(f"[{datetime.now().strftime('%H:%M:%S')}] {msg}")
|
| 1080 |
+
log_container.code("\n".join(logs[-30:]), language="text")
|
| 1081 |
+
|
| 1082 |
+
try:
|
| 1083 |
+
log(f"Starting: {len(uploaded_files)} files, model={model_name.split('/')[-1]}")
|
| 1084 |
+
|
| 1085 |
+
from visual_rag import VisualEmbedder
|
| 1086 |
+
from visual_rag.indexing import CloudinaryUploader, ProcessingPipeline, QdrantIndexer
|
| 1087 |
+
|
| 1088 |
+
log("Loading model...")
|
| 1089 |
+
embedder = VisualEmbedder(model_name=model_name)
|
| 1090 |
+
|
| 1091 |
+
url, api_key = get_qdrant_credentials()
|
| 1092 |
+
log("Connecting to Qdrant...")
|
| 1093 |
+
indexer = QdrantIndexer(url=url, api_key=api_key, collection_name=collection_name)
|
| 1094 |
+
indexer.create_collection(force_recreate=False)
|
| 1095 |
+
|
| 1096 |
+
cloudinary_uploader = None
|
| 1097 |
+
if use_cloudinary:
|
| 1098 |
+
try:
|
| 1099 |
+
cloudinary_uploader = CloudinaryUploader()
|
| 1100 |
+
log("Cloudinary ready")
|
| 1101 |
+
except Exception as e:
|
| 1102 |
+
log(f"Cloudinary failed: {e}")
|
| 1103 |
+
|
| 1104 |
+
pipeline = ProcessingPipeline(
|
| 1105 |
+
embedder=embedder,
|
| 1106 |
+
indexer=indexer,
|
| 1107 |
+
cloudinary_uploader=cloudinary_uploader,
|
| 1108 |
+
crop_empty=crop_empty,
|
| 1109 |
+
crop_empty_percentage_to_remove=crop_pct,
|
| 1110 |
+
)
|
| 1111 |
+
|
| 1112 |
+
total_uploaded, total_skipped, total_failed = 0, 0, 0
|
| 1113 |
+
file_results = []
|
| 1114 |
+
|
| 1115 |
+
for i, f in enumerate(uploaded_files):
|
| 1116 |
+
status.text(f"Processing: {f.name}")
|
| 1117 |
+
log(f"[{i+1}/{len(uploaded_files)}] {f.name}")
|
| 1118 |
+
|
| 1119 |
+
with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as tmp:
|
| 1120 |
+
tmp.write(f.getvalue())
|
| 1121 |
+
tmp_path = Path(tmp.name)
|
| 1122 |
+
|
| 1123 |
+
try:
|
| 1124 |
+
result = pipeline.process_pdf(tmp_path)
|
| 1125 |
+
total_uploaded += result.get("uploaded", 0)
|
| 1126 |
+
total_skipped += result.get("skipped", 0)
|
| 1127 |
+
file_results.append(
|
| 1128 |
+
{
|
| 1129 |
+
"file": f.name,
|
| 1130 |
+
"uploaded": result.get("uploaded", 0),
|
| 1131 |
+
"skipped": result.get("skipped", 0),
|
| 1132 |
+
}
|
| 1133 |
+
)
|
| 1134 |
+
log(f" ✓ uploaded={result.get('uploaded', 0)}, skipped={result.get('skipped', 0)}")
|
| 1135 |
+
except Exception as e:
|
| 1136 |
+
total_failed += 1
|
| 1137 |
+
log(f" ✗ Error: {e}")
|
| 1138 |
+
finally:
|
| 1139 |
+
os.unlink(tmp_path)
|
| 1140 |
+
|
| 1141 |
+
progress.progress((i + 1) / len(uploaded_files))
|
| 1142 |
+
|
| 1143 |
+
st.session_state["last_upload_result"] = {
|
| 1144 |
+
"total_uploaded": total_uploaded,
|
| 1145 |
+
"total_skipped": total_skipped,
|
| 1146 |
+
"total_failed": total_failed,
|
| 1147 |
+
"file_results": file_results,
|
| 1148 |
+
"collection": collection_name,
|
| 1149 |
+
}
|
| 1150 |
+
|
| 1151 |
+
get_collection_stats.clear()
|
| 1152 |
+
sample_points_cached.clear()
|
| 1153 |
+
|
| 1154 |
+
if total_uploaded > 0:
|
| 1155 |
+
st.session_state["upload_success"] = f"Uploaded {total_uploaded} pages"
|
| 1156 |
+
st.rerun()
|
| 1157 |
+
|
| 1158 |
+
except Exception as e:
|
| 1159 |
+
log(f"ERROR: {e}")
|
| 1160 |
+
st.error(f"Processing error: {e}")
|
| 1161 |
+
with st.expander("Traceback"):
|
| 1162 |
+
st.code(traceback.format_exc())
|
| 1163 |
+
|
| 1164 |
+
|
| 1165 |
+
def render_upload_results():
|
| 1166 |
+
result = st.session_state.get("last_upload_result", {})
|
| 1167 |
+
if not result:
|
| 1168 |
+
return
|
| 1169 |
+
|
| 1170 |
+
st.subheader("📊 Results")
|
| 1171 |
+
|
| 1172 |
+
c1, c2, c3 = st.columns(3)
|
| 1173 |
+
c1.metric("Uploaded", result.get("total_uploaded", 0))
|
| 1174 |
+
c2.metric("Skipped", result.get("total_skipped", 0))
|
| 1175 |
+
c3.metric("Failed", result.get("total_failed", 0))
|
| 1176 |
+
|
| 1177 |
+
|
| 1178 |
+
def render_playground_tab():
|
| 1179 |
+
st.subheader("🎮 Playground")
|
| 1180 |
+
|
| 1181 |
+
active_collection = st.session_state.get("active_collection")
|
| 1182 |
+
url, api_key = get_qdrant_credentials()
|
| 1183 |
+
|
| 1184 |
+
if not active_collection:
|
| 1185 |
+
collections = get_collections(url, api_key)
|
| 1186 |
+
if collections:
|
| 1187 |
+
active_collection = collections[0]
|
| 1188 |
+
|
| 1189 |
+
if not active_collection:
|
| 1190 |
+
st.warning("No collection available. Upload documents or select a collection.")
|
| 1191 |
+
return
|
| 1192 |
+
|
| 1193 |
+
points_for_model = sample_points_cached(active_collection, 1, 0, url, api_key)
|
| 1194 |
+
model_name = None
|
| 1195 |
+
if points_for_model:
|
| 1196 |
+
model_name = points_for_model[0].get("payload", {}).get("model_name")
|
| 1197 |
+
if not model_name:
|
| 1198 |
+
model_name = AVAILABLE_MODELS[1]
|
| 1199 |
+
|
| 1200 |
+
model_short = model_name.split("/")[-1] if model_name else "unknown"
|
| 1201 |
+
cache_key = f"{active_collection}_{model_name}"
|
| 1202 |
+
|
| 1203 |
+
if st.session_state.get("loaded_model_key") != cache_key:
|
| 1204 |
+
st.session_state["model_loaded"] = False
|
| 1205 |
+
|
| 1206 |
+
col_info, col_model = st.columns([2, 1])
|
| 1207 |
+
with col_info:
|
| 1208 |
+
st.info(f"**Collection:** `{active_collection}`")
|
| 1209 |
+
with col_model:
|
| 1210 |
+
if not st.session_state.get("model_loaded"):
|
| 1211 |
+
with st.spinner(f"Loading {model_short}..."):
|
| 1212 |
+
try:
|
| 1213 |
+
from visual_rag.retrieval import MultiVectorRetriever
|
| 1214 |
+
|
| 1215 |
+
_ = MultiVectorRetriever(
|
| 1216 |
+
collection_name=active_collection, model_name=model_name
|
| 1217 |
+
)
|
| 1218 |
+
st.session_state["model_loaded"] = True
|
| 1219 |
+
st.session_state["loaded_model_key"] = cache_key
|
| 1220 |
+
st.session_state["loaded_model_name"] = model_name
|
| 1221 |
+
except Exception:
|
| 1222 |
+
st.warning(f"Failed: {model_short}")
|
| 1223 |
+
|
| 1224 |
+
if st.session_state.get("model_loaded"):
|
| 1225 |
+
st.markdown(
|
| 1226 |
+
f"✅ Found <span style='color:#e74c3c;font-weight:bold;'>{model_short}</span> model",
|
| 1227 |
+
unsafe_allow_html=True,
|
| 1228 |
+
)
|
| 1229 |
+
|
| 1230 |
+
with st.expander("📦 Sample Points Explorer", expanded=True):
|
| 1231 |
+
render_sample_explorer(active_collection, url, api_key)
|
| 1232 |
+
|
| 1233 |
+
st.divider()
|
| 1234 |
+
|
| 1235 |
+
st.subheader("🔍 RAG Query")
|
| 1236 |
+
render_rag_query_interface(active_collection, model_name)
|
| 1237 |
+
|
| 1238 |
+
|
| 1239 |
+
def render_document_details(pt: dict, p: dict, score: float = None, rel_pct: float = None):
|
| 1240 |
+
doc_id = p.get("doc_id") or p.get("union_doc_id") or p.get("source_doc_id") or "?"
|
| 1241 |
+
corpus_id = p.get("corpus-id") or p.get("source_doc_id") or "?"
|
| 1242 |
+
dataset = p.get("dataset") or p.get("source") or "N/A"
|
| 1243 |
+
model = (p.get("model_name") or p.get("model") or "N/A").split("/")[-1]
|
| 1244 |
+
doc_name = p.get("doc-id") or p.get("filename") or "Unknown"
|
| 1245 |
+
|
| 1246 |
+
num_tiles = p.get("num_tiles") or "?"
|
| 1247 |
+
visual_tokens = p.get("index_recovery_num_visual_tokens") or p.get("num_visual_tokens") or "?"
|
| 1248 |
+
patches_per_tile = p.get("patches_per_tile") or "?"
|
| 1249 |
+
torch_dtype = p.get("torch_dtype") or "?"
|
| 1250 |
+
|
| 1251 |
+
orig_w = p.get("original_width") or "?"
|
| 1252 |
+
orig_h = p.get("original_height") or "?"
|
| 1253 |
+
crop_w = p.get("cropped_width") or "?"
|
| 1254 |
+
crop_h = p.get("cropped_height") or "?"
|
| 1255 |
+
resize_w = p.get("resized_width") or "?"
|
| 1256 |
+
resize_h = p.get("resized_height") or "?"
|
| 1257 |
+
crop_pct = p.get("crop_empty_percentage_to_remove") or 0
|
| 1258 |
+
crop_enabled = p.get("crop_empty_enabled", False)
|
| 1259 |
+
|
| 1260 |
+
col_meta, col_img = st.columns([1, 2])
|
| 1261 |
+
|
| 1262 |
+
with col_meta:
|
| 1263 |
+
st.markdown("##### 📄 Document Info")
|
| 1264 |
+
st.markdown(f"**📁 Doc:** {doc_name}")
|
| 1265 |
+
st.markdown(f"**🏛️ Dataset:** {dataset}")
|
| 1266 |
+
st.markdown(f"**🔑 Doc ID:** `{str(doc_id)[:20]}...`")
|
| 1267 |
+
st.markdown(f"**📋 Corpus ID:** {corpus_id}")
|
| 1268 |
+
|
| 1269 |
+
if score is not None:
|
| 1270 |
+
st.divider()
|
| 1271 |
+
st.markdown("##### 🎯 Relevance")
|
| 1272 |
+
if rel_pct is not None:
|
| 1273 |
+
st.markdown(f"**Relative:** 🟢 {rel_pct:.1f}%")
|
| 1274 |
+
st.progress(rel_pct / 100)
|
| 1275 |
+
st.caption(f"Raw score: {score:.4f}")
|
| 1276 |
+
|
| 1277 |
+
st.divider()
|
| 1278 |
+
st.markdown("##### 🎨 Visual Metadata")
|
| 1279 |
+
st.markdown(f"**🤖 Model:** `{model}`")
|
| 1280 |
+
st.markdown(f"**🔲 Tiles:** {num_tiles}")
|
| 1281 |
+
st.markdown(f"**🔢 Visual Tokens:** {visual_tokens}")
|
| 1282 |
+
st.markdown(f"**📦 Patches/Tile:** {patches_per_tile}")
|
| 1283 |
+
st.markdown(f"**⚙️ Dtype:** {torch_dtype}")
|
| 1284 |
+
|
| 1285 |
+
st.divider()
|
| 1286 |
+
st.markdown("##### 📐 Dimensions")
|
| 1287 |
+
st.markdown(f"**Original:** {orig_w}×{orig_h}")
|
| 1288 |
+
st.markdown(f"**Resized:** {resize_w}×{resize_h}")
|
| 1289 |
+
if crop_enabled:
|
| 1290 |
+
st.markdown(f"**Cropped:** {crop_w}×{crop_h}")
|
| 1291 |
+
st.markdown(f"**Crop %:** {int(crop_pct * 100) if crop_pct else 0}%")
|
| 1292 |
+
|
| 1293 |
+
with col_img:
|
| 1294 |
+
st.markdown("##### 📷 Document Page")
|
| 1295 |
+
tabs = st.tabs(["🖼️ Original", "📷 Resized", "✂️ Cropped"])
|
| 1296 |
+
|
| 1297 |
+
url_o = p.get("original_url")
|
| 1298 |
+
url_r = p.get("resized_url") or p.get("page")
|
| 1299 |
+
url_c = p.get("cropped_url")
|
| 1300 |
+
|
| 1301 |
+
with tabs[0]:
|
| 1302 |
+
if url_o:
|
| 1303 |
+
st.image(url_o, width=600)
|
| 1304 |
+
st.caption(f"📐 **{orig_w}×{orig_h}**")
|
| 1305 |
+
else:
|
| 1306 |
+
st.info("No original image available")
|
| 1307 |
+
|
| 1308 |
+
with tabs[1]:
|
| 1309 |
+
if url_r:
|
| 1310 |
+
st.image(url_r, width=600)
|
| 1311 |
+
st.caption(f"📐 **{resize_w}×{resize_h}**")
|
| 1312 |
+
else:
|
| 1313 |
+
st.info("No resized image available")
|
| 1314 |
+
|
| 1315 |
+
with tabs[2]:
|
| 1316 |
+
if url_c:
|
| 1317 |
+
st.image(url_c, width=600)
|
| 1318 |
+
st.caption(
|
| 1319 |
+
f"📐 **{crop_w}×{crop_h}** | Crop: {int(crop_pct * 100) if crop_pct else 0}%"
|
| 1320 |
+
)
|
| 1321 |
+
else:
|
| 1322 |
+
st.info("No cropped image available")
|
| 1323 |
+
|
| 1324 |
+
with st.expander("🔗 Image URLs"):
|
| 1325 |
+
if url_o:
|
| 1326 |
+
st.code(url_o, language=None)
|
| 1327 |
+
if url_r and url_r != url_o:
|
| 1328 |
+
st.code(url_r, language=None)
|
| 1329 |
+
if url_c:
|
| 1330 |
+
st.code(url_c, language=None)
|
| 1331 |
+
|
| 1332 |
+
|
| 1333 |
+
def render_sample_explorer(collection_name: str, url: str, api_key: str):
|
| 1334 |
+
sample_for_filters = sample_points_cached(collection_name, 50, 0, url, api_key)
|
| 1335 |
+
datasets = set()
|
| 1336 |
+
doc_ids = set()
|
| 1337 |
+
for pt in sample_for_filters:
|
| 1338 |
+
p = pt.get("payload", {})
|
| 1339 |
+
if ds := p.get("dataset"):
|
| 1340 |
+
datasets.add(ds)
|
| 1341 |
+
if did := (p.get("doc-id") or p.get("filename")):
|
| 1342 |
+
doc_ids.add(did)
|
| 1343 |
+
|
| 1344 |
+
c1, c2, c3, c4 = st.columns([1, 1, 2, 1])
|
| 1345 |
+
with c1:
|
| 1346 |
+
n_samples = st.slider("Samples", 1, 20, 3, key="pg_n")
|
| 1347 |
+
with c2:
|
| 1348 |
+
seed = st.number_input("Seed", 0, 9999, 42, key="pg_seed")
|
| 1349 |
+
with c3:
|
| 1350 |
+
filter_ds = st.selectbox("Dataset", ["All"] + sorted(datasets), key="pg_filter_ds")
|
| 1351 |
+
with c4:
|
| 1352 |
+
st.write("")
|
| 1353 |
+
do_sample = st.button("🎲 Sample", type="primary", key="pg_sample_btn")
|
| 1354 |
+
|
| 1355 |
+
if do_sample:
|
| 1356 |
+
points = sample_points_cached(collection_name, n_samples * 5, seed, url, api_key)
|
| 1357 |
+
if filter_ds != "All":
|
| 1358 |
+
points = [p for p in points if p.get("payload", {}).get("dataset") == filter_ds]
|
| 1359 |
+
points = points[:n_samples]
|
| 1360 |
+
st.session_state["pg_points"] = points
|
| 1361 |
+
|
| 1362 |
+
points = st.session_state.get("pg_points", [])
|
| 1363 |
+
|
| 1364 |
+
if not points:
|
| 1365 |
+
st.caption("Click 'Sample' to load documents")
|
| 1366 |
+
return
|
| 1367 |
+
|
| 1368 |
+
st.success(f"**{len(points)} points loaded**")
|
| 1369 |
+
|
| 1370 |
+
for i, pt in enumerate(points):
|
| 1371 |
+
p = pt.get("payload", {})
|
| 1372 |
+
|
| 1373 |
+
filename = p.get("filename") or p.get("doc_id") or p.get("source_doc_id") or "Unknown"
|
| 1374 |
+
page_num = p.get("page_number") or p.get("page") or "?"
|
| 1375 |
+
|
| 1376 |
+
with st.expander(f"**{i+1}.** {str(filename)[:40]} - Page {page_num}", expanded=(i == 0)):
|
| 1377 |
+
render_document_details(pt, p)
|
| 1378 |
+
|
| 1379 |
+
|
| 1380 |
+
def render_rag_query_interface(collection_name: str, model_name: str = None):
|
| 1381 |
+
if not collection_name:
|
| 1382 |
+
return
|
| 1383 |
+
|
| 1384 |
+
url, api_key = get_qdrant_credentials()
|
| 1385 |
+
|
| 1386 |
+
if not model_name:
|
| 1387 |
+
points = sample_points_cached(collection_name, 1, 0, url, api_key)
|
| 1388 |
+
if points:
|
| 1389 |
+
model_name = points[0].get("payload", {}).get("model_name")
|
| 1390 |
+
if not model_name:
|
| 1391 |
+
model_name = AVAILABLE_MODELS[1]
|
| 1392 |
+
|
| 1393 |
+
st.caption(f"Model: **{model_name.split('/')[-1] if model_name else 'auto'}**")
|
| 1394 |
+
|
| 1395 |
+
c1, c2, c3 = st.columns([2, 1, 1])
|
| 1396 |
+
with c2:
|
| 1397 |
+
mode = st.selectbox("Mode", RETRIEVAL_MODES, index=0, key="q_mode")
|
| 1398 |
+
with c3:
|
| 1399 |
+
top_k = st.slider("Top K", 1, 30, 10, key="q_topk")
|
| 1400 |
+
|
| 1401 |
+
prefetch_k, stage1_mode, stage1_k, stage2_k = 256, "tokens_vs_standard_pooling", 1000, 300
|
| 1402 |
+
|
| 1403 |
+
if mode == "two_stage":
|
| 1404 |
+
cc1, cc2 = st.columns(2)
|
| 1405 |
+
with cc1:
|
| 1406 |
+
stage1_mode = st.selectbox("Stage1", STAGE1_MODES, key="q_s1mode")
|
| 1407 |
+
with cc2:
|
| 1408 |
+
prefetch_k = st.slider("Prefetch K", 50, 500, 256, key="q_pk")
|
| 1409 |
+
elif mode == "three_stage":
|
| 1410 |
+
cc1, cc2 = st.columns(2)
|
| 1411 |
+
with cc1:
|
| 1412 |
+
stage1_k = st.number_input("Stage1 K", 100, 5000, 1000, key="q_s1k")
|
| 1413 |
+
with cc2:
|
| 1414 |
+
stage2_k = st.number_input("Stage2 K", 50, 1000, 300, key="q_s2k")
|
| 1415 |
+
|
| 1416 |
+
with c1:
|
| 1417 |
+
query = st.text_input("Query", placeholder="Enter your search query...", key="q_text")
|
| 1418 |
+
|
| 1419 |
+
if st.button("🔍 Search", type="primary", disabled=not query, key="q_search"):
|
| 1420 |
+
with st.spinner("Searching..."):
|
| 1421 |
+
results, err = search_collection(
|
| 1422 |
+
collection_name,
|
| 1423 |
+
query,
|
| 1424 |
+
top_k,
|
| 1425 |
+
mode,
|
| 1426 |
+
prefetch_k,
|
| 1427 |
+
stage1_mode,
|
| 1428 |
+
stage1_k,
|
| 1429 |
+
stage2_k,
|
| 1430 |
+
model_name,
|
| 1431 |
+
)
|
| 1432 |
+
if err:
|
| 1433 |
+
st.error("Search failed")
|
| 1434 |
+
st.code(err)
|
| 1435 |
+
else:
|
| 1436 |
+
st.session_state["q_results"] = results
|
| 1437 |
+
|
| 1438 |
+
results = st.session_state.get("q_results", [])
|
| 1439 |
+
if results:
|
| 1440 |
+
st.success(f"**{len(results)} results**")
|
| 1441 |
+
max_score = max(r.get("score_final", r.get("score_stage1", 0)) for r in results) or 1
|
| 1442 |
+
|
| 1443 |
+
for i, r in enumerate(results):
|
| 1444 |
+
p = r.get("payload", {})
|
| 1445 |
+
score = r.get("score_final", r.get("score_stage1", 0))
|
| 1446 |
+
rel = score / max_score * 100
|
| 1447 |
+
|
| 1448 |
+
filename = p.get("filename") or p.get("doc_id") or p.get("source_doc_id") or "Unknown"
|
| 1449 |
+
page_num = p.get("page_number") or p.get("page") or "?"
|
| 1450 |
+
|
| 1451 |
+
with st.expander(
|
| 1452 |
+
f"**#{i+1}** {str(filename)[:35]} - Page {page_num} | 🎯 {rel:.0f}%",
|
| 1453 |
+
expanded=(i < 3),
|
| 1454 |
+
):
|
| 1455 |
+
render_document_details(r, p, score=score, rel_pct=rel)
|
| 1456 |
+
|
| 1457 |
+
|
| 1458 |
+
def render_benchmark_tab():
|
| 1459 |
+
st.subheader("📊 Benchmarking")
|
| 1460 |
+
|
| 1461 |
+
tab_index, tab_eval, tab_results = st.tabs(["Indexing", "Evaluation", "Results"])
|
| 1462 |
+
|
| 1463 |
+
url, api_key = get_qdrant_credentials()
|
| 1464 |
+
collections = get_collections(url, api_key)
|
| 1465 |
+
|
| 1466 |
+
with tab_index:
|
| 1467 |
+
render_benchmark_indexing(collections)
|
| 1468 |
+
|
| 1469 |
+
with tab_eval:
|
| 1470 |
+
render_benchmark_evaluation(collections)
|
| 1471 |
+
|
| 1472 |
+
with tab_results:
|
| 1473 |
+
render_benchmark_results()
|
| 1474 |
+
|
| 1475 |
+
|
| 1476 |
+
def render_benchmark_indexing(collections: List[str]):
|
| 1477 |
+
c1, c2, c3 = st.columns(3)
|
| 1478 |
+
with c1:
|
| 1479 |
+
datasets = st.multiselect(
|
| 1480 |
+
"Datasets", BENCHMARK_DATASETS, default=BENCHMARK_DATASETS, key="bi_ds"
|
| 1481 |
+
)
|
| 1482 |
+
with c2:
|
| 1483 |
+
model = st.selectbox("Model", AVAILABLE_MODELS, key="bi_model")
|
| 1484 |
+
with c3:
|
| 1485 |
+
model_short = model.split("/")[-1].replace("-", "_").replace(".", "_")
|
| 1486 |
+
collection = st.text_input(
|
| 1487 |
+
"Collection", value=f"vidore_{len(datasets)}ds__{model_short}", key="bi_coll"
|
| 1488 |
+
)
|
| 1489 |
+
|
| 1490 |
+
c4, c5, c6, c7 = st.columns(4)
|
| 1491 |
+
with c4:
|
| 1492 |
+
crop = st.toggle("Crop", value=True, key="bi_crop")
|
| 1493 |
+
with c5:
|
| 1494 |
+
cloudinary = st.toggle("Cloudinary", value=True, key="bi_cloud")
|
| 1495 |
+
with c6:
|
| 1496 |
+
grpc = st.toggle("gRPC", value=True, key="bi_grpc")
|
| 1497 |
+
with c7:
|
| 1498 |
+
recreate = st.toggle("Recreate", value=False, key="bi_recreate")
|
| 1499 |
+
|
| 1500 |
+
crop_pct = st.slider("Crop %", 0.8, 0.99, 0.99, 0.01, key="bi_crop_pct") if crop else 0.99
|
| 1501 |
+
|
| 1502 |
+
config = {
|
| 1503 |
+
"datasets": datasets,
|
| 1504 |
+
"model": model,
|
| 1505 |
+
"collection": collection,
|
| 1506 |
+
"crop_empty": crop,
|
| 1507 |
+
"crop_percentage": crop_pct,
|
| 1508 |
+
"no_cloudinary": not cloudinary,
|
| 1509 |
+
"recreate": recreate,
|
| 1510 |
+
"resume": False,
|
| 1511 |
+
"prefer_grpc": grpc,
|
| 1512 |
+
"batch_size": 4,
|
| 1513 |
+
"upload_batch_size": 8,
|
| 1514 |
+
"qdrant_timeout": 180,
|
| 1515 |
+
"qdrant_retries": 5,
|
| 1516 |
+
"torch_dtype": "float16",
|
| 1517 |
+
"qdrant_vector_dtype": "float16",
|
| 1518 |
+
}
|
| 1519 |
+
|
| 1520 |
+
cmd = build_index_command(config)
|
| 1521 |
+
|
| 1522 |
+
col_cmd, col_stats = st.columns([2, 1])
|
| 1523 |
+
with col_cmd:
|
| 1524 |
+
st.code(cmd, language="bash")
|
| 1525 |
+
with col_stats:
|
| 1526 |
+
st.metric("Datasets", len(datasets))
|
| 1527 |
+
st.metric("Model", model.split("/")[-1])
|
| 1528 |
+
run_index = st.button("🚀 Run Index", type="primary", key="bi_run")
|
| 1529 |
+
|
| 1530 |
+
if run_index:
|
| 1531 |
+
if not collection:
|
| 1532 |
+
st.error("Please select a collection first")
|
| 1533 |
+
else:
|
| 1534 |
+
run_indexing_with_ui(config)
|
| 1535 |
+
|
| 1536 |
+
|
| 1537 |
+
def render_benchmark_evaluation(collections: List[str]):
|
| 1538 |
+
all_docs = sum(DATASET_STATS.get(d, {}).get("docs", 0) for d in BENCHMARK_DATASETS)
|
| 1539 |
+
all_queries = sum(DATASET_STATS.get(d, {}).get("queries", 0) for d in BENCHMARK_DATASETS)
|
| 1540 |
+
st.markdown(
|
| 1541 |
+
f"📊 **Available:** {len(BENCHMARK_DATASETS)} datasets — **{all_docs:,}** docs, **{all_queries:,}** queries"
|
| 1542 |
+
)
|
| 1543 |
+
|
| 1544 |
+
c1, c2, c3 = st.columns([2, 2, 1])
|
| 1545 |
+
with c1:
|
| 1546 |
+
if collections:
|
| 1547 |
+
collection = st.selectbox("Collection", collections, key="be_coll")
|
| 1548 |
+
else:
|
| 1549 |
+
collection = st.text_input("Collection", key="be_coll_txt")
|
| 1550 |
+
with c2:
|
| 1551 |
+
st.multiselect("Datasets", BENCHMARK_DATASETS, default=BENCHMARK_DATASETS, key="be_ds")
|
| 1552 |
+
with c3:
|
| 1553 |
+
model = st.selectbox("Model", AVAILABLE_MODELS, key="be_model")
|
| 1554 |
+
|
| 1555 |
+
datasets = st.session_state.get("be_ds", BENCHMARK_DATASETS)
|
| 1556 |
+
sel_docs = sum(DATASET_STATS.get(d, {}).get("docs", 0) for d in datasets)
|
| 1557 |
+
sel_queries = sum(DATASET_STATS.get(d, {}).get("queries", 0) for d in datasets)
|
| 1558 |
+
st.markdown(
|
| 1559 |
+
f"🎯 **Selected:** {len(datasets)} dataset(s) — **{sel_docs:,}** docs, **{sel_queries:,}** queries"
|
| 1560 |
+
)
|
| 1561 |
+
|
| 1562 |
+
st.markdown("---")
|
| 1563 |
+
|
| 1564 |
+
col_mode, col_topk = st.columns([2, 1])
|
| 1565 |
+
with col_mode:
|
| 1566 |
+
mode = st.selectbox("Mode", RETRIEVAL_MODES, key="be_mode")
|
| 1567 |
+
with col_topk:
|
| 1568 |
+
top_k = st.slider("Top K", 10, 100, 100, key="be_topk")
|
| 1569 |
+
|
| 1570 |
+
stage1_mode, prefetch_k, stage1_k, stage2_k = "tokens_vs_standard_pooling", 256, 1000, 300
|
| 1571 |
+
|
| 1572 |
+
if mode == "two_stage":
|
| 1573 |
+
cc1, cc2 = st.columns(2)
|
| 1574 |
+
with cc1:
|
| 1575 |
+
stage1_mode = st.selectbox("Stage1 Mode", STAGE1_MODES, key="be_s1mode")
|
| 1576 |
+
with cc2:
|
| 1577 |
+
prefetch_k = st.slider("Prefetch K", 50, 1000, 256, key="be_pk")
|
| 1578 |
+
elif mode == "three_stage":
|
| 1579 |
+
cc1, cc2 = st.columns(2)
|
| 1580 |
+
with cc1:
|
| 1581 |
+
stage1_k = st.number_input("Stage1 K", 100, 5000, 1000, key="be_s1k")
|
| 1582 |
+
with cc2:
|
| 1583 |
+
stage2_k = st.number_input("Stage2 K", 50, 1000, 300, key="be_s2k")
|
| 1584 |
+
|
| 1585 |
+
st.markdown("---")
|
| 1586 |
+
|
| 1587 |
+
col_scope, col_grpc, col_spacer = st.columns([2, 1, 1])
|
| 1588 |
+
with col_scope:
|
| 1589 |
+
scope = st.selectbox("Scope", ["union", "per_dataset"], key="be_scope")
|
| 1590 |
+
with col_grpc:
|
| 1591 |
+
grpc = st.toggle("gRPC", value=True, key="be_grpc")
|
| 1592 |
+
|
| 1593 |
+
result_prefix_val = st.session_state.get("be_prefix", "")
|
| 1594 |
+
|
| 1595 |
+
config = {
|
| 1596 |
+
"datasets": datasets,
|
| 1597 |
+
"model": model,
|
| 1598 |
+
"collection": collection,
|
| 1599 |
+
"mode": mode,
|
| 1600 |
+
"top_k": top_k,
|
| 1601 |
+
"evaluation_scope": scope,
|
| 1602 |
+
"prefer_grpc": grpc,
|
| 1603 |
+
"torch_dtype": "float16",
|
| 1604 |
+
"qdrant_vector_dtype": "float16",
|
| 1605 |
+
"qdrant_timeout": 180,
|
| 1606 |
+
"stage1_mode": stage1_mode,
|
| 1607 |
+
"prefetch_k": prefetch_k,
|
| 1608 |
+
"stage1_k": stage1_k,
|
| 1609 |
+
"stage2_k": stage2_k,
|
| 1610 |
+
"result_prefix": result_prefix_val,
|
| 1611 |
+
}
|
| 1612 |
+
|
| 1613 |
+
cmd = build_eval_command(config)
|
| 1614 |
+
|
| 1615 |
+
python_code = generate_python_eval_code(config)
|
| 1616 |
+
|
| 1617 |
+
col_cmd, col_info = st.columns([2, 1])
|
| 1618 |
+
with col_cmd:
|
| 1619 |
+
code_tab1, code_tab2 = st.tabs(["🐚 Bash", "🐍 Python"])
|
| 1620 |
+
with code_tab1:
|
| 1621 |
+
st.code(cmd, language="bash")
|
| 1622 |
+
with code_tab2:
|
| 1623 |
+
st.code(python_code, language="python")
|
| 1624 |
+
with col_info:
|
| 1625 |
+
mode_desc = {
|
| 1626 |
+
"single_full": "🔹 **Single Full**: Query all visual tokens against full document embeddings in one pass.",
|
| 1627 |
+
"single_tiles": "🔸 **Single Tiles**: Query against tile-level embeddings only.",
|
| 1628 |
+
"single_global": "🔶 **Single Global**: Query against global (pooled) document embeddings.",
|
| 1629 |
+
"two_stage": "🔷 **Two Stage**: Fast prefetch with global/tiles, then rerank with full tokens.",
|
| 1630 |
+
"three_stage": "🔶 **Three Stage**: Global → Tiles → Full tokens for maximum precision.",
|
| 1631 |
+
}
|
| 1632 |
+
scope_desc = {
|
| 1633 |
+
"union": "📊 **Union**: Evaluate across all datasets combined as one corpus.",
|
| 1634 |
+
"per_dataset": "📁 **Per Dataset**: Evaluate each dataset separately and report individual metrics.",
|
| 1635 |
+
}
|
| 1636 |
+
st.markdown(mode_desc.get(mode, ""))
|
| 1637 |
+
st.markdown(scope_desc.get(scope, ""))
|
| 1638 |
+
st.divider()
|
| 1639 |
+
st.text_input("Result Prefix", placeholder="optional prefix for output", key="be_prefix")
|
| 1640 |
+
|
| 1641 |
+
run_eval = st.button("🚀 Run Eval", type="primary", key="be_run", use_container_width=True)
|
| 1642 |
+
|
| 1643 |
+
if run_eval:
|
| 1644 |
+
if not collection:
|
| 1645 |
+
st.error("Please select a collection first")
|
| 1646 |
+
else:
|
| 1647 |
+
run_evaluation_with_ui(config)
|
| 1648 |
+
|
| 1649 |
+
|
| 1650 |
+
def run_evaluation_with_ui(config: Dict[str, Any]):
|
| 1651 |
+
st.divider()
|
| 1652 |
+
|
| 1653 |
+
progress_bar = st.progress(0.0)
|
| 1654 |
+
status_text = st.empty()
|
| 1655 |
+
output_area = st.empty()
|
| 1656 |
+
|
| 1657 |
+
status_text.info("🚀 Starting evaluation...")
|
| 1658 |
+
output_lines = []
|
| 1659 |
+
|
| 1660 |
+
def log(msg):
|
| 1661 |
+
output_lines.append(msg)
|
| 1662 |
+
output_area.code("\n".join(output_lines[-50:]), language="text")
|
| 1663 |
+
|
| 1664 |
+
try:
|
| 1665 |
+
url, api_key = get_qdrant_credentials()
|
| 1666 |
+
if not url:
|
| 1667 |
+
st.error("QDRANT_URL not configured")
|
| 1668 |
+
return
|
| 1669 |
+
|
| 1670 |
+
datasets = config.get("datasets", [])
|
| 1671 |
+
collection = config["collection"]
|
| 1672 |
+
model = config.get("model", "vidore/colpali-v1.3")
|
| 1673 |
+
mode = config.get("mode", "single_full")
|
| 1674 |
+
top_k = config.get("top_k", 100)
|
| 1675 |
+
prefetch_k = config.get("prefetch_k", 256)
|
| 1676 |
+
stage1_mode = config.get("stage1_mode", "tokens_vs_standard_pooling")
|
| 1677 |
+
stage1_k = config.get("stage1_k", 1000)
|
| 1678 |
+
stage2_k = config.get("stage2_k", 300)
|
| 1679 |
+
_evaluation_scope = config.get("evaluation_scope", "union")
|
| 1680 |
+
prefer_grpc = config.get("prefer_grpc", True)
|
| 1681 |
+
torch_dtype = config.get("torch_dtype", "float16")
|
| 1682 |
+
|
| 1683 |
+
log(f"[Eval] Model: {model}")
|
| 1684 |
+
log(f"[Eval] Collection: {collection}")
|
| 1685 |
+
log(f"[Eval] Mode: {mode}")
|
| 1686 |
+
log(f"[Eval] Datasets: {datasets}")
|
| 1687 |
+
status_text.info("📦 Loading embedder...")
|
| 1688 |
+
|
| 1689 |
+
embedder = VisualEmbedder(model_name=model, torch_dtype=torch_dtype)
|
| 1690 |
+
log("[Eval] Embedder loaded")
|
| 1691 |
+
|
| 1692 |
+
status_text.info("🔌 Connecting to Qdrant...")
|
| 1693 |
+
retriever = MultiVectorRetriever(
|
| 1694 |
+
collection_name=collection,
|
| 1695 |
+
model_name=model,
|
| 1696 |
+
qdrant_url=url,
|
| 1697 |
+
qdrant_api_key=api_key,
|
| 1698 |
+
prefer_grpc=prefer_grpc,
|
| 1699 |
+
embedder=embedder,
|
| 1700 |
+
)
|
| 1701 |
+
log("[Eval] Retriever connected")
|
| 1702 |
+
|
| 1703 |
+
all_queries = []
|
| 1704 |
+
all_qrels: Dict[str, Dict[str, int]] = {}
|
| 1705 |
+
|
| 1706 |
+
for ds_name in datasets:
|
| 1707 |
+
status_text.info(f"📚 Loading dataset: {ds_name}")
|
| 1708 |
+
corpus, queries, qrels = load_vidore_beir_dataset(ds_name)
|
| 1709 |
+
all_queries.extend(queries)
|
| 1710 |
+
for qid, rels in qrels.items():
|
| 1711 |
+
all_qrels[qid] = rels
|
| 1712 |
+
log(f"[Eval] Loaded {ds_name}: {len(corpus)} docs, {len(queries)} queries")
|
| 1713 |
+
|
| 1714 |
+
total_queries = len(all_queries)
|
| 1715 |
+
log(f"[Eval] Total queries to evaluate: {total_queries}")
|
| 1716 |
+
|
| 1717 |
+
status_text.info(f"🔍 Embedding {total_queries} queries...")
|
| 1718 |
+
query_texts = [q.text for q in all_queries]
|
| 1719 |
+
query_embeddings = embedder.embed_queries(query_texts, show_progress=False)
|
| 1720 |
+
log("[Eval] Queries embedded")
|
| 1721 |
+
|
| 1722 |
+
ndcg10_vals = []
|
| 1723 |
+
recall10_vals = []
|
| 1724 |
+
mrr10_vals = []
|
| 1725 |
+
latencies = []
|
| 1726 |
+
|
| 1727 |
+
status_text.info("🎯 Running evaluation...")
|
| 1728 |
+
|
| 1729 |
+
for i, (q, qemb) in enumerate(zip(all_queries, query_embeddings)):
|
| 1730 |
+
start = time.time()
|
| 1731 |
+
|
| 1732 |
+
try:
|
| 1733 |
+
import torch
|
| 1734 |
+
|
| 1735 |
+
if isinstance(qemb, torch.Tensor):
|
| 1736 |
+
qemb_np = qemb.detach().cpu().numpy()
|
| 1737 |
+
else:
|
| 1738 |
+
qemb_np = qemb.numpy()
|
| 1739 |
+
except ImportError:
|
| 1740 |
+
qemb_np = qemb.numpy()
|
| 1741 |
+
|
| 1742 |
+
results = retriever.search_embedded(
|
| 1743 |
+
query_embedding=qemb_np,
|
| 1744 |
+
top_k=max(100, top_k),
|
| 1745 |
+
mode=mode,
|
| 1746 |
+
prefetch_k=prefetch_k,
|
| 1747 |
+
stage1_mode=stage1_mode,
|
| 1748 |
+
stage1_k=stage1_k,
|
| 1749 |
+
stage2_k=stage2_k,
|
| 1750 |
+
)
|
| 1751 |
+
latencies.append((time.time() - start) * 1000)
|
| 1752 |
+
|
| 1753 |
+
ranking = [str(r["id"]) for r in results]
|
| 1754 |
+
rels = all_qrels.get(q.query_id, {})
|
| 1755 |
+
|
| 1756 |
+
ndcg10_vals.append(ndcg_at_k(ranking, rels, k=10))
|
| 1757 |
+
recall10_vals.append(recall_at_k(ranking, rels, k=10))
|
| 1758 |
+
mrr10_vals.append(mrr_at_k(ranking, rels, k=10))
|
| 1759 |
+
|
| 1760 |
+
progress = (i + 1) / total_queries
|
| 1761 |
+
progress_bar.progress(progress)
|
| 1762 |
+
status_text.info(f"🎯 Evaluating... {i+1}/{total_queries} ({int(progress*100)}%)")
|
| 1763 |
+
|
| 1764 |
+
if (i + 1) % 20 == 0:
|
| 1765 |
+
log(f"[Eval] Progress: {i+1}/{total_queries} queries")
|
| 1766 |
+
|
| 1767 |
+
progress_bar.progress(1.0)
|
| 1768 |
+
status_text.success("✅ Evaluation complete!")
|
| 1769 |
+
|
| 1770 |
+
final_metrics = {
|
| 1771 |
+
"ndcg@10": float(np.mean(ndcg10_vals)),
|
| 1772 |
+
"recall@10": float(np.mean(recall10_vals)),
|
| 1773 |
+
"mrr@10": float(np.mean(mrr10_vals)),
|
| 1774 |
+
"avg_latency_ms": float(np.mean(latencies)),
|
| 1775 |
+
"num_queries": total_queries,
|
| 1776 |
+
}
|
| 1777 |
+
|
| 1778 |
+
log("")
|
| 1779 |
+
log("=" * 40)
|
| 1780 |
+
log("RESULTS:")
|
| 1781 |
+
log(f" NDCG@10: {final_metrics['ndcg@10']:.4f}")
|
| 1782 |
+
log(f" Recall@10: {final_metrics['recall@10']:.4f}")
|
| 1783 |
+
log(f" MRR@10: {final_metrics['mrr@10']:.4f}")
|
| 1784 |
+
log(f" Avg Latency: {final_metrics['avg_latency_ms']:.1f}ms")
|
| 1785 |
+
log("=" * 40)
|
| 1786 |
+
|
| 1787 |
+
st.json(final_metrics)
|
| 1788 |
+
st.session_state["last_eval_metrics"] = final_metrics
|
| 1789 |
+
|
| 1790 |
+
except Exception as e:
|
| 1791 |
+
status_text.error(f"❌ Error: {e}")
|
| 1792 |
+
log(f"ERROR: {e}")
|
| 1793 |
+
log(traceback.format_exc())
|
| 1794 |
+
finally:
|
| 1795 |
+
st.session_state["bench_running"] = False
|
| 1796 |
+
|
| 1797 |
+
|
| 1798 |
+
def run_indexing_with_ui(config: Dict[str, Any]):
|
| 1799 |
+
st.divider()
|
| 1800 |
+
|
| 1801 |
+
progress_bar = st.progress(0.0)
|
| 1802 |
+
status_text = st.empty()
|
| 1803 |
+
output_area = st.empty()
|
| 1804 |
+
|
| 1805 |
+
status_text.info("🚀 Starting indexing...")
|
| 1806 |
+
output_lines = []
|
| 1807 |
+
|
| 1808 |
+
def log(msg):
|
| 1809 |
+
output_lines.append(msg)
|
| 1810 |
+
output_area.code("\n".join(output_lines[-50:]), language="text")
|
| 1811 |
+
|
| 1812 |
+
try:
|
| 1813 |
+
url, api_key = get_qdrant_credentials()
|
| 1814 |
+
if not url:
|
| 1815 |
+
st.error("QDRANT_URL not configured")
|
| 1816 |
+
return
|
| 1817 |
+
|
| 1818 |
+
datasets = config.get("datasets", [])
|
| 1819 |
+
collection = config["collection"]
|
| 1820 |
+
model = config.get("model", "vidore/colpali-v1.3")
|
| 1821 |
+
recreate = config.get("recreate", False)
|
| 1822 |
+
torch_dtype = config.get("torch_dtype", "float16")
|
| 1823 |
+
qdrant_vector_dtype = config.get("qdrant_vector_dtype", "float16")
|
| 1824 |
+
prefer_grpc = config.get("prefer_grpc", True)
|
| 1825 |
+
batch_size = config.get("batch_size", 4)
|
| 1826 |
+
|
| 1827 |
+
log(f"[Index] Model: {model}")
|
| 1828 |
+
log(f"[Index] Collection: {collection}")
|
| 1829 |
+
log(f"[Index] Datasets: {datasets}")
|
| 1830 |
+
status_text.info("📦 Loading embedder...")
|
| 1831 |
+
|
| 1832 |
+
embedder = VisualEmbedder(model_name=model, torch_dtype=torch_dtype)
|
| 1833 |
+
log("[Index] Embedder loaded")
|
| 1834 |
+
|
| 1835 |
+
status_text.info("🔌 Connecting to Qdrant...")
|
| 1836 |
+
indexer = QdrantIndexer(
|
| 1837 |
+
url=url,
|
| 1838 |
+
api_key=api_key,
|
| 1839 |
+
collection_name=collection,
|
| 1840 |
+
prefer_grpc=prefer_grpc,
|
| 1841 |
+
vector_datatype=qdrant_vector_dtype,
|
| 1842 |
+
)
|
| 1843 |
+
log("[Index] Connected to Qdrant")
|
| 1844 |
+
|
| 1845 |
+
status_text.info("📦 Creating collection...")
|
| 1846 |
+
indexer.create_collection(force_recreate=recreate)
|
| 1847 |
+
indexer.create_payload_indexes(
|
| 1848 |
+
fields=[
|
| 1849 |
+
{"field": "dataset", "type": "keyword"},
|
| 1850 |
+
{"field": "doc_id", "type": "keyword"},
|
| 1851 |
+
]
|
| 1852 |
+
)
|
| 1853 |
+
log(f"[Index] Collection '{collection}' ready")
|
| 1854 |
+
|
| 1855 |
+
total_uploaded = 0
|
| 1856 |
+
|
| 1857 |
+
for ds_name in datasets:
|
| 1858 |
+
status_text.info(f"📚 Loading dataset: {ds_name}")
|
| 1859 |
+
corpus, queries, qrels = load_vidore_beir_dataset(ds_name)
|
| 1860 |
+
log(f"[Index] Loaded {ds_name}: {len(corpus)} documents")
|
| 1861 |
+
|
| 1862 |
+
for i in range(0, len(corpus), batch_size):
|
| 1863 |
+
batch = corpus[i : i + batch_size]
|
| 1864 |
+
images = [doc.image for doc in batch if hasattr(doc, "image") and doc.image]
|
| 1865 |
+
|
| 1866 |
+
if not images:
|
| 1867 |
+
continue
|
| 1868 |
+
|
| 1869 |
+
status_text.info(f"🎨 Embedding batch {i//batch_size + 1}...")
|
| 1870 |
+
embeddings = embedder.embed_images(images)
|
| 1871 |
+
|
| 1872 |
+
points = []
|
| 1873 |
+
for j, (doc, emb) in enumerate(zip(batch, embeddings)):
|
| 1874 |
+
doc_id = doc.doc_id if hasattr(doc, "doc_id") else str(i + j)
|
| 1875 |
+
emb_np = emb.cpu().numpy() if hasattr(emb, "cpu") else np.array(emb)
|
| 1876 |
+
tile_pooled = emb_np.reshape(-1, 4, emb_np.shape[-1]).mean(axis=1)
|
| 1877 |
+
global_pooled = emb_np.mean(axis=0)
|
| 1878 |
+
|
| 1879 |
+
points.append(
|
| 1880 |
+
{
|
| 1881 |
+
"id": f"{ds_name}_{doc_id}".replace("/", "_"),
|
| 1882 |
+
"visual_embedding": emb_np,
|
| 1883 |
+
"tile_pooled_embedding": tile_pooled,
|
| 1884 |
+
"experimental_pooled_embedding": tile_pooled,
|
| 1885 |
+
"global_pooled_embedding": global_pooled,
|
| 1886 |
+
"metadata": {"dataset": ds_name, "doc_id": doc_id},
|
| 1887 |
+
}
|
| 1888 |
+
)
|
| 1889 |
+
|
| 1890 |
+
indexer.upload_batch(points)
|
| 1891 |
+
total_uploaded += len(points)
|
| 1892 |
+
|
| 1893 |
+
progress = (i + len(batch)) / len(corpus)
|
| 1894 |
+
progress_bar.progress(progress)
|
| 1895 |
+
log(f"[Index] Uploaded {total_uploaded} points")
|
| 1896 |
+
|
| 1897 |
+
progress_bar.progress(1.0)
|
| 1898 |
+
status_text.success(f"✅ Indexing complete! {total_uploaded} documents indexed.")
|
| 1899 |
+
|
| 1900 |
+
except Exception as e:
|
| 1901 |
+
status_text.error(f"❌ Error: {e}")
|
| 1902 |
+
log(f"ERROR: {e}")
|
| 1903 |
+
log(traceback.format_exc())
|
| 1904 |
+
|
| 1905 |
+
|
| 1906 |
+
def render_benchmark_results():
|
| 1907 |
+
st.markdown("##### Load Results")
|
| 1908 |
+
|
| 1909 |
+
available = get_available_results()
|
| 1910 |
+
|
| 1911 |
+
if not available:
|
| 1912 |
+
st.info("No results found")
|
| 1913 |
+
return
|
| 1914 |
+
|
| 1915 |
+
default_select = []
|
| 1916 |
+
if st.session_state.get("auto_select_result"):
|
| 1917 |
+
auto = st.session_state.pop("auto_select_result")
|
| 1918 |
+
if auto in [str(p) for p in available]:
|
| 1919 |
+
default_select = [auto]
|
| 1920 |
+
|
| 1921 |
+
selected = st.multiselect(
|
| 1922 |
+
"Result files",
|
| 1923 |
+
options=[str(p) for p in available],
|
| 1924 |
+
format_func=lambda x: Path(x).name[:60],
|
| 1925 |
+
default=default_select,
|
| 1926 |
+
key="br_files",
|
| 1927 |
+
)
|
| 1928 |
+
|
| 1929 |
+
for path in selected:
|
| 1930 |
+
data = load_results_file(Path(path))
|
| 1931 |
+
if data:
|
| 1932 |
+
render_result_card(data, Path(path).name)
|
| 1933 |
+
|
| 1934 |
+
|
| 1935 |
+
def render_result_card(data: Dict[str, Any], filename: str):
|
| 1936 |
+
with st.expander(f"📊 {filename[:50]}", expanded=True):
|
| 1937 |
+
c1, c2, c3, c4 = st.columns(4)
|
| 1938 |
+
c1.metric("Model", (data.get("model") or "?").split("/")[-1])
|
| 1939 |
+
c2.metric("Mode", data.get("mode", "?"))
|
| 1940 |
+
c3.metric("Top K", data.get("top_k", "?"))
|
| 1941 |
+
c4.metric("Time", f"{data.get('eval_wall_time_s', 0):.0f}s")
|
| 1942 |
+
|
| 1943 |
+
metrics = data.get("metrics_by_dataset", {})
|
| 1944 |
+
if not metrics:
|
| 1945 |
+
st.warning("No metrics data")
|
| 1946 |
+
return
|
| 1947 |
+
|
| 1948 |
+
rows = []
|
| 1949 |
+
for ds, m in metrics.items():
|
| 1950 |
+
rows.append(
|
| 1951 |
+
{
|
| 1952 |
+
"Dataset": ds.split("/")[-1].replace("_v2", ""),
|
| 1953 |
+
"NDCG@5": m.get("ndcg@5", 0),
|
| 1954 |
+
"NDCG@10": m.get("ndcg@10", 0),
|
| 1955 |
+
"Recall@5": m.get("recall@5", 0),
|
| 1956 |
+
"Recall@10": m.get("recall@10", 0),
|
| 1957 |
+
"MRR@10": m.get("mrr@10", 0),
|
| 1958 |
+
"Latency": m.get("avg_latency_ms", 0),
|
| 1959 |
+
"QPS": m.get("qps", 0),
|
| 1960 |
+
}
|
| 1961 |
+
)
|
| 1962 |
+
|
| 1963 |
+
df = pd.DataFrame(rows)
|
| 1964 |
+
|
| 1965 |
+
st.dataframe(
|
| 1966 |
+
df.style.format(
|
| 1967 |
+
{
|
| 1968 |
+
"NDCG@5": "{:.4f}",
|
| 1969 |
+
"NDCG@10": "{:.4f}",
|
| 1970 |
+
"Recall@5": "{:.4f}",
|
| 1971 |
+
"Recall@10": "{:.4f}",
|
| 1972 |
+
"MRR@10": "{:.4f}",
|
| 1973 |
+
"Latency": "{:.1f}",
|
| 1974 |
+
"QPS": "{:.2f}",
|
| 1975 |
+
}
|
| 1976 |
+
),
|
| 1977 |
+
hide_index=True,
|
| 1978 |
+
use_container_width=True,
|
| 1979 |
+
)
|
| 1980 |
+
|
| 1981 |
+
chart_data = []
|
| 1982 |
+
for ds, m in metrics.items():
|
| 1983 |
+
ds_short = ds.split("/")[-1].replace("_v2", "").replace("_", " ").title()
|
| 1984 |
+
chart_data.append(
|
| 1985 |
+
{"Dataset": ds_short, "Metric": "NDCG@10", "Value": m.get("ndcg@10", 0)}
|
| 1986 |
+
)
|
| 1987 |
+
chart_data.append(
|
| 1988 |
+
{"Dataset": ds_short, "Metric": "Recall@10", "Value": m.get("recall@10", 0)}
|
| 1989 |
+
)
|
| 1990 |
+
chart_data.append(
|
| 1991 |
+
{"Dataset": ds_short, "Metric": "MRR@10", "Value": m.get("mrr@10", 0)}
|
| 1992 |
+
)
|
| 1993 |
+
|
| 1994 |
+
chart_df = pd.DataFrame(chart_data)
|
| 1995 |
+
|
| 1996 |
+
chart = (
|
| 1997 |
+
alt.Chart(chart_df)
|
| 1998 |
+
.mark_bar()
|
| 1999 |
+
.encode(
|
| 2000 |
+
x=alt.X("Dataset:N", title=None),
|
| 2001 |
+
y=alt.Y("Value:Q", scale=alt.Scale(domain=[0, 1]), title="Score"),
|
| 2002 |
+
color=alt.Color("Metric:N", scale=alt.Scale(scheme="tableau10")),
|
| 2003 |
+
xOffset="Metric:N",
|
| 2004 |
+
tooltip=["Dataset", "Metric", alt.Tooltip("Value:Q", format=".4f")],
|
| 2005 |
+
)
|
| 2006 |
+
.properties(height=300, title="Metrics by Dataset")
|
| 2007 |
+
)
|
| 2008 |
+
|
| 2009 |
+
st.altair_chart(chart, use_container_width=True)
|
| 2010 |
+
|
| 2011 |
+
latency_data = [
|
| 2012 |
+
{
|
| 2013 |
+
"Dataset": ds.split("/")[-1].replace("_v2", ""),
|
| 2014 |
+
"Latency (ms)": m.get("avg_latency_ms", 0),
|
| 2015 |
+
"QPS": m.get("qps", 0),
|
| 2016 |
+
}
|
| 2017 |
+
for ds, m in metrics.items()
|
| 2018 |
+
]
|
| 2019 |
+
latency_df = pd.DataFrame(latency_data)
|
| 2020 |
+
|
| 2021 |
+
c1, c2 = st.columns(2)
|
| 2022 |
+
with c1:
|
| 2023 |
+
lat_chart = (
|
| 2024 |
+
alt.Chart(latency_df)
|
| 2025 |
+
.mark_bar(color="#ff6b6b")
|
| 2026 |
+
.encode(
|
| 2027 |
+
x=alt.X("Dataset:N"),
|
| 2028 |
+
y=alt.Y("Latency (ms):Q"),
|
| 2029 |
+
tooltip=["Dataset", alt.Tooltip("Latency (ms):Q", format=".1f")],
|
| 2030 |
+
)
|
| 2031 |
+
.properties(height=200, title="Avg Latency")
|
| 2032 |
+
)
|
| 2033 |
+
st.altair_chart(lat_chart, use_container_width=True)
|
| 2034 |
+
|
| 2035 |
+
with c2:
|
| 2036 |
+
qps_chart = (
|
| 2037 |
+
alt.Chart(latency_df)
|
| 2038 |
+
.mark_bar(color="#4ecdc4")
|
| 2039 |
+
.encode(
|
| 2040 |
+
x=alt.X("Dataset:N"),
|
| 2041 |
+
y=alt.Y("QPS:Q"),
|
| 2042 |
+
tooltip=["Dataset", alt.Tooltip("QPS:Q", format=".2f")],
|
| 2043 |
+
)
|
| 2044 |
+
.properties(height=200, title="QPS (Queries/sec)")
|
| 2045 |
+
)
|
| 2046 |
+
st.altair_chart(qps_chart, use_container_width=True)
|
| 2047 |
+
|
| 2048 |
+
|
| 2049 |
+
def main():
|
| 2050 |
+
render_header()
|
| 2051 |
+
render_sidebar()
|
| 2052 |
+
|
| 2053 |
+
tab_upload, tab_playground, tab_benchmark = st.tabs(
|
| 2054 |
+
["📤 Upload", "🎮 Playground", "📊 Benchmarking"]
|
| 2055 |
+
)
|
| 2056 |
+
|
| 2057 |
+
with tab_upload:
|
| 2058 |
+
render_upload_tab()
|
| 2059 |
+
|
| 2060 |
+
with tab_playground:
|
| 2061 |
+
render_playground_tab()
|
| 2062 |
+
|
| 2063 |
+
with tab_benchmark:
|
| 2064 |
+
render_benchmark_tab()
|
| 2065 |
+
|
| 2066 |
+
|
| 2067 |
+
if __name__ == "__main__":
|
| 2068 |
+
main()
|
examples/COMMANDS.md
CHANGED
|
@@ -57,7 +57,7 @@ python -m benchmarks.vidore_tatdqa_test.sweep_eval \
|
|
| 57 |
--collection vidore_tatdqa_test \
|
| 58 |
--prefer-grpc \
|
| 59 |
--mode two_stage \
|
| 60 |
-
--stage1-mode
|
| 61 |
--prefetch-ks 20,50,100,200,400 \
|
| 62 |
--torch-dtype auto \
|
| 63 |
--query-batch-size 32 \
|
|
@@ -80,4 +80,98 @@ python -m benchmarks.vidore_tatdqa_test.sweep_eval \
|
|
| 80 |
--out-dir results/sweeps
|
| 81 |
```
|
| 82 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
|
|
|
|
| 57 |
--collection vidore_tatdqa_test \
|
| 58 |
--prefer-grpc \
|
| 59 |
--mode two_stage \
|
| 60 |
+
--stage1-mode tokens_vs_standard_pooling \
|
| 61 |
--prefetch-ks 20,50,100,200,400 \
|
| 62 |
--torch-dtype auto \
|
| 63 |
--query-batch-size 32 \
|
|
|
|
| 80 |
--out-dir results/sweeps
|
| 81 |
```
|
| 82 |
|
| 83 |
+
---
|
| 84 |
+
|
| 85 |
+
# ViDoRe v2 BEIR datasets (Qdrant) — commands
|
| 86 |
+
|
| 87 |
+
This section indexes the **3 ViDoRe v2** datasets used in the demo UI:
|
| 88 |
+
|
| 89 |
+
- `vidore/esg_reports_v2`
|
| 90 |
+
- `vidore/biomedical_lectures_v2`
|
| 91 |
+
- `vidore/economics_reports_v2`
|
| 92 |
+
|
| 93 |
+
We use **`vidore/colqwen2.5-v0.2`**, **no cropping**, **no Cloudinary**, **gRPC**, and **float32** for both compute and stored vectors.
|
| 94 |
+
|
| 95 |
+
## Environment
|
| 96 |
+
|
| 97 |
+
```bash
|
| 98 |
+
export QDRANT_URL="https://YOUR_QDRANT_HOST:6333"
|
| 99 |
+
export QDRANT_API_KEY="YOUR_KEY" # optional for local Qdrant
|
| 100 |
+
```
|
| 101 |
+
|
| 102 |
+
Optional (recommended on machines with small disks):
|
| 103 |
+
|
| 104 |
+
```bash
|
| 105 |
+
export HF_HOME="$PWD/.cache/huggingface"
|
| 106 |
+
export TRANSFORMERS_CACHE="$PWD/.cache/huggingface"
|
| 107 |
+
```
|
| 108 |
+
|
| 109 |
+
## Index only (no evaluation)
|
| 110 |
+
|
| 111 |
+
```bash
|
| 112 |
+
python -m benchmarks.vidore_beir_qdrant.run_qdrant_beir \
|
| 113 |
+
--datasets \
|
| 114 |
+
vidore/esg_reports_v2 \
|
| 115 |
+
vidore/biomedical_lectures_v2 \
|
| 116 |
+
vidore/economics_reports_v2 \
|
| 117 |
+
--collection vidore_v2__colqwen25_fp32 \
|
| 118 |
+
--model vidore/colqwen2.5-v0.2 \
|
| 119 |
+
--index \
|
| 120 |
+
--recreate \
|
| 121 |
+
--indexing-threshold 0 \
|
| 122 |
+
--full-scan-threshold 0 \
|
| 123 |
+
--prefer-grpc \
|
| 124 |
+
--torch-dtype float32 \
|
| 125 |
+
--qdrant-vector-dtype float32 \
|
| 126 |
+
--batch-size 1 \
|
| 127 |
+
--upload-batch-size 4 \
|
| 128 |
+
--upload-workers 0 \
|
| 129 |
+
--no-cloudinary \
|
| 130 |
+
--no-eval
|
| 131 |
+
```
|
| 132 |
+
|
| 133 |
+
Notes:
|
| 134 |
+
- **`--batch-size 1`** is the safest starting point on Apple Silicon (MPS). Increase cautiously if stable.
|
| 135 |
+
- This does **not** enable cropping (we do **not** pass `--crop-empty`).
|
| 136 |
+
|
| 137 |
+
## Evaluate later (optional)
|
| 138 |
+
|
| 139 |
+
Single-stage full MaxSim:
|
| 140 |
+
|
| 141 |
+
```bash
|
| 142 |
+
python -m benchmarks.vidore_beir_qdrant.run_qdrant_beir \
|
| 143 |
+
--datasets \
|
| 144 |
+
vidore/esg_reports_v2 \
|
| 145 |
+
vidore/biomedical_lectures_v2 \
|
| 146 |
+
vidore/economics_reports_v2 \
|
| 147 |
+
--collection vidore_v2__colqwen25_fp32 \
|
| 148 |
+
--model vidore/colqwen2.5-v0.2 \
|
| 149 |
+
--prefer-grpc \
|
| 150 |
+
--torch-dtype float32 \
|
| 151 |
+
--qdrant-vector-dtype float32 \
|
| 152 |
+
--mode single_full \
|
| 153 |
+
--top-k 100 \
|
| 154 |
+
--evaluation-scope per_dataset
|
| 155 |
+
```
|
| 156 |
+
|
| 157 |
+
Two-stage (prefetch + rerank):
|
| 158 |
+
|
| 159 |
+
```bash
|
| 160 |
+
python -m benchmarks.vidore_beir_qdrant.run_qdrant_beir \
|
| 161 |
+
--datasets \
|
| 162 |
+
vidore/esg_reports_v2 \
|
| 163 |
+
vidore/biomedical_lectures_v2 \
|
| 164 |
+
vidore/economics_reports_v2 \
|
| 165 |
+
--collection vidore_v2__colqwen25_fp32 \
|
| 166 |
+
--model vidore/colqwen2.5-v0.2 \
|
| 167 |
+
--prefer-grpc \
|
| 168 |
+
--torch-dtype float32 \
|
| 169 |
+
--qdrant-vector-dtype float32 \
|
| 170 |
+
--mode two_stage \
|
| 171 |
+
--stage1-mode tokens_vs_experimental_pooling \
|
| 172 |
+
--prefetch-k 200 \
|
| 173 |
+
--top-k 100 \
|
| 174 |
+
--evaluation-scope per_dataset
|
| 175 |
+
```
|
| 176 |
+
|
| 177 |
|
examples/process_pdfs.py
CHANGED
|
@@ -10,18 +10,18 @@ This example demonstrates the full pipeline:
|
|
| 10 |
|
| 11 |
Usage:
|
| 12 |
python examples/process_pdfs.py --reports-dir /path/to/pdfs
|
| 13 |
-
|
| 14 |
# With metadata mapping
|
| 15 |
python examples/process_pdfs.py --reports-dir /path/to/pdfs --metadata-file metadata.json
|
| 16 |
-
|
| 17 |
# Without Cloudinary (local embeddings only)
|
| 18 |
python examples/process_pdfs.py --reports-dir /path/to/pdfs --no-cloudinary
|
| 19 |
"""
|
| 20 |
|
| 21 |
-
import os
|
| 22 |
-
import sys
|
| 23 |
import argparse
|
| 24 |
import logging
|
|
|
|
|
|
|
| 25 |
from pathlib import Path
|
| 26 |
|
| 27 |
from dotenv import load_dotenv
|
|
@@ -29,12 +29,11 @@ from dotenv import load_dotenv
|
|
| 29 |
# Add parent to path for development
|
| 30 |
sys.path.insert(0, str(Path(__file__).parent.parent))
|
| 31 |
|
| 32 |
-
from visual_rag import
|
| 33 |
-
from visual_rag.indexing.pipeline import ProcessingPipeline
|
| 34 |
|
| 35 |
logging.basicConfig(
|
| 36 |
-
level=logging.INFO,
|
| 37 |
-
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
| 38 |
)
|
| 39 |
logger = logging.getLogger(__name__)
|
| 40 |
|
|
@@ -42,97 +41,83 @@ logger = logging.getLogger(__name__)
|
|
| 42 |
def main():
|
| 43 |
parser = argparse.ArgumentParser(description="Process PDFs with Visual RAG Toolkit")
|
| 44 |
parser.add_argument(
|
| 45 |
-
"--reports-dir", type=str, required=True,
|
| 46 |
-
help="Directory containing PDF files"
|
| 47 |
-
)
|
| 48 |
-
parser.add_argument(
|
| 49 |
-
"--metadata-file", type=str,
|
| 50 |
-
help="JSON file with filename → metadata mapping (optional)"
|
| 51 |
-
)
|
| 52 |
-
parser.add_argument(
|
| 53 |
-
"--config", type=str, default="config.yaml",
|
| 54 |
-
help="Configuration file path"
|
| 55 |
-
)
|
| 56 |
-
parser.add_argument(
|
| 57 |
-
"--collection", type=str,
|
| 58 |
-
help="Qdrant collection name (overrides config)"
|
| 59 |
)
|
| 60 |
parser.add_argument(
|
| 61 |
-
"--
|
| 62 |
-
help="Model name (overrides config)"
|
| 63 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
parser.add_argument(
|
| 65 |
-
"--no-
|
| 66 |
-
help="Skip Cloudinary uploads"
|
| 67 |
)
|
| 68 |
parser.add_argument(
|
| 69 |
-
"--
|
| 70 |
-
|
|
|
|
|
|
|
| 71 |
)
|
| 72 |
-
parser.add_argument(
|
| 73 |
-
|
| 74 |
-
help="Skip pages that already exist in Qdrant (default: True)"
|
| 75 |
-
)
|
| 76 |
-
parser.add_argument(
|
| 77 |
-
"--force", action="store_true",
|
| 78 |
-
help="Process all pages even if they exist"
|
| 79 |
-
)
|
| 80 |
-
|
| 81 |
args = parser.parse_args()
|
| 82 |
-
|
| 83 |
# Load environment variables
|
| 84 |
load_dotenv()
|
| 85 |
-
|
| 86 |
# Load configuration
|
| 87 |
config = load_config(args.config)
|
| 88 |
-
|
| 89 |
# Get PDFs
|
| 90 |
reports_dir = Path(args.reports_dir)
|
| 91 |
if not reports_dir.exists():
|
| 92 |
logger.error(f"Reports directory not found: {reports_dir}")
|
| 93 |
sys.exit(1)
|
| 94 |
-
|
| 95 |
pdf_paths = sorted(reports_dir.glob("*.pdf")) + sorted(reports_dir.glob("*.PDF"))
|
| 96 |
if not pdf_paths:
|
| 97 |
logger.error(f"No PDF files found in: {reports_dir}")
|
| 98 |
sys.exit(1)
|
| 99 |
-
|
| 100 |
logger.info(f"📁 Found {len(pdf_paths)} PDF files in {reports_dir}")
|
| 101 |
-
|
| 102 |
# Load metadata mapping if provided
|
| 103 |
metadata_mapping = {}
|
| 104 |
if args.metadata_file:
|
| 105 |
metadata_mapping = ProcessingPipeline.load_metadata_mapping(Path(args.metadata_file))
|
| 106 |
-
|
| 107 |
# Get settings
|
| 108 |
model_name = args.model or config.get("model", {}).get("name", "vidore/colSmol-500M")
|
| 109 |
-
collection_name = args.collection or config.get("qdrant", {}).get(
|
| 110 |
-
|
|
|
|
|
|
|
| 111 |
# Initialize embedder
|
| 112 |
logger.info(f"🤖 Initializing embedder: {model_name}")
|
| 113 |
embedder = VisualEmbedder(model_name=model_name)
|
| 114 |
-
|
| 115 |
# Initialize Qdrant indexer (if not skipped)
|
| 116 |
indexer = None
|
| 117 |
if not args.no_qdrant:
|
| 118 |
qdrant_url = os.getenv("QDRANT_URL")
|
| 119 |
qdrant_api_key = os.getenv("QDRANT_API_KEY")
|
| 120 |
-
|
| 121 |
if not qdrant_url:
|
| 122 |
logger.error("QDRANT_URL environment variable not set")
|
| 123 |
sys.exit(1)
|
| 124 |
-
|
| 125 |
logger.info(f"🔌 Connecting to Qdrant: {qdrant_url}")
|
| 126 |
indexer = QdrantIndexer(
|
| 127 |
url=qdrant_url,
|
| 128 |
api_key=qdrant_api_key,
|
| 129 |
collection_name=collection_name,
|
| 130 |
)
|
| 131 |
-
|
| 132 |
# Create collection if needed
|
| 133 |
indexer.create_collection()
|
| 134 |
indexer.create_payload_indexes()
|
| 135 |
-
|
| 136 |
# Initialize Cloudinary uploader (if not skipped)
|
| 137 |
cloudinary_uploader = None
|
| 138 |
if not args.no_cloudinary:
|
|
@@ -143,7 +128,7 @@ def main():
|
|
| 143 |
except ValueError as e:
|
| 144 |
logger.warning(f"Cloudinary not configured: {e}")
|
| 145 |
logger.warning("Continuing without Cloudinary uploads")
|
| 146 |
-
|
| 147 |
# Create pipeline
|
| 148 |
pipeline = ProcessingPipeline(
|
| 149 |
embedder=embedder,
|
|
@@ -152,39 +137,39 @@ def main():
|
|
| 152 |
metadata_mapping=metadata_mapping,
|
| 153 |
config=config,
|
| 154 |
)
|
| 155 |
-
|
| 156 |
# Process PDFs
|
| 157 |
total_uploaded = 0
|
| 158 |
total_skipped = 0
|
| 159 |
total_failed = 0
|
| 160 |
-
|
| 161 |
skip_existing = args.skip_existing and not args.force
|
| 162 |
-
|
| 163 |
for pdf_idx, pdf_path in enumerate(pdf_paths, 1):
|
| 164 |
logger.info(f"\n{'='*60}")
|
| 165 |
logger.info(f"📄 [{pdf_idx}/{len(pdf_paths)}] {pdf_path.name}")
|
| 166 |
logger.info(f"{'='*60}")
|
| 167 |
-
|
| 168 |
result = pipeline.process_pdf(
|
| 169 |
pdf_path,
|
| 170 |
skip_existing=skip_existing,
|
| 171 |
upload_to_cloudinary=(not args.no_cloudinary),
|
| 172 |
upload_to_qdrant=(not args.no_qdrant),
|
| 173 |
)
|
| 174 |
-
|
| 175 |
total_uploaded += result["uploaded"]
|
| 176 |
total_skipped += result["skipped"]
|
| 177 |
total_failed += result["failed"]
|
| 178 |
-
|
| 179 |
# Summary
|
| 180 |
logger.info(f"\n{'='*60}")
|
| 181 |
-
logger.info(
|
| 182 |
logger.info(f"{'='*60}")
|
| 183 |
logger.info(f" Total PDFs: {len(pdf_paths)}")
|
| 184 |
logger.info(f" Uploaded: {total_uploaded}")
|
| 185 |
logger.info(f" Skipped: {total_skipped}")
|
| 186 |
logger.info(f" Failed: {total_failed}")
|
| 187 |
-
|
| 188 |
if indexer:
|
| 189 |
info = indexer.get_collection_info()
|
| 190 |
if info:
|
|
@@ -193,10 +178,3 @@ def main():
|
|
| 193 |
|
| 194 |
if __name__ == "__main__":
|
| 195 |
main()
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
|
|
|
| 10 |
|
| 11 |
Usage:
|
| 12 |
python examples/process_pdfs.py --reports-dir /path/to/pdfs
|
| 13 |
+
|
| 14 |
# With metadata mapping
|
| 15 |
python examples/process_pdfs.py --reports-dir /path/to/pdfs --metadata-file metadata.json
|
| 16 |
+
|
| 17 |
# Without Cloudinary (local embeddings only)
|
| 18 |
python examples/process_pdfs.py --reports-dir /path/to/pdfs --no-cloudinary
|
| 19 |
"""
|
| 20 |
|
|
|
|
|
|
|
| 21 |
import argparse
|
| 22 |
import logging
|
| 23 |
+
import os
|
| 24 |
+
import sys
|
| 25 |
from pathlib import Path
|
| 26 |
|
| 27 |
from dotenv import load_dotenv
|
|
|
|
| 29 |
# Add parent to path for development
|
| 30 |
sys.path.insert(0, str(Path(__file__).parent.parent))
|
| 31 |
|
| 32 |
+
from visual_rag import CloudinaryUploader, QdrantIndexer, VisualEmbedder, load_config # noqa: E402
|
| 33 |
+
from visual_rag.indexing.pipeline import ProcessingPipeline # noqa: E402
|
| 34 |
|
| 35 |
logging.basicConfig(
|
| 36 |
+
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
|
|
|
| 37 |
)
|
| 38 |
logger = logging.getLogger(__name__)
|
| 39 |
|
|
|
|
| 41 |
def main():
|
| 42 |
parser = argparse.ArgumentParser(description="Process PDFs with Visual RAG Toolkit")
|
| 43 |
parser.add_argument(
|
| 44 |
+
"--reports-dir", type=str, required=True, help="Directory containing PDF files"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
)
|
| 46 |
parser.add_argument(
|
| 47 |
+
"--metadata-file", type=str, help="JSON file with filename → metadata mapping (optional)"
|
|
|
|
| 48 |
)
|
| 49 |
+
parser.add_argument("--config", type=str, default="config.yaml", help="Configuration file path")
|
| 50 |
+
parser.add_argument("--collection", type=str, help="Qdrant collection name (overrides config)")
|
| 51 |
+
parser.add_argument("--model", type=str, help="Model name (overrides config)")
|
| 52 |
+
parser.add_argument("--no-cloudinary", action="store_true", help="Skip Cloudinary uploads")
|
| 53 |
parser.add_argument(
|
| 54 |
+
"--no-qdrant", action="store_true", help="Skip Qdrant uploads (just generate embeddings)"
|
|
|
|
| 55 |
)
|
| 56 |
parser.add_argument(
|
| 57 |
+
"--skip-existing",
|
| 58 |
+
action="store_true",
|
| 59 |
+
default=True,
|
| 60 |
+
help="Skip pages that already exist in Qdrant (default: True)",
|
| 61 |
)
|
| 62 |
+
parser.add_argument("--force", action="store_true", help="Process all pages even if they exist")
|
| 63 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
args = parser.parse_args()
|
| 65 |
+
|
| 66 |
# Load environment variables
|
| 67 |
load_dotenv()
|
| 68 |
+
|
| 69 |
# Load configuration
|
| 70 |
config = load_config(args.config)
|
| 71 |
+
|
| 72 |
# Get PDFs
|
| 73 |
reports_dir = Path(args.reports_dir)
|
| 74 |
if not reports_dir.exists():
|
| 75 |
logger.error(f"Reports directory not found: {reports_dir}")
|
| 76 |
sys.exit(1)
|
| 77 |
+
|
| 78 |
pdf_paths = sorted(reports_dir.glob("*.pdf")) + sorted(reports_dir.glob("*.PDF"))
|
| 79 |
if not pdf_paths:
|
| 80 |
logger.error(f"No PDF files found in: {reports_dir}")
|
| 81 |
sys.exit(1)
|
| 82 |
+
|
| 83 |
logger.info(f"📁 Found {len(pdf_paths)} PDF files in {reports_dir}")
|
| 84 |
+
|
| 85 |
# Load metadata mapping if provided
|
| 86 |
metadata_mapping = {}
|
| 87 |
if args.metadata_file:
|
| 88 |
metadata_mapping = ProcessingPipeline.load_metadata_mapping(Path(args.metadata_file))
|
| 89 |
+
|
| 90 |
# Get settings
|
| 91 |
model_name = args.model or config.get("model", {}).get("name", "vidore/colSmol-500M")
|
| 92 |
+
collection_name = args.collection or config.get("qdrant", {}).get(
|
| 93 |
+
"collection_name", "visual_documents"
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
# Initialize embedder
|
| 97 |
logger.info(f"🤖 Initializing embedder: {model_name}")
|
| 98 |
embedder = VisualEmbedder(model_name=model_name)
|
| 99 |
+
|
| 100 |
# Initialize Qdrant indexer (if not skipped)
|
| 101 |
indexer = None
|
| 102 |
if not args.no_qdrant:
|
| 103 |
qdrant_url = os.getenv("QDRANT_URL")
|
| 104 |
qdrant_api_key = os.getenv("QDRANT_API_KEY")
|
| 105 |
+
|
| 106 |
if not qdrant_url:
|
| 107 |
logger.error("QDRANT_URL environment variable not set")
|
| 108 |
sys.exit(1)
|
| 109 |
+
|
| 110 |
logger.info(f"🔌 Connecting to Qdrant: {qdrant_url}")
|
| 111 |
indexer = QdrantIndexer(
|
| 112 |
url=qdrant_url,
|
| 113 |
api_key=qdrant_api_key,
|
| 114 |
collection_name=collection_name,
|
| 115 |
)
|
| 116 |
+
|
| 117 |
# Create collection if needed
|
| 118 |
indexer.create_collection()
|
| 119 |
indexer.create_payload_indexes()
|
| 120 |
+
|
| 121 |
# Initialize Cloudinary uploader (if not skipped)
|
| 122 |
cloudinary_uploader = None
|
| 123 |
if not args.no_cloudinary:
|
|
|
|
| 128 |
except ValueError as e:
|
| 129 |
logger.warning(f"Cloudinary not configured: {e}")
|
| 130 |
logger.warning("Continuing without Cloudinary uploads")
|
| 131 |
+
|
| 132 |
# Create pipeline
|
| 133 |
pipeline = ProcessingPipeline(
|
| 134 |
embedder=embedder,
|
|
|
|
| 137 |
metadata_mapping=metadata_mapping,
|
| 138 |
config=config,
|
| 139 |
)
|
| 140 |
+
|
| 141 |
# Process PDFs
|
| 142 |
total_uploaded = 0
|
| 143 |
total_skipped = 0
|
| 144 |
total_failed = 0
|
| 145 |
+
|
| 146 |
skip_existing = args.skip_existing and not args.force
|
| 147 |
+
|
| 148 |
for pdf_idx, pdf_path in enumerate(pdf_paths, 1):
|
| 149 |
logger.info(f"\n{'='*60}")
|
| 150 |
logger.info(f"📄 [{pdf_idx}/{len(pdf_paths)}] {pdf_path.name}")
|
| 151 |
logger.info(f"{'='*60}")
|
| 152 |
+
|
| 153 |
result = pipeline.process_pdf(
|
| 154 |
pdf_path,
|
| 155 |
skip_existing=skip_existing,
|
| 156 |
upload_to_cloudinary=(not args.no_cloudinary),
|
| 157 |
upload_to_qdrant=(not args.no_qdrant),
|
| 158 |
)
|
| 159 |
+
|
| 160 |
total_uploaded += result["uploaded"]
|
| 161 |
total_skipped += result["skipped"]
|
| 162 |
total_failed += result["failed"]
|
| 163 |
+
|
| 164 |
# Summary
|
| 165 |
logger.info(f"\n{'='*60}")
|
| 166 |
+
logger.info("📊 SUMMARY")
|
| 167 |
logger.info(f"{'='*60}")
|
| 168 |
logger.info(f" Total PDFs: {len(pdf_paths)}")
|
| 169 |
logger.info(f" Uploaded: {total_uploaded}")
|
| 170 |
logger.info(f" Skipped: {total_skipped}")
|
| 171 |
logger.info(f" Failed: {total_failed}")
|
| 172 |
+
|
| 173 |
if indexer:
|
| 174 |
info = indexer.get_collection_info()
|
| 175 |
if info:
|
|
|
|
| 178 |
|
| 179 |
if __name__ == "__main__":
|
| 180 |
main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
examples/search_demo.py
CHANGED
|
@@ -9,18 +9,18 @@ This example demonstrates:
|
|
| 9 |
|
| 10 |
Usage:
|
| 11 |
python examples/search_demo.py --query "What is the budget allocation?"
|
| 12 |
-
|
| 13 |
# With filters
|
| 14 |
python examples/search_demo.py --query "budget" --year 2023 --source "Local Government"
|
| 15 |
-
|
| 16 |
# With saliency maps
|
| 17 |
python examples/search_demo.py --query "budget" --saliency
|
| 18 |
"""
|
| 19 |
|
| 20 |
-
import os
|
| 21 |
-
import sys
|
| 22 |
import argparse
|
| 23 |
import logging
|
|
|
|
|
|
|
| 24 |
from pathlib import Path
|
| 25 |
|
| 26 |
from dotenv import load_dotenv
|
|
@@ -29,9 +29,9 @@ from qdrant_client import QdrantClient
|
|
| 29 |
# Add parent to path for development
|
| 30 |
sys.path.insert(0, str(Path(__file__).parent.parent))
|
| 31 |
|
| 32 |
-
from visual_rag import VisualEmbedder
|
| 33 |
-
from visual_rag.retrieval.two_stage import TwoStageRetriever
|
| 34 |
-
from visual_rag.visualization import visualize_search_results
|
| 35 |
|
| 36 |
logging.basicConfig(level=logging.INFO)
|
| 37 |
logger = logging.getLogger(__name__)
|
|
@@ -39,82 +39,58 @@ logger = logging.getLogger(__name__)
|
|
| 39 |
|
| 40 |
def main():
|
| 41 |
parser = argparse.ArgumentParser(description="Search with Visual RAG Toolkit")
|
|
|
|
| 42 |
parser.add_argument(
|
| 43 |
-
"--
|
| 44 |
-
help="Search query"
|
| 45 |
-
)
|
| 46 |
-
parser.add_argument(
|
| 47 |
-
"--collection", type=str, default="visual_documents",
|
| 48 |
-
help="Qdrant collection name"
|
| 49 |
-
)
|
| 50 |
-
parser.add_argument(
|
| 51 |
-
"--model", type=str, default="vidore/colSmol-500M",
|
| 52 |
-
help="Model name"
|
| 53 |
-
)
|
| 54 |
-
parser.add_argument(
|
| 55 |
-
"--top-k", type=int, default=10,
|
| 56 |
-
help="Number of results"
|
| 57 |
-
)
|
| 58 |
-
parser.add_argument(
|
| 59 |
-
"--prefetch-k", type=int, default=200,
|
| 60 |
-
help="Candidates for two-stage retrieval"
|
| 61 |
)
|
|
|
|
|
|
|
| 62 |
parser.add_argument(
|
| 63 |
-
"--
|
| 64 |
-
help="Filter by year"
|
| 65 |
)
|
|
|
|
|
|
|
|
|
|
| 66 |
parser.add_argument(
|
| 67 |
-
"--
|
| 68 |
-
help="Filter by source"
|
| 69 |
)
|
| 70 |
-
parser.add_argument(
|
| 71 |
-
|
| 72 |
-
help="Filter by district"
|
| 73 |
-
)
|
| 74 |
-
parser.add_argument(
|
| 75 |
-
"--saliency", action="store_true",
|
| 76 |
-
help="Generate saliency maps for results"
|
| 77 |
-
)
|
| 78 |
-
parser.add_argument(
|
| 79 |
-
"--output", type=str,
|
| 80 |
-
help="Output path for visualization"
|
| 81 |
-
)
|
| 82 |
-
|
| 83 |
args = parser.parse_args()
|
| 84 |
-
|
| 85 |
# Load environment
|
| 86 |
load_dotenv()
|
| 87 |
-
|
| 88 |
qdrant_url = os.getenv("QDRANT_URL")
|
| 89 |
qdrant_api_key = os.getenv("QDRANT_API_KEY")
|
| 90 |
-
|
| 91 |
if not qdrant_url:
|
| 92 |
logger.error("QDRANT_URL not set")
|
| 93 |
sys.exit(1)
|
| 94 |
-
|
| 95 |
# Initialize components
|
| 96 |
logger.info(f"🤖 Loading model: {args.model}")
|
| 97 |
embedder = VisualEmbedder(model_name=args.model)
|
| 98 |
-
|
| 99 |
logger.info(f"🔌 Connecting to Qdrant: {qdrant_url}")
|
| 100 |
client = QdrantClient(url=qdrant_url, api_key=qdrant_api_key)
|
| 101 |
-
|
| 102 |
retriever = TwoStageRetriever(
|
| 103 |
qdrant_client=client,
|
| 104 |
collection_name=args.collection,
|
| 105 |
)
|
| 106 |
-
|
| 107 |
# Embed query
|
| 108 |
logger.info(f"🔍 Query: {args.query}")
|
| 109 |
query_embedding = embedder.embed_query(args.query)
|
| 110 |
-
|
| 111 |
# Build filter
|
| 112 |
filter_obj = retriever.build_filter(
|
| 113 |
year=args.year,
|
| 114 |
source=args.source,
|
| 115 |
district=args.district,
|
| 116 |
)
|
| 117 |
-
|
| 118 |
# Search
|
| 119 |
results = retriever.search(
|
| 120 |
query_embedding=query_embedding.numpy(),
|
|
@@ -122,31 +98,31 @@ def main():
|
|
| 122 |
prefetch_k=args.prefetch_k,
|
| 123 |
filter_obj=filter_obj,
|
| 124 |
)
|
| 125 |
-
|
| 126 |
# Display results
|
| 127 |
logger.info(f"\n📊 Results ({len(results)}):")
|
| 128 |
for i, result in enumerate(results, 1):
|
| 129 |
payload = result.get("payload", {})
|
| 130 |
score = result.get("score_final", result.get("score_stage1", 0))
|
| 131 |
-
|
| 132 |
filename = payload.get("filename", "N/A")
|
| 133 |
page_num = payload.get("page_number", "N/A")
|
| 134 |
year = payload.get("year", "N/A")
|
| 135 |
source = payload.get("source", "N/A")
|
| 136 |
-
|
| 137 |
logger.info(f" {i}. {filename} p.{page_num}")
|
| 138 |
logger.info(f" Score: {score:.4f} | Year: {year} | Source: {source}")
|
| 139 |
-
|
| 140 |
# Show text snippet
|
| 141 |
text = payload.get("text", "")
|
| 142 |
if text:
|
| 143 |
snippet = text[:200].replace("\n", " ")
|
| 144 |
logger.info(f" Text: {snippet}...")
|
| 145 |
-
|
| 146 |
# Visualize results
|
| 147 |
if args.output or args.saliency:
|
| 148 |
output_path = args.output or "search_results.png"
|
| 149 |
-
|
| 150 |
logger.info(f"\n🎨 Generating visualization: {output_path}")
|
| 151 |
visualize_search_results(
|
| 152 |
query=args.query,
|
|
@@ -158,10 +134,3 @@ def main():
|
|
| 158 |
|
| 159 |
if __name__ == "__main__":
|
| 160 |
main()
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
|
|
|
| 9 |
|
| 10 |
Usage:
|
| 11 |
python examples/search_demo.py --query "What is the budget allocation?"
|
| 12 |
+
|
| 13 |
# With filters
|
| 14 |
python examples/search_demo.py --query "budget" --year 2023 --source "Local Government"
|
| 15 |
+
|
| 16 |
# With saliency maps
|
| 17 |
python examples/search_demo.py --query "budget" --saliency
|
| 18 |
"""
|
| 19 |
|
|
|
|
|
|
|
| 20 |
import argparse
|
| 21 |
import logging
|
| 22 |
+
import os
|
| 23 |
+
import sys
|
| 24 |
from pathlib import Path
|
| 25 |
|
| 26 |
from dotenv import load_dotenv
|
|
|
|
| 29 |
# Add parent to path for development
|
| 30 |
sys.path.insert(0, str(Path(__file__).parent.parent))
|
| 31 |
|
| 32 |
+
from visual_rag import VisualEmbedder # noqa: E402
|
| 33 |
+
from visual_rag.retrieval.two_stage import TwoStageRetriever # noqa: E402
|
| 34 |
+
from visual_rag.visualization import visualize_search_results # noqa: E402
|
| 35 |
|
| 36 |
logging.basicConfig(level=logging.INFO)
|
| 37 |
logger = logging.getLogger(__name__)
|
|
|
|
| 39 |
|
| 40 |
def main():
|
| 41 |
parser = argparse.ArgumentParser(description="Search with Visual RAG Toolkit")
|
| 42 |
+
parser.add_argument("--query", type=str, required=True, help="Search query")
|
| 43 |
parser.add_argument(
|
| 44 |
+
"--collection", type=str, default="visual_documents", help="Qdrant collection name"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
)
|
| 46 |
+
parser.add_argument("--model", type=str, default="vidore/colSmol-500M", help="Model name")
|
| 47 |
+
parser.add_argument("--top-k", type=int, default=10, help="Number of results")
|
| 48 |
parser.add_argument(
|
| 49 |
+
"--prefetch-k", type=int, default=200, help="Candidates for two-stage retrieval"
|
|
|
|
| 50 |
)
|
| 51 |
+
parser.add_argument("--year", type=int, help="Filter by year")
|
| 52 |
+
parser.add_argument("--source", type=str, help="Filter by source")
|
| 53 |
+
parser.add_argument("--district", type=str, help="Filter by district")
|
| 54 |
parser.add_argument(
|
| 55 |
+
"--saliency", action="store_true", help="Generate saliency maps for results"
|
|
|
|
| 56 |
)
|
| 57 |
+
parser.add_argument("--output", type=str, help="Output path for visualization")
|
| 58 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
args = parser.parse_args()
|
| 60 |
+
|
| 61 |
# Load environment
|
| 62 |
load_dotenv()
|
| 63 |
+
|
| 64 |
qdrant_url = os.getenv("QDRANT_URL")
|
| 65 |
qdrant_api_key = os.getenv("QDRANT_API_KEY")
|
| 66 |
+
|
| 67 |
if not qdrant_url:
|
| 68 |
logger.error("QDRANT_URL not set")
|
| 69 |
sys.exit(1)
|
| 70 |
+
|
| 71 |
# Initialize components
|
| 72 |
logger.info(f"🤖 Loading model: {args.model}")
|
| 73 |
embedder = VisualEmbedder(model_name=args.model)
|
| 74 |
+
|
| 75 |
logger.info(f"🔌 Connecting to Qdrant: {qdrant_url}")
|
| 76 |
client = QdrantClient(url=qdrant_url, api_key=qdrant_api_key)
|
| 77 |
+
|
| 78 |
retriever = TwoStageRetriever(
|
| 79 |
qdrant_client=client,
|
| 80 |
collection_name=args.collection,
|
| 81 |
)
|
| 82 |
+
|
| 83 |
# Embed query
|
| 84 |
logger.info(f"🔍 Query: {args.query}")
|
| 85 |
query_embedding = embedder.embed_query(args.query)
|
| 86 |
+
|
| 87 |
# Build filter
|
| 88 |
filter_obj = retriever.build_filter(
|
| 89 |
year=args.year,
|
| 90 |
source=args.source,
|
| 91 |
district=args.district,
|
| 92 |
)
|
| 93 |
+
|
| 94 |
# Search
|
| 95 |
results = retriever.search(
|
| 96 |
query_embedding=query_embedding.numpy(),
|
|
|
|
| 98 |
prefetch_k=args.prefetch_k,
|
| 99 |
filter_obj=filter_obj,
|
| 100 |
)
|
| 101 |
+
|
| 102 |
# Display results
|
| 103 |
logger.info(f"\n📊 Results ({len(results)}):")
|
| 104 |
for i, result in enumerate(results, 1):
|
| 105 |
payload = result.get("payload", {})
|
| 106 |
score = result.get("score_final", result.get("score_stage1", 0))
|
| 107 |
+
|
| 108 |
filename = payload.get("filename", "N/A")
|
| 109 |
page_num = payload.get("page_number", "N/A")
|
| 110 |
year = payload.get("year", "N/A")
|
| 111 |
source = payload.get("source", "N/A")
|
| 112 |
+
|
| 113 |
logger.info(f" {i}. {filename} p.{page_num}")
|
| 114 |
logger.info(f" Score: {score:.4f} | Year: {year} | Source: {source}")
|
| 115 |
+
|
| 116 |
# Show text snippet
|
| 117 |
text = payload.get("text", "")
|
| 118 |
if text:
|
| 119 |
snippet = text[:200].replace("\n", " ")
|
| 120 |
logger.info(f" Text: {snippet}...")
|
| 121 |
+
|
| 122 |
# Visualize results
|
| 123 |
if args.output or args.saliency:
|
| 124 |
output_path = args.output or "search_results.png"
|
| 125 |
+
|
| 126 |
logger.info(f"\n🎨 Generating visualization: {output_path}")
|
| 127 |
visualize_search_results(
|
| 128 |
query=args.query,
|
|
|
|
| 134 |
|
| 135 |
if __name__ == "__main__":
|
| 136 |
main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
scripts/colqwen25_probe.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Probe script for vidore/colqwen2.5-v0.2 embedding layout.
|
| 3 |
+
|
| 4 |
+
Usage:
|
| 5 |
+
python scripts/colqwen25_probe.py --model vidore/colqwen2.5-v0.2 --device cuda:0
|
| 6 |
+
|
| 7 |
+
Notes:
|
| 8 |
+
- ColQwen2.5 requires colpali-engine>=0.3.7 and transformers>=4.45.0
|
| 9 |
+
(the model card recommends installing from source).
|
| 10 |
+
- This script prints embedding shapes + token_info (grid_h/grid_w when available),
|
| 11 |
+
and runs mean/experimental pooling to validate compatibility with the pipeline.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import argparse
|
| 17 |
+
|
| 18 |
+
from PIL import Image
|
| 19 |
+
|
| 20 |
+
from visual_rag import VisualEmbedder
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def main() -> None:
|
| 24 |
+
p = argparse.ArgumentParser()
|
| 25 |
+
p.add_argument("--model", default="vidore/colqwen2.5-v0.2")
|
| 26 |
+
p.add_argument("--device", default="cpu")
|
| 27 |
+
p.add_argument("--dtype", default="bfloat16", choices=["bfloat16", "float16", "float32"])
|
| 28 |
+
args = p.parse_args()
|
| 29 |
+
|
| 30 |
+
img = Image.new("RGB", (1024, 768), color="white")
|
| 31 |
+
|
| 32 |
+
embedder = VisualEmbedder(model_name=args.model, device=args.device, torch_dtype=args.dtype)
|
| 33 |
+
embs, infos = embedder.embed_images([img], return_token_info=True, show_progress=False)
|
| 34 |
+
|
| 35 |
+
emb = embs[0]
|
| 36 |
+
info = infos[0]
|
| 37 |
+
print("Model:", args.model)
|
| 38 |
+
print("Full embedding:", tuple(emb.shape), "dtype:", emb.dtype)
|
| 39 |
+
print(
|
| 40 |
+
"token_info:",
|
| 41 |
+
{
|
| 42 |
+
k: info.get(k)
|
| 43 |
+
for k in [
|
| 44 |
+
"num_visual_tokens",
|
| 45 |
+
"grid_t",
|
| 46 |
+
"grid_h",
|
| 47 |
+
"grid_w",
|
| 48 |
+
"n_rows",
|
| 49 |
+
"n_cols",
|
| 50 |
+
"num_tiles",
|
| 51 |
+
]
|
| 52 |
+
},
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
visual = embedder.extract_visual_embedding(emb, info)
|
| 56 |
+
print("Visual embedding:", tuple(visual.shape), "dtype:", visual.dtype)
|
| 57 |
+
|
| 58 |
+
mean_pool = embedder.mean_pool_visual_embedding(visual, info, target_vectors=32)
|
| 59 |
+
exp_pool = embedder.experimental_pool_visual_embedding(
|
| 60 |
+
visual, info, target_vectors=32, mean_pool=mean_pool
|
| 61 |
+
)
|
| 62 |
+
global_pool = embedder.global_pool_from_mean_pool(mean_pool)
|
| 63 |
+
|
| 64 |
+
print("mean_pool:", tuple(mean_pool.shape))
|
| 65 |
+
print("exp_pool:", tuple(exp_pool.shape))
|
| 66 |
+
print("global_pool:", tuple(global_pool.shape))
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
if __name__ == "__main__":
|
| 70 |
+
main()
|
scripts/compare_eval_scopes.py
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Run the same benchmark twice (union vs per_dataset) and print full reports + deltas.
|
| 3 |
+
|
| 4 |
+
This is designed to answer: "How much do distractors (union scope) hurt vs per-dataset filtering?"
|
| 5 |
+
|
| 6 |
+
It runs:
|
| 7 |
+
python -m benchmarks.vidore_beir_qdrant.run_qdrant_beir ... --evaluation-scope union
|
| 8 |
+
python -m benchmarks.vidore_beir_qdrant.run_qdrant_beir ... --evaluation-scope per_dataset
|
| 9 |
+
|
| 10 |
+
Then prints, per dataset:
|
| 11 |
+
- full metrics dict for union
|
| 12 |
+
- full metrics dict for per_dataset
|
| 13 |
+
- delta = per_dataset - union (for numeric metrics)
|
| 14 |
+
|
| 15 |
+
Usage example:
|
| 16 |
+
python scripts/compare_eval_scopes.py \\
|
| 17 |
+
--datasets vidore/esg_reports_v2 vidore/biomedical_lectures_v2 vidore/economics_reports_v2 \\
|
| 18 |
+
--collection vidore_beir_v2_3ds__colpali_v1_3__nocrop__union \\
|
| 19 |
+
--model vidore/colpali-v1.3 \\
|
| 20 |
+
--mode single_full \\
|
| 21 |
+
--top-k 100
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
from __future__ import annotations
|
| 25 |
+
|
| 26 |
+
import argparse
|
| 27 |
+
import json
|
| 28 |
+
import os
|
| 29 |
+
import subprocess
|
| 30 |
+
import sys
|
| 31 |
+
from datetime import datetime
|
| 32 |
+
from pathlib import Path
|
| 33 |
+
from typing import Any, Dict, List, Optional
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _now_tag() -> str:
|
| 37 |
+
return datetime.utcnow().strftime("%Y%m%d_%H%M%S")
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _as_number(x: Any) -> Optional[float]:
|
| 41 |
+
if x is None:
|
| 42 |
+
return None
|
| 43 |
+
if isinstance(x, bool):
|
| 44 |
+
return float(int(x))
|
| 45 |
+
if isinstance(x, (int, float)):
|
| 46 |
+
return float(x)
|
| 47 |
+
return None
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _delta_metrics(per_ds: Dict[str, Any], union: Dict[str, Any]) -> Dict[str, Any]:
|
| 51 |
+
out: Dict[str, Any] = {}
|
| 52 |
+
keys = set(per_ds.keys()) | set(union.keys())
|
| 53 |
+
for k in sorted(keys):
|
| 54 |
+
a = _as_number(per_ds.get(k))
|
| 55 |
+
b = _as_number(union.get(k))
|
| 56 |
+
if a is not None and b is not None:
|
| 57 |
+
out[k] = a - b
|
| 58 |
+
else:
|
| 59 |
+
# keep non-numerics as a tuple when present in either
|
| 60 |
+
if k in per_ds or k in union:
|
| 61 |
+
out[k] = {"per_dataset": per_ds.get(k), "union": union.get(k)}
|
| 62 |
+
return out
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _load_metrics_by_dataset(path: Path) -> Dict[str, Dict[str, Any]]:
|
| 66 |
+
obj = json.loads(path.read_text())
|
| 67 |
+
mbd = obj.get("metrics_by_dataset") or {}
|
| 68 |
+
if not isinstance(mbd, dict):
|
| 69 |
+
return {}
|
| 70 |
+
# ensure nested dicts
|
| 71 |
+
out: Dict[str, Dict[str, Any]] = {}
|
| 72 |
+
for k, v in mbd.items():
|
| 73 |
+
if isinstance(v, dict):
|
| 74 |
+
out[str(k)] = v
|
| 75 |
+
return out
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _run_once(
|
| 79 |
+
*,
|
| 80 |
+
datasets: List[str],
|
| 81 |
+
collection: str,
|
| 82 |
+
model: str,
|
| 83 |
+
mode: str,
|
| 84 |
+
top_k: int,
|
| 85 |
+
stage1_mode: Optional[str],
|
| 86 |
+
prefetch_k: Optional[int],
|
| 87 |
+
stage1_k: Optional[int],
|
| 88 |
+
stage2_k: Optional[int],
|
| 89 |
+
torch_dtype: str,
|
| 90 |
+
qdrant_vector_dtype: str,
|
| 91 |
+
prefer_grpc: bool,
|
| 92 |
+
max_queries: int,
|
| 93 |
+
evaluation_scope: str,
|
| 94 |
+
qdrant_timeout: int,
|
| 95 |
+
qdrant_retries: int,
|
| 96 |
+
qdrant_retry_sleep: float,
|
| 97 |
+
extra_args: List[str],
|
| 98 |
+
out_path: Path,
|
| 99 |
+
) -> None:
|
| 100 |
+
cmd: List[str] = [
|
| 101 |
+
sys.executable,
|
| 102 |
+
"-m",
|
| 103 |
+
"benchmarks.vidore_beir_qdrant.run_qdrant_beir",
|
| 104 |
+
"--datasets",
|
| 105 |
+
*datasets,
|
| 106 |
+
"--collection",
|
| 107 |
+
collection,
|
| 108 |
+
"--model",
|
| 109 |
+
model,
|
| 110 |
+
"--mode",
|
| 111 |
+
mode,
|
| 112 |
+
"--top-k",
|
| 113 |
+
str(int(top_k)),
|
| 114 |
+
"--evaluation-scope",
|
| 115 |
+
str(evaluation_scope),
|
| 116 |
+
"--torch-dtype",
|
| 117 |
+
torch_dtype,
|
| 118 |
+
"--qdrant-vector-dtype",
|
| 119 |
+
qdrant_vector_dtype,
|
| 120 |
+
"--qdrant-timeout",
|
| 121 |
+
str(int(qdrant_timeout)),
|
| 122 |
+
"--qdrant-retries",
|
| 123 |
+
str(int(qdrant_retries)),
|
| 124 |
+
"--qdrant-retry-sleep",
|
| 125 |
+
str(float(qdrant_retry_sleep)),
|
| 126 |
+
"--max-queries",
|
| 127 |
+
str(int(max_queries)),
|
| 128 |
+
"--output",
|
| 129 |
+
str(out_path),
|
| 130 |
+
]
|
| 131 |
+
|
| 132 |
+
if not prefer_grpc:
|
| 133 |
+
cmd.append("--no-prefer-grpc")
|
| 134 |
+
else:
|
| 135 |
+
cmd.append("--prefer-grpc")
|
| 136 |
+
|
| 137 |
+
if str(mode) == "two_stage":
|
| 138 |
+
if stage1_mode:
|
| 139 |
+
cmd += ["--stage1-mode", str(stage1_mode)]
|
| 140 |
+
if prefetch_k is not None:
|
| 141 |
+
cmd += ["--prefetch-k", str(int(prefetch_k))]
|
| 142 |
+
if str(mode) == "three_stage":
|
| 143 |
+
if stage1_k is not None:
|
| 144 |
+
cmd += ["--stage1-k", str(int(stage1_k))]
|
| 145 |
+
if stage2_k is not None:
|
| 146 |
+
cmd += ["--stage2-k", str(int(stage2_k))]
|
| 147 |
+
|
| 148 |
+
cmd += list(extra_args or [])
|
| 149 |
+
|
| 150 |
+
env = os.environ.copy()
|
| 151 |
+
env.setdefault("HF_HUB_DISABLE_XET", "1") # avoid xet crashes in some environments
|
| 152 |
+
|
| 153 |
+
print("\n" + "=" * 90)
|
| 154 |
+
print(f"RUN scope={evaluation_scope}")
|
| 155 |
+
print(" ".join(cmd))
|
| 156 |
+
print("=" * 90)
|
| 157 |
+
sys.stdout.flush()
|
| 158 |
+
|
| 159 |
+
subprocess.run(cmd, check=True, env=env)
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def main() -> None:
|
| 163 |
+
ap = argparse.ArgumentParser()
|
| 164 |
+
ap.add_argument("--datasets", nargs="+", required=True)
|
| 165 |
+
ap.add_argument("--collection", required=True)
|
| 166 |
+
ap.add_argument("--model", required=True)
|
| 167 |
+
ap.add_argument(
|
| 168 |
+
"--mode", default="single_full", choices=["single_full", "two_stage", "three_stage"]
|
| 169 |
+
)
|
| 170 |
+
ap.add_argument("--top-k", type=int, default=100)
|
| 171 |
+
ap.add_argument(
|
| 172 |
+
"--stage1-mode",
|
| 173 |
+
default="",
|
| 174 |
+
help="two_stage stage1 mode (e.g. tokens_vs_experimental_pooling or tokens_vs_standard_pooling)",
|
| 175 |
+
)
|
| 176 |
+
ap.add_argument("--prefetch-k", type=int, default=256)
|
| 177 |
+
ap.add_argument("--stage1-k", type=int, default=1000)
|
| 178 |
+
ap.add_argument("--stage2-k", type=int, default=300)
|
| 179 |
+
ap.add_argument(
|
| 180 |
+
"--torch-dtype", default="auto", choices=["auto", "float32", "float16", "bfloat16"]
|
| 181 |
+
)
|
| 182 |
+
ap.add_argument("--qdrant-vector-dtype", default="float16", choices=["float16", "float32"])
|
| 183 |
+
ap.add_argument("--prefer-grpc", action="store_true", default=False)
|
| 184 |
+
ap.add_argument("--max-queries", type=int, default=0)
|
| 185 |
+
ap.add_argument("--qdrant-timeout", type=int, default=120)
|
| 186 |
+
ap.add_argument("--qdrant-retries", type=int, default=3)
|
| 187 |
+
ap.add_argument("--qdrant-retry-sleep", type=float, default=0.5)
|
| 188 |
+
ap.add_argument(
|
| 189 |
+
"--out-dir",
|
| 190 |
+
default="results/scope_comparisons",
|
| 191 |
+
help="Directory to write the two raw JSON reports + the merged report",
|
| 192 |
+
)
|
| 193 |
+
ap.add_argument(
|
| 194 |
+
"--extra-arg",
|
| 195 |
+
action="append",
|
| 196 |
+
default=[],
|
| 197 |
+
help="Pass-through extra args to run_qdrant_beir (repeatable), e.g. --extra-arg --crop-empty",
|
| 198 |
+
)
|
| 199 |
+
args = ap.parse_args()
|
| 200 |
+
|
| 201 |
+
datasets = [str(x) for x in args.datasets]
|
| 202 |
+
stage1_mode = str(args.stage1_mode).strip() or None
|
| 203 |
+
|
| 204 |
+
out_dir = Path(str(args.out_dir))
|
| 205 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 206 |
+
tag = _now_tag()
|
| 207 |
+
|
| 208 |
+
base = f"{tag}__{Path(args.collection).name}__{Path(args.model).name}__{args.mode}"
|
| 209 |
+
union_path = out_dir / f"{base}__scope_union.json"
|
| 210 |
+
per_path = out_dir / f"{base}__scope_per_dataset.json"
|
| 211 |
+
merged_path = out_dir / f"{base}__scope_compare.json"
|
| 212 |
+
|
| 213 |
+
_run_once(
|
| 214 |
+
datasets=datasets,
|
| 215 |
+
collection=str(args.collection),
|
| 216 |
+
model=str(args.model),
|
| 217 |
+
mode=str(args.mode),
|
| 218 |
+
top_k=int(args.top_k),
|
| 219 |
+
stage1_mode=stage1_mode,
|
| 220 |
+
prefetch_k=int(args.prefetch_k),
|
| 221 |
+
stage1_k=int(args.stage1_k),
|
| 222 |
+
stage2_k=int(args.stage2_k),
|
| 223 |
+
torch_dtype=str(args.torch_dtype),
|
| 224 |
+
qdrant_vector_dtype=str(args.qdrant_vector_dtype),
|
| 225 |
+
prefer_grpc=bool(args.prefer_grpc),
|
| 226 |
+
max_queries=int(args.max_queries),
|
| 227 |
+
evaluation_scope="union",
|
| 228 |
+
qdrant_timeout=int(args.qdrant_timeout),
|
| 229 |
+
qdrant_retries=int(args.qdrant_retries),
|
| 230 |
+
qdrant_retry_sleep=float(args.qdrant_retry_sleep),
|
| 231 |
+
extra_args=list(args.extra_arg or []),
|
| 232 |
+
out_path=union_path,
|
| 233 |
+
)
|
| 234 |
+
_run_once(
|
| 235 |
+
datasets=datasets,
|
| 236 |
+
collection=str(args.collection),
|
| 237 |
+
model=str(args.model),
|
| 238 |
+
mode=str(args.mode),
|
| 239 |
+
top_k=int(args.top_k),
|
| 240 |
+
stage1_mode=stage1_mode,
|
| 241 |
+
prefetch_k=int(args.prefetch_k),
|
| 242 |
+
stage1_k=int(args.stage1_k),
|
| 243 |
+
stage2_k=int(args.stage2_k),
|
| 244 |
+
torch_dtype=str(args.torch_dtype),
|
| 245 |
+
qdrant_vector_dtype=str(args.qdrant_vector_dtype),
|
| 246 |
+
prefer_grpc=bool(args.prefer_grpc),
|
| 247 |
+
max_queries=int(args.max_queries),
|
| 248 |
+
evaluation_scope="per_dataset",
|
| 249 |
+
qdrant_timeout=int(args.qdrant_timeout),
|
| 250 |
+
qdrant_retries=int(args.qdrant_retries),
|
| 251 |
+
qdrant_retry_sleep=float(args.qdrant_retry_sleep),
|
| 252 |
+
extra_args=list(args.extra_arg or []),
|
| 253 |
+
out_path=per_path,
|
| 254 |
+
)
|
| 255 |
+
|
| 256 |
+
union_mbd = _load_metrics_by_dataset(union_path)
|
| 257 |
+
per_mbd = _load_metrics_by_dataset(per_path)
|
| 258 |
+
|
| 259 |
+
all_ds = sorted(set(union_mbd.keys()) | set(per_mbd.keys()))
|
| 260 |
+
comparison: Dict[str, Any] = {
|
| 261 |
+
"meta": {
|
| 262 |
+
"datasets": datasets,
|
| 263 |
+
"collection": str(args.collection),
|
| 264 |
+
"model": str(args.model),
|
| 265 |
+
"mode": str(args.mode),
|
| 266 |
+
"top_k": int(args.top_k),
|
| 267 |
+
"stage1_mode": stage1_mode,
|
| 268 |
+
"prefetch_k": int(args.prefetch_k) if str(args.mode) == "two_stage" else None,
|
| 269 |
+
"stage1_k": int(args.stage1_k) if str(args.mode) == "three_stage" else None,
|
| 270 |
+
"stage2_k": int(args.stage2_k) if str(args.mode) == "three_stage" else None,
|
| 271 |
+
"torch_dtype": str(args.torch_dtype),
|
| 272 |
+
"qdrant_vector_dtype": str(args.qdrant_vector_dtype),
|
| 273 |
+
"prefer_grpc": bool(args.prefer_grpc),
|
| 274 |
+
"max_queries": int(args.max_queries),
|
| 275 |
+
"union_report": str(union_path),
|
| 276 |
+
"per_dataset_report": str(per_path),
|
| 277 |
+
},
|
| 278 |
+
"by_dataset": {},
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
print("\n" + "#" * 90)
|
| 282 |
+
print("SCOPE COMPARISON (per_dataset − union)")
|
| 283 |
+
print("#" * 90)
|
| 284 |
+
for ds in all_ds:
|
| 285 |
+
u = union_mbd.get(ds, {})
|
| 286 |
+
p = per_mbd.get(ds, {})
|
| 287 |
+
d = _delta_metrics(p, u)
|
| 288 |
+
comparison["by_dataset"][ds] = {
|
| 289 |
+
"union": u,
|
| 290 |
+
"per_dataset": p,
|
| 291 |
+
"delta": d,
|
| 292 |
+
}
|
| 293 |
+
print("\n" + "-" * 90)
|
| 294 |
+
print(ds)
|
| 295 |
+
print("-" * 90)
|
| 296 |
+
print("UNION:")
|
| 297 |
+
print(json.dumps(u, indent=2, sort_keys=True))
|
| 298 |
+
print("\nPER_DATASET:")
|
| 299 |
+
print(json.dumps(p, indent=2, sort_keys=True))
|
| 300 |
+
print("\nDELTA (per_dataset - union):")
|
| 301 |
+
print(json.dumps(d, indent=2, sort_keys=True))
|
| 302 |
+
sys.stdout.flush()
|
| 303 |
+
|
| 304 |
+
merged_path.write_text(json.dumps(comparison, indent=2, sort_keys=True))
|
| 305 |
+
print("\nWrote merged comparison:", merged_path)
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
if __name__ == "__main__":
|
| 309 |
+
main()
|
scripts/compare_models_sample_queries.py
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Compare retrieval quality across two model+collection pairs on the same dataset queries.
|
| 3 |
+
|
| 4 |
+
This is a read-only diagnostic:
|
| 5 |
+
- Loads BEIR dataset (queries + qrels)
|
| 6 |
+
- Remaps qrels doc_ids -> Qdrant point IDs for each collection
|
| 7 |
+
- Runs retrieval for a sample of queries
|
| 8 |
+
- Computes simple hit-rate statistics + per-query best-rank
|
| 9 |
+
- Writes a JSON report under results/model_compare/
|
| 10 |
+
|
| 11 |
+
Example:
|
| 12 |
+
python scripts/compare_models_sample_queries.py \\
|
| 13 |
+
--dataset vidore/esg_reports_v2 \\
|
| 14 |
+
--top-k 100 \\
|
| 15 |
+
--max-queries 50
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import argparse
|
| 21 |
+
import gc
|
| 22 |
+
import hashlib
|
| 23 |
+
import json
|
| 24 |
+
import os
|
| 25 |
+
from dataclasses import dataclass
|
| 26 |
+
from pathlib import Path
|
| 27 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 28 |
+
|
| 29 |
+
import numpy as np
|
| 30 |
+
from qdrant_client.http import models as qm
|
| 31 |
+
|
| 32 |
+
from benchmarks.vidore_tatdqa_test.dataset_loader import load_vidore_beir_dataset
|
| 33 |
+
from visual_rag import VisualEmbedder
|
| 34 |
+
from visual_rag.retrieval import MultiVectorRetriever
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _stable_uuid(text: str) -> str:
|
| 38 |
+
hex_str = hashlib.sha256(text.encode("utf-8")).hexdigest()[:32]
|
| 39 |
+
return f"{hex_str[:8]}-{hex_str[8:12]}-{hex_str[12:16]}-{hex_str[16:20]}-{hex_str[20:32]}"
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _union_point_id(*, dataset_name: str, source_doc_id: str, union_namespace: str) -> str:
|
| 43 |
+
return _stable_uuid(f"{union_namespace}::{dataset_name}::{source_doc_id}")
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _get_qdrant_env() -> Tuple[str, Optional[str]]:
|
| 47 |
+
url = os.getenv("QDRANT_URL") or os.getenv("DEST_QDRANT_URL") or os.getenv("SIGIR_QDRANT_URL")
|
| 48 |
+
if not url:
|
| 49 |
+
raise SystemExit("QDRANT_URL not set")
|
| 50 |
+
key = (
|
| 51 |
+
os.getenv("QDRANT_API_KEY")
|
| 52 |
+
or os.getenv("DEST_QDRANT_API_KEY")
|
| 53 |
+
or os.getenv("SIGIR_QDRANT_KEY")
|
| 54 |
+
)
|
| 55 |
+
return str(url), (str(key) if key else None)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
@dataclass(frozen=True)
|
| 59 |
+
class RunSpec:
|
| 60 |
+
name: str
|
| 61 |
+
model: str
|
| 62 |
+
collection: str
|
| 63 |
+
torch_dtype: str
|
| 64 |
+
output_dtype: str
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
SPECS: List[RunSpec] = [
|
| 68 |
+
RunSpec(
|
| 69 |
+
name="colqwen2.5_fp16_collection",
|
| 70 |
+
model="vidore/colqwen2.5-v0.2",
|
| 71 |
+
collection="vidore_beir_v2_3ds__colqwen25_v0_2__nocrop__union__fp16",
|
| 72 |
+
torch_dtype="float16",
|
| 73 |
+
output_dtype="float16",
|
| 74 |
+
),
|
| 75 |
+
RunSpec(
|
| 76 |
+
name="colpali1.3_collection",
|
| 77 |
+
model="vidore/colpali-v1.3",
|
| 78 |
+
collection="vidore_beir_v2_3ds__colpali_v1_3__nocrop__union",
|
| 79 |
+
torch_dtype="float16",
|
| 80 |
+
output_dtype="float16",
|
| 81 |
+
),
|
| 82 |
+
]
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def _parse_dtype(s: str):
|
| 86 |
+
if s == "float16":
|
| 87 |
+
import torch
|
| 88 |
+
|
| 89 |
+
return torch.float16
|
| 90 |
+
if s == "float32":
|
| 91 |
+
import torch
|
| 92 |
+
|
| 93 |
+
return torch.float32
|
| 94 |
+
if s == "bfloat16":
|
| 95 |
+
import torch
|
| 96 |
+
|
| 97 |
+
return torch.bfloat16
|
| 98 |
+
return None
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def _np_dtype(s: str):
|
| 102 |
+
return np.float16 if s == "float16" else np.float32
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def _build_remapped_qrels(
|
| 106 |
+
*, corpus, qrels, dataset_name: str, collection: str
|
| 107 |
+
) -> Dict[str, Dict[str, int]]:
|
| 108 |
+
# corpus doc_id values are stable_uuid(source_doc_id)
|
| 109 |
+
id_map: Dict[str, str] = {}
|
| 110 |
+
for doc in corpus:
|
| 111 |
+
source_doc_id = str((doc.payload or {}).get("source_doc_id") or doc.doc_id)
|
| 112 |
+
id_map[str(doc.doc_id)] = _union_point_id(
|
| 113 |
+
dataset_name=str(dataset_name),
|
| 114 |
+
source_doc_id=str(source_doc_id),
|
| 115 |
+
union_namespace=str(collection),
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
remapped: Dict[str, Dict[str, int]] = {}
|
| 119 |
+
for qid, rels in (qrels or {}).items():
|
| 120 |
+
out: Dict[str, int] = {}
|
| 121 |
+
for did, score in (rels or {}).items():
|
| 122 |
+
mapped = id_map.get(str(did))
|
| 123 |
+
if mapped:
|
| 124 |
+
out[str(mapped)] = int(score)
|
| 125 |
+
if out:
|
| 126 |
+
remapped[str(qid)] = out
|
| 127 |
+
return remapped
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def _rank_stats_for_query(
|
| 131 |
+
*, ranking: List[str], qrels: Dict[str, int], top_k: int
|
| 132 |
+
) -> Dict[str, Any]:
|
| 133 |
+
relset = {did for did, s in (qrels or {}).items() if int(s) > 0}
|
| 134 |
+
best_rank = None
|
| 135 |
+
for i, did in enumerate(ranking[:top_k]):
|
| 136 |
+
if str(did) in relset:
|
| 137 |
+
best_rank = i + 1
|
| 138 |
+
break
|
| 139 |
+
return {
|
| 140 |
+
"num_relevant": int(len(relset)),
|
| 141 |
+
"best_rank": int(best_rank) if best_rank is not None else None,
|
| 142 |
+
"hit@1": bool(best_rank == 1),
|
| 143 |
+
"hit@5": bool(best_rank is not None and best_rank <= 5),
|
| 144 |
+
"hit@10": bool(best_rank is not None and best_rank <= 10),
|
| 145 |
+
"hit@100": bool(best_rank is not None and best_rank <= 100),
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def _run_one(
|
| 150 |
+
*,
|
| 151 |
+
spec: RunSpec,
|
| 152 |
+
dataset_name: str,
|
| 153 |
+
corpus,
|
| 154 |
+
queries,
|
| 155 |
+
qrels,
|
| 156 |
+
top_k: int,
|
| 157 |
+
max_queries: int,
|
| 158 |
+
prefer_grpc: bool,
|
| 159 |
+
timeout: int,
|
| 160 |
+
) -> Dict[str, Any]:
|
| 161 |
+
url, key = _get_qdrant_env()
|
| 162 |
+
|
| 163 |
+
remapped_qrels = _build_remapped_qrels(
|
| 164 |
+
corpus=corpus, qrels=qrels, dataset_name=dataset_name, collection=spec.collection
|
| 165 |
+
)
|
| 166 |
+
# Keep only queries with at least one positive relevant doc
|
| 167 |
+
kept = [
|
| 168 |
+
q for q in queries if any(v > 0 for v in remapped_qrels.get(str(q.query_id), {}).values())
|
| 169 |
+
]
|
| 170 |
+
kept = kept[: int(max_queries)] if int(max_queries) > 0 else kept
|
| 171 |
+
|
| 172 |
+
flt = qm.Filter(
|
| 173 |
+
must=[qm.FieldCondition(key="dataset", match=qm.MatchValue(value=str(dataset_name)))]
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
+
embedder = VisualEmbedder(
|
| 177 |
+
model_name=str(spec.model),
|
| 178 |
+
torch_dtype=_parse_dtype(spec.torch_dtype),
|
| 179 |
+
output_dtype=_np_dtype(spec.output_dtype),
|
| 180 |
+
)
|
| 181 |
+
retriever = MultiVectorRetriever(
|
| 182 |
+
collection_name=str(spec.collection),
|
| 183 |
+
model_name=str(spec.model),
|
| 184 |
+
embedder=embedder,
|
| 185 |
+
qdrant_url=url,
|
| 186 |
+
qdrant_api_key=key,
|
| 187 |
+
prefer_grpc=bool(prefer_grpc),
|
| 188 |
+
request_timeout=int(timeout),
|
| 189 |
+
)
|
| 190 |
+
|
| 191 |
+
per_query: Dict[str, Any] = {}
|
| 192 |
+
hits1 = hits5 = hits10 = hits100 = 0
|
| 193 |
+
best_ranks: List[int] = []
|
| 194 |
+
for q in kept:
|
| 195 |
+
qid = str(q.query_id)
|
| 196 |
+
rels = remapped_qrels.get(qid, {})
|
| 197 |
+
res = retriever.search(q.text, top_k=int(top_k), mode="single_full", filter_obj=flt)
|
| 198 |
+
ranking = [str(r["id"]) for r in (res or [])]
|
| 199 |
+
st = _rank_stats_for_query(ranking=ranking, qrels=rels, top_k=int(top_k))
|
| 200 |
+
per_query[qid] = {
|
| 201 |
+
"text": str(q.text),
|
| 202 |
+
"stats": st,
|
| 203 |
+
"top10": ranking[:10],
|
| 204 |
+
}
|
| 205 |
+
hits1 += 1 if st["hit@1"] else 0
|
| 206 |
+
hits5 += 1 if st["hit@5"] else 0
|
| 207 |
+
hits10 += 1 if st["hit@10"] else 0
|
| 208 |
+
hits100 += 1 if st["hit@100"] else 0
|
| 209 |
+
if st["best_rank"] is not None:
|
| 210 |
+
best_ranks.append(int(st["best_rank"]))
|
| 211 |
+
|
| 212 |
+
n = max(len(kept), 1)
|
| 213 |
+
summary = {
|
| 214 |
+
"queries_eval": int(len(kept)),
|
| 215 |
+
"hit_rate@1": float(hits1 / n),
|
| 216 |
+
"hit_rate@5": float(hits5 / n),
|
| 217 |
+
"hit_rate@10": float(hits10 / n),
|
| 218 |
+
"hit_rate@100": float(hits100 / n),
|
| 219 |
+
"median_best_rank": float(np.median(best_ranks)) if best_ranks else None,
|
| 220 |
+
"mean_best_rank": float(np.mean(best_ranks)) if best_ranks else None,
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
# Best-effort release memory
|
| 224 |
+
try:
|
| 225 |
+
import torch
|
| 226 |
+
|
| 227 |
+
del retriever
|
| 228 |
+
del embedder
|
| 229 |
+
gc.collect()
|
| 230 |
+
if torch.cuda.is_available():
|
| 231 |
+
torch.cuda.empty_cache()
|
| 232 |
+
elif torch.backends.mps.is_available():
|
| 233 |
+
torch.mps.empty_cache()
|
| 234 |
+
except Exception:
|
| 235 |
+
pass
|
| 236 |
+
|
| 237 |
+
return {
|
| 238 |
+
"spec": spec.__dict__,
|
| 239 |
+
"summary": summary,
|
| 240 |
+
"per_query": per_query,
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
def main() -> None:
|
| 245 |
+
ap = argparse.ArgumentParser()
|
| 246 |
+
ap.add_argument("--dataset", default="vidore/esg_reports_v2")
|
| 247 |
+
ap.add_argument("--top-k", type=int, default=100)
|
| 248 |
+
ap.add_argument("--max-queries", type=int, default=50)
|
| 249 |
+
ap.add_argument("--prefer-grpc", action="store_true", default=True)
|
| 250 |
+
ap.add_argument("--timeout", type=int, default=120)
|
| 251 |
+
ap.add_argument("--out", default="auto")
|
| 252 |
+
args = ap.parse_args()
|
| 253 |
+
|
| 254 |
+
dataset_name = str(args.dataset)
|
| 255 |
+
corpus, queries, qrels = load_vidore_beir_dataset(dataset_name)
|
| 256 |
+
|
| 257 |
+
out_dir = Path("results") / "model_compare"
|
| 258 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 259 |
+
out_path = out_dir / (
|
| 260 |
+
args.out
|
| 261 |
+
if str(args.out) != "auto"
|
| 262 |
+
else f"compare__{dataset_name.replace('/', '_')}__top{int(args.top_k)}__q{int(args.max_queries)}.json"
|
| 263 |
+
)
|
| 264 |
+
|
| 265 |
+
out: Dict[str, Any] = {
|
| 266 |
+
"dataset": dataset_name,
|
| 267 |
+
"top_k": int(args.top_k),
|
| 268 |
+
"max_queries": int(args.max_queries),
|
| 269 |
+
"runs": {},
|
| 270 |
+
}
|
| 271 |
+
for spec in SPECS:
|
| 272 |
+
out["runs"][spec.name] = _run_one(
|
| 273 |
+
spec=spec,
|
| 274 |
+
dataset_name=dataset_name,
|
| 275 |
+
corpus=corpus,
|
| 276 |
+
queries=queries,
|
| 277 |
+
qrels=qrels,
|
| 278 |
+
top_k=int(args.top_k),
|
| 279 |
+
max_queries=int(args.max_queries),
|
| 280 |
+
prefer_grpc=bool(args.prefer_grpc),
|
| 281 |
+
timeout=int(args.timeout),
|
| 282 |
+
)
|
| 283 |
+
|
| 284 |
+
out_path.write_text(json.dumps(out, ensure_ascii=False, indent=2))
|
| 285 |
+
print(f"Wrote: {out_path}")
|
| 286 |
+
print(json.dumps({k: v["summary"] for k, v in out["runs"].items()}, indent=2))
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
if __name__ == "__main__":
|
| 290 |
+
main()
|
scripts/create_qdrant_payload_indexes.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import argparse
|
| 2 |
+
import os
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
from typing import Dict, List
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def _maybe_load_dotenv() -> None:
|
| 8 |
+
try:
|
| 9 |
+
from dotenv import load_dotenv
|
| 10 |
+
except ImportError:
|
| 11 |
+
return
|
| 12 |
+
if Path(".env").exists():
|
| 13 |
+
load_dotenv(".env")
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _infer_type(values: List[object]) -> str:
|
| 17 |
+
for v in values:
|
| 18 |
+
if isinstance(v, bool):
|
| 19 |
+
return "bool"
|
| 20 |
+
for v in values:
|
| 21 |
+
if isinstance(v, int) and not isinstance(v, bool):
|
| 22 |
+
return "integer"
|
| 23 |
+
for v in values:
|
| 24 |
+
if isinstance(v, float):
|
| 25 |
+
return "float"
|
| 26 |
+
return "keyword"
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def main() -> None:
|
| 30 |
+
parser = argparse.ArgumentParser()
|
| 31 |
+
parser.add_argument("--collection", type=str, required=True)
|
| 32 |
+
parser.add_argument("--prefer-grpc", action="store_true")
|
| 33 |
+
parser.add_argument("--sample", type=int, default=200)
|
| 34 |
+
parser.add_argument(
|
| 35 |
+
"--only",
|
| 36 |
+
type=str,
|
| 37 |
+
default="",
|
| 38 |
+
help="Comma-separated list of payload fields to index (optional).",
|
| 39 |
+
)
|
| 40 |
+
args = parser.parse_args()
|
| 41 |
+
|
| 42 |
+
_maybe_load_dotenv()
|
| 43 |
+
|
| 44 |
+
qdrant_url = os.getenv("QDRANT_URL")
|
| 45 |
+
if not qdrant_url:
|
| 46 |
+
raise ValueError("QDRANT_URL not set")
|
| 47 |
+
qdrant_api_key = os.getenv("QDRANT_API_KEY")
|
| 48 |
+
|
| 49 |
+
from qdrant_client import QdrantClient
|
| 50 |
+
|
| 51 |
+
client = QdrantClient(
|
| 52 |
+
url=qdrant_url,
|
| 53 |
+
api_key=qdrant_api_key,
|
| 54 |
+
prefer_grpc=args.prefer_grpc,
|
| 55 |
+
check_compatibility=False,
|
| 56 |
+
timeout=120,
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
points, _ = client.scroll(
|
| 60 |
+
collection_name=args.collection,
|
| 61 |
+
limit=int(args.sample),
|
| 62 |
+
with_payload=True,
|
| 63 |
+
with_vectors=False,
|
| 64 |
+
)
|
| 65 |
+
if not points:
|
| 66 |
+
raise ValueError(f"No points found in collection '{args.collection}'")
|
| 67 |
+
|
| 68 |
+
only = [s.strip() for s in args.only.split(",") if s.strip()]
|
| 69 |
+
only_set = set(only) if only else None
|
| 70 |
+
|
| 71 |
+
values_by_key: Dict[str, List[object]] = {}
|
| 72 |
+
for p in points:
|
| 73 |
+
payload = p.payload or {}
|
| 74 |
+
if not isinstance(payload, dict):
|
| 75 |
+
continue
|
| 76 |
+
for k, v in payload.items():
|
| 77 |
+
if only_set is not None and k not in only_set:
|
| 78 |
+
continue
|
| 79 |
+
if isinstance(v, dict) or isinstance(v, list):
|
| 80 |
+
continue
|
| 81 |
+
values_by_key.setdefault(k, []).append(v)
|
| 82 |
+
|
| 83 |
+
from visual_rag.indexing.qdrant_indexer import QdrantIndexer
|
| 84 |
+
|
| 85 |
+
indexer = QdrantIndexer(
|
| 86 |
+
url=qdrant_url,
|
| 87 |
+
api_key=qdrant_api_key,
|
| 88 |
+
collection_name=args.collection,
|
| 89 |
+
prefer_grpc=args.prefer_grpc,
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
fields = [{"field": k, "type": _infer_type(vs)} for k, vs in sorted(values_by_key.items())]
|
| 93 |
+
if not fields:
|
| 94 |
+
raise ValueError("No indexable payload fields found (all were nested or empty?)")
|
| 95 |
+
|
| 96 |
+
indexer.create_payload_indexes(fields=fields)
|
| 97 |
+
print(
|
| 98 |
+
f"Created/ensured {len(fields)} payload indexes on '{args.collection}': {[f['field'] for f in fields]}"
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
if __name__ == "__main__":
|
| 103 |
+
main()
|
scripts/debug_failed_docs.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import argparse
|
| 2 |
+
import json
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def _ensure_pil(img):
|
| 7 |
+
from PIL import Image
|
| 8 |
+
|
| 9 |
+
if img is None:
|
| 10 |
+
return None
|
| 11 |
+
if isinstance(img, Image.Image):
|
| 12 |
+
return img.convert("RGB")
|
| 13 |
+
try:
|
| 14 |
+
return img.convert("RGB")
|
| 15 |
+
except Exception:
|
| 16 |
+
raise TypeError(f"Unsupported image type: {type(img)}")
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def main():
|
| 20 |
+
parser = argparse.ArgumentParser()
|
| 21 |
+
parser.add_argument("--dataset", type=str, required=True)
|
| 22 |
+
parser.add_argument("--model", type=str, default="vidore/colSmol-500M")
|
| 23 |
+
parser.add_argument("--device", type=str, default=None)
|
| 24 |
+
parser.add_argument(
|
| 25 |
+
"--torch-dtype", type=str, default="float16", choices=["float16", "float32", "bfloat16"]
|
| 26 |
+
)
|
| 27 |
+
parser.add_argument(
|
| 28 |
+
"--processor-speed", type=str, default="fast", choices=["fast", "slow", "auto"]
|
| 29 |
+
)
|
| 30 |
+
parser.add_argument("--source-doc-ids", type=str, nargs="+", required=True)
|
| 31 |
+
parser.add_argument("--crop-empty", action="store_true", default=False)
|
| 32 |
+
parser.add_argument("--crop-empty-percentage-to-remove", type=float, default=0.99)
|
| 33 |
+
parser.add_argument("--crop-empty-remove-page-number", action="store_true", default=False)
|
| 34 |
+
parser.add_argument("--crop-empty-preserve-border-px", type=int, default=1)
|
| 35 |
+
parser.add_argument("--crop-empty-uniform-std-threshold", type=float, default=0.0)
|
| 36 |
+
parser.add_argument("--out-dir", type=str, default="results/paper_eval/debug_failed_docs")
|
| 37 |
+
args = parser.parse_args()
|
| 38 |
+
|
| 39 |
+
import numpy as np
|
| 40 |
+
import torch
|
| 41 |
+
|
| 42 |
+
from benchmarks.vidore_tatdqa_test.dataset_loader import load_vidore_beir_dataset
|
| 43 |
+
from visual_rag.embedding.visual_embedder import VisualEmbedder
|
| 44 |
+
from visual_rag.preprocessing.crop_empty import CropEmptyConfig, crop_empty
|
| 45 |
+
|
| 46 |
+
torch_dtype = {"float16": torch.float16, "float32": torch.float32, "bfloat16": torch.bfloat16}[
|
| 47 |
+
args.torch_dtype
|
| 48 |
+
]
|
| 49 |
+
|
| 50 |
+
out_dir = Path(args.out_dir)
|
| 51 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 52 |
+
|
| 53 |
+
corpus, _, _ = load_vidore_beir_dataset(args.dataset)
|
| 54 |
+
wanted = set(str(x) for x in args.source_doc_ids)
|
| 55 |
+
found = []
|
| 56 |
+
for d in corpus:
|
| 57 |
+
sid = str((d.payload or {}).get("source_doc_id") or "")
|
| 58 |
+
if sid in wanted:
|
| 59 |
+
found.append(d)
|
| 60 |
+
found_by_id = {str((d.payload or {}).get("source_doc_id") or ""): d for d in found}
|
| 61 |
+
missing = [x for x in args.source_doc_ids if str(x) not in found_by_id]
|
| 62 |
+
if missing:
|
| 63 |
+
raise SystemExit(f"Could not find source_doc_id(s) in corpus: {missing}")
|
| 64 |
+
|
| 65 |
+
embedder = VisualEmbedder(
|
| 66 |
+
model_name=str(args.model),
|
| 67 |
+
device=args.device,
|
| 68 |
+
torch_dtype=torch_dtype,
|
| 69 |
+
processor_speed=str(args.processor_speed),
|
| 70 |
+
batch_size=1,
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
report = {}
|
| 74 |
+
for sid in args.source_doc_ids:
|
| 75 |
+
d = found_by_id[str(sid)]
|
| 76 |
+
original_img = _ensure_pil(d.image)
|
| 77 |
+
original_path = (
|
| 78 |
+
out_dir / f"{args.dataset.replace('/', '__')}__source_doc_id={sid}__original.png"
|
| 79 |
+
)
|
| 80 |
+
original_img.save(original_path)
|
| 81 |
+
|
| 82 |
+
crop_meta = {
|
| 83 |
+
"applied": False,
|
| 84 |
+
"crop_box": None,
|
| 85 |
+
"original_width": int(original_img.width),
|
| 86 |
+
"original_height": int(original_img.height),
|
| 87 |
+
"cropped_width": int(original_img.width),
|
| 88 |
+
"cropped_height": int(original_img.height),
|
| 89 |
+
}
|
| 90 |
+
embed_img = original_img
|
| 91 |
+
if bool(args.crop_empty):
|
| 92 |
+
embed_img, crop_meta = crop_empty(
|
| 93 |
+
original_img,
|
| 94 |
+
config=CropEmptyConfig(
|
| 95 |
+
percentage_to_remove=float(args.crop_empty_percentage_to_remove),
|
| 96 |
+
remove_page_number=bool(args.crop_empty_remove_page_number),
|
| 97 |
+
preserve_border_px=int(args.crop_empty_preserve_border_px),
|
| 98 |
+
uniform_rowcol_std_threshold=float(args.crop_empty_uniform_std_threshold),
|
| 99 |
+
),
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
cropped_path = (
|
| 103 |
+
out_dir / f"{args.dataset.replace('/', '__')}__source_doc_id={sid}__cropped.png"
|
| 104 |
+
)
|
| 105 |
+
_ensure_pil(embed_img).save(cropped_path)
|
| 106 |
+
|
| 107 |
+
embeddings, token_infos = embedder.embed_images(
|
| 108 |
+
[embed_img],
|
| 109 |
+
batch_size=1,
|
| 110 |
+
return_token_info=True,
|
| 111 |
+
show_progress=False,
|
| 112 |
+
)
|
| 113 |
+
emb = embeddings[0]
|
| 114 |
+
token_info = token_infos[0] or {}
|
| 115 |
+
|
| 116 |
+
emb_np = (
|
| 117 |
+
emb.cpu().float().numpy() if hasattr(emb, "cpu") else np.array(emb, dtype=np.float32)
|
| 118 |
+
)
|
| 119 |
+
visual_indices = token_info.get("visual_token_indices") or list(range(int(emb_np.shape[0])))
|
| 120 |
+
visual_embedding = emb_np[visual_indices].astype(np.float32)
|
| 121 |
+
|
| 122 |
+
tile_pooled = embedder.mean_pool_visual_embedding(
|
| 123 |
+
visual_embedding, token_info, target_vectors=32
|
| 124 |
+
)
|
| 125 |
+
experimental_pooled = embedder.experimental_pool_visual_embedding(
|
| 126 |
+
visual_embedding,
|
| 127 |
+
token_info,
|
| 128 |
+
target_vectors=32,
|
| 129 |
+
mean_pool=tile_pooled,
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
n_rows = token_info.get("n_rows")
|
| 133 |
+
n_cols = token_info.get("n_cols")
|
| 134 |
+
num_tiles_from_info = token_info.get("num_tiles")
|
| 135 |
+
num_visual_tokens = token_info.get("num_visual_tokens")
|
| 136 |
+
num_tiles_from_tokens = int(visual_embedding.shape[0]) // 64 + (
|
| 137 |
+
1 if int(visual_embedding.shape[0]) % 64 else 0
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
report[str(sid)] = {
|
| 141 |
+
"dataset": str(args.dataset),
|
| 142 |
+
"model": str(args.model),
|
| 143 |
+
"source_doc_id": str(sid),
|
| 144 |
+
"doc_id": str(getattr(d, "doc_id", "")),
|
| 145 |
+
"payload_source_doc_id": str((d.payload or {}).get("source_doc_id") or ""),
|
| 146 |
+
"original_image": {
|
| 147 |
+
"path": str(original_path),
|
| 148 |
+
"width": int(original_img.width),
|
| 149 |
+
"height": int(original_img.height),
|
| 150 |
+
},
|
| 151 |
+
"crop_meta": crop_meta,
|
| 152 |
+
"cropped_image": {
|
| 153 |
+
"path": str(cropped_path),
|
| 154 |
+
"width": int(_ensure_pil(embed_img).width),
|
| 155 |
+
"height": int(_ensure_pil(embed_img).height),
|
| 156 |
+
},
|
| 157 |
+
"processor": {
|
| 158 |
+
"n_rows": None if n_rows is None else int(n_rows),
|
| 159 |
+
"n_cols": None if n_cols is None else int(n_cols),
|
| 160 |
+
"num_tiles": None if num_tiles_from_info is None else int(num_tiles_from_info),
|
| 161 |
+
"num_visual_tokens": None if num_visual_tokens is None else int(num_visual_tokens),
|
| 162 |
+
"visual_token_indices_len": int(len(visual_indices)),
|
| 163 |
+
"num_tiles_from_visual_tokens_div64": int(num_tiles_from_tokens),
|
| 164 |
+
},
|
| 165 |
+
"embeddings": {
|
| 166 |
+
"full_embedding_shape": [int(x) for x in emb_np.shape],
|
| 167 |
+
"visual_embedding_shape": [int(x) for x in visual_embedding.shape],
|
| 168 |
+
"mean_pool_shape": [int(x) for x in tile_pooled.shape],
|
| 169 |
+
"experimental_pool_shape": [int(x) for x in experimental_pooled.shape],
|
| 170 |
+
},
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
out_json = out_dir / f"{args.dataset.replace('/', '__')}__debug_report.json"
|
| 174 |
+
out_json.write_text(json.dumps(report, indent=2), encoding="utf-8")
|
| 175 |
+
print(str(out_json))
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
if __name__ == "__main__":
|
| 179 |
+
main()
|
scripts/debug_vidore_qrels_alignment.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Debug ViDoRe-v2 evaluation mismatches between qrels and Qdrant point IDs.
|
| 3 |
+
|
| 4 |
+
This script helps answer:
|
| 5 |
+
- Are relevant docs (from qrels) actually present in Qdrant?
|
| 6 |
+
- Are we mapping qrels doc IDs to the correct Qdrant point IDs?
|
| 7 |
+
- Does per_dataset filtering actually reduce the search space?
|
| 8 |
+
- If docs exist, at what rank do they appear for single_full retrieval?
|
| 9 |
+
|
| 10 |
+
Typical use:
|
| 11 |
+
python scripts/debug_vidore_qrels_alignment.py \\
|
| 12 |
+
--dataset vidore/esg_reports_v2 \\
|
| 13 |
+
--collection vidore_beir_v2_3ds__colqwen25_v0_2__nocrop__union__fp32 \\
|
| 14 |
+
--model vidore/colqwen2.5-v0.2 \\
|
| 15 |
+
--max-queries 5 \\
|
| 16 |
+
--top-k 200 \\
|
| 17 |
+
--no-prefer-grpc
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import argparse
|
| 23 |
+
import os
|
| 24 |
+
from typing import Dict, Tuple
|
| 25 |
+
|
| 26 |
+
from qdrant_client import QdrantClient
|
| 27 |
+
from qdrant_client.http import models as qm
|
| 28 |
+
|
| 29 |
+
from benchmarks.vidore_tatdqa_test.dataset_loader import load_vidore_beir_dataset
|
| 30 |
+
from visual_rag import VisualEmbedder
|
| 31 |
+
from visual_rag.retrieval import MultiVectorRetriever
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _stable_uuid(text: str) -> str:
|
| 35 |
+
import hashlib
|
| 36 |
+
|
| 37 |
+
hex_str = hashlib.sha256(text.encode("utf-8")).hexdigest()[:32]
|
| 38 |
+
return f"{hex_str[:8]}-{hex_str[8:12]}-{hex_str[12:16]}-{hex_str[16:20]}-{hex_str[20:32]}"
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _union_point_id(*, dataset_name: str, source_doc_id: str, union_namespace: str) -> str:
|
| 42 |
+
return _stable_uuid(f"{union_namespace}::{dataset_name}::{source_doc_id}")
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _infer_qdrant_conn(prefer_grpc: bool, timeout: int) -> QdrantClient:
|
| 46 |
+
url = os.getenv("QDRANT_URL") or os.getenv("DEST_QDRANT_URL") or os.getenv("SIGIR_QDRANT_URL")
|
| 47 |
+
if not url:
|
| 48 |
+
raise SystemExit("QDRANT_URL not set")
|
| 49 |
+
key = (
|
| 50 |
+
os.getenv("QDRANT_API_KEY")
|
| 51 |
+
or os.getenv("DEST_QDRANT_API_KEY")
|
| 52 |
+
or os.getenv("SIGIR_QDRANT_KEY")
|
| 53 |
+
)
|
| 54 |
+
return QdrantClient(
|
| 55 |
+
url=url,
|
| 56 |
+
api_key=key,
|
| 57 |
+
prefer_grpc=bool(prefer_grpc),
|
| 58 |
+
timeout=int(timeout),
|
| 59 |
+
check_compatibility=False,
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _count_by_dataset(client: QdrantClient, collection: str, dataset: str) -> Tuple[int, int]:
|
| 64 |
+
# exact counts can be slow; we keep it exact for correctness.
|
| 65 |
+
all_cnt = client.count(collection_name=collection, exact=True).count
|
| 66 |
+
ds_cnt = client.count(
|
| 67 |
+
collection_name=collection,
|
| 68 |
+
count_filter=qm.Filter(
|
| 69 |
+
must=[qm.FieldCondition(key="dataset", match=qm.MatchValue(value=str(dataset)))]
|
| 70 |
+
),
|
| 71 |
+
exact=True,
|
| 72 |
+
).count
|
| 73 |
+
return int(ds_cnt), int(all_cnt)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def main() -> None:
|
| 77 |
+
ap = argparse.ArgumentParser()
|
| 78 |
+
ap.add_argument("--dataset", required=True)
|
| 79 |
+
ap.add_argument("--collection", required=True)
|
| 80 |
+
ap.add_argument("--model", required=True)
|
| 81 |
+
ap.add_argument("--top-k", type=int, default=200)
|
| 82 |
+
ap.add_argument("--max-queries", type=int, default=5)
|
| 83 |
+
ap.add_argument("--prefer-grpc", action="store_true", default=False)
|
| 84 |
+
ap.add_argument("--timeout", type=int, default=120)
|
| 85 |
+
ap.add_argument(
|
| 86 |
+
"--torch-dtype", default="auto", choices=["auto", "float32", "float16", "bfloat16"]
|
| 87 |
+
)
|
| 88 |
+
args = ap.parse_args()
|
| 89 |
+
|
| 90 |
+
corpus, queries, qrels = load_vidore_beir_dataset(str(args.dataset))
|
| 91 |
+
print(
|
| 92 |
+
f"Loaded dataset={args.dataset}: corpus={len(corpus)} queries={len(queries)} qrels_q={len(qrels)}"
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
# Build mapping exactly like the benchmark does.
|
| 96 |
+
id_map: Dict[str, str] = {}
|
| 97 |
+
for doc in corpus:
|
| 98 |
+
src = str((doc.payload or {}).get("source_doc_id") or doc.doc_id)
|
| 99 |
+
id_map[str(doc.doc_id)] = _union_point_id(
|
| 100 |
+
dataset_name=str(args.dataset),
|
| 101 |
+
source_doc_id=str(src),
|
| 102 |
+
union_namespace=str(args.collection),
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
remapped_qrels: Dict[str, Dict[str, int]] = {}
|
| 106 |
+
for qid, rels in qrels.items():
|
| 107 |
+
out: Dict[str, int] = {}
|
| 108 |
+
for did, score in rels.items():
|
| 109 |
+
if int(score) <= 0:
|
| 110 |
+
continue
|
| 111 |
+
mapped = id_map.get(str(did))
|
| 112 |
+
if mapped:
|
| 113 |
+
out[str(mapped)] = int(score)
|
| 114 |
+
if out:
|
| 115 |
+
remapped_qrels[str(qid)] = out
|
| 116 |
+
|
| 117 |
+
# Connectivity + counts
|
| 118 |
+
client = _infer_qdrant_conn(bool(args.prefer_grpc), int(args.timeout))
|
| 119 |
+
ds_cnt, all_cnt = _count_by_dataset(client, str(args.collection), str(args.dataset))
|
| 120 |
+
print(f"Qdrant counts: dataset={ds_cnt} / all={all_cnt} (collection={args.collection})")
|
| 121 |
+
|
| 122 |
+
# Pick queries that still have qrels after remap
|
| 123 |
+
kept = [q for q in queries if str(q.query_id) in remapped_qrels]
|
| 124 |
+
kept = kept[: int(args.max_queries)]
|
| 125 |
+
print(f"Queries kept after qrels remap: {len(kept)} (showing up to {args.max_queries})")
|
| 126 |
+
|
| 127 |
+
# Build retriever with the exact same embedder/retrieval path.
|
| 128 |
+
td = None
|
| 129 |
+
if str(args.torch_dtype) != "auto":
|
| 130 |
+
import torch
|
| 131 |
+
|
| 132 |
+
td = {"float32": torch.float32, "float16": torch.float16, "bfloat16": torch.bfloat16}[
|
| 133 |
+
str(args.torch_dtype)
|
| 134 |
+
]
|
| 135 |
+
embedder = VisualEmbedder(model_name=str(args.model), torch_dtype=td)
|
| 136 |
+
retriever = MultiVectorRetriever(
|
| 137 |
+
collection_name=str(args.collection),
|
| 138 |
+
model_name=str(args.model),
|
| 139 |
+
embedder=embedder,
|
| 140 |
+
qdrant_client=client,
|
| 141 |
+
prefer_grpc=bool(args.prefer_grpc),
|
| 142 |
+
request_timeout=int(args.timeout),
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
flt = qm.Filter(
|
| 146 |
+
must=[qm.FieldCondition(key="dataset", match=qm.MatchValue(value=str(args.dataset)))]
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
for i, q in enumerate(kept):
|
| 150 |
+
qid = str(q.query_id)
|
| 151 |
+
rels = remapped_qrels.get(qid, {})
|
| 152 |
+
# Only positive qrels are truly relevant.
|
| 153 |
+
rel_ids = [rid for rid, s in (rels or {}).items() if int(s) > 0]
|
| 154 |
+
print("\n" + "-" * 90)
|
| 155 |
+
print(f"Q{i}: {qid} text={q.text[:120]!r}")
|
| 156 |
+
print(f" relevant_ids(remapped)={len(rel_ids)} sample={rel_ids[:3]}")
|
| 157 |
+
|
| 158 |
+
# Check if relevant IDs exist in Qdrant at all
|
| 159 |
+
exists = 0
|
| 160 |
+
try:
|
| 161 |
+
recs = client.retrieve(
|
| 162 |
+
collection_name=str(args.collection),
|
| 163 |
+
ids=rel_ids[:20],
|
| 164 |
+
with_payload=False,
|
| 165 |
+
with_vectors=False,
|
| 166 |
+
timeout=int(args.timeout),
|
| 167 |
+
)
|
| 168 |
+
exists = len(recs)
|
| 169 |
+
except Exception:
|
| 170 |
+
exists = 0
|
| 171 |
+
print(f" relevant_ids_exist_in_qdrant(sample<=20): {exists}")
|
| 172 |
+
|
| 173 |
+
# Search per_dataset filter
|
| 174 |
+
res = retriever.search(q.text, top_k=int(args.top_k), mode="single_full", filter_obj=flt)
|
| 175 |
+
ranked = [str(r["id"]) for r in res]
|
| 176 |
+
# Find best rank of any relevant doc
|
| 177 |
+
best_rank = None
|
| 178 |
+
for rid in rel_ids:
|
| 179 |
+
if rid in ranked:
|
| 180 |
+
rnk = ranked.index(rid) + 1
|
| 181 |
+
best_rank = rnk if best_rank is None else min(best_rank, rnk)
|
| 182 |
+
print(f" best_rank_in_top{args.top_k} (per_dataset filter): {best_rank}")
|
| 183 |
+
print(f" top10 ids: {ranked[:10]}")
|
| 184 |
+
|
| 185 |
+
print("\nDone.")
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
if __name__ == "__main__":
|
| 189 |
+
main()
|
scripts/dedupe_failure_logs.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import argparse
|
| 2 |
+
import json
|
| 3 |
+
import os
|
| 4 |
+
import tempfile
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def _iter_jsonl(path: Path):
|
| 9 |
+
if not path.exists():
|
| 10 |
+
return
|
| 11 |
+
with path.open("r", encoding="utf-8") as f:
|
| 12 |
+
for line in f:
|
| 13 |
+
s = (line or "").strip()
|
| 14 |
+
if not s:
|
| 15 |
+
continue
|
| 16 |
+
yield json.loads(s)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _key(obj: dict) -> str:
|
| 20 |
+
for k in ("union_doc_id", "id", "doc_id", "source_doc_id"):
|
| 21 |
+
v = obj.get(k)
|
| 22 |
+
if v:
|
| 23 |
+
return str(v)
|
| 24 |
+
return json.dumps(obj, sort_keys=True)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _write_atomic(path: Path, lines: list[str]) -> None:
|
| 28 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 29 |
+
fd, tmp = tempfile.mkstemp(prefix=path.name + ".", dir=str(path.parent))
|
| 30 |
+
try:
|
| 31 |
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
| 32 |
+
for ln in lines:
|
| 33 |
+
f.write(ln)
|
| 34 |
+
f.write("\n")
|
| 35 |
+
os.replace(tmp, path)
|
| 36 |
+
finally:
|
| 37 |
+
try:
|
| 38 |
+
if os.path.exists(tmp):
|
| 39 |
+
os.unlink(tmp)
|
| 40 |
+
except Exception:
|
| 41 |
+
pass
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def dedupe_jsonl(path: Path) -> dict:
|
| 45 |
+
last_by_key: dict[str, dict] = {}
|
| 46 |
+
order: list[str] = []
|
| 47 |
+
for obj in _iter_jsonl(path):
|
| 48 |
+
k = _key(obj)
|
| 49 |
+
if k not in last_by_key:
|
| 50 |
+
order.append(k)
|
| 51 |
+
last_by_key[k] = obj
|
| 52 |
+
|
| 53 |
+
out_lines = [json.dumps(last_by_key[k], ensure_ascii=False) for k in order]
|
| 54 |
+
_write_atomic(path, out_lines)
|
| 55 |
+
return {"path": str(path), "unique": len(out_lines)}
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def main():
|
| 59 |
+
parser = argparse.ArgumentParser()
|
| 60 |
+
parser.add_argument("--paths", type=str, nargs="+", required=True)
|
| 61 |
+
args = parser.parse_args()
|
| 62 |
+
|
| 63 |
+
for p in args.paths:
|
| 64 |
+
path = Path(p)
|
| 65 |
+
res = dedupe_jsonl(path)
|
| 66 |
+
print(f"{res['path']}: unique={res['unique']}")
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
if __name__ == "__main__":
|
| 70 |
+
main()
|
scripts/force_qdrant_reindex.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import argparse
|
| 2 |
+
import os
|
| 3 |
+
import time
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def _maybe_load_dotenv() -> None:
|
| 8 |
+
try:
|
| 9 |
+
from dotenv import load_dotenv
|
| 10 |
+
except ImportError:
|
| 11 |
+
return
|
| 12 |
+
if Path(".env").exists():
|
| 13 |
+
load_dotenv(".env")
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _indexed_total(indexed_vectors_count) -> int:
|
| 17 |
+
if indexed_vectors_count is None:
|
| 18 |
+
return 0
|
| 19 |
+
if isinstance(indexed_vectors_count, dict):
|
| 20 |
+
try:
|
| 21 |
+
return int(sum(int(v) for v in indexed_vectors_count.values()))
|
| 22 |
+
except Exception:
|
| 23 |
+
return 0
|
| 24 |
+
try:
|
| 25 |
+
return int(indexed_vectors_count)
|
| 26 |
+
except Exception:
|
| 27 |
+
return 0
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def main() -> None:
|
| 31 |
+
parser = argparse.ArgumentParser()
|
| 32 |
+
parser.add_argument("--collection", type=str, required=True)
|
| 33 |
+
parser.add_argument("--prefer-grpc", action="store_true")
|
| 34 |
+
parser.add_argument("--url", type=str, default="")
|
| 35 |
+
parser.add_argument("--api-key", type=str, default="")
|
| 36 |
+
parser.add_argument("--indexing-threshold", type=int, default=0)
|
| 37 |
+
parser.add_argument("--m", type=int, default=32)
|
| 38 |
+
parser.add_argument("--ef-construct", type=int, default=100)
|
| 39 |
+
parser.add_argument("--full-scan-threshold", type=int, default=10000)
|
| 40 |
+
parser.add_argument(
|
| 41 |
+
"--on-disk",
|
| 42 |
+
action="store_true",
|
| 43 |
+
help="Store HNSW index on disk (recommended for large vectors).",
|
| 44 |
+
)
|
| 45 |
+
parser.add_argument("--max-indexing-threads", type=int, default=0)
|
| 46 |
+
parser.add_argument("--wait", action="store_true")
|
| 47 |
+
parser.add_argument("--timeout-sec", type=int, default=600)
|
| 48 |
+
parser.add_argument("--poll-sec", type=float, default=2.0)
|
| 49 |
+
args = parser.parse_args()
|
| 50 |
+
|
| 51 |
+
_maybe_load_dotenv()
|
| 52 |
+
|
| 53 |
+
qdrant_url = args.url or os.getenv("QDRANT_URL")
|
| 54 |
+
if not qdrant_url:
|
| 55 |
+
raise ValueError("QDRANT_URL not set")
|
| 56 |
+
qdrant_api_key = args.api_key or os.getenv("QDRANT_API_KEY")
|
| 57 |
+
|
| 58 |
+
from qdrant_client import QdrantClient
|
| 59 |
+
from qdrant_client.http import models
|
| 60 |
+
|
| 61 |
+
client = QdrantClient(
|
| 62 |
+
url=qdrant_url,
|
| 63 |
+
api_key=qdrant_api_key,
|
| 64 |
+
prefer_grpc=args.prefer_grpc,
|
| 65 |
+
check_compatibility=False,
|
| 66 |
+
timeout=120,
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
hnsw = models.HnswConfigDiff(
|
| 70 |
+
m=int(args.m),
|
| 71 |
+
ef_construct=int(args.ef_construct),
|
| 72 |
+
full_scan_threshold=int(args.full_scan_threshold),
|
| 73 |
+
on_disk=bool(args.on_disk),
|
| 74 |
+
max_indexing_threads=int(args.max_indexing_threads),
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
vectors_config = {
|
| 78 |
+
"initial": models.VectorParamsDiff(hnsw_config=hnsw, on_disk=True),
|
| 79 |
+
"mean_pooling": models.VectorParamsDiff(hnsw_config=hnsw),
|
| 80 |
+
"global_pooling": models.VectorParamsDiff(hnsw_config=hnsw),
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
client.update_collection(
|
| 84 |
+
collection_name=args.collection,
|
| 85 |
+
optimizers_config=models.OptimizersConfigDiff(
|
| 86 |
+
indexing_threshold=int(args.indexing_threshold)
|
| 87 |
+
),
|
| 88 |
+
hnsw_config=hnsw,
|
| 89 |
+
vectors_config=vectors_config,
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
info = client.get_collection(args.collection)
|
| 93 |
+
print(
|
| 94 |
+
f"Triggered reindex update for '{args.collection}'. "
|
| 95 |
+
f"points={info.points_count}, indexed_vectors={info.indexed_vectors_count}, "
|
| 96 |
+
f"status={getattr(getattr(info.status, 'value', None), 'value', getattr(info, 'status', None))}"
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
if not args.wait:
|
| 100 |
+
return
|
| 101 |
+
|
| 102 |
+
start = time.time()
|
| 103 |
+
while True:
|
| 104 |
+
info = client.get_collection(args.collection)
|
| 105 |
+
indexed_total = _indexed_total(info.indexed_vectors_count)
|
| 106 |
+
total = int(info.points_count or 0)
|
| 107 |
+
print(
|
| 108 |
+
f"poll: points={info.points_count}, indexed_vectors={info.indexed_vectors_count}, "
|
| 109 |
+
f"segments={getattr(info, 'segments_count', None)}"
|
| 110 |
+
)
|
| 111 |
+
if total > 0 and indexed_total >= total:
|
| 112 |
+
return
|
| 113 |
+
if time.time() - start > args.timeout_sec:
|
| 114 |
+
return
|
| 115 |
+
time.sleep(max(0.1, float(args.poll_sec)))
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
if __name__ == "__main__":
|
| 119 |
+
main()
|
scripts/inspect_qdrant_collection.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import argparse
|
| 2 |
+
import json
|
| 3 |
+
import os
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def _maybe_load_dotenv() -> None:
|
| 8 |
+
try:
|
| 9 |
+
from dotenv import load_dotenv
|
| 10 |
+
except ImportError:
|
| 11 |
+
return
|
| 12 |
+
if Path(".env").exists():
|
| 13 |
+
load_dotenv(".env")
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _as_jsonable(obj):
|
| 17 |
+
if obj is None:
|
| 18 |
+
return None
|
| 19 |
+
if isinstance(obj, (str, int, float, bool)):
|
| 20 |
+
return obj
|
| 21 |
+
if isinstance(obj, dict):
|
| 22 |
+
return {str(k): _as_jsonable(v) for k, v in obj.items()}
|
| 23 |
+
if isinstance(obj, (list, tuple)):
|
| 24 |
+
return [_as_jsonable(v) for v in obj]
|
| 25 |
+
if hasattr(obj, "model_dump"):
|
| 26 |
+
try:
|
| 27 |
+
return obj.model_dump()
|
| 28 |
+
except Exception:
|
| 29 |
+
pass
|
| 30 |
+
if hasattr(obj, "__dict__"):
|
| 31 |
+
try:
|
| 32 |
+
return {k: _as_jsonable(v) for k, v in obj.__dict__.items()}
|
| 33 |
+
except Exception:
|
| 34 |
+
pass
|
| 35 |
+
return str(obj)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def main() -> None:
|
| 39 |
+
parser = argparse.ArgumentParser()
|
| 40 |
+
parser.add_argument("--collection", type=str, required=True)
|
| 41 |
+
parser.add_argument("--prefer-grpc", action="store_true")
|
| 42 |
+
parser.add_argument("--url", type=str, default="")
|
| 43 |
+
parser.add_argument("--api-key", type=str, default="")
|
| 44 |
+
parser.add_argument("--out", type=str, default="")
|
| 45 |
+
args = parser.parse_args()
|
| 46 |
+
|
| 47 |
+
_maybe_load_dotenv()
|
| 48 |
+
|
| 49 |
+
qdrant_url = args.url or os.getenv("QDRANT_URL")
|
| 50 |
+
if not qdrant_url:
|
| 51 |
+
raise ValueError("QDRANT_URL not set")
|
| 52 |
+
qdrant_api_key = args.api_key or os.getenv("QDRANT_API_KEY")
|
| 53 |
+
|
| 54 |
+
from qdrant_client import QdrantClient
|
| 55 |
+
|
| 56 |
+
client = QdrantClient(
|
| 57 |
+
url=qdrant_url,
|
| 58 |
+
api_key=qdrant_api_key,
|
| 59 |
+
prefer_grpc=args.prefer_grpc,
|
| 60 |
+
check_compatibility=False,
|
| 61 |
+
timeout=120,
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
info = client.get_collection(args.collection)
|
| 65 |
+
payload_schema = getattr(info, "payload_schema", None)
|
| 66 |
+
snap = {
|
| 67 |
+
"collection": args.collection,
|
| 68 |
+
"points_count": _as_jsonable(getattr(info, "points_count", None)),
|
| 69 |
+
"indexed_vectors_count": _as_jsonable(getattr(info, "indexed_vectors_count", None)),
|
| 70 |
+
"segments_count": _as_jsonable(getattr(info, "segments_count", None)),
|
| 71 |
+
"status": _as_jsonable(
|
| 72 |
+
getattr(getattr(info, "status", None), "value", getattr(info, "status", None))
|
| 73 |
+
),
|
| 74 |
+
"optimizer_status": _as_jsonable(
|
| 75 |
+
getattr(
|
| 76 |
+
getattr(info, "optimizer_status", None),
|
| 77 |
+
"value",
|
| 78 |
+
getattr(info, "optimizer_status", None),
|
| 79 |
+
)
|
| 80 |
+
),
|
| 81 |
+
"config": _as_jsonable(getattr(info, "config", None)),
|
| 82 |
+
"payload_schema": _as_jsonable(payload_schema),
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
print(json.dumps(snap, indent=2)[:10000])
|
| 86 |
+
if args.out:
|
| 87 |
+
out_path = Path(args.out)
|
| 88 |
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
| 89 |
+
with open(out_path, "w") as f:
|
| 90 |
+
json.dump(snap, f, indent=2)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
if __name__ == "__main__":
|
| 94 |
+
main()
|
scripts/qdrant_clone_collection_no_index.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Clone an existing Qdrant collection into a new collection with indexing disabled.
|
| 3 |
+
|
| 4 |
+
Why: Qdrant doesn't provide an in-place "de-index" for already-built HNSW.
|
| 5 |
+
This script clones points (payload + vectors) into a fresh collection created
|
| 6 |
+
with a very large `indexing_threshold`, so `indexed_vectors_count` stays 0.
|
| 7 |
+
|
| 8 |
+
Usage:
|
| 9 |
+
python scripts/qdrant_clone_collection_no_index.py \
|
| 10 |
+
--source vidore_beir_v2_... \
|
| 11 |
+
--dest vidore_beir_v2_...__noindex \
|
| 12 |
+
--embedding-dim 128 \
|
| 13 |
+
--vector-dtype float32 \
|
| 14 |
+
--indexing-threshold 1000000000 \
|
| 15 |
+
--prefer-grpc
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import argparse
|
| 21 |
+
import os
|
| 22 |
+
from typing import Any, Dict, List, Optional, Sequence
|
| 23 |
+
|
| 24 |
+
try:
|
| 25 |
+
from dotenv import load_dotenv
|
| 26 |
+
|
| 27 |
+
DOTENV_AVAILABLE = True
|
| 28 |
+
except Exception:
|
| 29 |
+
DOTENV_AVAILABLE = False
|
| 30 |
+
|
| 31 |
+
from qdrant_client import QdrantClient
|
| 32 |
+
from qdrant_client.http import models as qm
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _get_env(name: str) -> Optional[str]:
|
| 36 |
+
v = os.getenv(name)
|
| 37 |
+
if v is None:
|
| 38 |
+
return None
|
| 39 |
+
v = str(v).strip()
|
| 40 |
+
return v or None
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _require(value: Optional[str], *, name: str) -> str:
|
| 44 |
+
if not value:
|
| 45 |
+
raise SystemExit(
|
| 46 |
+
f"Missing {name}. Set it in env (preferred) or pass the corresponding flag."
|
| 47 |
+
)
|
| 48 |
+
return value
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _chunks(seq: Sequence[Any], n: int) -> List[Sequence[Any]]:
|
| 52 |
+
return [seq[i : i + n] for i in range(0, len(seq), n)]
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def main() -> None:
|
| 56 |
+
parser = argparse.ArgumentParser()
|
| 57 |
+
parser.add_argument("--source", required=True, help="Existing source collection name")
|
| 58 |
+
parser.add_argument("--dest", required=True, help="Destination collection name")
|
| 59 |
+
parser.add_argument(
|
| 60 |
+
"--qdrant-url",
|
| 61 |
+
default=None,
|
| 62 |
+
help="Qdrant URL (or set QDRANT_URL env var)",
|
| 63 |
+
)
|
| 64 |
+
parser.add_argument(
|
| 65 |
+
"--qdrant-api-key",
|
| 66 |
+
default=None,
|
| 67 |
+
help="Qdrant API key (or set QDRANT_API_KEY env var)",
|
| 68 |
+
)
|
| 69 |
+
parser.add_argument(
|
| 70 |
+
"--prefer-grpc",
|
| 71 |
+
action="store_true",
|
| 72 |
+
help="Use gRPC transport (recommended for large vectors)",
|
| 73 |
+
)
|
| 74 |
+
parser.add_argument(
|
| 75 |
+
"--timeout",
|
| 76 |
+
type=float,
|
| 77 |
+
default=300.0,
|
| 78 |
+
help="Request timeout seconds",
|
| 79 |
+
)
|
| 80 |
+
parser.add_argument(
|
| 81 |
+
"--embedding-dim",
|
| 82 |
+
type=int,
|
| 83 |
+
default=128,
|
| 84 |
+
help="Vector dimension (typically 128 for ColPali/ColQwen)",
|
| 85 |
+
)
|
| 86 |
+
parser.add_argument(
|
| 87 |
+
"--vector-dtype",
|
| 88 |
+
choices=["float16", "float32"],
|
| 89 |
+
default="float32",
|
| 90 |
+
help="Vector datatype for destination collection",
|
| 91 |
+
)
|
| 92 |
+
parser.add_argument(
|
| 93 |
+
"--indexing-threshold",
|
| 94 |
+
type=int,
|
| 95 |
+
default=1_000_000_000,
|
| 96 |
+
help="Large value prevents HNSW building for small collections",
|
| 97 |
+
)
|
| 98 |
+
# Note: some qdrant-client versions don't support full_scan_threshold in OptimizersConfigDiff.
|
| 99 |
+
parser.add_argument(
|
| 100 |
+
"--recreate-dest",
|
| 101 |
+
action="store_true",
|
| 102 |
+
help="Delete destination collection if it already exists",
|
| 103 |
+
)
|
| 104 |
+
parser.add_argument(
|
| 105 |
+
"--scroll-limit",
|
| 106 |
+
type=int,
|
| 107 |
+
default=256,
|
| 108 |
+
help="How many points to fetch per scroll call",
|
| 109 |
+
)
|
| 110 |
+
parser.add_argument(
|
| 111 |
+
"--upsert-batch-size",
|
| 112 |
+
type=int,
|
| 113 |
+
default=64,
|
| 114 |
+
help="How many points per upsert call",
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
args = parser.parse_args()
|
| 118 |
+
|
| 119 |
+
if DOTENV_AVAILABLE:
|
| 120 |
+
load_dotenv()
|
| 121 |
+
|
| 122 |
+
url = args.qdrant_url or _get_env("QDRANT_URL")
|
| 123 |
+
api_key = args.qdrant_api_key or _get_env("QDRANT_API_KEY")
|
| 124 |
+
url = _require(url, name="QDRANT_URL/--qdrant-url")
|
| 125 |
+
api_key = _require(api_key, name="QDRANT_API_KEY/--qdrant-api-key")
|
| 126 |
+
|
| 127 |
+
client = QdrantClient(
|
| 128 |
+
url=url,
|
| 129 |
+
api_key=api_key,
|
| 130 |
+
prefer_grpc=bool(args.prefer_grpc),
|
| 131 |
+
timeout=float(args.timeout),
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
# Verify source exists
|
| 135 |
+
src_info = client.get_collection(args.source)
|
| 136 |
+
print(f"✅ Source collection found: {args.source}")
|
| 137 |
+
print(
|
| 138 |
+
f" points_count≈{src_info.points_count} indexed_vectors_count={src_info.indexed_vectors_count}"
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
# Create/recreate destination with the same named vectors layout
|
| 142 |
+
if args.recreate_dest:
|
| 143 |
+
try:
|
| 144 |
+
client.delete_collection(args.dest)
|
| 145 |
+
print(f"🗑️ Deleted existing destination: {args.dest}")
|
| 146 |
+
except Exception as e:
|
| 147 |
+
print(f"⚠️ Could not delete dest (may not exist): {e}")
|
| 148 |
+
|
| 149 |
+
# Use same vector names as toolkit expects
|
| 150 |
+
datatype = qm.Datatype.FLOAT16 if args.vector_dtype == "float16" else qm.Datatype.FLOAT32
|
| 151 |
+
multivector_config = qm.MultiVectorConfig(comparator=qm.MultiVectorComparator.MAX_SIM)
|
| 152 |
+
vectors_config: Dict[str, qm.VectorParams] = {
|
| 153 |
+
"initial": qm.VectorParams(
|
| 154 |
+
size=int(args.embedding_dim),
|
| 155 |
+
distance=qm.Distance.COSINE,
|
| 156 |
+
on_disk=True,
|
| 157 |
+
multivector_config=multivector_config,
|
| 158 |
+
datatype=datatype,
|
| 159 |
+
),
|
| 160 |
+
"mean_pooling": qm.VectorParams(
|
| 161 |
+
size=int(args.embedding_dim),
|
| 162 |
+
distance=qm.Distance.COSINE,
|
| 163 |
+
on_disk=False,
|
| 164 |
+
multivector_config=multivector_config,
|
| 165 |
+
datatype=datatype,
|
| 166 |
+
),
|
| 167 |
+
"experimental_pooling": qm.VectorParams(
|
| 168 |
+
size=int(args.embedding_dim),
|
| 169 |
+
distance=qm.Distance.COSINE,
|
| 170 |
+
on_disk=False,
|
| 171 |
+
multivector_config=multivector_config,
|
| 172 |
+
datatype=datatype,
|
| 173 |
+
),
|
| 174 |
+
"global_pooling": qm.VectorParams(
|
| 175 |
+
size=int(args.embedding_dim),
|
| 176 |
+
distance=qm.Distance.COSINE,
|
| 177 |
+
on_disk=False,
|
| 178 |
+
datatype=datatype,
|
| 179 |
+
),
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
try:
|
| 183 |
+
client.create_collection(
|
| 184 |
+
collection_name=args.dest,
|
| 185 |
+
vectors_config=vectors_config,
|
| 186 |
+
optimizers_config=qm.OptimizersConfigDiff(
|
| 187 |
+
indexing_threshold=int(args.indexing_threshold),
|
| 188 |
+
),
|
| 189 |
+
)
|
| 190 |
+
# Keep filename index for skip_existing (cheap and helpful)
|
| 191 |
+
try:
|
| 192 |
+
client.create_payload_index(
|
| 193 |
+
collection_name=args.dest,
|
| 194 |
+
field_name="filename",
|
| 195 |
+
field_schema=qm.PayloadSchemaType.KEYWORD,
|
| 196 |
+
)
|
| 197 |
+
except Exception:
|
| 198 |
+
pass
|
| 199 |
+
print(f"✅ Created destination collection: {args.dest}")
|
| 200 |
+
except Exception as e:
|
| 201 |
+
# If it already exists, proceed (unless user expected recreate)
|
| 202 |
+
print(f"ℹ️ Destination create skipped/failed (may already exist): {e}")
|
| 203 |
+
|
| 204 |
+
# Clone points
|
| 205 |
+
next_offset = None
|
| 206 |
+
total = 0
|
| 207 |
+
|
| 208 |
+
while True:
|
| 209 |
+
points, next_offset = client.scroll(
|
| 210 |
+
collection_name=args.source,
|
| 211 |
+
limit=int(args.scroll_limit),
|
| 212 |
+
with_payload=True,
|
| 213 |
+
with_vectors=True,
|
| 214 |
+
offset=next_offset,
|
| 215 |
+
)
|
| 216 |
+
if not points:
|
| 217 |
+
break
|
| 218 |
+
|
| 219 |
+
# Upsert in batches
|
| 220 |
+
for batch in _chunks(points, int(args.upsert_batch_size)):
|
| 221 |
+
upsert_points: List[qm.PointStruct] = []
|
| 222 |
+
for p in batch:
|
| 223 |
+
# p.vector may be dict (named vectors) or list (single). We expect dict.
|
| 224 |
+
vectors = p.vector
|
| 225 |
+
payload = p.payload or {}
|
| 226 |
+
upsert_points.append(
|
| 227 |
+
qm.PointStruct(
|
| 228 |
+
id=p.id,
|
| 229 |
+
vector=vectors,
|
| 230 |
+
payload=payload,
|
| 231 |
+
)
|
| 232 |
+
)
|
| 233 |
+
|
| 234 |
+
client.upsert(
|
| 235 |
+
collection_name=args.dest,
|
| 236 |
+
points=upsert_points,
|
| 237 |
+
wait=True,
|
| 238 |
+
)
|
| 239 |
+
total += len(upsert_points)
|
| 240 |
+
if total % 500 == 0:
|
| 241 |
+
print(f"… cloned {total} points")
|
| 242 |
+
|
| 243 |
+
dst_info = client.get_collection(args.dest)
|
| 244 |
+
exact = client.count(collection_name=args.dest, exact=True)
|
| 245 |
+
print("✅ Clone complete")
|
| 246 |
+
print(
|
| 247 |
+
f" dest.points_count≈{dst_info.points_count} dest.indexed_vectors_count={dst_info.indexed_vectors_count}"
|
| 248 |
+
)
|
| 249 |
+
print(f" dest.count(exact)= {exact.count}")
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
if __name__ == "__main__":
|
| 253 |
+
main()
|
scripts/qdrant_debug_collection.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Qdrant collection debugging / inspection helpers.
|
| 3 |
+
|
| 4 |
+
This script is intentionally lightweight and safe:
|
| 5 |
+
- Read-only operations (count/scroll/retrieve)
|
| 6 |
+
- Prints exact vs approximate counts (Qdrant UI often shows approximate)
|
| 7 |
+
- Can verify that IDs listed in index_failures__*.jsonl logs are actually present
|
| 8 |
+
|
| 9 |
+
Examples:
|
| 10 |
+
|
| 11 |
+
# Inspect counts + vector sanity (REST)
|
| 12 |
+
python scripts/qdrant_debug_collection.py \\
|
| 13 |
+
--collection vidore_beir_v2_3ds__colqwen25_v0_2__nocrop__union__fp32__grpc
|
| 14 |
+
|
| 15 |
+
# Same, but via gRPC
|
| 16 |
+
python scripts/qdrant_debug_collection.py \\
|
| 17 |
+
--collection vidore_beir_v2_3ds__colqwen25_v0_2__nocrop__union__fp32__grpc \\
|
| 18 |
+
--prefer-grpc
|
| 19 |
+
|
| 20 |
+
# Count per dataset (exact)
|
| 21 |
+
python scripts/qdrant_debug_collection.py \\
|
| 22 |
+
--collection <COLLECTION> \\
|
| 23 |
+
--datasets vidore/esg_reports_v2 vidore/biomedical_lectures_v2 vidore/economics_reports_v2
|
| 24 |
+
|
| 25 |
+
# Verify that any IDs in index_failures logs are present in Qdrant
|
| 26 |
+
python scripts/qdrant_debug_collection.py \\
|
| 27 |
+
--collection <COLLECTION> \\
|
| 28 |
+
--check-failures
|
| 29 |
+
|
| 30 |
+
Environment:
|
| 31 |
+
export QDRANT_URL=...
|
| 32 |
+
export QDRANT_API_KEY=... # optional
|
| 33 |
+
|
| 34 |
+
Or create a .env in repo root with the same variables.
|
| 35 |
+
"""
|
| 36 |
+
|
| 37 |
+
from __future__ import annotations
|
| 38 |
+
|
| 39 |
+
import argparse
|
| 40 |
+
import json
|
| 41 |
+
import os
|
| 42 |
+
from collections import Counter
|
| 43 |
+
from pathlib import Path
|
| 44 |
+
from typing import Iterable
|
| 45 |
+
|
| 46 |
+
from qdrant_client import QdrantClient
|
| 47 |
+
from qdrant_client.http import models as qm
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _maybe_load_dotenv() -> None:
|
| 51 |
+
try:
|
| 52 |
+
from dotenv import load_dotenv
|
| 53 |
+
except Exception:
|
| 54 |
+
return
|
| 55 |
+
|
| 56 |
+
for p in (Path(".env"), Path("..") / ".env"):
|
| 57 |
+
if p.exists():
|
| 58 |
+
load_dotenv(p)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _chunks(xs: list[str], n: int) -> Iterable[list[str]]:
|
| 62 |
+
for i in range(0, len(xs), n):
|
| 63 |
+
yield xs[i : i + n]
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def inspect_collection(*, client: QdrantClient, collection: str, sample_points: int) -> None:
|
| 67 |
+
info = client.get_collection(collection)
|
| 68 |
+
print("collection:", collection)
|
| 69 |
+
print("status:", info.status)
|
| 70 |
+
print("optimizer_status:", info.optimizer_status)
|
| 71 |
+
print("indexed_vectors_count:", info.indexed_vectors_count)
|
| 72 |
+
print()
|
| 73 |
+
|
| 74 |
+
approx = client.count(collection_name=collection, exact=False).count
|
| 75 |
+
exact = client.count(collection_name=collection, exact=True).count
|
| 76 |
+
print("info.points_count (approx):", info.points_count)
|
| 77 |
+
print("count(exact=False):", approx)
|
| 78 |
+
print("count(exact=True): ", exact)
|
| 79 |
+
|
| 80 |
+
if sample_points > 0:
|
| 81 |
+
points, _ = client.scroll(
|
| 82 |
+
collection_name=collection,
|
| 83 |
+
limit=int(sample_points),
|
| 84 |
+
with_payload=True,
|
| 85 |
+
with_vectors=True,
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
print("\nSample points vector sanity:")
|
| 89 |
+
for p in points:
|
| 90 |
+
vecs = p.vector or {}
|
| 91 |
+
|
| 92 |
+
def _len(v):
|
| 93 |
+
try:
|
| 94 |
+
return len(v)
|
| 95 |
+
except Exception:
|
| 96 |
+
return None
|
| 97 |
+
|
| 98 |
+
lengths = {
|
| 99 |
+
k: _len(v)
|
| 100 |
+
for k, v in vecs.items()
|
| 101 |
+
if k in {"initial", "mean_pooling", "experimental_pooling", "global_pooling"}
|
| 102 |
+
}
|
| 103 |
+
print("id:", p.id)
|
| 104 |
+
print(" dataset:", (p.payload or {}).get("dataset"))
|
| 105 |
+
print(" vector_keys:", sorted(list(vecs.keys())))
|
| 106 |
+
print(" lengths:", lengths)
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def count_per_dataset(*, client: QdrantClient, collection: str, datasets: list[str]) -> None:
|
| 110 |
+
if not datasets:
|
| 111 |
+
return
|
| 112 |
+
print("\nper-dataset exact counts:")
|
| 113 |
+
total = 0
|
| 114 |
+
for ds in datasets:
|
| 115 |
+
c = client.count(
|
| 116 |
+
collection_name=collection,
|
| 117 |
+
count_filter=qm.Filter(
|
| 118 |
+
must=[
|
| 119 |
+
qm.FieldCondition(key="dataset", match=qm.MatchValue(value=str(ds))),
|
| 120 |
+
]
|
| 121 |
+
),
|
| 122 |
+
exact=True,
|
| 123 |
+
).count
|
| 124 |
+
print(f"- {ds}: {c}")
|
| 125 |
+
total += int(c)
|
| 126 |
+
print("sum_datasets_exact:", total)
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def dataset_distribution_scroll(*, client: QdrantClient, collection: str, limit: int) -> None:
|
| 130 |
+
values: Counter[str] = Counter()
|
| 131 |
+
offset = None
|
| 132 |
+
seen = 0
|
| 133 |
+
while True:
|
| 134 |
+
points, next_offset = client.scroll(
|
| 135 |
+
collection_name=collection,
|
| 136 |
+
limit=min(int(limit), 2048),
|
| 137 |
+
offset=offset,
|
| 138 |
+
with_payload=True,
|
| 139 |
+
with_vectors=False,
|
| 140 |
+
)
|
| 141 |
+
if not points:
|
| 142 |
+
break
|
| 143 |
+
for p in points:
|
| 144 |
+
ds = (p.payload or {}).get("dataset")
|
| 145 |
+
values[str(ds)] += 1
|
| 146 |
+
seen += len(points)
|
| 147 |
+
offset = next_offset
|
| 148 |
+
if next_offset is None or (limit and seen >= int(limit)):
|
| 149 |
+
break
|
| 150 |
+
|
| 151 |
+
print("\nscroll distribution (dataset field):")
|
| 152 |
+
print("scrolled_points:", seen)
|
| 153 |
+
for k, v in values.most_common(20):
|
| 154 |
+
print(" ", k, v)
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def check_failure_logs_present(
|
| 158 |
+
*,
|
| 159 |
+
client: QdrantClient,
|
| 160 |
+
collection: str,
|
| 161 |
+
results_dir: Path,
|
| 162 |
+
retrieve_batch: int,
|
| 163 |
+
) -> None:
|
| 164 |
+
base = results_dir / collection
|
| 165 |
+
if not base.exists():
|
| 166 |
+
raise SystemExit(f"results dir not found: {base}")
|
| 167 |
+
|
| 168 |
+
log_paths = sorted(base.glob("index_failures__*.jsonl"))
|
| 169 |
+
if not log_paths:
|
| 170 |
+
print("\nNo failure logs found under:", base)
|
| 171 |
+
return
|
| 172 |
+
|
| 173 |
+
failed_ids: set[str] = set()
|
| 174 |
+
for p in log_paths:
|
| 175 |
+
for line in p.read_text().splitlines():
|
| 176 |
+
line = (line or "").strip()
|
| 177 |
+
if not line:
|
| 178 |
+
continue
|
| 179 |
+
try:
|
| 180 |
+
obj = json.loads(line)
|
| 181 |
+
except Exception:
|
| 182 |
+
continue
|
| 183 |
+
u = obj.get("union_doc_id")
|
| 184 |
+
if u:
|
| 185 |
+
failed_ids.add(str(u))
|
| 186 |
+
|
| 187 |
+
print("\nfailure logs:")
|
| 188 |
+
for p in log_paths:
|
| 189 |
+
print(" -", p)
|
| 190 |
+
print("failed_ids_in_logs:", len(failed_ids))
|
| 191 |
+
|
| 192 |
+
missing: list[str] = []
|
| 193 |
+
ids = list(failed_ids)
|
| 194 |
+
for chunk in _chunks(ids, int(retrieve_batch)):
|
| 195 |
+
pts = client.retrieve(
|
| 196 |
+
collection_name=collection,
|
| 197 |
+
ids=chunk,
|
| 198 |
+
with_payload=False,
|
| 199 |
+
with_vectors=False,
|
| 200 |
+
)
|
| 201 |
+
present = set(str(p.id) for p in pts)
|
| 202 |
+
for pid in chunk:
|
| 203 |
+
if pid not in present:
|
| 204 |
+
missing.append(pid)
|
| 205 |
+
|
| 206 |
+
print("failed_ids_missing_in_qdrant:", len(missing))
|
| 207 |
+
if missing:
|
| 208 |
+
print("sample_missing_ids:", missing[:10])
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
def main() -> None:
|
| 212 |
+
p = argparse.ArgumentParser(description="Qdrant collection debug utilities")
|
| 213 |
+
p.add_argument("--collection", required=True)
|
| 214 |
+
p.add_argument("--prefer-grpc", action="store_true", default=False)
|
| 215 |
+
p.add_argument("--timeout", type=int, default=120)
|
| 216 |
+
p.add_argument("--datasets", nargs="*", default=[])
|
| 217 |
+
p.add_argument("--sample-points", type=int, default=5)
|
| 218 |
+
p.add_argument("--scroll-limit", type=int, default=0, help="0 = no full scroll distribution")
|
| 219 |
+
p.add_argument("--check-failures", action="store_true", default=False)
|
| 220 |
+
p.add_argument("--results-dir", type=str, default="results")
|
| 221 |
+
p.add_argument("--retrieve-batch", type=int, default=64)
|
| 222 |
+
args = p.parse_args()
|
| 223 |
+
|
| 224 |
+
_maybe_load_dotenv()
|
| 225 |
+
url = os.getenv("QDRANT_URL")
|
| 226 |
+
key = os.getenv("QDRANT_API_KEY")
|
| 227 |
+
if not url:
|
| 228 |
+
raise SystemExit("QDRANT_URL not set")
|
| 229 |
+
|
| 230 |
+
client = QdrantClient(
|
| 231 |
+
url=url,
|
| 232 |
+
api_key=key,
|
| 233 |
+
prefer_grpc=bool(args.prefer_grpc),
|
| 234 |
+
timeout=int(args.timeout),
|
| 235 |
+
)
|
| 236 |
+
|
| 237 |
+
inspect_collection(
|
| 238 |
+
client=client, collection=str(args.collection), sample_points=int(args.sample_points)
|
| 239 |
+
)
|
| 240 |
+
count_per_dataset(client=client, collection=str(args.collection), datasets=list(args.datasets))
|
| 241 |
+
|
| 242 |
+
if int(args.scroll_limit) > 0:
|
| 243 |
+
dataset_distribution_scroll(
|
| 244 |
+
client=client, collection=str(args.collection), limit=int(args.scroll_limit)
|
| 245 |
+
)
|
| 246 |
+
|
| 247 |
+
if bool(args.check_failures):
|
| 248 |
+
check_failure_logs_present(
|
| 249 |
+
client=client,
|
| 250 |
+
collection=str(args.collection),
|
| 251 |
+
results_dir=Path(str(args.results_dir)),
|
| 252 |
+
retrieve_batch=int(args.retrieve_batch),
|
| 253 |
+
)
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
if __name__ == "__main__":
|
| 257 |
+
main()
|
scripts/qdrant_disable_hnsw.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Disable dense (HNSW) indexing for a Qdrant collection (make indexed_vectors_count go to 0).
|
| 3 |
+
|
| 4 |
+
Qdrant supports disabling HNSW by setting `hnsw_config.m = 0`.
|
| 5 |
+
For an already-indexed collection, Qdrant may run a reconstruction/optimization pass.
|
| 6 |
+
Once that finishes, `indexed_vectors_count` should become 0.
|
| 7 |
+
|
| 8 |
+
Ref: "Optimizing Memory for Bulk Uploads" (Qdrant, Feb 2025) recommends `m=0` to disable HNSW.
|
| 9 |
+
|
| 10 |
+
Usage:
|
| 11 |
+
python scripts/qdrant_disable_hnsw.py --collection "my_collection" --wait
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import argparse
|
| 17 |
+
import json
|
| 18 |
+
import os
|
| 19 |
+
import time
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
from typing import Any, Optional
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _maybe_load_dotenv() -> None:
|
| 25 |
+
try:
|
| 26 |
+
from dotenv import load_dotenv
|
| 27 |
+
except Exception:
|
| 28 |
+
return
|
| 29 |
+
if Path(".env").exists():
|
| 30 |
+
load_dotenv(".env")
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _get_env(name: str) -> Optional[str]:
|
| 34 |
+
v = os.getenv(name)
|
| 35 |
+
if v is None:
|
| 36 |
+
return None
|
| 37 |
+
v = str(v).strip()
|
| 38 |
+
return v or None
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _as_jsonable(obj: Any):
|
| 42 |
+
if obj is None:
|
| 43 |
+
return None
|
| 44 |
+
if isinstance(obj, (str, int, float, bool)):
|
| 45 |
+
return obj
|
| 46 |
+
if isinstance(obj, dict):
|
| 47 |
+
return {str(k): _as_jsonable(v) for k, v in obj.items()}
|
| 48 |
+
if isinstance(obj, (list, tuple)):
|
| 49 |
+
return [_as_jsonable(v) for v in obj]
|
| 50 |
+
if hasattr(obj, "model_dump"):
|
| 51 |
+
try:
|
| 52 |
+
return obj.model_dump()
|
| 53 |
+
except Exception:
|
| 54 |
+
pass
|
| 55 |
+
return str(obj)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _indexed_total(indexed_vectors_count) -> int:
|
| 59 |
+
if indexed_vectors_count is None:
|
| 60 |
+
return 0
|
| 61 |
+
if isinstance(indexed_vectors_count, dict):
|
| 62 |
+
try:
|
| 63 |
+
return int(sum(int(v) for v in indexed_vectors_count.values()))
|
| 64 |
+
except Exception:
|
| 65 |
+
return 0
|
| 66 |
+
try:
|
| 67 |
+
return int(indexed_vectors_count)
|
| 68 |
+
except Exception:
|
| 69 |
+
return 0
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _snapshot(client, collection: str) -> dict:
|
| 73 |
+
info = client.get_collection(collection)
|
| 74 |
+
status = getattr(info, "status", None)
|
| 75 |
+
if hasattr(status, "value"):
|
| 76 |
+
status = status.value
|
| 77 |
+
optimizer_status = getattr(info, "optimizer_status", None)
|
| 78 |
+
if hasattr(optimizer_status, "value"):
|
| 79 |
+
optimizer_status = optimizer_status.value
|
| 80 |
+
return {
|
| 81 |
+
"status": _as_jsonable(status),
|
| 82 |
+
"optimizer_status": _as_jsonable(optimizer_status),
|
| 83 |
+
"points_count": _as_jsonable(getattr(info, "points_count", None)),
|
| 84 |
+
"indexed_vectors_count": _as_jsonable(getattr(info, "indexed_vectors_count", None)),
|
| 85 |
+
"segments_count": _as_jsonable(getattr(info, "segments_count", None)),
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def main() -> None:
|
| 90 |
+
parser = argparse.ArgumentParser()
|
| 91 |
+
parser.add_argument("--collection", required=True)
|
| 92 |
+
parser.add_argument("--prefer-grpc", action="store_true")
|
| 93 |
+
parser.add_argument("--url", default="")
|
| 94 |
+
parser.add_argument("--api-key", default="")
|
| 95 |
+
parser.add_argument("--timeout", type=float, default=120.0)
|
| 96 |
+
parser.add_argument("--wait", action="store_true")
|
| 97 |
+
parser.add_argument("--poll-sec", type=float, default=5.0)
|
| 98 |
+
parser.add_argument("--timeout-sec", type=float, default=1800.0)
|
| 99 |
+
parser.add_argument("--dump-json", default="", help="Optional path to dump snapshots JSON")
|
| 100 |
+
args = parser.parse_args()
|
| 101 |
+
|
| 102 |
+
_maybe_load_dotenv()
|
| 103 |
+
|
| 104 |
+
qdrant_url = args.url or _get_env("QDRANT_URL")
|
| 105 |
+
if not qdrant_url:
|
| 106 |
+
raise SystemExit("QDRANT_URL not set (or pass --url)")
|
| 107 |
+
qdrant_api_key = args.api_key or _get_env("QDRANT_API_KEY")
|
| 108 |
+
|
| 109 |
+
from qdrant_client import QdrantClient
|
| 110 |
+
from qdrant_client.http import models
|
| 111 |
+
|
| 112 |
+
client = QdrantClient(
|
| 113 |
+
url=qdrant_url,
|
| 114 |
+
api_key=qdrant_api_key,
|
| 115 |
+
prefer_grpc=bool(args.prefer_grpc),
|
| 116 |
+
check_compatibility=False,
|
| 117 |
+
timeout=float(args.timeout),
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
before = _snapshot(client, args.collection)
|
| 121 |
+
print(
|
| 122 |
+
f"Before: points={before['points_count']} indexed_vectors={before['indexed_vectors_count']} "
|
| 123 |
+
f"status={before['status']} optimizer={before['optimizer_status']} segments={before['segments_count']}"
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
# Disable HNSW for dense vectors
|
| 127 |
+
client.update_collection(
|
| 128 |
+
collection_name=args.collection,
|
| 129 |
+
hnsw_config=models.HnswConfigDiff(m=0),
|
| 130 |
+
)
|
| 131 |
+
after = _snapshot(client, args.collection)
|
| 132 |
+
print(
|
| 133 |
+
f"After update(m=0): points={after['points_count']} indexed_vectors={after['indexed_vectors_count']} "
|
| 134 |
+
f"status={after['status']} optimizer={after['optimizer_status']} segments={after['segments_count']}"
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
if args.dump_json:
|
| 138 |
+
out_path = Path(args.dump_json)
|
| 139 |
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
| 140 |
+
with open(out_path, "w") as f:
|
| 141 |
+
json.dump(
|
| 142 |
+
{
|
| 143 |
+
"collection": args.collection,
|
| 144 |
+
"before": before,
|
| 145 |
+
"after_update": after,
|
| 146 |
+
},
|
| 147 |
+
f,
|
| 148 |
+
indent=2,
|
| 149 |
+
)
|
| 150 |
+
|
| 151 |
+
if not args.wait:
|
| 152 |
+
return
|
| 153 |
+
|
| 154 |
+
start = time.time()
|
| 155 |
+
while True:
|
| 156 |
+
snap = _snapshot(client, args.collection)
|
| 157 |
+
indexed = _indexed_total(snap["indexed_vectors_count"])
|
| 158 |
+
if indexed == 0:
|
| 159 |
+
print(
|
| 160 |
+
f"✅ Done: indexed_vectors_count is 0. points={snap['points_count']} "
|
| 161 |
+
f"status={snap['status']} optimizer={snap['optimizer_status']}"
|
| 162 |
+
)
|
| 163 |
+
if args.dump_json:
|
| 164 |
+
out_path = Path(args.dump_json)
|
| 165 |
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
| 166 |
+
with open(out_path, "w") as f:
|
| 167 |
+
json.dump(
|
| 168 |
+
{
|
| 169 |
+
"collection": args.collection,
|
| 170 |
+
"before": before,
|
| 171 |
+
"after_update": after,
|
| 172 |
+
"complete": snap,
|
| 173 |
+
},
|
| 174 |
+
f,
|
| 175 |
+
indent=2,
|
| 176 |
+
)
|
| 177 |
+
return
|
| 178 |
+
if time.time() - start > float(args.timeout_sec):
|
| 179 |
+
print(
|
| 180 |
+
f"⏳ Timeout waiting for indexed_vectors_count=0. indexed_vectors={snap['indexed_vectors_count']}, "
|
| 181 |
+
f"points={snap['points_count']}, status={snap['status']}, optimizer={snap['optimizer_status']}"
|
| 182 |
+
)
|
| 183 |
+
return
|
| 184 |
+
print(
|
| 185 |
+
f"… waiting: indexed_vectors={snap['indexed_vectors_count']} points={snap['points_count']} "
|
| 186 |
+
f"status={snap['status']} optimizer={snap['optimizer_status']} segments={snap['segments_count']}"
|
| 187 |
+
)
|
| 188 |
+
time.sleep(max(0.2, float(args.poll_sec)))
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
if __name__ == "__main__":
|
| 192 |
+
main()
|
scripts/qdrant_modify_vectors_smoketest.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import argparse
|
| 2 |
+
from pprint import pprint
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
def main():
|
| 6 |
+
parser = argparse.ArgumentParser()
|
| 7 |
+
parser.add_argument("--collection", type=str, required=True)
|
| 8 |
+
parser.add_argument("--prefer-grpc", action="store_true", default=False)
|
| 9 |
+
args = parser.parse_args()
|
| 10 |
+
|
| 11 |
+
from visual_rag import QdrantAdmin
|
| 12 |
+
|
| 13 |
+
admin = QdrantAdmin(prefer_grpc=bool(args.prefer_grpc), timeout=60)
|
| 14 |
+
before = admin.get_collection_info(collection_name=str(args.collection))
|
| 15 |
+
print("BEFORE points_count=", before.get("points_count"))
|
| 16 |
+
existing = sorted(
|
| 17 |
+
(((before.get("config") or {}).get("params") or {}).get("vectors") or {}).keys()
|
| 18 |
+
)
|
| 19 |
+
print("BEFORE vectors=", existing)
|
| 20 |
+
|
| 21 |
+
after = admin.ensure_collection_all_on_disk(collection_name=str(args.collection))
|
| 22 |
+
|
| 23 |
+
print("AFTER points_count=", after.get("points_count"))
|
| 24 |
+
print("AFTER params.vectors:")
|
| 25 |
+
pprint(((after.get("config") or {}).get("params") or {}).get("vectors"))
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
if __name__ == "__main__":
|
| 29 |
+
main()
|
scripts/qdrant_rebuild_collection_no_index.py
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Rebuild a Qdrant collection so `indexed_vectors_count` becomes 0 (no ANN/HNSW built).
|
| 3 |
+
|
| 4 |
+
Important:
|
| 5 |
+
- Qdrant does NOT support "unbuilding" an existing HNSW index in-place.
|
| 6 |
+
- The only reliable way to get indexed_vectors_count back to 0 is to:
|
| 7 |
+
1) copy points to a temporary collection,
|
| 8 |
+
2) delete + recreate the original collection with a very large indexing_threshold,
|
| 9 |
+
3) copy points back,
|
| 10 |
+
4) delete the temporary collection.
|
| 11 |
+
|
| 12 |
+
This script keeps the *final* collection name unchanged.
|
| 13 |
+
|
| 14 |
+
Usage:
|
| 15 |
+
python scripts/qdrant_rebuild_collection_no_index.py \
|
| 16 |
+
--collection "my_collection" \
|
| 17 |
+
--embedding-dim 128 \
|
| 18 |
+
--vector-dtype float32 \
|
| 19 |
+
--indexing-threshold 1000000000
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
from __future__ import annotations
|
| 23 |
+
|
| 24 |
+
import argparse
|
| 25 |
+
import os
|
| 26 |
+
import time
|
| 27 |
+
from datetime import datetime
|
| 28 |
+
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
| 29 |
+
|
| 30 |
+
try:
|
| 31 |
+
from dotenv import load_dotenv
|
| 32 |
+
|
| 33 |
+
DOTENV_AVAILABLE = True
|
| 34 |
+
except Exception:
|
| 35 |
+
DOTENV_AVAILABLE = False
|
| 36 |
+
|
| 37 |
+
from qdrant_client import QdrantClient
|
| 38 |
+
from qdrant_client.http import models as qm
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _get_env(name: str) -> Optional[str]:
|
| 42 |
+
v = os.getenv(name)
|
| 43 |
+
if v is None:
|
| 44 |
+
return None
|
| 45 |
+
v = str(v).strip()
|
| 46 |
+
return v or None
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _require(value: Optional[str], *, name: str) -> str:
|
| 50 |
+
if not value:
|
| 51 |
+
raise SystemExit(f"Missing {name}. Provide flag or set env var.")
|
| 52 |
+
return value
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _chunks(seq: Sequence[Any], n: int) -> List[Sequence[Any]]:
|
| 56 |
+
return [seq[i : i + n] for i in range(0, len(seq), n)]
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _vectors_config(*, embedding_dim: int, vector_dtype: str) -> Dict[str, qm.VectorParams]:
|
| 60 |
+
datatype = qm.Datatype.FLOAT16 if vector_dtype == "float16" else qm.Datatype.FLOAT32
|
| 61 |
+
multivector_config = qm.MultiVectorConfig(comparator=qm.MultiVectorComparator.MAX_SIM)
|
| 62 |
+
return {
|
| 63 |
+
"initial": qm.VectorParams(
|
| 64 |
+
size=int(embedding_dim),
|
| 65 |
+
distance=qm.Distance.COSINE,
|
| 66 |
+
on_disk=True,
|
| 67 |
+
multivector_config=multivector_config,
|
| 68 |
+
datatype=datatype,
|
| 69 |
+
),
|
| 70 |
+
"mean_pooling": qm.VectorParams(
|
| 71 |
+
size=int(embedding_dim),
|
| 72 |
+
distance=qm.Distance.COSINE,
|
| 73 |
+
on_disk=False,
|
| 74 |
+
multivector_config=multivector_config,
|
| 75 |
+
datatype=datatype,
|
| 76 |
+
),
|
| 77 |
+
"experimental_pooling": qm.VectorParams(
|
| 78 |
+
size=int(embedding_dim),
|
| 79 |
+
distance=qm.Distance.COSINE,
|
| 80 |
+
on_disk=False,
|
| 81 |
+
multivector_config=multivector_config,
|
| 82 |
+
datatype=datatype,
|
| 83 |
+
),
|
| 84 |
+
"global_pooling": qm.VectorParams(
|
| 85 |
+
size=int(embedding_dim),
|
| 86 |
+
distance=qm.Distance.COSINE,
|
| 87 |
+
on_disk=False,
|
| 88 |
+
datatype=datatype,
|
| 89 |
+
),
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def _scroll_points(
|
| 94 |
+
client: QdrantClient,
|
| 95 |
+
*,
|
| 96 |
+
collection: str,
|
| 97 |
+
limit: int,
|
| 98 |
+
offset: Any,
|
| 99 |
+
) -> Tuple[List[Any], Any]:
|
| 100 |
+
# qdrant-client returns (points, next_offset)
|
| 101 |
+
return client.scroll(
|
| 102 |
+
collection_name=collection,
|
| 103 |
+
limit=int(limit),
|
| 104 |
+
with_payload=True,
|
| 105 |
+
with_vectors=True,
|
| 106 |
+
offset=offset,
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def _clone(
|
| 111 |
+
client: QdrantClient,
|
| 112 |
+
*,
|
| 113 |
+
source: str,
|
| 114 |
+
dest: str,
|
| 115 |
+
embedding_dim: int,
|
| 116 |
+
vector_dtype: str,
|
| 117 |
+
indexing_threshold: int,
|
| 118 |
+
recreate_dest: bool,
|
| 119 |
+
scroll_limit: int,
|
| 120 |
+
upsert_batch_size: int,
|
| 121 |
+
) -> int:
|
| 122 |
+
if recreate_dest:
|
| 123 |
+
try:
|
| 124 |
+
client.delete_collection(dest)
|
| 125 |
+
except Exception:
|
| 126 |
+
pass
|
| 127 |
+
|
| 128 |
+
# Create destination collection
|
| 129 |
+
client.create_collection(
|
| 130 |
+
collection_name=dest,
|
| 131 |
+
vectors_config=_vectors_config(embedding_dim=embedding_dim, vector_dtype=vector_dtype),
|
| 132 |
+
optimizers_config=qm.OptimizersConfigDiff(indexing_threshold=int(indexing_threshold)),
|
| 133 |
+
)
|
| 134 |
+
# Keep filename payload index (cheap; useful for skip_existing)
|
| 135 |
+
try:
|
| 136 |
+
client.create_payload_index(
|
| 137 |
+
collection_name=dest,
|
| 138 |
+
field_name="filename",
|
| 139 |
+
field_schema=qm.PayloadSchemaType.KEYWORD,
|
| 140 |
+
)
|
| 141 |
+
except Exception:
|
| 142 |
+
pass
|
| 143 |
+
|
| 144 |
+
total = 0
|
| 145 |
+
next_offset = None
|
| 146 |
+
|
| 147 |
+
while True:
|
| 148 |
+
points, next_offset = _scroll_points(
|
| 149 |
+
client,
|
| 150 |
+
collection=source,
|
| 151 |
+
limit=scroll_limit,
|
| 152 |
+
offset=next_offset,
|
| 153 |
+
)
|
| 154 |
+
if not points:
|
| 155 |
+
break
|
| 156 |
+
|
| 157 |
+
for batch in _chunks(points, int(upsert_batch_size)):
|
| 158 |
+
upsert_points: List[qm.PointStruct] = []
|
| 159 |
+
for p in batch:
|
| 160 |
+
upsert_points.append(
|
| 161 |
+
qm.PointStruct(
|
| 162 |
+
id=p.id,
|
| 163 |
+
vector=p.vector,
|
| 164 |
+
payload=p.payload or {},
|
| 165 |
+
)
|
| 166 |
+
)
|
| 167 |
+
client.upsert(collection_name=dest, points=upsert_points, wait=True)
|
| 168 |
+
total += len(upsert_points)
|
| 169 |
+
if total % 500 == 0:
|
| 170 |
+
print(f"… copied {total} points to {dest}")
|
| 171 |
+
|
| 172 |
+
if next_offset is None:
|
| 173 |
+
break
|
| 174 |
+
|
| 175 |
+
return total
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def main() -> None:
|
| 179 |
+
parser = argparse.ArgumentParser()
|
| 180 |
+
parser.add_argument(
|
| 181 |
+
"--collection", required=True, help="Collection to rebuild (final name stays same)"
|
| 182 |
+
)
|
| 183 |
+
parser.add_argument("--qdrant-url", default=None, help="Override QDRANT_URL")
|
| 184 |
+
parser.add_argument("--qdrant-api-key", default=None, help="Override QDRANT_API_KEY")
|
| 185 |
+
parser.add_argument("--prefer-grpc", action="store_true", help="Use gRPC transport")
|
| 186 |
+
parser.add_argument("--timeout", type=float, default=300.0, help="Client timeout seconds")
|
| 187 |
+
parser.add_argument(
|
| 188 |
+
"--embedding-dim", type=int, default=128, help="Embedding dim (typically 128)"
|
| 189 |
+
)
|
| 190 |
+
parser.add_argument("--vector-dtype", choices=["float16", "float32"], default="float32")
|
| 191 |
+
parser.add_argument(
|
| 192 |
+
"--indexing-threshold",
|
| 193 |
+
type=int,
|
| 194 |
+
default=1_000_000_000,
|
| 195 |
+
help="Very large value keeps indexed_vectors_count at 0",
|
| 196 |
+
)
|
| 197 |
+
parser.add_argument("--scroll-limit", type=int, default=256)
|
| 198 |
+
parser.add_argument("--upsert-batch-size", type=int, default=64)
|
| 199 |
+
parser.add_argument(
|
| 200 |
+
"--keep-temp", action="store_true", help="Do not delete temp collection at the end"
|
| 201 |
+
)
|
| 202 |
+
args = parser.parse_args()
|
| 203 |
+
|
| 204 |
+
if DOTENV_AVAILABLE:
|
| 205 |
+
load_dotenv()
|
| 206 |
+
|
| 207 |
+
url = args.qdrant_url or _get_env("QDRANT_URL")
|
| 208 |
+
api_key = args.qdrant_api_key or _get_env("QDRANT_API_KEY")
|
| 209 |
+
url = _require(url, name="QDRANT_URL/--qdrant-url")
|
| 210 |
+
api_key = _require(api_key, name="QDRANT_API_KEY/--qdrant-api-key")
|
| 211 |
+
|
| 212 |
+
client = QdrantClient(
|
| 213 |
+
url=url,
|
| 214 |
+
api_key=api_key,
|
| 215 |
+
prefer_grpc=bool(args.prefer_grpc),
|
| 216 |
+
timeout=float(args.timeout),
|
| 217 |
+
check_compatibility=False,
|
| 218 |
+
)
|
| 219 |
+
|
| 220 |
+
info = client.get_collection(args.collection)
|
| 221 |
+
print(f"✅ Found collection: {args.collection}")
|
| 222 |
+
print(f" points_count≈{info.points_count} indexed_vectors_count={info.indexed_vectors_count}")
|
| 223 |
+
|
| 224 |
+
stamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
|
| 225 |
+
temp = f"{args.collection}__tmp_rebuild_noindex__{stamp}"
|
| 226 |
+
print(f"🧪 Temp collection: {temp}")
|
| 227 |
+
|
| 228 |
+
print("➡️ Step 1/4: Copying points to temp…")
|
| 229 |
+
copied1 = _clone(
|
| 230 |
+
client,
|
| 231 |
+
source=args.collection,
|
| 232 |
+
dest=temp,
|
| 233 |
+
embedding_dim=int(args.embedding_dim),
|
| 234 |
+
vector_dtype=str(args.vector_dtype),
|
| 235 |
+
indexing_threshold=int(args.indexing_threshold),
|
| 236 |
+
recreate_dest=True,
|
| 237 |
+
scroll_limit=int(args.scroll_limit),
|
| 238 |
+
upsert_batch_size=int(args.upsert_batch_size),
|
| 239 |
+
)
|
| 240 |
+
temp_info = client.get_collection(temp)
|
| 241 |
+
print(
|
| 242 |
+
f"✅ Temp ready: points_count≈{temp_info.points_count} indexed_vectors_count={temp_info.indexed_vectors_count}"
|
| 243 |
+
)
|
| 244 |
+
|
| 245 |
+
print("➡️ Step 2/4: Deleting original collection…")
|
| 246 |
+
client.delete_collection(args.collection)
|
| 247 |
+
time.sleep(1.0)
|
| 248 |
+
|
| 249 |
+
print("➡️ Step 3/4: Recreating original with indexing disabled…")
|
| 250 |
+
client.create_collection(
|
| 251 |
+
collection_name=args.collection,
|
| 252 |
+
vectors_config=_vectors_config(
|
| 253 |
+
embedding_dim=int(args.embedding_dim), vector_dtype=str(args.vector_dtype)
|
| 254 |
+
),
|
| 255 |
+
optimizers_config=qm.OptimizersConfigDiff(indexing_threshold=int(args.indexing_threshold)),
|
| 256 |
+
)
|
| 257 |
+
try:
|
| 258 |
+
client.create_payload_index(
|
| 259 |
+
collection_name=args.collection,
|
| 260 |
+
field_name="filename",
|
| 261 |
+
field_schema=qm.PayloadSchemaType.KEYWORD,
|
| 262 |
+
)
|
| 263 |
+
except Exception:
|
| 264 |
+
pass
|
| 265 |
+
|
| 266 |
+
print("➡️ Step 4/4: Copying points back to original…")
|
| 267 |
+
copied2 = _clone(
|
| 268 |
+
client,
|
| 269 |
+
source=temp,
|
| 270 |
+
dest=args.collection,
|
| 271 |
+
embedding_dim=int(args.embedding_dim),
|
| 272 |
+
vector_dtype=str(args.vector_dtype),
|
| 273 |
+
indexing_threshold=int(args.indexing_threshold),
|
| 274 |
+
recreate_dest=False,
|
| 275 |
+
scroll_limit=int(args.scroll_limit),
|
| 276 |
+
upsert_batch_size=int(args.upsert_batch_size),
|
| 277 |
+
)
|
| 278 |
+
|
| 279 |
+
final_info = client.get_collection(args.collection)
|
| 280 |
+
exact = client.count(collection_name=args.collection, exact=True)
|
| 281 |
+
print("✅ Rebuild complete")
|
| 282 |
+
print(f" copied_to_temp={copied1} copied_back={copied2}")
|
| 283 |
+
print(
|
| 284 |
+
f" final.points_count≈{final_info.points_count} final.count(exact)={exact.count} "
|
| 285 |
+
f"final.indexed_vectors_count={final_info.indexed_vectors_count}"
|
| 286 |
+
)
|
| 287 |
+
|
| 288 |
+
if not args.keep_temp:
|
| 289 |
+
print("🧹 Deleting temp collection…")
|
| 290 |
+
client.delete_collection(temp)
|
| 291 |
+
print("✅ Temp deleted")
|
| 292 |
+
else:
|
| 293 |
+
print("ℹ️ Temp kept:", temp)
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
if __name__ == "__main__":
|
| 297 |
+
main()
|
scripts/qdrant_recompute_colqwen_pooling_from_initial.py
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Recompute ColQwen2.5 pooled vectors from already-indexed `initial` vectors.
|
| 3 |
+
|
| 4 |
+
Why:
|
| 5 |
+
- Your collection already contains high-quality `initial` multi-vectors (single_full works well),
|
| 6 |
+
but two-stage prefetch using `experimental_pooling` is poor.
|
| 7 |
+
- We can fix that WITHOUT re-indexing images by recomputing:
|
| 8 |
+
- mean_pooling (32×dim) from `initial` (H×W×dim)
|
| 9 |
+
- experimental_pooling (36×dim) from mean_pooling with window=5
|
| 10 |
+
- global_pooling (dim)
|
| 11 |
+
|
| 12 |
+
How we infer (H, W):
|
| 13 |
+
- For each point we know `num_tokens=len(initial)` and the stored resized image aspect ratio.
|
| 14 |
+
- We factor `num_tokens` and pick the factor pair (h, w) whose w/h best matches width/height.
|
| 15 |
+
|
| 16 |
+
Usage:
|
| 17 |
+
python scripts/qdrant_recompute_colqwen_pooling_from_initial.py \
|
| 18 |
+
--collection "vidore_beir_v2_3ds__colqwen25_v0_2__nocrop__union__fp32__grpc" \
|
| 19 |
+
--dataset "vidore/esg_reports_v2" \
|
| 20 |
+
--limit 0
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
from __future__ import annotations
|
| 24 |
+
|
| 25 |
+
import argparse
|
| 26 |
+
import math
|
| 27 |
+
import os
|
| 28 |
+
import time
|
| 29 |
+
from pathlib import Path
|
| 30 |
+
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
| 31 |
+
|
| 32 |
+
import numpy as np
|
| 33 |
+
|
| 34 |
+
try:
|
| 35 |
+
from dotenv import load_dotenv
|
| 36 |
+
|
| 37 |
+
DOTENV_AVAILABLE = True
|
| 38 |
+
except Exception:
|
| 39 |
+
DOTENV_AVAILABLE = False
|
| 40 |
+
|
| 41 |
+
from qdrant_client import QdrantClient
|
| 42 |
+
from qdrant_client.http import models as qm
|
| 43 |
+
|
| 44 |
+
from visual_rag.embedding.pooling import (
|
| 45 |
+
adaptive_row_mean_pooling_from_grid,
|
| 46 |
+
colpali_experimental_pooling_from_rows,
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _maybe_load_dotenv() -> None:
|
| 51 |
+
if not DOTENV_AVAILABLE:
|
| 52 |
+
return
|
| 53 |
+
if Path(".env").exists():
|
| 54 |
+
load_dotenv(".env")
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _stable_uuid(text: str) -> str:
|
| 58 |
+
import hashlib
|
| 59 |
+
|
| 60 |
+
hex_str = hashlib.sha256(text.encode("utf-8")).hexdigest()[:32]
|
| 61 |
+
return f"{hex_str[:8]}-{hex_str[8:12]}-{hex_str[12:16]}-{hex_str[16:20]}-{hex_str[20:32]}"
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _infer_grid(num_tokens: int, *, width: Optional[int], height: Optional[int]) -> Tuple[int, int]:
|
| 65 |
+
"""
|
| 66 |
+
Infer (grid_h, grid_w) such that grid_h*grid_w=num_tokens.
|
| 67 |
+
|
| 68 |
+
Picks factor pair closest to the observed aspect ratio (width/height).
|
| 69 |
+
"""
|
| 70 |
+
n = int(num_tokens)
|
| 71 |
+
if n <= 0:
|
| 72 |
+
raise ValueError("num_tokens must be > 0")
|
| 73 |
+
|
| 74 |
+
# Fallback aspect if missing
|
| 75 |
+
if width and height and int(width) > 0 and int(height) > 0:
|
| 76 |
+
aspect = float(width) / float(height)
|
| 77 |
+
else:
|
| 78 |
+
aspect = 1.0
|
| 79 |
+
|
| 80 |
+
best = None
|
| 81 |
+
best_score = float("inf")
|
| 82 |
+
|
| 83 |
+
# Enumerate factors up to sqrt(n)
|
| 84 |
+
r = int(math.isqrt(n))
|
| 85 |
+
for h in range(1, r + 1):
|
| 86 |
+
if n % h != 0:
|
| 87 |
+
continue
|
| 88 |
+
w = n // h
|
| 89 |
+
|
| 90 |
+
# Consider both orientations
|
| 91 |
+
for hh, ww in ((h, w), (w, h)):
|
| 92 |
+
if hh <= 0 or ww <= 0:
|
| 93 |
+
continue
|
| 94 |
+
cand = float(ww) / float(hh)
|
| 95 |
+
# log-space ratio distance is symmetric and scale-invariant
|
| 96 |
+
score = abs(math.log(max(cand, 1e-9) / max(aspect, 1e-9)))
|
| 97 |
+
if score < best_score:
|
| 98 |
+
best_score = score
|
| 99 |
+
best = (int(hh), int(ww))
|
| 100 |
+
|
| 101 |
+
if best is None:
|
| 102 |
+
# Should never happen
|
| 103 |
+
g = int(round(math.sqrt(n)))
|
| 104 |
+
return g, max(1, n // max(1, g))
|
| 105 |
+
return best
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def _chunks(xs: List[Any], n: int) -> Iterable[List[Any]]:
|
| 109 |
+
for i in range(0, len(xs), n):
|
| 110 |
+
yield xs[i : i + n]
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def _has_none_nested(v: Any) -> bool:
|
| 114 |
+
try:
|
| 115 |
+
if not isinstance(v, list):
|
| 116 |
+
return True
|
| 117 |
+
if not v:
|
| 118 |
+
return True
|
| 119 |
+
if not isinstance(v[0], list):
|
| 120 |
+
return True
|
| 121 |
+
for row in v:
|
| 122 |
+
if not isinstance(row, list):
|
| 123 |
+
return True
|
| 124 |
+
for x in row:
|
| 125 |
+
if x is None:
|
| 126 |
+
return True
|
| 127 |
+
return False
|
| 128 |
+
except Exception:
|
| 129 |
+
return True
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def main() -> None:
|
| 133 |
+
ap = argparse.ArgumentParser()
|
| 134 |
+
ap.add_argument("--collection", required=True)
|
| 135 |
+
ap.add_argument("--dataset", required=True, help="payload['dataset'] value to filter on")
|
| 136 |
+
ap.add_argument("--url", default="")
|
| 137 |
+
ap.add_argument("--api-key", default="")
|
| 138 |
+
ap.add_argument("--timeout", type=float, default=120.0)
|
| 139 |
+
ap.add_argument("--scroll-limit", type=int, default=128)
|
| 140 |
+
ap.add_argument("--update-batch", type=int, default=64)
|
| 141 |
+
ap.add_argument(
|
| 142 |
+
"--retrieve-batch", type=int, default=16, help="Batch size for retrieve() calls"
|
| 143 |
+
)
|
| 144 |
+
ap.add_argument("--limit", type=int, default=0, help="0 means no limit")
|
| 145 |
+
ap.add_argument("--sleep-sec", type=float, default=0.0)
|
| 146 |
+
args = ap.parse_args()
|
| 147 |
+
|
| 148 |
+
_maybe_load_dotenv()
|
| 149 |
+
|
| 150 |
+
url = args.url or os.getenv("QDRANT_URL") or ""
|
| 151 |
+
if not url:
|
| 152 |
+
raise SystemExit("QDRANT_URL not set (or pass --url)")
|
| 153 |
+
api_key = args.api_key or os.getenv("QDRANT_API_KEY") or None
|
| 154 |
+
|
| 155 |
+
client = QdrantClient(
|
| 156 |
+
url=url,
|
| 157 |
+
api_key=api_key,
|
| 158 |
+
prefer_grpc=False, # avoid DNS issues for 6334 in some envs
|
| 159 |
+
timeout=float(args.timeout),
|
| 160 |
+
check_compatibility=False,
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
flt = qm.Filter(
|
| 164 |
+
must=[qm.FieldCondition(key="dataset", match=qm.MatchValue(value=str(args.dataset)))]
|
| 165 |
+
)
|
| 166 |
+
|
| 167 |
+
updated = 0
|
| 168 |
+
scanned = 0
|
| 169 |
+
next_offset = None
|
| 170 |
+
|
| 171 |
+
while True:
|
| 172 |
+
points, next_offset = client.scroll(
|
| 173 |
+
collection_name=str(args.collection),
|
| 174 |
+
scroll_filter=flt,
|
| 175 |
+
limit=int(args.scroll_limit),
|
| 176 |
+
offset=next_offset,
|
| 177 |
+
with_payload=True,
|
| 178 |
+
with_vectors=False, # retrieve vectors per-point to avoid whole-batch parse failures
|
| 179 |
+
)
|
| 180 |
+
if not points:
|
| 181 |
+
break
|
| 182 |
+
|
| 183 |
+
pv_batch: List[qm.PointVectors] = []
|
| 184 |
+
ids: List[Any] = [p.id for p in points]
|
| 185 |
+
payload_by_id: Dict[Any, Dict[str, Any]] = {p.id: (p.payload or {}) for p in points}
|
| 186 |
+
|
| 187 |
+
# Retrieve initial vectors in batches for speed; fallback to per-id retrieve on failure.
|
| 188 |
+
records_by_id: Dict[Any, Any] = {}
|
| 189 |
+
for id_chunk in _chunks(ids, int(args.retrieve_batch)):
|
| 190 |
+
if not id_chunk:
|
| 191 |
+
continue
|
| 192 |
+
try:
|
| 193 |
+
recs = client.retrieve(
|
| 194 |
+
collection_name=str(args.collection),
|
| 195 |
+
ids=id_chunk,
|
| 196 |
+
with_payload=False,
|
| 197 |
+
with_vectors=["initial"],
|
| 198 |
+
timeout=int(args.timeout),
|
| 199 |
+
)
|
| 200 |
+
for r in recs:
|
| 201 |
+
records_by_id[r.id] = r
|
| 202 |
+
except Exception:
|
| 203 |
+
# fallback: per-id
|
| 204 |
+
for pid in id_chunk:
|
| 205 |
+
try:
|
| 206 |
+
recs = client.retrieve(
|
| 207 |
+
collection_name=str(args.collection),
|
| 208 |
+
ids=[pid],
|
| 209 |
+
with_payload=False,
|
| 210 |
+
with_vectors=["initial"],
|
| 211 |
+
timeout=int(args.timeout),
|
| 212 |
+
)
|
| 213 |
+
if recs:
|
| 214 |
+
records_by_id[recs[0].id] = recs[0]
|
| 215 |
+
except Exception:
|
| 216 |
+
continue
|
| 217 |
+
|
| 218 |
+
for pid in ids:
|
| 219 |
+
scanned += 1
|
| 220 |
+
if args.limit and scanned > int(args.limit):
|
| 221 |
+
break
|
| 222 |
+
|
| 223 |
+
# Retrieve vectors for this point. Some points in this collection may contain placeholder
|
| 224 |
+
# vectors with nulls from recovery attempts; retrieving per-id lets us skip them safely.
|
| 225 |
+
rec = records_by_id.get(pid)
|
| 226 |
+
if rec is None:
|
| 227 |
+
continue
|
| 228 |
+
vec = (rec.vector or {}).get("initial")
|
| 229 |
+
if _has_none_nested(vec):
|
| 230 |
+
continue
|
| 231 |
+
|
| 232 |
+
emb = np.asarray(vec, dtype=np.float32) # [num_tokens, dim]
|
| 233 |
+
num_tokens = int(emb.shape[0])
|
| 234 |
+
|
| 235 |
+
payload = payload_by_id.get(pid) or {}
|
| 236 |
+
w = (
|
| 237 |
+
payload.get("resized_width")
|
| 238 |
+
or payload.get("cropped_width")
|
| 239 |
+
or payload.get("original_width")
|
| 240 |
+
)
|
| 241 |
+
h = (
|
| 242 |
+
payload.get("resized_height")
|
| 243 |
+
or payload.get("cropped_height")
|
| 244 |
+
or payload.get("original_height")
|
| 245 |
+
)
|
| 246 |
+
try:
|
| 247 |
+
w_i = int(w) if w is not None else None
|
| 248 |
+
h_i = int(h) if h is not None else None
|
| 249 |
+
except Exception:
|
| 250 |
+
w_i, h_i = None, None
|
| 251 |
+
|
| 252 |
+
grid_h, grid_w = _infer_grid(num_tokens, width=w_i, height=h_i)
|
| 253 |
+
if grid_h * grid_w != num_tokens:
|
| 254 |
+
# safety: if factor inference failed, skip
|
| 255 |
+
continue
|
| 256 |
+
|
| 257 |
+
mean_pool = adaptive_row_mean_pooling_from_grid(
|
| 258 |
+
emb,
|
| 259 |
+
grid_h=int(grid_h),
|
| 260 |
+
grid_w=int(grid_w),
|
| 261 |
+
target_rows=32, # IMPORTANT: fixed row count for good prefetch recall
|
| 262 |
+
output_dtype=np.float32,
|
| 263 |
+
)
|
| 264 |
+
exp_pool = colpali_experimental_pooling_from_rows(
|
| 265 |
+
mean_pool,
|
| 266 |
+
window_size=5,
|
| 267 |
+
output_dtype=np.float32,
|
| 268 |
+
)
|
| 269 |
+
glob = mean_pool.mean(axis=0).astype(np.float32)
|
| 270 |
+
|
| 271 |
+
pv_batch.append(
|
| 272 |
+
qm.PointVectors(
|
| 273 |
+
id=pid,
|
| 274 |
+
vector={
|
| 275 |
+
"mean_pooling": mean_pool.tolist(),
|
| 276 |
+
"experimental_pooling": exp_pool.tolist(),
|
| 277 |
+
"global_pooling": glob.tolist(),
|
| 278 |
+
},
|
| 279 |
+
)
|
| 280 |
+
)
|
| 281 |
+
|
| 282 |
+
if len(pv_batch) >= int(args.update_batch):
|
| 283 |
+
client.update_vectors(
|
| 284 |
+
collection_name=str(args.collection),
|
| 285 |
+
points=pv_batch,
|
| 286 |
+
wait=True,
|
| 287 |
+
)
|
| 288 |
+
updated += len(pv_batch)
|
| 289 |
+
print(f"✅ updated vectors: {updated} (scanned={scanned})", flush=True)
|
| 290 |
+
pv_batch = []
|
| 291 |
+
if float(args.sleep_sec) > 0:
|
| 292 |
+
time.sleep(float(args.sleep_sec))
|
| 293 |
+
|
| 294 |
+
if pv_batch:
|
| 295 |
+
client.update_vectors(
|
| 296 |
+
collection_name=str(args.collection),
|
| 297 |
+
points=pv_batch,
|
| 298 |
+
wait=True,
|
| 299 |
+
)
|
| 300 |
+
updated += len(pv_batch)
|
| 301 |
+
print(f"✅ updated vectors: {updated} (scanned={scanned})", flush=True)
|
| 302 |
+
|
| 303 |
+
if args.limit and scanned >= int(args.limit):
|
| 304 |
+
break
|
| 305 |
+
if next_offset is None:
|
| 306 |
+
break
|
| 307 |
+
|
| 308 |
+
print(f"Done. scanned={scanned}, updated={updated}")
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
if __name__ == "__main__":
|
| 312 |
+
main()
|
scripts/query_token_stats.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import argparse
|
| 2 |
+
import json
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
from typing import Any, Dict, List
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def _stats(xs: List[int]) -> Dict[str, Any]:
|
| 8 |
+
import numpy as np
|
| 9 |
+
|
| 10 |
+
if not xs:
|
| 11 |
+
return {
|
| 12 |
+
"count": 0,
|
| 13 |
+
"mean": None,
|
| 14 |
+
"median": None,
|
| 15 |
+
"p95": None,
|
| 16 |
+
"min": None,
|
| 17 |
+
"max": None,
|
| 18 |
+
}
|
| 19 |
+
arr = np.array(xs, dtype=np.int64)
|
| 20 |
+
return {
|
| 21 |
+
"count": int(arr.size),
|
| 22 |
+
"mean": float(arr.mean()),
|
| 23 |
+
"median": float(np.median(arr)),
|
| 24 |
+
"p95": float(np.percentile(arr, 95)),
|
| 25 |
+
"min": int(arr.min()),
|
| 26 |
+
"max": int(arr.max()),
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _count_tokens(emb) -> int:
|
| 31 |
+
try:
|
| 32 |
+
import torch
|
| 33 |
+
|
| 34 |
+
if isinstance(emb, torch.Tensor):
|
| 35 |
+
return int(emb.shape[0])
|
| 36 |
+
except Exception:
|
| 37 |
+
pass
|
| 38 |
+
try:
|
| 39 |
+
return int(emb.shape[0])
|
| 40 |
+
except Exception:
|
| 41 |
+
return int(len(emb))
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def main():
|
| 45 |
+
parser = argparse.ArgumentParser()
|
| 46 |
+
parser.add_argument("--dataset", type=str, default=None)
|
| 47 |
+
parser.add_argument("--datasets", type=str, nargs="+", default=None)
|
| 48 |
+
parser.add_argument("--model", type=str, default="vidore/colSmol-500M")
|
| 49 |
+
parser.add_argument(
|
| 50 |
+
"--torch-dtype",
|
| 51 |
+
type=str,
|
| 52 |
+
default="auto",
|
| 53 |
+
choices=["auto", "float32", "float16", "bfloat16"],
|
| 54 |
+
)
|
| 55 |
+
parser.add_argument(
|
| 56 |
+
"--processor-speed", type=str, default="fast", choices=["fast", "slow", "auto"]
|
| 57 |
+
)
|
| 58 |
+
parser.add_argument("--batch-size", type=int, default=16)
|
| 59 |
+
parser.add_argument("--no-filter-special-tokens", action="store_true", default=False)
|
| 60 |
+
parser.add_argument("--max-queries", type=int, default=0)
|
| 61 |
+
parser.add_argument("--output", type=str, default="")
|
| 62 |
+
args = parser.parse_args()
|
| 63 |
+
|
| 64 |
+
datasets: List[str] = []
|
| 65 |
+
if args.datasets:
|
| 66 |
+
datasets = list(args.datasets)
|
| 67 |
+
elif args.dataset:
|
| 68 |
+
datasets = [args.dataset]
|
| 69 |
+
else:
|
| 70 |
+
raise SystemExit("Provide --dataset or --datasets")
|
| 71 |
+
|
| 72 |
+
from benchmarks.vidore_beir_qdrant.run_qdrant_beir import _maybe_load_dotenv, _parse_torch_dtype
|
| 73 |
+
from benchmarks.vidore_tatdqa_test.dataset_loader import load_vidore_beir_dataset
|
| 74 |
+
from visual_rag.embedding.visual_embedder import VisualEmbedder
|
| 75 |
+
|
| 76 |
+
_maybe_load_dotenv()
|
| 77 |
+
|
| 78 |
+
embedder = VisualEmbedder(
|
| 79 |
+
model_name=str(args.model),
|
| 80 |
+
torch_dtype=_parse_torch_dtype(str(args.torch_dtype)),
|
| 81 |
+
batch_size=int(args.batch_size),
|
| 82 |
+
processor_speed=str(args.processor_speed),
|
| 83 |
+
)
|
| 84 |
+
filter_special = not bool(args.no_filter_special_tokens)
|
| 85 |
+
|
| 86 |
+
out: Dict[str, Any] = {
|
| 87 |
+
"model": str(args.model),
|
| 88 |
+
"torch_dtype": str(args.torch_dtype),
|
| 89 |
+
"processor_speed": str(args.processor_speed),
|
| 90 |
+
"filter_special_tokens": bool(filter_special),
|
| 91 |
+
"max_queries": int(args.max_queries),
|
| 92 |
+
"datasets": {},
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
for ds in datasets:
|
| 96 |
+
_, queries, _ = load_vidore_beir_dataset(ds)
|
| 97 |
+
qs = [q.text for q in queries]
|
| 98 |
+
if int(args.max_queries) and int(args.max_queries) > 0:
|
| 99 |
+
qs = qs[: int(args.max_queries)]
|
| 100 |
+
embs = embedder.embed_queries(
|
| 101 |
+
qs,
|
| 102 |
+
batch_size=int(args.batch_size),
|
| 103 |
+
filter_special_tokens=bool(filter_special),
|
| 104 |
+
show_progress=True,
|
| 105 |
+
)
|
| 106 |
+
token_counts = [_count_tokens(e) for e in embs]
|
| 107 |
+
out["datasets"][str(ds)] = {
|
| 108 |
+
"num_queries": int(len(qs)),
|
| 109 |
+
"token_count": _stats(token_counts),
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
text = json.dumps(out, indent=2)
|
| 113 |
+
if args.output:
|
| 114 |
+
p = Path(str(args.output))
|
| 115 |
+
p.parent.mkdir(parents=True, exist_ok=True)
|
| 116 |
+
p.write_text(text, encoding="utf-8")
|
| 117 |
+
print(str(p))
|
| 118 |
+
else:
|
| 119 |
+
print(text)
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
if __name__ == "__main__":
|
| 123 |
+
main()
|
scripts/update_qdrant_indexing_threshold.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import argparse
|
| 2 |
+
import json
|
| 3 |
+
import os
|
| 4 |
+
import time
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def _maybe_load_dotenv() -> None:
|
| 9 |
+
try:
|
| 10 |
+
from dotenv import load_dotenv
|
| 11 |
+
except ImportError:
|
| 12 |
+
return
|
| 13 |
+
if Path(".env").exists():
|
| 14 |
+
load_dotenv(".env")
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _as_jsonable(obj):
|
| 18 |
+
if obj is None:
|
| 19 |
+
return None
|
| 20 |
+
if isinstance(obj, (str, int, float, bool)):
|
| 21 |
+
return obj
|
| 22 |
+
if isinstance(obj, dict):
|
| 23 |
+
return {str(k): _as_jsonable(v) for k, v in obj.items()}
|
| 24 |
+
if isinstance(obj, (list, tuple)):
|
| 25 |
+
return [_as_jsonable(v) for v in obj]
|
| 26 |
+
if hasattr(obj, "model_dump"):
|
| 27 |
+
try:
|
| 28 |
+
return obj.model_dump()
|
| 29 |
+
except Exception:
|
| 30 |
+
pass
|
| 31 |
+
if hasattr(obj, "__dict__"):
|
| 32 |
+
try:
|
| 33 |
+
return {k: _as_jsonable(v) for k, v in obj.__dict__.items()}
|
| 34 |
+
except Exception:
|
| 35 |
+
pass
|
| 36 |
+
return str(obj)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _indexed_total(indexed_vectors_count) -> int:
|
| 40 |
+
if indexed_vectors_count is None:
|
| 41 |
+
return 0
|
| 42 |
+
if isinstance(indexed_vectors_count, dict):
|
| 43 |
+
try:
|
| 44 |
+
return int(sum(int(v) for v in indexed_vectors_count.values()))
|
| 45 |
+
except Exception:
|
| 46 |
+
return 0
|
| 47 |
+
try:
|
| 48 |
+
return int(indexed_vectors_count)
|
| 49 |
+
except Exception:
|
| 50 |
+
return 0
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _snapshot_info(info) -> dict:
|
| 54 |
+
status = getattr(info, "status", None)
|
| 55 |
+
if hasattr(status, "value"):
|
| 56 |
+
status = status.value
|
| 57 |
+
optimizer_status = getattr(info, "optimizer_status", None)
|
| 58 |
+
if hasattr(optimizer_status, "value"):
|
| 59 |
+
optimizer_status = optimizer_status.value
|
| 60 |
+
return {
|
| 61 |
+
"status": _as_jsonable(status),
|
| 62 |
+
"optimizer_status": _as_jsonable(optimizer_status),
|
| 63 |
+
"points_count": _as_jsonable(getattr(info, "points_count", None)),
|
| 64 |
+
"indexed_vectors_count": _as_jsonable(getattr(info, "indexed_vectors_count", None)),
|
| 65 |
+
"segments_count": _as_jsonable(getattr(info, "segments_count", None)),
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def main() -> None:
|
| 70 |
+
parser = argparse.ArgumentParser()
|
| 71 |
+
parser.add_argument("--collection", type=str, required=True)
|
| 72 |
+
parser.add_argument("--indexing-threshold", type=int, default=0)
|
| 73 |
+
parser.add_argument("--prefer-grpc", action="store_true")
|
| 74 |
+
parser.add_argument("--url", type=str, default="")
|
| 75 |
+
parser.add_argument("--api-key", type=str, default="")
|
| 76 |
+
parser.add_argument("--wait", action="store_true")
|
| 77 |
+
parser.add_argument("--timeout-sec", type=int, default=300)
|
| 78 |
+
parser.add_argument("--poll-sec", type=int, default=2)
|
| 79 |
+
parser.add_argument("--dump-json", type=str, default="")
|
| 80 |
+
args = parser.parse_args()
|
| 81 |
+
|
| 82 |
+
_maybe_load_dotenv()
|
| 83 |
+
|
| 84 |
+
qdrant_url = args.url or os.getenv("QDRANT_URL")
|
| 85 |
+
if not qdrant_url:
|
| 86 |
+
raise ValueError("QDRANT_URL not set")
|
| 87 |
+
qdrant_api_key = args.api_key or os.getenv("QDRANT_API_KEY")
|
| 88 |
+
|
| 89 |
+
from qdrant_client import QdrantClient
|
| 90 |
+
from qdrant_client.http import models
|
| 91 |
+
|
| 92 |
+
client = QdrantClient(
|
| 93 |
+
url=qdrant_url,
|
| 94 |
+
api_key=qdrant_api_key,
|
| 95 |
+
prefer_grpc=args.prefer_grpc,
|
| 96 |
+
check_compatibility=False,
|
| 97 |
+
timeout=60,
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
client.update_collection(
|
| 101 |
+
collection_name=args.collection,
|
| 102 |
+
optimizers_config=models.OptimizersConfigDiff(
|
| 103 |
+
indexing_threshold=int(args.indexing_threshold)
|
| 104 |
+
),
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
info = client.get_collection(args.collection)
|
| 108 |
+
snap = _snapshot_info(info)
|
| 109 |
+
print(
|
| 110 |
+
f"Updated optimizers.indexing_threshold={args.indexing_threshold} for collection='{args.collection}'. "
|
| 111 |
+
f"points={snap['points_count']}, indexed_vectors={snap['indexed_vectors_count']}, "
|
| 112 |
+
f"status={snap['status']}, optimizer_status={snap['optimizer_status']}, segments={snap['segments_count']}"
|
| 113 |
+
)
|
| 114 |
+
if args.dump_json:
|
| 115 |
+
out_path = Path(args.dump_json)
|
| 116 |
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
| 117 |
+
with open(out_path, "w") as f:
|
| 118 |
+
json.dump(
|
| 119 |
+
{"event": "after_update", "collection": args.collection, "snapshot": snap},
|
| 120 |
+
f,
|
| 121 |
+
indent=2,
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
if not args.wait:
|
| 125 |
+
return
|
| 126 |
+
|
| 127 |
+
start = time.time()
|
| 128 |
+
while True:
|
| 129 |
+
info = client.get_collection(args.collection)
|
| 130 |
+
snap = _snapshot_info(info)
|
| 131 |
+
indexed_total = _indexed_total(snap["indexed_vectors_count"])
|
| 132 |
+
total = int(snap["points_count"] or 0)
|
| 133 |
+
if indexed_total >= total and total > 0:
|
| 134 |
+
print(
|
| 135 |
+
f"Indexing complete: indexed_vectors={snap['indexed_vectors_count']}, points={snap['points_count']}"
|
| 136 |
+
)
|
| 137 |
+
if args.dump_json:
|
| 138 |
+
out_path = Path(args.dump_json)
|
| 139 |
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
| 140 |
+
with open(out_path, "w") as f:
|
| 141 |
+
json.dump(
|
| 142 |
+
{"event": "complete", "collection": args.collection, "snapshot": snap},
|
| 143 |
+
f,
|
| 144 |
+
indent=2,
|
| 145 |
+
)
|
| 146 |
+
return
|
| 147 |
+
if time.time() - start > args.timeout_sec:
|
| 148 |
+
print(
|
| 149 |
+
f"Timeout waiting for indexing: indexed_vectors={snap['indexed_vectors_count']}, "
|
| 150 |
+
f"points={snap['points_count']}, status={snap['status']}, optimizer_status={snap['optimizer_status']}"
|
| 151 |
+
)
|
| 152 |
+
if args.dump_json:
|
| 153 |
+
out_path = Path(args.dump_json)
|
| 154 |
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
| 155 |
+
with open(out_path, "w") as f:
|
| 156 |
+
json.dump(
|
| 157 |
+
{"event": "timeout", "collection": args.collection, "snapshot": snap},
|
| 158 |
+
f,
|
| 159 |
+
indent=2,
|
| 160 |
+
)
|
| 161 |
+
return
|
| 162 |
+
print(
|
| 163 |
+
f"Indexing in progress: indexed_vectors={snap['indexed_vectors_count']}, "
|
| 164 |
+
f"points={snap['points_count']}, status={snap['status']}, optimizer_status={snap['optimizer_status']}, "
|
| 165 |
+
f"segments={snap['segments_count']}"
|
| 166 |
+
)
|
| 167 |
+
time.sleep(max(0.1, float(args.poll_sec)))
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
if __name__ == "__main__":
|
| 171 |
+
main()
|
tests/__init__.py
CHANGED
|
@@ -1,8 +1 @@
|
|
| 1 |
# Tests for visual-rag-toolkit
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
|
|
|
| 1 |
# Tests for visual-rag-toolkit
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_config.py
CHANGED
|
@@ -1,18 +1,16 @@
|
|
| 1 |
"""Tests for configuration utilities."""
|
| 2 |
|
| 3 |
import os
|
| 4 |
-
import pytest
|
| 5 |
import tempfile
|
| 6 |
-
from pathlib import Path
|
| 7 |
|
| 8 |
|
| 9 |
class TestConfigLoading:
|
| 10 |
"""Test config file loading."""
|
| 11 |
-
|
| 12 |
def test_load_yaml_config(self):
|
| 13 |
"""Load config from YAML file."""
|
| 14 |
from visual_rag.config import load_config
|
| 15 |
-
|
| 16 |
# Create temp config file
|
| 17 |
config_content = """
|
| 18 |
model:
|
|
@@ -21,34 +19,34 @@ model:
|
|
| 21 |
qdrant:
|
| 22 |
url: "http://localhost:6333"
|
| 23 |
"""
|
| 24 |
-
with tempfile.NamedTemporaryFile(mode=
|
| 25 |
f.write(config_content)
|
| 26 |
config_path = f.name
|
| 27 |
-
|
| 28 |
try:
|
| 29 |
config = load_config(config_path, force_reload=True, apply_env_overrides=False)
|
| 30 |
-
|
| 31 |
assert config["model"]["name"] == "test-model"
|
| 32 |
assert config["model"]["batch_size"] == 8
|
| 33 |
assert config["qdrant"]["url"] == "http://localhost:6333"
|
| 34 |
finally:
|
| 35 |
os.unlink(config_path)
|
| 36 |
-
|
| 37 |
def test_env_override(self):
|
| 38 |
"""Environment variables override config values."""
|
| 39 |
-
from visual_rag.config import load_config
|
| 40 |
-
|
| 41 |
# Set env var
|
| 42 |
os.environ["VISUAL_RAG_MODEL_NAME"] = "env-override-model"
|
| 43 |
-
|
| 44 |
config_content = """
|
| 45 |
model:
|
| 46 |
name: "yaml-model"
|
| 47 |
"""
|
| 48 |
-
with tempfile.NamedTemporaryFile(mode=
|
| 49 |
f.write(config_content)
|
| 50 |
config_path = f.name
|
| 51 |
-
|
| 52 |
try:
|
| 53 |
config = load_config(config_path, force_reload=True, apply_env_overrides=False)
|
| 54 |
# The env var should be checked in get() if implemented
|
|
@@ -57,20 +55,19 @@ model:
|
|
| 57 |
finally:
|
| 58 |
os.unlink(config_path)
|
| 59 |
del os.environ["VISUAL_RAG_MODEL_NAME"]
|
| 60 |
-
|
| 61 |
def test_missing_config_uses_defaults(self):
|
| 62 |
"""Missing config file returns empty dict or defaults."""
|
| 63 |
from visual_rag.config import load_config
|
| 64 |
-
|
| 65 |
config = load_config("/nonexistent/path/config.yaml")
|
| 66 |
-
|
| 67 |
# Should not raise, returns empty or default config
|
| 68 |
assert isinstance(config, dict)
|
| 69 |
-
|
| 70 |
def test_get_nested_value(self):
|
| 71 |
"""Get nested config values with dot notation."""
|
| 72 |
-
|
| 73 |
-
|
| 74 |
# This tests the get() function if available
|
| 75 |
# Will need the config to be loaded first
|
| 76 |
pass # Placeholder - depends on implementation
|
|
@@ -78,39 +75,32 @@ model:
|
|
| 78 |
|
| 79 |
class TestConfigSection:
|
| 80 |
"""Test getting config sections."""
|
| 81 |
-
|
| 82 |
def test_get_section(self):
|
| 83 |
"""Get a config section."""
|
| 84 |
from visual_rag.config import get_section, load_config
|
| 85 |
-
|
| 86 |
config_content = """
|
| 87 |
qdrant:
|
| 88 |
url: "http://localhost"
|
| 89 |
collection: "test"
|
| 90 |
"""
|
| 91 |
-
with tempfile.NamedTemporaryFile(mode=
|
| 92 |
f.write(config_content)
|
| 93 |
config_path = f.name
|
| 94 |
-
|
| 95 |
try:
|
| 96 |
load_config(config_path, force_reload=True, apply_env_overrides=False)
|
| 97 |
section = get_section("qdrant", apply_env_overrides=False)
|
| 98 |
-
|
| 99 |
assert section["url"] == "http://localhost"
|
| 100 |
assert section["collection"] == "test"
|
| 101 |
finally:
|
| 102 |
os.unlink(config_path)
|
| 103 |
-
|
| 104 |
def test_missing_section(self):
|
| 105 |
"""Missing section returns empty dict."""
|
| 106 |
from visual_rag.config import get_section
|
| 107 |
-
|
| 108 |
section = get_section("nonexistent")
|
| 109 |
assert section == {}
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
|
|
|
| 1 |
"""Tests for configuration utilities."""
|
| 2 |
|
| 3 |
import os
|
|
|
|
| 4 |
import tempfile
|
|
|
|
| 5 |
|
| 6 |
|
| 7 |
class TestConfigLoading:
|
| 8 |
"""Test config file loading."""
|
| 9 |
+
|
| 10 |
def test_load_yaml_config(self):
|
| 11 |
"""Load config from YAML file."""
|
| 12 |
from visual_rag.config import load_config
|
| 13 |
+
|
| 14 |
# Create temp config file
|
| 15 |
config_content = """
|
| 16 |
model:
|
|
|
|
| 19 |
qdrant:
|
| 20 |
url: "http://localhost:6333"
|
| 21 |
"""
|
| 22 |
+
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
|
| 23 |
f.write(config_content)
|
| 24 |
config_path = f.name
|
| 25 |
+
|
| 26 |
try:
|
| 27 |
config = load_config(config_path, force_reload=True, apply_env_overrides=False)
|
| 28 |
+
|
| 29 |
assert config["model"]["name"] == "test-model"
|
| 30 |
assert config["model"]["batch_size"] == 8
|
| 31 |
assert config["qdrant"]["url"] == "http://localhost:6333"
|
| 32 |
finally:
|
| 33 |
os.unlink(config_path)
|
| 34 |
+
|
| 35 |
def test_env_override(self):
|
| 36 |
"""Environment variables override config values."""
|
| 37 |
+
from visual_rag.config import load_config
|
| 38 |
+
|
| 39 |
# Set env var
|
| 40 |
os.environ["VISUAL_RAG_MODEL_NAME"] = "env-override-model"
|
| 41 |
+
|
| 42 |
config_content = """
|
| 43 |
model:
|
| 44 |
name: "yaml-model"
|
| 45 |
"""
|
| 46 |
+
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
|
| 47 |
f.write(config_content)
|
| 48 |
config_path = f.name
|
| 49 |
+
|
| 50 |
try:
|
| 51 |
config = load_config(config_path, force_reload=True, apply_env_overrides=False)
|
| 52 |
# The env var should be checked in get() if implemented
|
|
|
|
| 55 |
finally:
|
| 56 |
os.unlink(config_path)
|
| 57 |
del os.environ["VISUAL_RAG_MODEL_NAME"]
|
| 58 |
+
|
| 59 |
def test_missing_config_uses_defaults(self):
|
| 60 |
"""Missing config file returns empty dict or defaults."""
|
| 61 |
from visual_rag.config import load_config
|
| 62 |
+
|
| 63 |
config = load_config("/nonexistent/path/config.yaml")
|
| 64 |
+
|
| 65 |
# Should not raise, returns empty or default config
|
| 66 |
assert isinstance(config, dict)
|
| 67 |
+
|
| 68 |
def test_get_nested_value(self):
|
| 69 |
"""Get nested config values with dot notation."""
|
| 70 |
+
|
|
|
|
| 71 |
# This tests the get() function if available
|
| 72 |
# Will need the config to be loaded first
|
| 73 |
pass # Placeholder - depends on implementation
|
|
|
|
| 75 |
|
| 76 |
class TestConfigSection:
|
| 77 |
"""Test getting config sections."""
|
| 78 |
+
|
| 79 |
def test_get_section(self):
|
| 80 |
"""Get a config section."""
|
| 81 |
from visual_rag.config import get_section, load_config
|
| 82 |
+
|
| 83 |
config_content = """
|
| 84 |
qdrant:
|
| 85 |
url: "http://localhost"
|
| 86 |
collection: "test"
|
| 87 |
"""
|
| 88 |
+
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
|
| 89 |
f.write(config_content)
|
| 90 |
config_path = f.name
|
| 91 |
+
|
| 92 |
try:
|
| 93 |
load_config(config_path, force_reload=True, apply_env_overrides=False)
|
| 94 |
section = get_section("qdrant", apply_env_overrides=False)
|
| 95 |
+
|
| 96 |
assert section["url"] == "http://localhost"
|
| 97 |
assert section["collection"] == "test"
|
| 98 |
finally:
|
| 99 |
os.unlink(config_path)
|
| 100 |
+
|
| 101 |
def test_missing_section(self):
|
| 102 |
"""Missing section returns empty dict."""
|
| 103 |
from visual_rag.config import get_section
|
| 104 |
+
|
| 105 |
section = get_section("nonexistent")
|
| 106 |
assert section == {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_pdf_processor.py
CHANGED
|
@@ -1,88 +1,87 @@
|
|
| 1 |
"""Tests for PDF processor."""
|
| 2 |
|
| 3 |
import pytest
|
| 4 |
-
import numpy as np
|
| 5 |
from PIL import Image
|
| 6 |
|
| 7 |
|
| 8 |
class TestResizeForColPali:
|
| 9 |
"""Test image resizing for ColPali processing."""
|
| 10 |
-
|
| 11 |
def test_resize_standard_image(self):
|
| 12 |
"""Standard image resizes to tile boundaries."""
|
| 13 |
from visual_rag.indexing.pdf_processor import PDFProcessor
|
| 14 |
-
|
| 15 |
processor = PDFProcessor()
|
| 16 |
-
|
| 17 |
# Create test image (A4-like ratio)
|
| 18 |
-
img = Image.new(
|
| 19 |
-
|
| 20 |
resized, tile_rows, tile_cols = processor.resize_for_colpali(img)
|
| 21 |
-
|
| 22 |
# Should resize to multiples of 512
|
| 23 |
assert resized.width % 512 == 0 or resized.width <= 2048
|
| 24 |
assert resized.height % 512 == 0 or resized.height <= 2048
|
| 25 |
assert tile_rows >= 1
|
| 26 |
assert tile_cols >= 1
|
| 27 |
-
|
| 28 |
def test_resize_small_image(self):
|
| 29 |
"""Small image handles gracefully."""
|
| 30 |
from visual_rag.indexing.pdf_processor import PDFProcessor
|
| 31 |
-
|
| 32 |
processor = PDFProcessor()
|
| 33 |
-
img = Image.new(
|
| 34 |
-
|
| 35 |
resized, tile_rows, tile_cols = processor.resize_for_colpali(img)
|
| 36 |
-
|
| 37 |
assert resized is not None
|
| 38 |
assert tile_rows >= 1
|
| 39 |
assert tile_cols >= 1
|
| 40 |
-
|
| 41 |
def test_resize_wide_image(self):
|
| 42 |
"""Wide image (panorama-like) resizes correctly."""
|
| 43 |
from visual_rag.indexing.pdf_processor import PDFProcessor
|
| 44 |
-
|
| 45 |
processor = PDFProcessor()
|
| 46 |
-
img = Image.new(
|
| 47 |
-
|
| 48 |
resized, tile_rows, tile_cols = processor.resize_for_colpali(img)
|
| 49 |
-
|
| 50 |
# Wide image should have more cols than rows
|
| 51 |
assert tile_cols >= tile_rows
|
| 52 |
-
|
| 53 |
def test_resize_preserves_rgb(self):
|
| 54 |
"""Resized image should be RGB."""
|
| 55 |
from visual_rag.indexing.pdf_processor import PDFProcessor
|
| 56 |
-
|
| 57 |
processor = PDFProcessor()
|
| 58 |
-
img = Image.new(
|
| 59 |
-
|
| 60 |
resized, _, _ = processor.resize_for_colpali(img)
|
| 61 |
-
|
| 62 |
-
assert resized.mode ==
|
| 63 |
|
| 64 |
|
| 65 |
class TestMetadataExtraction:
|
| 66 |
"""Test metadata extraction from filenames."""
|
| 67 |
-
|
| 68 |
def test_extract_year_from_filename(self):
|
| 69 |
"""Extract year from filename."""
|
| 70 |
from visual_rag.indexing.pdf_processor import PDFProcessor
|
| 71 |
-
|
| 72 |
processor = PDFProcessor()
|
| 73 |
-
|
| 74 |
metadata = processor.extract_metadata_from_filename("Annual_Report_2023.pdf")
|
| 75 |
-
|
| 76 |
# Should extract year if implemented
|
| 77 |
# This depends on your implementation
|
| 78 |
assert isinstance(metadata, dict)
|
| 79 |
-
|
| 80 |
def test_sanitize_filename(self):
|
| 81 |
"""Sanitize filename for safe storage."""
|
| 82 |
from visual_rag.indexing.pdf_processor import PDFProcessor
|
| 83 |
-
|
| 84 |
processor = PDFProcessor()
|
| 85 |
-
|
| 86 |
# Test with special characters
|
| 87 |
filename = "Report (Final) - v2.0.pdf"
|
| 88 |
# Should handle gracefully
|
|
@@ -92,41 +91,35 @@ class TestMetadataExtraction:
|
|
| 92 |
|
| 93 |
class TestChunkIdGeneration:
|
| 94 |
"""Test deterministic chunk ID generation."""
|
| 95 |
-
|
| 96 |
def test_chunk_id_deterministic(self):
|
| 97 |
"""Same input produces same chunk ID."""
|
| 98 |
from visual_rag.indexing.pipeline import ProcessingPipeline
|
| 99 |
-
|
| 100 |
id1 = ProcessingPipeline.generate_chunk_id("test.pdf", 1)
|
| 101 |
id2 = ProcessingPipeline.generate_chunk_id("test.pdf", 1)
|
| 102 |
-
|
| 103 |
assert id1 == id2
|
| 104 |
-
|
| 105 |
def test_chunk_id_unique(self):
|
| 106 |
"""Different pages produce different IDs."""
|
| 107 |
from visual_rag.indexing.pipeline import ProcessingPipeline
|
| 108 |
-
|
| 109 |
id1 = ProcessingPipeline.generate_chunk_id("test.pdf", 1)
|
| 110 |
id2 = ProcessingPipeline.generate_chunk_id("test.pdf", 2)
|
| 111 |
-
|
| 112 |
assert id1 != id2
|
| 113 |
-
|
| 114 |
def test_chunk_id_format(self):
|
| 115 |
"""Chunk ID should be valid UUID format."""
|
| 116 |
-
from visual_rag.indexing.pipeline import ProcessingPipeline
|
| 117 |
import uuid
|
| 118 |
-
|
|
|
|
|
|
|
| 119 |
chunk_id = ProcessingPipeline.generate_chunk_id("test.pdf", 1)
|
| 120 |
-
|
| 121 |
# Should be valid UUID
|
| 122 |
try:
|
| 123 |
uuid.UUID(chunk_id)
|
| 124 |
except ValueError:
|
| 125 |
pytest.fail(f"Invalid UUID format: {chunk_id}")
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
|
|
|
| 1 |
"""Tests for PDF processor."""
|
| 2 |
|
| 3 |
import pytest
|
|
|
|
| 4 |
from PIL import Image
|
| 5 |
|
| 6 |
|
| 7 |
class TestResizeForColPali:
|
| 8 |
"""Test image resizing for ColPali processing."""
|
| 9 |
+
|
| 10 |
def test_resize_standard_image(self):
|
| 11 |
"""Standard image resizes to tile boundaries."""
|
| 12 |
from visual_rag.indexing.pdf_processor import PDFProcessor
|
| 13 |
+
|
| 14 |
processor = PDFProcessor()
|
| 15 |
+
|
| 16 |
# Create test image (A4-like ratio)
|
| 17 |
+
img = Image.new("RGB", (1000, 1414), color="white")
|
| 18 |
+
|
| 19 |
resized, tile_rows, tile_cols = processor.resize_for_colpali(img)
|
| 20 |
+
|
| 21 |
# Should resize to multiples of 512
|
| 22 |
assert resized.width % 512 == 0 or resized.width <= 2048
|
| 23 |
assert resized.height % 512 == 0 or resized.height <= 2048
|
| 24 |
assert tile_rows >= 1
|
| 25 |
assert tile_cols >= 1
|
| 26 |
+
|
| 27 |
def test_resize_small_image(self):
|
| 28 |
"""Small image handles gracefully."""
|
| 29 |
from visual_rag.indexing.pdf_processor import PDFProcessor
|
| 30 |
+
|
| 31 |
processor = PDFProcessor()
|
| 32 |
+
img = Image.new("RGB", (100, 100), color="white")
|
| 33 |
+
|
| 34 |
resized, tile_rows, tile_cols = processor.resize_for_colpali(img)
|
| 35 |
+
|
| 36 |
assert resized is not None
|
| 37 |
assert tile_rows >= 1
|
| 38 |
assert tile_cols >= 1
|
| 39 |
+
|
| 40 |
def test_resize_wide_image(self):
|
| 41 |
"""Wide image (panorama-like) resizes correctly."""
|
| 42 |
from visual_rag.indexing.pdf_processor import PDFProcessor
|
| 43 |
+
|
| 44 |
processor = PDFProcessor()
|
| 45 |
+
img = Image.new("RGB", (3000, 500), color="white")
|
| 46 |
+
|
| 47 |
resized, tile_rows, tile_cols = processor.resize_for_colpali(img)
|
| 48 |
+
|
| 49 |
# Wide image should have more cols than rows
|
| 50 |
assert tile_cols >= tile_rows
|
| 51 |
+
|
| 52 |
def test_resize_preserves_rgb(self):
|
| 53 |
"""Resized image should be RGB."""
|
| 54 |
from visual_rag.indexing.pdf_processor import PDFProcessor
|
| 55 |
+
|
| 56 |
processor = PDFProcessor()
|
| 57 |
+
img = Image.new("RGBA", (1000, 1000), color="white")
|
| 58 |
+
|
| 59 |
resized, _, _ = processor.resize_for_colpali(img)
|
| 60 |
+
|
| 61 |
+
assert resized.mode == "RGB"
|
| 62 |
|
| 63 |
|
| 64 |
class TestMetadataExtraction:
|
| 65 |
"""Test metadata extraction from filenames."""
|
| 66 |
+
|
| 67 |
def test_extract_year_from_filename(self):
|
| 68 |
"""Extract year from filename."""
|
| 69 |
from visual_rag.indexing.pdf_processor import PDFProcessor
|
| 70 |
+
|
| 71 |
processor = PDFProcessor()
|
| 72 |
+
|
| 73 |
metadata = processor.extract_metadata_from_filename("Annual_Report_2023.pdf")
|
| 74 |
+
|
| 75 |
# Should extract year if implemented
|
| 76 |
# This depends on your implementation
|
| 77 |
assert isinstance(metadata, dict)
|
| 78 |
+
|
| 79 |
def test_sanitize_filename(self):
|
| 80 |
"""Sanitize filename for safe storage."""
|
| 81 |
from visual_rag.indexing.pdf_processor import PDFProcessor
|
| 82 |
+
|
| 83 |
processor = PDFProcessor()
|
| 84 |
+
|
| 85 |
# Test with special characters
|
| 86 |
filename = "Report (Final) - v2.0.pdf"
|
| 87 |
# Should handle gracefully
|
|
|
|
| 91 |
|
| 92 |
class TestChunkIdGeneration:
|
| 93 |
"""Test deterministic chunk ID generation."""
|
| 94 |
+
|
| 95 |
def test_chunk_id_deterministic(self):
|
| 96 |
"""Same input produces same chunk ID."""
|
| 97 |
from visual_rag.indexing.pipeline import ProcessingPipeline
|
| 98 |
+
|
| 99 |
id1 = ProcessingPipeline.generate_chunk_id("test.pdf", 1)
|
| 100 |
id2 = ProcessingPipeline.generate_chunk_id("test.pdf", 1)
|
| 101 |
+
|
| 102 |
assert id1 == id2
|
| 103 |
+
|
| 104 |
def test_chunk_id_unique(self):
|
| 105 |
"""Different pages produce different IDs."""
|
| 106 |
from visual_rag.indexing.pipeline import ProcessingPipeline
|
| 107 |
+
|
| 108 |
id1 = ProcessingPipeline.generate_chunk_id("test.pdf", 1)
|
| 109 |
id2 = ProcessingPipeline.generate_chunk_id("test.pdf", 2)
|
| 110 |
+
|
| 111 |
assert id1 != id2
|
| 112 |
+
|
| 113 |
def test_chunk_id_format(self):
|
| 114 |
"""Chunk ID should be valid UUID format."""
|
|
|
|
| 115 |
import uuid
|
| 116 |
+
|
| 117 |
+
from visual_rag.indexing.pipeline import ProcessingPipeline
|
| 118 |
+
|
| 119 |
chunk_id = ProcessingPipeline.generate_chunk_id("test.pdf", 1)
|
| 120 |
+
|
| 121 |
# Should be valid UUID
|
| 122 |
try:
|
| 123 |
uuid.UUID(chunk_id)
|
| 124 |
except ValueError:
|
| 125 |
pytest.fail(f"Invalid UUID format: {chunk_id}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_pooling.py
CHANGED
|
@@ -1,55 +1,54 @@
|
|
| 1 |
"""Tests for pooling functions."""
|
| 2 |
|
| 3 |
-
import pytest
|
| 4 |
import numpy as np
|
| 5 |
|
| 6 |
|
| 7 |
class TestTileLevelPooling:
|
| 8 |
"""Test tile-level mean pooling."""
|
| 9 |
-
|
| 10 |
def test_basic_pooling(self):
|
| 11 |
"""Pooling reduces [num_tokens, dim] → [num_tiles, dim]."""
|
| 12 |
from visual_rag.embedding.pooling import tile_level_mean_pooling
|
| 13 |
-
|
| 14 |
# 13 tiles × 64 patches = 832 visual tokens
|
| 15 |
num_tiles = 13
|
| 16 |
patches_per_tile = 64
|
| 17 |
num_tokens = num_tiles * patches_per_tile
|
| 18 |
dim = 128
|
| 19 |
-
|
| 20 |
embedding = np.random.randn(num_tokens, dim).astype(np.float32)
|
| 21 |
pooled = tile_level_mean_pooling(embedding, num_tiles, patches_per_tile)
|
| 22 |
-
|
| 23 |
assert pooled.shape == (num_tiles, dim)
|
| 24 |
assert pooled.dtype == np.float32
|
| 25 |
-
|
| 26 |
def test_pooling_preserves_info(self):
|
| 27 |
"""Pooled vectors should be mean of patches."""
|
| 28 |
from visual_rag.embedding.pooling import tile_level_mean_pooling
|
| 29 |
-
|
| 30 |
num_tiles = 5
|
| 31 |
patches_per_tile = 64
|
| 32 |
dim = 128
|
| 33 |
-
|
| 34 |
embedding = np.random.randn(num_tiles * patches_per_tile, dim).astype(np.float32)
|
| 35 |
pooled = tile_level_mean_pooling(embedding, num_tiles, patches_per_tile)
|
| 36 |
-
|
| 37 |
# Check first tile
|
| 38 |
expected_tile0 = embedding[:patches_per_tile].mean(axis=0)
|
| 39 |
np.testing.assert_array_almost_equal(pooled[0], expected_tile0, decimal=5)
|
| 40 |
-
|
| 41 |
def test_pooling_with_partial_last_tile(self):
|
| 42 |
"""Handle case where last tile has fewer patches."""
|
| 43 |
from visual_rag.embedding.pooling import tile_level_mean_pooling
|
| 44 |
-
|
| 45 |
# 800 tokens, 64 per tile = 12.5 tiles → 13 tiles with partial last
|
| 46 |
num_tokens = 800
|
| 47 |
num_tiles = 13
|
| 48 |
dim = 128
|
| 49 |
-
|
| 50 |
embedding = np.random.randn(num_tokens, dim).astype(np.float32)
|
| 51 |
pooled = tile_level_mean_pooling(embedding, num_tiles, patches_per_tile=64)
|
| 52 |
-
|
| 53 |
# Should handle gracefully - at least some tiles
|
| 54 |
assert pooled.shape[1] == dim
|
| 55 |
assert pooled.shape[0] >= 1
|
|
@@ -57,14 +56,14 @@ class TestTileLevelPooling:
|
|
| 57 |
|
| 58 |
class TestGlobalPooling:
|
| 59 |
"""Test global mean pooling."""
|
| 60 |
-
|
| 61 |
def test_global_mean(self):
|
| 62 |
"""Global pooling reduces to single vector."""
|
| 63 |
from visual_rag.embedding.pooling import global_mean_pooling
|
| 64 |
-
|
| 65 |
embedding = np.random.randn(832, 128).astype(np.float32)
|
| 66 |
pooled = global_mean_pooling(embedding)
|
| 67 |
-
|
| 68 |
assert pooled.shape == (128,)
|
| 69 |
np.testing.assert_array_almost_equal(pooled, embedding.mean(axis=0))
|
| 70 |
|
|
@@ -160,40 +159,40 @@ class TestColPaliExperimentalPooling:
|
|
| 160 |
|
| 161 |
class TestMaxSimScore:
|
| 162 |
"""Test MaxSim scoring."""
|
| 163 |
-
|
| 164 |
def test_maxsim_identical(self):
|
| 165 |
"""Identical embeddings should have high score."""
|
| 166 |
from visual_rag.embedding.pooling import compute_maxsim_score
|
| 167 |
-
|
| 168 |
embedding = np.random.randn(10, 128).astype(np.float32)
|
| 169 |
# Normalize
|
| 170 |
embedding = embedding / np.linalg.norm(embedding, axis=1, keepdims=True)
|
| 171 |
-
|
| 172 |
score = compute_maxsim_score(embedding, embedding)
|
| 173 |
-
|
| 174 |
# Should be close to num_tokens (each token matches itself perfectly)
|
| 175 |
assert score >= 9.0 # Allow some floating point tolerance
|
| 176 |
-
|
| 177 |
def test_maxsim_orthogonal(self):
|
| 178 |
"""Orthogonal embeddings should have low score."""
|
| 179 |
from visual_rag.embedding.pooling import compute_maxsim_score
|
| 180 |
-
|
| 181 |
# Create orthogonal vectors
|
| 182 |
query = np.array([[1, 0, 0, 0], [0, 1, 0, 0]], dtype=np.float32)
|
| 183 |
doc = np.array([[0, 0, 1, 0], [0, 0, 0, 1]], dtype=np.float32)
|
| 184 |
-
|
| 185 |
score = compute_maxsim_score(query, doc)
|
| 186 |
-
|
| 187 |
assert score < 0.1 # Near zero for orthogonal
|
| 188 |
-
|
| 189 |
def test_maxsim_shape_independence(self):
|
| 190 |
"""Score should work with different query/doc lengths."""
|
| 191 |
from visual_rag.embedding.pooling import compute_maxsim_score
|
| 192 |
-
|
| 193 |
query = np.random.randn(5, 128).astype(np.float32)
|
| 194 |
doc = np.random.randn(100, 128).astype(np.float32)
|
| 195 |
-
|
| 196 |
score = compute_maxsim_score(query, doc)
|
| 197 |
-
|
| 198 |
assert isinstance(score, float)
|
| 199 |
assert not np.isnan(score)
|
|
|
|
| 1 |
"""Tests for pooling functions."""
|
| 2 |
|
|
|
|
| 3 |
import numpy as np
|
| 4 |
|
| 5 |
|
| 6 |
class TestTileLevelPooling:
|
| 7 |
"""Test tile-level mean pooling."""
|
| 8 |
+
|
| 9 |
def test_basic_pooling(self):
|
| 10 |
"""Pooling reduces [num_tokens, dim] → [num_tiles, dim]."""
|
| 11 |
from visual_rag.embedding.pooling import tile_level_mean_pooling
|
| 12 |
+
|
| 13 |
# 13 tiles × 64 patches = 832 visual tokens
|
| 14 |
num_tiles = 13
|
| 15 |
patches_per_tile = 64
|
| 16 |
num_tokens = num_tiles * patches_per_tile
|
| 17 |
dim = 128
|
| 18 |
+
|
| 19 |
embedding = np.random.randn(num_tokens, dim).astype(np.float32)
|
| 20 |
pooled = tile_level_mean_pooling(embedding, num_tiles, patches_per_tile)
|
| 21 |
+
|
| 22 |
assert pooled.shape == (num_tiles, dim)
|
| 23 |
assert pooled.dtype == np.float32
|
| 24 |
+
|
| 25 |
def test_pooling_preserves_info(self):
|
| 26 |
"""Pooled vectors should be mean of patches."""
|
| 27 |
from visual_rag.embedding.pooling import tile_level_mean_pooling
|
| 28 |
+
|
| 29 |
num_tiles = 5
|
| 30 |
patches_per_tile = 64
|
| 31 |
dim = 128
|
| 32 |
+
|
| 33 |
embedding = np.random.randn(num_tiles * patches_per_tile, dim).astype(np.float32)
|
| 34 |
pooled = tile_level_mean_pooling(embedding, num_tiles, patches_per_tile)
|
| 35 |
+
|
| 36 |
# Check first tile
|
| 37 |
expected_tile0 = embedding[:patches_per_tile].mean(axis=0)
|
| 38 |
np.testing.assert_array_almost_equal(pooled[0], expected_tile0, decimal=5)
|
| 39 |
+
|
| 40 |
def test_pooling_with_partial_last_tile(self):
|
| 41 |
"""Handle case where last tile has fewer patches."""
|
| 42 |
from visual_rag.embedding.pooling import tile_level_mean_pooling
|
| 43 |
+
|
| 44 |
# 800 tokens, 64 per tile = 12.5 tiles → 13 tiles with partial last
|
| 45 |
num_tokens = 800
|
| 46 |
num_tiles = 13
|
| 47 |
dim = 128
|
| 48 |
+
|
| 49 |
embedding = np.random.randn(num_tokens, dim).astype(np.float32)
|
| 50 |
pooled = tile_level_mean_pooling(embedding, num_tiles, patches_per_tile=64)
|
| 51 |
+
|
| 52 |
# Should handle gracefully - at least some tiles
|
| 53 |
assert pooled.shape[1] == dim
|
| 54 |
assert pooled.shape[0] >= 1
|
|
|
|
| 56 |
|
| 57 |
class TestGlobalPooling:
|
| 58 |
"""Test global mean pooling."""
|
| 59 |
+
|
| 60 |
def test_global_mean(self):
|
| 61 |
"""Global pooling reduces to single vector."""
|
| 62 |
from visual_rag.embedding.pooling import global_mean_pooling
|
| 63 |
+
|
| 64 |
embedding = np.random.randn(832, 128).astype(np.float32)
|
| 65 |
pooled = global_mean_pooling(embedding)
|
| 66 |
+
|
| 67 |
assert pooled.shape == (128,)
|
| 68 |
np.testing.assert_array_almost_equal(pooled, embedding.mean(axis=0))
|
| 69 |
|
|
|
|
| 159 |
|
| 160 |
class TestMaxSimScore:
|
| 161 |
"""Test MaxSim scoring."""
|
| 162 |
+
|
| 163 |
def test_maxsim_identical(self):
|
| 164 |
"""Identical embeddings should have high score."""
|
| 165 |
from visual_rag.embedding.pooling import compute_maxsim_score
|
| 166 |
+
|
| 167 |
embedding = np.random.randn(10, 128).astype(np.float32)
|
| 168 |
# Normalize
|
| 169 |
embedding = embedding / np.linalg.norm(embedding, axis=1, keepdims=True)
|
| 170 |
+
|
| 171 |
score = compute_maxsim_score(embedding, embedding)
|
| 172 |
+
|
| 173 |
# Should be close to num_tokens (each token matches itself perfectly)
|
| 174 |
assert score >= 9.0 # Allow some floating point tolerance
|
| 175 |
+
|
| 176 |
def test_maxsim_orthogonal(self):
|
| 177 |
"""Orthogonal embeddings should have low score."""
|
| 178 |
from visual_rag.embedding.pooling import compute_maxsim_score
|
| 179 |
+
|
| 180 |
# Create orthogonal vectors
|
| 181 |
query = np.array([[1, 0, 0, 0], [0, 1, 0, 0]], dtype=np.float32)
|
| 182 |
doc = np.array([[0, 0, 1, 0], [0, 0, 0, 1]], dtype=np.float32)
|
| 183 |
+
|
| 184 |
score = compute_maxsim_score(query, doc)
|
| 185 |
+
|
| 186 |
assert score < 0.1 # Near zero for orthogonal
|
| 187 |
+
|
| 188 |
def test_maxsim_shape_independence(self):
|
| 189 |
"""Score should work with different query/doc lengths."""
|
| 190 |
from visual_rag.embedding.pooling import compute_maxsim_score
|
| 191 |
+
|
| 192 |
query = np.random.randn(5, 128).astype(np.float32)
|
| 193 |
doc = np.random.randn(100, 128).astype(np.float32)
|
| 194 |
+
|
| 195 |
score = compute_maxsim_score(query, doc)
|
| 196 |
+
|
| 197 |
assert isinstance(score, float)
|
| 198 |
assert not np.isnan(score)
|