Spaces:
Running
Running
add github CI/CD
Browse files- .github/workflows/ci.yaml +2 -2
- demo/evaluation.py +40 -2
- tests/test_config.py +4 -4
- visual_rag/__init__.py +4 -6
- visual_rag/cli/__init__.py +0 -2
- visual_rag/cli/main.py +125 -133
- visual_rag/config.py +53 -43
- visual_rag/demo_runner.py +3 -2
- visual_rag/embedding/__init__.py +4 -5
- visual_rag/embedding/pooling.py +53 -51
- visual_rag/embedding/visual_embedder.py +137 -91
- visual_rag/indexing/__init__.py +2 -2
- visual_rag/indexing/cloudinary_uploader.py +46 -51
- visual_rag/indexing/pdf_processor.py +85 -76
- visual_rag/indexing/pipeline.py +142 -115
- visual_rag/indexing/qdrant_indexer.py +75 -62
- visual_rag/preprocessing/__init__.py +0 -2
- visual_rag/preprocessing/crop_empty.py +15 -7
- visual_rag/qdrant_admin.py +29 -12
- visual_rag/retrieval/__init__.py +2 -2
- visual_rag/retrieval/multi_vector.py +6 -4
- visual_rag/retrieval/single_stage.py +17 -18
- visual_rag/retrieval/three_stage.py +1 -2
- visual_rag/retrieval/two_stage.py +69 -88
- visual_rag/visualization/__init__.py +1 -1
- visual_rag/visualization/saliency.py +63 -67
.github/workflows/ci.yaml
CHANGED
|
@@ -23,10 +23,10 @@ jobs:
|
|
| 23 |
run: pip install ruff black mypy
|
| 24 |
|
| 25 |
- name: Run ruff
|
| 26 |
-
run: ruff check visual_rag/
|
| 27 |
|
| 28 |
- name: Run black --check
|
| 29 |
-
run: black --check visual_rag/
|
| 30 |
|
| 31 |
test:
|
| 32 |
runs-on: ${{ matrix.os }}
|
|
|
|
| 23 |
run: pip install ruff black mypy
|
| 24 |
|
| 25 |
- name: Run ruff
|
| 26 |
+
run: ruff check visual_rag/
|
| 27 |
|
| 28 |
- name: Run black --check
|
| 29 |
+
run: black --check visual_rag/
|
| 30 |
|
| 31 |
test:
|
| 32 |
runs-on: ${{ matrix.os }}
|
demo/evaluation.py
CHANGED
|
@@ -1,11 +1,13 @@
|
|
| 1 |
"""Evaluation runner with UI updates."""
|
| 2 |
|
| 3 |
import hashlib
|
|
|
|
| 4 |
import json
|
| 5 |
import logging
|
| 6 |
import time
|
| 7 |
import traceback
|
| 8 |
from datetime import datetime
|
|
|
|
| 9 |
from typing import Any, Dict, List, Optional
|
| 10 |
|
| 11 |
import numpy as np
|
|
@@ -23,8 +25,44 @@ TORCH_DTYPE_MAP = {
|
|
| 23 |
from qdrant_client.models import Filter, FieldCondition, MatchValue
|
| 24 |
|
| 25 |
from visual_rag.retrieval import MultiVectorRetriever
|
| 26 |
-
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
from demo.qdrant_utils import get_qdrant_credentials
|
| 30 |
|
|
|
|
| 1 |
"""Evaluation runner with UI updates."""
|
| 2 |
|
| 3 |
import hashlib
|
| 4 |
+
import importlib.util
|
| 5 |
import json
|
| 6 |
import logging
|
| 7 |
import time
|
| 8 |
import traceback
|
| 9 |
from datetime import datetime
|
| 10 |
+
from pathlib import Path
|
| 11 |
from typing import Any, Dict, List, Optional
|
| 12 |
|
| 13 |
import numpy as np
|
|
|
|
| 25 |
from qdrant_client.models import Filter, FieldCondition, MatchValue
|
| 26 |
|
| 27 |
from visual_rag.retrieval import MultiVectorRetriever
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _load_local_benchmark_module(module_filename: str):
|
| 31 |
+
"""
|
| 32 |
+
Load `benchmarks/vidore_tatdqa_test/<module_filename>` via file path.
|
| 33 |
+
|
| 34 |
+
Motivation:
|
| 35 |
+
- Some environments (notably containers / Spaces) can have a third-party
|
| 36 |
+
`benchmarks` package installed, causing `import benchmarks...` to resolve
|
| 37 |
+
to the wrong module.
|
| 38 |
+
- This fallback guarantees we load the repo's benchmark utilities.
|
| 39 |
+
"""
|
| 40 |
+
root = Path(__file__).resolve().parents[1] # demo/.. = repo root
|
| 41 |
+
target = root / "benchmarks" / "vidore_tatdqa_test" / module_filename
|
| 42 |
+
if not target.exists():
|
| 43 |
+
raise ModuleNotFoundError(f"Missing local benchmark module file: {target}")
|
| 44 |
+
|
| 45 |
+
name = f"_visual_rag_toolkit_local_{target.stem}"
|
| 46 |
+
spec = importlib.util.spec_from_file_location(name, str(target))
|
| 47 |
+
if spec is None or spec.loader is None:
|
| 48 |
+
raise ModuleNotFoundError(f"Could not load module spec for: {target}")
|
| 49 |
+
mod = importlib.util.module_from_spec(spec)
|
| 50 |
+
spec.loader.exec_module(mod) # type: ignore[attr-defined]
|
| 51 |
+
return mod
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
try:
|
| 55 |
+
# Preferred: normal import
|
| 56 |
+
from benchmarks.vidore_tatdqa_test.dataset_loader import load_vidore_beir_dataset
|
| 57 |
+
from benchmarks.vidore_tatdqa_test.metrics import ndcg_at_k, mrr_at_k, recall_at_k
|
| 58 |
+
except ModuleNotFoundError:
|
| 59 |
+
# Robust fallback: load from local file paths
|
| 60 |
+
_dl = _load_local_benchmark_module("dataset_loader.py")
|
| 61 |
+
_mx = _load_local_benchmark_module("metrics.py")
|
| 62 |
+
load_vidore_beir_dataset = _dl.load_vidore_beir_dataset
|
| 63 |
+
ndcg_at_k = _mx.ndcg_at_k
|
| 64 |
+
mrr_at_k = _mx.mrr_at_k
|
| 65 |
+
recall_at_k = _mx.recall_at_k
|
| 66 |
|
| 67 |
from demo.qdrant_utils import get_qdrant_credentials
|
| 68 |
|
tests/test_config.py
CHANGED
|
@@ -26,7 +26,7 @@ qdrant:
|
|
| 26 |
config_path = f.name
|
| 27 |
|
| 28 |
try:
|
| 29 |
-
config = load_config(config_path)
|
| 30 |
|
| 31 |
assert config["model"]["name"] == "test-model"
|
| 32 |
assert config["model"]["batch_size"] == 8
|
|
@@ -50,7 +50,7 @@ model:
|
|
| 50 |
config_path = f.name
|
| 51 |
|
| 52 |
try:
|
| 53 |
-
config = load_config(config_path)
|
| 54 |
# The env var should be checked in get() if implemented
|
| 55 |
# For now, just verify config loads
|
| 56 |
assert config["model"]["name"] == "yaml-model"
|
|
@@ -93,8 +93,8 @@ qdrant:
|
|
| 93 |
config_path = f.name
|
| 94 |
|
| 95 |
try:
|
| 96 |
-
load_config(config_path)
|
| 97 |
-
section = get_section("qdrant")
|
| 98 |
|
| 99 |
assert section["url"] == "http://localhost"
|
| 100 |
assert section["collection"] == "test"
|
|
|
|
| 26 |
config_path = f.name
|
| 27 |
|
| 28 |
try:
|
| 29 |
+
config = load_config(config_path, force_reload=True, apply_env_overrides=False)
|
| 30 |
|
| 31 |
assert config["model"]["name"] == "test-model"
|
| 32 |
assert config["model"]["batch_size"] == 8
|
|
|
|
| 50 |
config_path = f.name
|
| 51 |
|
| 52 |
try:
|
| 53 |
+
config = load_config(config_path, force_reload=True, apply_env_overrides=False)
|
| 54 |
# The env var should be checked in get() if implemented
|
| 55 |
# For now, just verify config loads
|
| 56 |
assert config["model"]["name"] == "yaml-model"
|
|
|
|
| 93 |
config_path = f.name
|
| 94 |
|
| 95 |
try:
|
| 96 |
+
load_config(config_path, force_reload=True, apply_env_overrides=False)
|
| 97 |
+
section = get_section("qdrant", apply_env_overrides=False)
|
| 98 |
|
| 99 |
assert section["url"] == "http://localhost"
|
| 100 |
assert section["collection"] == "test"
|
visual_rag/__init__.py
CHANGED
|
@@ -14,16 +14,16 @@ Components:
|
|
| 14 |
Quick Start:
|
| 15 |
------------
|
| 16 |
>>> from visual_rag import VisualEmbedder, PDFProcessor, TwoStageRetriever
|
| 17 |
-
>>>
|
| 18 |
>>> # Process PDFs
|
| 19 |
>>> processor = PDFProcessor(dpi=140)
|
| 20 |
>>> images, texts = processor.process_pdf("report.pdf")
|
| 21 |
-
>>>
|
| 22 |
>>> # Generate embeddings
|
| 23 |
>>> embedder = VisualEmbedder()
|
| 24 |
>>> embeddings = embedder.embed_images(images)
|
| 25 |
>>> query_emb = embedder.embed_query("What is the budget?")
|
| 26 |
-
>>>
|
| 27 |
>>> # Search with two-stage retrieval
|
| 28 |
>>> retriever = TwoStageRetriever(qdrant_client, "my_collection")
|
| 29 |
>>> results = retriever.search(query_emb, top_k=10)
|
|
@@ -77,12 +77,11 @@ except ImportError:
|
|
| 77 |
demo = None
|
| 78 |
|
| 79 |
# Config utilities (always available)
|
| 80 |
-
from visual_rag.config import
|
| 81 |
|
| 82 |
__all__ = [
|
| 83 |
# Version
|
| 84 |
"__version__",
|
| 85 |
-
|
| 86 |
# Main classes
|
| 87 |
"VisualEmbedder",
|
| 88 |
"PDFProcessor",
|
|
@@ -92,7 +91,6 @@ __all__ = [
|
|
| 92 |
"MultiVectorRetriever",
|
| 93 |
"QdrantAdmin",
|
| 94 |
"demo",
|
| 95 |
-
|
| 96 |
# Config utilities
|
| 97 |
"load_config",
|
| 98 |
"get",
|
|
|
|
| 14 |
Quick Start:
|
| 15 |
------------
|
| 16 |
>>> from visual_rag import VisualEmbedder, PDFProcessor, TwoStageRetriever
|
| 17 |
+
>>>
|
| 18 |
>>> # Process PDFs
|
| 19 |
>>> processor = PDFProcessor(dpi=140)
|
| 20 |
>>> images, texts = processor.process_pdf("report.pdf")
|
| 21 |
+
>>>
|
| 22 |
>>> # Generate embeddings
|
| 23 |
>>> embedder = VisualEmbedder()
|
| 24 |
>>> embeddings = embedder.embed_images(images)
|
| 25 |
>>> query_emb = embedder.embed_query("What is the budget?")
|
| 26 |
+
>>>
|
| 27 |
>>> # Search with two-stage retrieval
|
| 28 |
>>> retriever = TwoStageRetriever(qdrant_client, "my_collection")
|
| 29 |
>>> results = retriever.search(query_emb, top_k=10)
|
|
|
|
| 77 |
demo = None
|
| 78 |
|
| 79 |
# Config utilities (always available)
|
| 80 |
+
from visual_rag.config import get, get_section, load_config
|
| 81 |
|
| 82 |
__all__ = [
|
| 83 |
# Version
|
| 84 |
"__version__",
|
|
|
|
| 85 |
# Main classes
|
| 86 |
"VisualEmbedder",
|
| 87 |
"PDFProcessor",
|
|
|
|
| 91 |
"MultiVectorRetriever",
|
| 92 |
"QdrantAdmin",
|
| 93 |
"demo",
|
|
|
|
| 94 |
# Config utilities
|
| 95 |
"load_config",
|
| 96 |
"get",
|
visual_rag/cli/__init__.py
CHANGED
|
@@ -1,3 +1 @@
|
|
| 1 |
"""CLI entry point for visual-rag-toolkit."""
|
| 2 |
-
|
| 3 |
-
|
|
|
|
| 1 |
"""CLI entry point for visual-rag-toolkit."""
|
|
|
|
|
|
visual_rag/cli/main.py
CHANGED
|
@@ -10,20 +10,19 @@ Provides command-line interface for:
|
|
| 10 |
Usage:
|
| 11 |
# Process PDFs (like process_pdfs_saliency_v2.py)
|
| 12 |
visual-rag process --reports-dir ./pdfs --metadata-file metadata.json
|
| 13 |
-
|
| 14 |
# Search
|
| 15 |
visual-rag search --query "budget allocation" --collection my_docs
|
| 16 |
-
|
| 17 |
# Show collection info
|
| 18 |
visual-rag info --collection my_docs
|
| 19 |
"""
|
| 20 |
|
| 21 |
-
import os
|
| 22 |
-
import sys
|
| 23 |
import argparse
|
| 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
|
|
@@ -44,38 +43,38 @@ def setup_logging(debug: bool = False):
|
|
| 44 |
def cmd_process(args):
|
| 45 |
"""
|
| 46 |
Process PDFs: convert → embed → upload to Cloudinary → index in Qdrant.
|
| 47 |
-
|
| 48 |
Equivalent to process_pdfs_saliency_v2.py
|
| 49 |
"""
|
| 50 |
-
from visual_rag import
|
| 51 |
from visual_rag.indexing.pipeline import ProcessingPipeline
|
| 52 |
-
|
| 53 |
# Load environment
|
| 54 |
load_dotenv()
|
| 55 |
-
|
| 56 |
# Load config
|
| 57 |
config = {}
|
| 58 |
if args.config and Path(args.config).exists():
|
| 59 |
config = load_config(args.config)
|
| 60 |
-
|
| 61 |
# Get PDFs
|
| 62 |
reports_dir = Path(args.reports_dir)
|
| 63 |
if not reports_dir.exists():
|
| 64 |
logger.error(f"❌ Reports directory not found: {reports_dir}")
|
| 65 |
sys.exit(1)
|
| 66 |
-
|
| 67 |
pdf_paths = sorted(reports_dir.glob("*.pdf")) + sorted(reports_dir.glob("*.PDF"))
|
| 68 |
if not pdf_paths:
|
| 69 |
logger.error(f"❌ No PDF files found in: {reports_dir}")
|
| 70 |
sys.exit(1)
|
| 71 |
-
|
| 72 |
logger.info(f"📁 Found {len(pdf_paths)} PDF files")
|
| 73 |
-
|
| 74 |
# Load metadata mapping
|
| 75 |
metadata_mapping = {}
|
| 76 |
if args.metadata_file:
|
| 77 |
metadata_mapping = ProcessingPipeline.load_metadata_mapping(Path(args.metadata_file))
|
| 78 |
-
|
| 79 |
# Dry run - just show summary
|
| 80 |
if args.dry_run:
|
| 81 |
logger.info("🏃 DRY RUN MODE")
|
|
@@ -83,21 +82,24 @@ def cmd_process(args):
|
|
| 83 |
logger.info(f" Metadata entries: {len(metadata_mapping)}")
|
| 84 |
logger.info(f" Collection: {args.collection}")
|
| 85 |
logger.info(f" Cloudinary: {'ENABLED' if not args.no_cloudinary else 'DISABLED'}")
|
| 86 |
-
|
| 87 |
for pdf in pdf_paths[:10]:
|
| 88 |
has_meta = "✓" if pdf.stem.lower() in metadata_mapping else "✗"
|
| 89 |
logger.info(f" {has_meta} {pdf.name}")
|
| 90 |
if len(pdf_paths) > 10:
|
| 91 |
logger.info(f" ... and {len(pdf_paths) - 10} more")
|
| 92 |
return
|
| 93 |
-
|
| 94 |
# Get settings
|
| 95 |
model_name = args.model or config.get("model", {}).get("name", "vidore/colSmol-500M")
|
| 96 |
-
collection_name = args.collection or config.get("qdrant", {}).get(
|
| 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,
|
|
@@ -111,20 +113,22 @@ def cmd_process(args):
|
|
| 111 |
torch_dtype=torch_dtype,
|
| 112 |
processor_speed=str(getattr(args, "processor_speed", "fast")),
|
| 113 |
)
|
| 114 |
-
|
| 115 |
# Initialize Qdrant indexer
|
| 116 |
-
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")
|
| 126 |
sys.exit(1)
|
| 127 |
-
|
| 128 |
logger.info(f"🔌 Connecting to Qdrant: {qdrant_url}")
|
| 129 |
indexer = QdrantIndexer(
|
| 130 |
url=qdrant_url,
|
|
@@ -133,7 +137,7 @@ def cmd_process(args):
|
|
| 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 = []
|
|
@@ -166,7 +170,7 @@ def cmd_process(args):
|
|
| 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
|
| 172 |
if not args.no_cloudinary:
|
|
@@ -176,7 +180,7 @@ def cmd_process(args):
|
|
| 176 |
except ValueError as e:
|
| 177 |
logger.warning(f"⚠️ Cloudinary not configured: {e}")
|
| 178 |
logger.warning(" Continuing without Cloudinary uploads")
|
| 179 |
-
|
| 180 |
# Create pipeline
|
| 181 |
pipeline = ProcessingPipeline(
|
| 182 |
embedder=embedder,
|
|
@@ -186,42 +190,44 @@ def cmd_process(args):
|
|
| 186 |
config=config,
|
| 187 |
embedding_strategy=args.strategy,
|
| 188 |
crop_empty=bool(getattr(args, "crop_empty", False)),
|
| 189 |
-
crop_empty_percentage_to_remove=float(
|
|
|
|
|
|
|
| 190 |
crop_empty_remove_page_number=bool(getattr(args, "crop_empty_remove_page_number", False)),
|
| 191 |
)
|
| 192 |
-
|
| 193 |
# Process PDFs
|
| 194 |
total_uploaded = 0
|
| 195 |
total_skipped = 0
|
| 196 |
total_failed = 0
|
| 197 |
-
|
| 198 |
skip_existing = not args.no_skip_existing
|
| 199 |
-
|
| 200 |
for pdf_idx, pdf_path in enumerate(pdf_paths, 1):
|
| 201 |
logger.info(f"\n{'='*60}")
|
| 202 |
logger.info(f"📄 [{pdf_idx}/{len(pdf_paths)}] {pdf_path.name}")
|
| 203 |
logger.info(f"{'='*60}")
|
| 204 |
-
|
| 205 |
result = pipeline.process_pdf(
|
| 206 |
pdf_path,
|
| 207 |
skip_existing=skip_existing,
|
| 208 |
upload_to_cloudinary=(not args.no_cloudinary),
|
| 209 |
upload_to_qdrant=True,
|
| 210 |
)
|
| 211 |
-
|
| 212 |
total_uploaded += result["uploaded"]
|
| 213 |
total_skipped += result["skipped"]
|
| 214 |
total_failed += result["failed"]
|
| 215 |
-
|
| 216 |
# Summary
|
| 217 |
logger.info(f"\n{'='*60}")
|
| 218 |
-
logger.info(
|
| 219 |
logger.info(f"{'='*60}")
|
| 220 |
logger.info(f" Total PDFs: {len(pdf_paths)}")
|
| 221 |
logger.info(f" Uploaded: {total_uploaded}")
|
| 222 |
logger.info(f" Skipped: {total_skipped}")
|
| 223 |
logger.info(f" Failed: {total_failed}")
|
| 224 |
-
|
| 225 |
info = indexer.get_collection_info()
|
| 226 |
if info:
|
| 227 |
logger.info(f" Collection points: {info.get('points_count', 'N/A')}")
|
|
@@ -229,29 +235,34 @@ def cmd_process(args):
|
|
| 229 |
|
| 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 =
|
|
|
|
|
|
|
| 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")
|
| 248 |
sys.exit(1)
|
| 249 |
-
|
| 250 |
# Initialize
|
| 251 |
logger.info(f"🤖 Loading model: {args.model}")
|
| 252 |
-
embedder = VisualEmbedder(
|
|
|
|
|
|
|
| 253 |
|
| 254 |
-
logger.info(
|
| 255 |
grpc_port = 6334 if args.prefer_grpc and urlparse(qdrant_url).port == 6333 else None
|
| 256 |
client = QdrantClient(
|
| 257 |
url=qdrant_url,
|
|
@@ -262,11 +273,11 @@ def cmd_search(args):
|
|
| 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}")
|
| 268 |
query_embedding = embedder.embed_query(args.query)
|
| 269 |
-
|
| 270 |
# Build filter
|
| 271 |
filter_obj = None
|
| 272 |
if args.year or args.source or args.district:
|
|
@@ -275,7 +286,7 @@ def cmd_search(args):
|
|
| 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":
|
|
@@ -307,21 +318,21 @@ def cmd_search(args):
|
|
| 307 |
filter_obj=filter_obj,
|
| 308 |
stage1_mode=args.stage1_mode,
|
| 309 |
)
|
| 310 |
-
|
| 311 |
# Display results
|
| 312 |
logger.info(f"\n📊 Results ({len(results)}):")
|
| 313 |
for i, result in enumerate(results, 1):
|
| 314 |
payload = result.get("payload", {})
|
| 315 |
score = result.get("score_final", result.get("score_stage1", 0))
|
| 316 |
-
|
| 317 |
filename = payload.get("filename", "N/A")
|
| 318 |
page_num = payload.get("page_number", "N/A")
|
| 319 |
year = payload.get("year", "N/A")
|
| 320 |
source = payload.get("source", "N/A")
|
| 321 |
-
|
| 322 |
logger.info(f" {i}. {filename} p.{page_num}")
|
| 323 |
logger.info(f" Score: {score:.4f} | Year: {year} | Source: {source}")
|
| 324 |
-
|
| 325 |
# Text snippet
|
| 326 |
text = payload.get("text", "")
|
| 327 |
if text and args.show_text:
|
|
@@ -332,21 +343,23 @@ def cmd_search(args):
|
|
| 332 |
def cmd_info(args):
|
| 333 |
"""Show collection info."""
|
| 334 |
from qdrant_client import QdrantClient
|
| 335 |
-
|
| 336 |
load_dotenv()
|
| 337 |
-
|
| 338 |
-
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,
|
|
@@ -355,29 +368,29 @@ def cmd_info(args):
|
|
| 355 |
grpc_port=grpc_port,
|
| 356 |
check_compatibility=False,
|
| 357 |
)
|
| 358 |
-
|
| 359 |
try:
|
| 360 |
info = client.get_collection(args.collection)
|
| 361 |
-
|
| 362 |
status = info.status
|
| 363 |
if hasattr(status, "value"):
|
| 364 |
status = status.value
|
| 365 |
-
|
| 366 |
indexed_count = getattr(info, "indexed_vectors_count", 0) or 0
|
| 367 |
if isinstance(indexed_count, dict):
|
| 368 |
indexed_count = sum(indexed_count.values())
|
| 369 |
-
|
| 370 |
logger.info(f"📊 Collection: {args.collection}")
|
| 371 |
logger.info(f" Status: {status}")
|
| 372 |
logger.info(f" Points: {info.points_count}")
|
| 373 |
logger.info(f" Indexed vectors: {indexed_count}")
|
| 374 |
-
|
| 375 |
# Show vector config
|
| 376 |
if hasattr(info, "config") and hasattr(info.config, "params"):
|
| 377 |
vectors = getattr(info.config.params, "vectors", {})
|
| 378 |
if vectors:
|
| 379 |
logger.info(f" Vectors: {list(vectors.keys())}")
|
| 380 |
-
|
| 381 |
except Exception as e:
|
| 382 |
logger.error(f"❌ Could not get collection info: {e}")
|
| 383 |
sys.exit(1)
|
|
@@ -393,24 +406,24 @@ def main():
|
|
| 393 |
Examples:
|
| 394 |
# Process PDFs (like process_pdfs_saliency_v2.py)
|
| 395 |
visual-rag process --reports-dir ./pdfs --metadata-file metadata.json
|
| 396 |
-
|
| 397 |
# Process without Cloudinary
|
| 398 |
visual-rag process --reports-dir ./pdfs --no-cloudinary
|
| 399 |
-
|
| 400 |
# Search
|
| 401 |
visual-rag search --query "budget allocation" --collection my_docs
|
| 402 |
-
|
| 403 |
# Search with filters
|
| 404 |
visual-rag search --query "budget" --year 2023 --source "Local Government"
|
| 405 |
-
|
| 406 |
# Show collection info
|
| 407 |
visual-rag info --collection my_docs
|
| 408 |
""",
|
| 409 |
)
|
| 410 |
parser.add_argument("--debug", action="store_true", help="Enable debug logging")
|
| 411 |
-
|
| 412 |
subparsers = parser.add_subparsers(dest="command", help="Command")
|
| 413 |
-
|
| 414 |
# =========================================================================
|
| 415 |
# PROCESS command
|
| 416 |
# =========================================================================
|
|
@@ -420,32 +433,26 @@ Examples:
|
|
| 420 |
formatter_class=argparse.RawDescriptionHelpFormatter,
|
| 421 |
)
|
| 422 |
process_parser.add_argument(
|
| 423 |
-
"--reports-dir", type=str, required=True,
|
| 424 |
-
help="Directory containing PDF files"
|
| 425 |
-
)
|
| 426 |
-
process_parser.add_argument(
|
| 427 |
-
"--metadata-file", type=str,
|
| 428 |
-
help="JSON file with filename → metadata mapping (like filename_metadata.json)"
|
| 429 |
-
)
|
| 430 |
-
process_parser.add_argument(
|
| 431 |
-
"--collection", type=str, default="visual_documents",
|
| 432 |
-
help="Qdrant collection name"
|
| 433 |
)
|
| 434 |
process_parser.add_argument(
|
| 435 |
-
"--
|
| 436 |
-
|
|
|
|
| 437 |
)
|
| 438 |
process_parser.add_argument(
|
| 439 |
-
"--
|
| 440 |
-
help="Embedding batch size"
|
| 441 |
)
|
| 442 |
process_parser.add_argument(
|
| 443 |
-
"--
|
| 444 |
-
|
|
|
|
|
|
|
| 445 |
)
|
|
|
|
|
|
|
| 446 |
process_parser.add_argument(
|
| 447 |
-
"--no-cloudinary", action="store_true",
|
| 448 |
-
help="Skip Cloudinary uploads"
|
| 449 |
)
|
| 450 |
process_parser.add_argument(
|
| 451 |
"--crop-empty",
|
|
@@ -464,22 +471,23 @@ Examples:
|
|
| 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",
|
| 468 |
-
|
|
|
|
| 469 |
)
|
| 470 |
process_parser.add_argument(
|
| 471 |
-
"--force-recreate", action="store_true",
|
| 472 |
-
help="Delete and recreate collection"
|
| 473 |
)
|
| 474 |
process_parser.add_argument(
|
| 475 |
-
"--dry-run", action="store_true",
|
| 476 |
-
help="Show what would be processed without doing it"
|
| 477 |
)
|
| 478 |
process_parser.add_argument(
|
| 479 |
-
"--strategy",
|
|
|
|
|
|
|
| 480 |
choices=["pooling", "standard", "all"],
|
| 481 |
help="Embedding strategy: 'pooling' (NOVEL), 'standard' (BASELINE), "
|
| 482 |
-
|
| 483 |
)
|
| 484 |
process_parser.add_argument(
|
| 485 |
"--torch-dtype",
|
|
@@ -517,7 +525,7 @@ Examples:
|
|
| 517 |
help="Disable gRPC for Qdrant client.",
|
| 518 |
)
|
| 519 |
process_parser.set_defaults(func=cmd_process)
|
| 520 |
-
|
| 521 |
# =========================================================================
|
| 522 |
# SEARCH command
|
| 523 |
# =========================================================================
|
|
@@ -525,17 +533,12 @@ Examples:
|
|
| 525 |
"search",
|
| 526 |
help="Search documents",
|
| 527 |
)
|
|
|
|
| 528 |
search_parser.add_argument(
|
| 529 |
-
"--
|
| 530 |
-
help="Search query"
|
| 531 |
)
|
| 532 |
search_parser.add_argument(
|
| 533 |
-
"--
|
| 534 |
-
help="Qdrant collection name"
|
| 535 |
-
)
|
| 536 |
-
search_parser.add_argument(
|
| 537 |
-
"--model", type=str, default="vidore/colSmol-500M",
|
| 538 |
-
help="Model name"
|
| 539 |
)
|
| 540 |
search_parser.add_argument(
|
| 541 |
"--processor-speed",
|
|
@@ -544,39 +547,29 @@ Examples:
|
|
| 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 |
-
"--
|
| 549 |
-
|
| 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",
|
|
|
|
|
|
|
| 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 |
-
"--
|
| 567 |
-
help="Filter by year"
|
| 568 |
-
)
|
| 569 |
-
search_parser.add_argument(
|
| 570 |
-
"--source", type=str,
|
| 571 |
-
help="Filter by source"
|
| 572 |
-
)
|
| 573 |
-
search_parser.add_argument(
|
| 574 |
-
"--district", type=str,
|
| 575 |
-
help="Filter by district"
|
| 576 |
-
)
|
| 577 |
-
search_parser.add_argument(
|
| 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(
|
|
@@ -593,7 +586,7 @@ Examples:
|
|
| 593 |
help="Disable gRPC for Qdrant client.",
|
| 594 |
)
|
| 595 |
search_parser.set_defaults(func=cmd_search)
|
| 596 |
-
|
| 597 |
# =========================================================================
|
| 598 |
# INFO command
|
| 599 |
# =========================================================================
|
|
@@ -602,8 +595,7 @@ Examples:
|
|
| 602 |
help="Show collection info",
|
| 603 |
)
|
| 604 |
info_parser.add_argument(
|
| 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(
|
|
@@ -620,16 +612,16 @@ Examples:
|
|
| 620 |
help="Disable gRPC for Qdrant client.",
|
| 621 |
)
|
| 622 |
info_parser.set_defaults(func=cmd_info)
|
| 623 |
-
|
| 624 |
# Parse and execute
|
| 625 |
args = parser.parse_args()
|
| 626 |
-
|
| 627 |
setup_logging(args.debug)
|
| 628 |
-
|
| 629 |
if not args.command:
|
| 630 |
parser.print_help()
|
| 631 |
sys.exit(0)
|
| 632 |
-
|
| 633 |
args.func(args)
|
| 634 |
|
| 635 |
|
|
|
|
| 10 |
Usage:
|
| 11 |
# Process PDFs (like process_pdfs_saliency_v2.py)
|
| 12 |
visual-rag process --reports-dir ./pdfs --metadata-file metadata.json
|
| 13 |
+
|
| 14 |
# Search
|
| 15 |
visual-rag search --query "budget allocation" --collection my_docs
|
| 16 |
+
|
| 17 |
# Show collection info
|
| 18 |
visual-rag info --collection my_docs
|
| 19 |
"""
|
| 20 |
|
|
|
|
|
|
|
| 21 |
import argparse
|
| 22 |
import logging
|
| 23 |
+
import os
|
| 24 |
+
import sys
|
| 25 |
from pathlib import Path
|
|
|
|
| 26 |
from urllib.parse import urlparse
|
| 27 |
|
| 28 |
from dotenv import load_dotenv
|
|
|
|
| 43 |
def cmd_process(args):
|
| 44 |
"""
|
| 45 |
Process PDFs: convert → embed → upload to Cloudinary → index in Qdrant.
|
| 46 |
+
|
| 47 |
Equivalent to process_pdfs_saliency_v2.py
|
| 48 |
"""
|
| 49 |
+
from visual_rag import CloudinaryUploader, QdrantIndexer, VisualEmbedder, load_config
|
| 50 |
from visual_rag.indexing.pipeline import ProcessingPipeline
|
| 51 |
+
|
| 52 |
# Load environment
|
| 53 |
load_dotenv()
|
| 54 |
+
|
| 55 |
# Load config
|
| 56 |
config = {}
|
| 57 |
if args.config and Path(args.config).exists():
|
| 58 |
config = load_config(args.config)
|
| 59 |
+
|
| 60 |
# Get PDFs
|
| 61 |
reports_dir = Path(args.reports_dir)
|
| 62 |
if not reports_dir.exists():
|
| 63 |
logger.error(f"❌ Reports directory not found: {reports_dir}")
|
| 64 |
sys.exit(1)
|
| 65 |
+
|
| 66 |
pdf_paths = sorted(reports_dir.glob("*.pdf")) + sorted(reports_dir.glob("*.PDF"))
|
| 67 |
if not pdf_paths:
|
| 68 |
logger.error(f"❌ No PDF files found in: {reports_dir}")
|
| 69 |
sys.exit(1)
|
| 70 |
+
|
| 71 |
logger.info(f"📁 Found {len(pdf_paths)} PDF files")
|
| 72 |
+
|
| 73 |
# Load metadata mapping
|
| 74 |
metadata_mapping = {}
|
| 75 |
if args.metadata_file:
|
| 76 |
metadata_mapping = ProcessingPipeline.load_metadata_mapping(Path(args.metadata_file))
|
| 77 |
+
|
| 78 |
# Dry run - just show summary
|
| 79 |
if args.dry_run:
|
| 80 |
logger.info("🏃 DRY RUN MODE")
|
|
|
|
| 82 |
logger.info(f" Metadata entries: {len(metadata_mapping)}")
|
| 83 |
logger.info(f" Collection: {args.collection}")
|
| 84 |
logger.info(f" Cloudinary: {'ENABLED' if not args.no_cloudinary else 'DISABLED'}")
|
| 85 |
+
|
| 86 |
for pdf in pdf_paths[:10]:
|
| 87 |
has_meta = "✓" if pdf.stem.lower() in metadata_mapping else "✗"
|
| 88 |
logger.info(f" {has_meta} {pdf.name}")
|
| 89 |
if len(pdf_paths) > 10:
|
| 90 |
logger.info(f" ... and {len(pdf_paths) - 10} more")
|
| 91 |
return
|
| 92 |
+
|
| 93 |
# Get settings
|
| 94 |
model_name = args.model or config.get("model", {}).get("name", "vidore/colSmol-500M")
|
| 95 |
+
collection_name = args.collection or config.get("qdrant", {}).get(
|
| 96 |
+
"collection_name", "visual_documents"
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
torch_dtype = None
|
| 100 |
if args.torch_dtype != "auto":
|
| 101 |
import torch
|
| 102 |
+
|
| 103 |
torch_dtype = {
|
| 104 |
"float32": torch.float32,
|
| 105 |
"float16": torch.float16,
|
|
|
|
| 113 |
torch_dtype=torch_dtype,
|
| 114 |
processor_speed=str(getattr(args, "processor_speed", "fast")),
|
| 115 |
)
|
| 116 |
+
|
| 117 |
# Initialize Qdrant indexer
|
| 118 |
+
qdrant_url = (
|
| 119 |
+
os.getenv("SIGIR_QDRANT_URL") or os.getenv("DEST_QDRANT_URL") or os.getenv("QDRANT_URL")
|
| 120 |
+
)
|
| 121 |
qdrant_api_key = (
|
| 122 |
os.getenv("SIGIR_QDRANT_KEY")
|
| 123 |
or os.getenv("SIGIR_QDRANT_API_KEY")
|
| 124 |
or os.getenv("DEST_QDRANT_API_KEY")
|
| 125 |
or os.getenv("QDRANT_API_KEY")
|
| 126 |
)
|
| 127 |
+
|
| 128 |
if not qdrant_url:
|
| 129 |
logger.error("❌ QDRANT_URL environment variable not set")
|
| 130 |
sys.exit(1)
|
| 131 |
+
|
| 132 |
logger.info(f"🔌 Connecting to Qdrant: {qdrant_url}")
|
| 133 |
indexer = QdrantIndexer(
|
| 134 |
url=qdrant_url,
|
|
|
|
| 137 |
prefer_grpc=args.prefer_grpc,
|
| 138 |
vector_datatype=args.qdrant_vector_dtype,
|
| 139 |
)
|
| 140 |
+
|
| 141 |
# Create collection if needed
|
| 142 |
indexer.create_collection(force_recreate=args.force_recreate)
|
| 143 |
inferred_fields = []
|
|
|
|
| 170 |
inferred_fields.append({"field": k, "type": inferred_type})
|
| 171 |
|
| 172 |
indexer.create_payload_indexes(fields=inferred_fields)
|
| 173 |
+
|
| 174 |
# Initialize Cloudinary uploader (optional)
|
| 175 |
cloudinary_uploader = None
|
| 176 |
if not args.no_cloudinary:
|
|
|
|
| 180 |
except ValueError as e:
|
| 181 |
logger.warning(f"⚠️ Cloudinary not configured: {e}")
|
| 182 |
logger.warning(" Continuing without Cloudinary uploads")
|
| 183 |
+
|
| 184 |
# Create pipeline
|
| 185 |
pipeline = ProcessingPipeline(
|
| 186 |
embedder=embedder,
|
|
|
|
| 190 |
config=config,
|
| 191 |
embedding_strategy=args.strategy,
|
| 192 |
crop_empty=bool(getattr(args, "crop_empty", False)),
|
| 193 |
+
crop_empty_percentage_to_remove=float(
|
| 194 |
+
getattr(args, "crop_empty_percentage_to_remove", 0.9)
|
| 195 |
+
),
|
| 196 |
crop_empty_remove_page_number=bool(getattr(args, "crop_empty_remove_page_number", False)),
|
| 197 |
)
|
| 198 |
+
|
| 199 |
# Process PDFs
|
| 200 |
total_uploaded = 0
|
| 201 |
total_skipped = 0
|
| 202 |
total_failed = 0
|
| 203 |
+
|
| 204 |
skip_existing = not args.no_skip_existing
|
| 205 |
+
|
| 206 |
for pdf_idx, pdf_path in enumerate(pdf_paths, 1):
|
| 207 |
logger.info(f"\n{'='*60}")
|
| 208 |
logger.info(f"📄 [{pdf_idx}/{len(pdf_paths)}] {pdf_path.name}")
|
| 209 |
logger.info(f"{'='*60}")
|
| 210 |
+
|
| 211 |
result = pipeline.process_pdf(
|
| 212 |
pdf_path,
|
| 213 |
skip_existing=skip_existing,
|
| 214 |
upload_to_cloudinary=(not args.no_cloudinary),
|
| 215 |
upload_to_qdrant=True,
|
| 216 |
)
|
| 217 |
+
|
| 218 |
total_uploaded += result["uploaded"]
|
| 219 |
total_skipped += result["skipped"]
|
| 220 |
total_failed += result["failed"]
|
| 221 |
+
|
| 222 |
# Summary
|
| 223 |
logger.info(f"\n{'='*60}")
|
| 224 |
+
logger.info("📊 SUMMARY")
|
| 225 |
logger.info(f"{'='*60}")
|
| 226 |
logger.info(f" Total PDFs: {len(pdf_paths)}")
|
| 227 |
logger.info(f" Uploaded: {total_uploaded}")
|
| 228 |
logger.info(f" Skipped: {total_skipped}")
|
| 229 |
logger.info(f" Failed: {total_failed}")
|
| 230 |
+
|
| 231 |
info = indexer.get_collection_info()
|
| 232 |
if info:
|
| 233 |
logger.info(f" Collection points: {info.get('points_count', 'N/A')}")
|
|
|
|
| 235 |
|
| 236 |
def cmd_search(args):
|
| 237 |
"""Search documents."""
|
|
|
|
|
|
|
| 238 |
from qdrant_client import QdrantClient
|
| 239 |
+
|
| 240 |
+
from visual_rag import VisualEmbedder
|
| 241 |
+
from visual_rag.retrieval import SingleStageRetriever, TwoStageRetriever
|
| 242 |
+
|
| 243 |
load_dotenv()
|
| 244 |
+
|
| 245 |
+
qdrant_url = (
|
| 246 |
+
os.getenv("SIGIR_QDRANT_URL") or os.getenv("DEST_QDRANT_URL") or os.getenv("QDRANT_URL")
|
| 247 |
+
)
|
| 248 |
qdrant_api_key = (
|
| 249 |
os.getenv("SIGIR_QDRANT_KEY")
|
| 250 |
or os.getenv("SIGIR_QDRANT_API_KEY")
|
| 251 |
or os.getenv("DEST_QDRANT_API_KEY")
|
| 252 |
or os.getenv("QDRANT_API_KEY")
|
| 253 |
)
|
| 254 |
+
|
| 255 |
if not qdrant_url:
|
| 256 |
logger.error("❌ QDRANT_URL not set")
|
| 257 |
sys.exit(1)
|
| 258 |
+
|
| 259 |
# Initialize
|
| 260 |
logger.info(f"🤖 Loading model: {args.model}")
|
| 261 |
+
embedder = VisualEmbedder(
|
| 262 |
+
model_name=args.model, processor_speed=str(getattr(args, "processor_speed", "fast"))
|
| 263 |
+
)
|
| 264 |
|
| 265 |
+
logger.info("🔌 Connecting to Qdrant")
|
| 266 |
grpc_port = 6334 if args.prefer_grpc and urlparse(qdrant_url).port == 6333 else None
|
| 267 |
client = QdrantClient(
|
| 268 |
url=qdrant_url,
|
|
|
|
| 273 |
)
|
| 274 |
two_stage = TwoStageRetriever(client, args.collection)
|
| 275 |
single_stage = SingleStageRetriever(client, args.collection)
|
| 276 |
+
|
| 277 |
# Embed query
|
| 278 |
logger.info(f"🔍 Query: {args.query}")
|
| 279 |
query_embedding = embedder.embed_query(args.query)
|
| 280 |
+
|
| 281 |
# Build filter
|
| 282 |
filter_obj = None
|
| 283 |
if args.year or args.source or args.district:
|
|
|
|
| 286 |
source=args.source,
|
| 287 |
district=args.district,
|
| 288 |
)
|
| 289 |
+
|
| 290 |
# Search
|
| 291 |
query_np = query_embedding.detach().cpu().numpy()
|
| 292 |
if args.strategy == "single_full":
|
|
|
|
| 318 |
filter_obj=filter_obj,
|
| 319 |
stage1_mode=args.stage1_mode,
|
| 320 |
)
|
| 321 |
+
|
| 322 |
# Display results
|
| 323 |
logger.info(f"\n📊 Results ({len(results)}):")
|
| 324 |
for i, result in enumerate(results, 1):
|
| 325 |
payload = result.get("payload", {})
|
| 326 |
score = result.get("score_final", result.get("score_stage1", 0))
|
| 327 |
+
|
| 328 |
filename = payload.get("filename", "N/A")
|
| 329 |
page_num = payload.get("page_number", "N/A")
|
| 330 |
year = payload.get("year", "N/A")
|
| 331 |
source = payload.get("source", "N/A")
|
| 332 |
+
|
| 333 |
logger.info(f" {i}. {filename} p.{page_num}")
|
| 334 |
logger.info(f" Score: {score:.4f} | Year: {year} | Source: {source}")
|
| 335 |
+
|
| 336 |
# Text snippet
|
| 337 |
text = payload.get("text", "")
|
| 338 |
if text and args.show_text:
|
|
|
|
| 343 |
def cmd_info(args):
|
| 344 |
"""Show collection info."""
|
| 345 |
from qdrant_client import QdrantClient
|
| 346 |
+
|
| 347 |
load_dotenv()
|
| 348 |
+
|
| 349 |
+
qdrant_url = (
|
| 350 |
+
os.getenv("SIGIR_QDRANT_URL") or os.getenv("DEST_QDRANT_URL") or os.getenv("QDRANT_URL")
|
| 351 |
+
)
|
| 352 |
qdrant_api_key = (
|
| 353 |
os.getenv("SIGIR_QDRANT_KEY")
|
| 354 |
or os.getenv("SIGIR_QDRANT_API_KEY")
|
| 355 |
or os.getenv("DEST_QDRANT_API_KEY")
|
| 356 |
or os.getenv("QDRANT_API_KEY")
|
| 357 |
)
|
| 358 |
+
|
| 359 |
if not qdrant_url:
|
| 360 |
logger.error("❌ QDRANT_URL not set")
|
| 361 |
sys.exit(1)
|
| 362 |
+
|
| 363 |
grpc_port = 6334 if args.prefer_grpc and urlparse(qdrant_url).port == 6333 else None
|
| 364 |
client = QdrantClient(
|
| 365 |
url=qdrant_url,
|
|
|
|
| 368 |
grpc_port=grpc_port,
|
| 369 |
check_compatibility=False,
|
| 370 |
)
|
| 371 |
+
|
| 372 |
try:
|
| 373 |
info = client.get_collection(args.collection)
|
| 374 |
+
|
| 375 |
status = info.status
|
| 376 |
if hasattr(status, "value"):
|
| 377 |
status = status.value
|
| 378 |
+
|
| 379 |
indexed_count = getattr(info, "indexed_vectors_count", 0) or 0
|
| 380 |
if isinstance(indexed_count, dict):
|
| 381 |
indexed_count = sum(indexed_count.values())
|
| 382 |
+
|
| 383 |
logger.info(f"📊 Collection: {args.collection}")
|
| 384 |
logger.info(f" Status: {status}")
|
| 385 |
logger.info(f" Points: {info.points_count}")
|
| 386 |
logger.info(f" Indexed vectors: {indexed_count}")
|
| 387 |
+
|
| 388 |
# Show vector config
|
| 389 |
if hasattr(info, "config") and hasattr(info.config, "params"):
|
| 390 |
vectors = getattr(info.config.params, "vectors", {})
|
| 391 |
if vectors:
|
| 392 |
logger.info(f" Vectors: {list(vectors.keys())}")
|
| 393 |
+
|
| 394 |
except Exception as e:
|
| 395 |
logger.error(f"❌ Could not get collection info: {e}")
|
| 396 |
sys.exit(1)
|
|
|
|
| 406 |
Examples:
|
| 407 |
# Process PDFs (like process_pdfs_saliency_v2.py)
|
| 408 |
visual-rag process --reports-dir ./pdfs --metadata-file metadata.json
|
| 409 |
+
|
| 410 |
# Process without Cloudinary
|
| 411 |
visual-rag process --reports-dir ./pdfs --no-cloudinary
|
| 412 |
+
|
| 413 |
# Search
|
| 414 |
visual-rag search --query "budget allocation" --collection my_docs
|
| 415 |
+
|
| 416 |
# Search with filters
|
| 417 |
visual-rag search --query "budget" --year 2023 --source "Local Government"
|
| 418 |
+
|
| 419 |
# Show collection info
|
| 420 |
visual-rag info --collection my_docs
|
| 421 |
""",
|
| 422 |
)
|
| 423 |
parser.add_argument("--debug", action="store_true", help="Enable debug logging")
|
| 424 |
+
|
| 425 |
subparsers = parser.add_subparsers(dest="command", help="Command")
|
| 426 |
+
|
| 427 |
# =========================================================================
|
| 428 |
# PROCESS command
|
| 429 |
# =========================================================================
|
|
|
|
| 433 |
formatter_class=argparse.RawDescriptionHelpFormatter,
|
| 434 |
)
|
| 435 |
process_parser.add_argument(
|
| 436 |
+
"--reports-dir", type=str, required=True, help="Directory containing PDF files"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 437 |
)
|
| 438 |
process_parser.add_argument(
|
| 439 |
+
"--metadata-file",
|
| 440 |
+
type=str,
|
| 441 |
+
help="JSON file with filename → metadata mapping (like filename_metadata.json)",
|
| 442 |
)
|
| 443 |
process_parser.add_argument(
|
| 444 |
+
"--collection", type=str, default="visual_documents", help="Qdrant collection name"
|
|
|
|
| 445 |
)
|
| 446 |
process_parser.add_argument(
|
| 447 |
+
"--model",
|
| 448 |
+
type=str,
|
| 449 |
+
default="vidore/colSmol-500M",
|
| 450 |
+
help="Model name (vidore/colSmol-500M, vidore/colpali-v1.3, etc.)",
|
| 451 |
)
|
| 452 |
+
process_parser.add_argument("--batch-size", type=int, default=8, help="Embedding batch size")
|
| 453 |
+
process_parser.add_argument("--config", type=str, help="Path to config.yaml file")
|
| 454 |
process_parser.add_argument(
|
| 455 |
+
"--no-cloudinary", action="store_true", help="Skip Cloudinary uploads"
|
|
|
|
| 456 |
)
|
| 457 |
process_parser.add_argument(
|
| 458 |
"--crop-empty",
|
|
|
|
| 471 |
help="If set, attempts to crop away the bottom region that contains sparse page numbers (default: off).",
|
| 472 |
)
|
| 473 |
process_parser.add_argument(
|
| 474 |
+
"--no-skip-existing",
|
| 475 |
+
action="store_true",
|
| 476 |
+
help="Process all pages even if they exist in Qdrant",
|
| 477 |
)
|
| 478 |
process_parser.add_argument(
|
| 479 |
+
"--force-recreate", action="store_true", help="Delete and recreate collection"
|
|
|
|
| 480 |
)
|
| 481 |
process_parser.add_argument(
|
| 482 |
+
"--dry-run", action="store_true", help="Show what would be processed without doing it"
|
|
|
|
| 483 |
)
|
| 484 |
process_parser.add_argument(
|
| 485 |
+
"--strategy",
|
| 486 |
+
type=str,
|
| 487 |
+
default="pooling",
|
| 488 |
choices=["pooling", "standard", "all"],
|
| 489 |
help="Embedding strategy: 'pooling' (NOVEL), 'standard' (BASELINE), "
|
| 490 |
+
"'all' (embed once, store BOTH for comparison)",
|
| 491 |
)
|
| 492 |
process_parser.add_argument(
|
| 493 |
"--torch-dtype",
|
|
|
|
| 525 |
help="Disable gRPC for Qdrant client.",
|
| 526 |
)
|
| 527 |
process_parser.set_defaults(func=cmd_process)
|
| 528 |
+
|
| 529 |
# =========================================================================
|
| 530 |
# SEARCH command
|
| 531 |
# =========================================================================
|
|
|
|
| 533 |
"search",
|
| 534 |
help="Search documents",
|
| 535 |
)
|
| 536 |
+
search_parser.add_argument("--query", type=str, required=True, help="Search query")
|
| 537 |
search_parser.add_argument(
|
| 538 |
+
"--collection", type=str, default="visual_documents", help="Qdrant collection name"
|
|
|
|
| 539 |
)
|
| 540 |
search_parser.add_argument(
|
| 541 |
+
"--model", type=str, default="vidore/colSmol-500M", help="Model name"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 542 |
)
|
| 543 |
search_parser.add_argument(
|
| 544 |
"--processor-speed",
|
|
|
|
| 547 |
choices=["fast", "slow", "auto"],
|
| 548 |
help="Processor implementation: fast (default, with fallback to slow), slow, or auto.",
|
| 549 |
)
|
| 550 |
+
search_parser.add_argument("--top-k", type=int, default=10, help="Number of results")
|
| 551 |
search_parser.add_argument(
|
| 552 |
+
"--strategy",
|
| 553 |
+
type=str,
|
| 554 |
+
default="single_full",
|
|
|
|
|
|
|
| 555 |
choices=["single_full", "single_tiles", "single_global", "two_stage"],
|
| 556 |
+
help="Search strategy",
|
| 557 |
)
|
| 558 |
search_parser.add_argument(
|
| 559 |
+
"--prefetch-k", type=int, default=200, help="Prefetch candidates for two-stage retrieval"
|
|
|
|
| 560 |
)
|
| 561 |
search_parser.add_argument(
|
| 562 |
+
"--stage1-mode",
|
| 563 |
+
type=str,
|
| 564 |
+
default="pooled_query_vs_tiles",
|
| 565 |
choices=["pooled_query_vs_tiles", "tokens_vs_tiles", "pooled_query_vs_global"],
|
| 566 |
+
help="Stage 1 mode for two-stage retrieval",
|
| 567 |
)
|
| 568 |
+
search_parser.add_argument("--year", type=int, help="Filter by year")
|
| 569 |
+
search_parser.add_argument("--source", type=str, help="Filter by source")
|
| 570 |
+
search_parser.add_argument("--district", type=str, help="Filter by district")
|
| 571 |
search_parser.add_argument(
|
| 572 |
+
"--show-text", action="store_true", help="Show text snippets in results"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 573 |
)
|
| 574 |
search_grpc_group = search_parser.add_mutually_exclusive_group()
|
| 575 |
search_grpc_group.add_argument(
|
|
|
|
| 586 |
help="Disable gRPC for Qdrant client.",
|
| 587 |
)
|
| 588 |
search_parser.set_defaults(func=cmd_search)
|
| 589 |
+
|
| 590 |
# =========================================================================
|
| 591 |
# INFO command
|
| 592 |
# =========================================================================
|
|
|
|
| 595 |
help="Show collection info",
|
| 596 |
)
|
| 597 |
info_parser.add_argument(
|
| 598 |
+
"--collection", type=str, default="visual_documents", help="Qdrant collection name"
|
|
|
|
| 599 |
)
|
| 600 |
info_grpc_group = info_parser.add_mutually_exclusive_group()
|
| 601 |
info_grpc_group.add_argument(
|
|
|
|
| 612 |
help="Disable gRPC for Qdrant client.",
|
| 613 |
)
|
| 614 |
info_parser.set_defaults(func=cmd_info)
|
| 615 |
+
|
| 616 |
# Parse and execute
|
| 617 |
args = parser.parse_args()
|
| 618 |
+
|
| 619 |
setup_logging(args.debug)
|
| 620 |
+
|
| 621 |
if not args.command:
|
| 622 |
parser.print_help()
|
| 623 |
sys.exit(0)
|
| 624 |
+
|
| 625 |
args.func(args)
|
| 626 |
|
| 627 |
|
visual_rag/config.py
CHANGED
|
@@ -7,15 +7,17 @@ Provides:
|
|
| 7 |
- Convenience getters for common settings
|
| 8 |
"""
|
| 9 |
|
| 10 |
-
import
|
| 11 |
import logging
|
|
|
|
| 12 |
from pathlib import Path
|
| 13 |
-
from typing import Any,
|
| 14 |
|
| 15 |
logger = logging.getLogger(__name__)
|
| 16 |
|
| 17 |
-
# Global config cache
|
| 18 |
-
|
|
|
|
| 19 |
|
| 20 |
|
| 21 |
def _env_qdrant_url() -> Optional[str]:
|
|
@@ -34,30 +36,30 @@ def _env_qdrant_api_key() -> Optional[str]:
|
|
| 34 |
def load_config(
|
| 35 |
config_path: Optional[str] = None,
|
| 36 |
force_reload: bool = False,
|
|
|
|
| 37 |
) -> Dict[str, Any]:
|
| 38 |
"""
|
| 39 |
Load configuration from YAML file.
|
| 40 |
-
|
| 41 |
Uses caching to avoid repeated file I/O.
|
| 42 |
Environment variables can override config values.
|
| 43 |
-
|
| 44 |
Args:
|
| 45 |
config_path: Path to config file (auto-detected if None)
|
| 46 |
force_reload: Bypass cache and reload from file
|
| 47 |
-
|
| 48 |
Returns:
|
| 49 |
Configuration dictionary
|
| 50 |
"""
|
| 51 |
-
global
|
| 52 |
-
|
| 53 |
-
#
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
# Find config file
|
| 58 |
if config_path is None:
|
| 59 |
config_path = os.getenv("VISUALRAG_CONFIG")
|
| 60 |
-
|
| 61 |
if config_path is None:
|
| 62 |
# Check common locations
|
| 63 |
search_paths = [
|
|
@@ -65,65 +67,75 @@ def load_config(
|
|
| 65 |
Path.cwd() / "visual_rag.yaml",
|
| 66 |
Path.home() / ".visual_rag" / "config.yaml",
|
| 67 |
]
|
| 68 |
-
|
| 69 |
for path in search_paths:
|
| 70 |
if path.exists():
|
| 71 |
config_path = str(path)
|
| 72 |
break
|
| 73 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
# Load YAML if file exists
|
| 75 |
config = {}
|
| 76 |
if config_path and Path(config_path).exists():
|
| 77 |
try:
|
| 78 |
import yaml
|
| 79 |
-
|
| 80 |
with open(config_path, "r") as f:
|
| 81 |
config = yaml.safe_load(f) or {}
|
| 82 |
-
|
| 83 |
logger.info(f"Loaded config from: {config_path}")
|
| 84 |
except ImportError:
|
| 85 |
logger.warning("PyYAML not installed, using environment variables only")
|
| 86 |
except Exception as e:
|
| 87 |
logger.warning(f"Could not load config file: {e}")
|
| 88 |
-
|
| 89 |
-
#
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
|
|
|
|
|
|
| 94 |
|
| 95 |
|
| 96 |
def _apply_env_overrides(config: Dict[str, Any]) -> Dict[str, Any]:
|
| 97 |
"""Apply environment variable overrides."""
|
| 98 |
-
|
| 99 |
env_mappings = {
|
| 100 |
# Qdrant
|
| 101 |
"QDRANT_URL": ["qdrant", "url"],
|
| 102 |
"QDRANT_API_KEY": ["qdrant", "api_key"],
|
| 103 |
"QDRANT_COLLECTION": ["qdrant", "collection"],
|
| 104 |
-
|
| 105 |
# Model
|
| 106 |
"VISUALRAG_MODEL": ["model", "name"],
|
| 107 |
"COLPALI_MODEL_NAME": ["model", "name"], # Alias
|
| 108 |
"EMBEDDING_BATCH_SIZE": ["model", "batch_size"],
|
| 109 |
-
|
| 110 |
# Cloudinary
|
| 111 |
"CLOUDINARY_CLOUD_NAME": ["cloudinary", "cloud_name"],
|
| 112 |
"CLOUDINARY_API_KEY": ["cloudinary", "api_key"],
|
| 113 |
"CLOUDINARY_API_SECRET": ["cloudinary", "api_secret"],
|
| 114 |
-
|
| 115 |
# Processing
|
| 116 |
"PDF_DPI": ["processing", "dpi"],
|
| 117 |
"JPEG_QUALITY": ["processing", "jpeg_quality"],
|
| 118 |
-
|
| 119 |
# Search
|
| 120 |
"SEARCH_STRATEGY": ["search", "strategy"],
|
| 121 |
"PREFETCH_K": ["search", "prefetch_k"],
|
| 122 |
-
|
| 123 |
# Special token handling
|
| 124 |
"VISUALRAG_INCLUDE_SPECIAL_TOKENS": ["embedding", "include_special_tokens"],
|
| 125 |
}
|
| 126 |
-
|
| 127 |
for env_var, path in env_mappings.items():
|
| 128 |
value = os.getenv(env_var)
|
| 129 |
if value is not None:
|
|
@@ -133,7 +145,7 @@ def _apply_env_overrides(config: Dict[str, Any]) -> Dict[str, Any]:
|
|
| 133 |
if key not in current:
|
| 134 |
current[key] = {}
|
| 135 |
current = current[key]
|
| 136 |
-
|
| 137 |
# Convert value to appropriate type
|
| 138 |
final_key = path[-1]
|
| 139 |
if final_key in current:
|
|
@@ -144,39 +156,39 @@ def _apply_env_overrides(config: Dict[str, Any]) -> Dict[str, Any]:
|
|
| 144 |
value = int(value)
|
| 145 |
elif existing_type == float:
|
| 146 |
value = float(value)
|
| 147 |
-
|
| 148 |
current[final_key] = value
|
| 149 |
logger.debug(f"Config override: {'.'.join(path)} = {value}")
|
| 150 |
-
|
| 151 |
return config
|
| 152 |
|
| 153 |
|
| 154 |
def get(key: str, default: Any = None) -> Any:
|
| 155 |
"""
|
| 156 |
Get a configuration value by dot-notation path.
|
| 157 |
-
|
| 158 |
Examples:
|
| 159 |
>>> get("qdrant.url")
|
| 160 |
>>> get("model.name", "vidore/colSmol-500M")
|
| 161 |
>>> get("search.strategy", "multi_vector")
|
| 162 |
"""
|
| 163 |
-
config = load_config()
|
| 164 |
-
|
| 165 |
keys = key.split(".")
|
| 166 |
current = config
|
| 167 |
-
|
| 168 |
for k in keys:
|
| 169 |
if isinstance(current, dict) and k in current:
|
| 170 |
current = current[k]
|
| 171 |
else:
|
| 172 |
return default
|
| 173 |
-
|
| 174 |
return current
|
| 175 |
|
| 176 |
|
| 177 |
-
def get_section(section: str) -> Dict[str, Any]:
|
| 178 |
"""Get an entire configuration section."""
|
| 179 |
-
config = load_config()
|
| 180 |
return config.get(section, {})
|
| 181 |
|
| 182 |
|
|
@@ -215,5 +227,3 @@ def get_search_config() -> Dict[str, Any]:
|
|
| 215 |
"prefetch_k": get("search.prefetch_k", 200),
|
| 216 |
"top_k": get("search.top_k", 10),
|
| 217 |
}
|
| 218 |
-
|
| 219 |
-
|
|
|
|
| 7 |
- Convenience getters for common settings
|
| 8 |
"""
|
| 9 |
|
| 10 |
+
import copy
|
| 11 |
import logging
|
| 12 |
+
import os
|
| 13 |
from pathlib import Path
|
| 14 |
+
from typing import Any, Dict, Optional
|
| 15 |
|
| 16 |
logger = logging.getLogger(__name__)
|
| 17 |
|
| 18 |
+
# Global config cache (raw YAML only; env overrides applied on demand)
|
| 19 |
+
_raw_config_cache: Optional[Dict[str, Any]] = None
|
| 20 |
+
_raw_config_cache_path: Optional[str] = None
|
| 21 |
|
| 22 |
|
| 23 |
def _env_qdrant_url() -> Optional[str]:
|
|
|
|
| 36 |
def load_config(
|
| 37 |
config_path: Optional[str] = None,
|
| 38 |
force_reload: bool = False,
|
| 39 |
+
apply_env_overrides: bool = True,
|
| 40 |
) -> Dict[str, Any]:
|
| 41 |
"""
|
| 42 |
Load configuration from YAML file.
|
| 43 |
+
|
| 44 |
Uses caching to avoid repeated file I/O.
|
| 45 |
Environment variables can override config values.
|
| 46 |
+
|
| 47 |
Args:
|
| 48 |
config_path: Path to config file (auto-detected if None)
|
| 49 |
force_reload: Bypass cache and reload from file
|
| 50 |
+
|
| 51 |
Returns:
|
| 52 |
Configuration dictionary
|
| 53 |
"""
|
| 54 |
+
global _raw_config_cache, _raw_config_cache_path
|
| 55 |
+
|
| 56 |
+
# Determine the effective config path (used for caching)
|
| 57 |
+
effective_path: Optional[str] = None
|
| 58 |
+
|
|
|
|
| 59 |
# Find config file
|
| 60 |
if config_path is None:
|
| 61 |
config_path = os.getenv("VISUALRAG_CONFIG")
|
| 62 |
+
|
| 63 |
if config_path is None:
|
| 64 |
# Check common locations
|
| 65 |
search_paths = [
|
|
|
|
| 67 |
Path.cwd() / "visual_rag.yaml",
|
| 68 |
Path.home() / ".visual_rag" / "config.yaml",
|
| 69 |
]
|
| 70 |
+
|
| 71 |
for path in search_paths:
|
| 72 |
if path.exists():
|
| 73 |
config_path = str(path)
|
| 74 |
break
|
| 75 |
+
effective_path = str(config_path) if config_path else None
|
| 76 |
+
|
| 77 |
+
# Return cached raw config if available.
|
| 78 |
+
# - If caller doesn't specify a path (effective_path is None), use whatever was
|
| 79 |
+
# loaded most recently (common pattern in apps).
|
| 80 |
+
# - If a path is specified, only reuse cache when it matches.
|
| 81 |
+
if (
|
| 82 |
+
_raw_config_cache is not None
|
| 83 |
+
and not force_reload
|
| 84 |
+
and (effective_path is None or _raw_config_cache_path == effective_path)
|
| 85 |
+
):
|
| 86 |
+
cfg = copy.deepcopy(_raw_config_cache)
|
| 87 |
+
return _apply_env_overrides(cfg) if apply_env_overrides else cfg
|
| 88 |
+
|
| 89 |
# Load YAML if file exists
|
| 90 |
config = {}
|
| 91 |
if config_path and Path(config_path).exists():
|
| 92 |
try:
|
| 93 |
import yaml
|
| 94 |
+
|
| 95 |
with open(config_path, "r") as f:
|
| 96 |
config = yaml.safe_load(f) or {}
|
| 97 |
+
|
| 98 |
logger.info(f"Loaded config from: {config_path}")
|
| 99 |
except ImportError:
|
| 100 |
logger.warning("PyYAML not installed, using environment variables only")
|
| 101 |
except Exception as e:
|
| 102 |
logger.warning(f"Could not load config file: {e}")
|
| 103 |
+
|
| 104 |
+
# Cache RAW config (no env overrides)
|
| 105 |
+
_raw_config_cache = copy.deepcopy(config)
|
| 106 |
+
_raw_config_cache_path = effective_path
|
| 107 |
+
|
| 108 |
+
# Return resolved or raw depending on caller preference
|
| 109 |
+
cfg = copy.deepcopy(config)
|
| 110 |
+
return _apply_env_overrides(cfg) if apply_env_overrides else cfg
|
| 111 |
|
| 112 |
|
| 113 |
def _apply_env_overrides(config: Dict[str, Any]) -> Dict[str, Any]:
|
| 114 |
"""Apply environment variable overrides."""
|
| 115 |
+
|
| 116 |
env_mappings = {
|
| 117 |
# Qdrant
|
| 118 |
"QDRANT_URL": ["qdrant", "url"],
|
| 119 |
"QDRANT_API_KEY": ["qdrant", "api_key"],
|
| 120 |
"QDRANT_COLLECTION": ["qdrant", "collection"],
|
|
|
|
| 121 |
# Model
|
| 122 |
"VISUALRAG_MODEL": ["model", "name"],
|
| 123 |
"COLPALI_MODEL_NAME": ["model", "name"], # Alias
|
| 124 |
"EMBEDDING_BATCH_SIZE": ["model", "batch_size"],
|
|
|
|
| 125 |
# Cloudinary
|
| 126 |
"CLOUDINARY_CLOUD_NAME": ["cloudinary", "cloud_name"],
|
| 127 |
"CLOUDINARY_API_KEY": ["cloudinary", "api_key"],
|
| 128 |
"CLOUDINARY_API_SECRET": ["cloudinary", "api_secret"],
|
|
|
|
| 129 |
# Processing
|
| 130 |
"PDF_DPI": ["processing", "dpi"],
|
| 131 |
"JPEG_QUALITY": ["processing", "jpeg_quality"],
|
|
|
|
| 132 |
# Search
|
| 133 |
"SEARCH_STRATEGY": ["search", "strategy"],
|
| 134 |
"PREFETCH_K": ["search", "prefetch_k"],
|
|
|
|
| 135 |
# Special token handling
|
| 136 |
"VISUALRAG_INCLUDE_SPECIAL_TOKENS": ["embedding", "include_special_tokens"],
|
| 137 |
}
|
| 138 |
+
|
| 139 |
for env_var, path in env_mappings.items():
|
| 140 |
value = os.getenv(env_var)
|
| 141 |
if value is not None:
|
|
|
|
| 145 |
if key not in current:
|
| 146 |
current[key] = {}
|
| 147 |
current = current[key]
|
| 148 |
+
|
| 149 |
# Convert value to appropriate type
|
| 150 |
final_key = path[-1]
|
| 151 |
if final_key in current:
|
|
|
|
| 156 |
value = int(value)
|
| 157 |
elif existing_type == float:
|
| 158 |
value = float(value)
|
| 159 |
+
|
| 160 |
current[final_key] = value
|
| 161 |
logger.debug(f"Config override: {'.'.join(path)} = {value}")
|
| 162 |
+
|
| 163 |
return config
|
| 164 |
|
| 165 |
|
| 166 |
def get(key: str, default: Any = None) -> Any:
|
| 167 |
"""
|
| 168 |
Get a configuration value by dot-notation path.
|
| 169 |
+
|
| 170 |
Examples:
|
| 171 |
>>> get("qdrant.url")
|
| 172 |
>>> get("model.name", "vidore/colSmol-500M")
|
| 173 |
>>> get("search.strategy", "multi_vector")
|
| 174 |
"""
|
| 175 |
+
config = load_config(apply_env_overrides=True)
|
| 176 |
+
|
| 177 |
keys = key.split(".")
|
| 178 |
current = config
|
| 179 |
+
|
| 180 |
for k in keys:
|
| 181 |
if isinstance(current, dict) and k in current:
|
| 182 |
current = current[k]
|
| 183 |
else:
|
| 184 |
return default
|
| 185 |
+
|
| 186 |
return current
|
| 187 |
|
| 188 |
|
| 189 |
+
def get_section(section: str, *, apply_env_overrides: bool = True) -> Dict[str, Any]:
|
| 190 |
"""Get an entire configuration section."""
|
| 191 |
+
config = load_config(apply_env_overrides=apply_env_overrides)
|
| 192 |
return config.get(section, {})
|
| 193 |
|
| 194 |
|
|
|
|
| 227 |
"prefetch_k": get("search.prefetch_k", 200),
|
| 228 |
"top_k": get("search.top_k", 10),
|
| 229 |
}
|
|
|
|
|
|
visual_rag/demo_runner.py
CHANGED
|
@@ -74,7 +74,9 @@ def main() -> None:
|
|
| 74 |
p = argparse.ArgumentParser(description="Launch the Visual RAG Toolkit Streamlit demo.")
|
| 75 |
p.add_argument("--host", default="0.0.0.0")
|
| 76 |
p.add_argument("--port", type=int, default=7860)
|
| 77 |
-
p.add_argument(
|
|
|
|
|
|
|
| 78 |
p.add_argument("--open", action="store_true", help="Open browser automatically.")
|
| 79 |
args, unknown = p.parse_known_args()
|
| 80 |
|
|
@@ -86,4 +88,3 @@ def main() -> None:
|
|
| 86 |
extra_args=unknown,
|
| 87 |
)
|
| 88 |
raise SystemExit(rc)
|
| 89 |
-
|
|
|
|
| 74 |
p = argparse.ArgumentParser(description="Launch the Visual RAG Toolkit Streamlit demo.")
|
| 75 |
p.add_argument("--host", default="0.0.0.0")
|
| 76 |
p.add_argument("--port", type=int, default=7860)
|
| 77 |
+
p.add_argument(
|
| 78 |
+
"--no-headless", action="store_true", help="Run with a browser window (not headless)."
|
| 79 |
+
)
|
| 80 |
p.add_argument("--open", action="store_true", help="Open browser automatically.")
|
| 81 |
args, unknown = p.parse_known_args()
|
| 82 |
|
|
|
|
| 88 |
extra_args=unknown,
|
| 89 |
)
|
| 90 |
raise SystemExit(rc)
|
|
|
visual_rag/embedding/__init__.py
CHANGED
|
@@ -6,19 +6,18 @@ Provides:
|
|
| 6 |
- Pooling utilities: tile-level, global, MaxSim scoring
|
| 7 |
"""
|
| 8 |
|
| 9 |
-
from visual_rag.embedding.visual_embedder import VisualEmbedder, ColPaliEmbedder
|
| 10 |
from visual_rag.embedding.pooling import (
|
| 11 |
-
tile_level_mean_pooling,
|
| 12 |
-
global_mean_pooling,
|
| 13 |
-
compute_maxsim_score,
|
| 14 |
compute_maxsim_batch,
|
|
|
|
|
|
|
|
|
|
| 15 |
)
|
|
|
|
| 16 |
|
| 17 |
__all__ = [
|
| 18 |
# Main embedder
|
| 19 |
"VisualEmbedder",
|
| 20 |
"ColPaliEmbedder", # Backward compatibility alias
|
| 21 |
-
|
| 22 |
# Pooling functions
|
| 23 |
"tile_level_mean_pooling",
|
| 24 |
"global_mean_pooling",
|
|
|
|
| 6 |
- Pooling utilities: tile-level, global, MaxSim scoring
|
| 7 |
"""
|
| 8 |
|
|
|
|
| 9 |
from visual_rag.embedding.pooling import (
|
|
|
|
|
|
|
|
|
|
| 10 |
compute_maxsim_batch,
|
| 11 |
+
compute_maxsim_score,
|
| 12 |
+
global_mean_pooling,
|
| 13 |
+
tile_level_mean_pooling,
|
| 14 |
)
|
| 15 |
+
from visual_rag.embedding.visual_embedder import ColPaliEmbedder, VisualEmbedder
|
| 16 |
|
| 17 |
__all__ = [
|
| 18 |
# Main embedder
|
| 19 |
"VisualEmbedder",
|
| 20 |
"ColPaliEmbedder", # Backward compatibility alias
|
|
|
|
| 21 |
# Pooling functions
|
| 22 |
"tile_level_mean_pooling",
|
| 23 |
"global_mean_pooling",
|
visual_rag/embedding/pooling.py
CHANGED
|
@@ -7,10 +7,11 @@ Provides:
|
|
| 7 |
- MaxSim scoring for ColBERT-style late interaction
|
| 8 |
"""
|
| 9 |
|
|
|
|
|
|
|
|
|
|
| 10 |
import numpy as np
|
| 11 |
import torch
|
| 12 |
-
from typing import Union, Optional
|
| 13 |
-
import logging
|
| 14 |
|
| 15 |
logger = logging.getLogger(__name__)
|
| 16 |
|
|
@@ -39,24 +40,24 @@ def tile_level_mean_pooling(
|
|
| 39 |
) -> np.ndarray:
|
| 40 |
"""
|
| 41 |
Compute tile-level mean pooling for multi-vector embeddings.
|
| 42 |
-
|
| 43 |
Instead of collapsing to 1×dim (global pooling), this preserves spatial
|
| 44 |
structure by computing mean per tile → num_tiles × dim.
|
| 45 |
-
|
| 46 |
This is our NOVEL contribution for scalable visual retrieval:
|
| 47 |
- Faster than full MaxSim (fewer vectors to compare)
|
| 48 |
- More accurate than global pooling (preserves spatial info)
|
| 49 |
- Ideal for two-stage retrieval (prefetch with pooled, rerank with full)
|
| 50 |
-
|
| 51 |
Args:
|
| 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]
|
| 59 |
-
|
| 60 |
Example:
|
| 61 |
>>> # Image with 4×3 tiles + 1 global = 13 tiles
|
| 62 |
>>> # Each tile has 64 patches → 832 visual tokens
|
|
@@ -71,31 +72,29 @@ def tile_level_mean_pooling(
|
|
| 71 |
emb_np = embedding.cpu().numpy().astype(np.float32)
|
| 72 |
else:
|
| 73 |
emb_np = np.array(embedding, dtype=np.float32)
|
| 74 |
-
|
| 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
|
| 90 |
end_idx = min(start_idx + patches_per_tile, num_visual_tokens)
|
| 91 |
-
|
| 92 |
if start_idx >= num_visual_tokens:
|
| 93 |
break
|
| 94 |
-
|
| 95 |
tile_patches = emb_np[start_idx:end_idx]
|
| 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 |
|
|
@@ -116,7 +115,9 @@ def colpali_row_mean_pooling(
|
|
| 116 |
num_tokens, dim = emb_np.shape
|
| 117 |
expected = int(grid_size) * int(grid_size)
|
| 118 |
if num_tokens != expected:
|
| 119 |
-
raise ValueError(
|
|
|
|
|
|
|
| 120 |
|
| 121 |
grid = emb_np.reshape(int(grid_size), int(grid_size), int(dim))
|
| 122 |
pooled = grid.mean(axis=1)
|
|
@@ -157,7 +158,9 @@ def colsmol_experimental_pooling(
|
|
| 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[
|
|
|
|
|
|
|
| 161 |
|
| 162 |
if prefix.size:
|
| 163 |
prefix_tiles = prefix.reshape(-1, int(patches_per_tile), int(dim))
|
|
@@ -174,7 +177,7 @@ def colpali_experimental_pooling_from_rows(
|
|
| 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)
|
|
@@ -182,7 +185,7 @@ def colpali_experimental_pooling_from_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)
|
|
@@ -202,13 +205,16 @@ def colpali_experimental_pooling_from_rows(
|
|
| 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 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
|
|
|
|
|
|
|
|
|
| 212 |
|
| 213 |
out = np.zeros((n + 2, dim), dtype=np.float32)
|
| 214 |
out[0] = rows[0]
|
|
@@ -227,14 +233,14 @@ def global_mean_pooling(
|
|
| 227 |
) -> np.ndarray:
|
| 228 |
"""
|
| 229 |
Compute global mean pooling → single vector.
|
| 230 |
-
|
| 231 |
This is the simplest pooling but loses all spatial information.
|
| 232 |
Use for fastest retrieval when accuracy can be sacrificed.
|
| 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 |
"""
|
|
@@ -246,7 +252,7 @@ def global_mean_pooling(
|
|
| 246 |
emb_np = embedding.cpu().numpy()
|
| 247 |
else:
|
| 248 |
emb_np = np.array(embedding)
|
| 249 |
-
|
| 250 |
return emb_np.mean(axis=0).astype(out_dtype)
|
| 251 |
|
| 252 |
|
|
@@ -257,21 +263,21 @@ def compute_maxsim_score(
|
|
| 257 |
) -> float:
|
| 258 |
"""
|
| 259 |
Compute ColBERT-style MaxSim late interaction score.
|
| 260 |
-
|
| 261 |
For each query token, finds max similarity with any document token,
|
| 262 |
then sums across query tokens.
|
| 263 |
-
|
| 264 |
This is the standard scoring for ColBERT/ColPali:
|
| 265 |
score = Σ_q max_d (sim(q, d))
|
| 266 |
-
|
| 267 |
Args:
|
| 268 |
query_embedding: Query embeddings [num_query_tokens, dim]
|
| 269 |
doc_embedding: Document embeddings [num_doc_tokens, dim]
|
| 270 |
normalize: L2 normalize embeddings before scoring (recommended)
|
| 271 |
-
|
| 272 |
Returns:
|
| 273 |
MaxSim score (higher is better)
|
| 274 |
-
|
| 275 |
Example:
|
| 276 |
>>> query = embedder.embed_query("budget allocation")
|
| 277 |
>>> doc = embeddings[0] # From embed_images
|
|
@@ -282,22 +288,20 @@ def compute_maxsim_score(
|
|
| 282 |
query_norm = query_embedding / (
|
| 283 |
np.linalg.norm(query_embedding, axis=1, keepdims=True) + 1e-8
|
| 284 |
)
|
| 285 |
-
doc_norm = doc_embedding / (
|
| 286 |
-
np.linalg.norm(doc_embedding, axis=1, keepdims=True) + 1e-8
|
| 287 |
-
)
|
| 288 |
else:
|
| 289 |
query_norm = query_embedding
|
| 290 |
doc_norm = doc_embedding
|
| 291 |
-
|
| 292 |
# Compute similarity matrix: [num_query, num_doc]
|
| 293 |
similarity_matrix = np.dot(query_norm, doc_norm.T)
|
| 294 |
-
|
| 295 |
# MaxSim: For each query token, take max similarity with any doc token
|
| 296 |
max_similarities = similarity_matrix.max(axis=1)
|
| 297 |
-
|
| 298 |
# Sum across query tokens
|
| 299 |
score = float(max_similarities.sum())
|
| 300 |
-
|
| 301 |
return score
|
| 302 |
|
| 303 |
|
|
@@ -308,12 +312,12 @@ def compute_maxsim_batch(
|
|
| 308 |
) -> list:
|
| 309 |
"""
|
| 310 |
Compute MaxSim scores for multiple documents efficiently.
|
| 311 |
-
|
| 312 |
Args:
|
| 313 |
query_embedding: Query embeddings [num_query_tokens, dim]
|
| 314 |
doc_embeddings: List of document embeddings
|
| 315 |
normalize: L2 normalize embeddings
|
| 316 |
-
|
| 317 |
Returns:
|
| 318 |
List of MaxSim scores
|
| 319 |
"""
|
|
@@ -324,18 +328,16 @@ def compute_maxsim_batch(
|
|
| 324 |
)
|
| 325 |
else:
|
| 326 |
query_norm = query_embedding
|
| 327 |
-
|
| 328 |
scores = []
|
| 329 |
for doc_emb in doc_embeddings:
|
| 330 |
if normalize:
|
| 331 |
-
doc_norm = doc_emb / (
|
| 332 |
-
np.linalg.norm(doc_emb, axis=1, keepdims=True) + 1e-8
|
| 333 |
-
)
|
| 334 |
else:
|
| 335 |
doc_norm = doc_emb
|
| 336 |
-
|
| 337 |
sim_matrix = np.dot(query_norm, doc_norm.T)
|
| 338 |
max_sims = sim_matrix.max(axis=1)
|
| 339 |
scores.append(float(max_sims.sum()))
|
| 340 |
-
|
| 341 |
return scores
|
|
|
|
| 7 |
- MaxSim scoring for ColBERT-style late interaction
|
| 8 |
"""
|
| 9 |
|
| 10 |
+
import logging
|
| 11 |
+
from typing import Optional, Union
|
| 12 |
+
|
| 13 |
import numpy as np
|
| 14 |
import torch
|
|
|
|
|
|
|
| 15 |
|
| 16 |
logger = logging.getLogger(__name__)
|
| 17 |
|
|
|
|
| 40 |
) -> np.ndarray:
|
| 41 |
"""
|
| 42 |
Compute tile-level mean pooling for multi-vector embeddings.
|
| 43 |
+
|
| 44 |
Instead of collapsing to 1×dim (global pooling), this preserves spatial
|
| 45 |
structure by computing mean per tile → num_tiles × dim.
|
| 46 |
+
|
| 47 |
This is our NOVEL contribution for scalable visual retrieval:
|
| 48 |
- Faster than full MaxSim (fewer vectors to compare)
|
| 49 |
- More accurate than global pooling (preserves spatial info)
|
| 50 |
- Ideal for two-stage retrieval (prefetch with pooled, rerank with full)
|
| 51 |
+
|
| 52 |
Args:
|
| 53 |
embedding: Visual token embeddings [num_visual_tokens, dim]
|
| 54 |
num_tiles: Number of tiles (including global tile)
|
| 55 |
patches_per_tile: Patches per tile (64 for ColSmol)
|
| 56 |
output_dtype: Output dtype (default: infer from input, fp16→fp16, bf16→fp32)
|
| 57 |
+
|
| 58 |
Returns:
|
| 59 |
Tile-level pooled embeddings [num_tiles, dim]
|
| 60 |
+
|
| 61 |
Example:
|
| 62 |
>>> # Image with 4×3 tiles + 1 global = 13 tiles
|
| 63 |
>>> # Each tile has 64 patches → 832 visual tokens
|
|
|
|
| 72 |
emb_np = embedding.cpu().numpy().astype(np.float32)
|
| 73 |
else:
|
| 74 |
emb_np = np.array(embedding, dtype=np.float32)
|
| 75 |
+
|
| 76 |
num_visual_tokens = emb_np.shape[0]
|
| 77 |
expected_tokens = num_tiles * patches_per_tile
|
| 78 |
+
|
| 79 |
if num_visual_tokens != expected_tokens:
|
| 80 |
+
logger.debug(f"Token count mismatch: {num_visual_tokens} vs expected {expected_tokens}")
|
|
|
|
|
|
|
| 81 |
actual_tiles = num_visual_tokens // patches_per_tile
|
| 82 |
if actual_tiles * patches_per_tile != num_visual_tokens:
|
| 83 |
actual_tiles += 1
|
| 84 |
num_tiles = actual_tiles
|
| 85 |
+
|
| 86 |
tile_embeddings = []
|
| 87 |
for tile_idx in range(num_tiles):
|
| 88 |
start_idx = tile_idx * patches_per_tile
|
| 89 |
end_idx = min(start_idx + patches_per_tile, num_visual_tokens)
|
| 90 |
+
|
| 91 |
if start_idx >= num_visual_tokens:
|
| 92 |
break
|
| 93 |
+
|
| 94 |
tile_patches = emb_np[start_idx:end_idx]
|
| 95 |
tile_mean = tile_patches.mean(axis=0)
|
| 96 |
tile_embeddings.append(tile_mean)
|
| 97 |
+
|
| 98 |
return np.array(tile_embeddings, dtype=out_dtype)
|
| 99 |
|
| 100 |
|
|
|
|
| 115 |
num_tokens, dim = emb_np.shape
|
| 116 |
expected = int(grid_size) * int(grid_size)
|
| 117 |
if num_tokens != expected:
|
| 118 |
+
raise ValueError(
|
| 119 |
+
f"Expected {expected} visual tokens for grid_size={grid_size}, got {num_tokens}"
|
| 120 |
+
)
|
| 121 |
|
| 122 |
grid = emb_np.reshape(int(grid_size), int(grid_size), int(dim))
|
| 123 |
pooled = grid.mean(axis=1)
|
|
|
|
| 158 |
last_tile_start = (int(num_tiles) - 1) * int(patches_per_tile)
|
| 159 |
|
| 160 |
prefix = emb_np[:last_tile_start]
|
| 161 |
+
last_tile = emb_np[
|
| 162 |
+
last_tile_start : min(last_tile_start + int(patches_per_tile), num_visual_tokens)
|
| 163 |
+
]
|
| 164 |
|
| 165 |
if prefix.size:
|
| 166 |
prefix_tiles = prefix.reshape(-1, int(patches_per_tile), int(dim))
|
|
|
|
| 177 |
) -> np.ndarray:
|
| 178 |
"""
|
| 179 |
Experimental "convolution-style" pooling with window size 3.
|
| 180 |
+
|
| 181 |
For N input rows, produces N + 2 output vectors:
|
| 182 |
- Position 0: row[0] alone (1 row)
|
| 183 |
- Position 1: mean(rows[0:2]) (2 rows)
|
|
|
|
| 185 |
- Positions 3 to N-1: sliding window of 3 (rows[i-2:i+1])
|
| 186 |
- Position N: mean(rows[N-2:N]) (last 2 rows)
|
| 187 |
- Position N+1: row[N-1] alone (last row)
|
| 188 |
+
|
| 189 |
For N=32 rows: produces 34 vectors.
|
| 190 |
"""
|
| 191 |
out_dtype = _infer_output_dtype(row_vectors, output_dtype)
|
|
|
|
| 205 |
if n == 2:
|
| 206 |
return np.stack([rows[0], rows[:2].mean(axis=0), rows[1]], axis=0).astype(out_dtype)
|
| 207 |
if n == 3:
|
| 208 |
+
return np.stack(
|
| 209 |
+
[
|
| 210 |
+
rows[0],
|
| 211 |
+
rows[:2].mean(axis=0),
|
| 212 |
+
rows[:3].mean(axis=0),
|
| 213 |
+
rows[1:3].mean(axis=0),
|
| 214 |
+
rows[2],
|
| 215 |
+
],
|
| 216 |
+
axis=0,
|
| 217 |
+
).astype(out_dtype)
|
| 218 |
|
| 219 |
out = np.zeros((n + 2, dim), dtype=np.float32)
|
| 220 |
out[0] = rows[0]
|
|
|
|
| 233 |
) -> np.ndarray:
|
| 234 |
"""
|
| 235 |
Compute global mean pooling → single vector.
|
| 236 |
+
|
| 237 |
This is the simplest pooling but loses all spatial information.
|
| 238 |
Use for fastest retrieval when accuracy can be sacrificed.
|
| 239 |
+
|
| 240 |
Args:
|
| 241 |
embedding: Multi-vector embeddings [num_tokens, dim]
|
| 242 |
output_dtype: Output dtype (default: infer from input, fp16→fp16, bf16→fp32)
|
| 243 |
+
|
| 244 |
Returns:
|
| 245 |
Pooled vector [dim]
|
| 246 |
"""
|
|
|
|
| 252 |
emb_np = embedding.cpu().numpy()
|
| 253 |
else:
|
| 254 |
emb_np = np.array(embedding)
|
| 255 |
+
|
| 256 |
return emb_np.mean(axis=0).astype(out_dtype)
|
| 257 |
|
| 258 |
|
|
|
|
| 263 |
) -> float:
|
| 264 |
"""
|
| 265 |
Compute ColBERT-style MaxSim late interaction score.
|
| 266 |
+
|
| 267 |
For each query token, finds max similarity with any document token,
|
| 268 |
then sums across query tokens.
|
| 269 |
+
|
| 270 |
This is the standard scoring for ColBERT/ColPali:
|
| 271 |
score = Σ_q max_d (sim(q, d))
|
| 272 |
+
|
| 273 |
Args:
|
| 274 |
query_embedding: Query embeddings [num_query_tokens, dim]
|
| 275 |
doc_embedding: Document embeddings [num_doc_tokens, dim]
|
| 276 |
normalize: L2 normalize embeddings before scoring (recommended)
|
| 277 |
+
|
| 278 |
Returns:
|
| 279 |
MaxSim score (higher is better)
|
| 280 |
+
|
| 281 |
Example:
|
| 282 |
>>> query = embedder.embed_query("budget allocation")
|
| 283 |
>>> doc = embeddings[0] # From embed_images
|
|
|
|
| 288 |
query_norm = query_embedding / (
|
| 289 |
np.linalg.norm(query_embedding, axis=1, keepdims=True) + 1e-8
|
| 290 |
)
|
| 291 |
+
doc_norm = doc_embedding / (np.linalg.norm(doc_embedding, axis=1, keepdims=True) + 1e-8)
|
|
|
|
|
|
|
| 292 |
else:
|
| 293 |
query_norm = query_embedding
|
| 294 |
doc_norm = doc_embedding
|
| 295 |
+
|
| 296 |
# Compute similarity matrix: [num_query, num_doc]
|
| 297 |
similarity_matrix = np.dot(query_norm, doc_norm.T)
|
| 298 |
+
|
| 299 |
# MaxSim: For each query token, take max similarity with any doc token
|
| 300 |
max_similarities = similarity_matrix.max(axis=1)
|
| 301 |
+
|
| 302 |
# Sum across query tokens
|
| 303 |
score = float(max_similarities.sum())
|
| 304 |
+
|
| 305 |
return score
|
| 306 |
|
| 307 |
|
|
|
|
| 312 |
) -> list:
|
| 313 |
"""
|
| 314 |
Compute MaxSim scores for multiple documents efficiently.
|
| 315 |
+
|
| 316 |
Args:
|
| 317 |
query_embedding: Query embeddings [num_query_tokens, dim]
|
| 318 |
doc_embeddings: List of document embeddings
|
| 319 |
normalize: L2 normalize embeddings
|
| 320 |
+
|
| 321 |
Returns:
|
| 322 |
List of MaxSim scores
|
| 323 |
"""
|
|
|
|
| 328 |
)
|
| 329 |
else:
|
| 330 |
query_norm = query_embedding
|
| 331 |
+
|
| 332 |
scores = []
|
| 333 |
for doc_emb in doc_embeddings:
|
| 334 |
if normalize:
|
| 335 |
+
doc_norm = doc_emb / (np.linalg.norm(doc_emb, axis=1, keepdims=True) + 1e-8)
|
|
|
|
|
|
|
| 336 |
else:
|
| 337 |
doc_norm = doc_emb
|
| 338 |
+
|
| 339 |
sim_matrix = np.dot(query_norm, doc_norm.T)
|
| 340 |
max_sims = sim_matrix.max(axis=1)
|
| 341 |
scores.append(float(max_sims.sum()))
|
| 342 |
+
|
| 343 |
return scores
|
visual_rag/embedding/visual_embedder.py
CHANGED
|
@@ -12,12 +12,12 @@ The embedder is BACKEND-AGNOSTIC - configure which model to use via the
|
|
| 12 |
"""
|
| 13 |
|
| 14 |
import gc
|
| 15 |
-
import os
|
| 16 |
import logging
|
| 17 |
-
|
|
|
|
| 18 |
|
| 19 |
-
import torch
|
| 20 |
import numpy as np
|
|
|
|
| 21 |
from PIL import Image
|
| 22 |
from tqdm import tqdm
|
| 23 |
|
|
@@ -27,11 +27,11 @@ logger = logging.getLogger(__name__)
|
|
| 27 |
class VisualEmbedder:
|
| 28 |
"""
|
| 29 |
Visual document embedder supporting multiple backends.
|
| 30 |
-
|
| 31 |
Currently supports:
|
| 32 |
- ColPali family (ColSmol-500M, ColPali, ColQwen2)
|
| 33 |
- More backends can be added
|
| 34 |
-
|
| 35 |
Args:
|
| 36 |
model_name: HuggingFace model name (e.g., "vidore/colSmol-500M")
|
| 37 |
backend: Backend type ("colpali", "auto"). "auto" detects from model_name.
|
|
@@ -39,23 +39,23 @@ class VisualEmbedder:
|
|
| 39 |
torch_dtype: Data type for model weights
|
| 40 |
batch_size: Batch size for image processing
|
| 41 |
filter_special_tokens: Filter special tokens from query embeddings
|
| 42 |
-
|
| 43 |
Example:
|
| 44 |
>>> # Auto-detect backend from model name
|
| 45 |
>>> embedder = VisualEmbedder(model_name="vidore/colSmol-500M")
|
| 46 |
-
>>>
|
| 47 |
>>> # Embed images
|
| 48 |
>>> image_embeddings = embedder.embed_images(images)
|
| 49 |
-
>>>
|
| 50 |
>>> # Embed query
|
| 51 |
>>> query_embedding = embedder.embed_query("What is the budget?")
|
| 52 |
-
>>>
|
| 53 |
>>> # Get token info for saliency maps
|
| 54 |
>>> embeddings, token_infos = embedder.embed_images(
|
| 55 |
... images, return_token_info=True
|
| 56 |
... )
|
| 57 |
"""
|
| 58 |
-
|
| 59 |
# Known model families and their backends
|
| 60 |
MODEL_BACKENDS = {
|
| 61 |
"colsmol": "colpali",
|
|
@@ -63,7 +63,7 @@ class VisualEmbedder:
|
|
| 63 |
"colqwen": "colpali",
|
| 64 |
"colidefics": "colpali",
|
| 65 |
}
|
| 66 |
-
|
| 67 |
def __init__(
|
| 68 |
self,
|
| 69 |
model_name: str = "vidore/colSmol-500M",
|
|
@@ -81,15 +81,15 @@ class VisualEmbedder:
|
|
| 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"
|
|
@@ -98,53 +98,55 @@ class VisualEmbedder:
|
|
| 98 |
else:
|
| 99 |
device = "cpu"
|
| 100 |
self.device = device
|
| 101 |
-
|
| 102 |
if torch_dtype is None:
|
| 103 |
if device == "cuda":
|
| 104 |
torch_dtype = torch.bfloat16
|
| 105 |
else:
|
| 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
|
| 119 |
-
|
| 120 |
-
logger.info(
|
| 121 |
logger.info(f" Model: {model_name}")
|
| 122 |
logger.info(f" Backend: {backend}")
|
| 123 |
-
logger.info(
|
| 124 |
-
|
|
|
|
|
|
|
| 125 |
def _detect_backend(self, model_name: str) -> str:
|
| 126 |
"""Auto-detect backend from model name."""
|
| 127 |
model_lower = model_name.lower()
|
| 128 |
-
|
| 129 |
for key, backend in self.MODEL_BACKENDS.items():
|
| 130 |
if key in model_lower:
|
| 131 |
logger.debug(f"Detected backend '{backend}' from model name")
|
| 132 |
return backend
|
| 133 |
-
|
| 134 |
# Default to colpali for unknown models
|
| 135 |
logger.warning(f"Unknown model '{model_name}', defaulting to 'colpali' backend")
|
| 136 |
return "colpali"
|
| 137 |
-
|
| 138 |
def _load_model(self):
|
| 139 |
"""Lazy load the model when first needed."""
|
| 140 |
if self._model is not None:
|
| 141 |
return
|
| 142 |
-
|
| 143 |
if self.backend == "colpali":
|
| 144 |
self._load_colpali_model()
|
| 145 |
else:
|
| 146 |
raise ValueError(f"Unknown backend: {self.backend}")
|
| 147 |
-
|
| 148 |
def _load_colpali_model(self):
|
| 149 |
"""Load ColPali-family model."""
|
| 150 |
try:
|
|
@@ -162,7 +164,7 @@ class VisualEmbedder:
|
|
| 162 |
"pip install visual-rag-toolkit[embedding] or "
|
| 163 |
"pip install colpali-engine"
|
| 164 |
)
|
| 165 |
-
|
| 166 |
logger.info(f"🤖 Loading ColPali model: {self.model_name}")
|
| 167 |
logger.info(f" Device: {self.device}, dtype: {self.torch_dtype}")
|
| 168 |
|
|
@@ -170,7 +172,7 @@ class VisualEmbedder:
|
|
| 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)
|
|
@@ -183,12 +185,16 @@ class VisualEmbedder:
|
|
| 183 |
device_map=self.device,
|
| 184 |
).eval()
|
| 185 |
try:
|
| 186 |
-
self._processor = ColPaliProcessor.from_pretrained(
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 192 |
else:
|
| 193 |
raise
|
| 194 |
self._image_token_id = self._processor.image_token_id
|
|
@@ -202,12 +208,18 @@ class VisualEmbedder:
|
|
| 202 |
device_map=self.device,
|
| 203 |
).eval()
|
| 204 |
try:
|
| 205 |
-
self._processor = ColQwen2Processor.from_pretrained(
|
|
|
|
|
|
|
| 206 |
except TypeError:
|
| 207 |
-
self._processor = ColQwen2Processor.from_pretrained(
|
|
|
|
|
|
|
| 208 |
except Exception:
|
| 209 |
if self.processor_speed == "fast":
|
| 210 |
-
self._processor = ColQwen2Processor.from_pretrained(
|
|
|
|
|
|
|
| 211 |
else:
|
| 212 |
raise
|
| 213 |
self._image_token_id = self._processor.image_token_id
|
|
@@ -231,33 +243,37 @@ class VisualEmbedder:
|
|
| 231 |
attn_implementation=attn_implementation,
|
| 232 |
).eval()
|
| 233 |
try:
|
| 234 |
-
self._processor = ColIdefics3Processor.from_pretrained(
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 240 |
else:
|
| 241 |
raise
|
| 242 |
self._image_token_id = self._processor.image_token_id
|
| 243 |
-
|
| 244 |
logger.info("✅ Model loaded successfully")
|
| 245 |
-
|
| 246 |
@property
|
| 247 |
def model(self):
|
| 248 |
self._load_model()
|
| 249 |
return self._model
|
| 250 |
-
|
| 251 |
@property
|
| 252 |
def processor(self):
|
| 253 |
self._load_model()
|
| 254 |
return self._processor
|
| 255 |
-
|
| 256 |
@property
|
| 257 |
def image_token_id(self):
|
| 258 |
self._load_model()
|
| 259 |
return self._image_token_id
|
| 260 |
-
|
| 261 |
def embed_query(
|
| 262 |
self,
|
| 263 |
query_text: str,
|
|
@@ -265,31 +281,31 @@ class VisualEmbedder:
|
|
| 265 |
) -> torch.Tensor:
|
| 266 |
"""
|
| 267 |
Generate embedding for a text query.
|
| 268 |
-
|
| 269 |
By default, filters out special tokens (CLS, SEP, PAD) to keep only
|
| 270 |
meaningful text tokens for better MaxSim matching.
|
| 271 |
-
|
| 272 |
Args:
|
| 273 |
query_text: Natural language query string
|
| 274 |
filter_special_tokens: Override instance-level setting
|
| 275 |
-
|
| 276 |
Returns:
|
| 277 |
Query embedding tensor of shape [num_tokens, embedding_dim]
|
| 278 |
"""
|
| 279 |
should_filter = (
|
| 280 |
-
filter_special_tokens
|
| 281 |
-
if filter_special_tokens is not None
|
| 282 |
else self.filter_special_tokens
|
| 283 |
)
|
| 284 |
-
|
| 285 |
with torch.no_grad():
|
| 286 |
processed = self.processor.process_queries([query_text]).to(self.model.device)
|
| 287 |
embedding = self.model(**processed)
|
| 288 |
-
|
| 289 |
# Remove batch dimension: [1, tokens, dim] -> [tokens, dim]
|
| 290 |
if embedding.dim() == 3:
|
| 291 |
embedding = embedding.squeeze(0)
|
| 292 |
-
|
| 293 |
if should_filter:
|
| 294 |
# Filter special tokens based on attention mask
|
| 295 |
attention_mask = processed.get("attention_mask")
|
|
@@ -297,7 +313,7 @@ class VisualEmbedder:
|
|
| 297 |
# Keep only tokens with attention_mask = 1
|
| 298 |
valid_mask = attention_mask.squeeze(0).bool()
|
| 299 |
embedding = embedding[valid_mask]
|
| 300 |
-
|
| 301 |
# Additionally filter padding tokens if present
|
| 302 |
input_ids = processed.get("input_ids")
|
| 303 |
if input_ids is not None:
|
|
@@ -307,11 +323,11 @@ class VisualEmbedder:
|
|
| 307 |
non_special_mask = input_ids >= 4
|
| 308 |
if non_special_mask.any():
|
| 309 |
embedding = embedding[non_special_mask]
|
| 310 |
-
|
| 311 |
logger.debug(f"Query embedding: {embedding.shape[0]} tokens after filtering")
|
| 312 |
else:
|
| 313 |
logger.debug(f"Query embedding: {embedding.shape[0]} tokens (unfiltered)")
|
| 314 |
-
|
| 315 |
return embedding
|
| 316 |
|
| 317 |
def embed_queries(
|
|
@@ -327,7 +343,9 @@ class VisualEmbedder:
|
|
| 327 |
Returns a list of tensors, each of shape [num_tokens, embedding_dim].
|
| 328 |
"""
|
| 329 |
should_filter = (
|
| 330 |
-
filter_special_tokens
|
|
|
|
|
|
|
| 331 |
)
|
| 332 |
batch_size = batch_size or self.batch_size
|
| 333 |
|
|
@@ -368,7 +386,7 @@ class VisualEmbedder:
|
|
| 368 |
torch.mps.empty_cache()
|
| 369 |
|
| 370 |
return outputs
|
| 371 |
-
|
| 372 |
def embed_images(
|
| 373 |
self,
|
| 374 |
images: List[Image.Image],
|
|
@@ -378,19 +396,19 @@ class VisualEmbedder:
|
|
| 378 |
) -> Union[List[torch.Tensor], Tuple[List[torch.Tensor], List[Dict[str, Any]]]]:
|
| 379 |
"""
|
| 380 |
Generate embeddings for a list of images.
|
| 381 |
-
|
| 382 |
Args:
|
| 383 |
images: List of PIL Images
|
| 384 |
batch_size: Override instance batch size
|
| 385 |
return_token_info: Also return token metadata (for saliency maps)
|
| 386 |
show_progress: Show progress bar
|
| 387 |
-
|
| 388 |
Returns:
|
| 389 |
If return_token_info=False:
|
| 390 |
List of embedding tensors [num_patches, dim]
|
| 391 |
If return_token_info=True:
|
| 392 |
Tuple of (embeddings, token_infos)
|
| 393 |
-
|
| 394 |
Token info contains:
|
| 395 |
- visual_token_indices: Indices of visual tokens in embedding
|
| 396 |
- num_visual_tokens: Count of visual tokens
|
|
@@ -398,54 +416,60 @@ class VisualEmbedder:
|
|
| 398 |
- num_tiles: Total tiles (n_rows × n_cols + 1 global)
|
| 399 |
"""
|
| 400 |
batch_size = batch_size or self.batch_size
|
| 401 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
| 402 |
batch_size = 1
|
| 403 |
-
|
| 404 |
embeddings = []
|
| 405 |
token_infos = [] if return_token_info else None
|
| 406 |
-
|
| 407 |
iterator = range(0, len(images), batch_size)
|
| 408 |
if show_progress:
|
| 409 |
iterator = tqdm(iterator, desc="🎨 Embedding", unit="batch")
|
| 410 |
-
|
| 411 |
for i in iterator:
|
| 412 |
-
batch = images[i:i + batch_size]
|
| 413 |
-
|
| 414 |
with torch.no_grad():
|
| 415 |
processed = self.processor.process_images(batch).to(self.model.device)
|
| 416 |
-
|
| 417 |
# Extract token info before model forward
|
| 418 |
if return_token_info:
|
| 419 |
input_ids = processed["input_ids"]
|
| 420 |
batch_n_rows = processed.get("n_rows")
|
| 421 |
batch_n_cols = processed.get("n_cols")
|
| 422 |
-
|
| 423 |
for j in range(input_ids.shape[0]):
|
| 424 |
# Find visual token indices
|
| 425 |
-
image_token_mask =
|
| 426 |
visual_indices = torch.where(image_token_mask)[0].cpu().numpy().tolist()
|
| 427 |
-
|
| 428 |
n_rows = batch_n_rows[j].item() if batch_n_rows is not None else None
|
| 429 |
n_cols = batch_n_cols[j].item() if batch_n_cols is not None else None
|
| 430 |
-
|
| 431 |
-
token_infos.append(
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
|
|
|
|
|
|
| 439 |
# Generate embeddings
|
| 440 |
batch_embeddings = self.model(**processed)
|
| 441 |
-
|
| 442 |
# Extract per-image embeddings
|
| 443 |
if isinstance(batch_embeddings, torch.Tensor) and batch_embeddings.dim() == 3:
|
| 444 |
for j in range(batch_embeddings.shape[0]):
|
| 445 |
embeddings.append(batch_embeddings[j].cpu())
|
| 446 |
else:
|
| 447 |
embeddings.extend([e.cpu() for e in batch_embeddings])
|
| 448 |
-
|
| 449 |
# Memory cleanup
|
| 450 |
del processed, batch_embeddings
|
| 451 |
gc.collect()
|
|
@@ -453,11 +477,11 @@ class VisualEmbedder:
|
|
| 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
|
| 459 |
return embeddings
|
| 460 |
-
|
| 461 |
def extract_visual_embedding(
|
| 462 |
self,
|
| 463 |
full_embedding: torch.Tensor,
|
|
@@ -465,18 +489,18 @@ class VisualEmbedder:
|
|
| 465 |
) -> np.ndarray:
|
| 466 |
"""
|
| 467 |
Extract only visual token embeddings from full embedding.
|
| 468 |
-
|
| 469 |
Filters out special tokens, keeping only visual patches for MaxSim.
|
| 470 |
-
|
| 471 |
Args:
|
| 472 |
full_embedding: Full embedding [all_tokens, dim]
|
| 473 |
token_info: Token info dict from embed_images
|
| 474 |
-
|
| 475 |
Returns:
|
| 476 |
Visual embedding array [num_visual_tokens, dim]
|
| 477 |
"""
|
| 478 |
visual_indices = token_info["visual_token_indices"]
|
| 479 |
-
|
| 480 |
if isinstance(full_embedding, torch.Tensor):
|
| 481 |
if full_embedding.dtype == torch.bfloat16:
|
| 482 |
visual_emb = full_embedding[visual_indices].cpu().float().numpy()
|
|
@@ -484,7 +508,7 @@ class VisualEmbedder:
|
|
| 484 |
visual_emb = full_embedding[visual_indices].cpu().numpy()
|
| 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(
|
|
@@ -511,17 +535,23 @@ class VisualEmbedder:
|
|
| 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(
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 525 |
|
| 526 |
def global_pool_from_mean_pool(self, mean_pool: np.ndarray) -> np.ndarray:
|
| 527 |
if mean_pool.size == 0:
|
|
@@ -536,7 +566,10 @@ class VisualEmbedder:
|
|
| 536 |
target_vectors: int = 32,
|
| 537 |
mean_pool: Optional[np.ndarray] = None,
|
| 538 |
) -> np.ndarray:
|
| 539 |
-
from visual_rag.embedding.pooling import
|
|
|
|
|
|
|
|
|
|
| 540 |
|
| 541 |
model_lower = (self.model_name or "").lower()
|
| 542 |
is_colsmol = "colsmol" in model_lower
|
|
@@ -550,7 +583,11 @@ class VisualEmbedder:
|
|
| 550 |
visual_np = np.array(visual_embedding, dtype=np.float32)
|
| 551 |
|
| 552 |
if is_colsmol:
|
| 553 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
| 554 |
num_tiles = int(mean_pool.shape[0])
|
| 555 |
else:
|
| 556 |
num_tiles = (token_info or {}).get("num_tiles")
|
|
@@ -563,14 +600,23 @@ class VisualEmbedder:
|
|
| 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(
|
|
|
|
|
|
|
| 567 |
|
| 568 |
-
rows =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 12 |
"""
|
| 13 |
|
| 14 |
import gc
|
|
|
|
| 15 |
import logging
|
| 16 |
+
import os
|
| 17 |
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
| 18 |
|
|
|
|
| 19 |
import numpy as np
|
| 20 |
+
import torch
|
| 21 |
from PIL import Image
|
| 22 |
from tqdm import tqdm
|
| 23 |
|
|
|
|
| 27 |
class VisualEmbedder:
|
| 28 |
"""
|
| 29 |
Visual document embedder supporting multiple backends.
|
| 30 |
+
|
| 31 |
Currently supports:
|
| 32 |
- ColPali family (ColSmol-500M, ColPali, ColQwen2)
|
| 33 |
- More backends can be added
|
| 34 |
+
|
| 35 |
Args:
|
| 36 |
model_name: HuggingFace model name (e.g., "vidore/colSmol-500M")
|
| 37 |
backend: Backend type ("colpali", "auto"). "auto" detects from model_name.
|
|
|
|
| 39 |
torch_dtype: Data type for model weights
|
| 40 |
batch_size: Batch size for image processing
|
| 41 |
filter_special_tokens: Filter special tokens from query embeddings
|
| 42 |
+
|
| 43 |
Example:
|
| 44 |
>>> # Auto-detect backend from model name
|
| 45 |
>>> embedder = VisualEmbedder(model_name="vidore/colSmol-500M")
|
| 46 |
+
>>>
|
| 47 |
>>> # Embed images
|
| 48 |
>>> image_embeddings = embedder.embed_images(images)
|
| 49 |
+
>>>
|
| 50 |
>>> # Embed query
|
| 51 |
>>> query_embedding = embedder.embed_query("What is the budget?")
|
| 52 |
+
>>>
|
| 53 |
>>> # Get token info for saliency maps
|
| 54 |
>>> embeddings, token_infos = embedder.embed_images(
|
| 55 |
... images, return_token_info=True
|
| 56 |
... )
|
| 57 |
"""
|
| 58 |
+
|
| 59 |
# Known model families and their backends
|
| 60 |
MODEL_BACKENDS = {
|
| 61 |
"colsmol": "colpali",
|
|
|
|
| 63 |
"colqwen": "colpali",
|
| 64 |
"colidefics": "colpali",
|
| 65 |
}
|
| 66 |
+
|
| 67 |
def __init__(
|
| 68 |
self,
|
| 69 |
model_name: str = "vidore/colSmol-500M",
|
|
|
|
| 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"
|
|
|
|
| 98 |
else:
|
| 99 |
device = "cpu"
|
| 100 |
self.device = device
|
| 101 |
+
|
| 102 |
if torch_dtype is None:
|
| 103 |
if device == "cuda":
|
| 104 |
torch_dtype = torch.bfloat16
|
| 105 |
else:
|
| 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
|
| 119 |
+
|
| 120 |
+
logger.info("🤖 VisualEmbedder initialized")
|
| 121 |
logger.info(f" Model: {model_name}")
|
| 122 |
logger.info(f" Backend: {backend}")
|
| 123 |
+
logger.info(
|
| 124 |
+
f" Device: {device}, torch_dtype: {torch_dtype}, output_dtype: {output_dtype}"
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
def _detect_backend(self, model_name: str) -> str:
|
| 128 |
"""Auto-detect backend from model name."""
|
| 129 |
model_lower = model_name.lower()
|
| 130 |
+
|
| 131 |
for key, backend in self.MODEL_BACKENDS.items():
|
| 132 |
if key in model_lower:
|
| 133 |
logger.debug(f"Detected backend '{backend}' from model name")
|
| 134 |
return backend
|
| 135 |
+
|
| 136 |
# Default to colpali for unknown models
|
| 137 |
logger.warning(f"Unknown model '{model_name}', defaulting to 'colpali' backend")
|
| 138 |
return "colpali"
|
| 139 |
+
|
| 140 |
def _load_model(self):
|
| 141 |
"""Lazy load the model when first needed."""
|
| 142 |
if self._model is not None:
|
| 143 |
return
|
| 144 |
+
|
| 145 |
if self.backend == "colpali":
|
| 146 |
self._load_colpali_model()
|
| 147 |
else:
|
| 148 |
raise ValueError(f"Unknown backend: {self.backend}")
|
| 149 |
+
|
| 150 |
def _load_colpali_model(self):
|
| 151 |
"""Load ColPali-family model."""
|
| 152 |
try:
|
|
|
|
| 164 |
"pip install visual-rag-toolkit[embedding] or "
|
| 165 |
"pip install colpali-engine"
|
| 166 |
)
|
| 167 |
+
|
| 168 |
logger.info(f"🤖 Loading ColPali model: {self.model_name}")
|
| 169 |
logger.info(f" Device: {self.device}, dtype: {self.torch_dtype}")
|
| 170 |
|
|
|
|
| 172 |
if self.processor_speed == "auto":
|
| 173 |
return {}
|
| 174 |
return {"use_fast": self.processor_speed == "fast"}
|
| 175 |
+
|
| 176 |
from transformers import AutoConfig
|
| 177 |
|
| 178 |
cfg = AutoConfig.from_pretrained(self.model_name)
|
|
|
|
| 185 |
device_map=self.device,
|
| 186 |
).eval()
|
| 187 |
try:
|
| 188 |
+
self._processor = ColPaliProcessor.from_pretrained(
|
| 189 |
+
self.model_name, **_processor_kwargs()
|
| 190 |
+
)
|
| 191 |
except TypeError:
|
| 192 |
self._processor = ColPaliProcessor.from_pretrained(self.model_name)
|
| 193 |
except Exception:
|
| 194 |
if self.processor_speed == "fast":
|
| 195 |
+
self._processor = ColPaliProcessor.from_pretrained(
|
| 196 |
+
self.model_name, use_fast=False
|
| 197 |
+
)
|
| 198 |
else:
|
| 199 |
raise
|
| 200 |
self._image_token_id = self._processor.image_token_id
|
|
|
|
| 208 |
device_map=self.device,
|
| 209 |
).eval()
|
| 210 |
try:
|
| 211 |
+
self._processor = ColQwen2Processor.from_pretrained(
|
| 212 |
+
self.model_name, device_map=self.device, **_processor_kwargs()
|
| 213 |
+
)
|
| 214 |
except TypeError:
|
| 215 |
+
self._processor = ColQwen2Processor.from_pretrained(
|
| 216 |
+
self.model_name, device_map=self.device
|
| 217 |
+
)
|
| 218 |
except Exception:
|
| 219 |
if self.processor_speed == "fast":
|
| 220 |
+
self._processor = ColQwen2Processor.from_pretrained(
|
| 221 |
+
self.model_name, device_map=self.device, use_fast=False
|
| 222 |
+
)
|
| 223 |
else:
|
| 224 |
raise
|
| 225 |
self._image_token_id = self._processor.image_token_id
|
|
|
|
| 243 |
attn_implementation=attn_implementation,
|
| 244 |
).eval()
|
| 245 |
try:
|
| 246 |
+
self._processor = ColIdefics3Processor.from_pretrained(
|
| 247 |
+
self.model_name, **_processor_kwargs()
|
| 248 |
+
)
|
| 249 |
except TypeError:
|
| 250 |
self._processor = ColIdefics3Processor.from_pretrained(self.model_name)
|
| 251 |
except Exception:
|
| 252 |
if self.processor_speed == "fast":
|
| 253 |
+
self._processor = ColIdefics3Processor.from_pretrained(
|
| 254 |
+
self.model_name, use_fast=False
|
| 255 |
+
)
|
| 256 |
else:
|
| 257 |
raise
|
| 258 |
self._image_token_id = self._processor.image_token_id
|
| 259 |
+
|
| 260 |
logger.info("✅ Model loaded successfully")
|
| 261 |
+
|
| 262 |
@property
|
| 263 |
def model(self):
|
| 264 |
self._load_model()
|
| 265 |
return self._model
|
| 266 |
+
|
| 267 |
@property
|
| 268 |
def processor(self):
|
| 269 |
self._load_model()
|
| 270 |
return self._processor
|
| 271 |
+
|
| 272 |
@property
|
| 273 |
def image_token_id(self):
|
| 274 |
self._load_model()
|
| 275 |
return self._image_token_id
|
| 276 |
+
|
| 277 |
def embed_query(
|
| 278 |
self,
|
| 279 |
query_text: str,
|
|
|
|
| 281 |
) -> torch.Tensor:
|
| 282 |
"""
|
| 283 |
Generate embedding for a text query.
|
| 284 |
+
|
| 285 |
By default, filters out special tokens (CLS, SEP, PAD) to keep only
|
| 286 |
meaningful text tokens for better MaxSim matching.
|
| 287 |
+
|
| 288 |
Args:
|
| 289 |
query_text: Natural language query string
|
| 290 |
filter_special_tokens: Override instance-level setting
|
| 291 |
+
|
| 292 |
Returns:
|
| 293 |
Query embedding tensor of shape [num_tokens, embedding_dim]
|
| 294 |
"""
|
| 295 |
should_filter = (
|
| 296 |
+
filter_special_tokens
|
| 297 |
+
if filter_special_tokens is not None
|
| 298 |
else self.filter_special_tokens
|
| 299 |
)
|
| 300 |
+
|
| 301 |
with torch.no_grad():
|
| 302 |
processed = self.processor.process_queries([query_text]).to(self.model.device)
|
| 303 |
embedding = self.model(**processed)
|
| 304 |
+
|
| 305 |
# Remove batch dimension: [1, tokens, dim] -> [tokens, dim]
|
| 306 |
if embedding.dim() == 3:
|
| 307 |
embedding = embedding.squeeze(0)
|
| 308 |
+
|
| 309 |
if should_filter:
|
| 310 |
# Filter special tokens based on attention mask
|
| 311 |
attention_mask = processed.get("attention_mask")
|
|
|
|
| 313 |
# Keep only tokens with attention_mask = 1
|
| 314 |
valid_mask = attention_mask.squeeze(0).bool()
|
| 315 |
embedding = embedding[valid_mask]
|
| 316 |
+
|
| 317 |
# Additionally filter padding tokens if present
|
| 318 |
input_ids = processed.get("input_ids")
|
| 319 |
if input_ids is not None:
|
|
|
|
| 323 |
non_special_mask = input_ids >= 4
|
| 324 |
if non_special_mask.any():
|
| 325 |
embedding = embedding[non_special_mask]
|
| 326 |
+
|
| 327 |
logger.debug(f"Query embedding: {embedding.shape[0]} tokens after filtering")
|
| 328 |
else:
|
| 329 |
logger.debug(f"Query embedding: {embedding.shape[0]} tokens (unfiltered)")
|
| 330 |
+
|
| 331 |
return embedding
|
| 332 |
|
| 333 |
def embed_queries(
|
|
|
|
| 343 |
Returns a list of tensors, each of shape [num_tokens, embedding_dim].
|
| 344 |
"""
|
| 345 |
should_filter = (
|
| 346 |
+
filter_special_tokens
|
| 347 |
+
if filter_special_tokens is not None
|
| 348 |
+
else self.filter_special_tokens
|
| 349 |
)
|
| 350 |
batch_size = batch_size or self.batch_size
|
| 351 |
|
|
|
|
| 386 |
torch.mps.empty_cache()
|
| 387 |
|
| 388 |
return outputs
|
| 389 |
+
|
| 390 |
def embed_images(
|
| 391 |
self,
|
| 392 |
images: List[Image.Image],
|
|
|
|
| 396 |
) -> Union[List[torch.Tensor], Tuple[List[torch.Tensor], List[Dict[str, Any]]]]:
|
| 397 |
"""
|
| 398 |
Generate embeddings for a list of images.
|
| 399 |
+
|
| 400 |
Args:
|
| 401 |
images: List of PIL Images
|
| 402 |
batch_size: Override instance batch size
|
| 403 |
return_token_info: Also return token metadata (for saliency maps)
|
| 404 |
show_progress: Show progress bar
|
| 405 |
+
|
| 406 |
Returns:
|
| 407 |
If return_token_info=False:
|
| 408 |
List of embedding tensors [num_patches, dim]
|
| 409 |
If return_token_info=True:
|
| 410 |
Tuple of (embeddings, token_infos)
|
| 411 |
+
|
| 412 |
Token info contains:
|
| 413 |
- visual_token_indices: Indices of visual tokens in embedding
|
| 414 |
- num_visual_tokens: Count of visual tokens
|
|
|
|
| 416 |
- num_tiles: Total tiles (n_rows × n_cols + 1 global)
|
| 417 |
"""
|
| 418 |
batch_size = batch_size or self.batch_size
|
| 419 |
+
if (
|
| 420 |
+
self.device == "mps"
|
| 421 |
+
and "colpali" in (self.model_name or "").lower()
|
| 422 |
+
and int(batch_size) > 1
|
| 423 |
+
):
|
| 424 |
batch_size = 1
|
| 425 |
+
|
| 426 |
embeddings = []
|
| 427 |
token_infos = [] if return_token_info else None
|
| 428 |
+
|
| 429 |
iterator = range(0, len(images), batch_size)
|
| 430 |
if show_progress:
|
| 431 |
iterator = tqdm(iterator, desc="🎨 Embedding", unit="batch")
|
| 432 |
+
|
| 433 |
for i in iterator:
|
| 434 |
+
batch = images[i : i + batch_size]
|
| 435 |
+
|
| 436 |
with torch.no_grad():
|
| 437 |
processed = self.processor.process_images(batch).to(self.model.device)
|
| 438 |
+
|
| 439 |
# Extract token info before model forward
|
| 440 |
if return_token_info:
|
| 441 |
input_ids = processed["input_ids"]
|
| 442 |
batch_n_rows = processed.get("n_rows")
|
| 443 |
batch_n_cols = processed.get("n_cols")
|
| 444 |
+
|
| 445 |
for j in range(input_ids.shape[0]):
|
| 446 |
# Find visual token indices
|
| 447 |
+
image_token_mask = input_ids[j] == self.image_token_id
|
| 448 |
visual_indices = torch.where(image_token_mask)[0].cpu().numpy().tolist()
|
| 449 |
+
|
| 450 |
n_rows = batch_n_rows[j].item() if batch_n_rows is not None else None
|
| 451 |
n_cols = batch_n_cols[j].item() if batch_n_cols is not None else None
|
| 452 |
+
|
| 453 |
+
token_infos.append(
|
| 454 |
+
{
|
| 455 |
+
"visual_token_indices": visual_indices,
|
| 456 |
+
"num_visual_tokens": len(visual_indices),
|
| 457 |
+
"n_rows": n_rows,
|
| 458 |
+
"n_cols": n_cols,
|
| 459 |
+
"num_tiles": (n_rows * n_cols + 1) if n_rows and n_cols else None,
|
| 460 |
+
}
|
| 461 |
+
)
|
| 462 |
+
|
| 463 |
# Generate embeddings
|
| 464 |
batch_embeddings = self.model(**processed)
|
| 465 |
+
|
| 466 |
# Extract per-image embeddings
|
| 467 |
if isinstance(batch_embeddings, torch.Tensor) and batch_embeddings.dim() == 3:
|
| 468 |
for j in range(batch_embeddings.shape[0]):
|
| 469 |
embeddings.append(batch_embeddings[j].cpu())
|
| 470 |
else:
|
| 471 |
embeddings.extend([e.cpu() for e in batch_embeddings])
|
| 472 |
+
|
| 473 |
# Memory cleanup
|
| 474 |
del processed, batch_embeddings
|
| 475 |
gc.collect()
|
|
|
|
| 477 |
torch.cuda.empty_cache()
|
| 478 |
elif torch.backends.mps.is_available():
|
| 479 |
torch.mps.empty_cache()
|
| 480 |
+
|
| 481 |
if return_token_info:
|
| 482 |
return embeddings, token_infos
|
| 483 |
return embeddings
|
| 484 |
+
|
| 485 |
def extract_visual_embedding(
|
| 486 |
self,
|
| 487 |
full_embedding: torch.Tensor,
|
|
|
|
| 489 |
) -> np.ndarray:
|
| 490 |
"""
|
| 491 |
Extract only visual token embeddings from full embedding.
|
| 492 |
+
|
| 493 |
Filters out special tokens, keeping only visual patches for MaxSim.
|
| 494 |
+
|
| 495 |
Args:
|
| 496 |
full_embedding: Full embedding [all_tokens, dim]
|
| 497 |
token_info: Token info dict from embed_images
|
| 498 |
+
|
| 499 |
Returns:
|
| 500 |
Visual embedding array [num_visual_tokens, dim]
|
| 501 |
"""
|
| 502 |
visual_indices = token_info["visual_token_indices"]
|
| 503 |
+
|
| 504 |
if isinstance(full_embedding, torch.Tensor):
|
| 505 |
if full_embedding.dtype == torch.bfloat16:
|
| 506 |
visual_emb = full_embedding[visual_indices].cpu().float().numpy()
|
|
|
|
| 508 |
visual_emb = full_embedding[visual_indices].cpu().numpy()
|
| 509 |
else:
|
| 510 |
visual_emb = np.array(full_embedding)[visual_indices]
|
| 511 |
+
|
| 512 |
return visual_emb.astype(self.output_dtype)
|
| 513 |
|
| 514 |
def mean_pool_visual_embedding(
|
|
|
|
| 535 |
n_rows = (token_info or {}).get("n_rows")
|
| 536 |
n_cols = (token_info or {}).get("n_cols")
|
| 537 |
num_tiles = int(n_rows) * int(n_cols) + 1 if n_rows and n_cols else 13
|
| 538 |
+
return tile_level_mean_pooling(
|
| 539 |
+
visual_np, num_tiles=num_tiles, patches_per_tile=64, output_dtype=self.output_dtype
|
| 540 |
+
)
|
| 541 |
|
| 542 |
num_tokens = int(visual_np.shape[0])
|
| 543 |
grid = int(round(float(num_tokens) ** 0.5))
|
| 544 |
if grid * grid != num_tokens:
|
| 545 |
+
raise ValueError(
|
| 546 |
+
f"Cannot infer square grid from num_visual_tokens={num_tokens} for model={self.model_name}"
|
| 547 |
+
)
|
| 548 |
if int(target_vectors) != int(grid):
|
| 549 |
raise ValueError(
|
| 550 |
f"target_vectors={target_vectors} does not match inferred grid_size={grid} for model={self.model_name}"
|
| 551 |
)
|
| 552 |
+
return colpali_row_mean_pooling(
|
| 553 |
+
visual_np, grid_size=int(target_vectors), output_dtype=self.output_dtype
|
| 554 |
+
)
|
| 555 |
|
| 556 |
def global_pool_from_mean_pool(self, mean_pool: np.ndarray) -> np.ndarray:
|
| 557 |
if mean_pool.size == 0:
|
|
|
|
| 566 |
target_vectors: int = 32,
|
| 567 |
mean_pool: Optional[np.ndarray] = None,
|
| 568 |
) -> np.ndarray:
|
| 569 |
+
from visual_rag.embedding.pooling import (
|
| 570 |
+
colpali_experimental_pooling_from_rows,
|
| 571 |
+
colsmol_experimental_pooling,
|
| 572 |
+
)
|
| 573 |
|
| 574 |
model_lower = (self.model_name or "").lower()
|
| 575 |
is_colsmol = "colsmol" in model_lower
|
|
|
|
| 583 |
visual_np = np.array(visual_embedding, dtype=np.float32)
|
| 584 |
|
| 585 |
if is_colsmol:
|
| 586 |
+
if (
|
| 587 |
+
mean_pool is not None
|
| 588 |
+
and getattr(mean_pool, "shape", None) is not None
|
| 589 |
+
and int(mean_pool.shape[0]) > 0
|
| 590 |
+
):
|
| 591 |
num_tiles = int(mean_pool.shape[0])
|
| 592 |
else:
|
| 593 |
num_tiles = (token_info or {}).get("num_tiles")
|
|
|
|
| 600 |
if int(num_tiles) * patches_per_tile != int(num_visual_tokens):
|
| 601 |
num_tiles = int(num_tiles) + 1
|
| 602 |
num_tiles = int(num_tiles)
|
| 603 |
+
return colsmol_experimental_pooling(
|
| 604 |
+
visual_np, num_tiles=num_tiles, patches_per_tile=64, output_dtype=self.output_dtype
|
| 605 |
+
)
|
| 606 |
|
| 607 |
+
rows = (
|
| 608 |
+
mean_pool
|
| 609 |
+
if mean_pool is not None
|
| 610 |
+
else self.mean_pool_visual_embedding(
|
| 611 |
+
visual_np, token_info, target_vectors=target_vectors
|
| 612 |
+
)
|
| 613 |
+
)
|
| 614 |
if int(rows.shape[0]) != int(target_vectors):
|
| 615 |
raise ValueError(
|
| 616 |
f"experimental pooling expects mean_pool to have {target_vectors} rows, got {rows.shape[0]} for model={self.model_name}"
|
| 617 |
)
|
| 618 |
return colpali_experimental_pooling_from_rows(rows, output_dtype=self.output_dtype)
|
| 619 |
|
| 620 |
+
|
| 621 |
# Backward compatibility alias
|
| 622 |
ColPaliEmbedder = VisualEmbedder
|
visual_rag/indexing/__init__.py
CHANGED
|
@@ -8,10 +8,10 @@ Components:
|
|
| 8 |
- ProcessingPipeline: End-to-end PDF → Qdrant pipeline
|
| 9 |
"""
|
| 10 |
|
| 11 |
-
from visual_rag.indexing.pdf_processor import PDFProcessor
|
| 12 |
-
from visual_rag.indexing.qdrant_indexer import QdrantIndexer
|
| 13 |
from visual_rag.indexing.cloudinary_uploader import CloudinaryUploader
|
|
|
|
| 14 |
from visual_rag.indexing.pipeline import ProcessingPipeline
|
|
|
|
| 15 |
|
| 16 |
__all__ = [
|
| 17 |
"PDFProcessor",
|
|
|
|
| 8 |
- ProcessingPipeline: End-to-end PDF → Qdrant pipeline
|
| 9 |
"""
|
| 10 |
|
|
|
|
|
|
|
| 11 |
from visual_rag.indexing.cloudinary_uploader import CloudinaryUploader
|
| 12 |
+
from visual_rag.indexing.pdf_processor import PDFProcessor
|
| 13 |
from visual_rag.indexing.pipeline import ProcessingPipeline
|
| 14 |
+
from visual_rag.indexing.qdrant_indexer import QdrantIndexer
|
| 15 |
|
| 16 |
__all__ = [
|
| 17 |
"PDFProcessor",
|
visual_rag/indexing/cloudinary_uploader.py
CHANGED
|
@@ -15,14 +15,15 @@ Environment Variables:
|
|
| 15 |
"""
|
| 16 |
|
| 17 |
import io
|
| 18 |
-
import os
|
| 19 |
-
import time
|
| 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 |
|
|
@@ -34,9 +35,9 @@ THREAD_SAFE_MODE = os.getenv("VISUAL_RAG_THREAD_SAFE", "").lower() in ("1", "tru
|
|
| 34 |
class CloudinaryUploader:
|
| 35 |
"""
|
| 36 |
Upload images to Cloudinary CDN.
|
| 37 |
-
|
| 38 |
Works independently - just needs PIL images.
|
| 39 |
-
|
| 40 |
Args:
|
| 41 |
cloud_name: Cloudinary cloud name
|
| 42 |
api_key: Cloudinary API key
|
|
@@ -44,7 +45,7 @@ class CloudinaryUploader:
|
|
| 44 |
folder: Base folder for uploads
|
| 45 |
max_retries: Number of retry attempts
|
| 46 |
timeout_seconds: Timeout per upload
|
| 47 |
-
|
| 48 |
Example:
|
| 49 |
>>> uploader = CloudinaryUploader(
|
| 50 |
... cloud_name="my-cloud",
|
|
@@ -52,11 +53,11 @@ class CloudinaryUploader:
|
|
| 52 |
... api_secret="yyy",
|
| 53 |
... folder="my-project",
|
| 54 |
... )
|
| 55 |
-
>>>
|
| 56 |
>>> url = uploader.upload(image, "doc_page_1")
|
| 57 |
>>> print(url) # https://res.cloudinary.com/.../doc_page_1.jpg
|
| 58 |
"""
|
| 59 |
-
|
| 60 |
def __init__(
|
| 61 |
self,
|
| 62 |
cloud_name: Optional[str] = None,
|
|
@@ -71,19 +72,19 @@ class CloudinaryUploader:
|
|
| 71 |
self.cloud_name = cloud_name or os.getenv("CLOUDINARY_CLOUD_NAME")
|
| 72 |
self.api_key = api_key or os.getenv("CLOUDINARY_API_KEY")
|
| 73 |
self.api_secret = api_secret or os.getenv("CLOUDINARY_API_SECRET")
|
| 74 |
-
|
| 75 |
if not all([self.cloud_name, self.api_key, self.api_secret]):
|
| 76 |
raise ValueError(
|
| 77 |
"Cloudinary credentials required. Set CLOUDINARY_CLOUD_NAME, "
|
| 78 |
"CLOUDINARY_API_KEY, CLOUDINARY_API_SECRET environment variables "
|
| 79 |
"or pass them as arguments."
|
| 80 |
)
|
| 81 |
-
|
| 82 |
self.folder = folder
|
| 83 |
self.max_retries = max_retries
|
| 84 |
self.timeout_seconds = timeout_seconds
|
| 85 |
self.jpeg_quality = jpeg_quality
|
| 86 |
-
|
| 87 |
# Check dependency
|
| 88 |
try:
|
| 89 |
import cloudinary # noqa
|
|
@@ -92,10 +93,10 @@ class CloudinaryUploader:
|
|
| 92 |
"Cloudinary not installed. "
|
| 93 |
"Install with: pip install visual-rag-toolkit[cloudinary]"
|
| 94 |
)
|
| 95 |
-
|
| 96 |
-
logger.info(
|
| 97 |
logger.info(f" Folder: {folder}")
|
| 98 |
-
|
| 99 |
def upload(
|
| 100 |
self,
|
| 101 |
image: Image.Image,
|
|
@@ -104,34 +105,34 @@ class CloudinaryUploader:
|
|
| 104 |
) -> Optional[str]:
|
| 105 |
"""
|
| 106 |
Upload a single image to Cloudinary.
|
| 107 |
-
|
| 108 |
Args:
|
| 109 |
image: PIL Image to upload
|
| 110 |
public_id: Public ID (filename without extension)
|
| 111 |
subfolder: Optional subfolder within base folder
|
| 112 |
-
|
| 113 |
Returns:
|
| 114 |
Secure URL of uploaded image, or None if failed
|
| 115 |
"""
|
| 116 |
import cloudinary
|
| 117 |
import cloudinary.uploader
|
| 118 |
-
|
| 119 |
# Prepare buffer
|
| 120 |
buffer = io.BytesIO()
|
| 121 |
image.save(buffer, format="JPEG", quality=self.jpeg_quality, optimize=True)
|
| 122 |
-
|
| 123 |
# Configure Cloudinary
|
| 124 |
cloudinary.config(
|
| 125 |
cloud_name=self.cloud_name,
|
| 126 |
api_key=self.api_key,
|
| 127 |
api_secret=self.api_secret,
|
| 128 |
)
|
| 129 |
-
|
| 130 |
# Build folder path
|
| 131 |
folder_path = self.folder
|
| 132 |
if subfolder:
|
| 133 |
folder_path = f"{self.folder}/{subfolder}"
|
| 134 |
-
|
| 135 |
def do_upload():
|
| 136 |
buffer.seek(0)
|
| 137 |
result = cloudinary.uploader.upload(
|
|
@@ -143,14 +144,14 @@ class CloudinaryUploader:
|
|
| 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):
|
|
@@ -158,64 +159,60 @@ class CloudinaryUploader:
|
|
| 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
|
| 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
|
| 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 |
)
|
| 206 |
if attempt < self.max_retries - 1:
|
| 207 |
-
time.sleep(2
|
| 208 |
-
|
| 209 |
except Exception as e:
|
| 210 |
-
logger.warning(
|
| 211 |
-
f"Upload failed (attempt {attempt + 1}/{self.max_retries}): {e}"
|
| 212 |
-
)
|
| 213 |
if attempt < self.max_retries - 1:
|
| 214 |
-
time.sleep(2
|
| 215 |
-
|
| 216 |
logger.error(f"❌ Upload failed after {self.max_retries} attempts: {public_id}")
|
| 217 |
return None
|
| 218 |
-
|
| 219 |
def upload_original_and_resized(
|
| 220 |
self,
|
| 221 |
original_image: Image.Image,
|
|
@@ -224,12 +221,12 @@ class CloudinaryUploader:
|
|
| 224 |
) -> tuple:
|
| 225 |
"""
|
| 226 |
Upload both original and resized versions.
|
| 227 |
-
|
| 228 |
Args:
|
| 229 |
original_image: Original PDF page image
|
| 230 |
resized_image: Resized image for ColPali
|
| 231 |
base_public_id: Base public ID (e.g., "doc_page_1")
|
| 232 |
-
|
| 233 |
Returns:
|
| 234 |
Tuple of (original_url, resized_url) - either can be None on failure
|
| 235 |
"""
|
|
@@ -238,13 +235,13 @@ class CloudinaryUploader:
|
|
| 238 |
base_public_id,
|
| 239 |
subfolder="original",
|
| 240 |
)
|
| 241 |
-
|
| 242 |
resized_url = self.upload(
|
| 243 |
resized_image,
|
| 244 |
base_public_id,
|
| 245 |
subfolder="resized",
|
| 246 |
)
|
| 247 |
-
|
| 248 |
return original_url, resized_url
|
| 249 |
|
| 250 |
def upload_original_cropped_and_resized(
|
|
@@ -275,5 +272,3 @@ class CloudinaryUploader:
|
|
| 275 |
)
|
| 276 |
|
| 277 |
return original_url, cropped_url, resized_url
|
| 278 |
-
|
| 279 |
-
|
|
|
|
| 15 |
"""
|
| 16 |
|
| 17 |
import io
|
|
|
|
|
|
|
|
|
|
| 18 |
import logging
|
| 19 |
+
import os
|
| 20 |
import platform
|
| 21 |
+
import signal
|
| 22 |
import threading
|
| 23 |
+
import time
|
| 24 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 25 |
+
from concurrent.futures import TimeoutError as FuturesTimeoutError
|
| 26 |
from typing import Optional
|
|
|
|
| 27 |
|
| 28 |
from PIL import Image
|
| 29 |
|
|
|
|
| 35 |
class CloudinaryUploader:
|
| 36 |
"""
|
| 37 |
Upload images to Cloudinary CDN.
|
| 38 |
+
|
| 39 |
Works independently - just needs PIL images.
|
| 40 |
+
|
| 41 |
Args:
|
| 42 |
cloud_name: Cloudinary cloud name
|
| 43 |
api_key: Cloudinary API key
|
|
|
|
| 45 |
folder: Base folder for uploads
|
| 46 |
max_retries: Number of retry attempts
|
| 47 |
timeout_seconds: Timeout per upload
|
| 48 |
+
|
| 49 |
Example:
|
| 50 |
>>> uploader = CloudinaryUploader(
|
| 51 |
... cloud_name="my-cloud",
|
|
|
|
| 53 |
... api_secret="yyy",
|
| 54 |
... folder="my-project",
|
| 55 |
... )
|
| 56 |
+
>>>
|
| 57 |
>>> url = uploader.upload(image, "doc_page_1")
|
| 58 |
>>> print(url) # https://res.cloudinary.com/.../doc_page_1.jpg
|
| 59 |
"""
|
| 60 |
+
|
| 61 |
def __init__(
|
| 62 |
self,
|
| 63 |
cloud_name: Optional[str] = None,
|
|
|
|
| 72 |
self.cloud_name = cloud_name or os.getenv("CLOUDINARY_CLOUD_NAME")
|
| 73 |
self.api_key = api_key or os.getenv("CLOUDINARY_API_KEY")
|
| 74 |
self.api_secret = api_secret or os.getenv("CLOUDINARY_API_SECRET")
|
| 75 |
+
|
| 76 |
if not all([self.cloud_name, self.api_key, self.api_secret]):
|
| 77 |
raise ValueError(
|
| 78 |
"Cloudinary credentials required. Set CLOUDINARY_CLOUD_NAME, "
|
| 79 |
"CLOUDINARY_API_KEY, CLOUDINARY_API_SECRET environment variables "
|
| 80 |
"or pass them as arguments."
|
| 81 |
)
|
| 82 |
+
|
| 83 |
self.folder = folder
|
| 84 |
self.max_retries = max_retries
|
| 85 |
self.timeout_seconds = timeout_seconds
|
| 86 |
self.jpeg_quality = jpeg_quality
|
| 87 |
+
|
| 88 |
# Check dependency
|
| 89 |
try:
|
| 90 |
import cloudinary # noqa
|
|
|
|
| 93 |
"Cloudinary not installed. "
|
| 94 |
"Install with: pip install visual-rag-toolkit[cloudinary]"
|
| 95 |
)
|
| 96 |
+
|
| 97 |
+
logger.info("☁️ Cloudinary uploader initialized")
|
| 98 |
logger.info(f" Folder: {folder}")
|
| 99 |
+
|
| 100 |
def upload(
|
| 101 |
self,
|
| 102 |
image: Image.Image,
|
|
|
|
| 105 |
) -> Optional[str]:
|
| 106 |
"""
|
| 107 |
Upload a single image to Cloudinary.
|
| 108 |
+
|
| 109 |
Args:
|
| 110 |
image: PIL Image to upload
|
| 111 |
public_id: Public ID (filename without extension)
|
| 112 |
subfolder: Optional subfolder within base folder
|
| 113 |
+
|
| 114 |
Returns:
|
| 115 |
Secure URL of uploaded image, or None if failed
|
| 116 |
"""
|
| 117 |
import cloudinary
|
| 118 |
import cloudinary.uploader
|
| 119 |
+
|
| 120 |
# Prepare buffer
|
| 121 |
buffer = io.BytesIO()
|
| 122 |
image.save(buffer, format="JPEG", quality=self.jpeg_quality, optimize=True)
|
| 123 |
+
|
| 124 |
# Configure Cloudinary
|
| 125 |
cloudinary.config(
|
| 126 |
cloud_name=self.cloud_name,
|
| 127 |
api_key=self.api_key,
|
| 128 |
api_secret=self.api_secret,
|
| 129 |
)
|
| 130 |
+
|
| 131 |
# Build folder path
|
| 132 |
folder_path = self.folder
|
| 133 |
if subfolder:
|
| 134 |
folder_path = f"{self.folder}/{subfolder}"
|
| 135 |
+
|
| 136 |
def do_upload():
|
| 137 |
buffer.seek(0)
|
| 138 |
result = cloudinary.uploader.upload(
|
|
|
|
| 144 |
timeout=self.timeout_seconds,
|
| 145 |
)
|
| 146 |
return result["secure_url"]
|
| 147 |
+
|
| 148 |
# Use thread-safe mode for Streamlit/Flask/threaded contexts
|
| 149 |
# Set VISUAL_RAG_THREAD_SAFE=1 to enable
|
| 150 |
if THREAD_SAFE_MODE or threading.current_thread() is not threading.main_thread():
|
| 151 |
return self._upload_with_thread_timeout(do_upload, public_id)
|
| 152 |
else:
|
| 153 |
return self._upload_with_signal_timeout(do_upload, public_id)
|
| 154 |
+
|
| 155 |
def _upload_with_thread_timeout(self, do_upload, public_id: str) -> Optional[str]:
|
| 156 |
"""Thread-safe upload with ThreadPoolExecutor timeout."""
|
| 157 |
for attempt in range(self.max_retries):
|
|
|
|
| 159 |
with ThreadPoolExecutor(max_workers=1) as executor:
|
| 160 |
future = executor.submit(do_upload)
|
| 161 |
return future.result(timeout=self.timeout_seconds)
|
| 162 |
+
|
| 163 |
except FuturesTimeoutError:
|
| 164 |
logger.warning(
|
| 165 |
f"Upload timeout (attempt {attempt + 1}/{self.max_retries}): {public_id}"
|
| 166 |
)
|
| 167 |
if attempt < self.max_retries - 1:
|
| 168 |
+
time.sleep(2**attempt)
|
| 169 |
+
|
| 170 |
except Exception as e:
|
| 171 |
+
logger.warning(f"Upload failed (attempt {attempt + 1}/{self.max_retries}): {e}")
|
|
|
|
|
|
|
| 172 |
if attempt < self.max_retries - 1:
|
| 173 |
+
time.sleep(2**attempt)
|
| 174 |
+
|
| 175 |
logger.error(f"❌ Upload failed after {self.max_retries} attempts: {public_id}")
|
| 176 |
return None
|
| 177 |
+
|
| 178 |
def _upload_with_signal_timeout(self, do_upload, public_id: str) -> Optional[str]:
|
| 179 |
"""Signal-based upload timeout (main thread only, Unix/macOS)."""
|
| 180 |
use_timeout = platform.system() != "Windows"
|
| 181 |
+
|
| 182 |
class SignalTimeoutError(Exception):
|
| 183 |
pass
|
| 184 |
+
|
| 185 |
def timeout_handler(signum, frame):
|
| 186 |
raise SignalTimeoutError(f"Upload timed out after {self.timeout_seconds}s")
|
| 187 |
+
|
| 188 |
for attempt in range(self.max_retries):
|
| 189 |
try:
|
| 190 |
if use_timeout:
|
| 191 |
old_handler = signal.signal(signal.SIGALRM, timeout_handler)
|
| 192 |
signal.alarm(self.timeout_seconds)
|
| 193 |
+
|
| 194 |
try:
|
| 195 |
return do_upload()
|
| 196 |
finally:
|
| 197 |
if use_timeout:
|
| 198 |
signal.alarm(0)
|
| 199 |
signal.signal(signal.SIGALRM, old_handler)
|
| 200 |
+
|
| 201 |
except SignalTimeoutError:
|
| 202 |
logger.warning(
|
| 203 |
f"Upload timeout (attempt {attempt + 1}/{self.max_retries}): {public_id}"
|
| 204 |
)
|
| 205 |
if attempt < self.max_retries - 1:
|
| 206 |
+
time.sleep(2**attempt)
|
| 207 |
+
|
| 208 |
except Exception as e:
|
| 209 |
+
logger.warning(f"Upload failed (attempt {attempt + 1}/{self.max_retries}): {e}")
|
|
|
|
|
|
|
| 210 |
if attempt < self.max_retries - 1:
|
| 211 |
+
time.sleep(2**attempt)
|
| 212 |
+
|
| 213 |
logger.error(f"❌ Upload failed after {self.max_retries} attempts: {public_id}")
|
| 214 |
return None
|
| 215 |
+
|
| 216 |
def upload_original_and_resized(
|
| 217 |
self,
|
| 218 |
original_image: Image.Image,
|
|
|
|
| 221 |
) -> tuple:
|
| 222 |
"""
|
| 223 |
Upload both original and resized versions.
|
| 224 |
+
|
| 225 |
Args:
|
| 226 |
original_image: Original PDF page image
|
| 227 |
resized_image: Resized image for ColPali
|
| 228 |
base_public_id: Base public ID (e.g., "doc_page_1")
|
| 229 |
+
|
| 230 |
Returns:
|
| 231 |
Tuple of (original_url, resized_url) - either can be None on failure
|
| 232 |
"""
|
|
|
|
| 235 |
base_public_id,
|
| 236 |
subfolder="original",
|
| 237 |
)
|
| 238 |
+
|
| 239 |
resized_url = self.upload(
|
| 240 |
resized_image,
|
| 241 |
base_public_id,
|
| 242 |
subfolder="resized",
|
| 243 |
)
|
| 244 |
+
|
| 245 |
return original_url, resized_url
|
| 246 |
|
| 247 |
def upload_original_cropped_and_resized(
|
|
|
|
| 272 |
)
|
| 273 |
|
| 274 |
return original_url, cropped_url, resized_url
|
|
|
|
|
|
visual_rag/indexing/pdf_processor.py
CHANGED
|
@@ -11,10 +11,10 @@ Features:
|
|
| 11 |
"""
|
| 12 |
|
| 13 |
import gc
|
| 14 |
-
import re
|
| 15 |
import logging
|
|
|
|
| 16 |
from pathlib import Path
|
| 17 |
-
from typing import
|
| 18 |
|
| 19 |
from PIL import Image
|
| 20 |
|
|
@@ -24,26 +24,26 @@ logger = logging.getLogger(__name__)
|
|
| 24 |
class PDFProcessor:
|
| 25 |
"""
|
| 26 |
Process PDFs into images and text for visual retrieval.
|
| 27 |
-
|
| 28 |
Works independently - no embedding or storage dependencies.
|
| 29 |
-
|
| 30 |
Args:
|
| 31 |
dpi: DPI for image conversion (higher = better quality)
|
| 32 |
output_format: Image format (RGB, L, etc.)
|
| 33 |
page_batch_size: Pages per batch for memory efficiency
|
| 34 |
-
|
| 35 |
Example:
|
| 36 |
>>> processor = PDFProcessor(dpi=140)
|
| 37 |
-
>>>
|
| 38 |
>>> # Convert single PDF
|
| 39 |
>>> images, texts = processor.process_pdf(Path("report.pdf"))
|
| 40 |
-
>>>
|
| 41 |
>>> # Stream large PDFs
|
| 42 |
>>> for images, texts in processor.stream_pdf(Path("large.pdf"), batch_size=10):
|
| 43 |
... # Process each batch
|
| 44 |
... pass
|
| 45 |
"""
|
| 46 |
-
|
| 47 |
def __init__(
|
| 48 |
self,
|
| 49 |
dpi: int = 140,
|
|
@@ -53,17 +53,24 @@ class PDFProcessor:
|
|
| 53 |
self.dpi = dpi
|
| 54 |
self.output_format = output_format
|
| 55 |
self.page_batch_size = page_batch_size
|
| 56 |
-
|
| 57 |
-
#
|
|
|
|
|
|
|
|
|
|
| 58 |
try:
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
except
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
raise ImportError(
|
| 63 |
-
"PDF processing requires pdf2image and pypdf.
|
| 64 |
-
|
| 65 |
)
|
| 66 |
-
|
| 67 |
def process_pdf(
|
| 68 |
self,
|
| 69 |
pdf_path: Path,
|
|
@@ -71,38 +78,39 @@ class PDFProcessor:
|
|
| 71 |
) -> Tuple[List[Image.Image], List[str]]:
|
| 72 |
"""
|
| 73 |
Convert PDF to images and extract text.
|
| 74 |
-
|
| 75 |
Args:
|
| 76 |
pdf_path: Path to PDF file
|
| 77 |
dpi: Override default DPI
|
| 78 |
-
|
| 79 |
Returns:
|
| 80 |
Tuple of (list of images, list of page texts)
|
| 81 |
"""
|
|
|
|
| 82 |
from pdf2image import convert_from_path
|
| 83 |
from pypdf import PdfReader
|
| 84 |
-
|
| 85 |
dpi = dpi or self.dpi
|
| 86 |
pdf_path = Path(pdf_path)
|
| 87 |
-
|
| 88 |
logger.info(f"📄 Processing PDF: {pdf_path.name}")
|
| 89 |
-
|
| 90 |
# Extract text
|
| 91 |
reader = PdfReader(str(pdf_path))
|
| 92 |
total_pages = len(reader.pages)
|
| 93 |
-
|
| 94 |
page_texts = []
|
| 95 |
for page in reader.pages:
|
| 96 |
text = page.extract_text() or ""
|
| 97 |
# Handle surrogate characters
|
| 98 |
text = self._sanitize_text(text)
|
| 99 |
page_texts.append(text)
|
| 100 |
-
|
| 101 |
# Convert to images in batches
|
| 102 |
all_images = []
|
| 103 |
for start_page in range(1, total_pages + 1, self.page_batch_size):
|
| 104 |
end_page = min(start_page + self.page_batch_size - 1, total_pages)
|
| 105 |
-
|
| 106 |
batch_images = convert_from_path(
|
| 107 |
str(pdf_path),
|
| 108 |
dpi=dpi,
|
|
@@ -110,19 +118,19 @@ class PDFProcessor:
|
|
| 110 |
first_page=start_page,
|
| 111 |
last_page=end_page,
|
| 112 |
)
|
| 113 |
-
|
| 114 |
all_images.extend(batch_images)
|
| 115 |
-
|
| 116 |
del batch_images
|
| 117 |
gc.collect()
|
| 118 |
-
|
| 119 |
-
assert len(all_images) == len(
|
| 120 |
-
|
| 121 |
-
)
|
| 122 |
-
|
| 123 |
logger.info(f"✅ Processed {len(all_images)} pages")
|
| 124 |
return all_images, page_texts
|
| 125 |
-
|
| 126 |
def stream_pdf(
|
| 127 |
self,
|
| 128 |
pdf_path: Path,
|
|
@@ -131,39 +139,40 @@ class PDFProcessor:
|
|
| 131 |
) -> Generator[Tuple[List[Image.Image], List[str], int], None, None]:
|
| 132 |
"""
|
| 133 |
Stream PDF processing for large files.
|
| 134 |
-
|
| 135 |
Yields batches of (images, texts, start_page) without loading
|
| 136 |
entire PDF into memory.
|
| 137 |
-
|
| 138 |
Args:
|
| 139 |
pdf_path: Path to PDF file
|
| 140 |
batch_size: Pages per batch
|
| 141 |
dpi: Override default DPI
|
| 142 |
-
|
| 143 |
Yields:
|
| 144 |
Tuple of (batch_images, batch_texts, start_page_number)
|
| 145 |
"""
|
|
|
|
| 146 |
from pdf2image import convert_from_path
|
| 147 |
from pypdf import PdfReader
|
| 148 |
-
|
| 149 |
dpi = dpi or self.dpi
|
| 150 |
pdf_path = Path(pdf_path)
|
| 151 |
-
|
| 152 |
reader = PdfReader(str(pdf_path))
|
| 153 |
total_pages = len(reader.pages)
|
| 154 |
-
|
| 155 |
logger.info(f"📄 Streaming PDF: {pdf_path.name} ({total_pages} pages)")
|
| 156 |
-
|
| 157 |
for start_idx in range(0, total_pages, batch_size):
|
| 158 |
end_idx = min(start_idx + batch_size, total_pages)
|
| 159 |
-
|
| 160 |
# Extract text for batch
|
| 161 |
batch_texts = []
|
| 162 |
for page_idx in range(start_idx, end_idx):
|
| 163 |
text = reader.pages[page_idx].extract_text() or ""
|
| 164 |
text = self._sanitize_text(text)
|
| 165 |
batch_texts.append(text)
|
| 166 |
-
|
| 167 |
# Convert images for batch
|
| 168 |
batch_images = convert_from_path(
|
| 169 |
str(pdf_path),
|
|
@@ -172,18 +181,20 @@ class PDFProcessor:
|
|
| 172 |
first_page=start_idx + 1, # 1-indexed
|
| 173 |
last_page=end_idx,
|
| 174 |
)
|
| 175 |
-
|
| 176 |
yield batch_images, batch_texts, start_idx + 1
|
| 177 |
-
|
| 178 |
del batch_images
|
| 179 |
gc.collect()
|
| 180 |
-
|
| 181 |
def get_page_count(self, pdf_path: Path) -> int:
|
| 182 |
"""Get number of pages in PDF without loading images."""
|
|
|
|
| 183 |
from pypdf import PdfReader
|
|
|
|
| 184 |
reader = PdfReader(str(pdf_path))
|
| 185 |
return len(reader.pages)
|
| 186 |
-
|
| 187 |
def resize_for_colpali(
|
| 188 |
self,
|
| 189 |
image: Image.Image,
|
|
@@ -192,19 +203,23 @@ class PDFProcessor:
|
|
| 192 |
) -> Tuple[Image.Image, int, int]:
|
| 193 |
"""
|
| 194 |
Resize image following ColPali/Idefics3 processor logic.
|
| 195 |
-
|
| 196 |
Resizes to fit within tile grid without black padding.
|
| 197 |
-
|
| 198 |
Args:
|
| 199 |
image: PIL Image
|
| 200 |
max_edge: Maximum edge length
|
| 201 |
tile_size: Size of each tile
|
| 202 |
-
|
| 203 |
Returns:
|
| 204 |
Tuple of (resized_image, tile_rows, tile_cols)
|
| 205 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
w, h = image.size
|
| 207 |
-
|
| 208 |
# Step 1: Resize so longest edge = max_edge
|
| 209 |
if w > h:
|
| 210 |
new_w = max_edge
|
|
@@ -212,25 +227,25 @@ class PDFProcessor:
|
|
| 212 |
else:
|
| 213 |
new_h = max_edge
|
| 214 |
new_w = int(w * (max_edge / h))
|
| 215 |
-
|
| 216 |
# Step 2: Calculate tile grid
|
| 217 |
tile_cols = (new_w + tile_size - 1) // tile_size
|
| 218 |
tile_rows = (new_h + tile_size - 1) // tile_size
|
| 219 |
-
|
| 220 |
# Step 3: Calculate exact dimensions for tiles
|
| 221 |
final_w = tile_cols * tile_size
|
| 222 |
final_h = tile_rows * tile_size
|
| 223 |
-
|
| 224 |
# Step 4: Scale to fit within tile grid
|
| 225 |
scale_w = final_w / w
|
| 226 |
scale_h = final_h / h
|
| 227 |
scale = min(scale_w, scale_h)
|
| 228 |
-
|
| 229 |
scaled_w = int(w * scale)
|
| 230 |
scaled_h = int(h * scale)
|
| 231 |
-
|
| 232 |
resized = image.resize((scaled_w, scaled_h), Image.LANCZOS)
|
| 233 |
-
|
| 234 |
# Center on white canvas if needed
|
| 235 |
if scaled_w != final_w or scaled_h != final_h:
|
| 236 |
canvas = Image.new("RGB", (final_w, final_h), (255, 255, 255))
|
|
@@ -238,19 +253,17 @@ class PDFProcessor:
|
|
| 238 |
offset_y = (final_h - scaled_h) // 2
|
| 239 |
canvas.paste(resized, (offset_x, offset_y))
|
| 240 |
resized = canvas
|
| 241 |
-
|
| 242 |
return resized, tile_rows, tile_cols
|
| 243 |
-
|
| 244 |
def _sanitize_text(self, text: str) -> str:
|
| 245 |
"""Remove invalid Unicode characters (surrogates) from text."""
|
| 246 |
if not text:
|
| 247 |
return ""
|
| 248 |
-
|
| 249 |
# Remove surrogate characters (U+D800-U+DFFF)
|
| 250 |
-
return text.encode("utf-8", errors="surrogatepass").decode(
|
| 251 |
-
|
| 252 |
-
)
|
| 253 |
-
|
| 254 |
def extract_metadata_from_filename(
|
| 255 |
self,
|
| 256 |
filename: str,
|
|
@@ -258,47 +271,45 @@ class PDFProcessor:
|
|
| 258 |
) -> Dict[str, Any]:
|
| 259 |
"""
|
| 260 |
Extract metadata from PDF filename.
|
| 261 |
-
|
| 262 |
Uses mapping if provided, otherwise falls back to pattern matching.
|
| 263 |
-
|
| 264 |
Args:
|
| 265 |
filename: PDF filename (with or without .pdf extension)
|
| 266 |
mapping: Optional mapping dict {filename: metadata}
|
| 267 |
-
|
| 268 |
Returns:
|
| 269 |
Metadata dict with year, source, district, etc.
|
| 270 |
"""
|
| 271 |
# Remove extension
|
| 272 |
stem = Path(filename).stem
|
| 273 |
stem_lower = stem.lower().strip()
|
| 274 |
-
|
| 275 |
# Try mapping first
|
| 276 |
if mapping:
|
| 277 |
if stem_lower in mapping:
|
| 278 |
return mapping[stem_lower].copy()
|
| 279 |
-
|
| 280 |
# Try without .pdf
|
| 281 |
stem_no_ext = stem_lower.replace(".pdf", "")
|
| 282 |
if stem_no_ext in mapping:
|
| 283 |
return mapping[stem_no_ext].copy()
|
| 284 |
-
|
| 285 |
# Fallback: pattern matching
|
| 286 |
metadata = {"filename": filename}
|
| 287 |
-
|
| 288 |
# Extract year
|
| 289 |
year_match = re.search(r"(20\d{2})", stem)
|
| 290 |
if year_match:
|
| 291 |
metadata["year"] = int(year_match.group(1))
|
| 292 |
-
|
| 293 |
# Detect source type
|
| 294 |
if "consolidated" in stem_lower or ("annual" in stem_lower and "oag" in stem_lower):
|
| 295 |
metadata["source"] = "Consolidated"
|
| 296 |
elif "dlg" in stem_lower or "district local government" in stem_lower:
|
| 297 |
metadata["source"] = "Local Government"
|
| 298 |
# Try to extract district name
|
| 299 |
-
district_match = re.search(
|
| 300 |
-
r"([a-z]+)\s+(?:dlg|district local government)", stem_lower
|
| 301 |
-
)
|
| 302 |
if district_match:
|
| 303 |
metadata["district"] = district_match.group(1).title()
|
| 304 |
elif "hospital" in stem_lower or "referral" in stem_lower:
|
|
@@ -309,7 +320,5 @@ class PDFProcessor:
|
|
| 309 |
metadata["source"] = "Project"
|
| 310 |
else:
|
| 311 |
metadata["source"] = "Unknown"
|
| 312 |
-
|
| 313 |
-
return metadata
|
| 314 |
-
|
| 315 |
|
|
|
|
|
|
| 11 |
"""
|
| 12 |
|
| 13 |
import gc
|
|
|
|
| 14 |
import logging
|
| 15 |
+
import re
|
| 16 |
from pathlib import Path
|
| 17 |
+
from typing import Any, Dict, Generator, List, Optional, Tuple
|
| 18 |
|
| 19 |
from PIL import Image
|
| 20 |
|
|
|
|
| 24 |
class PDFProcessor:
|
| 25 |
"""
|
| 26 |
Process PDFs into images and text for visual retrieval.
|
| 27 |
+
|
| 28 |
Works independently - no embedding or storage dependencies.
|
| 29 |
+
|
| 30 |
Args:
|
| 31 |
dpi: DPI for image conversion (higher = better quality)
|
| 32 |
output_format: Image format (RGB, L, etc.)
|
| 33 |
page_batch_size: Pages per batch for memory efficiency
|
| 34 |
+
|
| 35 |
Example:
|
| 36 |
>>> processor = PDFProcessor(dpi=140)
|
| 37 |
+
>>>
|
| 38 |
>>> # Convert single PDF
|
| 39 |
>>> images, texts = processor.process_pdf(Path("report.pdf"))
|
| 40 |
+
>>>
|
| 41 |
>>> # Stream large PDFs
|
| 42 |
>>> for images, texts in processor.stream_pdf(Path("large.pdf"), batch_size=10):
|
| 43 |
... # Process each batch
|
| 44 |
... pass
|
| 45 |
"""
|
| 46 |
+
|
| 47 |
def __init__(
|
| 48 |
self,
|
| 49 |
dpi: int = 140,
|
|
|
|
| 53 |
self.dpi = dpi
|
| 54 |
self.output_format = output_format
|
| 55 |
self.page_batch_size = page_batch_size
|
| 56 |
+
|
| 57 |
+
# PDF deps are optional: we only require them when calling PDF-specific methods.
|
| 58 |
+
# This keeps the class usable for helper utilities like `resize_for_colpali()`
|
| 59 |
+
# even in minimal installs.
|
| 60 |
+
self._pdf_deps_available = True
|
| 61 |
try:
|
| 62 |
+
import pdf2image # noqa: F401
|
| 63 |
+
import pypdf # noqa: F401
|
| 64 |
+
except Exception:
|
| 65 |
+
self._pdf_deps_available = False
|
| 66 |
+
|
| 67 |
+
def _require_pdf_deps(self) -> None:
|
| 68 |
+
if not self._pdf_deps_available:
|
| 69 |
raise ImportError(
|
| 70 |
+
"PDF processing requires `pdf2image` and `pypdf`.\n"
|
| 71 |
+
'Install with: pip install "visual-rag-toolkit[pdf]"'
|
| 72 |
)
|
| 73 |
+
|
| 74 |
def process_pdf(
|
| 75 |
self,
|
| 76 |
pdf_path: Path,
|
|
|
|
| 78 |
) -> Tuple[List[Image.Image], List[str]]:
|
| 79 |
"""
|
| 80 |
Convert PDF to images and extract text.
|
| 81 |
+
|
| 82 |
Args:
|
| 83 |
pdf_path: Path to PDF file
|
| 84 |
dpi: Override default DPI
|
| 85 |
+
|
| 86 |
Returns:
|
| 87 |
Tuple of (list of images, list of page texts)
|
| 88 |
"""
|
| 89 |
+
self._require_pdf_deps()
|
| 90 |
from pdf2image import convert_from_path
|
| 91 |
from pypdf import PdfReader
|
| 92 |
+
|
| 93 |
dpi = dpi or self.dpi
|
| 94 |
pdf_path = Path(pdf_path)
|
| 95 |
+
|
| 96 |
logger.info(f"📄 Processing PDF: {pdf_path.name}")
|
| 97 |
+
|
| 98 |
# Extract text
|
| 99 |
reader = PdfReader(str(pdf_path))
|
| 100 |
total_pages = len(reader.pages)
|
| 101 |
+
|
| 102 |
page_texts = []
|
| 103 |
for page in reader.pages:
|
| 104 |
text = page.extract_text() or ""
|
| 105 |
# Handle surrogate characters
|
| 106 |
text = self._sanitize_text(text)
|
| 107 |
page_texts.append(text)
|
| 108 |
+
|
| 109 |
# Convert to images in batches
|
| 110 |
all_images = []
|
| 111 |
for start_page in range(1, total_pages + 1, self.page_batch_size):
|
| 112 |
end_page = min(start_page + self.page_batch_size - 1, total_pages)
|
| 113 |
+
|
| 114 |
batch_images = convert_from_path(
|
| 115 |
str(pdf_path),
|
| 116 |
dpi=dpi,
|
|
|
|
| 118 |
first_page=start_page,
|
| 119 |
last_page=end_page,
|
| 120 |
)
|
| 121 |
+
|
| 122 |
all_images.extend(batch_images)
|
| 123 |
+
|
| 124 |
del batch_images
|
| 125 |
gc.collect()
|
| 126 |
+
|
| 127 |
+
assert len(all_images) == len(
|
| 128 |
+
page_texts
|
| 129 |
+
), f"Mismatch: {len(all_images)} images vs {len(page_texts)} texts"
|
| 130 |
+
|
| 131 |
logger.info(f"✅ Processed {len(all_images)} pages")
|
| 132 |
return all_images, page_texts
|
| 133 |
+
|
| 134 |
def stream_pdf(
|
| 135 |
self,
|
| 136 |
pdf_path: Path,
|
|
|
|
| 139 |
) -> Generator[Tuple[List[Image.Image], List[str], int], None, None]:
|
| 140 |
"""
|
| 141 |
Stream PDF processing for large files.
|
| 142 |
+
|
| 143 |
Yields batches of (images, texts, start_page) without loading
|
| 144 |
entire PDF into memory.
|
| 145 |
+
|
| 146 |
Args:
|
| 147 |
pdf_path: Path to PDF file
|
| 148 |
batch_size: Pages per batch
|
| 149 |
dpi: Override default DPI
|
| 150 |
+
|
| 151 |
Yields:
|
| 152 |
Tuple of (batch_images, batch_texts, start_page_number)
|
| 153 |
"""
|
| 154 |
+
self._require_pdf_deps()
|
| 155 |
from pdf2image import convert_from_path
|
| 156 |
from pypdf import PdfReader
|
| 157 |
+
|
| 158 |
dpi = dpi or self.dpi
|
| 159 |
pdf_path = Path(pdf_path)
|
| 160 |
+
|
| 161 |
reader = PdfReader(str(pdf_path))
|
| 162 |
total_pages = len(reader.pages)
|
| 163 |
+
|
| 164 |
logger.info(f"📄 Streaming PDF: {pdf_path.name} ({total_pages} pages)")
|
| 165 |
+
|
| 166 |
for start_idx in range(0, total_pages, batch_size):
|
| 167 |
end_idx = min(start_idx + batch_size, total_pages)
|
| 168 |
+
|
| 169 |
# Extract text for batch
|
| 170 |
batch_texts = []
|
| 171 |
for page_idx in range(start_idx, end_idx):
|
| 172 |
text = reader.pages[page_idx].extract_text() or ""
|
| 173 |
text = self._sanitize_text(text)
|
| 174 |
batch_texts.append(text)
|
| 175 |
+
|
| 176 |
# Convert images for batch
|
| 177 |
batch_images = convert_from_path(
|
| 178 |
str(pdf_path),
|
|
|
|
| 181 |
first_page=start_idx + 1, # 1-indexed
|
| 182 |
last_page=end_idx,
|
| 183 |
)
|
| 184 |
+
|
| 185 |
yield batch_images, batch_texts, start_idx + 1
|
| 186 |
+
|
| 187 |
del batch_images
|
| 188 |
gc.collect()
|
| 189 |
+
|
| 190 |
def get_page_count(self, pdf_path: Path) -> int:
|
| 191 |
"""Get number of pages in PDF without loading images."""
|
| 192 |
+
self._require_pdf_deps()
|
| 193 |
from pypdf import PdfReader
|
| 194 |
+
|
| 195 |
reader = PdfReader(str(pdf_path))
|
| 196 |
return len(reader.pages)
|
| 197 |
+
|
| 198 |
def resize_for_colpali(
|
| 199 |
self,
|
| 200 |
image: Image.Image,
|
|
|
|
| 203 |
) -> Tuple[Image.Image, int, int]:
|
| 204 |
"""
|
| 205 |
Resize image following ColPali/Idefics3 processor logic.
|
| 206 |
+
|
| 207 |
Resizes to fit within tile grid without black padding.
|
| 208 |
+
|
| 209 |
Args:
|
| 210 |
image: PIL Image
|
| 211 |
max_edge: Maximum edge length
|
| 212 |
tile_size: Size of each tile
|
| 213 |
+
|
| 214 |
Returns:
|
| 215 |
Tuple of (resized_image, tile_rows, tile_cols)
|
| 216 |
"""
|
| 217 |
+
# Ensure consistent mode for downstream processors (and predictable tests)
|
| 218 |
+
if image.mode != "RGB":
|
| 219 |
+
image = image.convert("RGB")
|
| 220 |
+
|
| 221 |
w, h = image.size
|
| 222 |
+
|
| 223 |
# Step 1: Resize so longest edge = max_edge
|
| 224 |
if w > h:
|
| 225 |
new_w = max_edge
|
|
|
|
| 227 |
else:
|
| 228 |
new_h = max_edge
|
| 229 |
new_w = int(w * (max_edge / h))
|
| 230 |
+
|
| 231 |
# Step 2: Calculate tile grid
|
| 232 |
tile_cols = (new_w + tile_size - 1) // tile_size
|
| 233 |
tile_rows = (new_h + tile_size - 1) // tile_size
|
| 234 |
+
|
| 235 |
# Step 3: Calculate exact dimensions for tiles
|
| 236 |
final_w = tile_cols * tile_size
|
| 237 |
final_h = tile_rows * tile_size
|
| 238 |
+
|
| 239 |
# Step 4: Scale to fit within tile grid
|
| 240 |
scale_w = final_w / w
|
| 241 |
scale_h = final_h / h
|
| 242 |
scale = min(scale_w, scale_h)
|
| 243 |
+
|
| 244 |
scaled_w = int(w * scale)
|
| 245 |
scaled_h = int(h * scale)
|
| 246 |
+
|
| 247 |
resized = image.resize((scaled_w, scaled_h), Image.LANCZOS)
|
| 248 |
+
|
| 249 |
# Center on white canvas if needed
|
| 250 |
if scaled_w != final_w or scaled_h != final_h:
|
| 251 |
canvas = Image.new("RGB", (final_w, final_h), (255, 255, 255))
|
|
|
|
| 253 |
offset_y = (final_h - scaled_h) // 2
|
| 254 |
canvas.paste(resized, (offset_x, offset_y))
|
| 255 |
resized = canvas
|
| 256 |
+
|
| 257 |
return resized, tile_rows, tile_cols
|
| 258 |
+
|
| 259 |
def _sanitize_text(self, text: str) -> str:
|
| 260 |
"""Remove invalid Unicode characters (surrogates) from text."""
|
| 261 |
if not text:
|
| 262 |
return ""
|
| 263 |
+
|
| 264 |
# Remove surrogate characters (U+D800-U+DFFF)
|
| 265 |
+
return text.encode("utf-8", errors="surrogatepass").decode("utf-8", errors="ignore")
|
| 266 |
+
|
|
|
|
|
|
|
| 267 |
def extract_metadata_from_filename(
|
| 268 |
self,
|
| 269 |
filename: str,
|
|
|
|
| 271 |
) -> Dict[str, Any]:
|
| 272 |
"""
|
| 273 |
Extract metadata from PDF filename.
|
| 274 |
+
|
| 275 |
Uses mapping if provided, otherwise falls back to pattern matching.
|
| 276 |
+
|
| 277 |
Args:
|
| 278 |
filename: PDF filename (with or without .pdf extension)
|
| 279 |
mapping: Optional mapping dict {filename: metadata}
|
| 280 |
+
|
| 281 |
Returns:
|
| 282 |
Metadata dict with year, source, district, etc.
|
| 283 |
"""
|
| 284 |
# Remove extension
|
| 285 |
stem = Path(filename).stem
|
| 286 |
stem_lower = stem.lower().strip()
|
| 287 |
+
|
| 288 |
# Try mapping first
|
| 289 |
if mapping:
|
| 290 |
if stem_lower in mapping:
|
| 291 |
return mapping[stem_lower].copy()
|
| 292 |
+
|
| 293 |
# Try without .pdf
|
| 294 |
stem_no_ext = stem_lower.replace(".pdf", "")
|
| 295 |
if stem_no_ext in mapping:
|
| 296 |
return mapping[stem_no_ext].copy()
|
| 297 |
+
|
| 298 |
# Fallback: pattern matching
|
| 299 |
metadata = {"filename": filename}
|
| 300 |
+
|
| 301 |
# Extract year
|
| 302 |
year_match = re.search(r"(20\d{2})", stem)
|
| 303 |
if year_match:
|
| 304 |
metadata["year"] = int(year_match.group(1))
|
| 305 |
+
|
| 306 |
# Detect source type
|
| 307 |
if "consolidated" in stem_lower or ("annual" in stem_lower and "oag" in stem_lower):
|
| 308 |
metadata["source"] = "Consolidated"
|
| 309 |
elif "dlg" in stem_lower or "district local government" in stem_lower:
|
| 310 |
metadata["source"] = "Local Government"
|
| 311 |
# Try to extract district name
|
| 312 |
+
district_match = re.search(r"([a-z]+)\s+(?:dlg|district local government)", stem_lower)
|
|
|
|
|
|
|
| 313 |
if district_match:
|
| 314 |
metadata["district"] = district_match.group(1).title()
|
| 315 |
elif "hospital" in stem_lower or "referral" in stem_lower:
|
|
|
|
| 320 |
metadata["source"] = "Project"
|
| 321 |
else:
|
| 322 |
metadata["source"] = "Unknown"
|
|
|
|
|
|
|
|
|
|
| 323 |
|
| 324 |
+
return metadata
|
visual_rag/indexing/pipeline.py
CHANGED
|
@@ -16,11 +16,10 @@ The metadata stored includes everything needed for saliency visualization:
|
|
| 16 |
"""
|
| 17 |
|
| 18 |
import gc
|
| 19 |
-
import time
|
| 20 |
import hashlib
|
| 21 |
import logging
|
| 22 |
from pathlib import Path
|
| 23 |
-
from typing import
|
| 24 |
|
| 25 |
import numpy as np
|
| 26 |
import torch
|
|
@@ -31,7 +30,7 @@ logger = logging.getLogger(__name__)
|
|
| 31 |
class ProcessingPipeline:
|
| 32 |
"""
|
| 33 |
End-to-end pipeline for PDF processing and indexing.
|
| 34 |
-
|
| 35 |
This pipeline:
|
| 36 |
1. Converts PDFs to images
|
| 37 |
2. Resizes for ColPali processing
|
|
@@ -39,7 +38,7 @@ class ProcessingPipeline:
|
|
| 39 |
4. Computes pooling (strategy-dependent)
|
| 40 |
5. Uploads images to Cloudinary (optional)
|
| 41 |
6. Stores in Qdrant with full saliency metadata
|
| 42 |
-
|
| 43 |
Args:
|
| 44 |
embedder: VisualEmbedder instance
|
| 45 |
indexer: QdrantIndexer instance (optional)
|
|
@@ -52,34 +51,34 @@ class ProcessingPipeline:
|
|
| 52 |
This is our NOVEL contribution - preserves spatial structure while reducing size.
|
| 53 |
- "standard": Push ALL tokens as-is (including special tokens, padding)
|
| 54 |
This is the baseline approach for comparison.
|
| 55 |
-
|
| 56 |
Example:
|
| 57 |
>>> from visual_rag import VisualEmbedder, QdrantIndexer, CloudinaryUploader
|
| 58 |
>>> from visual_rag.indexing.pipeline import ProcessingPipeline
|
| 59 |
-
>>>
|
| 60 |
>>> # Our novel pooling strategy (default)
|
| 61 |
>>> pipeline = ProcessingPipeline(
|
| 62 |
... embedder=VisualEmbedder(),
|
| 63 |
... indexer=QdrantIndexer(url, api_key, "my_collection"),
|
| 64 |
... embedding_strategy="pooling", # Visual tokens only + tile pooling
|
| 65 |
... )
|
| 66 |
-
>>>
|
| 67 |
>>> # Standard baseline (all tokens, no filtering)
|
| 68 |
>>> pipeline_baseline = ProcessingPipeline(
|
| 69 |
... embedder=VisualEmbedder(),
|
| 70 |
... indexer=QdrantIndexer(url, api_key, "my_collection_baseline"),
|
| 71 |
... embedding_strategy="standard", # All tokens as-is
|
| 72 |
... )
|
| 73 |
-
>>>
|
| 74 |
>>> pipeline.process_pdf(Path("report.pdf"))
|
| 75 |
"""
|
| 76 |
-
|
| 77 |
# Valid embedding strategies
|
| 78 |
# - "pooling": Visual tokens only + tile-level pooling (NOVEL)
|
| 79 |
# - "standard": All tokens + global mean (BASELINE)
|
| 80 |
# - "all": Embed once, push BOTH representations (efficient comparison)
|
| 81 |
STRATEGIES = ["pooling", "standard", "all"]
|
| 82 |
-
|
| 83 |
def __init__(
|
| 84 |
self,
|
| 85 |
embedder=None,
|
|
@@ -100,7 +99,7 @@ class ProcessingPipeline:
|
|
| 100 |
self.cloudinary_uploader = cloudinary_uploader
|
| 101 |
self.metadata_mapping = metadata_mapping or {}
|
| 102 |
self.config = config or {}
|
| 103 |
-
|
| 104 |
# Validate and set embedding strategy
|
| 105 |
if embedding_strategy not in self.STRATEGIES:
|
| 106 |
raise ValueError(
|
|
@@ -113,26 +112,29 @@ class ProcessingPipeline:
|
|
| 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(
|
| 117 |
-
|
|
|
|
|
|
|
| 118 |
logger.info(f"📊 Embedding strategy: {embedding_strategy}")
|
| 119 |
if embedding_strategy == "pooling":
|
| 120 |
logger.info(" → Visual tokens only + tile-level mean pooling (NOVEL)")
|
| 121 |
else:
|
| 122 |
logger.info(" → All tokens as-is (BASELINE)")
|
| 123 |
-
|
| 124 |
# Create PDF processor if not provided
|
| 125 |
if pdf_processor is None:
|
| 126 |
from visual_rag.indexing.pdf_processor import PDFProcessor
|
|
|
|
| 127 |
dpi = self.config.get("processing", {}).get("dpi", 140)
|
| 128 |
pdf_processor = PDFProcessor(dpi=dpi)
|
| 129 |
self.pdf_processor = pdf_processor
|
| 130 |
-
|
| 131 |
# Config defaults
|
| 132 |
self.embedding_batch_size = self.config.get("batching", {}).get("embedding_batch_size", 8)
|
| 133 |
self.upload_batch_size = self.config.get("batching", {}).get("upload_batch_size", 8)
|
| 134 |
self.delay_between_uploads = self.config.get("delays", {}).get("between_uploads", 0.5)
|
| 135 |
-
|
| 136 |
def process_pdf(
|
| 137 |
self,
|
| 138 |
pdf_path: Path,
|
|
@@ -144,7 +146,7 @@ class ProcessingPipeline:
|
|
| 144 |
) -> Dict[str, Any]:
|
| 145 |
"""
|
| 146 |
Process a single PDF end-to-end.
|
| 147 |
-
|
| 148 |
Args:
|
| 149 |
pdf_path: Path to PDF file
|
| 150 |
skip_existing: Skip pages that already exist in Qdrant
|
|
@@ -152,7 +154,7 @@ class ProcessingPipeline:
|
|
| 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:
|
| 158 |
{
|
|
@@ -167,15 +169,15 @@ class ProcessingPipeline:
|
|
| 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(
|
| 179 |
if progress_callback:
|
| 180 |
progress_callback("convert", 0, 0, "Converting PDF to images...")
|
| 181 |
images, texts = self.pdf_processor.process_pdf(pdf_path)
|
|
@@ -183,48 +185,55 @@ class ProcessingPipeline:
|
|
| 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 |
-
|
| 191 |
# Process in batches
|
| 192 |
uploaded = 0
|
| 193 |
skipped = 0
|
| 194 |
failed = 0
|
| 195 |
all_pages = []
|
| 196 |
upload_queue = []
|
| 197 |
-
|
| 198 |
for batch_start in range(0, total_pages, self.embedding_batch_size):
|
| 199 |
batch_end = min(batch_start + self.embedding_batch_size, total_pages)
|
| 200 |
batch_images = images[batch_start:batch_end]
|
| 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(
|
| 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
|
| 214 |
continue
|
| 215 |
-
|
| 216 |
-
pages_to_process.append(
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
|
|
|
|
|
|
| 224 |
if not pages_to_process:
|
| 225 |
logger.info(" All pages in batch exist, skipping...")
|
| 226 |
continue
|
| 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
|
|
@@ -239,7 +248,9 @@ class ProcessingPipeline:
|
|
| 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(
|
|
|
|
|
|
|
| 243 |
),
|
| 244 |
)
|
| 245 |
p["embed_image"] = cropped_img
|
|
@@ -249,14 +260,14 @@ class ProcessingPipeline:
|
|
| 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"]
|
|
@@ -266,10 +277,15 @@ class ProcessingPipeline:
|
|
| 266 |
text = page_info["text"]
|
| 267 |
embedding = embeddings[idx]
|
| 268 |
token_info = token_infos[idx]
|
| 269 |
-
|
| 270 |
if progress_callback:
|
| 271 |
-
progress_callback(
|
| 272 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
try:
|
| 274 |
page_data = self._process_single_page(
|
| 275 |
filename=filename,
|
|
@@ -286,34 +302,36 @@ class ProcessingPipeline:
|
|
| 286 |
upload_to_cloudinary=upload_to_cloudinary,
|
| 287 |
crop_meta=crop_meta,
|
| 288 |
)
|
| 289 |
-
|
| 290 |
all_pages.append(page_data)
|
| 291 |
-
|
| 292 |
if upload_to_qdrant and self.indexer:
|
| 293 |
upload_queue.append(page_data)
|
| 294 |
-
|
| 295 |
# Upload in batches
|
| 296 |
if len(upload_queue) >= self.upload_batch_size:
|
| 297 |
count = self._upload_batch(upload_queue)
|
| 298 |
uploaded += count
|
| 299 |
upload_queue = []
|
| 300 |
-
|
| 301 |
except Exception as e:
|
| 302 |
logger.error(f" ❌ Failed page {page_num}: {e}")
|
| 303 |
failed += 1
|
| 304 |
-
|
| 305 |
# Memory cleanup
|
| 306 |
gc.collect()
|
| 307 |
if torch.cuda.is_available():
|
| 308 |
torch.cuda.empty_cache()
|
| 309 |
-
|
| 310 |
# Upload remaining pages
|
| 311 |
if upload_queue and upload_to_qdrant and self.indexer:
|
| 312 |
count = self._upload_batch(upload_queue)
|
| 313 |
uploaded += count
|
| 314 |
-
|
| 315 |
-
logger.info(
|
| 316 |
-
|
|
|
|
|
|
|
| 317 |
return {
|
| 318 |
"filename": filename,
|
| 319 |
"total_pages": total_pages,
|
|
@@ -322,7 +340,7 @@ class ProcessingPipeline:
|
|
| 322 |
"failed": failed,
|
| 323 |
"pages": all_pages,
|
| 324 |
}
|
| 325 |
-
|
| 326 |
def _process_single_page(
|
| 327 |
self,
|
| 328 |
filename: str,
|
|
@@ -341,17 +359,17 @@ class ProcessingPipeline:
|
|
| 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")
|
| 350 |
proc_n_cols = token_info.get("n_cols")
|
| 351 |
if proc_n_rows and proc_n_cols:
|
| 352 |
tile_rows = proc_n_rows
|
| 353 |
tile_cols = proc_n_cols
|
| 354 |
-
|
| 355 |
# Convert embedding to numpy
|
| 356 |
if isinstance(embedding, torch.Tensor):
|
| 357 |
if embedding.dtype == torch.bfloat16:
|
|
@@ -361,24 +379,30 @@ class ProcessingPipeline:
|
|
| 361 |
else:
|
| 362 |
full_embedding = np.array(embedding)
|
| 363 |
full_embedding = full_embedding.astype(np.float32)
|
| 364 |
-
|
| 365 |
# Token info for metadata
|
| 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 |
# =========================================================================
|
| 372 |
-
|
| 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(
|
|
|
|
|
|
|
| 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 =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
@@ -387,62 +411,70 @@ class ProcessingPipeline:
|
|
| 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 =
|
| 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
|
| 408 |
-
global_pooling =
|
| 409 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 410 |
# ALSO store standard representations as additional vectors
|
| 411 |
# These will be added to metadata for optional use
|
| 412 |
pass # Extra vectors handled in return dict below
|
| 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 =
|
| 423 |
-
|
|
|
|
|
|
|
| 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()),
|
| 438 |
"text": safe_text,
|
| 439 |
-
|
| 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,
|
|
@@ -450,37 +482,33 @@ class ProcessingPipeline:
|
|
| 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 |
-
|
| 454 |
# Tile structure (needed for saliency)
|
| 455 |
"num_tiles": num_tiles,
|
| 456 |
"tile_rows": tile_rows,
|
| 457 |
"tile_cols": tile_cols,
|
| 458 |
"patches_per_tile": patches_per_tile,
|
| 459 |
-
|
| 460 |
# Token info (needed for saliency)
|
| 461 |
"num_visual_tokens": num_visual_tokens,
|
| 462 |
"visual_token_indices": visual_indices,
|
| 463 |
"total_tokens": len(full_embedding), # Total tokens in raw embedding
|
| 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(
|
| 476 |
-
|
|
|
|
| 477 |
# Extra metadata (year, district, etc.)
|
| 478 |
**extra_metadata,
|
| 479 |
}
|
| 480 |
-
|
| 481 |
result = {
|
| 482 |
"id": chunk_id,
|
| 483 |
-
"visual_embedding": embedding_for_initial,
|
| 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
|
|
@@ -488,70 +516,70 @@ class ProcessingPipeline:
|
|
| 488 |
"image": raw_img,
|
| 489 |
"resized_image": resized_img,
|
| 490 |
}
|
| 491 |
-
|
| 492 |
# For "all" strategy, include BOTH representations for comparison
|
| 493 |
if self.embedding_strategy == "all":
|
| 494 |
result["extra_vectors"] = {
|
| 495 |
# Standard baseline vectors (for comparison)
|
| 496 |
-
"full_embedding": full_embedding,
|
| 497 |
-
"global_pooled": global_pooled,
|
| 498 |
# Pooling vectors (already in main result)
|
| 499 |
-
"visual_embedding": visual_embedding,
|
| 500 |
-
"tile_pooled": tile_pooled,
|
| 501 |
}
|
| 502 |
-
|
| 503 |
return result
|
| 504 |
-
|
| 505 |
def _upload_batch(self, upload_queue: List[Dict[str, Any]]) -> int:
|
| 506 |
"""Upload batch to Qdrant."""
|
| 507 |
if not upload_queue or not self.indexer:
|
| 508 |
return 0
|
| 509 |
-
|
| 510 |
logger.info(f"📤 Uploading batch of {len(upload_queue)} pages...")
|
| 511 |
-
|
| 512 |
count = self.indexer.upload_batch(
|
| 513 |
upload_queue,
|
| 514 |
delay_between_batches=self.delay_between_uploads,
|
| 515 |
)
|
| 516 |
-
|
| 517 |
return count
|
| 518 |
-
|
| 519 |
def _get_extra_metadata(self, filename: str) -> Dict[str, Any]:
|
| 520 |
"""Get extra metadata for a filename."""
|
| 521 |
if not self.metadata_mapping:
|
| 522 |
return {}
|
| 523 |
-
|
| 524 |
# Normalize filename
|
| 525 |
filename_clean = filename.replace(".pdf", "").replace(".PDF", "").strip().lower()
|
| 526 |
-
|
| 527 |
# Try exact match
|
| 528 |
if filename_clean in self.metadata_mapping:
|
| 529 |
return self.metadata_mapping[filename_clean].copy()
|
| 530 |
-
|
| 531 |
# Try fuzzy match
|
| 532 |
from difflib import SequenceMatcher
|
| 533 |
-
|
| 534 |
best_match = None
|
| 535 |
best_score = 0.0
|
| 536 |
-
|
| 537 |
for known_filename, metadata in self.metadata_mapping.items():
|
| 538 |
score = SequenceMatcher(None, filename_clean, known_filename.lower()).ratio()
|
| 539 |
if score > best_score and score > 0.75:
|
| 540 |
best_score = score
|
| 541 |
best_match = metadata
|
| 542 |
-
|
| 543 |
if best_match:
|
| 544 |
logger.debug(f"Fuzzy matched '{filename}' with score {best_score:.2f}")
|
| 545 |
return best_match.copy()
|
| 546 |
-
|
| 547 |
return {}
|
| 548 |
-
|
| 549 |
def _sanitize_text(self, text: str) -> str:
|
| 550 |
"""Remove invalid Unicode characters."""
|
| 551 |
if not text:
|
| 552 |
return ""
|
| 553 |
return text.encode("utf-8", errors="surrogatepass").decode("utf-8", errors="ignore")
|
| 554 |
-
|
| 555 |
@staticmethod
|
| 556 |
def generate_chunk_id(filename: str, page_number: int) -> str:
|
| 557 |
"""Generate deterministic chunk ID."""
|
|
@@ -559,12 +587,12 @@ class ProcessingPipeline:
|
|
| 559 |
hash_obj = hashlib.sha256(content.encode())
|
| 560 |
hex_str = hash_obj.hexdigest()[:32]
|
| 561 |
return f"{hex_str[:8]}-{hex_str[8:12]}-{hex_str[12:16]}-{hex_str[16:20]}-{hex_str[20:32]}"
|
| 562 |
-
|
| 563 |
@staticmethod
|
| 564 |
def load_metadata_mapping(json_path: Path) -> Dict[str, Dict[str, Any]]:
|
| 565 |
"""
|
| 566 |
Load metadata mapping from JSON file.
|
| 567 |
-
|
| 568 |
Expected format:
|
| 569 |
{
|
| 570 |
"filenames": {
|
|
@@ -572,7 +600,7 @@ class ProcessingPipeline:
|
|
| 572 |
...
|
| 573 |
}
|
| 574 |
}
|
| 575 |
-
|
| 576 |
Or simple format:
|
| 577 |
{
|
| 578 |
"Report Name 2023": {"year": 2023, "source": "Local Government", ...},
|
|
@@ -580,22 +608,21 @@ class ProcessingPipeline:
|
|
| 580 |
}
|
| 581 |
"""
|
| 582 |
import json
|
| 583 |
-
|
| 584 |
with open(json_path, "r") as f:
|
| 585 |
data = json.load(f)
|
| 586 |
-
|
| 587 |
# Check if nested under "filenames"
|
| 588 |
if "filenames" in data and isinstance(data["filenames"], dict):
|
| 589 |
mapping = data["filenames"]
|
| 590 |
else:
|
| 591 |
mapping = data
|
| 592 |
-
|
| 593 |
# Normalize keys to lowercase
|
| 594 |
normalized = {}
|
| 595 |
for filename, metadata in mapping.items():
|
| 596 |
key = filename.lower().strip().replace(".pdf", "")
|
| 597 |
normalized[key] = metadata
|
| 598 |
-
|
| 599 |
logger.info(f"📖 Loaded metadata for {len(normalized)} files")
|
| 600 |
return normalized
|
| 601 |
-
|
|
|
|
| 16 |
"""
|
| 17 |
|
| 18 |
import gc
|
|
|
|
| 19 |
import hashlib
|
| 20 |
import logging
|
| 21 |
from pathlib import Path
|
| 22 |
+
from typing import Any, Dict, List, Optional, Set
|
| 23 |
|
| 24 |
import numpy as np
|
| 25 |
import torch
|
|
|
|
| 30 |
class ProcessingPipeline:
|
| 31 |
"""
|
| 32 |
End-to-end pipeline for PDF processing and indexing.
|
| 33 |
+
|
| 34 |
This pipeline:
|
| 35 |
1. Converts PDFs to images
|
| 36 |
2. Resizes for ColPali processing
|
|
|
|
| 38 |
4. Computes pooling (strategy-dependent)
|
| 39 |
5. Uploads images to Cloudinary (optional)
|
| 40 |
6. Stores in Qdrant with full saliency metadata
|
| 41 |
+
|
| 42 |
Args:
|
| 43 |
embedder: VisualEmbedder instance
|
| 44 |
indexer: QdrantIndexer instance (optional)
|
|
|
|
| 51 |
This is our NOVEL contribution - preserves spatial structure while reducing size.
|
| 52 |
- "standard": Push ALL tokens as-is (including special tokens, padding)
|
| 53 |
This is the baseline approach for comparison.
|
| 54 |
+
|
| 55 |
Example:
|
| 56 |
>>> from visual_rag import VisualEmbedder, QdrantIndexer, CloudinaryUploader
|
| 57 |
>>> from visual_rag.indexing.pipeline import ProcessingPipeline
|
| 58 |
+
>>>
|
| 59 |
>>> # Our novel pooling strategy (default)
|
| 60 |
>>> pipeline = ProcessingPipeline(
|
| 61 |
... embedder=VisualEmbedder(),
|
| 62 |
... indexer=QdrantIndexer(url, api_key, "my_collection"),
|
| 63 |
... embedding_strategy="pooling", # Visual tokens only + tile pooling
|
| 64 |
... )
|
| 65 |
+
>>>
|
| 66 |
>>> # Standard baseline (all tokens, no filtering)
|
| 67 |
>>> pipeline_baseline = ProcessingPipeline(
|
| 68 |
... embedder=VisualEmbedder(),
|
| 69 |
... indexer=QdrantIndexer(url, api_key, "my_collection_baseline"),
|
| 70 |
... embedding_strategy="standard", # All tokens as-is
|
| 71 |
... )
|
| 72 |
+
>>>
|
| 73 |
>>> pipeline.process_pdf(Path("report.pdf"))
|
| 74 |
"""
|
| 75 |
+
|
| 76 |
# Valid embedding strategies
|
| 77 |
# - "pooling": Visual tokens only + tile-level pooling (NOVEL)
|
| 78 |
# - "standard": All tokens + global mean (BASELINE)
|
| 79 |
# - "all": Embed once, push BOTH representations (efficient comparison)
|
| 80 |
STRATEGIES = ["pooling", "standard", "all"]
|
| 81 |
+
|
| 82 |
def __init__(
|
| 83 |
self,
|
| 84 |
embedder=None,
|
|
|
|
| 99 |
self.cloudinary_uploader = cloudinary_uploader
|
| 100 |
self.metadata_mapping = metadata_mapping or {}
|
| 101 |
self.config = config or {}
|
| 102 |
+
|
| 103 |
# Validate and set embedding strategy
|
| 104 |
if embedding_strategy not in self.STRATEGIES:
|
| 105 |
raise ValueError(
|
|
|
|
| 112 |
self.crop_empty_percentage_to_remove = float(crop_empty_percentage_to_remove)
|
| 113 |
self.crop_empty_remove_page_number = bool(crop_empty_remove_page_number)
|
| 114 |
self.crop_empty_preserve_border_px = int(crop_empty_preserve_border_px)
|
| 115 |
+
self.crop_empty_uniform_rowcol_std_threshold = float(
|
| 116 |
+
crop_empty_uniform_rowcol_std_threshold
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
logger.info(f"📊 Embedding strategy: {embedding_strategy}")
|
| 120 |
if embedding_strategy == "pooling":
|
| 121 |
logger.info(" → Visual tokens only + tile-level mean pooling (NOVEL)")
|
| 122 |
else:
|
| 123 |
logger.info(" → All tokens as-is (BASELINE)")
|
| 124 |
+
|
| 125 |
# Create PDF processor if not provided
|
| 126 |
if pdf_processor is None:
|
| 127 |
from visual_rag.indexing.pdf_processor import PDFProcessor
|
| 128 |
+
|
| 129 |
dpi = self.config.get("processing", {}).get("dpi", 140)
|
| 130 |
pdf_processor = PDFProcessor(dpi=dpi)
|
| 131 |
self.pdf_processor = pdf_processor
|
| 132 |
+
|
| 133 |
# Config defaults
|
| 134 |
self.embedding_batch_size = self.config.get("batching", {}).get("embedding_batch_size", 8)
|
| 135 |
self.upload_batch_size = self.config.get("batching", {}).get("upload_batch_size", 8)
|
| 136 |
self.delay_between_uploads = self.config.get("delays", {}).get("between_uploads", 0.5)
|
| 137 |
+
|
| 138 |
def process_pdf(
|
| 139 |
self,
|
| 140 |
pdf_path: Path,
|
|
|
|
| 146 |
) -> Dict[str, Any]:
|
| 147 |
"""
|
| 148 |
Process a single PDF end-to-end.
|
| 149 |
+
|
| 150 |
Args:
|
| 151 |
pdf_path: Path to PDF file
|
| 152 |
skip_existing: Skip pages that already exist in Qdrant
|
|
|
|
| 154 |
upload_to_qdrant: Upload embeddings to Qdrant
|
| 155 |
original_filename: Original filename (use this instead of pdf_path.name for temp files)
|
| 156 |
progress_callback: Optional callback(stage, current, total, message) for progress updates
|
| 157 |
+
|
| 158 |
Returns:
|
| 159 |
Dict with processing results:
|
| 160 |
{
|
|
|
|
| 169 |
pdf_path = Path(pdf_path)
|
| 170 |
filename = original_filename or pdf_path.name
|
| 171 |
logger.info(f"📚 Processing PDF: {filename}")
|
| 172 |
+
|
| 173 |
# Check existing pages
|
| 174 |
existing_ids: Set[str] = set()
|
| 175 |
if skip_existing and self.indexer:
|
| 176 |
existing_ids = self.indexer.get_existing_ids(filename)
|
| 177 |
if existing_ids:
|
| 178 |
logger.info(f" Found {len(existing_ids)} existing pages")
|
| 179 |
+
|
| 180 |
+
logger.info("🖼️ Converting PDF to images...")
|
| 181 |
if progress_callback:
|
| 182 |
progress_callback("convert", 0, 0, "Converting PDF to images...")
|
| 183 |
images, texts = self.pdf_processor.process_pdf(pdf_path)
|
|
|
|
| 185 |
logger.info(f" ✅ Converted {total_pages} pages")
|
| 186 |
if progress_callback:
|
| 187 |
progress_callback("convert", total_pages, total_pages, f"Converted {total_pages} pages")
|
| 188 |
+
|
| 189 |
extra_metadata = self._get_extra_metadata(filename)
|
| 190 |
if extra_metadata:
|
| 191 |
logger.info(f" 📋 Found extra metadata: {list(extra_metadata.keys())}")
|
| 192 |
+
|
| 193 |
# Process in batches
|
| 194 |
uploaded = 0
|
| 195 |
skipped = 0
|
| 196 |
failed = 0
|
| 197 |
all_pages = []
|
| 198 |
upload_queue = []
|
| 199 |
+
|
| 200 |
for batch_start in range(0, total_pages, self.embedding_batch_size):
|
| 201 |
batch_end = min(batch_start + self.embedding_batch_size, total_pages)
|
| 202 |
batch_images = images[batch_start:batch_end]
|
| 203 |
batch_texts = texts[batch_start:batch_end]
|
| 204 |
+
|
| 205 |
logger.info(f"📦 Processing pages {batch_start + 1}-{batch_end}/{total_pages}")
|
| 206 |
if progress_callback:
|
| 207 |
+
progress_callback(
|
| 208 |
+
"embed",
|
| 209 |
+
batch_start,
|
| 210 |
+
total_pages,
|
| 211 |
+
f"Embedding pages {batch_start + 1}-{batch_end}",
|
| 212 |
+
)
|
| 213 |
+
|
| 214 |
pages_to_process = []
|
| 215 |
for i, (img, text) in enumerate(zip(batch_images, batch_texts)):
|
| 216 |
page_num = batch_start + i + 1
|
| 217 |
chunk_id = self.generate_chunk_id(filename, page_num)
|
| 218 |
+
|
| 219 |
if skip_existing and chunk_id in existing_ids:
|
| 220 |
skipped += 1
|
| 221 |
continue
|
| 222 |
+
|
| 223 |
+
pages_to_process.append(
|
| 224 |
+
{
|
| 225 |
+
"index": i,
|
| 226 |
+
"page_num": page_num,
|
| 227 |
+
"chunk_id": chunk_id,
|
| 228 |
+
"raw_image": img,
|
| 229 |
+
"text": text,
|
| 230 |
+
}
|
| 231 |
+
)
|
| 232 |
+
|
| 233 |
if not pages_to_process:
|
| 234 |
logger.info(" All pages in batch exist, skipping...")
|
| 235 |
continue
|
| 236 |
+
|
| 237 |
# Generate embeddings with token info
|
| 238 |
logger.info(f"🤖 Generating embeddings for {len(pages_to_process)} pages...")
|
| 239 |
from visual_rag.preprocessing.crop_empty import CropEmptyConfig, crop_empty
|
|
|
|
| 248 |
percentage_to_remove=float(self.crop_empty_percentage_to_remove),
|
| 249 |
remove_page_number=bool(self.crop_empty_remove_page_number),
|
| 250 |
preserve_border_px=int(self.crop_empty_preserve_border_px),
|
| 251 |
+
uniform_rowcol_std_threshold=float(
|
| 252 |
+
self.crop_empty_uniform_rowcol_std_threshold
|
| 253 |
+
),
|
| 254 |
),
|
| 255 |
)
|
| 256 |
p["embed_image"] = cropped_img
|
|
|
|
| 260 |
p["embed_image"] = raw_img
|
| 261 |
p["crop_meta"] = None
|
| 262 |
images_to_embed.append(raw_img)
|
| 263 |
+
|
| 264 |
embeddings, token_infos = self.embedder.embed_images(
|
| 265 |
images_to_embed,
|
| 266 |
batch_size=self.embedding_batch_size,
|
| 267 |
return_token_info=True,
|
| 268 |
show_progress=False,
|
| 269 |
)
|
| 270 |
+
|
| 271 |
for idx, page_info in enumerate(pages_to_process):
|
| 272 |
raw_img = page_info["raw_image"]
|
| 273 |
embed_img = page_info["embed_image"]
|
|
|
|
| 277 |
text = page_info["text"]
|
| 278 |
embedding = embeddings[idx]
|
| 279 |
token_info = token_infos[idx]
|
| 280 |
+
|
| 281 |
if progress_callback:
|
| 282 |
+
progress_callback(
|
| 283 |
+
"process",
|
| 284 |
+
page_num,
|
| 285 |
+
total_pages,
|
| 286 |
+
f"Processing page {page_num}/{total_pages}",
|
| 287 |
+
)
|
| 288 |
+
|
| 289 |
try:
|
| 290 |
page_data = self._process_single_page(
|
| 291 |
filename=filename,
|
|
|
|
| 302 |
upload_to_cloudinary=upload_to_cloudinary,
|
| 303 |
crop_meta=crop_meta,
|
| 304 |
)
|
| 305 |
+
|
| 306 |
all_pages.append(page_data)
|
| 307 |
+
|
| 308 |
if upload_to_qdrant and self.indexer:
|
| 309 |
upload_queue.append(page_data)
|
| 310 |
+
|
| 311 |
# Upload in batches
|
| 312 |
if len(upload_queue) >= self.upload_batch_size:
|
| 313 |
count = self._upload_batch(upload_queue)
|
| 314 |
uploaded += count
|
| 315 |
upload_queue = []
|
| 316 |
+
|
| 317 |
except Exception as e:
|
| 318 |
logger.error(f" ❌ Failed page {page_num}: {e}")
|
| 319 |
failed += 1
|
| 320 |
+
|
| 321 |
# Memory cleanup
|
| 322 |
gc.collect()
|
| 323 |
if torch.cuda.is_available():
|
| 324 |
torch.cuda.empty_cache()
|
| 325 |
+
|
| 326 |
# Upload remaining pages
|
| 327 |
if upload_queue and upload_to_qdrant and self.indexer:
|
| 328 |
count = self._upload_batch(upload_queue)
|
| 329 |
uploaded += count
|
| 330 |
+
|
| 331 |
+
logger.info(
|
| 332 |
+
f"✅ Completed {filename}: {uploaded} uploaded, {skipped} skipped, {failed} failed"
|
| 333 |
+
)
|
| 334 |
+
|
| 335 |
return {
|
| 336 |
"filename": filename,
|
| 337 |
"total_pages": total_pages,
|
|
|
|
| 340 |
"failed": failed,
|
| 341 |
"pages": all_pages,
|
| 342 |
}
|
| 343 |
+
|
| 344 |
def _process_single_page(
|
| 345 |
self,
|
| 346 |
filename: str,
|
|
|
|
| 359 |
) -> Dict[str, Any]:
|
| 360 |
"""Process a single page with full metadata for saliency."""
|
| 361 |
from visual_rag.embedding.pooling import global_mean_pooling
|
| 362 |
+
|
| 363 |
# Resize image for ColPali
|
| 364 |
resized_img, tile_rows, tile_cols = self.pdf_processor.resize_for_colpali(embed_img)
|
| 365 |
+
|
| 366 |
# Use processor's tile info if available (more accurate)
|
| 367 |
proc_n_rows = token_info.get("n_rows")
|
| 368 |
proc_n_cols = token_info.get("n_cols")
|
| 369 |
if proc_n_rows and proc_n_cols:
|
| 370 |
tile_rows = proc_n_rows
|
| 371 |
tile_cols = proc_n_cols
|
| 372 |
+
|
| 373 |
# Convert embedding to numpy
|
| 374 |
if isinstance(embedding, torch.Tensor):
|
| 375 |
if embedding.dtype == torch.bfloat16:
|
|
|
|
| 379 |
else:
|
| 380 |
full_embedding = np.array(embedding)
|
| 381 |
full_embedding = full_embedding.astype(np.float32)
|
| 382 |
+
|
| 383 |
# Token info for metadata
|
| 384 |
visual_indices = token_info["visual_token_indices"]
|
| 385 |
num_visual_tokens = token_info["num_visual_tokens"]
|
| 386 |
+
|
| 387 |
# =========================================================================
|
| 388 |
# STRATEGY: "pooling" (NOVEL) vs "standard" (BASELINE) vs "all" (BOTH)
|
| 389 |
# =========================================================================
|
| 390 |
+
|
| 391 |
# Always compute visual-only embedding (needed for pooling and saliency)
|
| 392 |
visual_embedding = full_embedding[visual_indices]
|
| 393 |
+
|
| 394 |
+
tile_pooled = self.embedder.mean_pool_visual_embedding(
|
| 395 |
+
visual_embedding, token_info, target_vectors=32
|
| 396 |
+
)
|
| 397 |
experimental_pooled = self.embedder.experimental_pool_visual_embedding(
|
| 398 |
visual_embedding, token_info, target_vectors=32, mean_pool=tile_pooled
|
| 399 |
)
|
| 400 |
global_pooled = global_mean_pooling(full_embedding)
|
| 401 |
+
global_pooling = (
|
| 402 |
+
self.embedder.global_pool_from_mean_pool(tile_pooled)
|
| 403 |
+
if tile_pooled.size
|
| 404 |
+
else global_pooled
|
| 405 |
+
)
|
| 406 |
|
| 407 |
num_tiles = int(tile_pooled.shape[0])
|
| 408 |
patches_per_tile = int(visual_embedding.shape[0] // max(num_tiles, 1)) if num_tiles else 0
|
|
|
|
| 411 |
else:
|
| 412 |
tile_rows = token_info.get("n_rows") or None
|
| 413 |
tile_cols = token_info.get("n_cols") or None
|
| 414 |
+
|
| 415 |
if self.embedding_strategy == "pooling":
|
| 416 |
# NOVEL APPROACH: Visual tokens only + tile-level pooling
|
| 417 |
embedding_for_initial = visual_embedding
|
| 418 |
embedding_for_pooling = tile_pooled
|
| 419 |
+
global_pooling = (
|
| 420 |
+
self.embedder.global_pool_from_mean_pool(tile_pooled)
|
| 421 |
+
if tile_pooled.size
|
| 422 |
+
else global_pooled
|
| 423 |
+
)
|
| 424 |
+
|
| 425 |
elif self.embedding_strategy == "standard":
|
| 426 |
# BASELINE: All tokens + global mean
|
| 427 |
embedding_for_initial = full_embedding
|
| 428 |
embedding_for_pooling = global_pooled.reshape(1, -1)
|
| 429 |
global_pooling = global_pooled
|
| 430 |
+
|
| 431 |
else: # "all" - Push BOTH representations (efficient for comparison)
|
| 432 |
# Embed once, store multiple vector representations
|
| 433 |
# This allows comparing both strategies without re-embedding
|
| 434 |
embedding_for_initial = visual_embedding # Use visual for search
|
| 435 |
+
embedding_for_pooling = tile_pooled # Use tile-level for fast prefetch
|
| 436 |
+
global_pooling = (
|
| 437 |
+
self.embedder.global_pool_from_mean_pool(tile_pooled)
|
| 438 |
+
if tile_pooled.size
|
| 439 |
+
else global_pooled
|
| 440 |
+
)
|
| 441 |
+
|
| 442 |
# ALSO store standard representations as additional vectors
|
| 443 |
# These will be added to metadata for optional use
|
| 444 |
pass # Extra vectors handled in return dict below
|
| 445 |
+
|
| 446 |
# Upload to Cloudinary
|
| 447 |
original_url = None
|
| 448 |
cropped_url = None
|
| 449 |
resized_url = None
|
| 450 |
+
|
| 451 |
if upload_to_cloudinary and self.cloudinary_uploader:
|
| 452 |
base_filename = f"{pdf_stem}_page_{page_num}"
|
| 453 |
if self.crop_empty:
|
| 454 |
+
original_url, cropped_url, resized_url = (
|
| 455 |
+
self.cloudinary_uploader.upload_original_cropped_and_resized(
|
| 456 |
+
raw_img, embed_img, resized_img, base_filename
|
| 457 |
+
)
|
| 458 |
)
|
| 459 |
else:
|
| 460 |
original_url, resized_url = self.cloudinary_uploader.upload_original_and_resized(
|
| 461 |
raw_img, resized_img, base_filename
|
| 462 |
)
|
| 463 |
+
|
| 464 |
# Sanitize text
|
| 465 |
safe_text = self._sanitize_text(text[:10000]) if text else ""
|
| 466 |
+
|
| 467 |
metadata = {
|
| 468 |
"filename": filename,
|
| 469 |
"page_number": page_num,
|
| 470 |
"total_pages": total_pages,
|
| 471 |
"has_text": bool(text and text.strip()),
|
| 472 |
"text": safe_text,
|
|
|
|
| 473 |
# Image URLs
|
| 474 |
"page": resized_url or "", # For display
|
| 475 |
"original_url": original_url or "",
|
| 476 |
"cropped_url": cropped_url or "",
|
| 477 |
"resized_url": resized_url or "",
|
|
|
|
| 478 |
# Dimensions (needed for saliency overlay)
|
| 479 |
"original_width": raw_img.width,
|
| 480 |
"original_height": raw_img.height,
|
|
|
|
| 482 |
"cropped_height": int(embed_img.height) if self.crop_empty else int(raw_img.height),
|
| 483 |
"resized_width": resized_img.width,
|
| 484 |
"resized_height": resized_img.height,
|
|
|
|
| 485 |
# Tile structure (needed for saliency)
|
| 486 |
"num_tiles": num_tiles,
|
| 487 |
"tile_rows": tile_rows,
|
| 488 |
"tile_cols": tile_cols,
|
| 489 |
"patches_per_tile": patches_per_tile,
|
|
|
|
| 490 |
# Token info (needed for saliency)
|
| 491 |
"num_visual_tokens": num_visual_tokens,
|
| 492 |
"visual_token_indices": visual_indices,
|
| 493 |
"total_tokens": len(full_embedding), # Total tokens in raw embedding
|
|
|
|
| 494 |
# Strategy used (important for paper comparison)
|
| 495 |
"embedding_strategy": self.embedding_strategy,
|
|
|
|
| 496 |
"model_name": getattr(self.embedder, "model_name", None),
|
|
|
|
| 497 |
"crop_empty_enabled": bool(self.crop_empty),
|
| 498 |
"crop_empty_crop_box": (crop_meta or {}).get("crop_box"),
|
| 499 |
"crop_empty_remove_page_number": bool(self.crop_empty_remove_page_number),
|
| 500 |
"crop_empty_percentage_to_remove": float(self.crop_empty_percentage_to_remove),
|
| 501 |
"crop_empty_preserve_border_px": int(self.crop_empty_preserve_border_px),
|
| 502 |
+
"crop_empty_uniform_rowcol_std_threshold": float(
|
| 503 |
+
self.crop_empty_uniform_rowcol_std_threshold
|
| 504 |
+
),
|
| 505 |
# Extra metadata (year, district, etc.)
|
| 506 |
**extra_metadata,
|
| 507 |
}
|
| 508 |
+
|
| 509 |
result = {
|
| 510 |
"id": chunk_id,
|
| 511 |
+
"visual_embedding": embedding_for_initial, # "initial" vector in Qdrant
|
| 512 |
"tile_pooled_embedding": embedding_for_pooling, # "mean_pooling" vector in Qdrant
|
| 513 |
"experimental_pooled_embedding": experimental_pooled, # "experimental_pooling" vector in Qdrant
|
| 514 |
"global_pooled_embedding": global_pooling, # "global_pooling" vector in Qdrant
|
|
|
|
| 516 |
"image": raw_img,
|
| 517 |
"resized_image": resized_img,
|
| 518 |
}
|
| 519 |
+
|
| 520 |
# For "all" strategy, include BOTH representations for comparison
|
| 521 |
if self.embedding_strategy == "all":
|
| 522 |
result["extra_vectors"] = {
|
| 523 |
# Standard baseline vectors (for comparison)
|
| 524 |
+
"full_embedding": full_embedding, # All tokens [total, 128]
|
| 525 |
+
"global_pooled": global_pooled, # Global mean [128]
|
| 526 |
# Pooling vectors (already in main result)
|
| 527 |
+
"visual_embedding": visual_embedding, # Visual only [visual, 128]
|
| 528 |
+
"tile_pooled": tile_pooled, # Tile-level [tiles, 128]
|
| 529 |
}
|
| 530 |
+
|
| 531 |
return result
|
| 532 |
+
|
| 533 |
def _upload_batch(self, upload_queue: List[Dict[str, Any]]) -> int:
|
| 534 |
"""Upload batch to Qdrant."""
|
| 535 |
if not upload_queue or not self.indexer:
|
| 536 |
return 0
|
| 537 |
+
|
| 538 |
logger.info(f"📤 Uploading batch of {len(upload_queue)} pages...")
|
| 539 |
+
|
| 540 |
count = self.indexer.upload_batch(
|
| 541 |
upload_queue,
|
| 542 |
delay_between_batches=self.delay_between_uploads,
|
| 543 |
)
|
| 544 |
+
|
| 545 |
return count
|
| 546 |
+
|
| 547 |
def _get_extra_metadata(self, filename: str) -> Dict[str, Any]:
|
| 548 |
"""Get extra metadata for a filename."""
|
| 549 |
if not self.metadata_mapping:
|
| 550 |
return {}
|
| 551 |
+
|
| 552 |
# Normalize filename
|
| 553 |
filename_clean = filename.replace(".pdf", "").replace(".PDF", "").strip().lower()
|
| 554 |
+
|
| 555 |
# Try exact match
|
| 556 |
if filename_clean in self.metadata_mapping:
|
| 557 |
return self.metadata_mapping[filename_clean].copy()
|
| 558 |
+
|
| 559 |
# Try fuzzy match
|
| 560 |
from difflib import SequenceMatcher
|
| 561 |
+
|
| 562 |
best_match = None
|
| 563 |
best_score = 0.0
|
| 564 |
+
|
| 565 |
for known_filename, metadata in self.metadata_mapping.items():
|
| 566 |
score = SequenceMatcher(None, filename_clean, known_filename.lower()).ratio()
|
| 567 |
if score > best_score and score > 0.75:
|
| 568 |
best_score = score
|
| 569 |
best_match = metadata
|
| 570 |
+
|
| 571 |
if best_match:
|
| 572 |
logger.debug(f"Fuzzy matched '{filename}' with score {best_score:.2f}")
|
| 573 |
return best_match.copy()
|
| 574 |
+
|
| 575 |
return {}
|
| 576 |
+
|
| 577 |
def _sanitize_text(self, text: str) -> str:
|
| 578 |
"""Remove invalid Unicode characters."""
|
| 579 |
if not text:
|
| 580 |
return ""
|
| 581 |
return text.encode("utf-8", errors="surrogatepass").decode("utf-8", errors="ignore")
|
| 582 |
+
|
| 583 |
@staticmethod
|
| 584 |
def generate_chunk_id(filename: str, page_number: int) -> str:
|
| 585 |
"""Generate deterministic chunk ID."""
|
|
|
|
| 587 |
hash_obj = hashlib.sha256(content.encode())
|
| 588 |
hex_str = hash_obj.hexdigest()[:32]
|
| 589 |
return f"{hex_str[:8]}-{hex_str[8:12]}-{hex_str[12:16]}-{hex_str[16:20]}-{hex_str[20:32]}"
|
| 590 |
+
|
| 591 |
@staticmethod
|
| 592 |
def load_metadata_mapping(json_path: Path) -> Dict[str, Dict[str, Any]]:
|
| 593 |
"""
|
| 594 |
Load metadata mapping from JSON file.
|
| 595 |
+
|
| 596 |
Expected format:
|
| 597 |
{
|
| 598 |
"filenames": {
|
|
|
|
| 600 |
...
|
| 601 |
}
|
| 602 |
}
|
| 603 |
+
|
| 604 |
Or simple format:
|
| 605 |
{
|
| 606 |
"Report Name 2023": {"year": 2023, "source": "Local Government", ...},
|
|
|
|
| 608 |
}
|
| 609 |
"""
|
| 610 |
import json
|
| 611 |
+
|
| 612 |
with open(json_path, "r") as f:
|
| 613 |
data = json.load(f)
|
| 614 |
+
|
| 615 |
# Check if nested under "filenames"
|
| 616 |
if "filenames" in data and isinstance(data["filenames"], dict):
|
| 617 |
mapping = data["filenames"]
|
| 618 |
else:
|
| 619 |
mapping = data
|
| 620 |
+
|
| 621 |
# Normalize keys to lowercase
|
| 622 |
normalized = {}
|
| 623 |
for filename, metadata in mapping.items():
|
| 624 |
key = filename.lower().strip().replace(".pdf", "")
|
| 625 |
normalized[key] = metadata
|
| 626 |
+
|
| 627 |
logger.info(f"📖 Loaded metadata for {len(normalized)} files")
|
| 628 |
return normalized
|
|
|
visual_rag/indexing/qdrant_indexer.py
CHANGED
|
@@ -11,11 +11,12 @@ Features:
|
|
| 11 |
- Configurable payload indexes
|
| 12 |
"""
|
| 13 |
|
| 14 |
-
import time
|
| 15 |
import hashlib
|
| 16 |
import logging
|
| 17 |
-
|
|
|
|
| 18 |
from urllib.parse import urlparse
|
|
|
|
| 19 |
import numpy as np
|
| 20 |
|
| 21 |
logger = logging.getLogger(__name__)
|
|
@@ -24,30 +25,30 @@ logger = logging.getLogger(__name__)
|
|
| 24 |
class QdrantIndexer:
|
| 25 |
"""
|
| 26 |
Upload visual embeddings to Qdrant.
|
| 27 |
-
|
| 28 |
Works independently - just needs embeddings and metadata.
|
| 29 |
-
|
| 30 |
Args:
|
| 31 |
url: Qdrant server URL
|
| 32 |
api_key: Qdrant API key
|
| 33 |
collection_name: Name of the collection
|
| 34 |
timeout: Request timeout in seconds
|
| 35 |
prefer_grpc: Use gRPC protocol (faster but may have issues)
|
| 36 |
-
|
| 37 |
Example:
|
| 38 |
>>> indexer = QdrantIndexer(
|
| 39 |
... url="https://your-cluster.qdrant.io:6333",
|
| 40 |
... api_key="your-api-key",
|
| 41 |
... collection_name="my_collection",
|
| 42 |
... )
|
| 43 |
-
>>>
|
| 44 |
>>> # Create collection
|
| 45 |
>>> indexer.create_collection()
|
| 46 |
-
>>>
|
| 47 |
>>> # Upload points
|
| 48 |
>>> indexer.upload_batch(points)
|
| 49 |
"""
|
| 50 |
-
|
| 51 |
def __init__(
|
| 52 |
self,
|
| 53 |
url: str,
|
|
@@ -64,7 +65,7 @@ class QdrantIndexer:
|
|
| 64 |
"Qdrant client not installed. "
|
| 65 |
"Install with: pip install visual-rag-toolkit[qdrant]"
|
| 66 |
)
|
| 67 |
-
|
| 68 |
self.collection_name = collection_name
|
| 69 |
self.timeout = timeout
|
| 70 |
if vector_datatype not in ("float32", "float16"):
|
|
@@ -81,7 +82,7 @@ class QdrantIndexer:
|
|
| 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,
|
|
@@ -102,16 +103,16 @@ class QdrantIndexer:
|
|
| 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."""
|
| 112 |
collections = self.client.get_collections().collections
|
| 113 |
return any(c.name == self.collection_name for c in collections)
|
| 114 |
-
|
| 115 |
def create_collection(
|
| 116 |
self,
|
| 117 |
embedding_dim: int = 128,
|
|
@@ -122,25 +123,25 @@ class QdrantIndexer:
|
|
| 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:
|
| 146 |
logger.info(f"🗑️ Deleting existing collection: {self.collection_name}")
|
|
@@ -148,16 +149,20 @@ class QdrantIndexer:
|
|
| 148 |
else:
|
| 149 |
logger.info(f"✅ Collection already exists: {self.collection_name}")
|
| 150 |
return False
|
| 151 |
-
|
| 152 |
logger.info(f"📦 Creating collection: {self.collection_name}")
|
| 153 |
-
|
| 154 |
# Multi-vector config for ColBERT-style MaxSim
|
| 155 |
multivector_config = models.MultiVectorConfig(
|
| 156 |
comparator=models.MultiVectorComparator.MAX_SIM
|
| 157 |
)
|
| 158 |
-
|
| 159 |
# Vector configs - simplified for compatibility
|
| 160 |
-
datatype =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
vectors_config = {
|
| 162 |
"initial": VectorParams(
|
| 163 |
size=embedding_dim,
|
|
@@ -187,28 +192,28 @@ class QdrantIndexer:
|
|
| 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}")
|
| 197 |
return True
|
| 198 |
-
|
| 199 |
def create_payload_indexes(
|
| 200 |
self,
|
| 201 |
fields: Optional[List[Dict[str, str]]] = None,
|
| 202 |
):
|
| 203 |
"""
|
| 204 |
Create payload indexes for filtering.
|
| 205 |
-
|
| 206 |
Args:
|
| 207 |
fields: List of {field, type} dicts
|
| 208 |
type can be: integer, keyword, bool, float, text
|
| 209 |
"""
|
| 210 |
from qdrant_client.http import models
|
| 211 |
-
|
| 212 |
type_mapping = {
|
| 213 |
"integer": models.PayloadSchemaType.INTEGER,
|
| 214 |
"keyword": models.PayloadSchemaType.KEYWORD,
|
|
@@ -216,17 +221,17 @@ class QdrantIndexer:
|
|
| 216 |
"float": models.PayloadSchemaType.FLOAT,
|
| 217 |
"text": models.PayloadSchemaType.TEXT,
|
| 218 |
}
|
| 219 |
-
|
| 220 |
if not fields:
|
| 221 |
return
|
| 222 |
-
|
| 223 |
logger.info("📇 Creating payload indexes...")
|
| 224 |
-
|
| 225 |
for field_config in fields:
|
| 226 |
field_name = field_config["field"]
|
| 227 |
field_type_str = field_config.get("type", "keyword")
|
| 228 |
field_type = type_mapping.get(field_type_str, models.PayloadSchemaType.KEYWORD)
|
| 229 |
-
|
| 230 |
try:
|
| 231 |
self.client.create_payload_index(
|
| 232 |
collection_name=self.collection_name,
|
|
@@ -236,7 +241,7 @@ class QdrantIndexer:
|
|
| 236 |
logger.info(f" ✅ {field_name} ({field_type_str})")
|
| 237 |
except Exception as e:
|
| 238 |
logger.debug(f" Index {field_name} might already exist: {e}")
|
| 239 |
-
|
| 240 |
def upload_batch(
|
| 241 |
self,
|
| 242 |
points: List[Dict[str, Any]],
|
|
@@ -247,7 +252,7 @@ class QdrantIndexer:
|
|
| 247 |
) -> int:
|
| 248 |
"""
|
| 249 |
Upload a batch of points to Qdrant.
|
| 250 |
-
|
| 251 |
Each point should have:
|
| 252 |
- id: Unique point ID (string or UUID)
|
| 253 |
- visual_embedding: Full embedding [num_patches, dim]
|
|
@@ -255,28 +260,30 @@ class QdrantIndexer:
|
|
| 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
|
| 268 |
"""
|
| 269 |
from qdrant_client.http import models
|
| 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 (
|
|
|
|
|
|
|
| 280 |
return True
|
| 281 |
content = getattr(e, "content", None)
|
| 282 |
if content is not None:
|
|
@@ -287,7 +294,9 @@ class QdrantIndexer:
|
|
| 287 |
text = str(content)
|
| 288 |
except Exception:
|
| 289 |
text = ""
|
| 290 |
-
if ("JSON payload" in text and "larger than allowed" in text) or (
|
|
|
|
|
|
|
| 291 |
return True
|
| 292 |
resp = getattr(e, "response", None)
|
| 293 |
if resp is not None:
|
|
@@ -295,7 +304,9 @@ class QdrantIndexer:
|
|
| 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 (
|
|
|
|
|
|
|
| 299 |
return True
|
| 300 |
return False
|
| 301 |
|
|
@@ -313,11 +324,15 @@ class QdrantIndexer:
|
|
| 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(
|
| 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(
|
|
@@ -333,7 +348,7 @@ class QdrantIndexer:
|
|
| 333 |
)
|
| 334 |
)
|
| 335 |
return qdrant_points
|
| 336 |
-
|
| 337 |
# Upload with retry
|
| 338 |
for attempt in range(max_retries):
|
| 339 |
try:
|
|
@@ -379,11 +394,11 @@ class QdrantIndexer:
|
|
| 379 |
if attempt < max_retries - 1:
|
| 380 |
if _is_cancelled():
|
| 381 |
return 0
|
| 382 |
-
time.sleep(2
|
| 383 |
-
|
| 384 |
logger.error(f"❌ Upload failed after {max_retries} attempts")
|
| 385 |
return 0
|
| 386 |
-
|
| 387 |
def check_exists(self, chunk_id: str) -> bool:
|
| 388 |
"""Check if a point already exists."""
|
| 389 |
try:
|
|
@@ -396,14 +411,14 @@ class QdrantIndexer:
|
|
| 396 |
return len(result) > 0
|
| 397 |
except Exception:
|
| 398 |
return False
|
| 399 |
-
|
| 400 |
def get_existing_ids(self, filename: str) -> Set[str]:
|
| 401 |
"""Get all point IDs for a specific file."""
|
| 402 |
-
from qdrant_client.models import
|
| 403 |
-
|
| 404 |
existing_ids = set()
|
| 405 |
offset = None
|
| 406 |
-
|
| 407 |
while True:
|
| 408 |
results = self.client.scroll(
|
| 409 |
collection_name=self.collection_name,
|
|
@@ -415,31 +430,31 @@ class QdrantIndexer:
|
|
| 415 |
with_payload=["page_number"],
|
| 416 |
with_vectors=False,
|
| 417 |
)
|
| 418 |
-
|
| 419 |
points, next_offset = results
|
| 420 |
-
|
| 421 |
for point in points:
|
| 422 |
existing_ids.add(str(point.id))
|
| 423 |
-
|
| 424 |
if next_offset is None or len(points) == 0:
|
| 425 |
break
|
| 426 |
offset = next_offset
|
| 427 |
-
|
| 428 |
return existing_ids
|
| 429 |
-
|
| 430 |
def get_collection_info(self) -> Optional[Dict[str, Any]]:
|
| 431 |
"""Get collection statistics."""
|
| 432 |
try:
|
| 433 |
info = self.client.get_collection(self.collection_name)
|
| 434 |
-
|
| 435 |
status = info.status
|
| 436 |
if hasattr(status, "value"):
|
| 437 |
status = status.value
|
| 438 |
-
|
| 439 |
indexed_count = getattr(info, "indexed_vectors_count", 0) or 0
|
| 440 |
if isinstance(indexed_count, dict):
|
| 441 |
indexed_count = sum(indexed_count.values())
|
| 442 |
-
|
| 443 |
return {
|
| 444 |
"status": str(status),
|
| 445 |
"points_count": getattr(info, "points_count", 0),
|
|
@@ -448,12 +463,12 @@ class QdrantIndexer:
|
|
| 448 |
except Exception as e:
|
| 449 |
logger.warning(f"Could not get collection info: {e}")
|
| 450 |
return None
|
| 451 |
-
|
| 452 |
@staticmethod
|
| 453 |
def generate_point_id(filename: str, page_number: int) -> str:
|
| 454 |
"""
|
| 455 |
Generate deterministic point ID from filename and page.
|
| 456 |
-
|
| 457 |
Returns a valid UUID string.
|
| 458 |
"""
|
| 459 |
content = f"{filename}:page:{page_number}"
|
|
@@ -461,5 +476,3 @@ class QdrantIndexer:
|
|
| 461 |
hex_str = hash_obj.hexdigest()[:32]
|
| 462 |
# Format as UUID
|
| 463 |
return f"{hex_str[:8]}-{hex_str[8:12]}-{hex_str[12:16]}-{hex_str[16:20]}-{hex_str[20:32]}"
|
| 464 |
-
|
| 465 |
-
|
|
|
|
| 11 |
- Configurable payload indexes
|
| 12 |
"""
|
| 13 |
|
|
|
|
| 14 |
import hashlib
|
| 15 |
import logging
|
| 16 |
+
import time
|
| 17 |
+
from typing import Any, Dict, List, Optional, Set
|
| 18 |
from urllib.parse import urlparse
|
| 19 |
+
|
| 20 |
import numpy as np
|
| 21 |
|
| 22 |
logger = logging.getLogger(__name__)
|
|
|
|
| 25 |
class QdrantIndexer:
|
| 26 |
"""
|
| 27 |
Upload visual embeddings to Qdrant.
|
| 28 |
+
|
| 29 |
Works independently - just needs embeddings and metadata.
|
| 30 |
+
|
| 31 |
Args:
|
| 32 |
url: Qdrant server URL
|
| 33 |
api_key: Qdrant API key
|
| 34 |
collection_name: Name of the collection
|
| 35 |
timeout: Request timeout in seconds
|
| 36 |
prefer_grpc: Use gRPC protocol (faster but may have issues)
|
| 37 |
+
|
| 38 |
Example:
|
| 39 |
>>> indexer = QdrantIndexer(
|
| 40 |
... url="https://your-cluster.qdrant.io:6333",
|
| 41 |
... api_key="your-api-key",
|
| 42 |
... collection_name="my_collection",
|
| 43 |
... )
|
| 44 |
+
>>>
|
| 45 |
>>> # Create collection
|
| 46 |
>>> indexer.create_collection()
|
| 47 |
+
>>>
|
| 48 |
>>> # Upload points
|
| 49 |
>>> indexer.upload_batch(points)
|
| 50 |
"""
|
| 51 |
+
|
| 52 |
def __init__(
|
| 53 |
self,
|
| 54 |
url: str,
|
|
|
|
| 65 |
"Qdrant client not installed. "
|
| 66 |
"Install with: pip install visual-rag-toolkit[qdrant]"
|
| 67 |
)
|
| 68 |
+
|
| 69 |
self.collection_name = collection_name
|
| 70 |
self.timeout = timeout
|
| 71 |
if vector_datatype not in ("float32", "float16"):
|
|
|
|
| 82 |
grpc_port = 6334
|
| 83 |
except Exception:
|
| 84 |
grpc_port = None
|
| 85 |
+
|
| 86 |
def _make_client(use_grpc: bool):
|
| 87 |
return QdrantClient(
|
| 88 |
url=url,
|
|
|
|
| 103 |
self.client = _make_client(False)
|
| 104 |
else:
|
| 105 |
raise
|
| 106 |
+
|
| 107 |
logger.info(f"🔌 Connected to Qdrant: {url}")
|
| 108 |
logger.info(f" Collection: {collection_name}")
|
| 109 |
logger.info(f" Vector datatype: {self.vector_datatype}")
|
| 110 |
+
|
| 111 |
def collection_exists(self) -> bool:
|
| 112 |
"""Check if collection exists."""
|
| 113 |
collections = self.client.get_collections().collections
|
| 114 |
return any(c.name == self.collection_name for c in collections)
|
| 115 |
+
|
| 116 |
def create_collection(
|
| 117 |
self,
|
| 118 |
embedding_dim: int = 128,
|
|
|
|
| 123 |
) -> bool:
|
| 124 |
"""
|
| 125 |
Create collection with multi-vector support.
|
| 126 |
+
|
| 127 |
Creates named vectors:
|
| 128 |
- initial: Full multi-vector embeddings (num_patches × dim)
|
| 129 |
- mean_pooling: Tile-level pooled vectors (num_tiles × dim)
|
| 130 |
- experimental_pooling: Experimental multi-vector pooling (varies by model)
|
| 131 |
- global_pooling: Single vector pooled representation (dim)
|
| 132 |
+
|
| 133 |
Args:
|
| 134 |
embedding_dim: Embedding dimension (128 for ColSmol)
|
| 135 |
force_recreate: Delete and recreate if exists
|
| 136 |
enable_quantization: Enable int8 quantization
|
| 137 |
indexing_threshold: Qdrant optimizer indexing threshold (set 0 to always build ANN indexes)
|
| 138 |
+
|
| 139 |
Returns:
|
| 140 |
True if created, False if already existed
|
| 141 |
"""
|
| 142 |
from qdrant_client.http import models
|
| 143 |
from qdrant_client.http.models import Distance, VectorParams
|
| 144 |
+
|
| 145 |
if self.collection_exists():
|
| 146 |
if force_recreate:
|
| 147 |
logger.info(f"🗑️ Deleting existing collection: {self.collection_name}")
|
|
|
|
| 149 |
else:
|
| 150 |
logger.info(f"✅ Collection already exists: {self.collection_name}")
|
| 151 |
return False
|
| 152 |
+
|
| 153 |
logger.info(f"📦 Creating collection: {self.collection_name}")
|
| 154 |
+
|
| 155 |
# Multi-vector config for ColBERT-style MaxSim
|
| 156 |
multivector_config = models.MultiVectorConfig(
|
| 157 |
comparator=models.MultiVectorComparator.MAX_SIM
|
| 158 |
)
|
| 159 |
+
|
| 160 |
# Vector configs - simplified for compatibility
|
| 161 |
+
datatype = (
|
| 162 |
+
models.Datatype.FLOAT16
|
| 163 |
+
if self.vector_datatype == "float16"
|
| 164 |
+
else models.Datatype.FLOAT32
|
| 165 |
+
)
|
| 166 |
vectors_config = {
|
| 167 |
"initial": VectorParams(
|
| 168 |
size=embedding_dim,
|
|
|
|
| 192 |
datatype=datatype,
|
| 193 |
),
|
| 194 |
}
|
| 195 |
+
|
| 196 |
self.client.create_collection(
|
| 197 |
collection_name=self.collection_name,
|
| 198 |
vectors_config=vectors_config,
|
| 199 |
)
|
| 200 |
+
|
| 201 |
logger.info(f"✅ Collection created: {self.collection_name}")
|
| 202 |
return True
|
| 203 |
+
|
| 204 |
def create_payload_indexes(
|
| 205 |
self,
|
| 206 |
fields: Optional[List[Dict[str, str]]] = None,
|
| 207 |
):
|
| 208 |
"""
|
| 209 |
Create payload indexes for filtering.
|
| 210 |
+
|
| 211 |
Args:
|
| 212 |
fields: List of {field, type} dicts
|
| 213 |
type can be: integer, keyword, bool, float, text
|
| 214 |
"""
|
| 215 |
from qdrant_client.http import models
|
| 216 |
+
|
| 217 |
type_mapping = {
|
| 218 |
"integer": models.PayloadSchemaType.INTEGER,
|
| 219 |
"keyword": models.PayloadSchemaType.KEYWORD,
|
|
|
|
| 221 |
"float": models.PayloadSchemaType.FLOAT,
|
| 222 |
"text": models.PayloadSchemaType.TEXT,
|
| 223 |
}
|
| 224 |
+
|
| 225 |
if not fields:
|
| 226 |
return
|
| 227 |
+
|
| 228 |
logger.info("📇 Creating payload indexes...")
|
| 229 |
+
|
| 230 |
for field_config in fields:
|
| 231 |
field_name = field_config["field"]
|
| 232 |
field_type_str = field_config.get("type", "keyword")
|
| 233 |
field_type = type_mapping.get(field_type_str, models.PayloadSchemaType.KEYWORD)
|
| 234 |
+
|
| 235 |
try:
|
| 236 |
self.client.create_payload_index(
|
| 237 |
collection_name=self.collection_name,
|
|
|
|
| 241 |
logger.info(f" ✅ {field_name} ({field_type_str})")
|
| 242 |
except Exception as e:
|
| 243 |
logger.debug(f" Index {field_name} might already exist: {e}")
|
| 244 |
+
|
| 245 |
def upload_batch(
|
| 246 |
self,
|
| 247 |
points: List[Dict[str, Any]],
|
|
|
|
| 252 |
) -> int:
|
| 253 |
"""
|
| 254 |
Upload a batch of points to Qdrant.
|
| 255 |
+
|
| 256 |
Each point should have:
|
| 257 |
- id: Unique point ID (string or UUID)
|
| 258 |
- visual_embedding: Full embedding [num_patches, dim]
|
|
|
|
| 260 |
- experimental_pooled_embedding: Experimental pooled embedding [*, dim]
|
| 261 |
- global_pooled_embedding: Pooled embedding [dim]
|
| 262 |
- metadata: Payload dict
|
| 263 |
+
|
| 264 |
Args:
|
| 265 |
points: List of point dicts
|
| 266 |
max_retries: Retry attempts on failure
|
| 267 |
delay_between_batches: Delay after upload
|
| 268 |
wait: Wait for operation to complete on Qdrant server
|
| 269 |
stop_event: Optional threading.Event used to cancel uploads early
|
| 270 |
+
|
| 271 |
Returns:
|
| 272 |
Number of successfully uploaded points
|
| 273 |
"""
|
| 274 |
from qdrant_client.http import models
|
| 275 |
+
|
| 276 |
if not points:
|
| 277 |
return 0
|
| 278 |
|
| 279 |
def _is_cancelled() -> bool:
|
| 280 |
return stop_event is not None and getattr(stop_event, "is_set", lambda: False)()
|
| 281 |
+
|
| 282 |
def _is_payload_too_large_error(e: Exception) -> bool:
|
| 283 |
msg = str(e)
|
| 284 |
+
if ("JSON payload" in msg and "larger than allowed" in msg) or (
|
| 285 |
+
"Payload error:" in msg and "limit:" in msg
|
| 286 |
+
):
|
| 287 |
return True
|
| 288 |
content = getattr(e, "content", None)
|
| 289 |
if content is not None:
|
|
|
|
| 294 |
text = str(content)
|
| 295 |
except Exception:
|
| 296 |
text = ""
|
| 297 |
+
if ("JSON payload" in text and "larger than allowed" in text) or (
|
| 298 |
+
"Payload error" in text and "limit" in text
|
| 299 |
+
):
|
| 300 |
return True
|
| 301 |
resp = getattr(e, "response", None)
|
| 302 |
if resp is not None:
|
|
|
|
| 304 |
text = str(getattr(resp, "text", "") or "")
|
| 305 |
except Exception:
|
| 306 |
text = ""
|
| 307 |
+
if ("JSON payload" in text and "larger than allowed" in text) or (
|
| 308 |
+
"Payload error" in text and "limit" in text
|
| 309 |
+
):
|
| 310 |
return True
|
| 311 |
return False
|
| 312 |
|
|
|
|
| 324 |
global_pooled = tile_pooled.mean(axis=0)
|
| 325 |
global_pooled = np.array(global_pooled, dtype=np.float32).reshape(-1)
|
| 326 |
|
| 327 |
+
initial = np.array(p["visual_embedding"], dtype=np.float32).astype(
|
|
|
|
|
|
|
| 328 |
self._np_vector_dtype, copy=False
|
| 329 |
)
|
| 330 |
+
mean_pooling = np.array(p["tile_pooled_embedding"], dtype=np.float32).astype(
|
| 331 |
+
self._np_vector_dtype, copy=False
|
| 332 |
+
)
|
| 333 |
+
experimental_pooling = np.array(
|
| 334 |
+
p["experimental_pooled_embedding"], dtype=np.float32
|
| 335 |
+
).astype(self._np_vector_dtype, copy=False)
|
| 336 |
global_pooling = global_pooled.astype(self._np_vector_dtype, copy=False)
|
| 337 |
|
| 338 |
qdrant_points.append(
|
|
|
|
| 348 |
)
|
| 349 |
)
|
| 350 |
return qdrant_points
|
| 351 |
+
|
| 352 |
# Upload with retry
|
| 353 |
for attempt in range(max_retries):
|
| 354 |
try:
|
|
|
|
| 394 |
if attempt < max_retries - 1:
|
| 395 |
if _is_cancelled():
|
| 396 |
return 0
|
| 397 |
+
time.sleep(2**attempt) # Exponential backoff
|
| 398 |
+
|
| 399 |
logger.error(f"❌ Upload failed after {max_retries} attempts")
|
| 400 |
return 0
|
| 401 |
+
|
| 402 |
def check_exists(self, chunk_id: str) -> bool:
|
| 403 |
"""Check if a point already exists."""
|
| 404 |
try:
|
|
|
|
| 411 |
return len(result) > 0
|
| 412 |
except Exception:
|
| 413 |
return False
|
| 414 |
+
|
| 415 |
def get_existing_ids(self, filename: str) -> Set[str]:
|
| 416 |
"""Get all point IDs for a specific file."""
|
| 417 |
+
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
| 418 |
+
|
| 419 |
existing_ids = set()
|
| 420 |
offset = None
|
| 421 |
+
|
| 422 |
while True:
|
| 423 |
results = self.client.scroll(
|
| 424 |
collection_name=self.collection_name,
|
|
|
|
| 430 |
with_payload=["page_number"],
|
| 431 |
with_vectors=False,
|
| 432 |
)
|
| 433 |
+
|
| 434 |
points, next_offset = results
|
| 435 |
+
|
| 436 |
for point in points:
|
| 437 |
existing_ids.add(str(point.id))
|
| 438 |
+
|
| 439 |
if next_offset is None or len(points) == 0:
|
| 440 |
break
|
| 441 |
offset = next_offset
|
| 442 |
+
|
| 443 |
return existing_ids
|
| 444 |
+
|
| 445 |
def get_collection_info(self) -> Optional[Dict[str, Any]]:
|
| 446 |
"""Get collection statistics."""
|
| 447 |
try:
|
| 448 |
info = self.client.get_collection(self.collection_name)
|
| 449 |
+
|
| 450 |
status = info.status
|
| 451 |
if hasattr(status, "value"):
|
| 452 |
status = status.value
|
| 453 |
+
|
| 454 |
indexed_count = getattr(info, "indexed_vectors_count", 0) or 0
|
| 455 |
if isinstance(indexed_count, dict):
|
| 456 |
indexed_count = sum(indexed_count.values())
|
| 457 |
+
|
| 458 |
return {
|
| 459 |
"status": str(status),
|
| 460 |
"points_count": getattr(info, "points_count", 0),
|
|
|
|
| 463 |
except Exception as e:
|
| 464 |
logger.warning(f"Could not get collection info: {e}")
|
| 465 |
return None
|
| 466 |
+
|
| 467 |
@staticmethod
|
| 468 |
def generate_point_id(filename: str, page_number: int) -> str:
|
| 469 |
"""
|
| 470 |
Generate deterministic point ID from filename and page.
|
| 471 |
+
|
| 472 |
Returns a valid UUID string.
|
| 473 |
"""
|
| 474 |
content = f"{filename}:page:{page_number}"
|
|
|
|
| 476 |
hex_str = hash_obj.hexdigest()[:32]
|
| 477 |
# Format as UUID
|
| 478 |
return f"{hex_str[:8]}-{hex_str[8:12]}-{hex_str[12:16]}-{hex_str[16:20]}-{hex_str[20:32]}"
|
|
|
|
|
|
visual_rag/preprocessing/__init__.py
CHANGED
|
@@ -1,5 +1,3 @@
|
|
| 1 |
from visual_rag.preprocessing.crop_empty import CropEmptyConfig, crop_empty
|
| 2 |
|
| 3 |
__all__ = ["CropEmptyConfig", "crop_empty"]
|
| 4 |
-
|
| 5 |
-
|
|
|
|
| 1 |
from visual_rag.preprocessing.crop_empty import CropEmptyConfig, crop_empty
|
| 2 |
|
| 3 |
__all__ = ["CropEmptyConfig", "crop_empty"]
|
|
|
|
|
|
visual_rag/preprocessing/crop_empty.py
CHANGED
|
@@ -20,7 +20,9 @@ class CropEmptyConfig:
|
|
| 20 |
uniform_rowcol_std_threshold: float = 0.0
|
| 21 |
|
| 22 |
|
| 23 |
-
def crop_empty(
|
|
|
|
|
|
|
| 24 |
img = image.convert("RGB")
|
| 25 |
arr = np.array(img)
|
| 26 |
intensity = arr.mean(axis=2)
|
|
@@ -31,7 +33,9 @@ def crop_empty(image: Image.Image, *, config: CropEmptyConfig) -> Tuple[Image.Im
|
|
| 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(
|
|
|
|
|
|
|
| 35 |
continue
|
| 36 |
if (white < config.min_white_fraction) and (non_white > min_content_density_threshold):
|
| 37 |
return int(i)
|
|
@@ -43,7 +47,9 @@ def crop_empty(image: Image.Image, *, config: CropEmptyConfig) -> Tuple[Image.Im
|
|
| 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(
|
|
|
|
|
|
|
| 47 |
continue
|
| 48 |
if (white < config.min_white_fraction) and (non_white > min_content_density_threshold):
|
| 49 |
return int(i + 1)
|
|
@@ -53,8 +59,12 @@ def crop_empty(image: Image.Image, *, config: CropEmptyConfig) -> Tuple[Image.Im
|
|
| 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(
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
bottom = main_text_end if config.remove_page_number else last_content_end
|
| 59 |
|
| 60 |
width, height = img.size
|
|
@@ -108,5 +118,3 @@ def crop_empty(image: Image.Image, *, config: CropEmptyConfig) -> Tuple[Image.Im
|
|
| 108 |
"uniform_rowcol_std_threshold": float(config.uniform_rowcol_std_threshold),
|
| 109 |
},
|
| 110 |
}
|
| 111 |
-
|
| 112 |
-
|
|
|
|
| 20 |
uniform_rowcol_std_threshold: float = 0.0
|
| 21 |
|
| 22 |
|
| 23 |
+
def crop_empty(
|
| 24 |
+
image: Image.Image, *, config: CropEmptyConfig
|
| 25 |
+
) -> Tuple[Image.Image, Dict[str, Any]]:
|
| 26 |
img = image.convert("RGB")
|
| 27 |
arr = np.array(img)
|
| 28 |
intensity = arr.mean(axis=2)
|
|
|
|
| 33 |
pixels = intensity[i, :] if axis == 0 else intensity[:, i]
|
| 34 |
white = float(np.mean(pixels > config.color_threshold))
|
| 35 |
non_white = 1.0 - white
|
| 36 |
+
if float(config.uniform_rowcol_std_threshold) > 0.0 and float(np.std(pixels)) <= float(
|
| 37 |
+
config.uniform_rowcol_std_threshold
|
| 38 |
+
):
|
| 39 |
continue
|
| 40 |
if (white < config.min_white_fraction) and (non_white > min_content_density_threshold):
|
| 41 |
return int(i)
|
|
|
|
| 47 |
pixels = intensity[i, :] if axis == 0 else intensity[:, i]
|
| 48 |
white = float(np.mean(pixels > config.color_threshold))
|
| 49 |
non_white = 1.0 - white
|
| 50 |
+
if float(config.uniform_rowcol_std_threshold) > 0.0 and float(np.std(pixels)) <= float(
|
| 51 |
+
config.uniform_rowcol_std_threshold
|
| 52 |
+
):
|
| 53 |
continue
|
| 54 |
if (white < config.min_white_fraction) and (non_white > min_content_density_threshold):
|
| 55 |
return int(i + 1)
|
|
|
|
| 59 |
left = _find_border_start(1, min_content_density_threshold=float(config.content_density_sides))
|
| 60 |
right = _find_border_end(1, min_content_density_threshold=float(config.content_density_sides))
|
| 61 |
|
| 62 |
+
main_text_end = _find_border_end(
|
| 63 |
+
0, min_content_density_threshold=float(config.content_density_main_text)
|
| 64 |
+
)
|
| 65 |
+
last_content_end = _find_border_end(
|
| 66 |
+
0, min_content_density_threshold=float(config.content_density_any)
|
| 67 |
+
)
|
| 68 |
bottom = main_text_end if config.remove_page_number else last_content_end
|
| 69 |
|
| 70 |
width, height = img.size
|
|
|
|
| 118 |
"uniform_rowcol_std_threshold": float(config.uniform_rowcol_std_threshold),
|
| 119 |
},
|
| 120 |
}
|
|
|
|
|
|
visual_rag/qdrant_admin.py
CHANGED
|
@@ -33,9 +33,16 @@ def _resolve_qdrant_connection(
|
|
| 33 |
import os
|
| 34 |
|
| 35 |
_maybe_load_dotenv()
|
| 36 |
-
resolved_url =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
if not resolved_url:
|
| 38 |
-
raise ValueError(
|
|
|
|
|
|
|
| 39 |
resolved_key = (
|
| 40 |
api_key
|
| 41 |
or os.getenv("SIGIR_QDRANT_KEY")
|
|
@@ -105,7 +112,11 @@ class QdrantAdmin:
|
|
| 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 =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
@@ -143,7 +154,9 @@ class QdrantAdmin:
|
|
| 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(
|
|
|
|
|
|
|
| 147 |
|
| 148 |
ok = True
|
| 149 |
for name, cfg in (vectors or {}).items():
|
|
@@ -158,13 +171,16 @@ class QdrantAdmin:
|
|
| 158 |
)
|
| 159 |
}
|
| 160 |
|
| 161 |
-
ok =
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
|
|
|
|
|
|
| 166 |
)
|
| 167 |
-
|
|
|
|
| 168 |
|
| 169 |
return ok
|
| 170 |
|
|
@@ -192,7 +208,9 @@ class QdrantAdmin:
|
|
| 192 |
vectors[str(vname)] = {"on_disk": True, "hnsw_config": {"on_disk": True}}
|
| 193 |
|
| 194 |
if vectors:
|
| 195 |
-
self.modify_collection_vector_config(
|
|
|
|
|
|
|
| 196 |
|
| 197 |
self.modify_collection_config(
|
| 198 |
collection_name=collection_name,
|
|
@@ -202,4 +220,3 @@ class QdrantAdmin:
|
|
| 202 |
)
|
| 203 |
|
| 204 |
return self.get_collection_info(collection_name=collection_name)
|
| 205 |
-
|
|
|
|
| 33 |
import os
|
| 34 |
|
| 35 |
_maybe_load_dotenv()
|
| 36 |
+
resolved_url = (
|
| 37 |
+
url
|
| 38 |
+
or os.getenv("SIGIR_QDRANT_URL")
|
| 39 |
+
or os.getenv("DEST_QDRANT_URL")
|
| 40 |
+
or os.getenv("QDRANT_URL")
|
| 41 |
+
)
|
| 42 |
if not resolved_url:
|
| 43 |
+
raise ValueError(
|
| 44 |
+
"Qdrant URL not set (pass url= or set SIGIR_QDRANT_URL/DEST_QDRANT_URL/QDRANT_URL)."
|
| 45 |
+
)
|
| 46 |
resolved_key = (
|
| 47 |
api_key
|
| 48 |
or os.getenv("SIGIR_QDRANT_KEY")
|
|
|
|
| 112 |
from qdrant_client.http import models as m
|
| 113 |
|
| 114 |
hnsw_diff = m.HnswConfigDiff(**hnsw_config) if isinstance(hnsw_config, dict) else None
|
| 115 |
+
params_diff = (
|
| 116 |
+
m.CollectionParamsDiff(**collection_params)
|
| 117 |
+
if isinstance(collection_params, dict)
|
| 118 |
+
else None
|
| 119 |
+
)
|
| 120 |
if hnsw_diff is None and params_diff is None:
|
| 121 |
raise ValueError("No changes provided (pass hnsw_config and/or collection_params).")
|
| 122 |
return bool(
|
|
|
|
| 154 |
|
| 155 |
missing = [str(k) for k in (vectors or {}).keys() if existing and str(k) not in existing]
|
| 156 |
if missing:
|
| 157 |
+
raise ValueError(
|
| 158 |
+
f"Vectors do not exist in collection '{collection_name}': {missing}. Existing: {sorted(existing)}"
|
| 159 |
+
)
|
| 160 |
|
| 161 |
ok = True
|
| 162 |
for name, cfg in (vectors or {}).items():
|
|
|
|
| 171 |
)
|
| 172 |
}
|
| 173 |
|
| 174 |
+
ok = (
|
| 175 |
+
bool(
|
| 176 |
+
self.client.update_collection(
|
| 177 |
+
collection_name=collection_name,
|
| 178 |
+
vectors_config=vectors_diff,
|
| 179 |
+
timeout=int(timeout) if timeout is not None else None,
|
| 180 |
+
)
|
| 181 |
)
|
| 182 |
+
and ok
|
| 183 |
+
)
|
| 184 |
|
| 185 |
return ok
|
| 186 |
|
|
|
|
| 208 |
vectors[str(vname)] = {"on_disk": True, "hnsw_config": {"on_disk": True}}
|
| 209 |
|
| 210 |
if vectors:
|
| 211 |
+
self.modify_collection_vector_config(
|
| 212 |
+
collection_name=collection_name, vectors=vectors, timeout=timeout
|
| 213 |
+
)
|
| 214 |
|
| 215 |
self.modify_collection_config(
|
| 216 |
collection_name=collection_name,
|
|
|
|
| 220 |
)
|
| 221 |
|
| 222 |
return self.get_collection_info(collection_name=collection_name)
|
|
|
visual_rag/retrieval/__init__.py
CHANGED
|
@@ -6,10 +6,10 @@ Components:
|
|
| 6 |
- SingleStageRetriever: Direct multi-vector or pooled search
|
| 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",
|
|
|
|
| 6 |
- SingleStageRetriever: Direct multi-vector or pooled search
|
| 7 |
"""
|
| 8 |
|
|
|
|
|
|
|
| 9 |
from visual_rag.retrieval.multi_vector import MultiVectorRetriever
|
| 10 |
+
from visual_rag.retrieval.single_stage import SingleStageRetriever
|
| 11 |
from visual_rag.retrieval.three_stage import ThreeStageRetriever
|
| 12 |
+
from visual_rag.retrieval.two_stage import TwoStageRetriever
|
| 13 |
|
| 14 |
__all__ = [
|
| 15 |
"TwoStageRetriever",
|
visual_rag/retrieval/multi_vector.py
CHANGED
|
@@ -4,8 +4,8 @@ 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:
|
|
@@ -67,6 +67,7 @@ class MultiVectorRetriever:
|
|
| 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,
|
|
@@ -83,7 +84,10 @@ class MultiVectorRetriever:
|
|
| 83 |
_ = qdrant_client.get_collections()
|
| 84 |
except Exception as e:
|
| 85 |
msg = str(e)
|
| 86 |
-
if
|
|
|
|
|
|
|
|
|
|
| 87 |
qdrant_client = _make_client(False)
|
| 88 |
else:
|
| 89 |
raise
|
|
@@ -216,5 +220,3 @@ class MultiVectorRetriever:
|
|
| 216 |
)
|
| 217 |
|
| 218 |
raise ValueError(f"Unknown mode: {mode}")
|
| 219 |
-
|
| 220 |
-
|
|
|
|
| 4 |
|
| 5 |
from visual_rag.embedding.visual_embedder import VisualEmbedder
|
| 6 |
from visual_rag.retrieval.single_stage import SingleStageRetriever
|
|
|
|
| 7 |
from visual_rag.retrieval.three_stage import ThreeStageRetriever
|
| 8 |
+
from visual_rag.retrieval.two_stage import TwoStageRetriever
|
| 9 |
|
| 10 |
|
| 11 |
class MultiVectorRetriever:
|
|
|
|
| 67 |
grpc_port = 6334
|
| 68 |
except Exception:
|
| 69 |
grpc_port = None
|
| 70 |
+
|
| 71 |
def _make_client(use_grpc: bool):
|
| 72 |
return QdrantClient(
|
| 73 |
url=qdrant_url,
|
|
|
|
| 84 |
_ = qdrant_client.get_collections()
|
| 85 |
except Exception as e:
|
| 86 |
msg = str(e)
|
| 87 |
+
if (
|
| 88 |
+
"StatusCode.PERMISSION_DENIED" in msg
|
| 89 |
+
or "http2 header with status: 403" in msg
|
| 90 |
+
):
|
| 91 |
qdrant_client = _make_client(False)
|
| 92 |
else:
|
| 93 |
raise
|
|
|
|
| 220 |
)
|
| 221 |
|
| 222 |
raise ValueError(f"Unknown mode: {mode}")
|
|
|
|
|
|
visual_rag/retrieval/single_stage.py
CHANGED
|
@@ -9,7 +9,8 @@ Use when:
|
|
| 9 |
"""
|
| 10 |
|
| 11 |
import logging
|
| 12 |
-
from typing import
|
|
|
|
| 13 |
import numpy as np
|
| 14 |
import torch
|
| 15 |
|
|
@@ -19,22 +20,22 @@ logger = logging.getLogger(__name__)
|
|
| 19 |
class SingleStageRetriever:
|
| 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
|
| 31 |
collection_name: Name of the Qdrant collection
|
| 32 |
-
|
| 33 |
Example:
|
| 34 |
>>> retriever = SingleStageRetriever(client, "my_collection")
|
| 35 |
>>> results = retriever.search(query, top_k=10)
|
| 36 |
"""
|
| 37 |
-
|
| 38 |
def __init__(
|
| 39 |
self,
|
| 40 |
qdrant_client,
|
|
@@ -44,7 +45,7 @@ class SingleStageRetriever:
|
|
| 44 |
self.client = qdrant_client
|
| 45 |
self.collection_name = collection_name
|
| 46 |
self.request_timeout = int(request_timeout)
|
| 47 |
-
|
| 48 |
def search(
|
| 49 |
self,
|
| 50 |
query_embedding: Union[torch.Tensor, np.ndarray],
|
|
@@ -54,47 +55,47 @@ class SingleStageRetriever:
|
|
| 54 |
) -> List[Dict[str, Any]]:
|
| 55 |
"""
|
| 56 |
Single-stage search with configurable strategy.
|
| 57 |
-
|
| 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:
|
| 65 |
List of results with scores and metadata
|
| 66 |
"""
|
| 67 |
query_np = self._to_numpy(query_embedding)
|
| 68 |
-
|
| 69 |
if strategy == "multi_vector":
|
| 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}")
|
| 97 |
-
|
| 98 |
results = self.client.query_points(
|
| 99 |
collection_name=self.collection_name,
|
| 100 |
query=query_vector,
|
|
@@ -105,7 +106,7 @@ class SingleStageRetriever:
|
|
| 105 |
with_vectors=False,
|
| 106 |
timeout=self.request_timeout,
|
| 107 |
).points
|
| 108 |
-
|
| 109 |
return [
|
| 110 |
{
|
| 111 |
"id": r.id,
|
|
@@ -115,7 +116,7 @@ class SingleStageRetriever:
|
|
| 115 |
}
|
| 116 |
for r in results
|
| 117 |
]
|
| 118 |
-
|
| 119 |
def _to_numpy(self, embedding: Union[torch.Tensor, np.ndarray]) -> np.ndarray:
|
| 120 |
"""Convert embedding to numpy array."""
|
| 121 |
if isinstance(embedding, torch.Tensor):
|
|
@@ -123,5 +124,3 @@ class SingleStageRetriever:
|
|
| 123 |
return embedding.cpu().float().numpy()
|
| 124 |
return embedding.cpu().numpy()
|
| 125 |
return np.array(embedding, dtype=np.float32)
|
| 126 |
-
|
| 127 |
-
|
|
|
|
| 9 |
"""
|
| 10 |
|
| 11 |
import logging
|
| 12 |
+
from typing import Any, Dict, List, Union
|
| 13 |
+
|
| 14 |
import numpy as np
|
| 15 |
import torch
|
| 16 |
|
|
|
|
| 20 |
class SingleStageRetriever:
|
| 21 |
"""
|
| 22 |
Single-stage visual document retrieval using native Qdrant search.
|
| 23 |
+
|
| 24 |
Supports strategies:
|
| 25 |
- multi_vector: Native MaxSim on full embeddings (using="initial")
|
| 26 |
- tiles_maxsim: Native MaxSim between query tokens and tile vectors (using="mean_pooling")
|
| 27 |
- pooled_tile: Pooled query vs tile vectors (using="mean_pooling")
|
| 28 |
- pooled_global: Pooled query vs global pooled doc vector (using="global_pooling")
|
| 29 |
+
|
| 30 |
Args:
|
| 31 |
qdrant_client: Connected Qdrant client
|
| 32 |
collection_name: Name of the Qdrant collection
|
| 33 |
+
|
| 34 |
Example:
|
| 35 |
>>> retriever = SingleStageRetriever(client, "my_collection")
|
| 36 |
>>> results = retriever.search(query, top_k=10)
|
| 37 |
"""
|
| 38 |
+
|
| 39 |
def __init__(
|
| 40 |
self,
|
| 41 |
qdrant_client,
|
|
|
|
| 45 |
self.client = qdrant_client
|
| 46 |
self.collection_name = collection_name
|
| 47 |
self.request_timeout = int(request_timeout)
|
| 48 |
+
|
| 49 |
def search(
|
| 50 |
self,
|
| 51 |
query_embedding: Union[torch.Tensor, np.ndarray],
|
|
|
|
| 55 |
) -> List[Dict[str, Any]]:
|
| 56 |
"""
|
| 57 |
Single-stage search with configurable strategy.
|
| 58 |
+
|
| 59 |
Args:
|
| 60 |
query_embedding: Query embeddings [num_tokens, dim]
|
| 61 |
top_k: Number of results
|
| 62 |
strategy: "multi_vector", "tiles_maxsim", "pooled_tile", or "pooled_global"
|
| 63 |
filter_obj: Qdrant filter
|
| 64 |
+
|
| 65 |
Returns:
|
| 66 |
List of results with scores and metadata
|
| 67 |
"""
|
| 68 |
query_np = self._to_numpy(query_embedding)
|
| 69 |
+
|
| 70 |
if strategy == "multi_vector":
|
| 71 |
# Native multi-vector MaxSim
|
| 72 |
vector_name = "initial"
|
| 73 |
query_vector = query_np.tolist()
|
| 74 |
logger.debug(f"🎯 Multi-vector search on '{vector_name}'")
|
| 75 |
+
|
| 76 |
elif strategy == "tiles_maxsim":
|
| 77 |
# Native multi-vector MaxSim against tile vectors
|
| 78 |
vector_name = "mean_pooling"
|
| 79 |
query_vector = query_np.tolist()
|
| 80 |
logger.debug(f"🎯 Tile MaxSim search on '{vector_name}'")
|
| 81 |
+
|
| 82 |
elif strategy == "pooled_tile":
|
| 83 |
# Tile-level pooled
|
| 84 |
vector_name = "mean_pooling"
|
| 85 |
query_pooled = query_np.mean(axis=0)
|
| 86 |
query_vector = query_pooled.tolist()
|
| 87 |
logger.debug(f"🔍 Tile-pooled search on '{vector_name}'")
|
| 88 |
+
|
| 89 |
elif strategy == "pooled_global":
|
| 90 |
# Global pooled vector (single vector)
|
| 91 |
vector_name = "global_pooling"
|
| 92 |
query_pooled = query_np.mean(axis=0)
|
| 93 |
query_vector = query_pooled.tolist()
|
| 94 |
logger.debug(f"🔍 Global-pooled search on '{vector_name}'")
|
| 95 |
+
|
| 96 |
else:
|
| 97 |
raise ValueError(f"Unknown strategy: {strategy}")
|
| 98 |
+
|
| 99 |
results = self.client.query_points(
|
| 100 |
collection_name=self.collection_name,
|
| 101 |
query=query_vector,
|
|
|
|
| 106 |
with_vectors=False,
|
| 107 |
timeout=self.request_timeout,
|
| 108 |
).points
|
| 109 |
+
|
| 110 |
return [
|
| 111 |
{
|
| 112 |
"id": r.id,
|
|
|
|
| 116 |
}
|
| 117 |
for r in results
|
| 118 |
]
|
| 119 |
+
|
| 120 |
def _to_numpy(self, embedding: Union[torch.Tensor, np.ndarray]) -> np.ndarray:
|
| 121 |
"""Convert embedding to numpy array."""
|
| 122 |
if isinstance(embedding, torch.Tensor):
|
|
|
|
| 124 |
return embedding.cpu().float().numpy()
|
| 125 |
return embedding.cpu().numpy()
|
| 126 |
return np.array(embedding, dtype=np.float32)
|
|
|
|
|
|
visual_rag/retrieval/three_stage.py
CHANGED
|
@@ -43,7 +43,7 @@ class ThreeStageRetriever:
|
|
| 43 |
last_err = e
|
| 44 |
if attempt >= self.max_retries - 1:
|
| 45 |
break
|
| 46 |
-
time.sleep(self.retry_sleep * (2
|
| 47 |
if last_err is not None:
|
| 48 |
raise last_err
|
| 49 |
|
|
@@ -171,4 +171,3 @@ class ThreeStageRetriever:
|
|
| 171 |
}
|
| 172 |
)
|
| 173 |
return out
|
| 174 |
-
|
|
|
|
| 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 |
|
|
|
|
| 171 |
}
|
| 172 |
)
|
| 173 |
return out
|
|
|
visual_rag/retrieval/two_stage.py
CHANGED
|
@@ -17,7 +17,8 @@ Research Context:
|
|
| 17 |
"""
|
| 18 |
|
| 19 |
import logging
|
| 20 |
-
from typing import
|
|
|
|
| 21 |
import numpy as np
|
| 22 |
import torch
|
| 23 |
|
|
@@ -27,37 +28,37 @@ logger = logging.getLogger(__name__)
|
|
| 27 |
class TwoStageRetriever:
|
| 28 |
"""
|
| 29 |
Two-stage visual document retrieval with pooling and reranking.
|
| 30 |
-
|
| 31 |
Stage 1 (Prefetch):
|
| 32 |
Uses tile-level mean-pooled vectors for fast HNSW search.
|
| 33 |
Retrieves prefetch_k candidates (e.g., 100-500).
|
| 34 |
-
|
| 35 |
Stage 2 (Rerank):
|
| 36 |
Fetches full multi-vector embeddings for candidates.
|
| 37 |
Computes exact MaxSim scores for precise ranking.
|
| 38 |
Returns top_k results (e.g., 10).
|
| 39 |
-
|
| 40 |
Args:
|
| 41 |
qdrant_client: Connected Qdrant client
|
| 42 |
collection_name: Name of the Qdrant collection
|
| 43 |
full_vector_name: Name of full multi-vector field (default: "initial")
|
| 44 |
pooled_vector_name: Name of pooled vector field (default: "mean_pooling")
|
| 45 |
-
|
| 46 |
Example:
|
| 47 |
>>> retriever = TwoStageRetriever(client, "my_collection")
|
| 48 |
-
>>>
|
| 49 |
>>> # Two-stage search: prefetch 200, return top 10
|
| 50 |
>>> results = retriever.search(
|
| 51 |
... query_embedding=query,
|
| 52 |
... top_k=10,
|
| 53 |
... prefetch_k=200,
|
| 54 |
... )
|
| 55 |
-
>>>
|
| 56 |
>>> # Compare latency:
|
| 57 |
>>> # Full MaxSim (1000 docs): ~500ms
|
| 58 |
>>> # Two-stage (200→10): ~50ms
|
| 59 |
"""
|
| 60 |
-
|
| 61 |
def __init__(
|
| 62 |
self,
|
| 63 |
qdrant_client,
|
|
@@ -91,7 +92,7 @@ class TwoStageRetriever:
|
|
| 91 |
last_err = e
|
| 92 |
if attempt >= self.max_retries - 1:
|
| 93 |
break
|
| 94 |
-
time.sleep(self.retry_sleep * (2
|
| 95 |
if last_err is not None:
|
| 96 |
raise last_err
|
| 97 |
|
|
@@ -105,27 +106,27 @@ class TwoStageRetriever:
|
|
| 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
|
|
@@ -143,9 +144,9 @@ class TwoStageRetriever:
|
|
| 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,
|
|
@@ -164,9 +165,9 @@ class TwoStageRetriever:
|
|
| 164 |
],
|
| 165 |
timeout=self.request_timeout,
|
| 166 |
).points
|
| 167 |
-
|
| 168 |
results = self._retry_call(_do_query)
|
| 169 |
-
|
| 170 |
return [
|
| 171 |
{
|
| 172 |
"id": r.id,
|
|
@@ -177,7 +178,7 @@ class TwoStageRetriever:
|
|
| 177 |
}
|
| 178 |
for r in results
|
| 179 |
]
|
| 180 |
-
|
| 181 |
def search(
|
| 182 |
self,
|
| 183 |
query_embedding: Union[torch.Tensor, np.ndarray],
|
|
@@ -190,7 +191,7 @@ class TwoStageRetriever:
|
|
| 190 |
) -> List[Dict[str, Any]]:
|
| 191 |
"""
|
| 192 |
Two-stage retrieval: prefetch with pooling, rerank with MaxSim.
|
| 193 |
-
|
| 194 |
Args:
|
| 195 |
query_embedding: Query embeddings [num_tokens, dim]
|
| 196 |
top_k: Final number of results to return
|
|
@@ -202,7 +203,7 @@ class TwoStageRetriever:
|
|
| 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:
|
| 208 |
[
|
|
@@ -218,11 +219,11 @@ class TwoStageRetriever:
|
|
| 218 |
"""
|
| 219 |
# Convert to numpy
|
| 220 |
query_np = self._to_numpy(query_embedding)
|
| 221 |
-
|
| 222 |
# Auto-set prefetch_k
|
| 223 |
if prefetch_k is None:
|
| 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(
|
|
@@ -231,16 +232,16 @@ class TwoStageRetriever:
|
|
| 231 |
filter_obj=filter_obj,
|
| 232 |
stage1_mode=stage1_mode,
|
| 233 |
)
|
| 234 |
-
|
| 235 |
if not candidates:
|
| 236 |
logger.warning("No candidates found in stage 1")
|
| 237 |
return []
|
| 238 |
-
|
| 239 |
logger.info(f"✅ Stage 1: Retrieved {len(candidates)} candidates")
|
| 240 |
-
|
| 241 |
# Stage 2: Rerank with full embeddings
|
| 242 |
if use_reranking and len(candidates) > top_k:
|
| 243 |
-
logger.info(
|
| 244 |
results = self._stage2_rerank(
|
| 245 |
query_np=query_np,
|
| 246 |
candidates=candidates,
|
|
@@ -254,9 +255,9 @@ class TwoStageRetriever:
|
|
| 254 |
for r in results:
|
| 255 |
r["score_final"] = r["score_stage1"]
|
| 256 |
logger.info(f"⏭️ Skipping reranking, returning top {len(results)}")
|
| 257 |
-
|
| 258 |
return results
|
| 259 |
-
|
| 260 |
def search_single_stage(
|
| 261 |
self,
|
| 262 |
query_embedding: Union[torch.Tensor, np.ndarray],
|
|
@@ -266,18 +267,18 @@ class TwoStageRetriever:
|
|
| 266 |
) -> List[Dict[str, Any]]:
|
| 267 |
"""
|
| 268 |
Single-stage search (either pooled or full multi-vector).
|
| 269 |
-
|
| 270 |
Args:
|
| 271 |
query_embedding: Query embeddings
|
| 272 |
top_k: Number of results
|
| 273 |
filter_obj: Qdrant filter
|
| 274 |
use_pooling: Use pooled vectors (faster) or full (more accurate)
|
| 275 |
-
|
| 276 |
Returns:
|
| 277 |
List of results
|
| 278 |
"""
|
| 279 |
query_np = self._to_numpy(query_embedding)
|
| 280 |
-
|
| 281 |
if use_pooling:
|
| 282 |
# Pool query and search pooled vectors
|
| 283 |
query_pooled = query_np.mean(axis=0)
|
|
@@ -289,7 +290,7 @@ class TwoStageRetriever:
|
|
| 289 |
vector_name = self.full_vector_name
|
| 290 |
query_vector = query_np.tolist()
|
| 291 |
logger.info(f"🎯 Multi-vector search: {vector_name}")
|
| 292 |
-
|
| 293 |
results = self.client.query_points(
|
| 294 |
collection_name=self.collection_name,
|
| 295 |
query=query_vector,
|
|
@@ -300,7 +301,7 @@ class TwoStageRetriever:
|
|
| 300 |
with_vectors=False,
|
| 301 |
timeout=120,
|
| 302 |
).points
|
| 303 |
-
|
| 304 |
return [
|
| 305 |
{
|
| 306 |
"id": r.id,
|
|
@@ -310,7 +311,7 @@ class TwoStageRetriever:
|
|
| 310 |
}
|
| 311 |
for r in results
|
| 312 |
]
|
| 313 |
-
|
| 314 |
def _stage1_prefetch(
|
| 315 |
self,
|
| 316 |
query_np: np.ndarray,
|
|
@@ -330,7 +331,7 @@ class TwoStageRetriever:
|
|
| 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,
|
|
@@ -344,7 +345,7 @@ class TwoStageRetriever:
|
|
| 344 |
).points
|
| 345 |
|
| 346 |
results = self._retry_call(_do_query)
|
| 347 |
-
|
| 348 |
return [
|
| 349 |
{
|
| 350 |
"id": r.id,
|
|
@@ -353,7 +354,7 @@ class TwoStageRetriever:
|
|
| 353 |
}
|
| 354 |
for r in results
|
| 355 |
]
|
| 356 |
-
|
| 357 |
def _stage2_rerank(
|
| 358 |
self,
|
| 359 |
query_np: np.ndarray,
|
|
@@ -363,10 +364,10 @@ class TwoStageRetriever:
|
|
| 363 |
) -> List[Dict[str, Any]]:
|
| 364 |
"""Stage 2: Rerank with full multi-vector MaxSim scoring."""
|
| 365 |
from visual_rag.embedding.pooling import compute_maxsim_score
|
| 366 |
-
|
| 367 |
# Fetch full embeddings for candidates
|
| 368 |
candidate_ids = [c["id"] for c in candidates]
|
| 369 |
-
|
| 370 |
# Retrieve points with vectors
|
| 371 |
def _do_retrieve():
|
| 372 |
return self.client.retrieve(
|
|
@@ -378,7 +379,7 @@ class TwoStageRetriever:
|
|
| 378 |
)
|
| 379 |
|
| 380 |
points = self._retry_call(_do_retrieve)
|
| 381 |
-
|
| 382 |
# Build ID to embedding map
|
| 383 |
id_to_embedding = {}
|
| 384 |
for point in points:
|
|
@@ -386,13 +387,13 @@ class TwoStageRetriever:
|
|
| 386 |
id_to_embedding[point.id] = np.array(
|
| 387 |
point.vector[self.full_vector_name], dtype=np.float32
|
| 388 |
)
|
| 389 |
-
|
| 390 |
# Compute MaxSim scores
|
| 391 |
reranked = []
|
| 392 |
for candidate in candidates:
|
| 393 |
point_id = candidate["id"]
|
| 394 |
doc_embedding = id_to_embedding.get(point_id)
|
| 395 |
-
|
| 396 |
if doc_embedding is None:
|
| 397 |
# Fallback to stage 1 score
|
| 398 |
candidate["score_stage2"] = candidate["score_stage1"]
|
|
@@ -402,17 +403,17 @@ class TwoStageRetriever:
|
|
| 402 |
maxsim_score = compute_maxsim_score(query_np, doc_embedding)
|
| 403 |
candidate["score_stage2"] = maxsim_score
|
| 404 |
candidate["score_final"] = maxsim_score
|
| 405 |
-
|
| 406 |
if return_embeddings:
|
| 407 |
candidate["embedding"] = doc_embedding
|
| 408 |
-
|
| 409 |
reranked.append(candidate)
|
| 410 |
-
|
| 411 |
# Sort by final score (descending)
|
| 412 |
reranked.sort(key=lambda x: x["score_final"], reverse=True)
|
| 413 |
-
|
| 414 |
return reranked[:top_k]
|
| 415 |
-
|
| 416 |
def _to_numpy(self, embedding: Union[torch.Tensor, np.ndarray]) -> np.ndarray:
|
| 417 |
"""Convert embedding to numpy array."""
|
| 418 |
if isinstance(embedding, torch.Tensor):
|
|
@@ -420,7 +421,7 @@ class TwoStageRetriever:
|
|
| 420 |
return embedding.cpu().float().numpy()
|
| 421 |
return embedding.cpu().numpy()
|
| 422 |
return np.array(embedding, dtype=np.float32)
|
| 423 |
-
|
| 424 |
def build_filter(
|
| 425 |
self,
|
| 426 |
year: Optional[Any] = None,
|
|
@@ -431,60 +432,40 @@ class TwoStageRetriever:
|
|
| 431 |
):
|
| 432 |
"""
|
| 433 |
Build Qdrant filter from parameters.
|
| 434 |
-
|
| 435 |
Supports single values or lists (using MatchAny).
|
| 436 |
"""
|
| 437 |
-
from qdrant_client.models import
|
| 438 |
-
|
| 439 |
conditions = []
|
| 440 |
-
|
| 441 |
if year is not None:
|
| 442 |
if isinstance(year, list):
|
| 443 |
year_values = [int(y) if isinstance(y, str) else y for y in year]
|
| 444 |
-
conditions.append(
|
| 445 |
-
FieldCondition(key="year", match=MatchAny(any=year_values))
|
| 446 |
-
)
|
| 447 |
else:
|
| 448 |
year_value = int(year) if isinstance(year, str) else year
|
| 449 |
-
conditions.append(
|
| 450 |
-
|
| 451 |
-
)
|
| 452 |
-
|
| 453 |
if source is not None:
|
| 454 |
if isinstance(source, list):
|
| 455 |
-
conditions.append(
|
| 456 |
-
FieldCondition(key="source", match=MatchAny(any=source))
|
| 457 |
-
)
|
| 458 |
else:
|
| 459 |
-
conditions.append(
|
| 460 |
-
|
| 461 |
-
)
|
| 462 |
-
|
| 463 |
if district is not None:
|
| 464 |
if isinstance(district, list):
|
| 465 |
-
conditions.append(
|
| 466 |
-
FieldCondition(key="district", match=MatchAny(any=district))
|
| 467 |
-
)
|
| 468 |
else:
|
| 469 |
-
conditions.append(
|
| 470 |
-
|
| 471 |
-
)
|
| 472 |
-
|
| 473 |
if filename is not None:
|
| 474 |
if isinstance(filename, list):
|
| 475 |
-
conditions.append(
|
| 476 |
-
FieldCondition(key="filename", match=MatchAny(any=filename))
|
| 477 |
-
)
|
| 478 |
else:
|
| 479 |
-
conditions.append(
|
| 480 |
-
FieldCondition(key="filename", match=MatchValue(value=filename))
|
| 481 |
-
)
|
| 482 |
-
|
| 483 |
-
if has_text is not None:
|
| 484 |
-
conditions.append(
|
| 485 |
-
FieldCondition(key="has_text", match=MatchValue(value=has_text))
|
| 486 |
-
)
|
| 487 |
-
|
| 488 |
-
return Filter(must=conditions) if conditions else None
|
| 489 |
|
|
|
|
|
|
|
| 490 |
|
|
|
|
|
|
| 17 |
"""
|
| 18 |
|
| 19 |
import logging
|
| 20 |
+
from typing import Any, Dict, List, Optional, Union
|
| 21 |
+
|
| 22 |
import numpy as np
|
| 23 |
import torch
|
| 24 |
|
|
|
|
| 28 |
class TwoStageRetriever:
|
| 29 |
"""
|
| 30 |
Two-stage visual document retrieval with pooling and reranking.
|
| 31 |
+
|
| 32 |
Stage 1 (Prefetch):
|
| 33 |
Uses tile-level mean-pooled vectors for fast HNSW search.
|
| 34 |
Retrieves prefetch_k candidates (e.g., 100-500).
|
| 35 |
+
|
| 36 |
Stage 2 (Rerank):
|
| 37 |
Fetches full multi-vector embeddings for candidates.
|
| 38 |
Computes exact MaxSim scores for precise ranking.
|
| 39 |
Returns top_k results (e.g., 10).
|
| 40 |
+
|
| 41 |
Args:
|
| 42 |
qdrant_client: Connected Qdrant client
|
| 43 |
collection_name: Name of the Qdrant collection
|
| 44 |
full_vector_name: Name of full multi-vector field (default: "initial")
|
| 45 |
pooled_vector_name: Name of pooled vector field (default: "mean_pooling")
|
| 46 |
+
|
| 47 |
Example:
|
| 48 |
>>> retriever = TwoStageRetriever(client, "my_collection")
|
| 49 |
+
>>>
|
| 50 |
>>> # Two-stage search: prefetch 200, return top 10
|
| 51 |
>>> results = retriever.search(
|
| 52 |
... query_embedding=query,
|
| 53 |
... top_k=10,
|
| 54 |
... prefetch_k=200,
|
| 55 |
... )
|
| 56 |
+
>>>
|
| 57 |
>>> # Compare latency:
|
| 58 |
>>> # Full MaxSim (1000 docs): ~500ms
|
| 59 |
>>> # Two-stage (200→10): ~50ms
|
| 60 |
"""
|
| 61 |
+
|
| 62 |
def __init__(
|
| 63 |
self,
|
| 64 |
qdrant_client,
|
|
|
|
| 92 |
last_err = e
|
| 93 |
if attempt >= self.max_retries - 1:
|
| 94 |
break
|
| 95 |
+
time.sleep(self.retry_sleep * (2**attempt))
|
| 96 |
if last_err is not None:
|
| 97 |
raise last_err
|
| 98 |
|
|
|
|
| 106 |
) -> List[Dict[str, Any]]:
|
| 107 |
"""
|
| 108 |
Two-stage retrieval using Qdrant's native prefetch (all server-side).
|
| 109 |
+
|
| 110 |
This is MUCH faster than search() because it avoids network transfer
|
| 111 |
of large multi-vector embeddings. All computation happens in Qdrant.
|
| 112 |
+
|
| 113 |
Args:
|
| 114 |
query_embedding: Query embeddings [num_tokens, dim]
|
| 115 |
top_k: Final number of results
|
| 116 |
prefetch_k: Candidates for stage 1 (default: 10x top_k)
|
| 117 |
filter_obj: Qdrant filter
|
| 118 |
stage1_mode: How to do stage 1 prefetch
|
| 119 |
+
|
| 120 |
Returns:
|
| 121 |
List of results with scores
|
| 122 |
"""
|
| 123 |
from qdrant_client.http import models
|
| 124 |
+
|
| 125 |
query_np = self._to_numpy(query_embedding)
|
| 126 |
+
|
| 127 |
if prefetch_k is None:
|
| 128 |
prefetch_k = max(100, top_k * 10)
|
| 129 |
+
|
| 130 |
if stage1_mode == "pooled_query_vs_tiles":
|
| 131 |
prefetch_query = query_np.mean(axis=0).tolist()
|
| 132 |
prefetch_using = self.pooled_vector_name
|
|
|
|
| 144 |
prefetch_using = self.global_vector_name
|
| 145 |
else:
|
| 146 |
raise ValueError(f"Unknown stage1_mode: {stage1_mode}")
|
| 147 |
+
|
| 148 |
rerank_query = query_np.tolist()
|
| 149 |
+
|
| 150 |
def _do_query():
|
| 151 |
return self.client.query_points(
|
| 152 |
collection_name=self.collection_name,
|
|
|
|
| 165 |
],
|
| 166 |
timeout=self.request_timeout,
|
| 167 |
).points
|
| 168 |
+
|
| 169 |
results = self._retry_call(_do_query)
|
| 170 |
+
|
| 171 |
return [
|
| 172 |
{
|
| 173 |
"id": r.id,
|
|
|
|
| 178 |
}
|
| 179 |
for r in results
|
| 180 |
]
|
| 181 |
+
|
| 182 |
def search(
|
| 183 |
self,
|
| 184 |
query_embedding: Union[torch.Tensor, np.ndarray],
|
|
|
|
| 191 |
) -> List[Dict[str, Any]]:
|
| 192 |
"""
|
| 193 |
Two-stage retrieval: prefetch with pooling, rerank with MaxSim.
|
| 194 |
+
|
| 195 |
Args:
|
| 196 |
query_embedding: Query embeddings [num_tokens, dim]
|
| 197 |
top_k: Final number of results to return
|
|
|
|
| 203 |
- "pooled_query_vs_tiles": pool query to 1×dim and search tile vectors (using="mean_pooling")
|
| 204 |
- "tokens_vs_tiles": search tile vectors with full query tokens (using="mean_pooling")
|
| 205 |
- "pooled_query_vs_global": pool query to 1×dim and search global pooled doc vectors (using="global_pooling")
|
| 206 |
+
|
| 207 |
Returns:
|
| 208 |
List of results with scores and metadata:
|
| 209 |
[
|
|
|
|
| 219 |
"""
|
| 220 |
# Convert to numpy
|
| 221 |
query_np = self._to_numpy(query_embedding)
|
| 222 |
+
|
| 223 |
# Auto-set prefetch_k
|
| 224 |
if prefetch_k is None:
|
| 225 |
prefetch_k = max(100, top_k * 10)
|
| 226 |
+
|
| 227 |
# Stage 1: Prefetch with pooled vectors
|
| 228 |
logger.info(f"🔍 Stage 1: Prefetching {prefetch_k} candidates ({stage1_mode})")
|
| 229 |
candidates = self._stage1_prefetch(
|
|
|
|
| 232 |
filter_obj=filter_obj,
|
| 233 |
stage1_mode=stage1_mode,
|
| 234 |
)
|
| 235 |
+
|
| 236 |
if not candidates:
|
| 237 |
logger.warning("No candidates found in stage 1")
|
| 238 |
return []
|
| 239 |
+
|
| 240 |
logger.info(f"✅ Stage 1: Retrieved {len(candidates)} candidates")
|
| 241 |
+
|
| 242 |
# Stage 2: Rerank with full embeddings
|
| 243 |
if use_reranking and len(candidates) > top_k:
|
| 244 |
+
logger.info("🎯 Stage 2: Reranking with MaxSim...")
|
| 245 |
results = self._stage2_rerank(
|
| 246 |
query_np=query_np,
|
| 247 |
candidates=candidates,
|
|
|
|
| 255 |
for r in results:
|
| 256 |
r["score_final"] = r["score_stage1"]
|
| 257 |
logger.info(f"⏭️ Skipping reranking, returning top {len(results)}")
|
| 258 |
+
|
| 259 |
return results
|
| 260 |
+
|
| 261 |
def search_single_stage(
|
| 262 |
self,
|
| 263 |
query_embedding: Union[torch.Tensor, np.ndarray],
|
|
|
|
| 267 |
) -> List[Dict[str, Any]]:
|
| 268 |
"""
|
| 269 |
Single-stage search (either pooled or full multi-vector).
|
| 270 |
+
|
| 271 |
Args:
|
| 272 |
query_embedding: Query embeddings
|
| 273 |
top_k: Number of results
|
| 274 |
filter_obj: Qdrant filter
|
| 275 |
use_pooling: Use pooled vectors (faster) or full (more accurate)
|
| 276 |
+
|
| 277 |
Returns:
|
| 278 |
List of results
|
| 279 |
"""
|
| 280 |
query_np = self._to_numpy(query_embedding)
|
| 281 |
+
|
| 282 |
if use_pooling:
|
| 283 |
# Pool query and search pooled vectors
|
| 284 |
query_pooled = query_np.mean(axis=0)
|
|
|
|
| 290 |
vector_name = self.full_vector_name
|
| 291 |
query_vector = query_np.tolist()
|
| 292 |
logger.info(f"🎯 Multi-vector search: {vector_name}")
|
| 293 |
+
|
| 294 |
results = self.client.query_points(
|
| 295 |
collection_name=self.collection_name,
|
| 296 |
query=query_vector,
|
|
|
|
| 301 |
with_vectors=False,
|
| 302 |
timeout=120,
|
| 303 |
).points
|
| 304 |
+
|
| 305 |
return [
|
| 306 |
{
|
| 307 |
"id": r.id,
|
|
|
|
| 311 |
}
|
| 312 |
for r in results
|
| 313 |
]
|
| 314 |
+
|
| 315 |
def _stage1_prefetch(
|
| 316 |
self,
|
| 317 |
query_np: np.ndarray,
|
|
|
|
| 331 |
vector_name = self.global_vector_name
|
| 332 |
else:
|
| 333 |
raise ValueError(f"Unknown stage1_mode: {stage1_mode}")
|
| 334 |
+
|
| 335 |
def _do_query():
|
| 336 |
return self.client.query_points(
|
| 337 |
collection_name=self.collection_name,
|
|
|
|
| 345 |
).points
|
| 346 |
|
| 347 |
results = self._retry_call(_do_query)
|
| 348 |
+
|
| 349 |
return [
|
| 350 |
{
|
| 351 |
"id": r.id,
|
|
|
|
| 354 |
}
|
| 355 |
for r in results
|
| 356 |
]
|
| 357 |
+
|
| 358 |
def _stage2_rerank(
|
| 359 |
self,
|
| 360 |
query_np: np.ndarray,
|
|
|
|
| 364 |
) -> List[Dict[str, Any]]:
|
| 365 |
"""Stage 2: Rerank with full multi-vector MaxSim scoring."""
|
| 366 |
from visual_rag.embedding.pooling import compute_maxsim_score
|
| 367 |
+
|
| 368 |
# Fetch full embeddings for candidates
|
| 369 |
candidate_ids = [c["id"] for c in candidates]
|
| 370 |
+
|
| 371 |
# Retrieve points with vectors
|
| 372 |
def _do_retrieve():
|
| 373 |
return self.client.retrieve(
|
|
|
|
| 379 |
)
|
| 380 |
|
| 381 |
points = self._retry_call(_do_retrieve)
|
| 382 |
+
|
| 383 |
# Build ID to embedding map
|
| 384 |
id_to_embedding = {}
|
| 385 |
for point in points:
|
|
|
|
| 387 |
id_to_embedding[point.id] = np.array(
|
| 388 |
point.vector[self.full_vector_name], dtype=np.float32
|
| 389 |
)
|
| 390 |
+
|
| 391 |
# Compute MaxSim scores
|
| 392 |
reranked = []
|
| 393 |
for candidate in candidates:
|
| 394 |
point_id = candidate["id"]
|
| 395 |
doc_embedding = id_to_embedding.get(point_id)
|
| 396 |
+
|
| 397 |
if doc_embedding is None:
|
| 398 |
# Fallback to stage 1 score
|
| 399 |
candidate["score_stage2"] = candidate["score_stage1"]
|
|
|
|
| 403 |
maxsim_score = compute_maxsim_score(query_np, doc_embedding)
|
| 404 |
candidate["score_stage2"] = maxsim_score
|
| 405 |
candidate["score_final"] = maxsim_score
|
| 406 |
+
|
| 407 |
if return_embeddings:
|
| 408 |
candidate["embedding"] = doc_embedding
|
| 409 |
+
|
| 410 |
reranked.append(candidate)
|
| 411 |
+
|
| 412 |
# Sort by final score (descending)
|
| 413 |
reranked.sort(key=lambda x: x["score_final"], reverse=True)
|
| 414 |
+
|
| 415 |
return reranked[:top_k]
|
| 416 |
+
|
| 417 |
def _to_numpy(self, embedding: Union[torch.Tensor, np.ndarray]) -> np.ndarray:
|
| 418 |
"""Convert embedding to numpy array."""
|
| 419 |
if isinstance(embedding, torch.Tensor):
|
|
|
|
| 421 |
return embedding.cpu().float().numpy()
|
| 422 |
return embedding.cpu().numpy()
|
| 423 |
return np.array(embedding, dtype=np.float32)
|
| 424 |
+
|
| 425 |
def build_filter(
|
| 426 |
self,
|
| 427 |
year: Optional[Any] = None,
|
|
|
|
| 432 |
):
|
| 433 |
"""
|
| 434 |
Build Qdrant filter from parameters.
|
| 435 |
+
|
| 436 |
Supports single values or lists (using MatchAny).
|
| 437 |
"""
|
| 438 |
+
from qdrant_client.models import FieldCondition, Filter, MatchAny, MatchValue
|
| 439 |
+
|
| 440 |
conditions = []
|
| 441 |
+
|
| 442 |
if year is not None:
|
| 443 |
if isinstance(year, list):
|
| 444 |
year_values = [int(y) if isinstance(y, str) else y for y in year]
|
| 445 |
+
conditions.append(FieldCondition(key="year", match=MatchAny(any=year_values)))
|
|
|
|
|
|
|
| 446 |
else:
|
| 447 |
year_value = int(year) if isinstance(year, str) else year
|
| 448 |
+
conditions.append(FieldCondition(key="year", match=MatchValue(value=year_value)))
|
| 449 |
+
|
|
|
|
|
|
|
| 450 |
if source is not None:
|
| 451 |
if isinstance(source, list):
|
| 452 |
+
conditions.append(FieldCondition(key="source", match=MatchAny(any=source)))
|
|
|
|
|
|
|
| 453 |
else:
|
| 454 |
+
conditions.append(FieldCondition(key="source", match=MatchValue(value=source)))
|
| 455 |
+
|
|
|
|
|
|
|
| 456 |
if district is not None:
|
| 457 |
if isinstance(district, list):
|
| 458 |
+
conditions.append(FieldCondition(key="district", match=MatchAny(any=district)))
|
|
|
|
|
|
|
| 459 |
else:
|
| 460 |
+
conditions.append(FieldCondition(key="district", match=MatchValue(value=district)))
|
| 461 |
+
|
|
|
|
|
|
|
| 462 |
if filename is not None:
|
| 463 |
if isinstance(filename, list):
|
| 464 |
+
conditions.append(FieldCondition(key="filename", match=MatchAny(any=filename)))
|
|
|
|
|
|
|
| 465 |
else:
|
| 466 |
+
conditions.append(FieldCondition(key="filename", match=MatchValue(value=filename)))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 467 |
|
| 468 |
+
if has_text is not None:
|
| 469 |
+
conditions.append(FieldCondition(key="has_text", match=MatchValue(value=has_text)))
|
| 470 |
|
| 471 |
+
return Filter(must=conditions) if conditions else None
|
visual_rag/visualization/__init__.py
CHANGED
|
@@ -7,8 +7,8 @@ This module provides:
|
|
| 7 |
"""
|
| 8 |
|
| 9 |
from visual_rag.visualization.saliency import (
|
| 10 |
-
generate_saliency_map,
|
| 11 |
create_saliency_overlay,
|
|
|
|
| 12 |
visualize_search_results,
|
| 13 |
)
|
| 14 |
|
|
|
|
| 7 |
"""
|
| 8 |
|
| 9 |
from visual_rag.visualization.saliency import (
|
|
|
|
| 10 |
create_saliency_overlay,
|
| 11 |
+
generate_saliency_map,
|
| 12 |
visualize_search_results,
|
| 13 |
)
|
| 14 |
|
visual_rag/visualization/saliency.py
CHANGED
|
@@ -5,10 +5,11 @@ Generates attention/saliency maps to visualize which parts of documents
|
|
| 5 |
are most relevant to a query.
|
| 6 |
"""
|
| 7 |
|
| 8 |
-
import numpy as np
|
| 9 |
-
from PIL import Image, ImageDraw, ImageFont
|
| 10 |
-
from typing import List, Dict, Any, Optional, Tuple, Union
|
| 11 |
import logging
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
logger = logging.getLogger(__name__)
|
| 14 |
|
|
@@ -24,9 +25,9 @@ def generate_saliency_map(
|
|
| 24 |
) -> Tuple[Image.Image, np.ndarray]:
|
| 25 |
"""
|
| 26 |
Generate saliency map showing which parts of the image match the query.
|
| 27 |
-
|
| 28 |
Computes patch-level relevance scores and overlays them on the image.
|
| 29 |
-
|
| 30 |
Args:
|
| 31 |
query_embedding: Query embeddings [num_query_tokens, dim]
|
| 32 |
doc_embedding: Document visual embeddings [num_visual_tokens, dim]
|
|
@@ -35,10 +36,10 @@ def generate_saliency_map(
|
|
| 35 |
colormap: Matplotlib colormap name (Reds, viridis, jet, etc.)
|
| 36 |
alpha: Overlay transparency (0-1)
|
| 37 |
threshold_percentile: Only highlight patches above this percentile
|
| 38 |
-
|
| 39 |
Returns:
|
| 40 |
Tuple of (annotated_image, patch_scores)
|
| 41 |
-
|
| 42 |
Example:
|
| 43 |
>>> query = embedder.embed_query("budget allocation")
|
| 44 |
>>> doc = visual_embedding # From embed_images
|
|
@@ -51,57 +52,57 @@ def generate_saliency_map(
|
|
| 51 |
>>> annotated.save("saliency.png")
|
| 52 |
"""
|
| 53 |
# Ensure numpy arrays
|
| 54 |
-
if hasattr(query_embedding,
|
| 55 |
query_np = query_embedding.numpy()
|
| 56 |
-
elif hasattr(query_embedding,
|
| 57 |
query_np = query_embedding.cpu().numpy()
|
| 58 |
else:
|
| 59 |
query_np = np.array(query_embedding, dtype=np.float32)
|
| 60 |
-
|
| 61 |
-
if hasattr(doc_embedding,
|
| 62 |
doc_np = doc_embedding.numpy()
|
| 63 |
-
elif hasattr(doc_embedding,
|
| 64 |
doc_np = doc_embedding.cpu().numpy()
|
| 65 |
else:
|
| 66 |
doc_np = np.array(doc_embedding, dtype=np.float32)
|
| 67 |
-
|
| 68 |
# Normalize embeddings
|
| 69 |
query_norm = query_np / (np.linalg.norm(query_np, axis=1, keepdims=True) + 1e-8)
|
| 70 |
doc_norm = doc_np / (np.linalg.norm(doc_np, axis=1, keepdims=True) + 1e-8)
|
| 71 |
-
|
| 72 |
# Compute similarity matrix: [num_query, num_doc]
|
| 73 |
similarity_matrix = np.dot(query_norm, doc_norm.T)
|
| 74 |
-
|
| 75 |
# Get max similarity per document patch (best match from any query token)
|
| 76 |
patch_scores = similarity_matrix.max(axis=0)
|
| 77 |
-
|
| 78 |
# Normalize to [0, 1]
|
| 79 |
score_min, score_max = patch_scores.min(), patch_scores.max()
|
| 80 |
if score_max - score_min > 1e-8:
|
| 81 |
patch_scores_norm = (patch_scores - score_min) / (score_max - score_min)
|
| 82 |
else:
|
| 83 |
patch_scores_norm = np.zeros_like(patch_scores)
|
| 84 |
-
|
| 85 |
# Determine grid dimensions
|
| 86 |
if token_info and token_info.get("n_rows") and token_info.get("n_cols"):
|
| 87 |
n_rows = token_info["n_rows"]
|
| 88 |
n_cols = token_info["n_cols"]
|
| 89 |
num_tiles = n_rows * n_cols + 1 # +1 for global tile
|
| 90 |
patches_per_tile = 64 # ColSmol standard
|
| 91 |
-
|
| 92 |
# Reshape to tile grid (excluding global tile)
|
| 93 |
try:
|
| 94 |
# Skip global tile patches at the end
|
| 95 |
tile_patches = num_tiles * patches_per_tile
|
| 96 |
if len(patch_scores_norm) >= tile_patches:
|
| 97 |
-
grid_patches = patch_scores_norm[:n_rows * n_cols * patches_per_tile]
|
| 98 |
else:
|
| 99 |
grid_patches = patch_scores_norm
|
| 100 |
-
|
| 101 |
# Reshape: [tiles * patches_per_tile] -> [tiles, patches_per_tile]
|
| 102 |
# Then mean per tile
|
| 103 |
num_grid_tiles = n_rows * n_cols
|
| 104 |
-
grid_patches = grid_patches[:num_grid_tiles * patches_per_tile]
|
| 105 |
tile_scores = grid_patches.reshape(num_grid_tiles, patches_per_tile).mean(axis=1)
|
| 106 |
tile_scores = tile_scores.reshape(n_rows, n_cols)
|
| 107 |
except Exception as e:
|
|
@@ -110,7 +111,7 @@ def generate_saliency_map(
|
|
| 110 |
else:
|
| 111 |
tile_scores = None
|
| 112 |
n_rows = n_cols = None
|
| 113 |
-
|
| 114 |
# Create overlay
|
| 115 |
annotated = create_saliency_overlay(
|
| 116 |
image=image,
|
|
@@ -121,7 +122,7 @@ def generate_saliency_map(
|
|
| 121 |
grid_rows=n_rows,
|
| 122 |
grid_cols=n_cols,
|
| 123 |
)
|
| 124 |
-
|
| 125 |
return annotated, patch_scores
|
| 126 |
|
| 127 |
|
|
@@ -136,7 +137,7 @@ def create_saliency_overlay(
|
|
| 136 |
) -> Image.Image:
|
| 137 |
"""
|
| 138 |
Create colored overlay on image based on scores.
|
| 139 |
-
|
| 140 |
Args:
|
| 141 |
image: Base PIL Image
|
| 142 |
scores: Score array - 1D [num_patches] or 2D [rows, cols]
|
|
@@ -144,7 +145,7 @@ def create_saliency_overlay(
|
|
| 144 |
alpha: Overlay transparency
|
| 145 |
threshold_percentile: Only color patches above this percentile
|
| 146 |
grid_rows, grid_cols: Grid dimensions (auto-detected if not provided)
|
| 147 |
-
|
| 148 |
Returns:
|
| 149 |
Annotated PIL Image
|
| 150 |
"""
|
|
@@ -153,10 +154,10 @@ def create_saliency_overlay(
|
|
| 153 |
except ImportError:
|
| 154 |
logger.warning("matplotlib not installed, returning original image")
|
| 155 |
return image
|
| 156 |
-
|
| 157 |
img_array = np.array(image)
|
| 158 |
h, w = img_array.shape[:2]
|
| 159 |
-
|
| 160 |
# Handle 2D scores (tile grid)
|
| 161 |
if scores.ndim == 2:
|
| 162 |
rows, cols = scores.shape
|
|
@@ -171,58 +172,58 @@ def create_saliency_overlay(
|
|
| 171 |
aspect = w / h
|
| 172 |
cols = int(np.sqrt(num_patches * aspect))
|
| 173 |
rows = max(1, num_patches // cols)
|
| 174 |
-
scores = scores[:rows * cols].reshape(rows, cols)
|
| 175 |
else:
|
| 176 |
# Auto-estimate grid
|
| 177 |
num_patches = len(scores) if scores.ndim == 1 else scores.size
|
| 178 |
aspect = w / h
|
| 179 |
cols = max(1, int(np.sqrt(num_patches * aspect)))
|
| 180 |
rows = max(1, num_patches // cols)
|
| 181 |
-
|
| 182 |
if rows * cols > len(scores) if scores.ndim == 1 else scores.size:
|
| 183 |
cols = max(1, cols - 1)
|
| 184 |
-
|
| 185 |
if scores.ndim == 1:
|
| 186 |
-
scores = scores[:rows * cols].reshape(rows, cols)
|
| 187 |
-
|
| 188 |
# Get colormap
|
| 189 |
cmap = plt.cm.get_cmap(colormap)
|
| 190 |
-
|
| 191 |
# Calculate threshold
|
| 192 |
threshold = np.percentile(scores, threshold_percentile)
|
| 193 |
-
|
| 194 |
# Calculate cell dimensions
|
| 195 |
cell_h = h // rows
|
| 196 |
cell_w = w // cols
|
| 197 |
-
|
| 198 |
# Create RGBA overlay
|
| 199 |
overlay = np.zeros((h, w, 4), dtype=np.uint8)
|
| 200 |
-
|
| 201 |
for i in range(rows):
|
| 202 |
for j in range(cols):
|
| 203 |
score = scores[i, j]
|
| 204 |
-
|
| 205 |
if score >= threshold:
|
| 206 |
y1 = i * cell_h
|
| 207 |
y2 = min((i + 1) * cell_h, h)
|
| 208 |
x1 = j * cell_w
|
| 209 |
x2 = min((j + 1) * cell_w, w)
|
| 210 |
-
|
| 211 |
# Normalize score for coloring (above threshold)
|
| 212 |
norm_score = (score - threshold) / (1.0 - threshold + 1e-8)
|
| 213 |
norm_score = min(1.0, max(0.0, norm_score))
|
| 214 |
-
|
| 215 |
# Get color
|
| 216 |
color = cmap(norm_score)[:3]
|
| 217 |
color_uint8 = (np.array(color) * 255).astype(np.uint8)
|
| 218 |
-
|
| 219 |
overlay[y1:y2, x1:x2, :3] = color_uint8
|
| 220 |
overlay[y1:y2, x1:x2, 3] = int(alpha * 255 * norm_score)
|
| 221 |
-
|
| 222 |
# Blend with original
|
| 223 |
overlay_img = Image.fromarray(overlay, "RGBA")
|
| 224 |
result = Image.alpha_composite(image.convert("RGBA"), overlay_img)
|
| 225 |
-
|
| 226 |
return result.convert("RGB")
|
| 227 |
|
| 228 |
|
|
@@ -237,7 +238,7 @@ def visualize_search_results(
|
|
| 237 |
) -> Optional[Image.Image]:
|
| 238 |
"""
|
| 239 |
Visualize search results as a grid of images with scores.
|
| 240 |
-
|
| 241 |
Args:
|
| 242 |
query: Original query text
|
| 243 |
results: List of search results with 'payload' containing 'page' (image URL/base64)
|
|
@@ -246,7 +247,7 @@ def visualize_search_results(
|
|
| 246 |
output_path: Path to save visualization (optional)
|
| 247 |
max_results: Maximum results to show
|
| 248 |
show_saliency: Generate saliency overlays (requires query_embedding & embeddings)
|
| 249 |
-
|
| 250 |
Returns:
|
| 251 |
Combined visualization image if successful
|
| 252 |
"""
|
|
@@ -255,32 +256,32 @@ def visualize_search_results(
|
|
| 255 |
except ImportError:
|
| 256 |
logger.error("matplotlib required for visualization")
|
| 257 |
return None
|
| 258 |
-
|
| 259 |
results = results[:max_results]
|
| 260 |
n = len(results)
|
| 261 |
-
|
| 262 |
if n == 0:
|
| 263 |
logger.warning("No results to visualize")
|
| 264 |
return None
|
| 265 |
-
|
| 266 |
fig, axes = plt.subplots(1, n, figsize=(4 * n, 4))
|
| 267 |
if n == 1:
|
| 268 |
axes = [axes]
|
| 269 |
-
|
| 270 |
for idx, (result, ax) in enumerate(zip(results, axes)):
|
| 271 |
payload = result.get("payload", {})
|
| 272 |
score = result.get("score_final", result.get("score_stage1", 0))
|
| 273 |
-
|
| 274 |
# Try to load image from payload
|
| 275 |
page_data = payload.get("page", "")
|
| 276 |
image = None
|
| 277 |
-
|
| 278 |
if page_data.startswith("data:image"):
|
| 279 |
# Base64 encoded
|
| 280 |
try:
|
| 281 |
import base64
|
| 282 |
from io import BytesIO
|
| 283 |
-
|
| 284 |
b64_data = page_data.split(",")[1]
|
| 285 |
image = Image.open(BytesIO(base64.b64decode(b64_data)))
|
| 286 |
except Exception as e:
|
|
@@ -290,50 +291,45 @@ def visualize_search_results(
|
|
| 290 |
try:
|
| 291 |
import urllib.request
|
| 292 |
from io import BytesIO
|
| 293 |
-
|
| 294 |
with urllib.request.urlopen(page_data, timeout=5) as response:
|
| 295 |
image = Image.open(BytesIO(response.read()))
|
| 296 |
except Exception as e:
|
| 297 |
logger.debug(f"Could not fetch image URL: {e}")
|
| 298 |
-
|
| 299 |
if image:
|
| 300 |
ax.imshow(image)
|
| 301 |
else:
|
| 302 |
# Show placeholder
|
| 303 |
-
ax.text(
|
| 304 |
-
|
| 305 |
-
ha="center", va="center",
|
| 306 |
-
fontsize=12, color="gray"
|
| 307 |
-
)
|
| 308 |
-
|
| 309 |
# Add title
|
| 310 |
title = f"Rank {idx + 1}\nScore: {score:.3f}"
|
| 311 |
if payload.get("filename"):
|
| 312 |
title += f"\n{payload['filename'][:30]}"
|
| 313 |
if payload.get("page_number") is not None:
|
| 314 |
title += f" p.{payload['page_number'] + 1}"
|
| 315 |
-
|
| 316 |
ax.set_title(title, fontsize=9)
|
| 317 |
ax.axis("off")
|
| 318 |
-
|
| 319 |
# Add query as suptitle
|
| 320 |
query_display = query[:80] + "..." if len(query) > 80 else query
|
| 321 |
plt.suptitle(f"Query: {query_display}", fontsize=11, fontweight="bold")
|
| 322 |
plt.tight_layout()
|
| 323 |
-
|
| 324 |
if output_path:
|
| 325 |
plt.savefig(output_path, dpi=150, bbox_inches="tight")
|
| 326 |
logger.info(f"💾 Saved visualization to: {output_path}")
|
| 327 |
-
|
| 328 |
# Convert to PIL Image for return
|
| 329 |
from io import BytesIO
|
|
|
|
| 330 |
buf = BytesIO()
|
| 331 |
plt.savefig(buf, format="png", dpi=100, bbox_inches="tight")
|
| 332 |
buf.seek(0)
|
| 333 |
result_image = Image.open(buf)
|
| 334 |
-
|
| 335 |
-
plt.close()
|
| 336 |
-
|
| 337 |
-
return result_image
|
| 338 |
|
|
|
|
| 339 |
|
|
|
|
|
|
| 5 |
are most relevant to a query.
|
| 6 |
"""
|
| 7 |
|
|
|
|
|
|
|
|
|
|
| 8 |
import logging
|
| 9 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
from PIL import Image
|
| 13 |
|
| 14 |
logger = logging.getLogger(__name__)
|
| 15 |
|
|
|
|
| 25 |
) -> Tuple[Image.Image, np.ndarray]:
|
| 26 |
"""
|
| 27 |
Generate saliency map showing which parts of the image match the query.
|
| 28 |
+
|
| 29 |
Computes patch-level relevance scores and overlays them on the image.
|
| 30 |
+
|
| 31 |
Args:
|
| 32 |
query_embedding: Query embeddings [num_query_tokens, dim]
|
| 33 |
doc_embedding: Document visual embeddings [num_visual_tokens, dim]
|
|
|
|
| 36 |
colormap: Matplotlib colormap name (Reds, viridis, jet, etc.)
|
| 37 |
alpha: Overlay transparency (0-1)
|
| 38 |
threshold_percentile: Only highlight patches above this percentile
|
| 39 |
+
|
| 40 |
Returns:
|
| 41 |
Tuple of (annotated_image, patch_scores)
|
| 42 |
+
|
| 43 |
Example:
|
| 44 |
>>> query = embedder.embed_query("budget allocation")
|
| 45 |
>>> doc = visual_embedding # From embed_images
|
|
|
|
| 52 |
>>> annotated.save("saliency.png")
|
| 53 |
"""
|
| 54 |
# Ensure numpy arrays
|
| 55 |
+
if hasattr(query_embedding, "numpy"):
|
| 56 |
query_np = query_embedding.numpy()
|
| 57 |
+
elif hasattr(query_embedding, "cpu"):
|
| 58 |
query_np = query_embedding.cpu().numpy()
|
| 59 |
else:
|
| 60 |
query_np = np.array(query_embedding, dtype=np.float32)
|
| 61 |
+
|
| 62 |
+
if hasattr(doc_embedding, "numpy"):
|
| 63 |
doc_np = doc_embedding.numpy()
|
| 64 |
+
elif hasattr(doc_embedding, "cpu"):
|
| 65 |
doc_np = doc_embedding.cpu().numpy()
|
| 66 |
else:
|
| 67 |
doc_np = np.array(doc_embedding, dtype=np.float32)
|
| 68 |
+
|
| 69 |
# Normalize embeddings
|
| 70 |
query_norm = query_np / (np.linalg.norm(query_np, axis=1, keepdims=True) + 1e-8)
|
| 71 |
doc_norm = doc_np / (np.linalg.norm(doc_np, axis=1, keepdims=True) + 1e-8)
|
| 72 |
+
|
| 73 |
# Compute similarity matrix: [num_query, num_doc]
|
| 74 |
similarity_matrix = np.dot(query_norm, doc_norm.T)
|
| 75 |
+
|
| 76 |
# Get max similarity per document patch (best match from any query token)
|
| 77 |
patch_scores = similarity_matrix.max(axis=0)
|
| 78 |
+
|
| 79 |
# Normalize to [0, 1]
|
| 80 |
score_min, score_max = patch_scores.min(), patch_scores.max()
|
| 81 |
if score_max - score_min > 1e-8:
|
| 82 |
patch_scores_norm = (patch_scores - score_min) / (score_max - score_min)
|
| 83 |
else:
|
| 84 |
patch_scores_norm = np.zeros_like(patch_scores)
|
| 85 |
+
|
| 86 |
# Determine grid dimensions
|
| 87 |
if token_info and token_info.get("n_rows") and token_info.get("n_cols"):
|
| 88 |
n_rows = token_info["n_rows"]
|
| 89 |
n_cols = token_info["n_cols"]
|
| 90 |
num_tiles = n_rows * n_cols + 1 # +1 for global tile
|
| 91 |
patches_per_tile = 64 # ColSmol standard
|
| 92 |
+
|
| 93 |
# Reshape to tile grid (excluding global tile)
|
| 94 |
try:
|
| 95 |
# Skip global tile patches at the end
|
| 96 |
tile_patches = num_tiles * patches_per_tile
|
| 97 |
if len(patch_scores_norm) >= tile_patches:
|
| 98 |
+
grid_patches = patch_scores_norm[: n_rows * n_cols * patches_per_tile]
|
| 99 |
else:
|
| 100 |
grid_patches = patch_scores_norm
|
| 101 |
+
|
| 102 |
# Reshape: [tiles * patches_per_tile] -> [tiles, patches_per_tile]
|
| 103 |
# Then mean per tile
|
| 104 |
num_grid_tiles = n_rows * n_cols
|
| 105 |
+
grid_patches = grid_patches[: num_grid_tiles * patches_per_tile]
|
| 106 |
tile_scores = grid_patches.reshape(num_grid_tiles, patches_per_tile).mean(axis=1)
|
| 107 |
tile_scores = tile_scores.reshape(n_rows, n_cols)
|
| 108 |
except Exception as e:
|
|
|
|
| 111 |
else:
|
| 112 |
tile_scores = None
|
| 113 |
n_rows = n_cols = None
|
| 114 |
+
|
| 115 |
# Create overlay
|
| 116 |
annotated = create_saliency_overlay(
|
| 117 |
image=image,
|
|
|
|
| 122 |
grid_rows=n_rows,
|
| 123 |
grid_cols=n_cols,
|
| 124 |
)
|
| 125 |
+
|
| 126 |
return annotated, patch_scores
|
| 127 |
|
| 128 |
|
|
|
|
| 137 |
) -> Image.Image:
|
| 138 |
"""
|
| 139 |
Create colored overlay on image based on scores.
|
| 140 |
+
|
| 141 |
Args:
|
| 142 |
image: Base PIL Image
|
| 143 |
scores: Score array - 1D [num_patches] or 2D [rows, cols]
|
|
|
|
| 145 |
alpha: Overlay transparency
|
| 146 |
threshold_percentile: Only color patches above this percentile
|
| 147 |
grid_rows, grid_cols: Grid dimensions (auto-detected if not provided)
|
| 148 |
+
|
| 149 |
Returns:
|
| 150 |
Annotated PIL Image
|
| 151 |
"""
|
|
|
|
| 154 |
except ImportError:
|
| 155 |
logger.warning("matplotlib not installed, returning original image")
|
| 156 |
return image
|
| 157 |
+
|
| 158 |
img_array = np.array(image)
|
| 159 |
h, w = img_array.shape[:2]
|
| 160 |
+
|
| 161 |
# Handle 2D scores (tile grid)
|
| 162 |
if scores.ndim == 2:
|
| 163 |
rows, cols = scores.shape
|
|
|
|
| 172 |
aspect = w / h
|
| 173 |
cols = int(np.sqrt(num_patches * aspect))
|
| 174 |
rows = max(1, num_patches // cols)
|
| 175 |
+
scores = scores[: rows * cols].reshape(rows, cols)
|
| 176 |
else:
|
| 177 |
# Auto-estimate grid
|
| 178 |
num_patches = len(scores) if scores.ndim == 1 else scores.size
|
| 179 |
aspect = w / h
|
| 180 |
cols = max(1, int(np.sqrt(num_patches * aspect)))
|
| 181 |
rows = max(1, num_patches // cols)
|
| 182 |
+
|
| 183 |
if rows * cols > len(scores) if scores.ndim == 1 else scores.size:
|
| 184 |
cols = max(1, cols - 1)
|
| 185 |
+
|
| 186 |
if scores.ndim == 1:
|
| 187 |
+
scores = scores[: rows * cols].reshape(rows, cols)
|
| 188 |
+
|
| 189 |
# Get colormap
|
| 190 |
cmap = plt.cm.get_cmap(colormap)
|
| 191 |
+
|
| 192 |
# Calculate threshold
|
| 193 |
threshold = np.percentile(scores, threshold_percentile)
|
| 194 |
+
|
| 195 |
# Calculate cell dimensions
|
| 196 |
cell_h = h // rows
|
| 197 |
cell_w = w // cols
|
| 198 |
+
|
| 199 |
# Create RGBA overlay
|
| 200 |
overlay = np.zeros((h, w, 4), dtype=np.uint8)
|
| 201 |
+
|
| 202 |
for i in range(rows):
|
| 203 |
for j in range(cols):
|
| 204 |
score = scores[i, j]
|
| 205 |
+
|
| 206 |
if score >= threshold:
|
| 207 |
y1 = i * cell_h
|
| 208 |
y2 = min((i + 1) * cell_h, h)
|
| 209 |
x1 = j * cell_w
|
| 210 |
x2 = min((j + 1) * cell_w, w)
|
| 211 |
+
|
| 212 |
# Normalize score for coloring (above threshold)
|
| 213 |
norm_score = (score - threshold) / (1.0 - threshold + 1e-8)
|
| 214 |
norm_score = min(1.0, max(0.0, norm_score))
|
| 215 |
+
|
| 216 |
# Get color
|
| 217 |
color = cmap(norm_score)[:3]
|
| 218 |
color_uint8 = (np.array(color) * 255).astype(np.uint8)
|
| 219 |
+
|
| 220 |
overlay[y1:y2, x1:x2, :3] = color_uint8
|
| 221 |
overlay[y1:y2, x1:x2, 3] = int(alpha * 255 * norm_score)
|
| 222 |
+
|
| 223 |
# Blend with original
|
| 224 |
overlay_img = Image.fromarray(overlay, "RGBA")
|
| 225 |
result = Image.alpha_composite(image.convert("RGBA"), overlay_img)
|
| 226 |
+
|
| 227 |
return result.convert("RGB")
|
| 228 |
|
| 229 |
|
|
|
|
| 238 |
) -> Optional[Image.Image]:
|
| 239 |
"""
|
| 240 |
Visualize search results as a grid of images with scores.
|
| 241 |
+
|
| 242 |
Args:
|
| 243 |
query: Original query text
|
| 244 |
results: List of search results with 'payload' containing 'page' (image URL/base64)
|
|
|
|
| 247 |
output_path: Path to save visualization (optional)
|
| 248 |
max_results: Maximum results to show
|
| 249 |
show_saliency: Generate saliency overlays (requires query_embedding & embeddings)
|
| 250 |
+
|
| 251 |
Returns:
|
| 252 |
Combined visualization image if successful
|
| 253 |
"""
|
|
|
|
| 256 |
except ImportError:
|
| 257 |
logger.error("matplotlib required for visualization")
|
| 258 |
return None
|
| 259 |
+
|
| 260 |
results = results[:max_results]
|
| 261 |
n = len(results)
|
| 262 |
+
|
| 263 |
if n == 0:
|
| 264 |
logger.warning("No results to visualize")
|
| 265 |
return None
|
| 266 |
+
|
| 267 |
fig, axes = plt.subplots(1, n, figsize=(4 * n, 4))
|
| 268 |
if n == 1:
|
| 269 |
axes = [axes]
|
| 270 |
+
|
| 271 |
for idx, (result, ax) in enumerate(zip(results, axes)):
|
| 272 |
payload = result.get("payload", {})
|
| 273 |
score = result.get("score_final", result.get("score_stage1", 0))
|
| 274 |
+
|
| 275 |
# Try to load image from payload
|
| 276 |
page_data = payload.get("page", "")
|
| 277 |
image = None
|
| 278 |
+
|
| 279 |
if page_data.startswith("data:image"):
|
| 280 |
# Base64 encoded
|
| 281 |
try:
|
| 282 |
import base64
|
| 283 |
from io import BytesIO
|
| 284 |
+
|
| 285 |
b64_data = page_data.split(",")[1]
|
| 286 |
image = Image.open(BytesIO(base64.b64decode(b64_data)))
|
| 287 |
except Exception as e:
|
|
|
|
| 291 |
try:
|
| 292 |
import urllib.request
|
| 293 |
from io import BytesIO
|
| 294 |
+
|
| 295 |
with urllib.request.urlopen(page_data, timeout=5) as response:
|
| 296 |
image = Image.open(BytesIO(response.read()))
|
| 297 |
except Exception as e:
|
| 298 |
logger.debug(f"Could not fetch image URL: {e}")
|
| 299 |
+
|
| 300 |
if image:
|
| 301 |
ax.imshow(image)
|
| 302 |
else:
|
| 303 |
# Show placeholder
|
| 304 |
+
ax.text(0.5, 0.5, "No image", ha="center", va="center", fontsize=12, color="gray")
|
| 305 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
| 306 |
# Add title
|
| 307 |
title = f"Rank {idx + 1}\nScore: {score:.3f}"
|
| 308 |
if payload.get("filename"):
|
| 309 |
title += f"\n{payload['filename'][:30]}"
|
| 310 |
if payload.get("page_number") is not None:
|
| 311 |
title += f" p.{payload['page_number'] + 1}"
|
| 312 |
+
|
| 313 |
ax.set_title(title, fontsize=9)
|
| 314 |
ax.axis("off")
|
| 315 |
+
|
| 316 |
# Add query as suptitle
|
| 317 |
query_display = query[:80] + "..." if len(query) > 80 else query
|
| 318 |
plt.suptitle(f"Query: {query_display}", fontsize=11, fontweight="bold")
|
| 319 |
plt.tight_layout()
|
| 320 |
+
|
| 321 |
if output_path:
|
| 322 |
plt.savefig(output_path, dpi=150, bbox_inches="tight")
|
| 323 |
logger.info(f"💾 Saved visualization to: {output_path}")
|
| 324 |
+
|
| 325 |
# Convert to PIL Image for return
|
| 326 |
from io import BytesIO
|
| 327 |
+
|
| 328 |
buf = BytesIO()
|
| 329 |
plt.savefig(buf, format="png", dpi=100, bbox_inches="tight")
|
| 330 |
buf.seek(0)
|
| 331 |
result_image = Image.open(buf)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 332 |
|
| 333 |
+
plt.close()
|
| 334 |
|
| 335 |
+
return result_image
|