Module 4 · Retrieval-Augmented Generation (RAG) ⏱ 18 min

Grounding and Citations

By the end of this lesson you will be able to:
  • Explain why an ungrounded claim is indistinguishable from a true one without a source pointer
  • Implement a deterministic check that a cited chunk actually supports a claim
  • Map that check across a whole answer to surface its ungrounded claims

A model given good context will usually answer from it — but not always. Sometimes it drifts, inventing a detail no retrieved chunk supports, or it overstretches a real chunk into a claim that chunk never made. Without a way to check, that hallucination ships with the same confident tone as a grounded fact, and a reader has no way to tell them apart. Grounding is the discipline of proving every claim in an answer ties back to a retrieved source, and citation is the mechanism that proves it: each claim points at the specific chunk it came from. Together they turn a black-box generation into something a reader — or a downstream system — can verify claim by claim.

Number the chunks, then cite by number

The mechanism is simple and old. Build the context as a numbered list of chunks — chunk 1: refunds take thirty days, chunk 2: support replies by email — and ask the model to mark each claim with the number of the chunk it came from. A claim like 'refunds take thirty days [1]' now has a verifiable pointer: jump to chunk 1 and confirm it says that. The hard part is not the marking; it is the checking. A claim might cite a chunk that does not actually support it, or cite nothing at all. A grounding check reads each claim alongside its cited chunk and decides whether the chunk really contains the claim — catching the invented and the misattributed alike.

A check you can run without a model

Judging whether a chunk supports a claim is, in the wild, a job for another model — a cross-encoder or an entailment classifier. But you cannot call a model inside a deterministic exercise, and you do not need to in order to learn the shape of the check. We use a crude, honest stand-in: a claim is supported by a chunk if every content word of the claim also appears in the chunk. That lexical test catches the obvious failures — a claim citing a chunk whose text does not share its key terms — and it is something you can run and assert on directly. The structure is what matters here; the scorer is swappable.

flowchart TD
  C["a claim in the answer"] -->|"cites chunk 1"| CH["chunk 1: the source text"]
  CH --> CHECK["does the chunk contain<br/>the claim content words?"]
  CHECK -->|"yes"| OK["grounded: keep the claim"]
  CHECK -->|"no"| BAD["ungrounded: hallucination"]
  style OK fill:#166534,color:#fff
  style BAD fill:#b91c1c,color:#fff
A grounding check asks whether the cited chunk actually contains the claim's content words.
A lexical grounding check over three claims. Two fail — one cites a chunk that does not say it, one cites nothing. Run it.
const STOP = new Set(["the","a","an","is","are","was","of","to","in","and","or"]);
function terms(text) {
  return [...new Set(text.toLowerCase().split(/\W+/).filter(w => w && !STOP.has(w)))];
}
function supports(chunkText, claimText) {
  const need = terms(claimText);
  const have = new Set(terms(chunkText));
  return need.length > 0 && need.every(t => have.has(t));
}
function isGrounded(claim, context) {
  return claim.cites.some(id => {
    const c = context.find(x => x.id === id);
    return c && supports(c.text, claim.text);
  });
}

const context = [
  { id: 1, text: "refunds are issued within thirty days" },
  { id: 2, text: "the support team responds by email" }
];
const answer = [
  { text: "refunds are issued within thirty days", cites: [1] },
  { text: "refunds require a notarized form",       cites: [1] },
  { text: "the team responds by email",             cites: [] }
];
answer.forEach(c => console.log(isGrounded(c, context), c.text));

From one claim to a whole answer

Real answers are not one claim; they are a list, and a grounding check has to run over all of them. The pattern is the oldest one in programming: take the per-claim check you just wrote, map it across the answer, and keep the claims that fail. What comes back is a punch-list of exactly the sentences a human (or a stricter model) should review before the answer ships. An empty list means every claim is grounded; a non-empty one means work remains. The interesting design choice is what to do with the failures — drop them, flag them, or send them back for re-generation — but detecting them is always the first, mechanical step.

flowchart TD
  H["an ungrounded claim"] --> K1["no citation at all<br/>unsourced"]
  H --> K2["cites a chunk whose text<br/>does not support it<br/>fake citation"]
  H --> K3["cites a real chunk<br/>but overstates it<br/>unsupported detail"]
  style H fill:#b91c1c,color:#fff
Three ways a claim can be ungrounded, only some of which a lexical check catches.
Build a cited answer string. The second claim cites a real chunk, but the chunk does not actually say it — a fake citation. Run it.
function buildCitedAnswer(claims) {
  return claims.map(c => `${c.text} [${c.cites.join(",")}]`).join(" | ");
}

const claims = [
  { text: "the api returns json", cites: [1] },
  { text: "the api returns xml",  cites: [1] }
];
console.log(buildCitedAnswer(claims));

Why this is worth the trouble

Citations do more than decorate an answer. They turn each claim into a checkable statement: a reader who doubts 'refunds take thirty days' can jump to chunk 1 and see the source in one step, instead of trusting the model's confidence. For a support bot quoting policy, or a legal assistant citing clauses, that verifiability is the difference between a tool people rely on and one they cannot. And because the citation is structured data — claim text plus a chunk id — a downstream system can flag ungrounded claims automatically, instead of waiting for a human to notice a hallucination after it has already done damage.

flowchart LR
  CTX["numbered context chunks<br/>chunk 1, chunk 2, and so on"] --> MODEL["model writes an answer<br/>with 1 and 2 markers"]
  MODEL --> ANS["each marker points back<br/>to a verifiable chunk"]
  ANS -.->|"reader checks<br/>each claim"| CTX
  style CTX fill:#8b5cf6,color:#fff
Citations close a loop: the answer points back to numbered chunks a reader can verify.
Exercise

What does this print? terms drops common stop-words and returns the distinct content words that remain.

const STOP = new Set(["the","a","an","is","of","to","in","and"]);
function terms(text) {
  return [...new Set(text.toLowerCase().split(/\W+/).filter(w => w && !STOP.has(w)))];
}
console.log(terms("the cat and the dog"));
Exercise

Write supports(chunkText, claimText) that returns true when every content word of the claim also appears in the chunk, and false otherwise. A claim with no content words (only stop-words) is unsupported. The terms helper is provided.

const STOP = new Set(["the","a","an","is","are","was","of","to","in","and","or"]);
function terms(text) {
  return [...new Set(text.toLowerCase().split(/\W+/).filter(w => w && !STOP.has(w)))];
}

function supports(chunkText, claimText) {
  // true if every claim content word appears in the chunk
}
Exercise

Write isGrounded(claim, context) for a claim of shape {text, cites:[ids]} and a context of {id, text} chunks. Return true if at least one cited chunk supports the claim, and false if it cites nothing or no cited chunk supports it. terms and supports are provided.

const STOP = new Set(["the","a","an","is","are","was","of","to","in","and","or"]);
function terms(text) {
  return [...new Set(text.toLowerCase().split(/\W+/).filter(w => w && !STOP.has(w)))];
}
function supports(chunkText, claimText) {
  const need = terms(claimText);
  const have = new Set(terms(chunkText));
  return need.length > 0 && need.every(t => have.has(t));
}

function isGrounded(claim, context) {
  // true if some cited chunk supports the claim
}
Exercise

This isGrounded rejects a claim unless EVERY cited chunk supports it — so one weak citation dooms an otherwise grounded claim. The spec is that a claim is grounded when ANY cited chunk supports it. Fix the one array method so a single supporting citation is enough.

const STOP = new Set(["the","a","an","is","are","was","of","to","in","and","or"]);
function terms(text) {
  return [...new Set(text.toLowerCase().split(/\W+/).filter(w => w && !STOP.has(w)))];
}
function supports(chunkText, claimText) {
  const need = terms(claimText);
  const have = new Set(terms(chunkText));
  return need.length > 0 && need.every(t => have.has(t));
}
function isGrounded(claim, context) {
  return claim.cites.every(id => {
    const chunk = context.find(c => c.id === id);
    return chunk && supports(chunk.text, claim.text);
  });
}
Exercise

Write ungroundedClaims(answer, context) that takes an array of {text, cites} claims and returns the TEXTS of the claims that are NOT grounded (citing nothing, or whose cited chunks do not support them). terms, supports, and isGrounded are provided.

const STOP = new Set(["the","a","an","is","are","was","of","to","in","and","or"]);
function terms(text) {
  return [...new Set(text.toLowerCase().split(/\W+/).filter(w => w && !STOP.has(w)))];
}
function supports(chunkText, claimText) {
  const need = terms(claimText);
  const have = new Set(terms(chunkText));
  return need.length > 0 && need.every(t => have.has(t));
}
function isGrounded(claim, context) {
  return claim.cites.some(id => {
    const chunk = context.find(c => c.id === id);
    return chunk && supports(chunk.text, claim.text);
  });
}

function ungroundedClaims(answer, context) {
  // return the texts of the claims where isGrounded is false
}

Recap

  • Grounding means every claim in an answer ties to a retrieved source; citation is the pointer (a chunk number) that proves it.
  • Build context as a numbered list so each citation resolves to one specific, checkable chunk.
  • A grounding check tests whether a cited chunk actually contains a claim; without it, hallucinations ship as confidently as facts.
  • Our stand-in is lexical: a chunk supports a claim when every content word of the claim appears in the chunk — crude but runnable with no model.
  • A claim is grounded when any cited chunk supports it; an empty cites list means ungrounded.
  • Three failure kinds: no citation, a fake citation (real chunk, unsupported claim), and an overstatement (real chunk, stretched claim).
  • Mapping the check across an answer yields the list of ungrounded claims to review — empty means safe.

Checkpoint quiz

What makes a claim 'grounded' in a RAG answer?

Why is a lexical grounding check (matching content words) only a floor, not a ceiling?

Go deeper — technical resources