Chapter 62 · The model layer
Citations: making the model show its sources
A citation is not a footnote, it is the mechanism that makes an answer checkable. Four granularities, how to verify one mechanically, and why a wrong citation is worse than none.
10 min read
Citations have been in every prompt since chapter 9. This chapter is about what they are for and how to make them reliable.
The framing that matters: a citation is not attribution, it is the mechanism by which a reader can check an answer without trusting it. That is the whole product. A system people have to trust blindly gets abandoned after its first wrong answer; a system whose answers can be verified in ten seconds survives one.
Four granularities
| Granularity | Example | Checkable in | Cost |
|---|---|---|---|
| Document | "Returns and Credits Policy" | Minutes — it is 40 pages | Free |
| Section | "Returns > Damaged goods" | ~20 seconds | Free, from the heading path |
| Chunk | The retrieved passage | ~5 seconds | Free |
| Span | The exact sentence | ~2 seconds | Needs extraction |
Document-level is what most systems produce and it is barely better than nothing: pointing at a 40-page policy does not let anyone check anything. Section-level is the minimum useful granularity and it is free, because the heading path is already carried.
Span-level is where the experience changes, and it is worth the extraction work.
Span extraction without asking the model
The temptation is to have the model quote the supporting sentence. That costs output tokens and introduces a new failure — a quote that is not quite what the source says.
// Instead: locate the model's claim inside the cited chunk, after the fact.
// No extra tokens, no new failure mode, and it is verifiable.
export function locateSpans(answer: string, chunks: Retrieved[]): Span[] {
const spans: Span[] = []
for (const sentence of splitSentences(answer)) {
const cited = [...sentence.matchAll(/\[(\d+)\]/g)].map((m) => Number(m[1]))
for (const n of cited) {
const chunk = chunks[n - 1]
if (!chunk) continue
// Best matching sentence in the chunk, by token overlap on content
// words. Crude, fast, and right about 90% of the time — and when it
// is wrong it fails to find a span rather than finding a wrong one.
const best = bestOverlap(stripCitations(sentence), splitSentences(chunk.content))
if (best.score >= 0.4) {
spans.push({ citation: n, chunkId: chunk.id, start: best.start, end: best.end })
}
}
}
return spans
}// Overlap on content words only. Stop words make every pair look similar.
function bestOverlap(claim: string, candidates: string[]) {
const claimWords = new Set(contentWords(claim))
let best = { score: 0, start: 0, end: 0 }
let offset = 0
for (const candidate of candidates) {
const words = contentWords(candidate)
const hits = words.filter((w) => claimWords.has(w)).length
const score = hits / Math.max(claimWords.size, 1)
if (score > best.score) best = { score, start: offset, end: offset + candidate.length }
offset += candidate.length
}
return best
}The failure direction matters and is deliberate: below the 0.4 threshold no span is produced, so the citation degrades to chunk level. A wrong highlight is worse than no highlight, because it tells the reader to check the wrong sentence.
Verifying a citation mechanically
Chapter 57 checked that cited numbers exist. This is the stronger check: does the cited source actually support the claim?
// Three levels, increasing in cost and in what they catch.
export async function verifyCitations(answer: Answer, chunks: Retrieved[]) {
// 1. Range. Free. Catches fabricated references.
const range = answer.citations.filter((n) => n < 1 || n > chunks.length)
// 2. Overlap. Free. Catches a citation to a chunk about something else.
const weak = answer.citations.filter((n) => {
const overlap = bestOverlap(answer.answer, splitSentences(chunks[n - 1]?.content ?? ''))
return overlap.score < 0.25
})
// 3. Entailment. Costs a model call. Catches the citation that is
// topically right and does not support the specific claim.
// ⚠️ Sample it rather than running it on every answer.
const entailment = shouldSample() ? await checkEntailment(answer, chunks) : null
return { range, weak, entailment }
}// The entailment check, as a separate cheap model call. It is a judge, so
// chapter 106's calibration caveat applies — but the task is narrow enough
// that agreement with human raters is high.
const ENTAILMENT_PROMPT = `
Does the SOURCE support the CLAIM? Answer exactly "yes", "no", or "partial".
Do not use outside knowledge. A source that is about the same topic but does
not state the claim is "no".
SOURCE: {{source}}
CLAIM: {{claim}}
`Atlas's sampled entailment, 2% of answers over a month:
| Verdict | Share |
|---|---|
| yes | 94.1% |
| partial | 4.6% |
| no | 1.3% |
1.3% is roughly one answer in 77 where a citation points at a source that does not support the claim. Undetectable by reading the answer, because the answer is fluent and the source is topically right.
// Which makes it a review queue rather than a gate, and a drift signal.
if (verdict === 'no') await queueForReview(questionId, { claim, source, verdict })Rendering citations so they get used
A citation nobody clicks has done nothing.
// What the interface needs, assembled once rather than fetched on click.
interface RenderedCitation {
n: number
documentTitle: string
breadcrumb: string[] // the heading path
excerpt: string // the chunk, or the span's surrounding sentences
highlight?: { start: number; end: number }
href: string // deep link into the source system
updatedAt: string
warning?: string // freshness or OCR caveat
}Three properties that decide whether anyone uses them:
Expand in place. A citation that opens the document store in a new tab is a context switch, and the measured click-through was 4%. Expanding the passage inline took it to 31%.
Show the breadcrumb. Returns > Damaged goods tells the reader where they are before they read the passage.
Carry the warning. The OCR caveat and the freshness stamp belong at the citation, not buried in the answer text.
// And the deep link, which is the difference between "I can check this" and
// "I can act on this".
href: `${sourceSystem.baseUrl}/documents/${chunk.sourceRef}#page=${chunk.page ?? 1}`Measuring whether citations work
Not whether they are present — whether they do their job.
-- Citation engagement. A low rate means either the answers are trusted
-- blindly or the citations are useless. Both are worth knowing.
select date_trunc('week', answered_at) as week,
count(*) as answers,
round(100.0 * count(*) filter (where citation_expanded) / count(*)) as expanded_pct,
round(100.0 * count(*) filter (where source_opened) / count(*)) as opened_pct
from answer_log
where answered_at > now() - interval '12 weeks'
group by 1 order by 1;Atlas's pattern over three months: expansion fell from 48% in week one to 19% by week twelve.
That is the healthy shape. Early users check everything because they do not trust the system; as it earns trust they check selectively. A rate that stays high means the answers are not trustworthy; a rate near zero within two weeks means people have stopped verifying something they should still verify — which is the more dangerous of the two and is worth saying out loud in onboarding. Section 12 returns to it.
The failure mode: the citation that was right about the wrong clause
A rep asks about returns. Atlas answers: "Damaged goods may be returned within 30 days of delivery [1]."
Source [1] is the Returns and Credits Policy, section Returns > Late returns, which says: "Items returned after 30 days are assessed individually."
Every mechanical check passes. The citation is in range. Overlap is high — "30 days", "returned", "items" all appear. The document is right, the section is adjacent, and the sentence cited does not say what the answer says.
This is the near-miss distractor producing a wrong answer with a credible citation, and overlap scoring cannot catch it because the two clauses share almost all their vocabulary.
Three defences, in increasing strength.
// 1. Span-level citation makes it visible to the reader. The highlighted
// sentence is "Items returned after 30 days are assessed individually",
// which does not match the claim — and a human sees that instantly.// 2. Entailment sampling catches it at a rate. This is exactly the 1.3%.// 3. The structural fix: do not send both clauses without their headings.
// With the heading path rendered, the model sees "Returns > Damaged
// goods" and "Returns > Late returns" as distinct, and the error rate
// on this class dropped from 2.1% to 0.4%.
`[${n}] ${title} — ${chunk.headingPath.join(' > ')}\n${chunk.content}`The third is chapter 61's heading path doing a third job: it improved retrieval, it improved comprehension, and it disambiguates adjacent clauses that are textually near-identical.
The cheapest defence against a plausible wrong citation is giving the reader a highlighted sentence instead of a document name. Verification is only a defence if it is fast enough that people actually do it.
Key points
- A citation is the mechanism that makes an answer checkable, which is what lets a system survive its first wrong answer.
- Section level is the minimum useful granularity and it is free from the heading path. Span level is where the experience changes.
- Locate spans by overlap after generation rather than asking the model to quote. No extra tokens, no new failure mode.
- Fail to find a span rather than finding a wrong one. A wrong highlight sends the reader to the wrong sentence.
- Verify in three levels: range, overlap, and sampled entailment. Entailment found 1.3% of citations unsupported — invisible by reading.
- Expand citations in place. Click-through went from 4% to 31%.
- Carry freshness and OCR warnings at the citation, not in the answer text.
- Falling engagement over weeks is healthy; near zero within two weeks is not.
- Rendering the heading path cut adjacent-clause citation errors from 2.1% to 0.4%.
Next: refusal and abstention — teaching it to say it does not know, which is the capability that separates model tiers.