Grounded Generation & Citations
You retrieved the right chunks and ranked them on top. The model can still ignore them, blend them with its own training memory, or state a fact no chunk supports. Generation is the last stage, and its failure mode — confident, well-cited-sounding, unsupported claims — is the one users actually see.
The whole promise of RAG is faithfulness: every claim in the answer is supported by the retrieved context, not by the model's parametric memory. A faithful answer can still be "wrong" if the context is wrong — but that's a retrieval problem you can fix. An unfaithful answer is worse: the model asserts something the context doesn't say, and no amount of good retrieval prevents it. This chapter is about closing that last gap.
Three levers that make generation faithful
- The grounding instruction. Tell the model explicitly: answer only from the context; if the context doesn't contain the answer, say you don't know. This single instruction converts many hallucinations into honest refusals — the model's default is to be helpful and fill gaps; you must license it to abstain.
- Inline citations. Require the model to tag each claim with the chunk id it came from ("[c12]"). Citations do double duty: they let users verify, and they make unfaithfulness measurable — a claim with no valid supporting chunk is a caught hallucination.
- Context ordering. Because of lost-in-the-middle (Ch. 1), put the highest-ranked chunks at the start and end of the context, not buried in the middle. Order matters even when the right chunks are all present.
A RAG system that says "I don't have information on that" when the context lacks the answer is working correctly. Teams that punish refusals in eval train their models to hallucinate. Score faithfulness (is every claim supported?) and answer-rate separately — a good system has high faithfulness and refuses exactly when it should.
Measuring faithfulness
You measure it the way Chapter 8 measured retrieval: with a checkable signal. Decompose the answer into individual claims, and for each claim ask — is it supported by any retrieved chunk? The fraction supported is your faithfulness score. A cheap, scalable version uses an LLM-as-judge to check each claim against the context (this is exactly the boundary with the Eval Engineer's job). The point: hallucination stops being a vibe and becomes a number you can drive down.
Measure faithfulness — catch the unsupported claim
You'll take generated answers, decompose each into claims, check each claim against the retrieved context, and compute a faithfulness score. You'll catch the answer that's fluent, well-formatted, and partly unsupported — the hallucination that slips past a human skim. Free and deterministic (the claim-checker is a substring/keyword match standing in for an LLM judge; the harness is identical when you swap in a real judge).
Setup: none — pure Python.
Step 1. For each answer, split into its listed claims.
Step 2. Mark each claim supported if its key fact appears in the retrieved context.
Step 3. Faithfulness = supported claims / total claims. Print per-answer and overall.
Your goal: a faithfulness score below 1.0 for the answer containing an invented fact — and identification of the exact unsupported claim.
Starter code
# Each item: the context the model was given, and the claims its answer made
# (already decomposed). 'key' is the checkable fact substring for each claim.
ITEMS = [
{"context": "The /export endpoint is rate limited to 20 requests per minute. "
"Keys rotate every 90 days.",
"claims": [ {"text":"/export allows 20 req/min", "key":"20 requests per minute"},
{"text":"keys rotate every 90 days", "key":"90 days"} ]},
{"context": "The free tier allows 3 projects. Webhooks retry up to 5 times.",
"claims": [ {"text":"free tier allows 3 projects", "key":"3 projects"},
{"text":"there is a 30-day money-back guarantee", "key":"30-day money-back"} ]}, # unsupported!
]
def supported(claim, context):
return claim["key"].lower() in context.lower()
# TODO: per item, compute faithfulness = supported/total; print unsupported claims;
# print overall faithfulness across all claims.
total = supported_total = 0
for item in ITEMS:
n = len(item["claims"])
s = sum(supported(c, item["context"]) for c in item["claims"])
total += n; supported_total += s
print(f"faithfulness {s}/{n}")
for c in item["claims"]:
if not supported(c, item["context"]):
print(f" UNSUPPORTED: {c['text']}")
print(f"overall faithfulness: {supported_total/total:.2f}")
What you should see: item 1 scores 2/2; item 2 scores 1/2 with UNSUPPORTED: there is a 30-day money-back guarantee; overall 0.75. That invented guarantee is the dangerous case — it's plausible, well-phrased, and would sail past a reader who trusts a confident answer. The claim-level check catches it precisely because it verifies each claim against the context instead of judging the answer's overall vibe.
The production shape: in a real system you'd (1) require the model to cite a chunk id per claim, (2) verify each cited chunk actually supports the claim with an LLM judge, and (3) alert or suppress answers below a faithfulness threshold. Same harness, real judge. This is also the natural seam between the RAG engineer (produces grounded answers + citations) and the Eval engineer (scores faithfulness at scale).
Going further: add the grounding instruction to a real claude-opus-4-8 call — with and without "if not in context, say you don't know" — over a question whose answer is absent from the context, and measure how the refusal rate and false-claim rate change. You'll see the instruction convert confident fabrication into an honest "I don't know."
FinAdvise: the fee that wasn't in the docs
FinAdvise retrieved the right chunks and still shipped a dangerous answer: asked about account fees, it stated a confident "2.9% transfer fee" that appeared in none of the retrieved text. Retrieval was fine; this was a pure generation failure — the model filling a gap with a plausible-shaped number. In a regulated product, that's not a bug, it's a liability.
Two changes fixed it. First, cite-or-abstain: the prompt requires every claim to cite the specific chunk it came from, and if the chunks don't contain the answer, the correct output is "I don't have that information." Second, an automated citation-support check that flags any sentence whose cited chunk doesn't actually support it. Unsupported claims dropped from 12% to 1.5%. The abstention rate rose — and they counted that as a win, because a grounded "I don't know" beats a confident fabrication. Penalizing abstention would have trained the system to hallucinate.
Acme requires each answer sentence to cite a chunk, and an automated check flags unsupported ones. "That parameter isn't documented" is treated as a correct answer, not a miss — Acme would rather abstain than invent a flag that doesn't exist.
Quiz · Chapter 9 — reasoning, not recall
- An answer is fluent, well-formatted, and cites chunk ids — but one claim isn't actually in any cited chunk. This is:
- Why can adding "if the answer isn't in the context, say you don't know" REDUCE hallucinations?
- Your eval penalizes every "I don't know," rewarding only answers. The likely long-term effect is:
- All the right chunks are in context, but the model keeps missing a fact that sits in the middle of a long context block. The fix most aligned with this chapter is:
- Why require inline citations even beyond letting users verify?