An LLM that answers questions without citing sources is a liability. In regulated industries — healthcare, finance, legal — every claim must be traceable to a specific document, paragraph, or data record. Even in general enterprise RAG, citations serve three functions: trust (users can verify claims), debugging (developers can trace incorrect answers to bad retrieval), and compliance (auditors can confirm the system used authorized sources).
Without grounding, LLMs frequently confabulate — they produce fluent, confident text that blends retrieved facts with hallucinated details. Studies show that even with perfect retrieval, GPT-4-class models fabricate details in 8–15% of responses. Citation enforcement reduces this to under 3% by constraining the model to only make claims it can attribute.
Grounding vs. Citation: Grounding ensures the answer is faithful to retrieved context. Citation goes further by marking which specific source supports each claim. Both are necessary for production RAG.
Inline Attribution
The most effective citation pattern is inline attribution using numbered markers. Each retrieved chunk is assigned an index [1], [2], etc. The LLM is instructed to place these markers immediately after any claim derived from a specific chunk. The prompt must be explicit:
defbuild_citation_prompt(query: str, chunks: list[dict]) -> list[dict]:
"""Build a prompt that enforces inline citations."""# Number each chunk for reference
context_parts = []
for i, chunk inenumerate(chunks, 1):
context_parts.append(f"[{i}] {chunk['text']}")
context = "\n\n".join(context_parts)
system = (
"You are a research assistant. Answer the question using ONLY ""the provided sources. After every factual claim, include the ""source number in brackets like [1]. If a claim uses multiple ""sources, list all: [1][3]. If you cannot find supporting ""evidence for a claim, do NOT include it. Never fabricate citations."
)
return [
{"role": "system", "content": system},
{"role": "user", "content": f"Sources:\n{context}\n\nQuestion: {query}"},
]
Two design decisions significantly affect citation quality. First, the chunk granularity: coarse chunks (500+ tokens) make it harder for the model to pinpoint which sentence supports a claim. Finer chunks (100–200 tokens) improve citation precision but require more context window space. Second, the chunk ordering: placing the most relevant chunks first (after reranking) leads to better citation usage because LLMs attend more strongly to early context.
Grounding Techniques
Beyond simple prompting, there are several techniques to enforce that the LLM stays faithful to the retrieved context:
1. Constrained decoding: Some LLM providers support logit bias or structured output modes. You can boost the probability of citation tokens [, 1–9, ] and suppress tokens that frequently appear in hallucinated content.
2. Quote-then-answer: Instruct the model to first extract relevant quotes from the context, then synthesize an answer using only those quotes. This two-step process reduces hallucination because the model commits to specific evidence before generating claims.
defquote_then_answer(query: str, chunks: list[dict]) -> dict:
"""Two-pass grounding: extract quotes first, then answer."""
context = format_chunks(chunks)
# Pass 1: Extract relevant quotes
quote_prompt = (
f"From these sources, extract exact quotes relevant to: {query}\n"
f"Return as JSON: {{\"quotes\": [{{\"source_id\": N, \"text\": \"...\"}}]}}\n\n"
f"{context}"
)
quotes = llm_json_call(quote_prompt)
# Pass 2: Answer using only extracted quotes
answer_prompt = (
f"Using ONLY these verified quotes, answer: {query}\n"
f"Cite each quote by its source_id.\n\n"
f"Quotes: {json.dumps(quotes)}"
)
answer = llm_call(answer_prompt)
return {"answer": answer, "quotes": quotes}
3. Self-reflection: After generating an answer, pass it back to the LLM along with the original context and ask: "Does every claim in the answer have support in the provided sources? Identify any unsupported claims." This catches hallucinations that slipped through the first generation pass.
Caveat: Self-reflection is not foolproof. LLMs sometimes validate their own hallucinations. For high-stakes use cases, combine self-reflection with NLI-based verification (see next section).
Verification Pipeline
A robust citation verification pipeline uses Natural Language Inference (NLI) to check whether each cited claim is actually entailed by its source. NLI models classify a (premise, hypothesis) pair as entailment, contradiction, or neutral. For citation verification, the premise is the cited chunk and the hypothesis is the claim.
from transformers import pipeline
import re
classCitationVerifier:
def__init__(self):
self.nli = pipeline("text-classification",
model="cross-encoder/nli-deberta-v3-large")
self.citation_pattern = re.compile(r"\[(\d+)\]")
defextract_claims(self, answer: str) -> list[dict]:
"""Split answer into sentences and extract citation markers."""
sentences = answer.split(".")
claims = []
for sent in sentences:
sent = sent.strip()
if not sent:
continue
refs = [int(m) for m in self.citation_pattern.findall(sent)]
clean = self.citation_pattern.sub("", sent).strip()
claims.append({"text": clean, "refs": refs})
return claims
defverify(self, answer: str, chunks: list[dict]) -> dict:
"""Verify every cited claim against its source chunk."""
claims = self.extract_claims(answer)
results = []
for claim in claims:
if not claim["refs"]:
results.append({**claim, "status": "uncited"})
continue# Check NLI against each cited source
all_entailed = Truefor ref in claim["refs"]:
chunk_text = chunks[ref - 1]["text"]
result = self.nli(f"{chunk_text} [SEP] {claim['text']}")
if result[0]["label"] != "ENTAILMENT":
all_entailed = False
status = "verified"if all_entailed else"unverified"
results.append({**claim, "status": status})
verified = sum(1 for r in results if r["status"] == "verified")
return {"claims": results,
"faithfulness": verified / max(len(results), 1)}
The NLI model runs locally and adds ~50 ms per claim. For a typical 5-sentence answer with 8 citations, verification takes ~400 ms — fast enough to run inline before returning the response. Claims that fail verification can be flagged in the UI or trigger a re-generation loop.
Implementation
A complete citation-grounded RAG pipeline ties together retrieval, prompted generation, parsing, and verification into a single request flow:
classCitedRAG:
def__init__(self, retriever, verifier: CitationVerifier):
self.retriever = retriever
self.verifier = verifier
self.max_retries = 2
async defanswer(self, query: str) -> dict:
chunks = self.retriever.search(query, top_k=8)
prompt = build_citation_prompt(query, chunks)
for attempt inrange(self.max_retries + 1):
response = awaitllm_call(prompt)
verification = self.verifier.verify(response, chunks)
if verification["faithfulness"] >= 0.85:
return {
"answer": response,
"sources": [{"id": c["doc_id"], "text": c["text"][:200]}
for c in chunks],
"faithfulness": verification["faithfulness"],
"claims": verification["claims"],
}
# Add feedback for retry
unverified = [c["text"] for c in verification["claims"]
if c["status"] != "verified"]
prompt.append({
"role": "user",
"content": f"These claims could not be verified: {unverified}. "
f"Rewrite the answer using only verifiable claims."
})
return {"answer": response, "warning": "Low faithfulness after retries"}
The retry loop is capped at 2–3 attempts. In practice, the first retry fixes most issues because the model sees which specific claims failed. If faithfulness remains low after retries, the system returns the answer with a warning flag, letting the UI display a "low confidence" indicator.
Evaluation Metrics
Measuring citation quality requires a different set of metrics than standard RAG evaluation:
Citation Precision
Of all citations in the answer, how many actually support the associated claim? Measured via NLI verification. Target: >90%.
Citation Recall
Of all claims in the answer, how many have at least one citation? Uncited claims are potential hallucinations. Target: >95%.
defcompute_citation_metrics(claims: list[dict]) -> dict:
"""Compute citation precision, recall, and faithfulness."""
total = len(claims)
cited = [c for c in claims if c["refs"]]
verified = [c for c in claims if c["status"] == "verified"]
uncited = [c for c in claims if not c["refs"]]
precision = len(verified) / max(len(cited), 1)
recall = len(cited) / max(total, 1)
faithfulness = len(verified) / max(total, 1)
return {
"citation_precision": round(precision, 3),
"citation_recall": round(recall, 3),
"faithfulness": round(faithfulness, 3),
"uncited_claims": [c["text"] for c in uncited],
}
For benchmarking, build a golden set of 50–100 question-answer pairs where human annotators mark each claim as correctly cited, incorrectly cited, or uncited. Compute citation precision and recall against this ground truth. Monitor these metrics continuously in production using a sampled evaluation pipeline that runs on 1–5% of live traffic.
Production tip: Log every answer with its citation verification results. When faithfulness drops below threshold, trigger an alert. Common causes: retrieval quality degradation, prompt drift after template updates, or new document types that the extraction pipeline handles poorly.