Build Visual Provenance for RAG: Trace Every Answer Back to Its Source

The support engineer clicks the citation and lands on page 47 of a two-hundred-page vendor agreement. The answer said termination requires thirty days' notice. Page 47 says sixty. The citation wasn't broken — it pointed to a real page in a real document. It just pointed to the wrong passage.

That's the gap most RAG systems ship with. The retrieval pipeline found the right file. The generator produced a plausible answer. The citation metadata says source: vendor-agreement.pdf, page: 47. Everyone downstream treats it as provenance. It's closer to a signed confession with the wrong paragraph underlined.

Visual provenance is the discipline of closing that gap: binding every claim to a specific, inspectable region of a specific version of a specific source, and showing that binding in a way a human can verify in seconds rather than minutes.

📋 Table of Contents

The Citation Looks Right Until You Click It

Here's a number worth sitting with: OWASP's AISVS research on source attribution cites evidence that 57% of RAG citations are post-rationalized from parametric memory rather than generated from the retrieved evidence. The model already knew (or thought it knew) the answer. It produced a citation afterward to make the answer look grounded. The citation is decoration, not evidence.

This is not a model-quality problem you can prompt your way out of. It's an architectural problem. If the citation string comes from the generator, it is a generated token like any other — and generated tokens can be wrong, invented, or borrowed from a nearby passage that sounds similar.

The same research documents a failure mode called citation laundering: a topically relevant source is presented as warrant for a stronger claim than it actually supports. The source is real. The quote is real. The inference the model drew from it is not.

Visual provenance attacks both problems at the same layer: make the evidence inspectable, and make the binding between claim and evidence deterministic rather than generative.

1. Inline Micro-Citations Beat a Source Panel at the Bottom

Scenario: Your RAG assistant answers a policy question with four paragraphs of synthesis. At the bottom, it lists five source links. The user has no idea which of the five supports which sentence, so they either trust the whole thing or verify none of it.

Why the obvious solution fails: A source panel is provenance theater. It proves the system retrieved something. It doesn't prove the answer used it. When a claim is wrong, the panel gives the reviewer five haystacks and no needle.

Solution: Bind citations to the smallest unit of meaning the user can verify. For most answer interfaces, that's the sentence or the claim. The citation UX research is direct about this: start with inline micro-citations for every non-trivial factual claim, anchored to the one or two top-ranked sources. Use the source panel as a supplement for browsing, not as the primary attribution mechanism.

The implementation pattern that holds up in production is a structured citation object attached to the answer at generation time, with a positional index into the source list. LlamaIndex's CitationQueryEngine does exactly this: it retrieves top-k nodes, re-chunks them to a configurable citation granularity (default 512 tokens), and prompts the model to emit [source_N] markers where each marker maps to a 1-based index over the re-chunked source list. The mapping is deterministic — response.source_nodes[0] is always [source_1].

from llama_index.core import VectorStoreIndex
from llama_index.core.query_engine import CitationQueryEngine

index = VectorStoreIndex.from_documents(docs)
query_engine = CitationQueryEngine.from_args(
    index,
    similarity_top_k=5,
    citation_chunk_size=512,
)

response = query_engine.query("What is the refund window?")

for i, source in enumerate(response.source_nodes, start=1):
    node = source.node
    print(f"[source_{i}] {node.metadata.get('file_name')} "
          f"page={node.metadata.get('page_label')} "
          f"score={source.score:.3f}")

Why this works: The citation index is positional inside the prompt, but the source list on the response carries stable node IDs and metadata. The frontend can render [source_3] as a chip, and the chip can deep-link to the exact chunk because source_nodes[2] still knows its node_id and file coordinates.

⚠️ Gotcha: The positional contract breaks the moment you rerank after retrieval. If you reorder nodes before injection but use the original order for citation mapping — or vice versa — the indices shift and the citation points to the wrong passage. Finalize node order before the citation engine sees it, or carry explicit source IDs in the prompt instead of positional indices.

2. The Provenance Contract: Store Coordinates, Not Just a URL

Scenario: Your citation popover shows a filename and a document link. A compliance reviewer asks, "Which section of the document?" You don't have an answer, because the chunk was created by fixed-size token splitting and the parser threw away the page and section structure.

Why the obvious solution fails: Traditional RAG cuts documents into token windows before embedding. It's computationally convenient, but it "brutally discards the document's structural information — page numbers, line numbers, paragraph boundaries," leaving the system able to identify the file but not the location within it. A file-level pointer proves where the corpus item came from, not which bytes support the sentence.

Solution: Treat provenance as a first-class data structure that travels with the chunk from parsing through retrieval to rendering. At minimum, every chunk should carry: a stable source identifier, a document version or content fingerprint, a page identifier (both machine index and human-facing label), a section breadcrumb, character offsets or a bounding box, and the exact evidence text that was embedded. EagleRAG's TextSource schema is a useful reference: it carries path, document ID, score, content, page numbers, keywords, and source_chunk_id, while its ImageSource carries four visual anchors — image ID, chunk type, parent section, content summary, and source chunk ID — so visual evidence can be section-attributed.

Here's a Pydantic schema that captures the contract without overfitting to one document format:

from pydantic import BaseModel, Field

class BoundingBox(BaseModel):
    x0: float
    y0: float
    x1: float
    y1: float

class Provenance(BaseModel):
    document_id: str
    document_version: str          # git SHA, S3 version, or content hash
    page_index: int | None         # machine page number
    page_label: str | None         # human-facing label ("iv", "A-12")
    section: str | None            # breadcrumb path
    char_start: int | None
    char_end: int | None
    bbox: BoundingBox | None
    evidence_text: str             # the exact text the embedding represents
    fingerprint: str               # hash of evidence_text + document_version

class CitedChunk(BaseModel):
    chunk_id: str                  # versioned, not just page_id::chunk_index
    provenance: Provenance
    retrieval_score: float

Why this works: The fingerprint is the anchor. If the document version changes, the fingerprint changes, and the old citation can no longer be rendered over the new document — which is exactly the behavior you want. The docushell provenance design calls this the "source-region evidence chain": claim → verification check ID → source fingerprint → page ID and dimensions → region ID → bounding box → canonical crop descriptor. Every link is immutable or versioned.

💡 Practical note: Store the provenance descriptor in the same transactional write as the chunk, not in a separate table you join at render time. The Hashnode discussion of a real RAG build put it well: store enough in the audit row to reconstruct what was actually cited "without a live join against current index state". Audit trails want per-stage provenance, and retrofitting that later means a full reindex.

3. "Right File, Wrong Passage" Is the Default Failure Mode

Scenario: The user asks about a specific exception in a contract. The retriever ranks the correct contract highly. But the top-ranked chunk is the definitions section, not the clause that contains the exception. The model reads the definitions, infers the exception from parametric knowledge, and cites the contract. The citation is real. The support is not.

This is the most common provenance failure in production, and it's structurally inevitable when document recall and passage recall are treated as the same problem. A file can rank highly because its title or overall embedding matches the query. That does not prove the answer-bearing paragraph is in the retrieved chunk set.

Three mechanisms produce it:

  • Chunk boundaries split evidence from context. A fixed-size split can put a heading in one chunk, the claim in another, and the exception in a third. The retriever selects the chunk that mentions the query terms but lacks the complete answer.
  • Similar passages cause ID mixing. The context contains several chunks from the same file with near-identical language. The model uses one passage's content and emits another nearby chunk's citation label.
  • Rerankers score topic similarity, not passage support. A first-stage retriever brings many chunks from the right file. A reranker that only measures topical relevance can't distinguish the section that directly answers the query from the section that merely shares the topic.

Solution: Measure document recall, page recall, and chunk recall separately, and verify support at the passage level after generation. The retrieval side is a chunking problem: prefer structure-aware splitting, parent-child retrieval, and neighbor expansion — but only when source offsets remain traceable. The generation side is a verification problem, which gets its own section below.

A cheap, high-signal check is to validate that the evidence text the model actually quoted or paraphrased appears in the cited chunk, not just in some retrieved chunk:

def citation_supports_claim(cited_chunk: CitedChunk, claim_quote: str) -> bool:
    cited_text = normalize(cited_chunk.provenance.evidence_text)
    quote = normalize(claim_quote)
    if not quote:
        return False
    # Require the quote to appear in the *cited* chunk, not just any chunk.
    return quote in cited_text or lexical_overlap(quote, cited_text) > 0.8

🚨 Production warning: If you verify against the union of all retrieved chunks rather than the specific cited chunk, you'll pass claims that are supported by chunk B but attributed to chunk A. That's citation laundering with a green checkmark.

4. Never Let the Model Generate the Citation Metadata

Scenario: A user asks for the page number of a policy. The model responds with a precise-looking page reference. You check the retrieval logs: the model was never given page metadata. It invented the number because the prompt asked for one.

Why the obvious solution fails: Asking the model to "cite your sources" produces citations as generated tokens. Generated tokens can be plausible, fluent, and completely fabricated — especially for structured fields like page numbers, section IDs, and URLs, where the model has no grounding signal beyond the surrounding text.

Solution: Make the citation metadata a passthrough from the retrieval layer, not a generation output. The model's job is to decide which evidence IDs support which claims. The application's job is to resolve those IDs into document coordinates and attach them to the response.

OWASP's AISVS control 7.4.2 states this as a verifiable requirement: confirm that document IDs, chunk IDs, page numbers, and URLs originate from the retriever's response object and are attached in the application layer, not produced as generated tokens. It also calls for retrieval-generation isolation — immutable evidence records, and a generator that cannot mutate the metadata store.

Vertex AI's grounding metadata follows this pattern at the API level. The response contains a grounding_chunk list (the retrieved content) and a grounding_supports structure whose grounding_chunk_indices field maps each claim to indices into that list. For example, [1,3,4] means the claim is attributed to grounding_chunk[1], [3], and [4]. The model selects indices; the API resolves them to source content.

LangChain's citation model is similar in spirit: the Citation struct carries cited_text, a source object, start_index, end_index, an optional confidence score, and provider-specific metadata for round-tripping. The structured fields exist so the application can render and validate them without parsing the model's prose.

def attach_citations(answer: str, cited_ids: list[str], evidence_store: dict) -> dict:
    sources = []
    for idx, evidence_id in enumerate(cited_ids, start=1):
        record = evidence_store[evidence_id]          # immutable, from retrieval
        sources.append({
            "marker": f"[{idx}]",
            "chunk_id": record["chunk_id"],
            "document_id": record["document_id"],
            "page_label": record["page_label"],
            "bbox": record["bbox"],
            "evidence_text": record["evidence_text"],
        })
    return {"answer": answer, "sources": sources}

Why this works: The model never touches page_label or bbox. If it emits [2], the application resolves [2] to whatever the retrieval layer recorded as the second evidence item. Fabricating a page number becomes impossible because the page number isn't in the model's output vocabulary — it's joined in after the fact.

🔍 Why this matters: Metadata passthrough is well understood, but the OWASP analysis flags two underappreciated gaps: there is no cross-vendor citation-metadata schema, and chunk IDs can silently break across re-embeddings because index versioning is underappreciated. Passthrough solves fabrication. It doesn't solve identity drift — that's the next problem.

5. Chunk IDs That Survive Re-Embedding

Scenario: Your audit log records that answer A cited chunk_abc123. Six weeks and two reindexes later, a regulator asks you to reproduce the answer. You look up chunk_abc123 and get different text. The ID is stable. The content behind it isn't.

Why the obvious solution fails: The common chunk ID scheme is page_id::chunk_index. It's deterministic and easy to generate. It's also a live pointer into current index state. When an incremental reindex overwrites the chunk in place, every audit row that cited that ID now resolves to content that didn't exist when the answer was generated.

Solution: Make the chunk ID encode version, or soft-supersede old chunks instead of overwriting them. A practical format is page_id::version::chunk_index, where version is a document content hash or commit SHA. The audit row should store enough to reconstruct the cited text without a live join: the evidence text itself, the document version, and the fingerprint.

ProofRAG's ingestion path does this explicitly: document and chunk IDs are deterministic and stable across re-ingestion, and provenance is recorded in Open Knowledge Format bundles containing document, page, chunk, and text. The deterministic-ID property matters more than the specific format. What you need is an ID that changes when the evidence changes and doesn't change when it doesn't.

import hashlib

def versioned_chunk_id(document_id: str, doc_version: str,
                       page_index: int, chunk_index: int) -> str:
    material = f"{document_id}:{doc_version}:{page_index}:{chunk_index}"
    return hashlib.sha256(material.encode()).hexdigest()[:24]

Why this works: The ID is content-addressed at the document-version level. A new document version produces new chunk IDs, so old audit rows still resolve to the old evidence. A re-embedding that doesn't change the source text produces the same IDs, so you don't invalidate citations unnecessarily.

🧠 The important part: This is a primary-key decision, and it's expensive to change later. The Hashnode build retrospective is blunt about it: doing versioned IDs after the fact means a full reindex, so bake it in before you ship the audit log.

6. Drawing the Box: Visual Grounding for PDFs and Scans

Scenario: The answer cites page 12 of a scanned invoice. The user opens page 12 and sees three tables and a wall of fine print. The system knew which chunk it used, but it can't show where on the page the evidence lives, so the user still has to hunt.

Why the obvious solution fails: A page link narrows the search to one page, which is better than a file link, but a page can contain multiple columns, repeated terms, similar table cells, footnotes, headers, and sidebars. Opening page 12 still forces the reviewer to search. The docushell analysis puts it neatly: document-only citations make the reviewer search the entire file; page-only citations make them search one page; a page region or table cell lets them inspect the exact area with context.

Solution: Preserve document geometry during parsing and bind each chunk to a validated bounding box. The parser must retain the document and source fingerprints, machine page index and human page label, page width and height, rotation, coordinate origin, stable element IDs, quantized bounding boxes, and the text associated with each region. Flattened Markdown is fine for generation, but it cannot power an exact visual overlay unless it keeps links back to geometric source elements.

The coordinate contract is the part teams skip and later regret. Bounding boxes are meaningless without a declared origin (top-left or bottom-left), axis directions, units (PDF points, pixels, quantized units), page box definition, rotation normalization, box ordering, endpoint inclusivity, and rounding rules. A box in the wrong coordinate system is worse than no box: it draws a confident highlight over the wrong text.

from pydantic import BaseModel, field_validator

class PageGeometry(BaseModel):
    page_width: float
    page_height: float
    origin: str = "top-left"     # must be explicit; never assume

    @field_validator("origin")
    @classmethod
    def known_origin(cls, v: str) -> str:
        if v not in {"top-left", "bottom-left"}:
            raise ValueError("unknown coordinate origin must not be interpreted")
        return v

def validate_bbox(bbox: BoundingBox, page: PageGeometry) -> BoundingBox:
    coords = [bbox.x0, bbox.y0, bbox.x1, bbox.y1]
    if not all(map(math.isfinite, coords)):
        raise ValueError("non-finite coordinate")
    if bbox.x1 <= bbox.x0 or bbox.y1 <= bbox.y0:
        raise ValueError("non-positive area")
    if not (0 <= bbox.x0 < page.page_width and 0 <= bbox.x1 <= page.page_width):
        raise ValueError("x out of page bounds")
    if not (0 <= bbox.y0 < page.page_height and 0 <= bbox.y1 <= page.page_height):
        raise ValueError("y out of page bounds")
    return bbox

Why this works: The validator fails closed. A box with a bad origin, a negative area, or an out-of-page coordinate never reaches the renderer. That's the right default for provenance: a missing highlight is an inconvenience, but a wrong highlight is a false assurance.

💡 Practical note: Reauthorize the user before displaying the source region. A citation does not grant access to the underlying document, and the visual overlay should not become an authorization bypass.

7. Claim-Level Verification: When the Citation Exists but Proves Nothing

Scenario: The answer says "the policy allows a 45-day extension." The citation points to a chunk that says "extensions may be granted at the discretion of the committee." The citation is real, the chunk is relevant, and the specific number is invented. A citation-existence check passes. A support check should not.

Why the obvious solution fails: Checking that a citation points to a retrieved chunk only validates reference integrity, not entailment. OWASP's AISVS distinguishes the two: control 7.4.2 asks whether attributions come from retrieval metadata rather than generation, while 7.4.3 asks whether claims in the response can actually be traced to the retrieved chunk. You need both.

Solution: Decompose the answer into atomic claims and run entailment against the cited chunk, not against any retrieved chunk. Bind each claim's subject entity to the entity asserted by its supporting passage, and fail closed when entity-specific support is absent. The OWASP guidance also recommends negative tests with topically similar evidence about a different entity, because deceptive grounding — genuine evidence about one entity attributed to another — measured 7.8% wrong-entity attribution across 740 clinical pairs despite the sources genuinely supporting the claims.

ProofRAG implements a pragmatic version of this: after generation, every citation is checked with NLTK-based lexical overlap and embedding similarity between the answer and the cited evidence. If the check fails, the system returns "Not found in the documents" instead of the answer. The refusal is the feature. A system that can't prove its answer should say so.

def verify_claim(claim: str, cited: CitedChunk, embed, threshold=0.75) -> bool:
    lexical = lexical_overlap(claim, cited.provenance.evidence_text)
    semantic = cosine(embed(claim), embed(cited.provenance.evidence_text))
    entity_ok = subject_entity_matches(claim, cited.provenance.evidence_text)
    return lexical > 0.5 and semantic > threshold and entity_ok

def answer_or_refuse(claims, cited_chunks, embed):
    for claim, cited in zip(claims, cited_chunks):
        if not verify_claim(claim, cited, embed):
            return {"status": "refused", "reason": "unsupported_claim"}
    return {"status": "verified", "claims": claims}

Why this works: The check is per-claim and per-cited-chunk, not per-answer. That's what catches citation laundering: a chunk can be topically related to the answer while failing to support the specific inference. The entity check catches the deceptive-grounding case, where the passage is genuinely about the right topic but the wrong subject.

⚠️ Gotcha: Lexical overlap alone is brittle. A correct paraphrase can score low, and a copied sentence with a swapped number can score high. Use lexical overlap as a fast gate, not as the final verdict. For high-stakes domains, pair it with an NLI model or a judge LLM, and keep the per-claim mapping so a failed claim doesn't silently pass because its neighbor succeeded.

8. Trace the Provenance, Not Just the Answer

Scenario: A user reports that an answer cited the wrong section. You open your observability dashboard and see one span: chat gpt-4o-mini, 2.1 seconds, 4,200 input tokens. No retrieval span, no chunk IDs, no reranker scores. You can see that the model was slow. You cannot see why it cited the wrong chunk.

Why the obvious solution fails: Traditional APM has no vocabulary for retrieval. It tracks requests, latency, and exceptions. A RAG failure is usually none of those. The answer returns 200 OK, in normal latency, with a citation that happens to be wrong.

Solution: Instrument the pipeline with OpenTelemetry GenAI semantic conventions: one parent span per query, and a child span for each stage — routing, retrieval, reranking, and generation. The GenAI conventions define a vendor-neutral gen_ai.* schema for LLM spans, metrics, and events, and retriever-specific attributes like chunk_id, similarity_score, and doc_version extend the schema for the retrieval stage.

The status caveat matters: as of mid-2026, the GenAI conventions are still marked Development, and in June 2026 they moved out of the main semantic-conventions repository into open-telemetry/semantic-conventions-genai. Attribute names can still change, so pin your instrumentation versions.

from opentelemetry import trace

tracer = trace.get_tracer("rag.pipeline")

def answer_query(query: str, retriever, generator):
    with tracer.start_as_current_span("rag.query") as parent:
        with tracer.start_as_current_span("rag.retrieve") as retrieval:
            results = retriever.search(query, top_k=8)
            retrieval.set_attribute("gen_ai.operation.name", "retrieval")
            retrieval.set_attribute("rag.result_count", len(results))
            for i, r in enumerate(results[:3]):
                retrieval.set_attribute(f"rag.result.{i}.chunk_id", r.chunk_id)
                retrieval.set_attribute(f"rag.result.{i}.score", r.score)

        with tracer.start_as_current_span("rag.generate") as generation:
            answer = generator.generate(query, results)
            generation.set_attribute("gen_ai.operation.name", "chat")
            generation.set_attribute("gen_ai.usage.input_tokens", answer.usage.input_tokens)
            generation.set_attribute("gen_ai.usage.output_tokens", answer.usage.output_tokens)

        return answer

Why this works: When the citation is wrong, the trace tells you whether retrieval surfaced the wrong chunk, reranking promoted the wrong passage, or generation misattributed a correct chunk. Those are three different bugs with three different fixes, and without span-level provenance you can't tell them apart.

🔍 Why this matters: Span-attached evaluation is becoming the production pattern: each retrieval, rerank, and generation span carries its own eval verdict, so a trace where retrieval scored 0.4 and the answer scored 0.9 is the smoking gun for hallucination. Provenance isn't only for users. It's for the people debugging the system at 2 AM.

9. Measuring What Users Actually Do With Citations

Scenario: Your citation rendering is beautiful. Inline chips, hovercards, highlighted bounding boxes. Then you look at the analytics: almost nobody clicks. The users who do click bounce because the source view opens a raw PDF at page 1 instead of the cited region. The feature exists. It isn't working.

Why the obvious solution fails: Teams measure faithfulness, context precision, and answer relevance — all of which evaluate the system given the retrieved context. None of them measure whether the provenance UI reduces verification effort. A citation can be technically correct and practically useless if the user can't find the passage.

Solution: Measure provenance at two levels: correctness and utility.

Correctness metrics:

  • Citation accuracy: the fraction of claims whose cited chunk actually entails the claim. A 2026 RAG benchmark suite includes citation accuracy alongside Recall@5, MRR, NDCG@10, and faithfulness, specifically because retrieval and generation metrics don't capture attribution quality.
  • Stale citation rate: the fraction of rendered citations whose document version no longer matches the index. Versioned chunk IDs make this measurable.
  • Unsupported-claim rate: the fraction of answers that pass an end-to-end eval but fail per-claim verification. This is the metric that exposes citation laundering.

Utility metrics:

  • Citation click-through rate (CTR): the fraction of answers where a user opens a citation. It's the most actionable faithfulness proxy that requires zero LLM inference to compute.
  • Verification time: how long it takes a user to confirm or reject a claim after clicking. This is the number visual grounding is supposed to move.
  • Refusal calibration: whether the system refuses when evidence is insufficient, and whether users accept the refusal as legitimate.

The evaluation stack in 2026 supports this. Ragas (0.4.3 as of October 2026) measures faithfulness by decomposing the answer into statements and checking each against the retrieved chunks, which is the right shape for claim-level attribution. Pair it with a citation-specific check rather than using faithfulness as a proxy for provenance.

def citation_accuracy(pairs) -> float:
    """pairs: list of (claim, cited_chunk) tuples already verified."""
    if not pairs:
        return 1.0
    supported = sum(1 for claim, chunk in pairs if verify_claim(claim, chunk, embed))
    return supported / len(pairs)

def citation_ctr(events) -> float:
    answers_with_click = {e.answer_id for e in events if e.event == "citation_click"}
    answers_rendered = {e.answer_id for e in events if e.event == "answer_rendered"}
    return len(answers_with_click) / max(len(answers_rendered), 1)

Why this works: Correctness metrics catch bad citations. Utility metrics catch bad provenance UX. You need both, because a system can score well on citation accuracy while rendering citations in a way that no user ever inspects — which is exactly the failure mode visual provenance is meant to prevent.

The Provenance Stack Compared

ApproachPrecisionBuild costRuntime costHandles re-embedsUX effortBest fit
File-level source panelLowVery lowNegligibleN/ALowInternal chat, low-stakes Q&A
Page-level citationMediumLowNegligibleWeakLowDocument Q&A where pages are the unit
Inline micro-citations + source listMedium–highMediumLowDepends on IDsMediumMost production RAG assistants
Versioned chunk IDs + metadata passthroughHighMediumLowStrongMediumAudit-sensitive, regulated workflows
Bounding-box visual overlayVery highHigh (geometry-preserving parser)Medium (crop/render)Strong if fingerprintedHighContracts, invoices, scans, clinical docs
Claim-level entailment verificationVery highHigh (verifier pipeline)Medium–highN/AMediumLegal, medical, compliance
OTel span-level provenanceN/A (debugging)MediumLowN/ALowAny team operating RAG in production

The pattern in the table is worth reading carefully. Precision and build cost move together, but the jumps aren't linear. Moving from a file-level panel to inline citations is cheap and buys a lot. Moving from inline citations to bounding boxes is expensive and buys precision that only matters in some domains. Moving from bounding boxes to claim-level entailment is expensive in a different way — it adds latency and a verifier you have to maintain — but it's the only layer that catches citation laundering.

A Production Checklist for Visual Provenance

Before you ship the citation UI, verify these in order. Each one is cheaper to fix before the next is built.

  1. Citations resolve from retrieval metadata, not generated tokens. The model emits evidence IDs; the application joins them to coordinates. Test that a model cannot override a page number.
  2. Every chunk carries a versioned ID and a document fingerprint. No page_id::chunk_index without a version component. The audit row stores the evidence text, so it survives a reindex.
  3. The parser preserves geometry, not just text. Page index, page label, dimensions, rotation, coordinate origin, and bounding boxes are captured at ingestion. Flattened Markdown alone is insufficient for visual overlays.
  4. Bounding boxes are validated before rendering. Finite coordinates, positive area, within page bounds, declared origin. Fail closed on unknown geometry.
  5. Citation indices are positional only inside the prompt, and node order is finalized before injection. Reranking after citation mapping shifts indices and breaks attribution.
  6. Per-claim verification runs against the cited chunk, not the retrieved set. Lexical overlap as a fast gate, semantic or NLI entailment as the verdict, entity-match check for deceptive grounding.
  7. The system can refuse. When evidence is insufficient or verification fails, return a first-class "not found" rather than an answer with a decorative citation.
  8. The pipeline is traced end to end. Parent span per query, child spans for retrieval, reranking, and generation, with chunk IDs and scores attached to the retrieval span.
  9. You measure citation accuracy and citation CTR together. Correctness without utility means nobody verifies. Utility without correctness means users verify the wrong thing confidently.

The uncomfortable lesson from the 2026 tooling is that the hard part isn't drawing the highlight. It's making sure the highlight points at the right bytes of the right version of the right document — and that the model never had the opportunity to invent the coordinates in the first place. Visual provenance is not a frontend feature bolted onto a RAG pipeline. It's a data contract that starts at parsing, survives retrieval, and ends as a pixel rectangle a human can trust.

Post a Comment

Previous Post Next Post