Generation, Citations & Guardrails
Introduction
L2 gave you retrieve(query) — it returns the top-6 chunks an answer should be built from. This lesson builds the generate/ module that turns those chunks into the assistant's actual reply. And here is the whole point of the lesson: the generation step is where a RAG demo becomes a product you can trust — or where it quietly becomes a confident liar. Two things make the difference, and we build both:
- Citations — every claim in the answer is tied to the exact source span it came from, so a user (or an auditor) can verify it in one glance. Not a vague "according to the docs" — a precise pointer to the sentence.
- Abstention (guardrails) — when retrieval is weak, or the retrieved chunks simply don't contain the answer, the assistant says "I don't have enough information" instead of hallucinating. Refusing to answer is a feature, not a bug.
Why this matters more than it looks. A counter-intuitive 2025 finding: giving a model retrieved context actually lowers its tendency to abstain — Claude 3.5 Sonnet's abstention rate dropped from ~84% to ~52% once RAG context was present. Models over-trust whatever you put in front of them. So "just add RAG" does not make a system honest; you have to engineer grounding and refusal explicitly. That's this lesson.
We'll use the course's own stack: Claude for generation, and crucially Anthropic's native Citations API — which returns verified pointers to your source text and, in Anthropic's evals, beats hand-rolled "put a [1] in the prompt" citation by up to 15% on citation accuracy. By the end, generate() will take a query + chunks and return either a grounded, cited answer or a principled abstention — and the /ask endpoint will be live.
![Hero infographic titled 'Generation, Citations & Guardrails' for the third lesson of the Production RAG Assistant capstone, on a white background. The deck says: turn retrieved chunks into a grounded, cited answer — and refuse when you can't. The centre is a left-to-right generation pipeline. It starts with retrieve()'s top-6 chunks entering a first guardrail diamond labelled GUARDRAIL 1 — retrieval gate: if the top rerank score is below min_score, the flow branches down to an ABSTAIN box reading 'I don't have enough information'. Otherwise it continues into a Build prompt box (system: answer only from the documents) feeding a Claude Generate box that uses the Anthropic Citations API, where each retrieved chunk becomes a document block with citations enabled. The output is a grounded answer where each sentence carries a citation chip linking to its cited_text span. A second guardrail diamond, GUARDRAIL 2 — grounding rule, checks whether the answer has any citations: if there are zero citations the flow branches to the same ABSTAIN box; otherwise it emits the final cited answer box (grounded plus cited). A side panel titled THE CITATIONS API lists: document blocks with citations.enabled true, cited_text returned per claim (free on output tokens), document_index and char range, cache_control on the source documents, and a note that native citations beat prompt-based [1] tags by about 15 percent. Three summary cards along the bottom read: 'Ground the prompt — answer only from retrieved chunks'; 'Cite with the Citations API — every claim maps to a source span'; and 'Abstain twice — a retrieval gate and a no-citations rule'. A family strip lists the five build lessons — Brief & Architecture, Ingestion & Hybrid Retrieval, Generation Citations & Guardrails, Building the Eval Harness, Deploy & Observe — with the third highlighted. Slate, sky, cyan, violet, amber, emerald accents; generous legible text.](https://pub-a1b3030acfb94e84ba8a89fb182c53bc.r2.dev/public/aie-content-14896ce2-c5a9-58f4-9040-8edda9d87a78/generation-citations-and-guardrails.webp)
The Generation Contract
Define what generate() takes and returns before writing it — the shape is the design. It takes the query and the reranked chunks from L2; it returns a small, honest object: the answer, the citations that ground it, and an abstained flag with a reason. Everything the API and the UI need to show a trustworthy reply (or a clean refusal):
# src/rag/generate/types.py
from dataclasses import dataclass, field
@dataclass
class Citation:
cited_text: str # the EXACT source span (from Claude's Citations API)
source: str # which document -> shown to the user / auditor
title: str
@dataclass
class Answer:
text: str # the answer (or the abstain message)
citations: list[Citation] = field(default_factory=list)
abstained: bool = False
reason: str = "" # '', 'low_retrieval_score', 'no_citations'
The flow has three gates between query and answer: a retrieval gate (is anything relevant enough to bother?), the grounded generation itself (answer only from the chunks, with citations), and a grounding gate (did the model actually cite anything?). Two of those three are guardrails whose only job is to abstain. Build them in that order.
Guardrail #1 — The Retrieval Gate
The cheapest, most reliable guardrail runs before you call the LLM at all: if the best chunk isn't relevant enough, don't generate — abstain. You already have the perfect signal: the top rerank score from L2. If it's below min_score, retrieval failed, and no amount of clever prompting will conjure an answer that isn't there.
Why a retrieval-score gate beats asking the model how sure it is. Calibrating an LLM's own uncertainty is famously unreliable — models are often confidently wrong. The reranker's relevance score, by contrast, is a direct measurement of whether your corpus actually contains relevant material. It's a far better abstain signal, and it's free (you already computed it):
# src/rag/generate/guardrails.py
from rag.config import settings
ABSTAIN_MSG = "I don't have enough information to answer that."
def retrieval_gate_open(chunks: list[dict]) -> bool:
"""Guardrail #1: only generate if the best chunk clears min_score."""
if not chunks:
return False
return chunks[0].get("rerank_score", 0.0) >= settings().min_score
Set min_score from your eval set in L4 — too low and you answer from noise; too high and you abstain on answerable questions. A sensible start is ~0.3 for rerank-2.5. This one check eliminates a whole class of hallucinations — the "I have no idea, but here's a confident paragraph" failure — for essentially zero cost. (In the console below, drag the threshold and watch this gate fire.)
Grounded Prompting — Answer Only From the Context
If the gate is open, you generate — but constrained. The system prompt has one job: make Claude answer strictly from the provided documents, cite them, and abstain when they don't contain the answer. Because RAG reduces a model's instinct to refuse (it over-trusts the context), the abstain instruction must be explicit and exact — give the model the precise words to say:
# src/rag/generate/prompt.py
from rag.generate.guardrails import ABSTAIN_MSG
SYSTEM = f"""You are a precise assistant that answers questions using ONLY the provided documents.
Rules:
- Use only facts found in the documents. Never use outside knowledge or guess.
- Cite the document(s) that support each claim.
- If the documents do not contain the answer, reply with EXACTLY:
"{ABSTAIN_MSG}"
- Be concise. Do not speculate or add caveats beyond the documents."""
Notice what's not here: no "answer to the best of your ability." That phrase invites guessing. Grounded prompting is about removing the model's permission to improvise. The documents themselves go into the message as structured document blocks — which is also what unlocks real citations, next.
Citations — The Anthropic Citations API
The naive way to "cite" is to ask the model to write [1], [2] in its text and hope the numbers match. They drift, they hallucinate, and a [1] is not verifiable — it doesn't tell you which sentence supports the claim. The right way on the course stack is the Anthropic Citations API: pass each retrieved chunk as a document content block with citations.enabled = true, and Claude attaches a structured citation to each claim — including the exact cited_text span, the document_index, and the character range. The pointers are guaranteed valid (the API extracts them from your text), and cited_text is free — it doesn't count toward output tokens.
Build one document block per chunk. Put each RAG chunk in its own plain-text document so Claude can cite specific sentences, and cache_control the documents so re-asks over the same context are cheap:
# src/rag/generate/prompt.py (continued)
import json
def to_document_blocks(chunks: list[dict]) -> list[dict]:
"""Each retrieved chunk -> a citable document block (Anthropic Citations API)."""
blocks = []
for i, c in enumerate(chunks):
block = {
"type": "document",
"source": {"type": "text", "media_type": "text/plain", "data": c["text"]},
"title": c["title"][:200], # shown, not citable
"context": json.dumps({"source": c["source"]}), # metadata, not citable
"citations": {"enabled": True}, # all-or-none across docs
}
if i == len(chunks) - 1:
block["cache_control"] = {"type": "ephemeral"} # cache the doc prefix
blocks.append(block)
return blocks
Why native beats prompt-based. Anthropic's internal evals found the Citations feature is significantly more likely to cite the most relevant quotes than a prompt-only approach — up to +15% citation accuracy — because the model emits citations in a standardized format the API parses into verified spans. You get attribution you can trust instead of plausible-looking footnotes. (Citations must be enabled on all documents in a request, or none.)
Parsing the Cited Response
The response comes back as a list of content blocks: each is a piece of text, and each text block may carry a citations array pointing into your documents. Walk the blocks to assemble the answer string and collect the citations (mapping each back to its source via document_index):
# src/rag/generate/parse.py
from rag.generate.types import Answer, Citation
def parse_response(content, chunks: list[dict]) -> Answer:
"""Walk Claude's content blocks -> answer text + verified citations."""
parts: list[str] = []
cites: list[Citation] = []
for block in content:
if block.type != "text":
continue
parts.append(block.text)
for cit in (block.citations or []): # char_location citations
src = chunks[cit.document_index]
cites.append(Citation(
cited_text=cit.cited_text, # the EXACT supporting span
source=src["source"],
title=src["title"],
))
return Answer(text="".join(parts), citations=cites)
Each citation's cited_text is the precise quote from your chunk that supports the surrounding claim — exactly what the UI renders as a clickable source span (and what your eval harness will check in L4 to measure faithfulness: does the cited span actually support the claim?).
Guardrail #2 — Abstain When Nothing Supports the Answer
The retrieval gate (Guardrail #1) catches "nothing relevant was retrieved." But there's a subtler failure: the chunks are on-topic and score well, yet none actually states the answer. The gate is open, but the right move is still to abstain. The Citations API gives you a beautiful signal for free: if the model produced zero citations, nothing in the documents grounded its answer — so don't trust it.
This is the attribution check: a grounded answer should have citations; an answer with no citations (that isn't already the abstain message) is ungrounded and must be replaced with an abstention:
# src/rag/generate/guardrails.py (continued)
from rag.generate.types import Answer
def grounding_gate(answer: Answer) -> Answer:
"""Guardrail #2: an answer with no citations is ungrounded -> abstain."""
already_abstained = answer.text.strip() == ABSTAIN_MSG
if not answer.citations and not already_abstained:
return Answer(text=ABSTAIN_MSG, abstained=True, reason="no_citations")
answer.abstained = already_abstained
return answer
Two guardrails, two distinct failure modes: #1 abstains when retrieval failed (low score), #2 abstains when grounding failed (no citations). Together they make the assistant honest by construction — it can only answer when it has both relevant chunks and specific spans to cite. (Toggle the 'on-topic, unsupported' query in the console to watch Guardrail #2 fire while the score is still above threshold.)
The Full generate() — and the /ask Endpoint
Compose the pieces: gate → ground → cite → check. This is the public generate() the API calls:
# src/rag/generate/__init__.py
from anthropic import Anthropic
from rag.config import settings
from rag.generate.guardrails import ABSTAIN_MSG, grounding_gate, retrieval_gate_open
from rag.generate.parse import parse_response
from rag.generate.prompt import SYSTEM, to_document_blocks
from rag.generate.types import Answer
_client = Anthropic(api_key=settings().anthropic_api_key)
def generate(query: str, chunks: list[dict]) -> Answer:
# Guardrail #1 — retrieval gate (no LLM call if retrieval was weak)
if not retrieval_gate_open(chunks):
return Answer(text=ABSTAIN_MSG, abstained=True, reason="low_retrieval_score")
# Grounded generation with the Citations API
resp = _client.messages.create(
model=settings().gen_model, # claude-sonnet-4-6
max_tokens=1024,
system=SYSTEM,
messages=[{
"role": "user",
"content": [*to_document_blocks(chunks), {"type": "text", "text": query}],
}],
)
# Parse + Guardrail #2 — grounding gate (no citations -> abstain)
return grounding_gate(parse_response(resp.content, chunks))
Wire it into the API as /ask — retrieve, then generate. This is the assistant's front door:
# src/rag/api.py (add to the L1 scaffold)
from dataclasses import asdict
from rag.generate import generate
from rag.retrieve import retrieve
@app.post("/ask")
def ask(q: str) -> dict:
chunks = retrieve(q) # L2: hybrid -> RRF -> rerank -> top-6
return asdict(generate(q, chunks)) # L3: gate -> ground -> cite -> check
# try it
curl -s -X POST 'localhost:8000/ask?q=How+often+must+API+keys+be+rotated%3F' | jq
{
"text": "API keys must be rotated every 90 days.",
"citations": [{"cited_text": "rotated every 90 days", "source": "auth-guide.md", ...}],
"abstained": false
}
Streaming (for real UX): swap messages.create for messages.stream and yield stream.text_stream; the citations arrive on the final message (stream.get_final_message()). Stream the prose, then attach the verified citations once the block closes — the user sees tokens immediately and sources a beat later.
✅ Definition of Done (this step)
Your generate/ module and /ask endpoint should be real and honest:
-
generate(query, chunks)returns a typedAnswer(text + citations +abstained+ reason). - Guardrail #1 (retrieval gate): a low-relevance query (top
rerank_score<min_score) abstains without an LLM call. - Grounded prompt: a system prompt that forbids outside knowledge and gives the exact abstain wording.
- Citations API: each chunk is a document block with
citations.enabled; the response is parsed into answer text + verifiedcited_textspans. - Guardrail #2 (grounding gate): an answer with zero citations is replaced by an abstention.
-
/askwiresretrieve()→generate(); an answerable query returns a cited answer, an unanswerable one abstains.
Smoke test all three paths: an answerable query (cited answer), a low-score query (gate abstains), and a plausible-but-unsupported query (grounding gate abstains). If your assistant refuses to make things up, you've built the part most RAG demos skip. Next, L4 measures all of this with an eval harness.
See It — The Grounded Answer Console
This console is generate() made playable. Pick a query: the left shows retrieve()'s reranked chunks (with scores), the right shows Claude's answer, and both guardrails are live.
- Drag the abstain threshold (
min_score). When the top rerank score drops below it, Guardrail #1 fires — the assistant refuses before generating. - Pick 'on-topic, unsupported.' The score clears the threshold (gate open), but because no chunk actually states the answer there's nothing to cite — so Guardrail #2 abstains anyway. This is the subtle failure most systems miss.
- On an answerable query, the answer renders with inline citation chips — click one to highlight the exact
cited_textspan in its source chunk (the Citations API model). - Flip GROUNDED → UNGROUNDED. Same question, no documents: a confident, fabricated answer with no citations — the precise failure grounding + abstention replace.

Notice three things. One: abstaining is two decisions, not one — a retrieval gate and a grounding check, catching different failures. Two: a citation is a verifiable span, not a footnote number — that's why native Citations beats prompt-based [1]s. Three: the dangerous answer isn't the one that says "I don't know" — it's the confident, uncited one, which is exactly what these guardrails replace.
🧪 Try It Yourself
Reason these out, then check against the console and the code.
1. Why put the retrieval gate before the LLM call instead of asking the model "are you confident?" after it answers? Name the property of LLM self-assessment that makes the after-the-fact approach unreliable.
2. A query retrieves three chunks, all scoring 0.6 (above min_score), all about the right product — but none states the specific number the user asked for. What does a well-built assistant do, and which guardrail makes it happen?
3. Why is the Anthropic Citations API better than instructing the model to write [1], [2] inline? Give two concrete reasons.
4. Your PM says: "abstaining looks bad — make it always answer." Give the one-sentence business case for keeping abstention, in terms of trust and risk.
5. You raise min_score from 0.30 to 0.55. What gets better, what gets worse, and how would you choose the value rather than guess?
Answers.
1. Because LLM self-reported confidence is poorly calibrated — models are frequently confidently wrong, so "are you sure?" often returns "yes" on a hallucination. The rerank score is a direct measurement of whether the corpus contains relevant material, computed before any generation — a far more reliable abstain signal, and free.
2. It abstains ("I don't have enough information"). Guardrail #2 (the grounding gate): the retrieval gate is open (0.6 ≥ min_score), but the model can't ground the specific claim, so it produces no citations — and the no-citations rule converts that into an abstention instead of an invented number.
3. (a) Verifiability — the API returns the exact cited_text span and character range, guaranteed to point at real source text, so a claim can be checked at a glance; a [1] is just a number that can drift or be fabricated. (b) Accuracy — Anthropic's native citations are measurably more likely to cite the most relevant quote (up to +15%), and cited_text is free on output tokens.
4. A wrong-but-confident answer destroys trust (and can create legal/financial risk) far faster than an honest "I don't know" — one hallucinated refund policy or dosage is worth more than a hundred graceful refusals. Abstention is how you make the assistant safe to ship.
5. Better: higher precision — fewer answers grounded on weak chunks, so fewer subtle hallucinations. Worse: higher abstention — you'll refuse some genuinely answerable questions (lower recall/coverage). Choose it with data, not vibes: run your eval set (L4), plot faithfulness and coverage against min_score, and pick the knee of the curve for your risk tolerance.
Mental-Model Corrections
- "Add RAG and the model stops hallucinating." → RAG actually lowers abstention — models over-trust supplied context. You must engineer grounding (a strict prompt) and refusal (two guardrails) explicitly.
- "Abstaining is a failure." → A confident wrong answer is the failure. Abstention is a feature — it's what makes the assistant safe to put in front of users.
- "Citations = put [1], [2] in the prompt." → Those drift and can't be verified. The Citations API returns the exact
cited_textspan per claim, guaranteed valid — and it's ~15% more accurate and free on output tokens. - "A citation means the claim is correct." → A citation means there's a span to check — necessary, not sufficient. That's why L4 measures faithfulness (does the span actually support the claim?). Citations make verification possible; eval makes it certain.
- "One confidence check is enough." → You need two: a retrieval gate (nothing relevant retrieved) and a grounding gate (nothing cited). They catch different failures.
- "Ask the model how confident it is." → LLM self-confidence is badly calibrated. Gate on the retrieval score (a measurement) and the presence of citations (evidence), not the model's feelings.
- "Let the model fall back on its training when the docs are thin." → That's how you get answers that sound grounded but aren't. Answer only from the documents, or abstain — anything else is untraceable.
Key Takeaways
- Generation is where a RAG demo becomes trustworthy — or a confident liar. The two features that decide it are citations and abstention, and both must be engineered (RAG reduces a model's instinct to refuse).
generate(query, chunks)is gate → ground → cite → check and returns a typedAnswer(text + verified citations +abstained+ reason).- Guardrail #1 — retrieval gate: if the top
rerank_score<min_score, abstain without calling the LLM. The rerank score is a measurement, far better than the model's self-reported confidence. - Grounded prompt: answer only from the documents, never guess, and give the model the exact abstain wording.
- Cite with the Anthropic Citations API: each chunk is a document block with
citations.enabled; the response carries verifiedcited_textspans (free on output tokens, ~15% more accurate than prompt-based[1]s). - Guardrail #2 — grounding gate: an answer with zero citations is ungrounded → replace it with an abstention. Catches the on-topic-but-unsupported failure the retrieval gate misses.
/ask= retrieve() → generate(). The assistant can now find, ground, cite, and refuse — the front door is live.- Next — L4 (Building the Evaluation Harness): stop trusting vibes. Build a golden Q&A set and score the system with RAGAS (faithfulness, answer-relevancy, context-precision) so every change — including
min_score— is a measured decision, not a guess.