AI Engineering
tutorial
Featured

Your RAG Cites a Source That Does Not Say That

Your retrieval is good. The right chunks come back, the citation links resolve, and the answer reads clean. Then a user clicks the citation and the document says nothing of the kind. The model wrote a confident claim, stapled a real source to it, and shipped. A grounding gate catches that before the user does.

Viral Ruparel
11 min read
Share:

Your retrieval is good. You did the work. Hybrid search, a reranker, sensible chunking, the whole stack. When a user asks about your refund window, the right paragraph comes back at rank one. The answer reads clean, it has a citation, the citation is a real document that really is about refunds. You ship it.

Then a user clicks the citation. The linked document says refunds are processed within fourteen days. The answer said thirty. The model took a real, correctly retrieved source and wrote a claim the source does not make, then stapled the source to it as proof. The user believed it, because it looked cited. That is not a retrieval bug. Retrieval did its job perfectly. This is a grounding bug, and it lives one layer downstream of everything most teams spend their time tuning.

Retrieval and grounding are different failure surfaces

It is worth separating these cleanly, because conflating them is why teams pour months into the wrong layer.

Retrieval quality is about whether the right evidence made it into the context. If the answer is wrong because the chunk was missing, split across a boundary, or buried under three irrelevant ones, that is a retrieval problem, and no amount of downstream checking fixes it. I wrote about that side of the house in Your RAG Is Not Broken, Your Retrieval Is. Go fix retrieval first. It raises your ceiling.

Grounding is a different question. Given that the right evidence is sitting in the context, does the answer actually say only what the evidence supports? This is where confident hallucinations get manufactured even on a healthy retrieval stack. The chunk says fourteen days, the model writes thirty. The chunk describes a feature for enterprise plans, the model presents it as available to everyone. The chunk lists three requirements, the model lists four. Every one of these passes retrieval, passes a system prompt that says "only answer from the provided context," and still ships something false with a citation on it.

The reason "only answer from the context" does not save you is that it is an instruction, not a check. The model tries to follow it and mostly does, but "mostly" is not a property you can put in front of customers. You need something that verifies the output against the evidence after generation, mechanically, before the answer leaves your API. A grounding gate.

The version that ships hallucinated citations

Here is the shape of the handler almost every RAG system starts with. It is not wrong, exactly. It just trusts the model completely.

// app/api/ask/route.ts
export async function POST(req: Request) {
  const { question } = await req.json();

  const chunks = await retrieve(question); // your good retrieval stack
  const context = chunks
    .map((c, i) => `[${i + 1}] ${c.text}`)
    .join("\n\n");

  const answer = await generate(question, context);

  // We return whatever the model produced, citations and all,
  // having verified none of it against the chunks.
  return Response.json({ answer, sources: chunks });
}

The answer here can assert anything. The prompt handed the model good context and asked it to stay inside that context, and the model agreed, and neither of those facts constrains the actual tokens that came out. The bracketed citation markers the model inserts, [1], [2], are just text it decided to write. Nothing confirmed that claim [1] is supported by chunk one. So you ship, and you find out it was wrong when a customer complains, which is the most expensive possible place to find out.

Break the answer into claims and check each one

The core move is to stop treating the answer as one blob and start treating it as a set of individual claims, each of which either is or is not supported by a specific piece of retrieved evidence. Groundedness is a property of claims, not of paragraphs.

Step one is extraction. Split the answer into atomic claims and, where the model cited a source, capture which chunk it pointed at.

// grounding/extract.ts
type Claim = { text: string; citedChunk: number | null };

// Ask a small model to decompose the answer into standalone factual claims,
// preserving any [n] citation the sentence carried.
export async function extractClaims(answer: string): Promise<Claim[]> {
  const res = await callModel({
    model: "claude-haiku-4-5", // cheap, fast, good enough for decomposition
    system:
      "Split the text into atomic factual claims. Return JSON array of " +
      "{text, citedChunk}. citedChunk is the number inside a [n] marker on " +
      "that sentence, or null. Do not invent claims. Ignore hedges and filler.",
    input: answer,
    responseFormat: "json",
  });
  return JSON.parse(res).claims as Claim[];
}

Step two is the actual check. For each claim, ask whether the cited chunk (or, if the claim carried no citation, any retrieved chunk) actually entails it. This is a narrow entailment question, and narrow entailment questions are exactly what small models are good at. You do not want your strongest model here. You want your cheapest reliable one, run in parallel across claims.

// grounding/verify.ts
type Verdict = { supported: boolean; evidenceChunk: number | null };

async function checkClaim(claim: Claim, chunks: Chunk[]): Promise<Verdict> {
  // If the model cited a specific chunk, hold it to that chunk. A claim that
  // is "true" against some other chunk is still a mis-citation.
  const candidates =
    claim.citedChunk != null ? [chunks[claim.citedChunk - 1]] : chunks;

  const evidence = candidates
    .map((c, i) => `[${i + 1}] ${c.text}`)
    .join("\n\n");

  const res = await callModel({
    model: "claude-haiku-4-5",
    system:
      "Does the EVIDENCE support the CLAIM, fully and without adding facts? " +
      "Answer strictly as JSON {supported: boolean, evidenceChunk: number|null}. " +
      "If the claim adds any number, date, or condition not in the evidence, " +
      "supported is false.",
    input: `CLAIM: ${claim.text}\n\nEVIDENCE:\n${evidence}`,
    responseFormat: "json",
  });
  return JSON.parse(res) as Verdict;
}

export async function verifyClaims(claims: Claim[], chunks: Chunk[]) {
  // Claims are independent, so check them all at once instead of in sequence.
  return Promise.all(claims.map((c) => checkClaim(c, chunks)));
}

Two details in there matter more than they look. First, when the model cited a specific chunk, the check holds it to that chunk. A claim that happens to be true against some other retrieved chunk is still a mis-citation, and mis-citations are how you "teach" users to trust a citation that does not mean what they think it means. Second, the prompt explicitly names the common failure: a claim that adds a number, a date, or a condition the evidence does not contain is unsupported, even if it feels plausible. Plausible is the whole problem. Plausible is what a hallucination is.

The gate: decide what ships

Now you have a verdict per claim. The gate turns that into a decision. The cheapest useful policy is to compute a groundedness score, drop the unsupported sentences, and only escalate to a regenerate or a refusal when too much of the answer failed.

// grounding/gate.ts
export async function groundedAnswer(question: string, chunks: Chunk[]) {
  const answer = await generate(question, formatContext(chunks));
  const claims = await extractClaims(answer);
  const verdicts = await verifyClaims(claims, chunks);

  const supported = verdicts.filter((v) => v.supported).length;
  const score = claims.length ? supported / claims.length : 0;

  // Fully grounded: ship as is.
  if (score === 1) return { answer, score, action: "pass" };

  // Mostly grounded: strip the unsupported claims, keep the rest.
  if (score >= 0.7) {
    const kept = claims
      .filter((_, i) => verdicts[i].supported)
      .map((c) => c.text)
      .join(" ");
    return { answer: kept, score, action: "redacted" };
  }

  // Too much was unsupported to trust the shape of the answer at all.
  return {
    answer:
      "I could not find enough in the available sources to answer that " +
      "confidently.",
    score,
    action: "refused",
  };
}

The thresholds here are placeholders, and picking the real ones is the actual engineering. A support assistant answering "how do I reset my password" can run a loose gate, because the cost of dropping a marginal sentence is small and the cost of an occasional soft refusal is smaller. A system quoting contract terms, medical dosages, or financial figures runs a strict gate, because one confident wrong number is a genuine liability and a soft refusal is cheap by comparison. There is no universal cutoff. There is only the cutoff that gives your domain the precision it needs, and you find it by labeling a few hundred real answers and measuring, not by guessing.

This is the same discipline as putting a real check at the boundary rather than trusting the model to behave, which I went into for a different failure mode in Your Agent Just Leaked One Customer's Data to Another. The pattern generalizes: the model's output is an input to your system, and inputs get validated.

The parts people get wrong

Treating groundedness and correctness as the same thing. They are not. A claim can be perfectly grounded in a chunk that is itself out of date, and the gate will happily pass it, because its job is to confirm the answer matches the evidence, not to confirm the evidence matches reality. Grounding protects you from the model inventing things. It does not protect you from a stale corpus. You still need retrieval hygiene and freshness upstream. The gate is the last line, not the only line.

Checking the paragraph instead of the claim. If you ask a model "is this answer supported by these chunks," it will pattern match on overall topical overlap and tell you yes, because the answer is obviously about the same subject as the chunks. That is not grounding, that is vibes. The decomposition into atomic claims is what forces a real per fact judgment, and it is the step people skip when they want to save a call. Do not skip it. The blob level check is the one that lets the fourteen-versus-thirty error straight through.

Letting the check add latency you never measured. Extraction plus verification is extra model calls on the critical path, and if you run them on a slow model in sequence you will add seconds and the product team will pull the gate out. Use a small model, run the per claim checks in parallel, and treat the verifier's latency as a number you watch, not an afterthought. In practice a decomposition call plus a fan out of small parallel entailment checks lands in the few hundred millisecond range for a normal answer, which is a price worth paying to not lie to customers.

Silent redaction with no signal. If your gate quietly drops sentences, you have hidden your own failure rate from yourself. Log every verdict. The groundedness score per answer, the claims that failed, and the chunks that should have supported them are the richest debugging signal you will get, and they tell you whether your problem is really generation or actually retrieval wearing a generation costume. Half the time a claim fails grounding because the supporting chunk was never retrieved, and now you have found a retrieval gap you would never have seen from the outside.

The takeaway

A RAG system that says "I don't know" is annoying. A RAG system that says "thirty days" with a citation to a document that says fourteen is dangerous, because it looks correct and people act on it. Retrieval tuning does not fix this, because the failure happens after retrieval, at generation time, inside the model, where every upstream check has already passed. The fix is a grounding gate: break the answer into claims, verify each claim against the evidence it cited, and decide what ships based on how much of it held up. It is a small amount of code and a couple of cheap model calls, and it is the difference between a demo that reads well and a system you can put in front of customers who will click the citation.

If your RAG answers look right but you have no mechanical guarantee they are grounded in your sources, that gap is where trust quietly leaks out. Book a consultation call and we can look at where your answers drift from your evidence and put a gate between that drift and your users.

Viral Ruparel

Generative AI consultant helping teams ship reliable LLM and agent systems in production.

Contact Viral about your AI project →

Frequently Asked Questions

Isn't a strong retrieval stack enough to prevent this?+

No, and this is the trap most teams fall into. Retrieval and grounding are two different failure surfaces. Retrieval decides whether the right evidence is in the context at all. Grounding decides whether the answer the model wrote is actually supported by that evidence. You can have perfect retrieval, the exact chunk that answers the question sitting right there in the prompt, and the model still adds a number, a date, or a condition that no chunk mentions. Fixing retrieval raises your ceiling. It does not close the gap between what the chunks say and what the model claims they say. That gap is what a grounding gate is for.

Does the grounding check need a big expensive model?+

Usually the opposite. Verifying that one short claim is or is not supported by one short piece of evidence is a narrow, well scoped task, and small fast models do it well and cheaply. The generation step wants your strongest model. The verification step wants your cheapest model that is still reliable at entailment. Running claims in parallel against a small model keeps the added latency in the few hundred millisecond range for a typical answer, which is the difference between a gate you ship and a gate the product team rips out for being too slow.

What should happen when a claim fails the grounding check?+

It depends on how much the surface can tolerate a gap. The safest option is to drop the unsupported sentence and keep the rest, so the user gets a shorter but trustworthy answer. A stricter option is to regenerate once with the failing claim fed back as a constraint, which often produces a grounded version. The strictest is to refuse and say you cannot answer confidently from the available sources. What you must not do is ship the unsupported claim with a citation attached, because that is the single most damaging output a RAG system can produce.

Won't the gate reject correct answers sometimes?+

Yes, and you tune for that explicitly. Every threshold trades false rejects against false accepts. Set it too strict and you drop good sentences and annoy users. Set it too loose and hallucinations slip through. The right threshold is not a guess, it comes from labeling a few hundred real answers as grounded or not and picking the cut that gives you the precision your domain needs. A support bot can tolerate a looser gate than a system quoting medical dosages or contract terms. Measure on your own traffic and revisit it as your corpus changes.