Yeroyan commited on
Commit
e9ece19
·
1 Parent(s): 596d6d8

refactor pooling options

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. benchmarks/__init__.py +0 -1
  2. benchmarks/analyze_results.py +34 -51
  3. benchmarks/prepare_submission.py +34 -51
  4. benchmarks/quick_test.py +198 -172
  5. benchmarks/run_vidore.py +127 -127
  6. benchmarks/vidore_beir_qdrant/run_qdrant_beir.py +493 -95
  7. benchmarks/vidore_tatdqa_test/__init__.py +0 -5
  8. benchmarks/vidore_tatdqa_test/dataset_loader.py +23 -11
  9. benchmarks/vidore_tatdqa_test/metrics.py +0 -5
  10. benchmarks/vidore_tatdqa_test/run_qdrant.py +77 -25
  11. benchmarks/vidore_tatdqa_test/sweep_eval.py +40 -13
  12. demo/app.py +7 -8
  13. demo/commands.py +215 -185
  14. demo/config.py +5 -4
  15. demo/download_models.py +16 -7
  16. demo/evaluation.py +175 -122
  17. demo/indexing.py +6 -18
  18. demo/qdrant_utils.py +23 -9
  19. demo/test_qdrant_connection.py +23 -21
  20. demo/ui/__init__.py +2 -2
  21. demo/ui/benchmark.py +196 -119
  22. demo/ui/header.py +5 -2
  23. demo/ui/playground.py +73 -53
  24. demo/ui/sidebar.py +48 -28
  25. demo/ui/upload.py +140 -99
  26. demo_app.py +2068 -0
  27. examples/COMMANDS.md +95 -1
  28. examples/process_pdfs.py +45 -67
  29. examples/search_demo.py +33 -64
  30. scripts/colqwen25_probe.py +70 -0
  31. scripts/compare_eval_scopes.py +309 -0
  32. scripts/compare_models_sample_queries.py +290 -0
  33. scripts/create_qdrant_payload_indexes.py +103 -0
  34. scripts/debug_failed_docs.py +179 -0
  35. scripts/debug_vidore_qrels_alignment.py +189 -0
  36. scripts/dedupe_failure_logs.py +70 -0
  37. scripts/force_qdrant_reindex.py +119 -0
  38. scripts/inspect_qdrant_collection.py +94 -0
  39. scripts/qdrant_clone_collection_no_index.py +253 -0
  40. scripts/qdrant_debug_collection.py +257 -0
  41. scripts/qdrant_disable_hnsw.py +192 -0
  42. scripts/qdrant_modify_vectors_smoketest.py +29 -0
  43. scripts/qdrant_rebuild_collection_no_index.py +297 -0
  44. scripts/qdrant_recompute_colqwen_pooling_from_initial.py +312 -0
  45. scripts/query_token_stats.py +123 -0
  46. scripts/update_qdrant_indexing_threshold.py +171 -0
  47. tests/__init__.py +0 -7
  48. tests/test_config.py +23 -33
  49. tests/test_pdf_processor.py +40 -47
  50. 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, List, Any
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(f"{dataset:<30} {method:<12} {m.get('ndcg@10', 0):>10.4f} {m.get('mrr@10', 0):>10.4f} {time_ms:>10.2f}")
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
- "--results", type=str, default="results",
138
- help="Results directory"
139
- )
140
- parser.add_argument(
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(f"❌ No results found")
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 typing import Dict, Any, 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,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(f"{display:<25} {metrics['ndcg@5']:>10.4f} {metrics['ndcg@10']:>10.4f} {metrics['mrr@10']:>10.4f}")
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
- "--model-name", type=str, default="visual-rag-toolkit",
145
- help="Model name for leaderboard"
146
  )
147
  parser.add_argument(
148
- "--model-url", type=str,
149
- help="URL to model/paper"
150
  )
151
- parser.add_argument(
152
- "--description", type=str,
153
- help="Model description"
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 List, Dict, Any
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='%(asctime)s - %(levelname)s - %(message)s')
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
- "id": i,
74
- "doc_id": f"doc_{i}",
75
- "query_id": f"q_{i}",
76
- "image": example.get("image", example.get("page_image")),
77
- "query": example.get("query", example.get("question", "")),
78
- # Ground truth: query i is relevant to doc i
79
- "relevant_doc": f"doc_{i}",
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
- images, batch_size=4, return_token_info=True
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, 'cpu'):
112
  emb = emb.cpu()
113
- emb_np = emb.numpy() if hasattr(emb, 'numpy') else np.array(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, 'cpu'):
141
  q_emb = q_emb.cpu()
142
- q_np = q_emb.numpy() if hasattr(q_emb, 'numpy') else np.array(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({"id": doc_id, "score": score, "stage1_rank": stage1_scores.index(cand) + 1})
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('inf')
288
- metrics["median_rank"] = np.median(found_ranks) if found_ranks else float('inf')
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(f" Two-stage will fetch ALL docs (same as exhaustive)")
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(f"\n🔬 Running Two-Stage retrieval (prefetch top-{prefetch_k}, rerank to top-{top_k})...")
 
 
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(f"🔬 Running Exhaustive MaxSim (searches ALL {num_docs} docs, returns top-{top_k})...")
 
 
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['docs'])
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['docs'].values())[0]
382
- print(f"\n📏 Embedding (after visual token filtering):")
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" # Exhaustive MaxSim on all docs
396
-
397
  # Recall / Hit Rate table
398
- print(f"\n🎯 RECALL (Hit Rate) @ K:")
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(f" {get_label(method):<20} "
404
- f"{metrics.get('Recall@1', 0):>8.3f} "
405
- f"{metrics.get('Recall@3', 0):>8.3f} "
406
- f"{metrics.get('Recall@5', 0):>8.3f} "
407
- f"{metrics.get('Recall@7', 0):>8.3f} "
408
- f"{metrics.get('Recall@10', 0):>8.3f}")
409
-
 
 
410
  # Precision table (optional)
411
  if show_precision:
412
- print(f"\n📐 PRECISION @ K:")
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(f" {get_label(method):<20} "
418
- f"{metrics.get('P@1', 0):>8.3f} "
419
- f"{metrics.get('P@3', 0):>8.3f} "
420
- f"{metrics.get('P@5', 0):>8.3f} "
421
- f"{metrics.get('P@7', 0):>8.3f} "
422
- f"{metrics.get('P@10', 0):>8.3f}")
423
-
 
 
424
  # NDCG table
425
- print(f"\n📈 NDCG @ K:")
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(f" {get_label(method):<20} "
431
- f"{metrics.get('NDCG@1', 0):>8.3f} "
432
- f"{metrics.get('NDCG@3', 0):>8.3f} "
433
- f"{metrics.get('NDCG@5', 0):>8.3f} "
434
- f"{metrics.get('NDCG@7', 0):>8.3f} "
435
- f"{metrics.get('NDCG@10', 0):>8.3f}")
436
-
 
 
437
  # MRR table
438
- print(f"\n🔍 MRR @ K:")
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(f" {get_label(method):<20} "
444
- f"{metrics.get('MRR@1', 0):>8.3f} "
445
- f"{metrics.get('MRR@3', 0):>8.3f} "
446
- f"{metrics.get('MRR@5', 0):>8.3f} "
447
- f"{metrics.get('MRR@7', 0):>8.3f} "
448
- f"{metrics.get('MRR@10', 0):>8.3f}")
449
-
 
 
450
  # Speed comparison
451
- top_k = benchmark_results.get("two_stage", benchmark_results.get("exhaustive", {})).get("top_k", 10)
 
 
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(f"\n💡 POOLED+RERANK vs FULL MAXSIM:")
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(f" • Recall@{k} retention: {retention:.1f}% ({ts_recall:.3f} vs {ex_recall:.3f})")
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(f"\n📊 POOLED+RERANK STATISTICS:")
490
- print(f" Stage-1 (pooled prefetch):")
491
  print(f" • Searches top-{prefetch_k} candidates using tile-pooled vectors")
492
- print(f" • {total - not_found}/{total} queries ({100 - not_found/total*100:.1f}%) had relevant doc in prefetch")
493
- print(f" • {not_found}/{total} queries ({not_found/total*100:.1f}%) missed (relevant doc ranked >{prefetch_k})")
494
- print(f" Stage-2 (MaxSim reranking):")
495
- print(f" • Reranks prefetch candidates with exact MaxSim")
 
 
 
 
496
  print(f" • Returns final top-{top_k} results")
497
- if ts['avg_rank'] < float('inf'):
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(f" Final Recall@{top_k} shows how many relevant docs ARE in top-{top_k} results.")
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
- "--samples", type=int, default=100,
514
- help="Number of samples (default: 100)"
515
- )
516
- parser.add_argument(
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", type=int, default=None,
522
- help="Stage 1 candidates for two-stage (default: 20 for <=100 docs, auto for larger)"
 
 
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", type=int, default=10,
534
- help="Number of results to return (default: 10, realistic retrieval scenario)"
 
 
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 List, Dict, Any, Optional
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", # ~500 queries, Document VQA
37
- "infovqa": "vidore/infovqa_test_subsampled", # ~500 queries, Infographics
38
  "tabfquad": "vidore/tabfquad_test_subsampled", # ~500 queries, Tables
39
-
40
  # === FULL EVALUATION ===
41
- "tatdqa": "vidore/tatdqa_test", # ~1500 queries, Financial tables
42
- "arxivqa": "vidore/arxivqa_test_subsampled", # ~500 queries, Scientific papers
43
- "shift": "vidore/shiftproject_test", # ~500 queries, Sustainability reports
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
- "id": query_id,
77
- "text": query_text,
78
- })
79
-
 
 
80
  # Get document image
81
  image = example.get("image", example.get("page_image"))
82
- documents.append({
83
- "id": doc_id,
84
- "image": image,
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
- "id": doc_id,
246
- "score": score,
247
- "stage1_score": cand["score"],
248
- "stage1_rank": stage1_scores.index(cand) + 1,
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(f"=" * 60)
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(f"=" * 60)
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(f"Using tile-level pooling for two-stage retrieval")
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(f"\nResults:")
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
- "dataset": dataset_name,
395
- "model": model_name,
396
- "two_stage": two_stage,
397
- "metrics": metrics,
398
- }, f, indent=2)
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
- "--all", action="store_true",
442
- help="Evaluate on all ViDoRe datasets"
 
 
443
  )
444
  parser.add_argument(
445
- "--model", type=str, default="vidore/colSmol-500M",
446
- help="Model: vidore/colSmol-500M (default), vidore/colpali-v1.3, vidore/colqwen2-v1.0"
447
  )
 
448
  parser.add_argument(
449
- "--two-stage", action="store_true",
450
- help="Use two-stage retrieval (tile-level pooled prefetch + MaxSim rerank)"
 
 
451
  )
452
  parser.add_argument(
453
- "--prefetch-k", type=int, default=100,
454
- help="Stage 1 candidates (default: 100)"
 
455
  )
456
  parser.add_argument(
457
- "--top-k", type=int, default=10,
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 ndcg_at_k, mrr_at_k, recall_at_k
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(*, dataset_name: str, source_doc_id: str, union_namespace: Optional[str]) -> str:
 
 
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(qrels: Dict[str, Dict[str, int]], query_ids: List[str]) -> Dict[str, Dict[str, int]]:
 
 
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("tokens_vs_experimental")
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(qrels: Dict[str, Dict[str, int]], failed_ids: set) -> Tuple[Dict[str, Dict[str, int]], int]:
 
 
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
- query_embeddings = embedder.embed_queries(
287
- query_texts,
288
- batch_size=getattr(embedder, "batch_size", None),
289
- show_progress=False,
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
- qemb_np = qemb.detach().cpu().numpy()
 
 
 
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 = os.getenv("SIGIR_QDRANT_URL") or os.getenv("DEST_QDRANT_URL") or os.getenv("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(failure_log, dataset_name=dataset_name, union_namespace=union_namespace)
 
 
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 ThreadPoolExecutor, wait as futures_wait, FIRST_EXCEPTION
 
540
 
541
  stop_event = threading.Event()
542
- executor = ThreadPoolExecutor(max_workers=int(upload_workers)) if upload_workers and upload_workers > 0 else None
 
 
 
 
543
  futures = []
544
 
545
  def _upload(points: List[Dict[str, Any]]) -> int:
546
- uploaded = int(indexer.upload_batch(points, delay_between_batches=0.0, wait=upsert_wait, stop_event=stop_event) or 0)
 
 
 
 
 
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((p.get("metadata") or {}).get("source_doc_id") or ""),
 
 
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, crop_empty as _crop_empty
 
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 as e:
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((doc_i.payload or {}).get("source_doc_id") or doc_i.doc_id)
 
 
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 = emb.cpu().float().numpy() if hasattr(emb, "cpu") else np.array(emb, dtype=np.float32)
709
- visual_indices = token_info.get("visual_token_indices") or list(range(emb_np.shape[0]))
 
 
 
 
 
 
710
  visual_embedding = emb_np[visual_indices].astype(np.float32)
711
- tile_pooled = embedder.mean_pool_visual_embedding(visual_embedding, token_info, target_vectors=32)
 
 
712
  experimental_pooled = embedder.experimental_pool_visual_embedding(
713
- visual_embedding, token_info, target_vectors=32, mean_pool=tile_pooled
 
 
 
714
  )
715
  global_pooled = embedder.global_pool_from_mean_pool(tile_pooled)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
716
  except Exception as e_single:
717
- source_doc_id_i = str((doc.payload or {}).get("source_doc_id") or doc.doc_id)
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(source_doc_id_i),
731
  "doc_id": str(getattr(doc, "doc_id", "")),
732
- "union_doc_id": str(union_doc_id_i),
733
  "error": str(e_single),
734
  },
735
  )
736
- failed_ids.add(str(union_doc_id_i))
737
- existing_ids.add(str(union_doc_id_i))
738
  skipped_docs += 1
739
  continue
740
 
741
  num_tiles = int(tile_pooled.shape[0])
742
- patches_per_tile = int(visual_embedding.shape[0] // max(num_tiles, 1)) if num_tiles else 0
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 cloudinary_uploader is not None and original_img is not None and resized_img is not None:
 
 
 
 
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 = cloudinary_uploader.upload_original_cropped_and_resized(
760
- original_img,
761
- embed_img if embed_img is not None and embed_img is not original_img else None,
762
- resized_img,
763
- base_public_id,
 
 
 
 
 
 
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": int(original_img.height) if original_img is not None else None,
789
- "cropped_width": int(embed_img.width) if embed_img is not None else None,
790
- "cropped_height": int(embed_img.height) if embed_img is not None else None,
 
 
 
 
 
 
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": (crop_meta or {}).get("crop_box") if crop_empty else None,
799
- "crop_empty_remove_page_number": bool(crop_empty_remove_page_number) if crop_empty else None,
800
- "crop_empty_percentage_to_remove": float(crop_empty_percentage_to_remove) if crop_empty else None,
 
 
 
 
 
 
801
  "index_recovery_previously_failed": bool(union_doc_id in previously_failed_ids),
802
  "index_recovery_mode": (
803
- "only_failures" if bool(only_failures) else ("retry_failures" if bool(retry_failures) else None)
 
 
804
  ),
805
  "index_recovery_pooling_inferred_tiles": bool(
806
- (token_info or {}).get("num_tiles") is None and (token_info or {}).get("n_rows") is None and (token_info or {}).get("n_cols") 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="float16",
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("--sample-corpus-strategy", type=str, default="first", choices=["first", "random"])
 
 
914
  parser.add_argument("--sample-seed", type=int, default=0)
915
  parser.add_argument("--sample-queries", type=int, default=0)
916
- parser.add_argument("--sample-query-strategy", type=str, default="first", choices=["first", "random"])
 
 
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("--no-index-common-metadata", dest="index_common_metadata", action="store_false")
 
 
931
  parser.add_argument("--payload-index", action="append", default=[])
932
- parser.add_argument(
 
 
 
 
 
 
 
 
933
  "--no-cloudinary",
 
934
  action="store_true",
935
- help="Disable Cloudinary uploads during indexing (default: enabled).",
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("--top-k", type=int, default=100, help="Retrieve top-k results (default: 100 to calculate metrics at all cutoffs)")
957
- parser.add_argument("--prefetch-k", type=int, default=200, help="Prefetch candidates for two-stage (default: 200)")
 
 
 
 
 
 
 
 
 
 
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="tokens_vs_tiles",
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("--drop-empty-queries", dest="drop_empty_queries", action="store_true", default=True)
987
- drop_group.add_argument("--no-drop-empty-queries", dest="drop_empty_queries", action="store_false")
 
 
 
 
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 args.sample_corpus_docs and int(args.sample_corpus_docs) > 0 and args.max_corpus_docs and int(args.max_corpus_docs) > 0:
 
 
 
 
 
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 (args.max_corpus_docs and int(args.max_corpus_docs) > 0):
1023
- raise ValueError("Use --index-from-queries with --sample-queries only (do not combine with corpus sampling).")
 
 
 
 
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(f"Qdrant request timeout: {int(args.qdrant_timeout)}s, retries: {int(args.qdrant_retries)}.")
 
 
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
- output_dtype = np.float16 if args.qdrant_vector_dtype == "float16" else np.float32
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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=args.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] = {"corpus_docs": int(len(corpus)), "queries": int(len(queries)), "queries_eval": 0}
 
 
 
 
1164
  failed_path = _failed_log_path(collection_name=args.collection, dataset_name=ds_name)
1165
- failed_ids = _load_failed_union_ids(failed_path, dataset_name=ds_name, union_namespace=args.collection)
 
 
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": args.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") or os.getenv("DEST_QDRANT_URL") or os.getenv("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": args.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
- + (f", stage1_mode={args.stage1_mode}, prefetch_k={int(args.prefetch_k)}" if args.mode == "two_stage" else "")
1275
- + (f", stage1_k={int(args.stage1_k)}, stage2_k={int(args.stage2_k)}" if args.mode == "three_stage" else "")
 
 
 
 
 
 
 
 
1276
  + f", top_k={int(args.top_k)}"
1277
  )
1278
  sys.stdout.flush()
1279
 
1280
- dataset_counts[ds_name] = {"corpus_docs": int(len(corpus)), "queries": int(len(queries)), "queries_eval": 0}
 
 
 
 
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
- failed_ids = _load_failed_union_ids(failed_path, dataset_name=ds_name, union_namespace=args.collection)
1302
- remapped_qrels, removed_rels = _remove_failed_from_qrels(remapped_qrels, failed_ids)
 
 
 
 
 
 
 
 
 
1303
  dataset_index_failures[ds_name] = {
1304
  "failed_log_path": str(failed_path),
1305
- "failed_ids_count": int(len(failed_ids)),
 
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=[qmodels.FieldCondition(key="dataset", match=qmodels.MatchValue(value=str(ds_name)))]
 
 
 
 
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(metrics_by_dataset[ds_name].get("num_queries_eval", 0))
 
 
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(row.get("corpus-id") or row.get("corpus_id") or row.get("doc_id") or row.get("did"))
 
 
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(f"{context}: missing required field(s): {missing}. Available: {list(obj.keys())}")
 
 
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(dataset_name: str) -> Tuple[List[CorpusDoc], List[Query], Dict[str, Dict[str, int]]]:
 
 
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("corpus row: missing image field (tried image/page_image/document/img)")
 
 
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(dataset_name: str) -> Tuple[List[CorpusDoc], List[Query], Dict[str, Dict[str, int]]]:
 
 
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(dataset_name: str) -> Tuple[List[CorpusDoc], List[Query], Dict[str, Dict[str, int]], str]:
 
 
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(points, delay_between_batches=0.0, wait=upsert_wait, stop_event=stop_event)
 
 
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 ThreadPoolExecutor, wait as futures_wait, FIRST_EXCEPTION
 
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("CorpusDoc.image is None. For paired datasets, use _index_paired_dataset().")
230
- emb_np = emb.cpu().float().numpy() if hasattr(emb, "cpu") else np.array(emb, dtype=np.float32)
231
- visual_indices = token_info.get("visual_token_indices") or list(range(emb_np.shape[0]))
 
 
 
 
 
 
 
 
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(visual_embedding, num_tiles=num_tiles, patches_per_tile=64)
 
 
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
- "avg_s/doc": f"{avg_s_per_doc:.2f}",
274
- "last_s/doc": f"{last_s_per_doc:.2f}",
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("torch is required. Install with: pip install visual-rag-toolkit[embedding]") from e
 
 
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(points, delay_between_batches=0.0, wait=upsert_wait, stop_event=stop_event)
 
 
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 ThreadPoolExecutor, wait as futures_wait, FIRST_EXCEPTION
 
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(dataset_name=dataset_name, split="test", total_docs=total_docs, image_col=image_col),
 
 
 
 
 
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 = [{k: batch[k][i] for k in batch.keys() if k != image_col} for i in range(len(images))]
 
 
 
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 = emb.cpu().float().numpy() if hasattr(emb, "cpu") else np.array(emb, dtype=np.float32)
505
- visual_indices = token_info.get("visual_token_indices") or list(range(emb_np.shape[0]))
 
 
 
 
 
 
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(visual_embedding, num_tiles=num_tiles, patches_per_tile=64)
 
 
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("--index", action="store_true", help="Index corpus into Qdrant before evaluating")
658
- parser.add_argument("--recreate", action="store_true", help="Delete and recreate the collection (implies --index)")
 
 
 
 
 
 
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="tokens_vs_tiles",
683
- choices=["pooled_query_vs_tiles", "tokens_vs_tiles", "pooled_query_vs_global"],
 
 
 
 
 
 
 
 
 
 
 
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, Tuple
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 ndcg_at_k, mrr_at_k, recall_at_k
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(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s", force=True)
 
 
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("--mode", type=str, default="two_stage", choices=["single_full", "two_stage"])
 
 
169
  parser.add_argument(
170
  "--stage1-mode",
171
  type=str,
172
- default="tokens_vs_tiles",
173
- choices=["pooled_query_vs_tiles", "tokens_vs_tiles", "pooled_query_vs_global"],
 
 
 
 
 
 
 
 
 
 
 
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(texts, batch_size=args.query_batch_size, show_progress=True)
 
 
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": args.sample_seed if args.sample_queries and args.sample_strategy == "random" else None,
 
 
 
 
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 = out_dir / f"{protocol}__two_stage__{args.stage1_mode}__prefetch{k}__top{args.top_k}.json"
 
 
 
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": args.sample_seed if args.sample_queries and args.sample_strategy == "random" else None,
 
 
 
 
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.header import render_header
32
- from demo.ui.sidebar import render_sidebar
33
- from demo.ui.upload import render_upload_tab
34
- from demo.ui.playground import render_playground_tab
35
- from demo.ui.benchmark import render_benchmark_tab
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 = {"float16": "torch.float16", "float32": "torch.float32", "bfloat16": "torch.bfloat16"}
 
 
 
 
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'DATASETS = [{datasets_str}]',
65
- f'TORCH_DTYPE = {torch_dtype_val}',
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
- "# Initialize embedder",
75
- "embedder = VisualEmbedder(",
76
- " model_name=MODEL,",
77
- " torch_dtype=TORCH_DTYPE,",
78
- ")",
79
- "",
80
- "# Initialize indexer",
81
- "indexer = QdrantIndexer(",
82
- ' url=os.getenv("QDRANT_URL"),',
83
- ' api_key=os.getenv("QDRANT_API_KEY"),',
84
- " collection_name=COLLECTION,",
85
- f" prefer_grpc={prefer_grpc},",
86
- " vector_datatype=QDRANT_DTYPE,",
87
- ")",
88
- "",
89
- "# Create collection",
90
- f"indexer.create_collection(force_recreate={config.get('recreate', False)})",
91
- 'indexer.create_payload_indexes(fields=[',
92
- ' {"field": "dataset", "type": "keyword"},',
93
- ' {"field": "doc_id", "type": "keyword"},',
94
- ' {"field": "source_doc_id", "type": "keyword"},',
95
- "])",
96
- "",
97
- "# Index each dataset",
98
- "for ds_name in DATASETS:",
99
- " print(f'Loading {ds_name}...')",
100
- " corpus, queries, qrels = load_vidore_beir_dataset(ds_name)",
101
- ])
102
-
 
 
103
  if max_docs:
104
  code_lines.append(" corpus = corpus[:MAX_DOCS] # Limit")
105
-
106
- code_lines.extend([
107
- " print(f'Indexing {len(corpus)} documents...')",
108
- "",
109
- " for i in range(0, len(corpus), BATCH_SIZE):",
110
- " batch = corpus[i:i + BATCH_SIZE]",
111
- " images = [doc.image for doc in batch]",
112
- "",
113
- " # Embed images",
114
- " embeddings, token_infos = embedder.embed_images(",
115
- " images, return_token_info=True",
116
- " )",
117
- "",
118
- " # Build points with multi-vector representations",
119
- " points = []",
120
- " for doc, emb, info in zip(batch, embeddings, token_infos):",
121
- " emb_np = emb.cpu().numpy()",
122
- " visual_idx = info.get('visual_token_indices', range(len(emb_np)))",
123
- " visual_emb = emb_np[visual_idx]",
124
- "",
125
- " tile_pooled = embedder.mean_pool_visual_embedding(visual_emb, info)",
126
- " experimental = embedder.experimental_pool_visual_embedding(",
127
- " visual_emb, info, mean_pool=tile_pooled",
128
- " )",
129
- " global_pooled = embedder.global_pool_from_mean_pool(tile_pooled)",
130
- "",
131
- " points.append({",
132
- ' "id": f"{ds_name}_{doc.doc_id}",',
133
- ' "visual_embedding": visual_emb,',
134
- ' "tile_pooled_embedding": tile_pooled,',
135
- ' "experimental_pooled_embedding": experimental,',
136
- ' "global_pooled_embedding": global_pooled,',
137
- ' "metadata": {',
138
- ' "dataset": ds_name,',
139
- ' "doc_id": doc.doc_id,',
140
- ' "source_doc_id": doc.payload.get("source_doc_id"),',
141
- " },",
142
- " })",
143
- "",
144
- " indexer.upload_batch(points)",
145
- " print(f' Batch {i//BATCH_SIZE + 1}: {len(points)} uploaded')",
146
- "",
147
- ' print(f"Done: {ds_name}")',
148
- ])
149
-
 
 
150
  if crop_empty:
151
- code_lines.insert(3, "from visual_rag.preprocessing.crop_empty import crop_empty, CropEmptyConfig")
152
- code_lines.insert(len(code_lines) - 20, " # Note: Add crop_empty() preprocessing before embedding")
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 = {"float16": "torch.float16", "float32": "torch.float32", "bfloat16": "torch.bfloat16"}
 
 
 
 
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'DATASETS = [{datasets_str}]',
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
- "# Single-stage full retrieval",
234
- "def search(query: str):",
235
- " query_embedding = embedder.embed_query(query)",
236
- " return retriever.search_single_stage(",
237
- " query_embedding=query_embedding,",
238
- f" limit={top_k},",
239
- ' vector_name="initial",',
240
- " )",
241
- ])
 
 
242
  elif mode == "single_tiles":
243
- code_lines.extend([
244
- "# Single-stage tiles retrieval",
245
- "def search(query: str):",
246
- " query_embedding = embedder.embed_query(query)",
247
- " return retriever.search_single_stage(",
248
- " query_embedding=query_embedding,",
249
- f" limit={top_k},",
250
- ' vector_name="mean_pooling",',
251
- " )",
252
- ])
 
 
253
  elif mode == "single_global":
254
- code_lines.extend([
255
- "# Single-stage global retrieval",
256
- "def search(query: str):",
257
- " query_embedding = embedder.embed_query(query)",
258
- " return retriever.search_single_stage(",
259
- " query_embedding=query_embedding,",
260
- f" limit={top_k},",
261
- ' vector_name="global_pooling",',
262
- " )",
263
- ])
 
 
264
  elif mode == "two_stage":
265
  prefetch_k = config.get("prefetch_k", 256)
266
- stage1_mode = config.get("stage1_mode", "tokens_vs_tiles")
267
- code_lines.extend([
268
- "# Two-stage retrieval",
269
- "from visual_rag.retrieval import TwoStageRetriever",
270
- "",
271
- "two_stage = TwoStageRetriever(",
272
- " client=client,",
273
- " collection_name=COLLECTION,",
274
- " embedder=embedder,",
275
- ")",
276
- "",
277
- "def search(query: str):",
278
- " query_embedding = embedder.embed_query(query)",
279
- " return two_stage.search(",
280
- " query_embedding=query_embedding,",
281
- f" prefetch_limit={prefetch_k},",
282
- f" limit={top_k},",
283
- f' stage1_mode="{stage1_mode}",',
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
- "# Three-stage retrieval",
291
- "from visual_rag.retrieval import ThreeStageRetriever",
292
- "",
293
- "three_stage = ThreeStageRetriever(",
294
- " client=client,",
295
- " collection_name=COLLECTION,",
296
- " embedder=embedder,",
297
- ")",
298
- "",
299
- "def search(query: str):",
300
- " query_embedding = embedder.embed_query(query)",
301
- " return three_stage.search(",
302
- " query_embedding=query_embedding,",
303
- f" stage1_limit={stage1_k},",
304
- f" stage2_limit={stage2_k},",
305
- f" limit={top_k},",
306
- " )",
307
- ])
308
-
 
 
309
  if scope == "per_dataset":
310
- code_lines.extend([
311
- "",
312
- "# Per-dataset filtering",
313
- "from qdrant_client.models import Filter, FieldCondition, MatchValue",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
314
  "",
315
- 'def search_dataset(query: str, dataset: str = "vidore/esg_reports_v2"):',
316
- " query_embedding = embedder.embed_query(query)",
317
- " dataset_filter = Filter(",
318
- " must=[FieldCondition(",
319
- ' key="dataset",',
320
- " match=MatchValue(value=dataset),",
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
- "tokens_vs_tiles",
30
- "tokens_vs_experimental",
31
- "pooled_query_vs_tiles",
32
- "pooled_query_vs_experimental",
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
- if "colsmol" in model_name.lower():
 
 
 
 
 
 
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 ndcg_at_k, mrr_at_k, recall_at_k
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(*, dataset_name: str, source_doc_id: str, union_namespace: Optional[str]) -> str:
 
 
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", "tokens_vs_tiles")
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(f"[EVAL] ═══════════════════════════════════════════════════")
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(f"[EVAL] ═══════════════════════════════════════════════════")
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(f"[EVAL] Embedder loaded (torch_dtype={torch_dtype}, output_dtype={qdrant_dtype})")
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(f"[EVAL] Connected to Qdrant")
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(f"[EVAL] Remapped {len(qrels)} -> {len(remapped_qrels)} queries with valid rels")
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 evaluation_scope == "union" and max_queries_per_ds and max_queries_per_ds < total_queries:
 
 
 
 
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(f"Embedding queries...")
209
-
210
  with phase3_container:
211
  st.markdown("##### 🎯 Phase 3: Running Evaluation")
212
-
213
  metrics_collectors = {
214
- "ndcg@5": [], "ndcg@10": [],
215
- "recall@5": [], "recall@10": [],
216
- "mrr@5": [], "mrr@10": [],
 
 
 
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 = {"ndcg@5": [], "ndcg@10": [], "recall@5": [], "recall@10": [], "mrr@5": [], "mrr@10": []}
 
 
 
 
 
 
 
251
  ds_latencies = []
252
  ds_log_lines = []
253
-
254
  eval_status.info(f"Evaluating {len(queries)} queries...")
255
- print(f"[EVAL] Starting per-dataset evaluation: {ds_short}, {len(queries)} queries")
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, 'numpy') else np.array(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(f"[EVAL] qrels has {len(qrels)} queries, this query in qrels: {q.query_id in qrels}")
 
 
286
  if results:
287
  r0 = results[0]
288
- print(f"[EVAL] Sample result payload keys: {list(r0.get('payload', {}).keys())}")
 
 
289
  p = r0.get("payload", {})
290
- print(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')}")
 
 
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(f"🎯 {i+1}/{len(queries)} ({int(progress*100)}%) — latency: {np.mean(ds_latencies):.0f}ms")
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 = np.mean(ds_latencies[1:]) if len(ds_latencies) > 1 else ds_latencies[0]
309
- ds_log_lines.append(f"[{i+1}/{len(queries)}] NDCG@10={cur_ndcg:.4f}, lat={cur_lat:.0f}ms")
 
 
 
 
 
 
310
  log_area.code("\n".join(ds_log_lines[-5:]), language="text")
311
- print(f"[EVAL] {ds_short} {i+1}/{len(queries)}: NDCG@10={cur_ndcg:.4f}, lat={cur_lat:.0f}ms")
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(f"✅ `{ds_short}`: NDCG@10={ds_final['ndcg@10']:.4f}, latency={ds_final['avg_latency_ms']:.0f}ms")
 
 
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(f"✅ All queries embedded")
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(f"[EVAL] Queries embedded")
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, 'numpy') else np.array(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(f"[EVAL] all_qrels has {len(all_qrels)} queries, this query in qrels: {q.query_id in all_qrels}")
 
 
393
  if results:
394
  r0 = results[0]
395
- print(f"[EVAL] Sample result payload keys: {list(r0.get('payload', {}).keys())}")
 
 
396
  p = r0.get("payload", {})
397
- print(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')}")
 
 
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(f"🎯 {i+1}/{total_queries} ({int(progress*100)}%) — latency: {np.mean(latencies):.0f}ms")
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(f"[{i+1}/{total_queries}] NDCG@10={cur_ndcg:.4f}, lat={cur_lat:.0f}ms")
 
 
417
  log_area.code("\n".join(log_lines[-10:]), language="text")
418
- print(f"[EVAL] Progress {i+1}/{total_queries}: NDCG@10={cur_ndcg:.4f}, lat={cur_lat:.0f}ms")
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 = error_msg.split("name ")[-1].split(" is")[0] if "name " in error_msg else "unknown"
549
- st.error(f" **Collection Mismatch**: Vector `{vector_name}` not found in collection `{collection}`")
550
- st.warning(f"""
 
 
 
 
 
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(getattr(getattr(info, "config", None), "params", None), "vectors", None)
 
 
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("Datatype.", "")
 
 
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(collection_name: str, n: int, seed: int, _url: str, _api_key: str) -> List[Dict[str, Any]]:
 
 
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
- "id": str(p.id),
141
- "payload": payload,
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 = "tokens_vs_tiles",
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(f"\n4. Deleting test collection...")
49
  try:
50
  client.delete_collection(test_collection)
51
  print(" Deleted")
52
  except Exception as e:
53
  print(f" ERROR: {e}")
54
-
55
- print(f"\n5. Creating SIMPLE collection (single vector)...")
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(f"\n6. Deleting test collection...")
72
  try:
73
  client.delete_collection(test_collection)
74
  print(" Deleted")
75
  except Exception as e:
76
  print(f" ERROR: {e}")
77
-
78
- print(f"\n7. Creating MULTI-VECTOR collection (like visual-rag)...")
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(f"\n8. Final cleanup...")
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("Datasets", BENCHMARK_DATASETS, default=BENCHMARK_DATASETS, key="bi_ds")
 
 
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("New Collection Name", value=f"vidore_{len(datasets)}ds__{model_short}", key="bi_coll")
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(f"🎯 **Selected:** {len(datasets)} dataset(s) — **{sel_docs:,}** docs, **{sel_queries:,}** queries")
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("Batch Size", min_value=1, max_value=16, value=4, key="bi_batch")
 
 
85
  with col_torch:
86
- torch_dtype = st.selectbox("Torch dtype", ["float16", "float32"], index=0, key="bi_torch_dtype")
 
 
87
  with col_qdrant:
88
- qdrant_dtype = st.selectbox("Qdrant dtype", ["float16", "float32"], index=0, key="bi_qdrant_dtype")
89
-
90
- effective_docs = min(max_docs * len(datasets), sel_docs) if max_docs < max_docs_val else sel_docs
91
-
 
 
 
 
92
  config = {
93
- "datasets": datasets, "model": model, "collection": collection,
94
- "crop_empty": crop, "crop_percentage": crop_pct,
95
- "no_cloudinary": not cloudinary, "recreate": recreate, "resume": False,
96
- "prefer_grpc": grpc, "batch_size": batch_size, "upload_batch_size": 8,
97
- "qdrant_timeout": 180, "qdrant_retries": 5,
98
- "torch_dtype": torch_dtype, "qdrant_vector_dtype": qdrant_dtype,
 
 
 
 
 
 
 
 
 
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("🚀 Run Index", type="primary", key="bi_run", use_container_width=True)
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(f"📊 **Available:** {len(BENCHMARK_DATASETS)} datasets — **{all_docs:,}** docs, **{all_queries:,}** queries")
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(f"🎯 **Selected:** {len(datasets)} dataset(s) — **{sel_docs:,}** docs, **{sel_queries:,}** queries")
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 = "tokens_vs_tiles", 256, 1000, 300
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, "model": model, "collection": collection,
202
- "mode": mode, "top_k": top_k, "evaluation_scope": scope,
 
 
 
 
203
  "prefer_grpc": grpc,
204
  "torch_dtype": "float16",
205
  "qdrant_vector_dtype": "float16",
206
  "qdrant_timeout": 180,
207
- "stage1_mode": stage1_mode, "prefetch_k": prefetch_k,
208
- "stage1_k": stage1_k, "stage2_k": stage2_k,
 
 
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
- "Dataset": ds.split("/")[-1].replace("_v2", ""),
298
- "NDCG@5": m.get("ndcg@5", 0),
299
- "NDCG@10": m.get("ndcg@10", 0),
300
- "Recall@5": m.get("recall@5", 0),
301
- "Recall@10": m.get("recall@10", 0),
302
- "MRR@10": m.get("mrr@10", 0),
303
- "Latency": m.get("avg_latency_ms", 0),
304
- "QPS": m.get("qps", 0),
305
- })
306
-
 
 
307
  df = pd.DataFrame(rows)
308
-
309
  st.dataframe(
310
- df.style.format({
311
- "NDCG@5": "{:.4f}", "NDCG@10": "{:.4f}",
312
- "Recall@5": "{:.4f}", "Recall@10": "{:.4f}",
313
- "MRR@10": "{:.4f}", "Latency": "{:.1f}", "QPS": "{:.2f}"
314
- }),
315
- hide_index=True, use_container_width=True
 
 
 
 
 
 
 
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({"Dataset": ds_short, "Metric": "NDCG@10", "Value": m.get("ndcg@10", 0)})
322
- chart_data.append({"Dataset": ds_short, "Metric": "Recall@10", "Value": m.get("recall@10", 0)})
323
- chart_data.append({"Dataset": ds_short, "Metric": "MRR@10", "Value": m.get("mrr@10", 0)})
324
-
 
 
 
 
 
 
325
  chart_df = pd.DataFrame(chart_data)
326
-
327
- chart = alt.Chart(chart_df).mark_bar().encode(
328
- x=alt.X("Dataset:N", title=None),
329
- y=alt.Y("Value:Q", scale=alt.Scale(domain=[0, 1]), title="Score"),
330
- color=alt.Color("Metric:N", scale=alt.Scale(scheme="tableau10")),
331
- xOffset="Metric:N",
332
- tooltip=["Dataset", "Metric", alt.Tooltip("Value:Q", format=".4f")]
333
- ).properties(height=300, title="Metrics by Dataset")
334
-
 
 
 
 
 
335
  st.altair_chart(chart, use_container_width=True)
336
-
337
- latency_data = [{"Dataset": ds.split("/")[-1].replace("_v2", ""), "Latency (ms)": m.get("avg_latency_ms", 0), "QPS": m.get("qps", 0)} for ds, m in metrics.items()]
 
 
 
 
 
 
 
338
  latency_df = pd.DataFrame(latency_data)
339
-
340
  c1, c2 = st.columns(2)
341
  with c1:
342
- lat_chart = alt.Chart(latency_df).mark_bar(color="#ff6b6b").encode(
343
- x=alt.X("Dataset:N"),
344
- y=alt.Y("Latency (ms):Q"),
345
- tooltip=["Dataset", alt.Tooltip("Latency (ms):Q", format=".1f")]
346
- ).properties(height=200, title="Avg Latency")
 
 
 
 
 
347
  st.altair_chart(lat_chart, use_container_width=True)
348
-
349
  with c2:
350
- qps_chart = alt.Chart(latency_df).mark_bar(color="#4ecdc4").encode(
351
- x=alt.X("Dataset:N"),
352
- y=alt.Y("QPS:Q"),
353
- tooltip=["Dataset", alt.Tooltip("QPS:Q", format=".2f")]
354
- ).properties(height=200, title="QPS (Queries/sec)")
 
 
 
 
 
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
- """, unsafe_allow_html=True)
 
 
 
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(collection_name=active_collection, model_name=model_name)
 
 
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 as e:
55
  st.warning(f"Failed: {model_short}")
56
-
57
  if st.session_state.get("model_loaded"):
58
- st.markdown(f"✅ Found <span style='color:#e74c3c;font-weight:bold;'>{model_short}</span> model", unsafe_allow_html=True)
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 = (p.get("model_name") or p.get("model") or None)
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 = f"{crop_w}×{crop_h}" if (not _is_missing(crop_w) and not _is_missing(crop_h)) else None
 
 
 
 
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, "tokens_vs_tiles", 1000, 300
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, query, top_k, mode, prefetch_k, stage1_mode, stage1_k, stage2_k, model_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(f"**#{i+1}** {str(filename)[:35]} - Page {page_num} | 🎯 {rel:.0f}%", expanded=(i < 3)):
 
 
 
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
- """, unsafe_allow_html=True)
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 qdrant_url != st.session_state["qdrant_url_input"] or qdrant_key != st.session_state["qdrant_key_input"]:
 
 
 
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([os.getenv("CLOUDINARY_CLOUD_NAME"), os.getenv("CLOUDINARY_API_KEY")])
 
 
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 = stats.get("status", "unknown").replace("CollectionStatus.", "").lower()
113
- status_icon = "🟢" if status_raw == "green" else "🟡" if status_raw == "yellow" else "🔴"
 
 
 
 
 
 
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(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>")
 
 
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("Move to RAM", value=current_in_ram, key=f"admin_ram_{sel_vec}")
 
 
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={sel_vec: VectorParamsDiff(on_disk=not target_in_ram)}
 
 
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("Collection", value="my_collection", key="upload_collection_input")
97
-
 
 
98
  c3, c4 = st.columns(2)
99
  with c3:
100
- vector_dtype = st.selectbox("Vector Dtype", ["float16", "float32"], index=0, key="upload_dtype")
 
 
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(st.session_state.get("upload_uniform_rowcol_std_threshold", 0.0) or 0.0),
 
 
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("Crop %", 0.90, 0.99, 0.99, 0.01, key="upload_crop_pct",
171
- help="Remove margins with this % empty space")
 
 
 
 
 
 
 
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(f"Connecting to Qdrant...")
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(f"✅ Connected to Qdrant")
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 as e:
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(fields=[
318
- {"field": "filename", "type": "keyword"},
319
- {"field": "page_number", "type": "integer"},
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, indexer=indexer, cloudinary_uploader=cloudinary_uploader,
 
 
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, crop_empty_percentage_to_remove=crop_pct,
348
- **({
349
- "crop_empty_uniform_rowcol_std_threshold": uniform_rowcol_std_threshold
350
- } if "crop_empty_uniform_rowcol_std_threshold" in inspect.signature(ProcessingPipeline.__init__).parameters else {}),
 
 
 
 
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(f"📄 Processing `{original_filename}` ({i+1}/{len(uploaded_files)})")
 
 
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(f" └─ Embedding pages… ({current+1}-{min(current + 1 + (pipeline.embedding_batch_size - 1), total)}/{total})")
 
 
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
- "file": original_filename,
399
- "uploaded": uploaded,
400
- "skipped": skipped,
401
- "total_pages": total_pages,
402
- "elapsed_s": float(elapsed_s),
403
- "sec_per_page": float(sec_per_page) if sec_per_page is not None else None,
404
- })
405
- timing_str = f"{elapsed_s:.1f}s" + (f" ({sec_per_page:.2f}s/page)" if sec_per_page is not None else "")
406
- log_lines.append(f"✓ {original_filename}: {uploaded} uploaded, {skipped} skipped | {timing_str}")
 
 
 
 
 
 
 
 
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(f"✅ **{total_uploaded} pages** uploaded to `{collection_name}`" +
424
- (f" ({total_skipped} skipped)" if total_skipped else "") +
425
- (f" ({total_failed} failed)" if total_failed else ""))
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, "total_skipped": total_skipped, "total_failed": total_failed,
444
- "file_results": file_results, "collection": collection_name,
 
 
 
445
  }
446
-
447
  get_collection_stats.clear()
448
  sample_points_cached.clear()
449
-
450
  if total_uploaded > 0:
451
- st.session_state["upload_success"] = f"Uploaded {total_uploaded} pages to {collection_name}"
 
 
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(f"✅ **{uploaded} pages** uploaded to `{collection}`" +
472
- (f" ({skipped} skipped)" if skipped else "") +
473
- (f" ({failed} failed)" if failed else ""))
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 tokens_vs_tiles \
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 VisualEmbedder, QdrantIndexer, CloudinaryUploader, load_config
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
- "--model", type=str,
62
- help="Model name (overrides config)"
63
  )
 
 
 
 
64
  parser.add_argument(
65
- "--no-cloudinary", action="store_true",
66
- help="Skip Cloudinary uploads"
67
  )
68
  parser.add_argument(
69
- "--no-qdrant", action="store_true",
70
- help="Skip Qdrant uploads (just generate embeddings)"
 
 
71
  )
72
- parser.add_argument(
73
- "--skip-existing", action="store_true", default=True,
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("collection_name", "visual_documents")
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(f"📊 SUMMARY")
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, generate_saliency_map
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
- "--query", type=str, required=True,
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
- "--year", type=int,
64
- help="Filter by year"
65
  )
 
 
 
66
  parser.add_argument(
67
- "--source", type=str,
68
- help="Filter by source"
69
  )
70
- parser.add_argument(
71
- "--district", type=str,
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='w', suffix='.yaml', delete=False) as f:
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, get
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='w', suffix='.yaml', delete=False) as f:
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
- from visual_rag.config import get
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='w', suffix='.yaml', delete=False) as f:
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('RGB', (1000, 1414), color='white')
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('RGB', (100, 100), color='white')
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('RGB', (3000, 500), color='white')
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('RGBA', (1000, 1000), color='white')
59
-
60
  resized, _, _ = processor.resize_for_colpali(img)
61
-
62
- assert resized.mode == 'RGB'
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)