Grounding and Citation
Making an answer traceable to its source, forcing a refusal when the sources do not support one, and checking citations mechanically instead of trusting that they exist.
Making an answer traceable to its source, forcing a refusal when the sources do not support one, and checking citations mechanically instead of trusting that they exist.
A customer asks your docs assistant how long audit events are
kept. The answer comes back fluent, confident, and carrying a
citation: retention-policy.md. The customer clicks it. The
page is real. The sentence is not in it.
Nothing failed loudly. Retrieval returned the right neighbourhood, the model wrote a clean paragraph, and the citation is a plausible filename — plausible filenames being exactly the sort of thing a language model is good at producing. By the end of this lesson you will have sources a claim can actually point at, a check that runs on every response for the price of a string comparison, and a refusal path you treat as a success rather than a bug.
Grounding means the content of an answer is determined by the sources you supplied, rather than by whatever the model absorbed in training. Putting eight retrieved chunks in the window is how you make grounding possible. It is not how you make it happen, and it is certainly not how you find out whether it did.
The model produces one continuous stream of tokens. Nothing in that stream marks where a fact came from. A sentence assembled half from chunk three and half from training data reads exactly like a sentence copied out of chunk three, because it is the same kind of object: text the model considered likely. The dangerous case is not the wild fabrication — someone notices those. It is the answer that is right for the wrong reason, correct today because training data and your documentation happen to agree, and silently wrong the week you change the policy and the model keeps confidently quoting the old one.
Which puts a citation in an awkward position. It arrives in the same stream as everything else, so it is not evidence about the answer — it is a second claim, generated the same way, with the same failure modes. Treating it as proof is like accepting a receipt written by the person you are auditing.
So there are two jobs, and both are yours rather than the model's. Make the sources pointable, then check where the pointer landed.
Retrieval hands you a list of chunks. What you do with that list before it reaches the prompt decides whether verification is possible at all — and the default thing to do with a list of strings is join them.
Bad — the finest thing a claim can name afterwards is "the documentation".
context = "\n\n".join(chunk.text for chunk in retrieved)
prompt = f"Documentation:\n{context}\n\nQuestion: {question}"Good — each chunk arrives wearing an identifier the answer can quote back.
With the first version there is no verification to write. Every sentence in the answer could be invented and there is no mechanical check that would notice, because the response has nothing granular enough to compare against.
A chunk identifier has one job — resolving, later, to the
exact text the model saw. Build it from something durable:
the document id, the chunk's position in that document, and a
short hash of the chunk's content, like
retention-policy.md#c3:8f2a91. Not the row number in today's
index, which changes the next time you rebuild it.
The hash is the part people skip, and it earns its keep on the day you re-chunk. Ids drift, and a six-week-old citation in a support ticket now resolves to a different paragraph that happens to sit at that position. With a content hash in the id the lookup fails instead, which is the outcome you want: loud and visibly wrong, rather than quiet and subtly wrong.
Keep the id-to-text mapping on your side for the lifetime of the request. That map is what the checker reads, and it must hold the exact bytes you put in the prompt — not a freshly fetched copy, which may already have changed.
Now the shape of the response. The instinctive ask is "cite your sources", and the model will happily oblige — at the end, in bulk, in a way that attaches to nothing.
Bad — two real files, both genuinely retrieved, attached to nothing in particular.
Good — every claim carries its own source and the words it came from.
The first shape can only be checked for existence — do these ids resolve? — and a well-formed fabrication passes that check every time, since the ids came from chunks the model just read. You have built a green light that turns on regardless of the answer.
Claim-level attribution costs you a slightly longer response and buys you the only thing that makes checking possible: the verbatim quote. Without a quote there is nothing to compare; with one, verification is a containment test.
Bound the quote. Ask for a short exact span — a sentence, twenty-five words or so, copied character for character. Longer spans get quietly paraphrased as the model reformats them, and an unbounded quote degenerates into the whole chunk, which proves nothing at all. One claim per entry, too: a sentence resting on two sources is two entries, or you cannot tell which half of it the check passed.
Getting this object back reliably formed — the schema, the validation, the repair when it comes back malformed — is the subject of "Structured Output You Can Trust". Here we assume it arrives.
Here is the part that surprises people: verifying a citation is not an AI problem. The model told you which source and which words. You have the source. Look for the words.
The one complication is that a model reproducing a span rarely reproduces the bytes. It straightens a curly apostrophe, turns an em dash into a hyphen, collapses a line break that fell in the middle of a sentence. So normalization does the real work — you flatten both sides into a canonical form first.
With that in hand, the check itself is four lines — but only if it compares the right thing.
Bad — confirms the id resolves, which is the part a fabricated claim gets right.
Good — also confirms the quoted words are in that source.
A model that invents a claim almost always attaches it to a chunk it genuinely just read, because that chunk is right there in its context. The id is the easy half to get right and the quote is the half it cannot fake, so a checker that stops at the id is checking the one field that was never in doubt.
Return a reason rather than a boolean, and log it. The two
outcomes have different causes and different fixes:
unknown_source usually means your ids are unstable or the
model is composing them from the filename, while
quote_not_found means paraphrase or invention. A dashboard
showing 4% ungrounded tells you nothing; one showing 4%
quote_not_found and 0% unknown_source tells you where to
look.
Resist adding a similarity threshold. A fuzzy ratio feels more
forgiving, and it is — it is a number you will lower, once,
during an incident, and never raise again. If you need
tolerance, put it in normalize, where it is deterministic,
reviewable in a diff, and testable. Then keep the comparison
exact.
The economics are the whole argument for doing this at all. Checking one citation by hand is a minute of a person's attention; checking it this way is microseconds, so it runs on every claim of every response in production rather than on a sample somebody gets to on Thursday. A verification you can only afford sometimes is a verification that is not running when it matters.
You now have a signal, and the next mistake is believing more of it than it says. A passing check proves exactly three things: the cited source exists, it was in the context for this request, and those words appear in it. That is provenance, and it is worth having. It is not correctness.
Consider a chunk that reads: "Audit events are retained for 90 days on the legacy plan. Current plans retain them for 30." The model quotes the first seven words, attaches them to a claim that audit events are kept for 90 days, and the check passes. The words are in the source. The answer is wrong.
Provenance.
The cited source exists. It was in the context for this request. Those exact words appear in it.
Worth having, and cheap to compute.
Correctness.
That the quote entails the claim rather than merely sitting near it. That the next sentence does not reverse it. That the source is current.
A confidently cited stale document is still a stale document.
And it says nothing whatsoever about the sentences that carry no citation. This is the gap that swallows teams: an answer where one claim in six is attributed passes every check you have while being mostly ungrounded. Measure citation coverage — the share of load-bearing claims that carry a citation at all — alongside the pass rate, or you are grading only the work that was handed in.
Judging whether a claim genuinely follows from its quote is a different kind of question, and scoring it across a whole set of answers is what "Evaluating an AI Feature" is for. What you have built here is the cheap, deterministic layer underneath it, which catches the failures that do not need judgement.
Everything so far assumes the sources answer the question. Often they do not — the index has a gap, the question is about a feature that does not exist, the customer asked something no document covers. What the system does in that moment is the difference between a docs assistant people trust and one they learn to double-check.
A model asked to answer will answer. If your instructions say "answer the question using the sources below", the only compliant behaviour when the sources fall short is to stretch them. So make refusal an explicit, legitimate branch with a shape of its own: a flag, an empty claim list, and a sentence naming what was missing.
Say so in the prompt too, in as many words: if the sources do
not contain enough to answer, set refused and describe what
would be needed. That last part is not politeness. A cluster of
refusals all naming the same gap is the best retrieval bug
report you will ever get — it is your users telling you which
document to write, and it costs nothing to collect.
The same trap wears a second costume on the dashboard. Refusal rate looks like a defect metric — it goes up, someone asks why quality dropped — so watch it against the ungrounded rate rather than on its own. They trade off directly, and the trade is not symmetric: a refusal costs a user thirty seconds and some mild irritation, while a wrong answer with a citation attached costs them a bad decision made with false confidence, because the citation is precisely what convinced them not to check.
The check runs, a claim comes back quote_not_found, and the
user is waiting. You have three reasonable moves, in increasing
cost, and one tempting move that is never right.
Drop the offending claim and serve the rest. Cheap and instant, but only honest when claims stand alone — in a list of pricing tiers, fine; in a summary or a set of steps, removing one sentence quietly changes what the remaining ones mean.
Retry once, naming the failure: tell the model which claim failed, that its quote was not found in the source it cited, and that spans must be copied exactly. One retry. If a second pass fails the same way, the most likely explanation is that the sources do not support the claim and no amount of asking will change that — which is the third move.
Refuse, and log the whole thing: question, retrieved ids, the response, the failure reasons. That is the honest end state, and those logs are the highest-value evaluation cases you have, because every one of them is a real question your system got wrong in a specific, reproducible way.
Decide once, in advance, whether this feature fails open or closed, and write it down next to the code. An internal search tool can reasonably serve a flagged answer with a warning; a system answering questions about money, medication or legal obligation should refuse. What you cannot do is decide it at 3am, under pressure, per incident.
You can now tell the difference between an answer that cites a source and an answer that came from one — ids that resolve, quotes that are checked rather than trusted, coverage measured next to the pass rate, and a refusal branch nobody is quietly optimising away.
All of it rests on the claim object arriving well-formed, which
this lesson assumed throughout. "Structured Output You Can Trust"
is where that assumption gets paid for: constraining the shape
so the fields are there at all, validating at the boundary
before your checker touches them, and repairing malformed
responses in a loop that terminates. A checker that crashes on
a missing quote key verifies nothing.
The thing to go and do is small and slightly uncomfortable. Take
fifty logged answers from something you have already shipped,
write the twenty lines of normalize and check_claim above,
and run them over the citations you are serving today. The
number you get is usually worse than you expect, and it will be
the most useful number you see this week.
SOURCE BLOCKS
id on every chunk # doc id + position + content hash
stable across re-index # an id that drifts cites the wrong text
keep the id -> text map # exact bytes you sent, for the checker
RESPONSE SHAPE
one entry per claim # not one bibliography at the end
source_id + quote # the quote is what makes it checkable
quote <= ~25 words # longer spans get paraphrased
refusal is a valid shape # refused, plus what was missing
THE CHECK (every claim, every response)
source_id in the map? # else: unknown_source
normalize both sides # NFKC, quotes, dashes, spaces, case
quote inside source text? # else: quote_not_found
exact after normalizing # tolerance lives in normalize()
log the reason, not a bool # the two causes have two fixes
WHAT GREEN MEANS
proves the source exists and was in the context
proves those words appear in it
not that the quote supports the claim
not that the next sentence fails to reverse it
not that the source is current
also track coverage: cited claims / load-bearing claims
ON FAILURE
drop the claim # only if claims stand alone
retry once, naming it # twice means the sources lack it
refuse and log # best evaluation cases you will get
never strip the citation # deletes the only detectable signal
REFUSAL HYGIENE
unanswerable items in the golden set # or it gets tuned out
watch refusal vs ungrounded rate # they trade off
collect the "missing" field # your retrieval backlogblocks = [
f'<source id="{chunk.id}">\n{chunk.text}\n</source>'
for chunk in retrieved
]
prompt = (
"Sources:\n"
+ "\n\n".join(blocks)
+ f"\n\nQuestion: {question}"
){
"answer": "Audit events are kept 90 days; CSV export is on.",
"sources": ["retention.md#c3", "exports.md#c1"]
}{
"claims": [
{
"text": "Audit events are kept for 90 days.",
"source_id": "retention.md#c3:8f2a91",
"quote": "audit events are retained for 90 days"
},
{
"text": "You can export them as CSV.",
"source_id": "exports.md#c1:2b70de",
"quote": "exports are available in CSV and JSON"
}
],
"refused": false
}import re
import unicodedata
FANCY = {"‘": "'", "’": "'", "“": '"',
"”": '"', "–": "-", "—": "-"}
def normalize(text: str) -> str:
text = unicodedata.normalize("NFKC", text)
for fancy, plain in FANCY.items():
text = text.replace(fancy, plain)
return re.sub(r"\s+", " ", text).strip().casefold()def check_claim(claim, sources) -> str:
if claim["source_id"] not in sources:
return "unknown_source"
return "ok"def check_claim(claim, sources) -> str:
if claim["source_id"] not in sources:
return "unknown_source"
source_text = normalize(sources[claim["source_id"]])
if normalize(claim["quote"]) not in source_text:
return "quote_not_found"
return "ok"{
"claims": [],
"refused": true,
"missing": "No source covers retention on trial accounts."
}