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