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.

Repository Install API Known limits Open queue

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

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:

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

Not on PyPI yet. Publishing is queued; until it lands, install from the repository. Any page or README telling you 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

CallableSignatureReturns
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

VerdictStageMeaning
not_foundgateThe quote appears in no document — fabricated or stitched.
misattributedgateReal quote, wrong source.
supportsjudgeThe passage establishes the claim at full strength.
partialjudgeRelated and true of the source, but weaker than the claim.
unrelatedjudgeDoes not bear on the claim — also the fail-closed value.
contradictsjudgeThe 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.

LimitConsequenceIssue
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.