Citation Tracing

By the Syncfusion DevTutorials team · Last updated

Linking every claim to its source and checking it: chunk ids, sections and pages, the citation-first pattern, claim verification, structured citation schemas, fake and missing citations, and streaming with citations.

Imagine reading a news article that says, "Experts agree this is true." Most people would immediately ask:

Which experts? Where did they say it? Can I verify it myself?

Good journalism does not just present claims. It shows the sources behind them so readers can check the information.

RAG systems should follow the same principle.

When an assistant provides information, users should be able to see exactly where that information came from. More importantly, the system should verify that the cited source actually supports the claim being made.

This process is called citation tracing.

Citation tracing connects each claim in an answer to the source that supports it and checks that the connection is correct.

The main idea: each sentence of the answer cites chunk ids like [S1], and those map to a title, section and page a person can open. A citation-first pattern extracts supporting quotes before writing the answer, and structured output makes citations machine-checkable. Quality controls catch hallucinated citation numbers, unreferenced claims and claims the cited source doesn't support, including in streamed answers.

A Real-World Example

Brightgrid reviewed 200 answers produced by its assistant. The review found three common citation problems:

Invalid citations: An answer used [S4] even though the model received only sources S1, S2, and S3. Unsupported claims: An answer said, “Shipping costs are always refunded [S2].” However, source S2 said that shipping costs are refunded only when the product is faulty. Missing citations: An answer promised a 10% voucher, but no retrieved source supported that information.

Brightgrid added a citation checker that runs before an answer is shown to the user. The checker verifies that:

Every citation points to a source that exists. Each factual claim includes a citation. Important details, especially names, dates, amounts, and other numbers, are supported by the cited source. The complete meaning of the claim matches the source, including any conditions or limits.

If an answer fails these checks, the application can ask the model to try again using clearer instructions. If the revised answer is still unsupported, the application can display a warning or say that the available sources do not contain a reliable answer.

In simple words: cite everything, then check that the citations are true.

What You Will Learn

In this lesson, you will learn:

  1. Citation mechanics: linking claims to chunk ids, and mapping ids to sections and pages.
  2. The citation-first pattern: extracting quotes before writing, verifying claims, and structured citation schemas.
  3. Quality controls: fake citation numbers, unreferenced claims, multi-source attribution and automatic checkers.
  4. Streaming answers with citations.

Every answer in the RAG Lab's Ask tab now has a claim check table. For each sentence, it shows which sources it cites, what share of its words appear in those sources, and a verdict: supported, weak, no citation, bad citation or refusal.

Citation tracing: each sentence of the answer cites a source id, each id maps to a chunk with its title, section and page, and a checker confirms the id exists and the source supports the claim.

Citation Mechanics

Linking Claims to Specific Chunk IDs

A claim is a sentence, or a part of one, that states a fact. Each claim needs a link to the chunk that supports it. Two common styles:

  • Inline ids: "Refunds take 5 business days [S2]." This is simple, and works with any model.
  • Structured citations: the model returns JSON with each citation's id and a supporting quote (below).

Your code keeps a map from the short ids in the prompt (S1, S2) to the real chunk ids (returns-policy#3). Never ask the model to copy long internal ids. It will make typos.

Section & Page Number Mapping

For users, [S2] means nothing. Turn it into something they can open: a title, a section, a page number, a link. That's why ingestion keeps the section path and page for every chunk (Lessons 4 and 5). Renumber the citations in order of first use, like a footnote list.

Turn [S1] into footnotes with pages

// From [S1] in the answer to a link a person can open: map ids to chunks, sections and pages.
const sources = {
  S1: {
    chunk: "returns-policy#1",
    title: "Returns and refunds policy",
    section: "How long you have",
    page: 1,
  },
  S2: {
    chunk: "returns-policy#3",
    title: "Returns and refunds policy",
    section: "Refunds",
    page: 2,
  },
};
const answer =
  "You can return a lamp within 30 days [S1]. Refunds arrive within 5 business days [S2].";

const used = [...new Set([...answer.matchAll(/\[(S\d+)\]/g)].map((m) => m[1]))];
const numbered = answer.replace(/\[(S\d+)\]/g, (_m, id) => `[${used.indexOf(id) + 1}]`);
console.log(numbered);
used.forEach((id, i) => {
  const s = sources[id];
  console.log(`[${i + 1}] ${s.title} > ${s.section}, page ${s.page} (${s.chunk})`);
});

Expected output:

You can return a lamp within 30 days [1]. Refunds arrive within 5 business days [2].
[1] Returns and refunds policy > How long you have, page 1 (returns-policy#1)
[2] Returns and refunds policy > Refunds, page 2 (returns-policy#3)

Citation-First Pattern

Extracting Supporting Quotes Before Answer Synthesis

In the citation-first pattern, the model works in two steps:

  1. Quote: list the exact sentences from the sources that answer the question, each with its id.
  2. Answer: write the answer using only those quotes.

Because the answer is built from verbatim quotes, it's much harder to drift away from the sources. The quotes can also be shown to the user as evidence.

Quotes first, then the answer

// Citation-first: pull out supporting quotes, then build the answer only from those quotes.
const sources = {
  S1: "You can return any Brightgrid product within 30 days of delivery. The product must come with its box.",
  S2: "We refund the full price within 5 business days after the parcel reaches our warehouse.",
};
const question = "how long do I have to return it and when is the refund paid";
const stop = new Set([
  "how",
  "long",
  "do",
  "i",
  "have",
  "to",
  "it",
  "and",
  "when",
  "is",
  "the",
]);
const qWords = question.split(" ").filter((w) => !stop.has(w));

// Step 1: quotes = exact sentences that share words with the question.
const quotes = [];
for (const [id, text] of Object.entries(sources)) {
  for (const sentence of text.match(/[^.]+\./g).map((s) => s.trim())) {
    const hits = qWords.filter((w) =>
      sentence.toLowerCase().includes(w.replace(/s$/, "")),
    );
    if (hits.length) {
      quotes.push({ id, sentence });
    }
  }
}
quotes.forEach((q) => console.log(`quote ${q.id}: "${q.sentence}"`));

// Step 2: the answer may only restate the quotes, each with its id.
console.log("\nanswer: " + quotes.map((q) => `${q.sentence} [${q.id}]`).join(" "));

Expected output:

quote S1: "You can return any Brightgrid product within 30 days of delivery."
quote S2: "We refund the full price within 5 business days after the parcel reaches our warehouse."

answer: You can return any Brightgrid product within 30 days of delivery. [S1] We refund the full price within 5 business days after the parcel reaches our warehouse. [S2]

Verifying Generated Claims Against Sources

After generation, check each sentence against the source it cites. A practical first check combines two tests:

  • Word support: what share of the sentence's important words appear in the cited source?
  • Number match: every number in the sentence must appear in the source, because wrong numbers are the most harmful errors.

Check each claim against its source

// Verify each claim: are its words actually in the source it cites?
const sources = {
  S1: "You can return any product within 30 days of delivery.",
  S2: "Shipping costs are refunded only if the product was faulty.",
};
const claims = [
  { text: "You can return a product within 30 days", cite: "S1" },
  { text: "Shipping costs are always refunded", cite: "S2" },
  { text: "Returns are free for 60 days", cite: "S1" },
];
const words = (t) =>
  t
    .toLowerCase()
    .match(/[a-z0-9]+/g)
    .filter((w) => w.length > 2);

for (const c of claims) {
  const src = new Set(words(sources[c.cite]));
  const w = words(c.text);
  const support = w.filter((x) => src.has(x)).length / w.length;
  const numbersOk = (c.text.match(/\d+/g) ?? []).every((n) =>
    sources[c.cite].includes(n),
  );
  const verdict = support >= 0.6 && numbersOk ? "supported" : "CHECK";
  console.log(
    `${verdict.padEnd(9)} ${Math.round(support * 100)}% words, numbers ${numbersOk ? "ok" : "differ"} | ${c.text} [${c.cite}]`,
  );
}
console.log(
  'Note: "always refunded" passes the word test but changes the meaning. Word checks are a first filter, not proof.',
);

Expected output:

supported 100% words, numbers ok | You can return a product within 30 days [S1]
supported 80% words, numbers ok | Shipping costs are always refunded [S2]
CHECK     20% words, numbers differ | Returns are free for 60 days [S1]
Note: "always refunded" passes the word test but changes the meaning. Word checks are a first filter, not proof.

Look at the second claim: it passes the word test, but "always" changes the meaning. Word checks catch most invented facts cheaply, but not every twist. Stronger checks use an entailment model, which judges whether text A supports text B, or a judge model that reads the source and the claim (Chapter 8).

Structured Output Citation Schemas

Ask the model for JSON like this:

{
  "answer": "You have 30 days to return it [S1].",
  "citations": [{ "id": "S1", "quote": "within 30 days of delivery" }]
}

Then check it with code: every id must exist, and every quote must appear word for word in that source. Many model APIs can force output to match a JSON schema, which makes parsing reliable.

Check structured citations

// Structured citations: ask for JSON, then check every quote appears word for word in its source.
const sources = {
  S1: "You can return any product within 30 days of delivery.",
  S2: "Refunds take 5 business days.",
};
const modelOutput = `{
  "answer": "You have 30 days to return it, and refunds take 5 business days.",
  "citations": [
    { "id": "S1", "quote": "within 30 days of delivery" },
    { "id": "S2", "quote": "Refunds take 5 business days" },
    { "id": "S3", "quote": "gift cards are refundable" }
  ]
}`;

const out = JSON.parse(modelOutput);
for (const c of out.citations) {
  const src = sources[c.id];
  const status = !src
    ? "id does not exist"
    : src.includes(c.quote)
      ? "quote found"
      : "quote NOT in source";
  console.log(`${c.id}: ${status.padEnd(19)} "${c.quote}"`);
}

Expected output:

S1: quote found         "within 30 days of delivery"
S2: quote found         "Refunds take 5 business days"
S3: id does not exist   "gift cards are refundable"

Citation Quality Controls

Detecting Hallucinated Citation Numbers

A hallucinated citation points at a source that doesn't exist, like [S7] when there are three sources. It's easy to catch: compare every cited number with the number of sources. Treat it as a serious failure, because it suggests the whole sentence may be invented.

Preventing Unreferenced Claim Injection

Models like to add helpful extras: a friendly promise, a tip, a guess. Those unreferenced claims have no citation. Your checker should flag every sentence without a citation, except the exact refusal sentence. Tell the model in the rules that every sentence must be cited.

Multi-Source Attribution

Some sentences combine facts from two sources: "Returns are free within 30 days, and refunds take 5 business days [S1][S2]." Allow multiple citations per sentence. When checking, look for support in the union of the cited sources. Better still, encourage one fact per sentence, so each citation is precise.

Automated Citation Verification Scanners

Put it all together as a scanner that runs on every answer:

  1. Parse the citations.
  2. Check the ids exist.
  3. Check every sentence has a citation.
  4. Check word support and numbers.
  5. Decide: show, show with a warning, retry, or refuse.

Log every failure. Patterns in the log show which prompts, sources or questions cause trouble.

Show citations as clickable links in your UI, and open the exact section, not just the document. Users trust answers they can check, and they'll report wrong citations, which is free quality control.

Streaming Answers with Citations

Many chat applications display an answer gradually instead of waiting for the complete response. This process is called streaming.

During streaming, the answer arrives in small pieces called tokens. A token may be a word, part of a word, or a punctuation mark.

This can cause a problem with citations. For example, the citation [S1] may arrive in separate pieces: [ S 1]

If the application processes each piece immediately, it may briefly display an incomplete citation or create a broken source link.

To prevent this, the application uses a buffer. A buffer temporarily holds an unfinished citation until the closing bracket ] arrives.

After the complete citation is available, the application can:

Display it as a source link if the source ID is valid Show a warning if the source ID does not exist Keep incomplete citation text hidden from the user.

Buffer citations while streaming

// Streaming with citations: tokens arrive in pieces, so "[S" and "1]" can be split.
// Buffer until a citation is complete, then turn it into a link.
const tokens = [
  "Returns",
  " take 30",
  " days [",
  "S",
  "1]. Refunds",
  " take 5 days [S",
  "2]",
  " [S7].",
];
const valid = new Set(["S1", "S2"]);

let buffer = "";
const shown = [];
for (const t of tokens) {
  buffer += t;
  // Hold back anything after an unfinished "[".
  const open = buffer.lastIndexOf("[");
  const safeEnd = open >= 0 && !buffer.slice(open).includes("]") ? open : buffer.length;
  const ready = buffer.slice(0, safeEnd);
  buffer = buffer.slice(safeEnd);
  shown.push(
    ready.replace(/\[(S\d+)\]/g, (_m, id) =>
      valid.has(id) ? `<${id}>` : "<?bad citation?>",
    ),
  );
}
shown.push(buffer);
console.log(
  shown
    .filter(Boolean)
    .map((s) => JSON.stringify(s))
    .join(" + "),
);
console.log("final:", shown.join(""));

Expected output:

"Returns" + " take 30" + " days " + "<S1>. Refunds" + " take 5 days " + "<S2>" + " <?bad citation?>."
final: Returns take 30 days <S1>. Refunds take 5 days <S2> <?bad citation?>.

Run the full claim check after the stream ends. While streaming, you can only check ids. If the finished answer fails, show a clear warning next to it, or replace it, rather than silently leaving an unsupported answer on screen.

Try the claim check in the lab. With no model running, the lab's extractive answers copy source sentences, so they show as supported. With a model, try the loose prompt and watch for weak or uncited sentences:

Ask and read the claim check table

The same question with the loose prompt

Common mistakes

  • No id map, so citations can't be turned into links.
  • Citation checks that only test ids, missing claims the source doesn't support.
  • Ignoring numbers, which is where the most harmful mistakes happen.
  • Accepting uncited "helpful" sentences.
  • Checking only before streaming, never after the final text.

Exercises

Each exercise ends with a value on the last line. Press Check to compare it with the expected answer.

What to Remember

  • Every claim cites a short id (S1, S2), mapped in code to a chunk, section and page a person can open.
  • Citation-first: extract verbatim quotes, then answer only from them.
  • Verify claims: word support plus number matching, and stronger entailment or judge checks for high stakes.
  • Structured citations (JSON with id and quote) are easy to check: ids must exist, and quotes must appear word for word.
  • Catch fake citation numbers, uncited sentences and unsupported claims. Allow multi-source citations.
  • When streaming, buffer unfinished citations, and run the full check when the answer ends.

Next: Unanswerable Queries, knowing when not to answer.