The LLM StackFrom Silicon to Agents
Part IX — Retrieval & RAG
31 min read·Updated ·▶ Run the code (Colab)

9.6 Multimodal & Visual-Document Retrieval: ColPali & Late Interaction

Most of Part IX has quietly assumed that a “document” is text — a paragraph you can tokenize, embed, and stuff into a context window. But an enormous fraction of the world’s high-value knowledge does not live as clean text. It lives in PDFs full of multi-column layouts, financial reports dense with tables, slide decks where the meaning is in the figure, scanned contracts, scientific papers with equations, invoices, engineering diagrams, and screenshots. The moment you try to retrieve over these with the standard pipeline, you hit a wall that has nothing to do with embeddings and everything to do with parsing.

The traditional answer is an OCR-and-layout pipeline: run optical character recognition, detect tables and reading order, reconstruct a linearized text stream, chunk it, embed it, and proceed as in Chunking, Reranking & Hybrid Search. This works, but it is brittle and lossy. OCR mangles multi-column reading order, drops table structure, and silently discards the very thing that made a chart informative — its visual form. Every error compounds downstream: a misread row in a table becomes a wrong retrieval becomes a wrong answer.

This chapter is about the alternative that emerged in 2024–2025 and rapidly became a default for visually rich corpora: treat each page as an image and retrieve directly over the pixels, skipping OCR entirely. We will build up from cross-modal dense retrieval (CLIP/SigLIP), then develop the key idea — late interaction, borrowed from ColBERT and extended to vision in ColPali and ColQwen2 — and finally engineer the full system: how to index thousands of multi-vector page embeddings (PLAID, HNSW-over-patches), how to rerank, what it costs, and how to evaluate it on ViDoRe. The generation half — feeding retrieved page images to a vision-language model (VLM) — connects directly to Vision-Language Models.

Cross-Modal Dense Retrieval: CLIP and SigLIP

Before late interaction, the first question is simpler: can we put an image and a text query into the same vector space so that nearest-neighbor search retrieves the right image for a textual query? This is cross-modal dense retrieval, and the canonical answer is CLIP.

The Dual-Encoder Contrastive Recipe

CLIP (Radford et al., Learning Transferable Visual Models From Natural Language Supervision, 2021) trains two encoders — an image encoder \(f_\text{img}\) (a Vision Transformer; see Vision Transformers & Image Encoders) and a text encoder \(f_\text{txt}\) — to map their inputs into a shared \(d\)-dimensional space. Each image and each text is reduced to a single normalized vector. Given a batch of \(N\) matched (image, caption) pairs, we compute all \(N \times N\) cosine similarities and apply a symmetric contrastive loss (InfoNCE) that pulls matched pairs together and pushes mismatched pairs apart.

Let \(u_i = f_\text{img}(I_i) / \lVert f_\text{img}(I_i)\rVert\) and \(v_j = f_\text{txt}(T_j) / \lVert f_\text{txt}(T_j)\rVert\). With a learned temperature \(\tau\), the image-to-text loss is

\[ \mathcal{L}_{\text{i}\to\text{t}} = -\frac{1}{N}\sum_{i=1}^{N} \log \frac{\exp(u_i^\top v_i / \tau)}{\sum_{j=1}^{N}\exp(u_i^\top v_j / \tau)} \]

and the full loss symmetrizes over text-to-image as well. The mechanics are exactly the contrastive learning from Embeddings & Representation Learning, just with two modalities sharing one space.

SigLIP (Zhai et al., Sigmoid Loss for Language Image Pre-Training, 2023) replaces the softmax-over-the-batch with an independent sigmoid loss per pair, treating every (image, text) pair as a binary classification:

\[ \mathcal{L}_{\text{SigLIP}} = -\frac{1}{N}\sum_{i=1}^{N}\sum_{j=1}^{N} \log \sigma\!\big(z_{ij}\,(t\, u_i^\top v_j + b)\big), \quad z_{ij} = \begin{cases} +1 & i = j \\ -1 & i \ne j\end{cases} \]

where \(\sigma\) is the logistic sigmoid, \(t\) is a learned scale, and \(b\) a learned bias initialized negative (since most pairs are negatives). The practical win is that the sigmoid loss does not require a global softmax normalization across the batch, so it scales to very large batches without the all-gather coupling that softmax needs — and it tends to give better small-batch behavior. SigLIP’s vision tower later became the backbone of several VLMs and, crucially for us, of ColPali.

Using CLIP/SigLIP for Visual-Document Retrieval

import torch
import torch.nn.functional as F
from PIL import Image
from transformers import AutoModel, AutoProcessor

# Load a SigLIP checkpoint (image + text towers sharing one space).
model = AutoModel.from_pretrained("google/siglip-base-patch16-224").eval()
proc = AutoProcessor.from_pretrained("google/siglip-base-patch16-224")

@torch.no_grad()
def embed_images(paths):
    imgs = [Image.open(p).convert("RGB") for p in paths]
    inp = proc(images=imgs, return_tensors="pt")
    emb = model.get_image_features(**inp)        # [N, d]
    return F.normalize(emb, dim=-1)              # unit vectors -> cosine = dot

@torch.no_grad()
def embed_text(queries):
    inp = proc(text=queries, padding="max_length", return_tensors="pt")
    emb = model.get_text_features(**inp)         # [M, d]
    return F.normalize(emb, dim=-1)

# Index: one vector per page image.
page_paths = ["page_001.png", "page_002.png", "page_003.png"]
page_vecs  = embed_images(page_paths)            # [3, d]

q = embed_text(["What was Q3 revenue growth?"]) # [1, d]
scores = q @ page_vecs.T                         # [1, 3] cosine similarities
best = scores.argmax(dim=-1).item()
print("Best page:", page_paths[best], "score:", scores[0, best].item())

This is genuinely useful for natural-image retrieval (“find the photo of a dog on a beach”). But it is weak for dense documents, and the reason is structural, not a tuning problem. CLIP/SigLIP compress an entire page into one vector. A page of a 10-K filing contains dozens of distinct facts: a revenue figure, a footnote about currency hedging, a segment table, a risk paragraph. A single 768-dimensional vector cannot simultaneously preserve all of them in a way that a specific keyword-like query can latch onto. This is the information-bottleneck problem of single-vector (“bi-encoder”) retrieval, and it is exactly the problem that late interaction was invented to solve in the text world.

Aside: contrastive captions vs. document queries

CLIP and SigLIP are trained on web image–caption pairs (“a golden retriever running on a beach”). The query distribution for documents is utterly different: “depreciation schedule for fiscal 2022,” “the bar chart comparing latency across GPUs.” A model trained on captions has never learned to align fine-grained query terms to the small region of a page that answers them. This domain mismatch is why off-the-shelf CLIP underperforms on ViDoRe, and why ColPali fine-tunes specifically on document-query pairs.

Late Interaction: From ColBERT to ColPali

The ColBERT Idea

ColBERT (Khattab & Zaharia, ColBERT: Efficient and Effective Passage Search via Contextualized Late Interaction over BERT, 2020) sits between two extremes. A cross-encoder concatenates query and document, runs full attention across both, and scores them jointly — maximally expressive but \(O(\text{queries} \times \text{docs})\) forward passes, far too slow to scan a corpus. A bi-encoder (single vector each) is fast — you precompute document vectors and do a dot product — but throws away token-level detail.

Cross-encoder query Q Q3 revenue growth document D joint stack (full attention over Q+D) S joint attention over Q+D Expressiveness: High Scannability: Low O(query x doc) forward passes - too slow to scan a corpus Bi-encoder (single vector) query Q Q3 revenue growth encoder 1 vector document D encoder 1 vector dot product S one vector per side Expressiveness: Low Scannability: High fast, fully precomputable - detail lost (info bottleneck) Late interaction n query vecs Q3 revenue growth m patch vecs SUM MaxSim: sum of row-maxes S token/patch granularity kept cheap dot products, only at the end Expressiveness: High Scannability: High docs precomputable AND fine-grained (best of both) Expressiveness vs. scannability expressiveness / granularity scannability / corpus-scale cross-encoder bi-encoder late interaction (best of both)
Late interaction is the middle path between cross-encoders and bi-encoders. A cross-encoder runs full attention across every query-document pair (expressive but too slow to scan a corpus); a bi-encoder crushes each side into one vector (fast but loses fine-grained detail); late interaction keeps a vector per token or patch and lets each query token find only its single best-matching document element at the very end, staying both expressive and fully precomputable.

Late interaction is the middle path. Encode the query into one vector per query token \(\{q_1, \dots, q_n\}\) and the document into one vector per document token \(\{d_1, \dots, d_m\}\). Precompute and store all document token vectors offline. At query time, score with the MaxSim operator: for each query token, find its best-matching document token, then sum:

\[ S(Q, D) = \sum_{i=1}^{n} \max_{j=1}^{m} \; q_i^\top d_j \]

Intuitively, each query term gets to “go shopping” across the whole document for its single best evidence, and the score adds up that evidence. There is no cross-attention between query and document — the only interaction is the cheap MaxSim dot products at the very end, hence late interaction. This preserves token granularity (a rare query term can find its exact match) while keeping documents fully precomputable.

import torch

def maxsim(Q, D):
    """
    Q: [n, d] query token embeddings (L2-normalized)
    D: [m, d] document token embeddings (L2-normalized)
    Returns the ColBERT late-interaction score (a scalar).
    """
    sim = Q @ D.T                  # [n, m] all query-token x doc-token sims
    per_query = sim.max(dim=1).values   # [n] best doc token per query token
    return per_query.sum()         # sum over query tokens

# Toy: 4 query tokens, 6 doc tokens, d=8
Q = torch.nn.functional.normalize(torch.randn(4, 8), dim=-1)
D = torch.nn.functional.normalize(torch.randn(6, 8), dim=-1)
print("MaxSim score:", maxsim(Q, D).item())
Page D "Revenue: 4.2B" table-cell patch (col 2) chart patch Query-token x patch similarity p1 p2 p3 p4 p5 p6 p7 p8 ... 1024 Q3 revenue growth ? ... 1024 patches total (8 shown) the "revenue" token finds the patch that renders the revenue figure - no OCR ever run Row maxima 0.9 0.9 0.8 0.7 row max, kept sum S(Q,D) = 3.3 Each query token scans ALL patches and keeps only its single best match - the boxed cell in that row (row max). The page score sums those four best matches. The only query-page interaction is this dot product, at the very end.
MaxSim reduces a query-token x patch matrix to one number per query token, then sums. Each row is one query token scanning all 1024 document patches; only the row's highest-similarity cell (boxed) counts, so "revenue" finds the exact patch rendering "Revenue: 4.2B" with no OCR involved, and the four row maxima sum to the page score.

The cost is storage: instead of one vector per document you store \(m\) (often 100–300 for a passage). This is the central trade-off of all multi-vector retrieval, and most of the systems engineering later in this chapter exists to make it affordable.

ColPali: Late Interaction Over Page Patches

ColPali (Faysse et al., ColPali: Efficient Document Retrieval with Vision Language Models, 2024) makes one conceptually clean move: replace ColBERT’s BERT-token document encoder with a vision-language model that turns a page image into a grid of patch embeddings. The query is still text; the document is now an image.

The pipeline:

  1. Render each PDF page to an image (e.g., at ~150 DPI). No OCR.
  2. Feed the image to a VLM’s vision encoder + projection. The original ColPali used PaliGemma (a SigLIP vision tower feeding a Gemma language model). The image becomes a sequence of patch tokens — for PaliGemma, a \(32\times 32\) grid yields 1024 patch embeddings per page.
  3. Project each patch embedding down to a low dimension \(d\) (ColPali uses \(d = 128\), matching ColBERT) with a linear layer, and L2-normalize. Store these 1024 vectors as the page’s representation.
  4. The text query is tokenized and run through the same model’s language tower to produce one \(d\)-vector per query token. Following ColBERT, the query is also augmented: colpali-engine’s process_queries prepends a short instruction-style prefix and appends a handful of padding tokens that are not masked out of the MaxSim. Those extra slots behave as learned query-expansion vectors — they can latch onto page evidence the literal query words never mention — and removing them typically costs a little recall.
  5. Score with MaxSim, identical to ColBERT.
PDF page (pixels) raw image, no OCR render ~150 DPI, no OCR VLM vision encoder SigLIP tower + linear projection 1024 patch vectors each in R^128 (representative 4x4 grid) Text query "Q3 revenue?" tokenize VLM language tower Gemma / Qwen2 text encoder n query-token vectors each in R^128 patch vectors query vectors MaxSim operator S(Q,D) = sum_i max_j q_i . patch_j late interaction — no cross-attention between query and page each query token picks its best patch single relevance score page score
ColPali late interaction: two multi-vector towers meet at MaxSim. The VLM vision encoder converts a PDF page into 1024 patch vectors (R^128) while the VLM language tower converts the query into n token vectors (R^128). The only query-document interaction is the cheap MaxSim sum — each query token independently finds its single best-matching patch — making the page representation fully precomputable offline with no cross-attention needed.

The magic is in step 2. Because the patch embeddings come from a VLM that was pretrained to read, each patch vector is contextualized: a patch sitting on the cell of a table that says “Revenue: USD 4.2B” carries a representation that a query token for “revenue” can match against — without anyone ever running OCR. The model learned to associate visual glyphs and layout with textual meaning during pretraining, and ColPali’s fine-tuning sharpens that for the retrieval objective.

ColQwen2 and the Family

ColQwen2 swaps the PaliGemma backbone for Qwen2-VL. Qwen2-VL’s vision encoder supports dynamic resolution — it does not force every page into a fixed \(448\times 448\) box but processes the native aspect ratio, producing a variable number of patch tokens. For a tall, dense page this means more patches (finer evidence) and for a sparse slide fewer (cheaper). This typically lifts retrieval quality on dense documents at the cost of variable, sometimes larger, per-page storage. The broader family keeps the same skeleton — VLM patch encoder + low-dim projection + MaxSim — and only swaps the document encoder. By 2026 the strong open defaults have moved to the Qwen2.5-VL backbone: ColQwen2.5 reaches roughly 89 nDCG@5 on the original ViDoRe benchmark, ColSmol (256M/500M) targets edge/CPU, ColNomic-embed-multimodal (also Qwen2.5-VL) leads on the harder ViDoRe-v2, and the first Qwen3-VL-based checkpoints are already pushing the frontier further. The contrast with ColBERT-text is still only in the document encoder.

Common pitfall: forgetting the query side is also multi-vector

A frequent misreading is “ColPali embeds pages into 1024 vectors and queries into one vector.” No — the query is also multi-vector (one \(d\)-dim vector per query token, typically a few dozen). MaxSim runs over the full \(n \times m\) grid. If you accidentally mean-pool the query into a single vector, you have silently reverted to a worse bi-encoder and thrown away ColPali’s entire advantage. Keep both sides token/patch-level until the MaxSim.

Training ColPali

ColPali is fine-tuned with a contrastive in-batch loss on (query, positive-page) pairs, where the negatives are the other pages in the batch. The score is the MaxSim, and the loss is the same InfoNCE softmax over MaxSim scores. A common refinement adds a margin-aware or in-batch hard-negative term. The training data is the crux: synthetic and curated document-query pairs spanning tables, figures, infographics, and full pages, so the model learns the document query distribution that CLIP never saw.

import torch
import torch.nn.functional as F

def colbert_scores(Qb, Db, q_mask, d_mask):
    """
    Vectorized MaxSim for a batch (used both in training and scoring).
    Qb: [B, n, d] query token embeddings (padded)
    Db: [B, m, d] doc patch embeddings (padded)
    q_mask: [B, n] 1 for real query tokens, 0 for padding
    d_mask: [B, m] 1 for real patches
    Returns: [B, B] score matrix S[i, j] = MaxSim(query_i, doc_j)
    """
    # Pairwise sims across the cross product of the batch:
    #   [B, n, d] x [B, m, d] -> [B(query), B(doc), n, m]
    sim = torch.einsum("ind,jmd->ijnm", Qb, Db)
    # Mask out padded doc patches before the max over patches.
    sim = sim.masked_fill(~d_mask[None, :, None, :].bool(), -1e4)
    sim = sim.max(dim=-1).values                 # [B, B, n] max over patches
    # Zero out padded query tokens before summing over query tokens.
    sim = sim * q_mask[:, None, :]
    return sim.sum(dim=-1)                        # [B, B]

def colpali_loss(Qb, Db, q_mask, d_mask):
    S = colbert_scores(Qb, Db, q_mask, d_mask)   # [B, B], diagonal = positives
    labels = torch.arange(S.size(0), device=S.device)
    # Standard in-batch InfoNCE over MaxSim scores (both directions optional).
    return F.cross_entropy(S, labels)

A Worked Example: Storage and Scoring Cost

Worked example: indexing 100k pages with ColPali

Suppose a corpus of 100,000 pages. ColPali (PaliGemma backbone) produces 1024 patch vectors per page, each of dimension \(d = 128\). Store at fp16 (2 bytes/element).

Per page:

\[ 1024 \text{ patches} \times 128 \text{ dims} \times 2 \text{ bytes} = 262{,}144 \text{ bytes} \approx 256 \text{ KiB} \]

Full corpus:

\[ 100{,}000 \times 256 \text{ KiB} \approx 25.6 \text{ GiB} \]

Compare a single-vector SigLIP index at the same \(d=128\) and fp16: \(100{,}000 \times 128 \times 2 = 25.6\) MB — a 1000× difference, because each page now holds 1024 vectors instead of 1. That factor of 1024 is the price of late interaction and the reason indexing strategy matters enormously.

Scoring cost (brute force) for one query with \(n = 20\) query tokens against all pages:

\[ 100{,}000 \text{ pages} \times 1024 \text{ patches} \times 20 \text{ q-tokens} \times 128 \text{ flops/dot} \approx 2.6 \times 10^{11} \text{ FLOPs} \]

That is ~260 GFLOPs of dot products per query if you score every page exhaustively. On a modern GPU this is milliseconds of compute but tens of GiB of memory traffic — memory bandwidth, not arithmetic, is the bottleneck (see the Roofline Model). At scale you cannot brute-force every query; you need an approximate candidate-generation stage (next section) and only run full MaxSim on a shortlist.

Binary quantization (1 bit/dim instead of 16) shrinks the index from 25.6 GiB to 1.6 GiB at a small recall cost — often the single highest-leverage optimization for multi-vector indexes.

Indexing Multi-Vector Embeddings at Scale

The brute-force MaxSim above is fine for a few thousand pages but collapses at corpus scale. The whole field of efficient multi-vector retrieval is about avoiding the full \(O(\text{pages} \times \text{patches} \times \text{q-tokens})\) scan. There is also a structural reason you cannot simply hand MaxSim to an off-the-shelf ANN library: MaxSim is a Chamfer-style set similarity, not an inner product between two single vectors, and it is not a metric (it is asymmetric and violates the triangle inequality), so the geometric assumptions HNSW and IVF are built on do not hold for it. Every practical system therefore reduces MaxSim to something indexable — per-token ANN over individual patches, centroid assignments, or a fixed-dimensional encoding whose dot product approximates MaxSim — and computes true MaxSim only on a shortlist. There are two dominant approaches.

One page, two representations page SigLIP 1 vector x1024 same page, patch-level embedding page ColPali ~1024 patch vectors Index size for a 100,000-page corpus (d=128, fp16): SigLIP (1 vec/page) ~25.6 MB ColPali (1024 vec/page) ~25.6 GiB the factor of 1024 is the price of late interaction (bar not to scale past the break) Making it affordable: compress, then shortlist, then re-score exactly Stage 1 · Compress at index time 1024 128 token pooling fp16 -> 1-2 bit residuals or binary quantize (PLAID) shrinks index ~10-30x Stage 2 · Approximate candidates PLAID centroids / HNSW-over- patches / MUVERA fixed-dim each query token -> ANN index output = union of candidates never touches full-precision vectors Stage 3 · Exact MaxSim rerank decompress full fp16 patches for the shortlist only compute exact MaxSim sort candidates returns top 3-5 pages 100,000 pages few hundred candidates top 3-5 Two storage tiers, split by which stage reads them Compressed / in-RAM tier binary or 1-2 bit residuals, centroids · small enough to keep resident feeds Stage 1 feeds Stage 2 Full fp16 / on NVMe memory-mapped · read only for the few hundred survivors of Stage 2 feeds Stage 3
Multi-vector storage is 1000x a single vector's, so real systems compress before they shortlist, and shortlist before they score exactly. ColPali's 1024 patch vectors per page push a 100,000-page index from ~25.6 MB (SigLIP) to ~25.6 GiB — the price of late interaction. A three-stage funnel makes this affordable: compress the index (pooling + low-bit residuals), find a few hundred candidates approximately, then decompress and run exact MaxSim on only that shortlist, keeping compressed data in RAM and full-precision vectors on cheaper, memory-mapped storage.

PLAID: ColBERT’s Native Engine

PLAID (Santhanam et al., PLAID: An Efficient Engine for Late Interaction Retrieval, 2022) is the production indexing system designed for ColBERT, and it carries over to ColPali. Its pipeline:

  1. Residual compression. Cluster all patch/token vectors in the corpus with k-means into a codebook of centroids. Store each vector as (centroid_id, residual), where the residual (vector minus its centroid) is quantized to 1–2 bits per dimension. This is the dominant memory saving.
  2. Centroid-based candidate generation. Each query token retrieves the nearest centroids. Any document that has patches assigned to those centroids becomes a candidate. This avoids touching documents with no plausible matching patch.
  3. Centroid-pruned scoring. Approximate MaxSim using centroid similarities first to prune, then refine only promising candidates with the decompressed residuals.
  4. Full re-ranking. Decompress the top candidates and compute exact MaxSim for the final ordering.

The staged funnel — cheap-and-approximate to expensive-and-exact — is the recurring pattern of all large-scale retrieval, and you saw it for single-vector ANN in Vector Databases & Approximate Nearest Neighbor Search.

HNSW Over Flattened Patch Vectors

A simpler, store-agnostic approach: flatten every patch vector of every page into one giant single-vector ANN index, tagging each with its page id. Build an HNSW graph (see the ANN chapter) over all \(100{,}000 \times 1024 \approx 10^8\) patch vectors. Then:

  1. For each of the \(n\) query token vectors, run an ANN search to retrieve the top-\(k\) nearest patches (across all pages).
  2. Collect the union of page ids those patches belong to — this is the candidate page set.
  3. For each candidate page, fetch its full patch matrix and compute exact MaxSim.
  4. Sort by exact score; return top pages.

This is the strategy used by libraries that build ColPali on top of generic vector databases (Qdrant’s multivector support, Vespa’s tensor/MaxSim ranking, Weaviate’s MUVERA-style approaches). The candidate-generation step is approximate (you might miss a page whose best patch did not make any query token’s top-\(k\)), but in practice recall is high because a relevant page usually has several strongly matching patches.

import numpy as np
import hnswlib

class PatchHNSWIndex:
    """Flatten all page patches into one HNSW index; rerank pages with exact MaxSim."""
    def __init__(self, dim=128):
        self.dim = dim
        self.index = hnswlib.Index(space="ip", dim=dim)  # inner product
        self.page_patches = {}   # page_id -> [m, d] float32 patch matrix
        self.label_to_page = {}  # global patch label -> page_id
        self._next = 0

    def init(self, max_patches):
        self.index.init_index(max_elements=max_patches, ef_construction=200, M=16)

    def add_page(self, page_id, patches):
        patches = patches.astype(np.float32)
        n = patches.shape[0]
        labels = np.arange(self._next, self._next + n)
        self.index.add_items(patches, labels)
        for lab in labels:
            self.label_to_page[int(lab)] = page_id
        self.page_patches[page_id] = patches
        self._next += n

    def search(self, query_patches, k_per_token=50, topn=10):
        query_patches = query_patches.astype(np.float32)
        # 1) candidate generation: nearest patches per query token -> union of pages
        candidates = set()
        for qtok in query_patches:
            labels, _ = self.index.knn_query(qtok, k=k_per_token)
            for lab in labels[0]:
                candidates.add(self.label_to_page[int(lab)])
        # 2) exact MaxSim rerank over candidate pages only
        scored = []
        for pid in candidates:
            D = self.page_patches[pid]               # [m, d]
            sim = query_patches @ D.T                # [n, m]
            scored.append((pid, sim.max(axis=1).sum()))   # MaxSim
        scored.sort(key=lambda x: -x[1])
        return scored[:topn]

In production you rarely hand-roll this. Qdrant stores a whole matrix of patch vectors per point and evaluates MaxSim natively, so the funnel above becomes a few lines of client code:

from qdrant_client import QdrantClient, models

client = QdrantClient(":memory:")                 # or url="http://localhost:6333"
client.create_collection(
    collection_name="pages",
    vectors_config=models.VectorParams(
        size=128,                                  # ColPali projection dim
        distance=models.Distance.COSINE,
        # Each point holds [m, 128] patch vectors; scoring is MaxSim, not a dot product.
        multivector_config=models.MultiVectorConfig(
            comparator=models.MultiVectorComparator.MAX_SIM),
    ),
)
client.upsert("pages", points=[
    models.PointStruct(id=i, vector=emb.tolist(), payload={"page": i})
    for i, emb in enumerate(page_embeddings)       # emb: [m, 128] per page
])
hits = client.query_points("pages", query=q_emb[0].tolist(), limit=5).points

Vespa expresses the same scoring declaratively as a tensor rank-profile (a reduce(..., max, patch) over the query-token × patch tensor product, then a sum over query tokens), and Weaviate exposes multi-vector fields with an optional MUVERA encoder for the candidate stage. The trade-off is identical everywhere: the database can compute MaxSim for you, but you still decide how the candidate stage narrows millions of pages down to hundreds.

Token Pooling and Compression

Because 1024 patches per page is the cost driver, a cheap win is token pooling: cluster a page’s patch vectors (e.g., hierarchical agglomerative clustering) and keep a smaller set of representative vectors — say 256 instead of 1024 — before indexing. Empirically you can drop a large fraction of patches with little retrieval-quality loss, because many patches (margins, whitespace, repeated background) are redundant. It is about ten lines:

import numpy as np
from scipy.cluster.hierarchy import linkage, fcluster
from scipy.spatial.distance import pdist

def pool_patches(D, keep=128):
    """D: [m, d] L2-normalized patch vectors -> [<=keep, d] pooled, renormalized.
    Average-link agglomerative clustering on cosine distance, cut at `keep` clusters;
    each cluster collapses to the (renormalized) mean of its members."""
    if D.shape[0] <= keep:
        return D
    Z = linkage(pdist(D, metric="cosine"), method="average")
    labels = fcluster(Z, t=keep, criterion="maxclust")     # at most `keep` clusters
    pooled = np.stack([D[labels == c].sum(0) for c in np.unique(labels)])
    return pooled / np.linalg.norm(pooled, axis=1, keepdims=True)

D = np.random.default_rng(0).standard_normal((1024, 128))
D /= np.linalg.norm(D, axis=1, keepdims=True)
print(pool_patches(D, keep=128).shape)                     # (128, 128)

MaxSim over the pooled set is a lower-resolution version of the original: a query token now matches a region of the page rather than a single patch, which is exactly why quality degrades gracefully. colpali-engine ships an equivalent hierarchical token pooler (parameterized by a pool factor) so you can apply this at index time without writing it yourself. Combined with binary quantization of what remains, real ColPali deployments routinely shrink the index by 10–30× from the naive 256 KiB/page figure. MUVERA (Multi-Vector Retrieval via Fixed Dimensional Encodings) goes further: it deterministically projects the whole multi-vector set into a single fixed-dimensional vector whose dot product approximates MaxSim, letting you reuse a standard single-vector ANN index for candidate generation and only fall back to exact MaxSim for reranking.

Practitioner tip: separate the two storage tiers

Keep two representations of each page’s patches: a compressed form (binary or 2-bit residuals) for fast candidate generation, and the full fp16 form (possibly on cheaper storage or memory-mapped) for exact MaxSim reranking of the ~50 candidates that survive. You almost never need the full-precision vectors of pages that never enter the shortlist, so they can live on disk. This two-tier split is what keeps RAM bounded while preserving final-ranking accuracy.

The OCR-Free Page-as-Image RAG Pipeline

Retrieval is half the system. The other half feeds the retrieved page images to a vision-language model to generate the answer — never reconstructing text at all. This is the OCR-free RAG loop, and it composes cleanly with everything in Retrieval-Augmented Generation Architectures, with images replacing text chunks.

Offline indexing Online query PDFs source documents render pages to PNG ColPali / ColQwen2 patch embeddings 1024 vectors x R^128 per page token-pool + quantize multi-vector index PLAID / HNSW over patch vectors user question (text) "How did margin change in 2022?" ColPali query embeddings n token vectors in R^128 candidate gen + exact rerank query vectors MaxSim retrieval S = sum_i max_j q_i . patch_j top-k PAGE IMAGES top-k page images raw pixels — no OCR reconstruction VLM generator Qwen2-VL, GPT-4o, Gemini, InternVL ... prompt = question + k page images (no OCR text) question also passed to VLM generate grounded answer (+ page cites)
The OCR-free page-as-image RAG pipeline, offline and online. Offline, PDFs are rendered to images and encoded by ColPali into multi-vector patch embeddings, then compressed and stored in a PLAID or HNSW index. Online, the query is encoded to token vectors by the same model; MaxSim retrieval finds the top-k matching page images — raw pixels, no OCR reconstruction — which are passed with the question to a VLM generator that produces a grounded, page-cited answer.
# End-to-end sketch with the `colpali-engine` library + a VLM generator.
import torch
from colpali_engine.models import ColPali, ColPaliProcessor
from pdf2image import convert_from_path

device = "cuda" if torch.cuda.is_available() else "cpu"
model = ColPali.from_pretrained("vidore/colpali-v1.3",
                                torch_dtype=torch.bfloat16).to(device).eval()
processor = ColPaliProcessor.from_pretrained("vidore/colpali-v1.3")

# --- OFFLINE: render pages and embed them ---
# pdf2image shells out to poppler; `pypdfium2` is a pure-wheel alternative with no
# system dependency, which matters when you containerize the indexer.
pages = convert_from_path("annual_report.pdf", dpi=150)  # list[PIL.Image]
page_embeddings = []
with torch.no_grad():
    for batch_start in range(0, len(pages), 4):
        batch = pages[batch_start:batch_start + 4]
        inp = processor.process_images(batch).to(device)
        emb = model(**inp)                  # [b, num_patches, 128]
        page_embeddings.extend(list(emb.to(torch.float16).cpu()))
# (Index page_embeddings into PLAID/HNSW here; see previous section.)

# --- ONLINE: embed the query, score with MaxSim, retrieve top pages ---
query = "How did operating margin change from 2021 to 2022?"
with torch.no_grad():
    q_inp = processor.process_queries([query]).to(device)
    q_emb = model(**q_inp)                  # [1, n_tokens, 128]

# Exact MaxSim against all pages (small corpus; use the index at scale).
def maxsim(q, d):
    return (q @ d.T).max(dim=1).values.sum()
scores = torch.tensor([maxsim(q_emb[0].float(), d.float()) for d in page_embeddings])
top = scores.topk(3).indices.tolist()
retrieved_images = [pages[i] for i in top]

# --- GENERATION: hand the raw page images to a VLM (no OCR text!) ---
# Any capable VLM works here; swap in a newer Qwen2.5-VL / Qwen3-VL checkpoint
# (or serve it behind vLLM/SGLang) without touching the retrieval half.
from transformers import AutoModelForImageTextToText, AutoProcessor
vlm = AutoModelForImageTextToText.from_pretrained(
    "Qwen/Qwen2-VL-7B-Instruct", torch_dtype=torch.bfloat16).to(device).eval()
vproc = AutoProcessor.from_pretrained("Qwen/Qwen2-VL-7B-Instruct")

messages = [{"role": "user", "content":
    [{"type": "image", "image": im} for im in retrieved_images]
    + [{"type": "text", "text": query
        + " Cite the page number you used."}]}]
prompt = vproc.apply_chat_template(messages, add_generation_prompt=True, tokenize=False)
vin = vproc(text=[prompt], images=retrieved_images, return_tensors="pt").to(device)
with torch.no_grad():
    out = vlm.generate(**vin, max_new_tokens=256)
print(vproc.batch_decode(out, skip_special_tokens=True)[0])

Why “no OCR” Is a Feature, Not a Hack

The instinct of a careful engineer is to distrust skipping OCR — surely text is more reliable than pixels? But consider what survives the round-trip. A bar chart’s meaning is the relative height of bars; OCR captures only the axis labels and legend, discarding the comparison. A complex table’s meaning is its 2-D structure; linearized OCR text frequently scrambles which number belongs to which row/column header. The page-as-image approach keeps the layout, the chart geometry, the equation rendering, and the spatial relationships intact, and a modern VLM can read all of it. You trade OCR’s parsing errors for the VLM’s vision — and for visually rich documents that is a strongly favorable trade.

The cost is on the generation side: page images are expensive in tokens. A single high-resolution page can consume hundreds to over a thousand vision tokens in the VLM’s context, so retrieving \(k=10\) pages can blow a context budget that \(k=10\) text chunks would not. This makes precision of retrieval more important here than in text RAG — you want the top 1–3 pages to be right, because you cannot afford to dump 20 page images into the VLM. It also makes reranking valuable.

Visual Reranking and Hybrid Strategies

Late-interaction retrieval already does fine-grained matching, so do we still need a reranker? Often yes — for the same staged-funnel reason as text RAG (see Chunking, Reranking & Hybrid Search). Three flavors of visual reranking exist.

  1. Exact-MaxSim rerank. If candidate generation was approximate (HNSW union, MUVERA, or PLAID centroid pruning), recomputing exact MaxSim on the shortlist is itself a reranker. Cheap and almost always worth it.

  2. Cross-encoder / VLM-as-judge rerank. Pass each candidate page image and the query jointly into a VLM and ask for a relevance score. This is the visual analogue of a text cross-encoder: maximally expressive (full cross-attention between query and page) but expensive, so apply it only to the top ~20 candidates. A prompt like “On a scale of 0–10, does this page contain the information to answer: <query>?” turns any capable VLM into a reranker.

  3. Hybrid with text/OCR signals. Nothing forbids running OCR in addition for a BM25 lexical channel (see hybrid search). For documents with exact identifiers — invoice numbers, part codes, legal citations — a lexical match on OCR’d text is unbeatable, while ColPali handles the semantic/visual layout. Fuse the rankings with Reciprocal Rank Fusion (RRF):

\[ \text{RRF}(d) = \sum_{r \in \{\text{ColPali},\,\text{BM25}\}} \frac{1}{k + \operatorname{rank}_r(d)}, \quad k \approx 60 \]
def reciprocal_rank_fusion(rankings, k=60):
    """rankings: list of ranked lists, each a list of page_ids best-first."""
    scores = {}
    for ranking in rankings:
        for rank, pid in enumerate(ranking):
            scores[pid] = scores.get(pid, 0.0) + 1.0 / (k + rank + 1)
    return sorted(scores, key=lambda p: -scores[p])

colpali_order = ["p17", "p4", "p23", "p9"]
bm25_order    = ["p4", "p17", "p88", "p23"]   # from OCR + BM25
print(reciprocal_rank_fusion([colpali_order, bm25_order]))

Interview Corner

Q: Your team retrieves over 2 million scanned pages. ColPali at 1024 patches/page would need a multi-vector index in the tens-to-hundreds of GB and exact MaxSim is too slow to scan. Walk me through a serving design that hits sub-200 ms p95 retrieval latency.

A: I would build a three-stage funnel and aggressively compress. (1) Compression at index time: token-pool each page from 1024 to ~128 patches via clustering, then store residuals at 1–2 bits/dim (PLAID) or binary-quantize. That alone shrinks 2M pages from hundreds of GB to a few tens of GB that fit in RAM across a couple of shards. (2) Approximate candidate generation: either PLAID’s centroid-based retrieval or a MUVERA fixed-dimensional encoding so each query token hits a standard HNSW index; take the union of, say, the top few hundred candidate pages. This stage never touches full-precision vectors. (3) Exact MaxSim rerank: decompress the full fp16 patches for only those few hundred candidates (the two-tier storage split, full vectors memory-mapped on NVMe) and compute exact MaxSim, returning the top 3–5 pages. The latency budget goes mostly to stage 2’s ANN graph traversal; stage 3 is a few hundred small matmuls. I would shard by document, replicate for QPS, cache query embeddings for repeated queries, and keep generation precision high by returning few but accurate pages. If latency still misses, I trade recall for speed by lowering ef/nprobe and the per-token top-\(k\), and I validate the recall hit on a held-out ViDoRe-style eval before shipping.

Evaluation: ViDoRe and What to Measure

You cannot tune what you cannot measure, and visual-document retrieval needed its own benchmark because text-retrieval datasets do not exist as page images. ViDoRe (the Visual Document Retrieval Benchmark, introduced alongside ColPali) is the standard. It contains query–page tasks spanning academic papers, figures, infographics, tables, and multi-domain documents (medical, energy, government, AI), in multiple languages, with both synthetic and human-curated queries. Crucially, each task is page-level: given a query, retrieve the correct page(s) from a corpus of page images.

The primary metric is nDCG@k (Normalized Discounted Cumulative Gain), with Recall@k and MRR as companions. nDCG rewards placing relevant pages near the top and discounts gains logarithmically by rank:

\[ \text{DCG@}k = \sum_{i=1}^{k} \frac{2^{\text{rel}_i} - 1}{\log_2(i + 1)}, \qquad \text{nDCG@}k = \frac{\text{DCG@}k}{\text{IDCG@}k} \]

where \(\text{rel}_i\) is the relevance of the page at rank \(i\) and IDCG is the DCG of the ideal ordering (so nDCG \(\in [0, 1]\)).

import numpy as np

def dcg(relevances):
    relevances = np.asarray(relevances, dtype=float)
    discounts = np.log2(np.arange(2, relevances.size + 2))
    return np.sum((2**relevances - 1) / discounts)

def ndcg_at_k(retrieved_rels, ideal_rels, k=5):
    """retrieved_rels: relevance of each retrieved page, in retrieved order.
       ideal_rels: all true relevances sorted descending (for the ideal ranking)."""
    actual = dcg(retrieved_rels[:k])
    ideal  = dcg(sorted(ideal_rels, reverse=True)[:k])
    return actual / ideal if ideal > 0 else 0.0

# Retrieved pages had relevances [1, 0, 1, 0, 0]; one other relevant page (rel 1)
# existed but was missed, so the ideal top-5 is [1, 1, 1, 0, 0].
print(round(ndcg_at_k([1, 0, 1, 0, 0], [1, 1, 1, 0, 0], k=5), 4))

You should not re-implement the harness around that metric. The ViDoRe authors ship vidore-benchmark (a Python package plus CLI) that pulls the benchmark’s page-image datasets from the Hugging Face Hub, runs any colpali-engine retriever — or your own, by registering a class that exposes forward_queries / forward_passages and a scoring function — across every task and reports per-task nDCG@5; the ViDoRe tasks are also reachable through MTEB’s multimodal retrieval suite. Wiring your retriever into it is the same argument as using lm-evaluation-harness for text evals rather than hand-rolling one (see Building Eval Harnesses): comparability with published numbers is the whole point.

A few measurement subtleties specific to this setting:

  • Report retrieval and end-to-end separately. A page can be retrieved correctly yet the VLM still answers wrong, or vice versa. Measure nDCG@k for retrieval and an answer-correctness metric (exact match, LLM-as-judge; see LLM-as-a-Judge & Automated Evaluation) for the full pipeline.
  • Watch DPI and resolution. Rendering pages too low-res hurts ColPali’s patch evidence and the VLM’s reading; too high-res inflates cost. ViDoRe-style ablations across DPI catch this.
  • Beware synthetic-query leakage. Synthetic queries generated by the same family of models used in retrieval can inflate scores; trust human-curated splits more. ViDoRe-v2 specifically broadened domains and tightened this. By 2026 the original ViDoRe-v1 is largely saturated (top systems ~89 nDCG@5), so ViDoRe-v2 — where the best models still sit in the low-60s nDCG@5 — is the more discriminating benchmark to report.
  • Language and OCR-hardness. ColPali’s biggest wins over OCR pipelines are on non-Latin scripts, handwriting, and heavy-layout pages where OCR degrades; stratify your eval by document difficulty to see where the visual approach actually pays off.

Common pitfall: comparing ColPali to OCR on the wrong corpus

If your corpus is clean, single-column, born-digital text (e.g., Markdown docs or simple PDFs), a good OCR/text pipeline will match or beat ColPali at a fraction of the index size — the page images buy you nothing because there is no visual structure to lose. ColPali earns its 100–1000× storage premium specifically on visually rich, layout-heavy, OCR-hostile documents. Always benchmark both on your corpus before committing; do not assume the fancier method wins universally.

Putting It Together: When to Reach for This

The decision is a corpus question, not a fashion question. Reach for ColPali-style visual-document retrieval when:

  • Your corpus is PDFs, slides, scans, or screenshots with meaningful layout, tables, figures, or charts.
  • OCR is lossy or failing on your documents (multi-column, non-Latin scripts, handwriting, complex tables).
  • You can afford a multi-vector index (or the compression to make it affordable) and you want to skip the brittle OCR-and-layout engineering entirely.

Stay with text RAG (and OCR if needed) when the corpus is born-digital clean text, when index size is tightly constrained, or when you need exact lexical matching on identifiers as the dominant signal (though hybrid fusion lets you have both). And remember the cost asymmetry: visual retrieval shifts expense from a parsing pipeline (offline, one-time) to storage (multi-vector index) and generation tokens (page images in the VLM). For high-value, layout-heavy corpora that trade is usually worth it; for commodity text it usually is not. The capstone makes exactly this call and lands on the text side: Stack-100M has no vision tower and its research corpus is born-digital, so A Narrow Auto-Research Agent uses a single-vector text index. Late interaction is the upgrade path the moment that corpus becomes scanned PDFs — the MaxSim scorer, the candidate-then-exact funnel, and the two-tier storage split all port over unchanged; only the document encoder changes.

This chapter closes Part IX. The retrieval mechanisms here — dual encoders, late interaction, multi-vector indexing, staged reranking — are the same primitives you have seen throughout the part, recombined for pixels instead of tokens. The generation side hands off directly to Part X: see Vision-Language Models for how the VLM actually reads those retrieved pages, and Vision Transformers & Image Encoders for the patch encoders that make any of this possible.

Key Takeaways

  • Single-vector cross-modal retrieval (CLIP/SigLIP) puts images and text in one space but compresses each page to one vector — an information bottleneck that fails on dense, multi-fact documents.
  • Late interaction (ColBERT’s MaxSim) keeps one vector per token/patch and scores by summing each query token’s best match, recovering fine-grained matching while keeping documents precomputable.
  • ColPali / ColQwen2 extend late interaction to vision: a VLM turns a page image into ~1024 patch vectors (128-dim), scored against multi-vector text queries via MaxSim — no OCR required.
  • Multi-vector indexing is the engineering crux: ~1000× more vectors than single-vector means PLAID (residual/centroid compression), HNSW-over-patches, token pooling, MUVERA, and binary quantization are essential, not optional.
  • The OCR-free pipeline feeds retrieved page images straight to a VLM generator, preserving layout, tables, and charts that OCR destroys — but page images are token-expensive, so retrieval precision matters more than in text RAG.
  • Reranking still helps: exact-MaxSim rerank after approximate candidate generation, VLM-as-judge cross-encoding, and RRF fusion with OCR/BM25 for exact-identifier queries.
  • Evaluate on ViDoRe with nDCG@k/Recall@k, separating retrieval quality from end-to-end answer correctness, and stratify by document difficulty and language.
  • Reach for it on layout-heavy, OCR-hostile corpora; stay with text RAG on clean born-digital text where the storage premium buys nothing.

State of the Art & Resources (2026)

Visual-document retrieval went from a niche idea to a default for layout-heavy corpora in under two years, driven by the ColPali line of work and a fast-maturing indexing ecosystem.

Foundational work

Visual late interaction

Current models & leaderboards (2026)

  • ViDoRe leaderboard — the live ranking; ViDoRe-v1 is now saturated near ~89 nDCG@5, so ViDoRe-v2 is the benchmark that separates 2025–2026 systems.
  • vidore/colqwen2.5-v0.2 — the Qwen2.5-VL-based late-interaction default (~89 nDCG@5 on ViDoRe-v1), superseding the original PaliGemma ColPali.
  • nomic-ai/colnomic-embed-multimodal-7b — a leading open multimodal late-interaction retriever, strongest on the harder ViDoRe-v2.

Open-source & tools

  • illuin-tech/colpali (colpali-engine) — reference ColPali/ColQwen2/ColQwen2.5 training and inference (colpali-engine ≥ 0.3).
  • stanford-futuredata/ColBERT — the original ColBERT + PLAID engine (RAGatouille is the friendlier wrapper around it).
  • illuin-tech/vidore-benchmark — the vidore-benchmark package/CLI for evaluating any retriever on ViDoRe v1/v2 with comparable nDCG@5.
  • Multi-vector support in Qdrant (MultiVectorComparator.MAX_SIM), Vespa (tensor MaxSim rank-profiles), and Weaviate (multi-vector fields + MUVERA encoding) for building ColPali indexes on production vector databases.

Further Reading

  • Faysse et al., ColPali: Efficient Document Retrieval with Vision Language Models, 2024 — the foundational paper and source of the ViDoRe benchmark.
  • Khattab & Zaharia, ColBERT: Efficient and Effective Passage Search via Contextualized Late Interaction over BERT, SIGIR 2020.
  • Santhanam et al., ColBERTv2: Effective and Efficient Retrieval via Lightweight Late Interaction, NAACL 2022.
  • Santhanam et al., PLAID: An Efficient Engine for Late Interaction Retrieval, CIKM 2022.
  • Radford et al., Learning Transferable Visual Models From Natural Language Supervision (CLIP), ICML 2021.
  • Zhai et al., Sigmoid Loss for Language Image Pre-Training (SigLIP), ICCV 2023.
  • Beyer et al., PaliGemma: A versatile 3B VLM for transfer, 2024 — the backbone of the original ColPali.
  • Jayaram et al., MUVERA: Multi-Vector Retrieval via Fixed Dimensional Encodings, 2024.
  • The colpali-engine and ColBERT open-source repositories for reference implementations.

Exercises

1. (Conceptual) A colleague proposes speeding up ColPali by mean-pooling the query’s per-token vectors into a single 128-dim vector before scoring, arguing “the pages are still multi-vector, so we keep the fine-grained matching.” Explain (a) why single-vector CLIP/SigLIP already struggles on dense document pages, and (b) why your colleague’s change silently throws away ColPali’s advantage. What retrieval regime does the modified system collapse to?

Solution

(a) CLIP/SigLIP are bi-encoders: each page is compressed into one \(d\)-dimensional vector. A single page of a 10-K holds many distinct facts (a revenue figure, a hedging footnote, a segment table, a risk paragraph). One vector cannot simultaneously preserve all of them so that a specific keyword-like query (“depreciation schedule for fiscal 2022”) can latch onto the small region that answers it. This is the information-bottleneck problem of single-vector retrieval, compounded on documents by the caption-vs-document-query domain mismatch (CLIP was trained on web captions, never on document queries).

(b) Late interaction’s whole value is that every query token independently “goes shopping” across the page’s patches via MaxSim: $$ S(Q,D)=\sum_{i=1}{n}\max_{j=1} q_i^\top d_j . $$ If you mean-pool the query into a single vector \(\bar q\), the score becomes \(\max_j \bar q^\top d_j\) — a single averaged query intent matched to one best patch. A rare, discriminative query term (an exact table header, an equation symbol) is averaged into the mush of the other tokens and can no longer independently find its exact patch. You have reverted to a single-vector-query / multi-vector-document bi-encoder-style match: you keep the page’s patch storage cost but lose the token-level query granularity that justified it. This is exactly the “forgetting the query side is also multi-vector” pitfall from the chapter.

2. (Quantitative) A query has \(n=3\) tokens and a page has \(m=4\) patches. All vectors are L2-normalized, and the resulting query-token by patch dot-product matrix (rows = query tokens, columns = patches) is

q1: [0.2, 0.9, 0.1, 0.4]
q2: [0.7, 0.3, 0.5, 0.2]
q3: [0.1, 0.2, 0.8, 0.6]

Compute the MaxSim score. Then compute the score you would get if you (wrongly) mean-pooled the three query rows into one row before taking a single max over patches, and comment on the difference.

Solution

MaxSim takes the max along each row (best patch per query token), then sums:

  • \(q_1\): \(\max(0.2, 0.9, 0.1, 0.4) = 0.9\)
  • \(q_2\): \(\max(0.7, 0.3, 0.5, 0.2) = 0.7\)
  • \(q_3\): \(\max(0.1, 0.2, 0.8, 0.6) = 0.8\)
\[ S = 0.9 + 0.7 + 0.8 = 2.4 . \]

Note each query token matched a different patch (patch 2, patch 1, patch 3 respectively) — the page answers three different query terms in three different regions, which is exactly what late interaction rewards.

Mean-pooled query. Averaging the three rows column-wise:

\[ \bar q = \left[\tfrac{0.2+0.7+0.1}{3},\ \tfrac{0.9+0.3+0.2}{3},\ \tfrac{0.1+0.5+0.8}{3},\ \tfrac{0.4+0.2+0.6}{3}\right] = [0.333,\ 0.467,\ 0.467,\ 0.400]. \]

A single max over patches gives \(\max(0.333, 0.467, 0.467, 0.400) = 0.467\). Even scaled up by \(n=3\) for a fair comparison (\(1.40\)), this is far below \(2.4\): no single patch is simultaneously the best for all three query terms, so pooling destroys the ability to credit evidence spread across the page. This is the numeric face of Exercise 1.

3. (Quantitative) You index 50,000 pages with ColQwen2, which (dynamic resolution) produces on average 768 patch vectors per page, each \(d=128\), stored at fp16 (2 bytes/element). (a) What is the raw index size? (b) You apply token pooling to reduce each page to 128 representative patches. New size? © You then binary-quantize the pooled vectors to 1 bit/dimension. Final size, and the overall reduction factor from (a)?

Solution

(a) Raw. Per page: \(768 \times 128 \times 2 = 196{,}608\) bytes \(= 192\) KiB. Corpus: \(50{,}000 \times 192\ \text{KiB} = 9{,}600{,}000\ \text{KiB} = 9375\ \text{MiB} \approx 9.2\ \text{GiB}\). (For reference, a single-vector index at \(d=128\) fp16 would be \(50{,}000 \times 256\ \text{B} = 12.8\) MB — the multi-vector premium is the \(768\times\).)

(b) Token pooling to 128 patches. Per page: \(128 \times 128 \times 2 = 32{,}768\) bytes \(= 32\) KiB. Corpus: \(50{,}000 \times 32\ \text{KiB} = 1{,}600{,}000\ \text{KiB} = 1562.5\ \text{MiB} \approx 1.53\ \text{GiB}\). This is a \(768/128 = 6\times\) reduction.

© Binary quantization, 1 bit/dim. Per page: \(128\ \text{patches} \times 128\ \text{dims} \times 1\ \text{bit} = 16{,}384\ \text{bits} = 2048\) bytes \(= 2\) KiB. Corpus: \(50{,}000 \times 2\ \text{KiB} = 100{,}000\ \text{KiB} = 97.66\ \text{MiB} \approx 98\ \text{MiB}\).

Overall reduction from (a): fp16 is 16 bits/dim vs 1 bit/dim (\(16\times\)) on top of the \(6\times\) pooling, so \(6 \times 16 = 96\times\). Check: \(9375\ \text{MiB} / 97.66\ \text{MiB} \approx 96\). The 9.2 GiB index becomes ~98 MiB, easily RAM-resident.

4. (Quantitative) On a ViDoRe-style task, your system retrieves 3 pages with binary relevances (in retrieved order) \([1, 0, 1]\). The corpus contains exactly 2 relevant pages for this query. Compute nDCG@3 using the chapter’s definition. Show DCG and IDCG.

Solution

With binary relevance, \(2^{\text{rel}}-1\) is \(1\) for a relevant page and \(0\) otherwise, and the rank discount is \(\log_2(i+1)\).

DCG@3 of \([1,0,1]\): $$ \frac{1}{\log_2 2} + \frac{0}{\log_2 3} + \frac{1}{\log_2 4} = \frac{1}{1} + 0 + \frac{1}{2} = 1.5 . $$

IDCG@3. The ideal ordering puts both relevant pages first: \([1, 1, 0]\). $$ \frac{1}{\log_2 2} + \frac{1}{\log_2 3} + \frac{0}{\log_2 4} = 1 + \frac{1}{1.585} + 0 = 1 + 0.6309 = 1.6309 . $$

nDCG@3: $$ \frac{1.5}{1.6309} \approx 0.9197 . $$

The score is below 1 because a relevant page sat at rank 3 instead of rank 2 — nDCG penalizes the relevant page pushed down by the irrelevant one at rank 2.

5. (Implementation) The chapter notes binary quantization (1 bit/dim) is “often the single highest-leverage optimization.” Implement it. Write binarize(V) that sign-quantizes and bit-packs a patch matrix, and approx_maxsim(q_bits, d_bits, dim) that computes an approximate MaxSim using XOR + popcount (Hamming distance) instead of float dot products. Verify on random unit vectors that the approximate ranking tracks the exact MaxSim.

Solution

For \(\pm 1\) sign vectors \(b_u, b_v \in \{-1,+1\}^d\), the dot product is \(b_u^\top b_v = d - 2\,\mathrm{Hamming}(b_u, b_v)\): each dimension contributes \(+1\) when the signs agree and \(-1\) when they differ. So a monotone surrogate for cosine is \(d - 2\cdot\text{Hamming}\), computed with bit ops on packed representations (1 bit/dim storage).

import numpy as np

def binarize(V):
    """V: [m, d] float -> packed sign bits [m, ceil(d/8)] uint8 (1 = nonneg)."""
    signs = (V >= 0).astype(np.uint8)      # 1 bit per dimension
    return np.packbits(signs, axis=-1)

def approx_maxsim(q_bits, d_bits, dim):
    """q_bits: [n, bytes], d_bits: [m, bytes]. Approx MaxSim via Hamming."""
    xor = np.bitwise_xor(q_bits[:, None, :], d_bits[None, :, :])  # [n, m, bytes]
    # Cast to signed: unpackbits.sum returns uint64, and dim - 2*ham goes
    # negative when >half the bits differ, which would underflow uint64.
    ham = np.unpackbits(xor, axis=-1).sum(-1).astype(np.int64)   # [n, m]
    sim = dim - 2 * ham                     # [n, m] surrogate for cos * dim
    return sim.max(axis=1).sum()            # MaxSim: best patch per q-token

def exact_maxsim(Q, D):
    return float((Q @ D.T).max(axis=1).sum())

# --- sanity check: approximate ranking should track exact ranking ---
rng = np.random.default_rng(0)
dim = 128
def unit(m): 
    X = rng.standard_normal((m, dim)); return X / np.linalg.norm(X, axis=1, keepdims=True)

Q = unit(8)                                  # 8 query tokens
pages = [unit(rng.integers(40, 80)) for _ in range(20)]

exact = np.array([exact_maxsim(Q, D) for D in pages])
qb = binarize(Q)
approx = np.array([approx_maxsim(qb, binarize(D), dim) for D in pages])

# Compare orderings (top pages should mostly agree).
print("exact top5 :", np.argsort(-exact)[:5])
print("approx top5:", np.argsort(-approx)[:5])

The bit-packed index uses \(1/16\) of the fp16 memory. Ranks are approximate, so in a real system this stage does candidate generation and exact fp16 MaxSim reranks the shortlist (the two-tier storage split from the chapter).

6. (Implementation) Reproduce the chapter’s HNSW-over-patches funnel without an ANN library, so the candidate-generation logic is explicit. Given a query’s patch matrix and a dict of page_id -> [m, d] patch matrices, implement (1) candidate generation as the union of pages owning each query token’s top-\(k\) nearest patches across the whole corpus, then (2) exact-MaxSim reranking over only those candidate pages. Return the top-\(n\) pages.

Solution

This mirrors PatchHNSWIndex.search from the chapter: flatten every patch, tag it with its page, take each query token’s nearest patches, union the owning pages, and rerank that shortlist with exact MaxSim. The only difference is that we use an exact top-\(k\) (via argpartition) in place of an approximate HNSW knn_query — the funnel structure is identical.

import numpy as np

def maxsim(Q, D):
    return float((Q @ D.T).max(axis=1).sum())

def candidate_pages(q_patches, pages, k_per_token=5):
    # Flatten all patches, remembering which page owns each.
    mats, owner = [], []
    for pid, D in pages.items():
        mats.append(D)
        owner.extend([pid] * D.shape[0])
    A = np.vstack(mats).astype(np.float32)     # [total_patches, d]
    owner = np.asarray(owner)
    cands = set()
    for q in q_patches:                        # one ANN probe per query token
        sims = A @ q                           # [total_patches]
        top = np.argpartition(-sims, k_per_token)[:k_per_token]
        cands.update(owner[top].tolist())      # union of owning pages
    return cands

def search(q_patches, pages, k_per_token=5, topn=3):
    q_patches = q_patches.astype(np.float32)
    cands = candidate_pages(q_patches, pages, k_per_token)      # stage 1: approx
    scored = [(pid, maxsim(q_patches, pages[pid])) for pid in cands]  # stage 2: exact
    scored.sort(key=lambda x: -x[1])
    return scored[:topn]

# --- demo ---
rng = np.random.default_rng(1)
d = 128
def unit(m):
    X = rng.standard_normal((m, d)); return (X / np.linalg.norm(X, axis=1, keepdims=True)).astype(np.float32)
pages = {f"p{i}": unit(int(rng.integers(60, 100))) for i in range(50)}
Q = unit(10)
print(search(Q, pages, k_per_token=5, topn=3))

Candidate generation is approximate: a relevant page is missed only if none of its patches makes any query token’s top-\(k\). Recall stays high in practice because a truly relevant page usually has several strongly matching patches, so it enters the union through more than one query token. Raising k_per_token trades recall for cost — the same ef/nprobe knob discussed in the Interview Corner.