RAG Citations: Does the Cited Source Actually Say That?
Objective
A RAG answer with [1] [2] markers looks trustworthy. Most citation checkers
only confirm that each [n] points to a document that exists. Few check
whether that document says what the sentence claims. In practice the
common failures are:
- Misattributed: the claim is true and one of the sources supports it, but the marker points to a different one.
- Contradicted: the cited source says the opposite.
- Unsupported: no retrieved source backs the sentence, but it still carries a citation that makes it look sourced.
This recipe checks every (sentence, cited source) pair with the NLI model
built into SynapCores. When the cited source doesn't support a sentence, the
same SQL query uses EMBED similarity to find a source that does. The output
is one row per citation: SUPPORTED, MISATTRIBUTED (with the correct
source), CONTRADICTED or UNSUPPORTED. It runs as one query in the engine
that holds the sources, with no second service.
Generating the citations in the first place is covered in RAG with Citations. Citation Integrity applies the same check to research papers.
Before you start: NLI is off by default
Without an enabled model, NLI calls fail with
Inference error: NLI is disabled; install a model and enable NLI.
synapcores --config /etc/synapcores/gateway.toml pull nli-default
synapcores --config /etc/synapcores/gateway.toml models enable-nli nli-default
# then restart the engine
On Docker, set AIDB_NLI_ENABLED=1 and AIDB_NLI_MODEL=nli-default.
GET /v1/ai/nli/models should report ready.
Step 1: The sources the answer may cite
These are the retrieved chunks, numbered the way the answer cites them:
CREATE TABLE IF NOT EXISTS recipe_citesupport_sources (
id INT PRIMARY KEY,
title TEXT,
body TEXT,
embedding VECTOR(384)
);
INSERT INTO recipe_citesupport_sources (id, title, body, embedding) VALUES
(1, 'Backup policy', 'Backups run every night at 02:00 UTC and are kept for 35 days.',
EMBED('Backups run every night at 02:00 UTC and are kept for 35 days.')),
(2, 'Plans and features', 'Point-in-time recovery is available on the Business plan and above.',
EMBED('Point-in-time recovery is available on the Business plan and above.')),
(3, 'Security overview', 'All customer data is encrypted at rest with AES-256.',
EMBED('All customer data is encrypted at rest with AES-256.')),
(4, 'Data residency', 'Customer data is stored in the EU (Frankfurt) region by default.',
EMBED('Customer data is stored in the EU (Frankfurt) region by default.'));
Step 2: The answer, one row per cited sentence
Parse the generated answer into (sentence, [n]) rows. If a sentence cites two
sources, it becomes two rows.
CREATE TABLE IF NOT EXISTS recipe_citesupport_claims (
id INT PRIMARY KEY,
sentence TEXT,
cited_source INT,
embedding VECTOR(384)
);
INSERT INTO recipe_citesupport_claims (id, sentence, cited_source, embedding) VALUES
(1, 'Backups are kept for 35 days.', 1,
EMBED('Backups are kept for 35 days.')),
(2, 'Your data is encrypted at rest.', 2,
EMBED('Your data is encrypted at rest.')),
(3, 'Point-in-time recovery is included on every plan.', 2,
EMBED('Point-in-time recovery is included on every plan.')),
(4, 'By default, customer data is stored in the United States.', 4,
EMBED('By default, customer data is stored in the United States.')),
(5, 'Backups are replicated to a second region.', 1,
EMBED('Backups are replicated to a second region.'));
Claim 1 is correct. Claim 2 is true but cites the wrong source; it should be
[3]. Claim 3 overstates source 2. Claim 4 reverses source 4. Claim 5 isn't
in any source.
Step 3: Check the cited source only, and see what it misses
The obvious check scores each sentence against the source it cites:
SELECT c.id, c.cited_source, c.sentence,
ROUND(COSINE_SIMILARITY(c.embedding, s.embedding), 3) AS cited_similarity,
ROUND(CAST(JSON_EXTRACT(NLI_CLASSIFY('nli-default', s.body, c.sentence),
'$.scores.entailment') AS DOUBLE), 3) AS cited_entailment,
ROUND(CAST(JSON_EXTRACT(NLI_CLASSIFY('nli-default', s.body, c.sentence),
'$.scores.contradiction') AS DOUBLE), 3) AS cited_contradiction
FROM recipe_citesupport_claims c
JOIN recipe_citesupport_sources s ON s.id = c.cited_source
ORDER BY c.id;
Observed on 2.3.1 with nli-default:
| id | cited | sentence | similarity | entailment | contradiction |
|---|---|---|---|---|---|
| 1 | 1 | Backups are kept for 35 days. | 0.870 | 0.995 | 0.000 |
| 2 | 2 | Your data is encrypted at rest. | 0.106 | 0.000 | 0.997 |
| 3 | 2 | Point-in-time recovery is included on every plan. | 0.836 | 0.004 | 0.003 |
| 4 | 4 | By default, customer data is stored in the United States. | 0.808 | 0.000 | 1.000 |
| 5 | 1 | Backups are replicated to a second region. | 0.546 | 0.001 | 0.001 |
Look at claim 2. On contradiction alone, it looks as bad as claim 4 (0.997). But its similarity to the cited source is 0.106: the two texts are about different topics. The NLI model scores pairs of sentences, and it often calls unrelated sentences a "contradiction". Claim 2 is a wrong pointer, not a false statement. Telling those apart needs the similarity score. It also needs a look at the other sources.
Claim 3 is an overclaim ("every plan" vs "Business and above"). The model reads it as neutral, not contradictory: entailment is missing, but nothing contradicts it. Flag on missing entailment, not only on contradiction.
Step 4: One query: classify each citation and suggest the correct source
SELECT c.id AS claim, c.sentence, c.cited_source,
CASE
WHEN chk.cited_ent > 0.6 THEN 'SUPPORTED'
WHEN chk.cited_sim > 0.45 AND chk.cited_con > 0.6 THEN 'CONTRADICTED'
WHEN alt.better_source IS NOT NULL THEN 'MISATTRIBUTED'
ELSE 'UNSUPPORTED'
END AS status,
alt.better_source
FROM recipe_citesupport_claims c
JOIN (
SELECT c2.id AS claim_id,
COSINE_SIMILARITY(c2.embedding, s.embedding) AS cited_sim,
CAST(JSON_EXTRACT(NLI_CLASSIFY('nli-default', s.body, c2.sentence),
'$.scores.entailment') AS DOUBLE) AS cited_ent,
CAST(JSON_EXTRACT(NLI_CLASSIFY('nli-default', s.body, c2.sentence),
'$.scores.contradiction') AS DOUBLE) AS cited_con
FROM recipe_citesupport_claims c2
JOIN recipe_citesupport_sources s ON s.id = c2.cited_source
) chk ON chk.claim_id = c.id
LEFT JOIN (
SELECT c3.id AS alt_claim_id, MIN(o.id) AS better_source
FROM recipe_citesupport_claims c3
CROSS JOIN recipe_citesupport_sources o
WHERE o.id <> c3.cited_source
AND COSINE_SIMILARITY(c3.embedding, o.embedding) > 0.45
AND CAST(JSON_EXTRACT(NLI_CLASSIFY('nli-default', o.body, c3.sentence),
'$.scores.entailment') AS DOUBLE) > 0.6
GROUP BY c3.id
) alt ON alt.alt_claim_id = c.id
ORDER BY c.id;
Observed:
| claim | sentence | cited_source | status | better_source |
|---|---|---|---|---|
| 1 | Backups are kept for 35 days. | 1 | SUPPORTED | |
| 2 | Your data is encrypted at rest. | 2 | MISATTRIBUTED | 3 |
| 3 | Point-in-time recovery is included on every plan. | 2 | UNSUPPORTED | |
| 4 | By default, customer data is stored in the United States. | 4 | CONTRADICTED | |
| 5 | Backups are replicated to a second region. | 1 | UNSUPPORTED |
All five are classified correctly. Claim 2 comes with its fix: source 3
entails it (similarity 0.629, entailment 0.930), so [2] should be [3].
CONTRADICTED is only assigned when the cited source is on topic (similarity
above 0.45), so an off-topic citation can't be mislabelled as a contradiction.
Two notes for 2.3.1:
- Give each derived table's join key a unique name (
claim_idandalt_claim_idabove). With the same alias in both, 2.3.1 silently joined theLEFT JOINon the wrong key, and every row showedbetter_source = 3. - Put similarity filters in
WHEREover aCROSS JOIN. InJOIN … ONthey returned no rows on that build.
Step 5: Turn the result into an action
| status | What to do |
|---|---|
SUPPORTED |
Keep the citation |
MISATTRIBUTED |
Rewrite [n] to better_source automatically, or show both |
CONTRADICTED |
Remove the sentence or regenerate it; never show it with the citation |
UNSUPPORTED |
Remove the citation marker and caveat the sentence, or drop it |
The thresholds come from the observed scores. Supported pairs entailed at 0.930 or higher, and the unsupported ones at 0.004 or lower. The off-topic citation had similarity 0.106, while the on-topic ones were 0.546 or higher. Re-run Step 3 on a sample of your own answers before fixing the numbers.
Cleanup (Optional)
DROP TABLE IF EXISTS recipe_citesupport_sources;
DROP TABLE IF EXISTS recipe_citesupport_claims;
Honest boundaries
- Support, not truth.
SUPPORTEDmeans the cited text entails the sentence. If the source itself is wrong, the check passes. - Chunk-level. The check reads the retrieved chunk, not the whole
document. A claim supported three paragraphs away will look
UNSUPPORTED, so cite at the chunk level you retrieve at. - One source per row. A sentence that combines facts from two sources ("kept 35 days and encrypted with AES-256") won't be entailed by either source alone. Split compound sentences before checking.
- Overclaims look neutral, not contradictory, and numbers aren't compared as numbers. Check quantities and dates exactly.
- English model. NLI is off by default, so enable it as described above.
Key Concepts Learned
- Checking that a citation exists isn't enough. The real question is whether the cited text entails the sentence (premise = source, hypothesis = sentence).
- A high contradiction score with low similarity means a wrong pointer, not a false claim. Using similarity and NLI together tells you which.
- One query checks the cited source and searches the other sources for a better one. That needs the embeddings, the NLI model and the join in the same engine.