visual-doc-rag / src /visual_doc_rag /visual_index.py
tungpth's picture
Deploy slim ZeroGPU bundle with pinned model startup downloads (part 6)
86e797b verified
Raw History Blame Contribute Delete
6.61 kB
"""Official ColModernVBERT encoding, float16 disk cache, CPU MaxSim search."""
import hashlib
import json
from importlib.metadata import version
from pathlib import Path
from time import perf_counter
import torch
from PIL import Image
from torch.nn.utils.rnn import pad_sequence
from visual_doc_rag.data import write_json
from visual_doc_rag.maxsim import maxsim
from visual_doc_rag.models import cache_model
from visual_doc_rag.pages import load_pages
from visual_doc_rag.retrieve import rank_scores
class Encoder:
def __init__(self, data_dir: Path, model_info: dict, device: str):
from colpali_engine.models import ColModernVBert, ColModernVBertProcessor
self.device = torch.device(device)
self.processor = ColModernVBertProcessor.from_pretrained(
data_dir / "models" / model_info["adapter_path"],
local_files_only=True,
)
self.model = ColModernVBert.from_pretrained(
data_dir / "models" / model_info["base_path"],
local_files_only=True,
dtype=torch.float32,
attn_implementation="sdpa",
)
self.model.load_adapter(
str(data_dir / "models" / model_info["adapter_path"]),
adapter_kwargs={"local_files_only": True},
)
self.model.to(self.device).eval()
@torch.inference_mode()
def encode(self, *, image=None, query: str | None = None) -> torch.Tensor:
if (image is None) == (query is None):
raise ValueError("Provide exactly one image or query.")
inputs = (
self.processor.process_images([image])
if image is not None
else self.processor.process_texts([query])
).to(self.device)
embedding = self.model(**inputs)[0]
# Remove padding, preserving every genuine text/image/prompt token.
embedding = embedding[inputs["attention_mask"][0].bool()].detach().cpu()
if embedding.ndim != 2 or embedding.shape[0] == 0 or not torch.isfinite(embedding).all():
raise ValueError("Model returned an empty or nonfinite embedding.")
return embedding
def build_visual(
subset: str,
data_dir: Path,
source: dict,
device: str,
model_revision: str = "main",
base_revision: str | None = None,
) -> dict:
started = perf_counter()
model_info = cache_model(data_dir, model_revision, base_revision)
page_root, page_manifest = load_pages(subset, data_dir, source["revision"])
signature = {
"dataset_revision": source["revision"],
"model": model_info,
"encoder_dtype": "float32",
"storage_dtype": "float16",
"processor": "official defaults; process_texts without suffix; all unpadded tokens",
"attention": "sdpa",
"page_format": "original RGB PNG",
"library_versions": {
name: version(name) for name in ("colpali-engine", "transformers", "torch")
},
}
fingerprint = hashlib.sha256(json.dumps(signature, sort_keys=True).encode()).hexdigest()[:16]
root = data_dir / subset / "indexes"
embedding_root = root / "visual" / fingerprint
embedding_root.mkdir(parents=True, exist_ok=True)
if device == "cuda":
if not torch.cuda.is_available():
raise ValueError("CUDA unavailable. Run outside the GPU sandbox or use --device cpu.")
torch.cuda.reset_peak_memory_stats()
loading = perf_counter()
encoder = Encoder(data_dir, model_info, device)
load_seconds = perf_counter() - loading
computed = 0
pages = []
for index, page in enumerate(page_manifest["pages"]):
page_id = page["corpus_id"]
target = embedding_root / f"{page_id}.pt"
if target.exists():
embedding = torch.load(target, map_location="cpu", weights_only=True)
else:
with Image.open(page_root / page["file"]) as image:
embedding = encoder.encode(image=image).to(torch.float16)
temporary = target.with_suffix(".tmp")
torch.save(embedding, temporary)
temporary.replace(target)
computed += 1
if embedding.ndim != 2 or embedding.shape[1] != 128 or not torch.isfinite(embedding).all():
raise ValueError(f"Invalid cached embedding: {target}. Remove this file and retry.")
pages.append(
{
"corpus_id": page_id,
"file": str(target.relative_to(root)),
"tokens": embedding.shape[0],
}
)
if (index + 1) % 25 == 0 or index + 1 == len(page_manifest["pages"]):
print(f"Visual index: {index + 1}/{len(page_manifest['pages'])}", flush=True)
result = {
"dataset": source,
"signature": signature,
"pages": pages,
"build_seconds": perf_counter() - started,
"model_load_seconds": load_seconds,
"new_pages_encoded": computed,
"cached_pages_reused": len(pages) - computed,
"index_bytes": sum((root / page["file"]).stat().st_size for page in pages),
"peak_vram_bytes": torch.cuda.max_memory_allocated() if device == "cuda" else 0,
"device": device,
"device_name": torch.cuda.get_device_name() if device == "cuda" else "CPU",
}
write_json(root / "visual.json", result)
return result
class VisualIndex:
def __init__(self, root: Path, source: dict, batch_size: int = 32):
self.manifest = json.loads((root / "visual.json").read_text(encoding="utf-8"))
if self.manifest["dataset"]["revision"] != source["revision"]:
raise ValueError("Visual index dataset revision differs from downloaded data.")
self.page_ids = [page["corpus_id"] for page in self.manifest["pages"]]
self.batches = []
pages = self.manifest["pages"]
for start in range(0, len(pages), batch_size):
embeddings = [
torch.load(root / page["file"], map_location="cpu", weights_only=True).float()
for page in pages[start : start + batch_size]
]
padded = pad_sequence(embeddings, batch_first=True)
mask = (
torch.arange(padded.shape[1])[None, :]
< torch.tensor([embedding.shape[0] for embedding in embeddings])[:, None]
)
self.batches.append((padded, mask))
@torch.inference_mode()
def search(self, query: torch.Tensor) -> list[int]:
scores = torch.cat([maxsim(query, pages, mask) for pages, mask in self.batches])
return rank_scores(self.page_ids, scores.tolist())