A citation that points at a document is not a citation
Linking the whole PDF passes review and helps nobody. Span-level attribution is harder, verifiable, and the difference between trust and decoration.
The answer arrives with a citation. Underneath it, a link: Source: Employee Handbook 2026.pdf.
The handbook is 180 pages. The citation has told the reader that the answer came from somewhere in a document they already knew was relevant. To check it they must read the handbook — which is the task the system was built to remove.
A citation is a promise that a specific claim can be checked in a specific place. A document link makes the promise and does not keep it.
What a citation is for
Two distinct jobs, and they have different requirements:
Verification. A reader who doubts the answer needs to land on the sentence that supports it in a couple of seconds. This needs a location — page, offset, highlight.
Grounding. The presence of enforced citation constrains the generation itself. A model required to attribute every claim to a retrieved span produces fewer unsupported claims, because unsupported claims have nowhere to attach.
The second only works if attribution is checked. A citation the system never verifies is decoration, and the model learns nothing from being asked for it.
Carry offsets from the start
Span-level citation is not a generation problem. It is a data problem, decided at ingestion, and retrofitting it means re-indexing:
type Chunk = {
id: string
documentId: string
text: string
/** Character offsets into the ORIGINAL document text, not the chunk. */
start: number
end: number
/** For rendering a highlight over the source rendering. */
page?: number
bbox?: [number, number, number, number]
}
The mistake is storing offsets relative to some intermediate cleaned text. By the time you render a highlight you have the original PDF or HTML, and offsets into a normalised string you threw away are unusable. Normalise for embedding; keep offsets against the artefact you will actually display.
Ask for spans, not sentence numbers
Have the model quote the text it relied on, and cite which chunk it came from:
type Answer = {
claims: {
text: string // one assertion from the answer
support: {
chunkId: string
quote: string // verbatim from that chunk
}[]
}[]
}
Quoting is what makes verification mechanical. A chunk id alone cannot be
checked — the model may have cited a chunk it did not use. A quote can be
checked with indexOf.
Verify every citation before rendering
This is the step that turns citation from decoration into a guarantee, and it is cheap:
function verify(answer: Answer, chunks: Map<string, Chunk>) {
for (const claim of answer.claims) {
for (const s of claim.support) {
const chunk = chunks.get(s.chunkId)
// The model cited a chunk that was never retrieved. This happens,
// and it is the clearest fabrication signal you will ever get.
if (!chunk) return reject(claim, "unknown_chunk")
const idx = chunk.text.indexOf(s.quote)
// The quote is not in the chunk. Either paraphrased (tolerable,
// with a fuzzy match) or invented (not tolerable).
if (idx === -1) {
const near = fuzzyFind(chunk.text, s.quote, { threshold: 0.9 })
if (!near) return reject(claim, "quote_not_found")
return locate(claim, s, chunk, near.index)
}
// Translate chunk-relative offset to document-absolute, so the
// highlight lands on the original.
locate(claim, s, chunk, chunk.start + idx)
}
}
}
Two things fall out of this that are worth more than the citations themselves.
unknown_chunk is a fabrication detector. A model citing a chunk that was
not in its context has invented the attribution, which strongly suggests it
invented the claim. Log the rate. It is one of the few direct measurements of
grounding failure you can take in production without a labelled set.
quote_not_found distinguishes paraphrase from invention. A fuzzy match at
0.9 catches reasonable paraphrase. Nothing matching means the supporting text
does not exist.
What to do on failure is a product decision: drop the unsupported claim, show it marked as unverified, or refuse the whole answer. For anything where being wrong is expensive, drop the claim. An answer that is 80% of the length and fully checkable is worth more than a complete one that is partly fiction.
Render it so verification is one click
The interface has to close the loop:
- Inline markers on the specific sentence, not a source list at the bottom. The reader needs to know which part of the answer a citation covers.
- Hover shows the quote. Most verification stops here — the reader reads the supporting sentence and is satisfied. This is the single highest-value piece of the whole feature.
- Click opens the source at the highlight. Page and scroll position, with the
span highlighted.
#page=47on a PDF viewer URL gets most of the way.
Multiple sources and none
Two cases the naive shape gets wrong:
A claim supported by several chunks. "Refunds take 5–10 business days" might
combine a policy statement with a processing-time table. support is a list for
this reason. Showing one citation when two were used is a small lie.
A claim supported by nothing. Sometimes the honest output is that the corpus does not answer the question. A model that must cite has a much easier time saying so, provided the prompt makes abstention legitimate — the same argument as teaching an extractor to refuse. A system that always produces an answer with a citation-shaped object attached has optimised for the appearance of rigour.
More in why your RAG returns wrong answers, adding provenance to AI outputs, and retrieval systems that are actually evaluated.