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
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
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
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"));
Split into words, then drop the stop-words: the, and are removed.
cat and dog remain, and the Set keeps them distinct.
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
}
Collect the claim's content words with terms(); if there are none, return false.
Build a Set of the chunk's content words and check that every claim word is in 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));
}
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
}
Use the claim's cites list, and ask whether some cited id maps to a chunk that supports the claim.
An empty cites list should make .some return false — a claim that cites nothing is ungrounded.
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);
});
}
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);
});
}
.every returns true only if all cited chunks support the claim — too strict.
Swap it for the method that is satisfied when at least one cited chunk supports 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 chunk = context.find(c => c.id === id);
return chunk && supports(chunk.text, claim.text);
});
}
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
}
Keep the claims for which isGrounded returns false.
Then map those claims down to just their .text, so the result is a list of claim strings to review.
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 answer.filter(c => !isGrounded(c, context)).map(c => c.text);
}
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.