Capstone 04 — Multimodal Document QA (Vision-First PDF, Tables, Charts)
Vision-first document QA treats each PDF page as an image instead of running OCR and searching the text. ColPali, ColQwen2.5, and ColQwen3-omni embed every page with multi-vector late interaction, so the query attends to patches directly. On financial 10-Ks, scientific papers, and handwritten notes, that beats OCR-first by a wide margin. Build the pipeline end to end on 10k pages and publish the side-by-side against OCR-then-text.
Learning Objectives
- Explain how vision-first late-interaction retrieval (ColPali, ColQwen) scores page images with MaxSim, and why it beats OCR-then-text on tables, charts, and equations.
- Build the pipeline end to end: render pages, embed with multi-vector ColQwen, prune with DocPruner, and index 10k pages in a multi-vector store.
- Wire a vision-language answerer that cites pages and evidence regions, with an OCR text channel for equation-dense pages.
- Evaluate the system with ViDoRe v3 nDCG@5 and M3DocVQA against an OCR-then-text baseline, and report a content-type by approach matrix.
Problem
Vision-first document retrieval means embedding the raw image of each PDF page and searching those embeddings, instead of running OCR and searching the extracted text. The reason to do it: enterprises sit on PDFs that OCR pipelines mangle. Scanned 10-Ks have rotated tables, scientific papers are dense with equations, charts only make sense as images, and handwritten annotations don't survive text extraction. Treat these as text-first and you lose half the signal.
The 2026 answer is late-interaction multi-vector retrieval on page images. ColPali (Illuin Tech) introduced it, and ColQwen2.5-v0.2 and ColQwen3-omni pushed accuracy further. On ViDoRe v3, vision-first retrieval scores above OCR-then-text by meaningful margins, and the gap widens on charts, tables, and handwriting.
The cost is storage and latency. A ColQwen embedding is ~2048 patch vectors per page (each a small 128-dim vector), not a single pooled vector per page, so raw storage balloons. DocPruner (2026) prunes 50% of the patches without measurable accuracy loss. You will index 10k pages, measure ViDoRe v3 nDCG@5, serve answers under 2s, and compare directly against an OCR-then-text baseline.
Concept
Late interaction is a scoring scheme where every query token scores against every patch token, and the maximum score per query token is summed. That sum is called MaxSim. You get fine-grained matching without squeezing a page into one pooled vector. A multi-vector index (Vespa, Qdrant multi-vector, or AstraDB) stores the per-patch embeddings and runs MaxSim at retrieval time.
The answerer is a vision-language model that takes the query plus the top-k retrieved pages as images and writes an answer with evidence regions (bounding boxes or page references). Qwen3-VL-30B, Gemini 2.5 Pro, and InternVL3 are the 2026 frontier choices. For equations and scientific notation, an OCR fallback (Nougat, dots.ocr) can be spliced in as an optional text channel.
Evaluation is a two-dimensional matrix. One axis is content type: plain text paragraphs, dense tables, bar/line charts, handwritten notes, equations. The other is retrieval approach: vision-first late interaction, OCR-then-text, or a hybrid. Each cell gets nDCG@5 and answer accuracy. The report is the deliverable.
Architecture
PDFs -> page renderer (PyMuPDF, 180 DPI)
|
v
ColQwen2.5-v0.2 embed (multi-vector per page, ~2048 patches)
|
+------> DocPruner 50% compression
|
v
multi-vector index (Vespa or Qdrant multi-vector)
|
query ----+----> retrieve top-k pages (MaxSim)
|
v
VLM answerer: Qwen3-VL-30B | Gemini 2.5 Pro | InternVL3
inputs: query + top-k page images + optional OCR text
|
v
answer with cited page numbers + evidence regions
|
v
Streamlit / Next.js viewer: highlighted boxes on source page
Stack
- Page rendering: PyMuPDF (fitz) at 180 DPI, portrait-normalized
- Late-interaction model: ColQwen2.5-v0.2 or ColQwen3-omni (vidore team on Hugging Face)
- Index: Vespa with multi-vector field, or Qdrant multi-vector, or AstraDB with MaxSim
- Pruning: DocPruner 2026 policy (keep high-variance patches, 50% compression at < 0.5% accuracy loss)
- OCR fallback (equations / dense tables): dots.ocr or Nougat
- VLM answerer: Qwen3-VL-30B self-hosted or Gemini 2.5 Pro hosted; InternVL3 as fallback
- Evaluation: ViDoRe v3 benchmark, M3DocVQA for multi-page reasoning
- Viewer UI: Next.js 15 with canvas overlay for evidence regions
Build It
-
Ingest. Walk a corpus of 10k PDF pages across 10-Ks, scientific papers, and scanned documents. Render each page to a 1536x2048 PNG and persist
{doc_id, page_num, image_path}. -
Embed. Run ColQwen2.5-v0.2 on each page image. The output is ~2048 patch embeddings of dim 128. Apply DocPruner to keep the highest-signal half, then write to a Vespa multi-vector field or Qdrant multi-vector.
-
Query. Embed each incoming query with the query tower, which gives token-level embeddings. Run MaxSim against the index: for every query token, take the max dot-product over the page's patch embeddings, then sum. Return the top-k pages.
-
Synthesize. Call Qwen3-VL-30B with the query and the top-5 page images. Prompt: "Answer using only the supplied pages. Cite each claim by (doc_id, page) and name the region (figure, table, paragraph)."
-
Evidence regions. Post-process the answer to extract cited regions. If the VLM emits bounding boxes (Qwen3-VL does), render them as overlays in the viewer.
-
OCR fallback. For pages identified as equation-dense (a heuristic on image variance), run Nougat or dots.ocr and pass the OCR text as an extra channel alongside the image.
-
Eval. Run ViDoRe v3 (retrieval nDCG@5) and M3DocVQA (multi-page QA accuracy). Run the OCR-then-text pipeline on the same corpus with the same synthesizer, and produce a content-type × approach matrix.
-
UI. Start with a Streamlit prototype, then build the Next.js 15 production viewer with page-by-page evidence-region overlays.
Use It
$ doc-qa ask "what was the 2024 operating margin change for segment EMEA?"
[retrieve] top-5 pages in 320ms (ColQwen2.5, MaxSim, Vespa)
[synth] qwen3-vl-30b, 1.4s, cited (form-10k-2024, p. 88) + (..., p. 92)
answer:
EMEA operating margin moved from 18.2% to 16.8%, a 140bp decline.
cited: 10-K-2024.pdf p.88 (Table 4, Segment Operating Margin)
10-K-2024.pdf p.92 (MD&A, Operating Performance)
[viewer] open with highlighted bounding boxes overlaid on p.88 Table 4
Ship It
outputs/skill-doc-qa.md describes the deliverable: a vision-first document QA system tuned to a specific corpus and evaluated against an OCR-then-text baseline on ViDoRe v3.
| Weight | Criterion | How it is measured |
|---|---|---|
| 25 | ViDoRe v3 / M3DocVQA accuracy | Benchmark numbers vs OCR-text baseline and published leaderboard |
| 20 | Evidence-region grounding | Fraction of cited regions that actually contain the answer span |
| 20 | Storage and latency engineering | DocPruner compression ratio, index p95, answer p95 |
| 20 | Multi-page reasoning | Accuracy on a hand-labeled 100-question multi-page set |
| 15 | Source-inspection UX | Viewer clarity, overlay fidelity, side-by-side comparison tools |
| 100 |
I'd build the OCR-then-text baseline before the vision pipeline, not after. The whole claim of this capstone is a gap between the two, and the Ship It rubric scores you against that baseline, so a ColQwen system with nothing to compare to has no result to report.
My bet is that evidence-region grounding is the number to watch, more than nDCG@5. The rubric defines it as the fraction of cited regions that actually contain the answer span. An answer that is right but points at the wrong box is a citation nobody can check, and the viewer exists so that a person can check.
Exercises
-
Measure ColQwen2.5-v0.2 against ColQwen3-omni on the same corpus. Which pages does one get right and the other miss? Add a "content class" tag to the index to route by type.
-
Prune embeddings aggressively (75%, 90%) and find the compression cliff, the point where ViDoRe nDCG@5 drops below the OCR baseline.
-
Build a hybrid. Run OCR-then-text and ColQwen in parallel, fuse with RRF, and rerank with a cross-encoder. Does the hybrid beat either alone? Where does it help most?
-
Swap Qwen3-VL-30B for a smaller VLM (Qwen2.5-VL-7B) and measure the accuracy-per-dollar curve.
-
Add handwritten-note support. Render the handwriting corpus, embed it with ColQwen, and measure retrieval. Compare against a handwriting OCR pipeline.
Key Terms
| Term | What people say | What it actually means |
|---|---|---|
| Late interaction | "ColPali-style retrieval" | Query tokens score against page patches independently; MaxSim aggregates |
| Multi-vector | "Per-patch embedding" | Each document has many vectors, not one pooled vector |
| MaxSim | "Late-interaction scoring" | For every query token, take max similarity over document vectors; sum |
| DocPruner | "Patch compression" | 2026 pruning that keeps 50% of patches with negligible accuracy loss |
| ViDoRe v3 | "Document-retrieval benchmark" | The 2026 standard for measuring visual-document retrieval |
| Evidence region | "Cited bounding box" | A bbox on the source page that localizes the answer span |
| OCR fallback | "Equation channel" | Text pipeline used alongside vision for equation- or table-heavy pages |
Further Reading
- ColPali (Illuin Tech) repository - reference late-interaction doc retrieval
- ColPali paper (arXiv:2407.01449) - the foundational method paper
- ColQwen family on Hugging Face - production-ready checkpoints
- M3DocRAG (Adobe) - multi-page multimodal RAG baseline
- Vespa multi-vector tutorial - reference serving stack
- Qdrant multi-vector support - alternate index
- AstraDB multi-vector - alternate managed index
- Nougat OCR - equation-capable OCR fallback
Code
- main.py (python, 165 lines)
- ts/package.json (json, 23 lines)
- ts/README.md (markdown, 45 lines)
- ts/src/fixtures.ts (typescript, 55 lines)
- ts/src/index.ts (typescript, 121 lines)
- ts/src/render.ts (typescript, 94 lines)
- ts/src/server.ts (typescript, 42 lines)
- ts/src/types.ts (typescript, 25 lines)
- ts/tests/fixtures.test.ts (typescript, 32 lines)
- ts/tests/render.test.ts (typescript, 65 lines)
- ts/tests/server.test.ts (typescript, 51 lines)
- ts/tsconfig.json (json, 22 lines)