Skip to content

Repository files navigation

Atlas — Multimodal Due-Diligence Data Room Intelligence

Visual-citation RAG for M&A / investment data rooms. Ask a question, get a grounded answer with the exact region highlighted on the original scanned page — retrieved over page images, not lossy OCR text, verified before it's shown, and costed in dollars on every response.

Python 3.11+ LangGraph Gemini ColPali Tests License: MIT

Atlas answers a data-room question and highlights the exact cited region on the original page

Ask a question → the relevant region resolves on the page → a grounded, cited answer with its cost.  ·  ▶ full-quality MP4

Why retrieve over page images instead of OCR text? In real filings the answer lives in the layout — a number in a table cell, a signature block, a stamp, a figure in an infographic. Text-only RAG flattens the page and throws that structure away. Atlas keeps the page intact and lets the model look at it.


Table of contents


What it does

Atlas ingests a heterogeneous data room — scanned PDFs, contracts, financial tables, charts — and answers analyst questions the way a diligence associate would: by pointing at the page.

  1. Ingest — render every page to an image, embed it with ColPali (late-interaction visual retrieval) on HF ZeroGPU, embed the extracted text with bge-m3, and pull named entities with GLiNER into a Neo4j knowledge graph.
  2. Retrieve — fuse three channels (visual + dense + graph) with Reciprocal Rank Fusion, then re-rank with a cross-encoder.
  3. AnswerGemini 3.1 Flash Lite reads the actual page images and returns an answer with a per-claim {page, quote}.
  4. Verify — a second pass re-checks every claim against its cited page and refuses, re-retrieves, or escalates to a human rather than hallucinate.
  5. Cite — the ColPali patch-similarity map is the highlight: the answer ships with a cropped page region + bounding box + heatmap.
  6. Account — every LLM call and every pipeline node is traced and costed; each response carries a meta.cost breakdown in USD.

The demo corpus is a real public SEC S-1 filing (Uber Technologies, 407 pages) fetched from EDGAR.

What makes it different

Atlas Typical "chat with PDF"
Retrieval unit The rendered page image (ColPali late interaction) OCR'd text chunks
Layout awareness Preserved — tables, figures, stamps, signatures Flattened / lost
Citations Pixel-level: crop + bbox + heatmap on the scan Page number, at best
Groundedness Verifier gate → refuse / re-retrieve / escalate Answer as-is
Retrieval channels Visual + dense + entity-graph, RRF-fused + reranked Single dense channel
Ops Per-call $ + latency, budget guard, Langfuse/Grafana/ledger None

This isn't a wrapper around an LLM — it's the retrieval, verification, and observability machinery a real diligence tool would need, built from scratch behind clean interfaces.

System architecture

flowchart LR
    subgraph Ingest["Ingestion — idempotent, sha256-deduped"]
        PDF["S-1 / data-room PDF"] --> R["PyMuPDF render<br/>page to PNG"]
        R --> CP["ColPali embed<br/>(HF ZeroGPU)"]
        R --> TX["Text + word-bbox<br/>extract and chunk"]
        TX --> D["bge-m3 dense"]
        TX --> NER["GLiNER entities<br/>(windowed)"]
    end

    subgraph Stores["Stores"]
        PG[("Postgres + pgvector<br/>multivectors · pooled ANN<br/>dense · cost ledger")]
        NEO[("Neo4j<br/>entity graph")]
        RD[("Redis<br/>dedup · cache · HITL")]
    end

    CP --> PG
    D --> PG
    NER --> NEO

    subgraph Query["Query — LangGraph"]
        Q["Analyst question"] --> API["FastAPI"]
        API --> GR["Planner → Retrieve → Answer → Verify"]
    end

    PG --> GR
    NEO --> GR
    RD --> GR
    GR --> ANS["Answer + visual citation<br/>+ meta.cost"]
    GR -. traces .-> OBS["OTel → Langfuse<br/>Prometheus / Grafana"]
Loading

The control plane stays laptop-light. ColPali (a 3B PaliGemma-based model) runs remotely on ZeroGPU behind a small Gradio Space; MaxSim scoring and citation heatmaps are pure numpy, so torch is not in the core install. Gemini is a hosted API. Only Postgres and Redis need to run locally (Neo4j, Prometheus and Grafana are opt-in compose profiles).

The query pipeline (LangGraph)

The pipeline is a StateGraph. The interesting part is the gate after the verifier — it balances answer recall against API spend instead of blindly retrying:

flowchart TD
    P["planner"] --> R["retriever<br/>visual + dense + graph → RRF → rerank"]
    R --> A["answerer<br/>Gemini reads page images →<br/>answer + per-claim citations"]
    A --> V{"verifier<br/>re-checks each claim vs cited page image"}
    V -->|"grounded"| F["finalize<br/>answer + visual citation + meta.cost"]
    V -->|"unverified — retries remain"| RP["replan · widen retrieval"]
    V -->|"unverified — budget spent"| H["hitl · escalate to human review"]
    V -->|"no evidence found"| F
    RP --> R
    H --> F
Loading
  • Grounded → return the answer with its citation and cost.
  • Drafted but unverified, retries remaining → re-plan (widen retrieval via extra_k) and try again — up to max_retries.
  • Still unverified after the retry budget → escalate to a Redis-backed human review queue (/review/queue, /review/resolve); the reviewer's decision is logged as eval data.
  • Model found nothing → refuse immediately — no wasted retry loops, no wasted tokens.

Visual citations — the headline feature

ColPali is a late-interaction model: each page is stored as a multivector — one embedding per image patch — and each query as one embedding per token. The relevance score is MaxSim:

score(query, page) = Σ_i  max_j  ( q_i · p_j )        # sum over query tokens of best-matching patch

The elegant part: the per-patch term of that same dot product is the citation heatmap — no extra model, no separate "highlighter":

relevance(patch_j) = max_i ( q_i · p_j )   →   reshape to (ny, nx) grid   →   upsample   →   threshold → bbox

So the highlight drawn on the scan is computed locally in numpy from vectors already in the database. Click an answer in the Streamlit demo and the exact region lights up on the original page.

Hybrid retrieval

Three independent channels are fused, then reranked with a cross-encoder:

flowchart TD
    Q["Query"] --> VE["ColPali query embed<br/>(ZeroGPU)"]
    Q --> DE["bge-m3 dense embed"]
    Q --> GE["GLiNER query entities"]

    VE --> VIS["Visual channel<br/>pooled ANN → exact MaxSim"]
    DE --> DEN["Dense channel<br/>pgvector cosine"]
    GE --> GRAPH["Graph channel<br/>Neo4j entity + 1-hop co-occurrence"]

    VIS --> RRF["Reciprocal Rank Fusion"]
    DEN --> RRF
    GRAPH --> RRF
    RRF --> RR["bge-reranker-v2-m3<br/>cross-encoder"]
    RR --> OUT["top-k candidates → answerer"]
Loading

Scaling the visual channel. Exact MaxSim over every page's full multivector is accurate but grows linearly with the corpus. Atlas stores a mean-pooled, L2-normalised single vector per page in a pgvector HNSW index, uses it for a coarse ANN shortlist (top-visual_coarse_n), then runs exact MaxSim only on the shortlist — turning a linear scan into a sub-linear one, with a full-scan fallback if the pooled table isn't backfilled yet. On the 407-page corpus this took the visual scan from 734 ms → 181 ms (ADR 0001).

The graph channel extracts entities from the query with GLiNER, matches them against the Neo4j graph, and expands one co-occurrence hop — so "who advised on the offering?" can reach the underwriting section through the entities it mentions, even when the page shares few keywords with the question. If Neo4j is unavailable the channel fast-fails and degrades to visual + dense.

Ingestion

Ingestion is idempotent (sha256 + Redis SETNX) and atomic (a partially-ingested document is deleted on failure, and the dedup lock is always released in a finally):

sequenceDiagram
    participant U as atlas ingest
    participant P as Pipeline
    participant R as Redis
    participant Z as ColPali Space · ZeroGPU
    participant DB as Postgres+pgvector
    participant N as Neo4j

    U->>P: ingest_pdf(path)
    P->>R: SETNX sha256 lock (dedup)
    P->>P: PyMuPDF render pages to PNG
    P->>P: extract text + word bboxes, chunk
    P->>Z: embed_pages(images)
    Z-->>P: multivectors + patch grids
    P->>DB: store multivectors + pooled ANN vectors
    P->>P: bge-m3 dense embeds
    P->>DB: store dense vectors + chunks
    P->>P: GLiNER windowed NER
    P->>N: upsert entity graph (best-effort)
    P->>R: release lock (finally)
    Note over P,DB: atomic — partial doc deleted on failure
Loading

Entity extraction runs GLiNER over overlapping ~1,200-char windows and dedupes, instead of truncating the page — a long prospectus page yields dozens of entities the naïve single-pass approach would silently drop.

Observability & cost

Cost and latency are first-class, not an afterthought — this is a system designed to run on ~$3 of Gemini credit without surprises.

  • Per LLM call: read usage_metadata (input / output / thinking tokens) → price it from pricing.yaml → write a row to the Postgres cost ledger (llm_calls, request_costs).
  • Per node: each pipeline node opens an OpenTelemetry span (GenAI semantic conventions) with latency; ColPali calls also record ZeroGPU seconds.
  • Three sinks: Langfuse (trace-level $/latency + dashboards), Prometheus + Grafana (provisioned dashboard, observability profile), and the SQL ledger for rollups and cost-per-answer in the eval report.
  • Guardrails: per-request USD cap, a hard daily budget (max_usd_per_day), and a Redis answer cache keyed on (question, index_version) to avoid paying twice for the same question.

Every /query response includes:

"meta": {
  "latency_ms": 4213.7,
  "cost": { "usd_total": 0.0021, "n_llm_calls": 2 },
  "versions": { "index": "v1", "prompt": "v1", "model": "gemini-3.1-flash-lite" }
}

Engineering decisions & trade-offs

Decision Why Trade-off / notes
Retrieve over page images (ColPali) Preserves layout; enables pixel citations Heavier per-page store than text (ADR 0002)
ColPali on HF ZeroGPU, not local 16 GB Mac can't host a 3B VLM; keeps torch out of core Network hop + cold starts; downscale images before upload
MaxSim + heatmaps in numpy No torch in the control plane; citations are "free" Re-implements late interaction by hand (unit-tested)
Gemini 3.1 Flash Lite Cheap enough to run a verifier pass on every answer function_calling structured output (json-schema returned empty on this model)
Pooled-ANN two-stage visual search Linear MaxSim doesn't scale Approximate shortlist; exact rescoring recovers precision
Verifier re-retrieve loop, cost-aware Recall without runaway spend Adds latency on hard questions; capped by max_retries
Postgres + pgvector for everything One store for dense, multivector, pooled ANN, and the ledger VectorChord / Qdrant is the scale path (documented)

Full write-ups live in docs/architecture.md and docs/adr/.

Measured on the demo corpus

Observed during development on the Uber Technologies S-1/A (407 pages) — illustrative, not a published benchmark. The eval harness (make eval) reports recall@k, nDCG@10, citation precision, groundedness, refusal calibration, Cohen's κ vs. human labels, and cost-per-answer.

Metric Value
Corpus Uber Technologies S-1/A — 407 pages
Visual scan latency 734 ms → 181 ms (pooled ANN vs. full MaxSim)
Entity graph ~2,040 entities · 395 pages · ~25k co-occurrence edges
Cost per answered query ~$0.002 (answerer + verifier, Gemini 3.1 Flash Lite)
Automated tests 40 passing

Tech stack

Orchestration LangGraph · LangChain · VLM Gemini 3.1 Flash Lite (Google AI Studio) · Visual retrieval ColPali vidore/colpali-v1.2-hf on HF ZeroGPU · Dense bge-m3 · Rerank bge-reranker-v2-m3 · NER GLiNER · Stores PostgreSQL + pgvector, Redis, Neo4j · API/UI FastAPI, Streamlit · Docs PyMuPDF, Playwright · Observability OpenTelemetry, Langfuse, Prometheus + Grafana · Tooling Python 3.11, uv, ruff, pytest.

Quick start

Prerequisites: Python ≥ 3.11, uv, Docker, a Google AI Studio API key, and a Hugging Face token with ZeroGPU access (HF Pro).

cp .env.example .env          # fill GOOGLE_API_KEY, HF_TOKEN, COLPALI_SPACE_ID (LANGFUSE_* optional)
make install                  # uv sync (core + local models + ingest + graph) + playwright chromium
make up                       # Postgres+pgvector + Redis   (make up-obs adds Prometheus+Grafana)

# Deploy the ColPali ZeroGPU Space once — see services/colpali_space/README.md — then:
make download CIK=0001543151  # fetch + render Uber's S-1 to data/raw/
make ingest PDF=data/raw/<file>.pdf

make serve                    # FastAPI on :8000
make ui                       # Streamlit demo on :8501

Ask a question:

curl -s localhost:8000/query -d '{"question":"Who are the underwriters?"}' \
  | jq '{answer, citations, cost: .meta.cost}'

Optional V2 (GraphRAG + ANN):

make neo4j                    # start Neo4j (compose --profile v2)
uv run atlas backfill-pooled  # pooled ANN vectors for existing pages
uv run atlas backfill-graph   # load pages/entities into the Neo4j graph

Evaluate & test:

make eval                     # recall@k, nDCG, citation precision, groundedness, κ, cost/answer → report
make test                     # 40 tests (Gemini + ColPali Space mocked — no keys needed)

All secrets live in .env, which is git-ignored. Only the blank .env.example is committed.

Project structure

atlas/
├── src/atlas/
│   ├── models/          # colpali (zerogpu+local), maxsim (numpy), gemini, dense, reranker, ner
│   ├── ingest/          # download (EDGAR→PDF), render, extract, pipeline, kafka_consumer (V2 stub)
│   ├── index/           # pgvector_store, graph_store (Neo4j), schema
│   ├── retrieve/        # visual, dense, graph, fusion (RRF), rerank, hybrid
│   ├── pipeline/        # LangGraph: build.py + nodes/{planner,retriever,answerer,verifier,finalize,...}
│   ├── citations/       # heatmap.py (patch relevance → bbox), render_citation.py
│   ├── observability/   # tracing (OTel+Langfuse), cost, metrics (Prometheus), ledger, instrument
│   ├── hitl/            # Redis human-review queue (V2)
│   ├── eval/            # dataset, metrics, judge (Gemini + Cohen's κ), run_eval, report
│   ├── api/             # FastAPI app + routes (query, ingest, review, metrics)
│   └── cli.py           # `atlas` command (download-s1, ingest, serve, backfill-*, seed-eval, eval)
├── services/colpali_space/  # Gradio ZeroGPU Space: embed_pages / embed_query
├── infra/               # postgres/init.sql, prometheus, grafana dashboards, k8s (stub)
├── docs/                # architecture.md + adr/
├── ui/streamlit_app.py  # demo UI with the cost/latency strip
└── tests/               # unit + integration (external seams mocked)

Testing

make test    # uv run pytest  — 40 tests
make lint    # ruff check + format --check

Tests mock the external seams (ColPali Space, Gemini, Postgres, Redis) so the real orchestration is exercised — answer-vs-refuse-vs-escalate routing, RRF fusion, MaxSim math, heatmap→bbox, cost accounting — without needing a deployed Space or an API key. The integration suite drives the full LangGraph, including the verifier loop actually enqueuing to the HITL queue.

Roadmap

  • V1 — shipped & running end-to-end. ZeroGPU ColPali · dense + RRF + rerank · Gemini answerer · verifier/refusal · pixel-level visual citations · SEC S-1 ingest · full cost/latency observability · Streamlit demo · eval harness with Cohen's κ.
  • V2 — implemented. Windowed NER (entity-loss fix) · pooled-ANN two-stage visual search · Neo4j GraphRAG as a third retrieval channel · verifier re-retrieve loop + HITL escalation queue.
  • V3 — scaffolded behind interfaces. Multi-tenant RLS + access-scoped citations · MNPI / conflict governance · Kafka ingest consumer · self-hosted vLLM provider · A/B retrieval routing · K8s manifests.

License

MIT © Ayush Gupta

Built as a from-scratch senior-level systems project: visual retrieval, grounded generation, and real LLMOps.

About

Multimodal Due-Diligence Data Room Intelligence

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages