verbatim-citation-gate
Catch fabricated RAG citations before they reach the user — with a check that costs zero tokens and cannot be talked out of its answer.
Python ≥3.9 · zero dependencies for stage 1 · any model for stage 2 · MIT. Maintained by the Palo Alto AI Research Lab.
The problem
RAG systems love to cite. What matters is how they fail: a quote can read as authoritative and word-for-word and still be
- fabricated — a plausible sentence that appears in no source;
- a frankenquote — every word is real, but the sentence was stitched together and never written;
- misattributed — a real quote lifted from a different document.
A judge model asked “does this quote support this claim?” waves all three through: they read as fluent and supportive. The fix is to ask the cheaper, prior question first, deterministically.
The two stages
┌──────────────────────────┐
claim + quote → │ 1. verbatim gate (free) │ → not_found / misattributed (rejected, 0 tokens)
+ doc id └─────────────┬────────────┘
│ found
▼
┌──────────────────────────┐
│ 2. skeptical judge (LLM) │ → supports / partial / unrelated / contradicts
└──────────────────────────┘
Stage 1 — the verbatim gate
Pure re and substring matching over a normalized form (case, smart quotes, dashes and
whitespace folded; digits, . and % preserved, because 25% and
0.5 are load-bearing in quantitative claims). The match is contiguous, which is
what kills frankenquotes — a bag-of-words comparison would pass them. A real quote that lives in
another document is surfaced as misattributed instead of collapsing into
not_found, so that failure mode stays visible.
Stage 2 — the skeptical judge
For quotes that do exist, a burden-of-proof prompt decides whether the passage actually establishes the claim:
- Default-refute — the verdict starts at unsupported; ties break against the claim.
- Outside knowledge is inadmissible — a claim can be true in the world and still unsupported by this source.
- Full-strength support or nothing — same population, direction, magnitude and certainty, or the verdict caps at
partial(subgroup→everyone, correlation→causation, “may”→“does”).
If the judge's output does not parse — or the call raises — it fails closed: verdict
unrelated, confidence 0.0. An unverifiable citation never counts as support.
Install
pip install verbatim-citation-gate is ahead
of reality — tell us
if you find one we missed.pip install "git+https://github.com/Palo-Alto-AI-Research-Lab/verbatim-citation-gate"
# or, from a clone, with the test extra:
git clone https://github.com/Palo-Alto-AI-Research-Lab/verbatim-citation-gate
cd verbatim-citation-gate && pip install -e ".[test]" && pytest -q
Quickstart
from verbatim_citation_gate import audit_citation
docs = {"veltranib-rct": "... Body weight was unchanged in both arms."}
# Stage 1 only — no model, no key, fully offline:
audit_citation("Body weight did not change.", "veltranib-rct",
"Body weight was unchanged in both arms.", docs) # -> "found"
# Both stages — supply any model as llm_call(system, user) -> str:
def llm_call(system, user):
return client.messages.create( # Anthropic shown; any vendor works
model="claude-haiku-4-5", system=system, max_tokens=1024,
messages=[{"role": "user", "content": user}],
).content[0].text
audit_citation("Body weight did not change.", "veltranib-rct",
"Body weight was unchanged in both arms.", docs, llm_call=llm_call)
# -> "supports" | "partial" | "unrelated" | "contradicts" | "not_found" | "misattributed"
llm_call is deliberately the smallest possible contract —
(system, user) → text — so the judge wires to Claude, GPT, Gemini, Mistral, Cohere or a
local Qwen without adapters. One-liners for several vendors are in
examples/quickstart.py.
API
| Callable | Signature | Returns |
|---|---|---|
audit_citation |
(claim, doc_id, quote, docs, llm_call=None) |
Full two-stage audit. Gate first; the judge is only reached by quotes that exist. With no
llm_call, returns the gate result — the deterministic half is usable entirely offline. |
quote_gate |
(quote, cited_doc_id, docs) |
found · misattributed · not_found. Fails closed: an empty
quote or unknown doc_id yields not_found rather than raising. |
judge_support |
(claim, quote, source, llm_call) |
A Verdict (reasoning, label, confidence). Any exception or unparseable output →
unrelated / 0.0. |
normalize |
(text) |
The matching form used by the gate. Exported so you can reproduce a verdict by hand. |
Verdicts
| Verdict | Stage | Meaning |
|---|---|---|
not_found | gate | The quote appears in no document — fabricated or stitched. |
misattributed | gate | Real quote, wrong source. |
supports | judge | The passage establishes the claim at full strength. |
partial | judge | Related and true of the source, but weaker than the claim. |
unrelated | judge | Does not bear on the claim — also the fail-closed value. |
contradicts | judge | The source says the opposite. |
Why the gate goes first
The number you page on for a citation auditor is recall of unfaithful citations — the fraction of bad citations you actually catch. The gate lifts that recall for free on the three failure modes a judge is worst at, because those are exactly the ones that look supportive. What reaches the model is only the genuinely ambiguous case: a real quote whose sufficiency is a judgment call.
Known limits — read before you trust it
Every one of these is an open issue with a reproduction, not a caveat we hope you skip.
| Limit | Consequence | Issue |
|---|---|---|
Non-Latin scripts. normalize() keeps only [a-z0-9%.], so Cyrillic, CJK, Greek and Arabic text is stripped to its digits. |
🔴 In those scripts a fabricated quote can be graded found. Do not run this as your only gate on non-Latin corpora until the fix lands. |
#1 |
| Re-normalization on every call. The corpus is normalized per invocation instead of once. | Measured 2.4 s per miss on a 300-document corpus. Correct, but not a hot path yet. | #2 |
No py.typed marker. |
Downstream type checkers ignore the annotations that are already there. | #3 |
| One-word quotes. A single common word matches almost any document. | found on evidence too thin to mean anything; a minimum-evidence rule is needed. |
#4 |
Scope. The gate presumes quote-style citations. If your pipeline cites by document id or character offsets, resolve the span to its source text first, then gate that text.
Contributing — there is a queue, and it is real
Issues labelled
accepted
are scoped, reproduced, and nobody is on them. Comment “claiming this” — no permission needed —
and it is yours for 7 days.
You keep the copyright to your code. No CLA, no assignment, ever; your contribution goes in under this repo's existing MIT license, the same terms as ours. We answer every issue and PR within 48 hours, including “no, and here is why” — our silence is our bug, so ping the thread. Full deal: CONTRIBUTING.md.
Built by
A human + AI team, and the git log says so: Claude writes most of the code, Codex and Grok review it, Gemini feeds the research — each credited on a commit only when its output changed that commit. Policy: AI-CONTRIBUTORS.md.