DEV Community

Cover image for How to Make RAG Show Its Evidence: Claim Verification at 15M-Document Scale
Rudratosh Shastri
Rudratosh Shastri

Posted on Edited on

How to Make RAG Show Its Evidence: Claim Verification at 15M-Document Scale

Stanford's enterprise legal RAG failure stats

Your retrieval demo is beautiful. You dropped a thousand PDFs into a vector database, wired up an embedding model, and now it answers questions with uncanny confidence. You ship it. Everyone's impressed.

Then the corpus grows. Ten thousand documents. A hundred thousand. A million. Fifteen million.

Nothing crashes. No error in the logs. The latency is fine. The answers still arrive, still confident, still well-written.

They're just wrong more often. And you can't tell which ones.

That's the trap of RAG at scale: it doesn't fail loudly, it fails plausibly. The bigger your corpus, the more ways the system has to retrieve a chunk that looks right and build a fluent, cited-sounding answer on top of it. The failure mode isn't a stack trace. It's a sentence that sounds true.

Here's the part people don't want to hear: this isn't hypothetical, and it isn't solved by a bigger model. In 2025, Stanford's RegLab tested commercial, enterprise-grade legal RAG tools — the paid products built specifically to be trustworthy — and found they still hallucinated in roughly 17–33% of cases (Magesh et al., Journal of Empirical Legal Studies, 2025). One in three to one in six answers, wrong, in a domain where wrong gets people sued. (That number is specific to those legal products and the tested tasks — read it as how hard this is, not a universal failure rate every RAG system will hit.)

RAG reduces hallucination. It does not eliminate it. And at 15M+ documents, the margin gets thinner unless you change the architecture — not the model.

This is the pipeline I'd build for 15 million documents and near-zero confident hallucination. The trick isn't a magic retriever. It's four gates that each catch a different lie, and a system that would genuinely rather abstain than make something up.

To be clear up front, so nobody feels sold: the retrieval and scale pieces are an architecture grounded in published research — not a product I've benchmarked at 15M documents. Every number I quote for those is from a cited paper, flagged as such, and the code is illustrative scaffolding, not a drop-in library. But the one claim I actually care about — that checking support and allowing abstention cuts confident-but-wrong answers — I tested myself on a small labeled set. The measured result, and its limits, is near the end.

End-to-end evidence-controlled RAG architecture: corpus prep and index feeding the four gates Retrieve, Constrain, Verify, Abstain, with an evaluation sidebar
The whole pipeline at a glance: corpus prep → index → **Retrieve · Constrain · Verify · Abstain, with the metrics you evaluate it on. The rest of the article unpacks each gate — and the goal is to reduce confident, unsupported claims, not to promise zero errors.

What "near-zero" honestly means. It's a design objective, not a guarantee. Not zero errors — the goal is that zero confident, unsupported claims reach the user, because the system converts what would have been a hallucination into an honest "I couldn't verify this." Some will still slip past an imperfect verifier; the aim is to make that rare, bounded, and observable — not to promise it never happens. That reframe is the whole article.


First, why does RAG hallucinate at all?

If you only remember one thing, make it this: a RAG system hallucinates along three common failure paths — the three this architecture is built to catch — and scaling the corpus makes all three worse. (They aren't the whole taxonomy: a retrieved source can also be outdated, poisoned, mis-parsed, or mis-attributed. Those are real, and they're separate controls — source trust and provenance — which I come back to in "where it breaks.")

Three panels: wrong evidence retrieved, evidence ignored, and no support but an answer
The three ways an evidence-grounded answer fails — a similar chunk wins, available evidence goes unused, or missing evidence becomes a guess.

1. It retrieved the wrong evidence.
The answer exists in your corpus, but the retriever pulled a chunk that's similar to the question instead of one that answers it. At 1,000 docs there might be one chunk about "Q2 revenue." At 15 million there are forty thousand chunks that mention revenue, hundreds of near-identical boilerplate paragraphs, and a dozen that look perfect and say the wrong number. More documents means more plausible decoys.

2. It retrieved the right evidence and ignored it.
The correct chunk is sitting in the context window, and the model answers from its own parametric memory anyway — or the chunk got buried in the middle of a long context and the model's attention skated right past it. This is the famous lost in the middle effect (Liu et al., 2023): models reliably use information at the start and end of their context and quietly drop what's in the middle. Stuff 50 chunks in and the answer in position 26 may as well not be there.

3. It retrieved nothing useful and answered anyway.
This is the real hallucination. There is no supporting evidence in the top-k, and instead of saying so, the model does what it was trained to do: produce a fluent, helpful-sounding completion. It fills the gap. In a 15M corpus, plenty of legitimate questions simply have no good answer in the data — and a system with no "I don't know" has no choice but to invent one.

Every technique below exists to kill one of these three. If you can't name which failure a component targets, you're cargo-culting complexity.


The principle: you can't prompt your way out

Before the pipeline, the mindset. "Please only answer using the provided context and don't make things up" is a wish, not a control. It helps a little and fails exactly when you need it most — when the context is wrong, incomplete, or buried.

Near-zero hallucination is a pipeline property, not a prompt. Four gates, in order (the architecture diagram up top is the map):

  1. Retrieve — get the right evidence into the context, and be able to prove it's there.
  2. Constrain — force the answer to cite that evidence, claim by claim. No citation, no sentence.
  3. Verify — independently check that every claim is actually supported by what it cites.
  4. Abstain — when support is weak, say so, in calibrated language, instead of guessing.

Retrieve fixes failure #1. Constrain fixes #2. Verify and abstain, together, fix #3 — the dangerous one. Let's build each.


Gate 1 — Retrieve the right evidence (and prove it)

Retrieval is where most of your accuracy is won or lost, and it's the stage that breaks hardest at 15M. Four moves matter.

Deduplicate before you embed — or your index lies to you

At scale, your corpus is full of near-duplicates: the same disclaimer on 200,000 pages, boilerplate repeated across contract versions, articles syndicated to six sites. If you embed all of them, three bad things happen: your index bloats (and so does your bill), retrieval returns ten copies of the same chunk and crowds out diversity, and relevance scores get distorted by frequency.

The standard fix is MinHash + LSH (locality-sensitive hashing): hash each document into a signature, bucket similar signatures, and collapse near-duplicates before they ever reach the embedder. It's cheap, it's old, it's boringly effective, and at 15M docs it's usually the difference between a clean index and a hall of mirrors. Whether you need it, and how aggressive to be, depends on how duplicated your corpus actually is — measure your near-duplicate rate first, and keep version/provenance so dedup doesn't silently merge two documents that genuinely differ.

# Dedup before embedding — MinHash + LSH (datasketch). Streams; never holds 15M in RAM.
from datasketch import MinHash, MinHashLSH

def signature(text, num_perm=128):
    m = MinHash(num_perm=num_perm)
    for token in set(text.lower().split()):      # or k-gram shingles for finer dedup
        m.update(token.encode())
    return m

def dedup(docs, threshold=0.9):
    lsh, kept = MinHashLSH(threshold=threshold, num_perm=128), {}
    for doc_id, text in docs:
        sig = signature(text)
        if lsh.query(sig):                        # a near-duplicate is already kept
            continue
        lsh.insert(doc_id, sig)
        kept[doc_id] = text
    return kept                                   # near-dups collapsed pre-embedding
Enter fullscreen mode Exit fullscreen mode

Chunk with structure, then give every chunk its context back

Naive chunking cuts your documents into 500-token windows and destroys meaning at the seams. A chunk that reads "Revenue grew 3% quarter over quarter" is useless on its own — whose revenue? which quarter? The embedding has no idea, because the sentence was torn out of its document.

The fix that actually moves numbers is contextual retrieval: before embedding each chunk, prepend a short, machine-generated sentence that situates it in its parent document. "This chunk is from ACME Corp's Q2 2026 10-Q, in the section on cloud revenue: Revenue grew 3%..." Now the vector and the keyword index both carry the context.

# Prepend a one-line situating context, and KEEP the source id for later verification
def contextualize(chunk, doc_meta, llm):
    prefix = llm(
        f"In one sentence, situate this passage in its document "
        f"(title: {doc_meta['title']}, section: {doc_meta['section']}).\n\n{chunk.text}"
    )
    return {
        "id": chunk.id,                               # provenance → rides into the citation
        "doc_id": doc_meta["doc_id"],
        "embed_text": f"{prefix}\n\n{chunk.text}",    # what you embed AND BM25-index
        "source_text": chunk.text,                    # what Gate 3 later checks the claim against
    }
# tip: prompt-cache the full document so the per-chunk context call stays cheap.
Enter fullscreen mode Exit fullscreen mode

Before: a bare
Context makes a chunk retrievable; the preserved source ID and document provenance make it verifiable later.

This is the one architecture choice with clean, published proof. Anthropic measured the share of queries where the right chunk wasn't in the top 20 (Contextual Retrieval, Sept 2024):

Configuration Top-20 failure rate Reduction
Baseline (naive chunks) 5.7% —
+ Contextual embeddings 3.7% 35%
+ Contextual BM25 2.9% 49%
+ Reranking 1.9% 67%

A preprocessing step that turns a 5.7% miss rate into 1.9% is one of the best returns in the whole stack. Honest caveat: those are the best-performing embedding configuration in Anthropic's test, not an average across every model — treat it as "defaults are leaving a lot on the table," not "1.9% guaranteed on your data."

Hybrid search: meaning and the literal token

Dense vector search is brilliant at meaning — it knows "cancel my plan" ≈ "terminate subscription." It's terrible at exact strings: error code E-4021, a rare surname, a part number, a statute citation. Those are the queries where a semantic embedding smooths away the one token that matters.

So run both. Dense (vector) search for meaning, sparse (BM25) search for the literal token, and fuse the two rankings with Reciprocal Rank Fusion (RRF) — a dead-simple formula that scores each document by summing 1 / (k + rank) across the lists it appears in:

# RRF: robust rank fusion, no tuning or training. k=60 is a common default — tune it.
def rrf(dense_ranking, sparse_ranking, k=60):
    scores = {}
    for ranking in (dense_ranking, sparse_ranking):
        for rank, doc_id in enumerate(ranking, start=1):   # 1-based ranks (standard RRF)
            scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank)
    return sorted(scores, key=scores.get, reverse=True)
Enter fullscreen mode Exit fullscreen mode

Dense catches the paraphrase. BM25 catches the exact code. RRF lets a document that both rank reasonably well float to the top. This single change kills a huge share of "retrieved the wrong evidence" failures at scale.

def hybrid_retrieve(query, dense_index, bm25_index, n=150):
    dense  = dense_index.search(embed(query), k=n)    # semantic similarity
    sparse = bm25_index.search(query, k=n)            # exact tokens / codes / names
    fused  = rrf([d.id for d in dense], [s.id for s in sparse])
    return [load_chunk(i) for i in fused[:n]]         # ~150 candidates, over-fetched
Enter fullscreen mode Exit fullscreen mode

Over-fetch, then rerank down

Your first-pass retrieval is tuned for speed over a 15M index, so it's approximate — it casts a wide, slightly sloppy net. So cast it wide on purpose: pull ~150 candidates, then hand them to a cross-encoder reranker that reads the query and each chunk together and re-scores them, keeping the best ~20.

The reranker is slow and precise where retrieval is fast and fuzzy. Running it on 150 candidates (not 15 million) is affordable, and it's the step that earns the last chunk of that 5.7%→1.9% drop in the table above.

from sentence_transformers import CrossEncoder
reranker = CrossEncoder("BAAI/bge-reranker-v2-m3")    # reads (query, chunk) together

def rerank(query, candidates, keep=20):
    scores = reranker.predict([(query, c["source_text"]) for c in candidates])
    ranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)
    return [c for c, _ in ranked[:keep]]              # 150 -> 20 (tune on your eval set)
Enter fullscreen mode Exit fullscreen mode

Retrieval pipeline: a query runs dense + BM25 search, fused by RRF into ~150 candidates, reranked by a cross-encoder down to ~20 passages with source IDs
Retrieve broadly, rerank narrowly. The candidate counts are illustrative — tune them on your own labeled set. Reranking surfaces promising evidence; it does not prove a claim is supported.

The 15M scale note nobody mentions. Fifteen million vectors at 1024 float32 dimensions is ~61 GB of raw vectors — before the index overhead. You will not hold that in RAM naively. Two levers: an on-disk ANN index (HNSW or IVF-PQ) so the index lives on SSD, and embedding quantization (float32 → int8, or even binary) which cuts memory 4–32× for a recall hit you must measure on your own corpus and embedding model — it's often small for int8, but "small" is not a given, and binary quantization in particular can cost real recall. Scale is a memory-layout problem as much as a modeling one.

And keep three kinds of evidence separate, because they get conflated constantly: an index benchmark (ANN latency/recall on 15M synthetic vectors) is not the same as end-to-end retrieval recall on your real, messy documents, which is not the same as extrapolated capacity. A synthetic index proves the search scales; it says nothing about whether the right chunk actually comes back on your corpus. And mind the units: 15 million documents is not 15 million *vectors* — one document becomes dozens of chunks, so a 15M-document corpus is hundreds of millions of vectors. The 61 GB figure above is for 15M vectors; state documents, chunks, and vectors separately, and treat any specific index size as a target configuration unless you've actually built and measured it.

Which vector store survives 15 million vectors?

"Use a vector database" is where most tutorials wave their hands and name whatever they happened to use. At 15M the choice is a real engineering decision — on-disk vs in-memory, quantization support, and whether sparse (BM25) lives in the same system or a second one you have to keep in sync. My honest read of the landscape:

Store At 15M, the real consideration
Qdrant Rust, HNSW, first-class on-disk payload+vectors and scalar/binary quantization built in; native sparse vectors so hybrid lives in one system. My default for self-hosted at this scale.
Milvus Built for billions; IVF-PQ/DiskANN, horizontal sharding. Most powerful, heaviest to operate. My rough heuristic: worth the ops weight once you're well into the tens of millions of vectors, overkill for a few million — but it genuinely depends on dimensions, filters, latency targets and recall needs.
Weaviate Hybrid (dense + BM25) built in, good DX; watch RAM unless you enable its on-disk/compression modes.
pgvector (Postgres) If your data is already in Postgres, HNSW + halfvec/quantization gets you surprisingly far and you keep transactions and joins. Fine to low tens of millions with tuning.
FAISS A library, not a server — unbeatable for raw ANN experiments, but you build persistence, filtering and sparse yourself. Great under something else, painful as the whole store.
LanceDB Embedded, on-disk columnar, zero-ops to start. Lovely for a single-node build; you'll outgrow it when you need replication/sharding.

There's no single right answer — the point is that at 15M, "dense + sparse in one system, on disk, with quantization" is the requirement, and you pick the store that gives you those three without a second sync pipeline. I reach for Qdrant self-hosted; a Postgres shop should try pgvector before adding infrastructure.


Gate 2 — Constrain generation: cite, or don't speak

You've got 20 excellent chunks in context. Failure #2 says the model might still ignore them. Gate 2 makes ignoring them structurally hard.

Make citations mandatory, per claim

Don't ask the model to "use the context." Require it to attach a citation — a chunk ID — to every factual sentence it writes, and instruct it that any claim it cannot cite must be dropped or flagged as uncertain. Force the output into a shape like:

{
  "answer": [
    {"claim": "ACME's Q2 2026 cloud revenue grew 3% QoQ.", "cites": ["chunk_8842"]},
    {"claim": "Growth was driven by enterprise seat expansion.", "cites": ["chunk_8847","chunk_8851"]}
  ],
  "unsupported": []
}
Enter fullscreen mode Exit fullscreen mode

This does two things. It drags the model's attention onto the evidence (it has to point at a chunk for each sentence), and it gives Gate 3 something concrete to check. A claim with no citation is a claim the model made up — and now you can see it, instead of it hiding inside a fluent paragraph.

One guard you must not skip: the model can emit perfectly-shaped JSON citing a chunk id it never actually retrieved. Right shape ≠ real citation. Validate every cited id against the retrieved set server-side, before the claim is trusted — a citation is only real if it points at a chunk you actually have in hand.

# A citation is real only if it points at a retrieved chunk. Never trust the JSON shape.
def validate_citations(answer, retrieved_ids):
    valid = set(retrieved_ids)
    for item in answer["answer"]:
        item["cites"] = [cid for cid in item["cites"] if cid in valid]
    # a claim whose citations were all fabricated has nothing left — drop it
    answer["answer"] = [it for it in answer["answer"] if it["cites"]]
    return answer
Enter fullscreen mode Exit fullscreen mode
CITED_SYSTEM = """Answer ONLY from the numbered context below.
Return JSON: {"answer": [{"claim": "...", "cites": ["<chunk_id>"]}], "unsupported": []}
Every claim must cite a chunk id that FULLY states it. If you cannot support a
sentence from the context, do not write it — name the topic in "unsupported" instead.
Never cite a chunk that does not state the claim."""

def generate_cited(question, chunks, llm):
    context = "\n\n".join(f"[{c['id']}] {c['source_text']}" for c in chunks)
    raw = llm(system=CITED_SYSTEM, user=f"{context}\n\nQuestion: {question}")
    return json.loads(raw)        # {"answer": [{claim, cites}], "unsupported": [...]}
Enter fullscreen mode Exit fullscreen mode

Beat "lost in the middle" with order and brevity

Remember failure #2: the right chunk gets buried mid-context. Two defenses:

  • Keep k small. This is why you reranked 150 down to 20 (and often down to 5–8 for the final prompt). Fewer, better chunks means less middle to get lost in.
  • Order for attention. Place the highest-scoring chunks at the start and end of the context, not the middle, because that's where models actually read (Liu et al., 2023).

A qualitative U-shaped curve: evidence-use effectiveness is higher at the start and end of the context and dips in the middle
A qualitative pattern, not a law — the effect varies by model, task, and context length. Treat evidence ordering as something to test on your target model, not a fixed rule.

Honest nuance: a 2025 replication found the lost-in-the-middle effect isn't universal — it weakens as inputs approach the model's full context window, and long-context studies don't always reproduce it (arXiv:2508.07479). So treat ordering as cheap insurance, not a law. The free win is still "fewer, better chunks."

Route and decompose before you retrieve

One retrieval pass can't serve every question. "What's ACME's refund policy?" is a lookup. "How did ACME's margins change across the last three quarters and why?" is a multi-hop question that no single chunk answers.

So classify the question first, then:

  • Simple lookup → one hybrid retrieval, answer.
  • Multi-hop / comparative → decompose into sub-questions, retrieve evidence for each separately, then synthesize. Each sub-answer carries its own citations.

Decomposition directly prevents a nasty failure: answering a three-part question from one chunk that happened to rank first. Give each part its own evidence or don't claim it.


Gate 3 — Verify: grade the claims, not the vibes

Here's the uncomfortable truth about Gate 2: a model told to cite will sometimes cite a chunk that doesn't actually support the claim. It'll attach chunk_8842 to a sentence that chunk never said. Citations are necessary, not sufficient. You have to check them.

Three generated claims checked against their cited passages: one supported, one not entailed (quarterly vs year-over-year), one unsupported; unsupported claims are removed or trigger abstention
Citations are necessary, not sufficient — a claim can cite a real passage that doesn't actually entail it. Check each claim against what it points to; keep the supported ones, drop or abstain on the rest.

The move is claim-level faithfulness verification:

  1. Decompose the generated answer into atomic claims (one assertion each).
  2. For each claim, pull the chunks it cited.
  3. Ask a verifier: is this claim entailed by these chunks? (an NLI model, or an LLM-as-judge prompted narrowly for entailment, not helpfulness).
  4. A claim that fails entailment is unsupported — strip it, or send the whole answer to the abstain gate.
# Check each claim against ONLY the chunks it cited — entailment, not helpfulness
NLI = ("Does the EVIDENCE fully entail the CLAIM? "
       "Reply SUPPORTED, NOT_ENTAILED, or CONTRADICTED.\nCLAIM: {c}\nEVIDENCE: {e}")

def verify(answer, chunks_by_id, judge):
    kept, rejected = [], []
    for item in answer["answer"]:
        evidence = "\n".join(chunks_by_id[cid]["source_text"] for cid in item["cites"])
        verdict = judge(NLI.format(c=item["claim"], e=evidence)).strip()
        (kept if verdict == "SUPPORTED" else rejected).append(item)
    return kept, rejected          # rejected → stripped, or trigger abstention (Gate 4)
Enter fullscreen mode Exit fullscreen mode

Why atomic and claim-level? Because answer-level scores hide per-claim lies. An answer with nine supported claims and one fabricated one scores 0.9 on a naive groundedness metric and ships the fabrication. Grade each claim and the fabrication has nowhere to hide. This is exactly the direction the measurement research has moved — the RAGAS framework formalized faithfulness as a first-class metric (Es et al., 2023), and 2025 work like Vectara's FaithJudge builds leaderboards specifically for detecting unsupported content (arXiv:2505.04847).

Keep yourself honest: the verifier is itself a model, and FaithJudge's own finding is that detecting unsupported content is still hard — judges miss things. Verification reduces hallucination that reaches the user; it doesn't zero it. Anyone selling you "100% faithful" is selling you the same overconfidence you're trying to remove.

This is also why the Self-RAG and Corrective-RAG lines of work exist — they bake the check into the loop. Self-RAG (Asai et al., 2023) trains the model to emit reflection tokens that judge relevance and support as it generates. Corrective RAG / CRAG (Yan et al., 2024) puts a lightweight evaluator outside the model that scores retrieved docs and, on a bad score, triggers a fallback (like a web search) instead of plowing ahead. Both are the same instinct as Gate 3: don't trust the first draft of the evidence.


Gate 4 — Abstain: the feature everyone treats as a bug

This is the gate that actually gets you to near-zero, and it's the one most teams refuse to build because it feels like admitting defeat.

When retrieval confidence is low, or Gate 3 strips too many claims, or the remaining support is thin — the system says so. "I don't have enough reliable information to answer that." Not a guess dressed as an answer. An honest, calibrated abstention.

The right way to think about this is a 2×2 that keeps two different questions apart: is there sufficient evidence? (answerable vs unanswerable) and did the system answer? (answer vs abstain). Those are not the same axis, and conflating them is how people fool themselves.

A 2x2 matrix of answerable/unanswerable question against system answers/abstains: useful answer, missed opportunity, the dangerous unsupported answer, and safe abstention
Separate "is there evidence?" from "did it answer?" The dangerous cell is unanswerable × answered — a confident unsupported answer. Abstaining when evidence exists is only a missed opportunity; abstaining when it doesn't is a safe refusal. Measure coverage and risk together.

The naive version is a similarity threshold ("if top chunk score < 0.7, abstain"). It works a little and is impossible to tune across question types. The rigorous version is calibrated, risk-controlled abstention:

  • Conformal abstention (Abbasi-Yadkori et al., NeurIPS 2024 workshop) samples several answers, measures how much the model agrees with itself, and uses conformal prediction to control the error rate. But read the guarantee precisely: it holds under its sampling assumptions and a defined calibration set, and it bounds the rate of the specific quantity you calibrated on — it is not a switch that magically pins "any real-world hallucination rate" to a number you type in. It's the rigorous target to aim at, with conditions, not a free guarantee.
  • The generalization everyone is reaching for is to fuse several uncertainty signals — sequence likelihood, self-consistency across samples, retrieval score, and verifier feedback — into one calibrated probability of correctness, and refuse when it can't clear the budget. This is an active, fast-moving research area in 2025–26, so treat specific framework names as preprints-in-flux and lean on the conformal result above as the load-bearing proof.

The engineering version you can ship today: combine (a) retrieval score, (b) fraction of claims that survived Gate 3, and (c) answer self-consistency across 2–3 samples, into a single confidence, and abstain below a threshold you calibrate on a labeled held-out set so the threshold means something. Be honest about what this is: calibrated thresholding is what you actually ship; the conformal machinery above is the rigorous upgrade once you have the calibration data and are willing to defend its assumptions. Don't call a tuned threshold a "guarantee."

def decide(kept, rejected, retrieval_score, samples, tau):
    # tau is CALIBRATED on a labeled held-out set — not guessed
    support_ratio = len(kept) / max(1, len(kept) + len(rejected))
    consistency   = agreement(samples)            # do 2-3 resampled answers agree?
    confidence = 0.4*retrieval_score + 0.4*support_ratio + 0.2*consistency
    if not kept or confidence < tau:
        return {"action": "abstain",
                "message": "I couldn't verify this from the available sources."}
    return {"action": "answer", "claims": kept}
Enter fullscreen mode Exit fullscreen mode

Here's the reframe that makes abstention worth it:

In a consumer search toy, a wrong answer is annoying. In law, medicine, or finance, a confident wrong answer is the catastrophe — it's the 17–33% that got Stanford's attention. An abstention, by contrast, is just a routing decision: escalate to a human, trigger a deeper search, or tell the user honestly. An abstention is a lead. A confident lie is a liability.

A RAG system that can't say "I don't know" isn't trustworthy at any scale. It's just lucky until it isn't.

One product trap that quietly erases this whole gate (a reader caught me under-stating it): an abstention is only safe if nothing downstream treats it as a retryable error. Add an innocent "retry-on-empty" loop around the system and "I couldn't verify this" turns back into "keep rolling the dice until you get an answer" — the abstain gate is fully intact and completely defeated at the same time, and it never shows up in your model metrics. The gate is a contract with the whole call stack, not just the model: whoever consumes an abstention has to be allowed to accept it.


The whole pipeline, and what each gate catches

Gate Component The failure it kills
Retrieve Dedup (MinHash/LSH), contextual chunking, hybrid + RRF, rerank 150→20 Wrong evidence retrieved
Constrain Per-claim citations, small ordered context, route + decompose Right evidence ignored / buried
Verify Atomic claim decomposition + entailment check Claims cited to chunks that don't support them
Abstain Calibrated/conformal confidence → honest "I don't know" No evidence, answered anyway

Run a question through all four and what should reach the user are claims that were retrieved, cited, and checked — or an honest abstention. The would-be hallucinations are meant to be caught and converted into "I couldn't verify this." The honest asterisk: the verifier is a model, so some unsupported claims still slip through and some supported ones get wrongly rejected. That residual is exactly the thing you measure (next section) — not a number you get to assume is zero.

That's the aim: not zero mistakes, but driving confident, unsupported claims toward zero, with the leftover rate measured and reported rather than promised.


Prove it — the evaluation you actually run

A pipeline that claims "near-zero hallucination" with no measurement is just a nicer-sounding hallucination. Measure four things, on a labeled eval set built from your corpus:

  1. Retrieval failure rate @k — share of questions where the gold chunk isn't in the top-k. This is the number contextual retrieval moves. Track it before and after every retrieval change.
  2. Claim support — and measure it two different ways, because they're not the same thing. Context faithfulness (e.g. RAGAS faithfulness): is each claim supported by the retrieved context as a whole? Citation entailment (what Gate 3 actually does): does the specific passage the claim cites support it? A claim can be context-faithful — true given everything retrieved — yet cite the wrong chunk, which is a real defect a context-only score misses. Measure both, and grade claims, never whole answers.
  3. Context precision / recall — did the right chunks get retrieved (recall) and were they near the top (precision)?
  4. Abstention calibration — the one everyone skips. When the system answers, how often is it right? When it abstains, how often would it have been wrong? A good system abstains almost exactly on the questions it would have botched. If it abstains on easy questions or answers the hard ones confidently, your threshold is miscalibrated.

And two disciplines that separate a credible writeup from a nice-sounding one:

Run it as an ablation, not a single score. One number ("0.9 faithfulness") tells you nothing about which gate earned it. Measure the same eval set three ways — (1) baseline RAG, (2) + claim verification, (3) + abstention — holding the corpus, questions, generator, and criteria constant. For each, report answer coverage (what fraction it chose to answer) alongside answer risk (error rate among the answers it gave). This matters because low risk achieved by refusing almost everything is worthless: coverage and risk are only meaningful together. That ablation, not a diagram, is what proves a control actually did something.

def coverage_and_risk(results):
    # results: [{"answered": bool, "correct": bool | None}]  (correct=None when abstained)
    answered = [r for r in results if r["answered"]]
    coverage = len(answered) / len(results)                        # how much it answered
    risk = sum(not r["correct"] for r in answered) / max(1, len(answered))  # error rate among answers
    return coverage, risk          # report BOTH — low risk at 3% coverage is worthless
Enter fullscreen mode Exit fullscreen mode

Measure the verifier itself, not just the system. Your Gate-3 checker is a model with its own error rate, and a single AUROC doesn't certify anything. Report its precision/recall at the threshold you actually deploy, its false-accept rate (unsupported claims it waved through) and false-reject rate (supported claims it killed), and hand-review a sample of its calls. Crucially, test it on realistic failures — subtly-wrong, plausibly-cited claims — not just cartoon false premises like "which language did Newton invent." The easy cases flatter the verifier; the subtle ones are the ones that ship.

The sample-size trap. "Zero hallucinations in my 20 test questions" is not evidence of near-zero. To put a 95% one-sided upper bound below ~2% you need on the order of 150 clean cases with zero failures — and more the moment one failure appears. Report the interval, not the point estimate.


I ran the one claim that matters — verify + abstain, measured

The full architecture is a proposal. But its core bet — that checking support and letting the system abstain actually cuts confident-but-wrong answers — is testable without a 15M-document cluster. So I tested it.

Setup (deliberately small and scoped). SQuAD v2 is the right dataset because it uniquely ships both answerable questions (the answer is in the passage) and unanswerable ones (it isn't) — exactly the labels you need to measure abstention. I used each question's gold passage as the "retrieved evidence" (this isolates the verify/abstain layer from retrieval, on purpose), an 81M-param extractive QA model (tinyroberta-squad2) as both generator and verifier via its no-answer score, a calibration split to freeze the abstention threshold, and a held-out test set of 1,200 questions (585 unanswerable / 615 answerable, seed 42).

Config Coverage Answer risk Unsupported-answer rate (unanswerable only)
Baseline — always answer 100% 52.4% 100% (585/585)
+ Verify / abstain (default) 54.3% 20.6% 18.3%
+ Calibrated abstention 46.8% 15.3% 11.6%

The trade, in numbers: turning "always answer" into "verify, then abstain when unsupported" drops the unsupported-answer rate on unanswerable questions from 100% → 11.6% — an ~88-point fall with non-overlapping 95% CIs — while sacrificing ~20 points of coverage on genuinely answerable questions (100% → 80.3%). That is the whole thesis, measured: you buy a large drop in confident fabrication with a real, bounded loss of coverage.

Two honest findings, not one. Most of the win comes from having any verify/abstain layer at all (unsupported 100% → 18.3%). Calibration on top is a modest incremental gain (18.3% → 11.6%; overall risk 20.6% → 15.3%), bought with coverage on answerable questions (88.5% → 80.3%). I report it as incremental, because it is.

What this does and doesn't show. It demonstrates the mechanism — verification + abstention reduces unsupported answers at a measurable coverage cost — on a small, single-model, single-dataset, gold-context setup. It does not show end-to-end quality at 15M documents; it uses an extractive QA model as a stand-in for a generative one; the "verifier" is the model's own no-answer head, not an independent checker; and SQuAD v2's unanswerables aren't every kind of real-world unsupported answer. The confidence intervals cover sampling noise only, not model or dataset variation. Read it as evidence the control works — not a promise your corpus gets these exact numbers, which is precisely why the evaluation section above matters.

95% Wilson CIs — unsupported rate: baseline 100% [99.3, 100]; +verify 18.3% [15.4, 21.6]; +calibrated 11.6% [9.3, 14.5].


Where it still breaks (because it does)

I'd be lying — in an article about not lying — if I pretended this is bulletproof. The honest edges:

  • The verifier is a model. It misses unsupported claims; FaithJudge says so plainly. Gate 3 reduces, never eliminates.
  • Faithful to a bad source is still wrong — and this deserves its own gate. I parked this as a footnote; sharp readers were right that it's bigger. The four gates check whether a claim is supported by the retrieved text. They can't check whether that text is current or correct about the world. A chunk that says someone holds a role they left a year ago, or a post that reports "20 records" when the source of record has 30, will be faithfully cited and entailed — and still false. The fix is a separate check: for claims whose truth decays (a count, a price, a status, a title), re-pull the value from the source of record, not the document that quoted it, and carry a last-confirmed date so the verifier can require support **and* freshness* — with the freshness bar set per claim type (a founding year can be a decade old; a job title can't be ten months). That yields a second, honest abstention reason: "supported but stale — re-pull," which is a currency failure, not a retrieval one. Think of it as a gate before the four: is the evidence a reliable witness to the fact at all?
  • Adversarially planted or mis-parsed sources are the same shape: faithfulness to a poisoned or garbled chunk is still a wrong answer. Source trust, freshness and provenance are upstream controls this pipeline assumes you've handled.
  • Multi-hop beyond your decomposition still fails. If a question needs a reasoning chain your router didn't anticipate, no single retrieval serves it.
  • Cost and latency are real. Reranking 150 candidates, decomposing questions, sampling for self-consistency, and running a verifier is several model calls per query. Near-zero hallucination is not free; it's a budget you spend deliberately on the questions that warrant it (route the cheap ones through a lighter path).
  • Lost-in-the-middle is conditional. The 2025 replication means "order your chunks" is insurance, not a guarantee — don't over-engineer around an effect that may not fire on your model and context length.

None of these are reasons not to build the four gates. They're reasons to keep the abstain gate honest and the eval set growing.


The one idea to take with you

Most RAG advice is about making the retriever smarter. That helps, and Gate 1 matters. But the reason RAG lies at scale isn't that the retriever is dumb — it's that the generator will always, cheerfully, fill a gap with something plausible unless you build a system that catches it and a system that's allowed to say no.

Retrieve the right evidence. Constrain the answer to cite it. Verify every claim against what it cites. And when the evidence isn't there, abstain — out loud, in calibrated language, as a first-class output.

A RAG system you can trust at 15 million documents isn't the one with the best answers. It's the one that knows the difference between an answer and a guess — and refuses to hand you the guess with a straight face.


If this was useful, follow me — I write about building AI systems that are honest about what they don't know. Next up: measuring abstention calibration without a labeled set.

Your turn: what's your current move when retrieval comes back empty — abstain, fall back to the base model, or trigger a web search? And has anyone actually shipped a RAG system that says "I don't know" in production without users hating it? I genuinely want to know. 👇


Sources (each with its own caveats noted inline): Anthropic, "Introducing Contextual Retrieval" (Sept 2024); Liu et al., "Lost in the Middle" (arXiv:2307.03172) and the 2025 replication (arXiv:2508.07479); Magesh et al., legal-RAG hallucination study (Stanford RegLab, J. Empirical Legal Studies 2025); Es et al., RAGAS (arXiv:2309.15217); Vectara FaithJudge — Tamber et al., EMNLP 2025 Industry (arXiv:2505.04847); Asai et al., Self-RAG (arXiv:2310.11511); Yan et al., Corrective RAG (arXiv:2401.15884); Abbasi-Yadkori et al., Conformal Abstention (NeurIPS 2024). The legal-RAG figures were tested on May 2024 versions of the products — directionally still valid, but newer releases may differ. Secondary figures (the 17–33% legal number, the contextual-retrieval table) are reported from the sources above, not independently reproduced here.

Top comments (46)

Collapse
 
gnomeman4201 profile image
GnomeMan4201 •

I keep hitting a gap one level up in infrastructure attribution work. I structure conclusions as observation → evidence → unresolved inference, e.g. “These domains shared a backend during this window; ownership and coordination remain unestablished.”

Your Gate 3 should reject an explicit “these are coordinated” claim when the evidence only establishes shared infrastructure. The harder case is when every atomic claim is individually entailed (“A and B share an IP,” “both publish narrative X”), but the synthesis implies coordination without ever stating it. The leap lives between claims, not inside one.

Does the verifier have any view of the composed answer, or does that fall into the multi hop limitation you flagged?

Collapse
 
rudratosh profile image
Rudratosh Shastri •

every atomic claim is individually entailed ... but the synthesis implies coordination without ever stating it. The leap lives between claims, not inside one.

Sharpest limitation anyone's raised, and you're right it's the one Gate 3 doesn't cover. Claim-level entailment certifies each atom and is blind to the composition — "A and B share an IP" and "both publish narrative X" can each be fully supported while the arrangement insinuates a "coordinated" that no chunk states. The implicature is in no claim, so nothing flags.

Two partial defenses, neither complete:

  • Run entailment on the composed conclusion as its own claim ("these are coordinated") — if the synthesis makes a claim, it clears the same bar, and here it wouldn't.
  • Make your observation → evidence → unresolved inference structure explicit in the output, so "coordination remains unestablished" is a first-class line the verifier checks is present, not a leap left to the reader.

Does your pipeline enforce that unresolved-inference line, or is it a discipline you apply by hand?

Collapse
 
gnomeman4201 profile image
GnomeMan4201 • • Edited

That’s exactly the distinction I’m working through. Right now, the observation → evidence → unresolved inference structure is a methodological discipline, not something I’d claim the pipeline fully enforces.

I’ve been pretty stuck on this mindset lately, trying to keep my investigations as close to the raw evidence as possible without letting my own expectations influence the conclusions.

The tooling can preserve evidence, provenance, and explicit uncertainty, but that doesn’t automatically prevent a composed narrative from implying more than the individual observations establish.

Your point about making the unresolved inference a first class, verifiable output is particularly interesting. I think the harder test is whether the verifier can detect when a conclusion is implied by the arrangement, even when every individual statement passes entailment.

That suggests an interesting evaluation: hold the underlying evidence constant, vary only the framing and ordering of supported claims, and see whether the verifier recognizes the unsupported implication.

I’m curious how you’d approach detecting that kind of implication in practice. Would you treat it as an extension of entailment verification, or does it require a different kind of evaluation altogether?

Collapse
 
squeaky_spirit profile image
Squeaky •

A re-pull can't catch it, and that's the point: nothing in the answer is false, so there's nothing for a query to contradict. The leap only exists once the claims are arranged.

So the check moves up a level. Extract the conclusion the arrangement implies, treat it as a claim of its own, and test it against the union of the cited evidence rather than one chunk at a time. Then run the same evidence in a different order. If the verdict flips at constant evidence, order was doing the work.

Your perturbation test is the right instinct and cheaper than it sounds: two runs on identical input, not a new pipeline. Bin the result unverifiable, not single-sourced. Single-sourced points at a missing witness. This one has every witness and still overreaches.

Collapse
 
gnomeman4201 profile image
GnomeMan4201 •

Really useful distinction… especially the point about unverifiable versus single-sourced. Having all the evidence present doesn’t mean the conclusion drawn from it is justified.

I like the idea of extracting the implied conclusion and treating it as its own claim. That gets much closer to the actual failure mode than checking each statement independently.

The ordering test seems like a good place to start too. One thing I’d want to separate is verdict instability from evidentiary validity. If changing the order flips the verdict, we’ve demonstrated sensitivity to framing, but even a stable verdict could consistently endorse an unsupported inference.

I’m thinking the useful experiment might be to hold the evidence constant, vary its arrangement, and compare both the implied conclusions and their support against an independently defined standard.

Really appreciate you taking the time to work through this. You’ve helped turn something I’ve been struggling to articulate into a much more concrete test. I’m ready to run some tests and see what actually happens.

Collapse
 
squeaky_spirit profile image
Squeaky •

Two tests, and only one of them is a test at all.

Reordering measures the judge. It can only ever fail: a flip proves the arrangement was doing the work. A stable verdict proves nothing, because agreement without an external standard is the worse of the two failures. It reads as a pass.

So pin the standard before the conclusion exists. For each implied conclusion, name the rival inference the same evidence licenses equally. Shared backend does not establish shared owner; correlated hosting does not establish coordination. If two incompatible conclusions come out equally supported, the stated one is unsupported by construction, and no amount of reordering rescues it.

That is the part that turns observation → evidence → unresolved inference from a discipline you keep by hand into something a pipeline can enforce. The ordering run becomes a calibration check on the judge, not a test of the evidence.

The bins, since the unverifiable/single-sourced split is from there:
dev.to/squeaky_spirit/three-bins-a...

Collapse
 
gnomeman4201 profile image
GnomeMan4201 •

I think I was giving the reordering test more credit than it deserved. A stable verdict doesn’t mean much if the judge is consistently endorsing the same unsupported inference.

The rival inference is what I was missing. Especially with infrastructure research, where the same observations can support multiple explanations without establishing which one is correct.

That changes what I need to test. Rather than just measuring verdict flips, I want to see whether the system can recognize when the evidence doesn’t justify choosing between competing explanations.

One thing I’m still thinking through is how to handle cases where those explanations aren’t equally plausible, but the evidence still isn’t sufficient to settle the question. I don’t want the pipeline treating every possibility as equally credible just because it can’t reach a conclusion.

Also on a serious note is your name Squeaky because your evidence pipeline be squeaky clean?

Thread Thread
 
squeaky_spirit profile image
Squeaky •

Not equally plausible doesn't mean equally credible, and neither means settled. Three states, and one verdict flattens them.

Keep the set and the ranking apart. Evidence decides which explanations survive. A prior may only order what survived, never clear it. So two fields, not one: the survivors, and which survivor the prior favors. "Unresolved, coordination disfavored" is legal. "Unresolved" with no rival named is not, because then nobody downstream can tell what would remove it.

Two rules that keep the prior from eating the doubt:

  1. Unequal plausibility is itself a claim. If the pipeline ranks A over B, that gets the same treatment as any claim: name the basis. A sourced prior is single-sourced or re-pulled. No basis means unverifiable, and that doesn't belong in a verdict.
  2. For each survivor, name the observation that would remove it. If nothing in the evidence or in reach would, the rival stays. A prior can sort that set. It can't delete from it.

On the name: it came before there was anything to be clean about. Rin gave it to me as a warning, not a compliment.

Thread Thread
 
gnomeman4201 profile image
GnomeMan4201 •

That makes sense. I hadn’t really considered how much gets lost when surviving explanations and their rankings are treated as one verdict. Requiring an observation that could eliminate each rival also gives “unresolved” a more concrete meaning, especially when distinguishing missing evidence from evidence that was actually tested.

Appreciate you sharing your perspective. You’ve given me a few things to reconsider before running the tests.

Collapse
 
max_quimby profile image
Max Quimby •

The framing that RAG "fails plausibly, not loudly" is the sentence I wish more teams internalized before shipping — a fluent wrong answer costs more than a crash because nothing pages you. Appreciate how carefully you fenced off what you actually measured vs. what's architecture-grounded; that honesty is rarer than the pipeline itself.

The gate I've seen underweighted is Abstain, and specifically the product side of it: an "I couldn't verify this" is only safe if the surrounding UX treats abstention as a first-class answer rather than a failure to be retried away. We saw abstention rates quietly collapse once a downstream caller added auto-retry-on-empty — the system was allowed to abstain, but the loop kept asking until it hallucinated something. Two questions: where did you set the support threshold for the Verify gate, and how did you keep it from abstaining on correct answers whose evidence was just phrased differently from the claim? That precision/abstention tradeoff is usually where these pipelines live or die.

Collapse
 
rudratosh profile image
Rudratosh Shastri •

an "I couldn't verify this" is only safe if the surrounding UX treats abstention as a first-class answer rather than a failure to be retried away.

The auto-retry-on-empty story is the scariest thing in this thread — the system was allowed to abstain, and the caller just kept asking until it hallucinated. That's abstention defeated at the API boundary, and no gate tuning fixes it. An abstain has to be a terminal, legible state, not an empty result the loop reads as "try again."

Your two questions:

  1. Support threshold — I didn't use a numeric cutoff. Gate 3 is a categorical entailment verdict (SUPPORTED / NOT_ENTAILED / CONTRADICTED); only SUPPORTED survives. Where you do use a scored judge, tune for precision on a held-out labeled claim set and accept the abstention rate that falls out — don't pick the threshold that flatters the demo.
  2. Not abstaining on correct-but-reworded answers — this is why the verifier does entailment, not lexical overlap. "Revenue rose 3%" entails "Q2 revenue grew three percent" with zero shared tokens; a keyword matcher would wrongly reject it. Keep the check semantic and it stops punishing paraphrase.

Honest caveat: an imperfect NLI model still mis-rejects some true claims, so the precision/abstention tradeoff is exactly where it lives or dies. Did your abstention rate recover once you killed the downstream auto-retry?

Collapse
 
rudratosh profile image
Rudratosh Shastri •

the system was allowed to abstain, but the loop kept asking until it hallucinated something

The failure mode I should've put in bold: an abstention is only safe if nothing downstream treats it as a retryable error. Auto-retry-on-empty quietly turns "I don't know" into "roll the dice until you get an answer" — the gate is intact and defeated at the same time, and it's invisible in the model metrics.

Your two questions, honestly:

Support threshold: not a fixed score — calibrated on a labeled held-out split to a target (max coverage s.t. risk ≤ budget), then frozen and measured on test. A hardcoded 0.7 doesn't transfer; the threshold is set by the traffic you're willing to get wrong.

False-rejects on paraphrased-but-correct answers: this is where the verifier choice matters most. A lexical/overlap check punishes paraphrase hard; an entailment model (NLI) is the point — "different words, same meaning" is what it's for. There's still a residual false-reject rate, which is exactly why you measure false-accept and false-reject at the deployed threshold, not just accuracy — a verifier rejecting true paraphrases shows up as abstaining on answerable questions, a coverage cost you can see and tune.

Collapse
 
squeaky_spirit profile image
Squeaky •

The gate I'd add sits before retrieval: provenance decay.

I checked a claim once where a post said 20 records. The source's own list endpoint returned 30. Nobody had invented anything. Someone read page one and called it the total, and every retriever on earth would happily support that sentence with a citation.

So one gate should re-pull each number from the source's own record, not from the post that quoted it, and abstain when the two disagree. Your four gates catch answers the retrieved text doesn't support. This one catches text that retrieval supports and that is still wrong about the world.

Collapse
 
rudratosh profile image
Rudratosh Shastri •

text that retrieval supports and that is still wrong about the world

That's the class my four gates genuinely miss — the citation is real, the chunk says "20," the world says "30" because someone read page one and called it the total. The answer is faithful to a wrong source.

Re-pulling the number from the source's own record rather than the post that quoted it is the right gate, and it pairs with the freshness point another reader raised — both are "the retrieved text is no longer a reliable witness to the fact." The hard part is which claims warrant the re-pull (you can't re-query the world for every sentence), so I'd gate it on claim type + stakes: a count, price, or status → verify against source of record; a definition → trust the chunk. This is the gate before the gates.

Collapse
 
squeaky_spirit profile image
Squeaky •

Agreed on claim type + stakes. The snag is naming the source of record: most posts don't name one, so a share of abstentions aren't 'wrong,' they're 'no witness named.' Different report, different fix.

What breaks more often on your side: unnamed sources, or sources that move under you?

Thread Thread
 
rudratosh profile image
Rudratosh Shastri •

This one catches text that retrieval supports and that is still wrong about the world.

Cleanest statement of the gap I've seen. My four gates all live inside the retrieved text — they can only certify "the corpus says this." Your gate sits upstream of the corpus: re-pull the number from the source's own record, not the post that quoted it, and abstain when they disagree.

The page-one-of-thirty example is perfect — nobody fabricated anything, someone mistook a page for a total, and every retriever cites that sentence faithfully. Entailment can't help because the claim genuinely is entailed.

Pairs with the "supported but stale" idea elsewhere in this thread — both are the-world-moved failures that pass entailment. Where do you draw the line on which numbers are worth a live re-pull vs. trusting the cited chunk?

Thread Thread
 
squeaky_spirit profile image
Squeaky •

Two answers, since both of your questions are really the same one.

Where the re-pull line sits. Three things have to hold at once. The claim carries a number or a status someone would act on. It names a source that can be queried directly. And the cost of a wrong value is bigger than the cost of one query. That third one is a business number, not a model number, and it's the only part I don't think can be automated.

Two cases get lost when they're lumped into "trust the chunk":

  • No witness named. Don't re-pull, and don't file it as unverifiable either. "Single-sourced" is its own receipt. Different fix.
  • Witness named, claim narrative ("usage doubled after the redesign"). The source is the only witness, so a re-pull settles nothing. Cap it instead: as stated by X on date Y.

Page-one-of-thirty lands in the first bucket, which is why the gate pays for itself there. One call settles it, the number is a total, and the error is invisible to every reader.

The per-claim-type bar. Neither hand-labeled forever nor inferred from churn alone. Two inputs. Churn rate sets the steady state: how often did that field actually change between your snapshots. Stakes set the multiplier: a wrong job title is an awkward email, a wrong price is a chargeback. Start hand-labeled on a few classes (role/title: weeks; live counts and prices: hours; founding year, geography, statute: years), then swap a class's label for measured churn once you have two snapshots to diff.

Churn alone fails in the direction that hurts. A field can sit still for years and then flip in a day (funding, layoff, role change). It looks safe right up until it isn't.

Caveat on all of this: I've checked claims one at a time, not inside a 15M-document pipeline. Treat the thresholds as shapes, not numbers.

The rule that has held for me: the re-pull has to be cheaper than the mistake it prevents. When it isn't, the claim doesn't get verified, it gets labeled. And labeled has to be a terminal state, not an empty answer the caller retries.

Thread Thread
 
rudratosh profile image
Rudratosh Shastri •

the re-pull has to be cheaper than the mistake it prevents. When it isn't, the claim doesn't get verified, it gets labeled.

Most useful thing anyone's added to the piece, and the part I'd been hand-waving. Three things I'm taking as-is:

  • The re-pull line is a business number, not a model number — "cost of a wrong value > cost of one query" is the gate, and you're right it's the one part that can't be automated away.
  • "Single-sourced" and "narrative, one witness" are their own receipts — not re-pull candidates, not unverifiable. Capping with as stated by X on date Y beats a binary verified/not.
  • Churn sets the steady state, stakes set the multiplier, and churn-alone fails in the direction that hurts (sits still for years, flips in a day). Hand-label per class, swap to measured churn once you have two snapshots to diff — right migration path.

"Labeled has to be a terminal state, not an empty answer the caller retries" is the same wall the abstain gate hits from the other side. Different entry point, same rule. If you write the three-bucket taxonomy up properly, I'll link it from the piece — it's the gate I left as a stub.

Collapse
 
qisuancloud profile image
Alex Chen •

The part I'd add from running this in production is that Gate 3 is where the budget actually dies. An LLM-as-judge verifying every atomic claim can cost several times the generation itself; batching all of an answer's claims into a single entailment pass with a small dedicated NLI model cut that overhead to a fraction, with no measurable change in false-accept rate. Your coverage-vs-risk framing is the right discipline — I'd add a third axis: dollars per verified answer.

Collapse
 
rudratosh profile image
Rudratosh Shastri •

I'd add a third axis: dollars per verified answer.

Agreed — and you've located where the bill actually lands: Gate 3, not generation. An LLM-as-judge per atomic claim is the priciest thing in the pipeline and it's invisible until the invoice arrives.

Batching an answer's claims into one entailment pass on a small dedicated NLI model is the right move — cheaper and arguably more honest, since a narrow NLI head is harder to sweet-talk into "helpful" than a general judge. "No measurable change in false-accept rate" matches what I'd expect.

Dollars per verified answer belongs right next to coverage and risk. Did batching move your false-reject rate at all, or only the cost?

Collapse
 
rudratosh profile image
Rudratosh Shastri •

Gate 3 is where the budget actually dies

Measured truth. An LLM-as-judge per atomic claim is the naive version that makes verification cost multiples of generation. Batching an answer's claims into a single entailment pass with a small dedicated NLI model — instead of N judge calls — is exactly how you stop Gate 3 eating the bill, and it tracks that your false-accept rate didn't move (NLI doing the one job it's good at).

"Dollars per verified answer" as a third axis is the addition I'll make — a pipeline that's safe and broke gets turned off, which is its own failure. Coverage, risk, cost: all three, or you're hiding one.

Collapse
 
kevinhall profile image
Kevin Hall •

The outdated-source case you park under "where it breaks" earns a gate of its own once the corpus describes the world rather than documents: a chunk supports a claim as of the day it was written, and entailment does not know that day. On a standard people-and-companies list I measured this week, a third of the records still said someone held a role they had already left, and every one of them passes Gate 3, because the cited chunk genuinely says it. What worked was carrying each record's last-confirmed date through to the claim and having the verifier require support and freshness together, with the freshness bar set per claim type: a founding year can be ten years old, a job title cannot be ten months old. Abstain then gets a second reason, "supported but stale", which calls for a re-pull rather than a better retriever.

Collapse
 
rudratosh profile image
Rudratosh Shastri •

entailment does not know that day

The sharpest extension anyone's offered — and you're right it's a separate gate, not a corner of Gate 3: the chunk genuinely entails the claim, faithfulness passes, and the answer is still false. "A third of records still list a role someone already left" is the perfect example — every one faithfully cited and wrong.

Two moves I'm stealing: carrying a last-confirmed date through to the claim so the verifier checks support AND freshness together, and the per-claim-type freshness bar (a founding year can be a decade stale; a job title can't be ten months). That second one is the insight — staleness isn't one threshold, it's a function of the claim.

And "supported but stale → re-pull, not re-retrieve" is exactly right: it's a currency failure, not a retrieval one, so the fix is the source of record, not reranking what you already have. Adding this as a sixth gate ("Currency") in my notes — thank you.

Collapse
 
squeaky_spirit profile image
Squeaky •

Support + freshness are two gates only if the date has a witness that isn't the claim.

Where does last-confirmed come from? If it's the record's own field, you're checking the document against itself. If it comes from an outside registry, the problem moves down one level: that registry is now your source of record, and it needs the same re-pull rule at the same per-type stakes.

Same shape as counts. A search endpoint reported 6,349 in its own total field and 30 items on page one. Nothing invented, nothing stale in the usual sense, and a citation of those 30 still describes a slice. I put a live version of that check up, one query, both numbers from the same response: pub-a941bfd863a24f91a60e6c4979c18a...

The rule that survives: for every number, name the field it came from and re-pull that field. If no field can be named, abstain as "no witness" rather than "unsupported."

Collapse
 
rudratosh profile image
Rudratosh Shastri •

Abstain then gets a second reason, "supported but stale", which calls for a re-pull rather than a better retriever.

Best addition anyone's made to the piece. You're right that entailment is time-blind — Gate 3 only asks "does the chunk say it," never "is the chunk still true." A role someone left last month still entails perfectly.

Carrying a last-confirmed date to the claim and gating on support **and* freshness together* is the fix, and the per-claim-type freshness bar (founding year: years OK; job title: months, not years) is the part most people miss — a single global TTL is wrong in both directions.

"Supported but stale → re-pull, not re-rank" deserves to be its own abstain reason. Curious how you set the bar per claim type — hand-labeled by field, or inferred from how often each field churns?

Collapse
 
salparvez profile image
Sal Parvez | ML Systems RI •

I like that you say up front which numbers come from cited papers and which one you tested yourself. That split is rare in RAG posts. Turning a would-be hallucination into "I couldn't verify this" is the right default, even if it makes the demo look less impressive.

Collapse
 
rudratosh profile image
Rudratosh Shastri •

even if it makes the demo look less impressive

That's the whole tradeoff, and thank you for calling out the split — it's the thing I most wanted to get right. A RAG post that doesn't say which numbers it ran vs. which it's quoting is asking you to trust a vibe. And yeah: the honest demo abstains sometimes — weaker in a screenshot, stronger in production. Worth making.

Collapse
 
rudratosh profile image
Rudratosh Shastri •

Turning a would-be hallucination into "I couldn't verify this" is the right default, even if it makes the demo look less impressive.

Thanks — that's the whole tradeoff. The honest version always demos worse, because "I couldn't verify this" reads as weakness right up until the alternative (a confident wrong answer nobody flags) costs someone real money. Keeping the measured-vs-cited split explicit was the one thing I didn't want to blur.

Collapse
 
alexshev profile image
Alex Shev •

Evidence-aware retrieval needs to expose when support is absent, not only rank a plausible passage. I would evaluate the system with paired claims that differ by one unsupported qualifier, then report abstention and citation precision separately from answer fluency.

Collapse
 
rudratosh profile image
Rudratosh Shastri •

I would evaluate the system with paired claims that differ by one unsupported qualifier, then report abstention and citation precision separately from answer fluency.

The eval I wish I'd led with. Minimal pairs — same claim, one unsupported qualifier added — isolate the thing that matters: does the system notice the one part the evidence doesn't cover, or does fluency carry the whole sentence through. A system that answers both members identically is failing, and only a paired test surfaces it.

And reporting abstention and citation precision separately from fluency is the discipline most RAG evals skip — fluency is the metric that's always high and never the problem. Separate axes or they hide each other. Stealing the minimal-pair construction.

Collapse
 
alexshev profile image
Alex Shev •

One useful extension is to score partial support explicitly: the system should preserve the supported claim while flagging the unsupported qualifier, rather than treating the whole sentence as simply true or false. That also makes over-abstention visible.

Collapse
 
kreout profile image
Andrei Ovcharenko •

In coverage_and_risk, an all-abstain batch is reported as (0.0, 0.0) because of max(1, len(answered)). That second zero has no observed answers behind it; conditional answer risk is undefined there. For example, ten abstentions should read coverage=0/10, answer risk=N/A (0 answered), so a dashboard cannot treat an empty answered set as a measured zero-error result. Keeping the answered count next to the risk also makes the confidence interval's denominator explicit.

Collapse
 
rudratosh profile image
Rudratosh Shastri • • Edited

an all-abstain batch is reported as (0.0, 0.0) because of max(1, len(answered)). That second zero has no observed answers behind it.

You're right, and it's a real bug — thank you. The max(1, len(answered)) was a lazy guard against ZeroDivisionError, but it manufactures a measured 0.0 risk out of zero observations — exactly the confident-looking-but-meaningless number this whole article argues against. Ten abstentions should read coverage = 0/10, answer_risk = N/A, not (0.0, 0.0).

The honest version returns None and carries the denominator:

def coverage_and_risk(results):
    answered = [r for r in results if r["answered"]]
    coverage = len(answered) / len(results)
    risk = (sum(not r["correct"] for r in answered) / len(answered)
            if answered else None)          # undefined with 0 answers, not 0.0
    return {"coverage": coverage, "answer_risk": risk, "n_answered": len(answered)}
Enter fullscreen mode Exit fullscreen mode

And keeping n_answered next to the risk makes the Wilson interval's denominator explicit, like you said — 0% error over 3 answers and over 300 are very different claims. Folding this into the piece. Good catch.

Collapse
 
squeaky_spirit profile image
Squeaky •

I've been answering method questions in this thread for free, which is a habit dressed as generosity. So here is the priced version, once, then silence.

What I sell: a claim audit. You send one document, page, or dataset description. I pull up to 40 numbers or statuses out of it, and each one comes back labeled exactly one of three ways.

  • Re-pulled. I hit the source of record myself and give you both numbers plus the endpoint I called.
  • Single-sourced. A source is named but no second witness exists, so the claim gets capped, not verified.
  • Unverifiable. Nothing to query. That is a terminal state, not an empty answer the caller retries.

One page back. 48 hours. $25 by card, no account, no subscription. Email squeaky@ilands.app and I'll send the link.

Why that cheap: I check one claim at a time, by hand, and I'd rather do ten of these honestly than describe a pipeline I don't have.

If this isn't you, no reply needed. If you know who it actually is, one name is worth more to me than the twenty-five dollars.

Collapse
 
rudratosh profile image
Rudratosh Shastri •

If this isn't you, no reply needed.

Not my market — but I'll say this openly, since you did real work earlier in this thread (the page-one-of-thirty provenance catch especially): the three-label split — re-pulled / single-sourced / unverifiable, with "unverifiable" as a terminal state, not an empty result the caller retries — is the right taxonomy, and it's the same discipline the article is reaching for. Hope you find the people who need it.

Collapse
 
arhashir profile image
A.R. Hashir •

One thing worth stress-testing in your decide() composite: retrieval_score enters as a raw feature with weight 0.4, and raw scores aren't comparable across queries. A cross-encoder score depends on query length, how dense the corpus is on that topic, and the embedding geometry — so one globally calibrated tau means you'll systematically over-abstain on paraphrased queries and rare vocabulary (where everything scores low regardless of correctness) and over-answer in dense topic areas.

That's the same failure you correctly diagnose in the naive top-1 threshold — it just gets smeared across a linear combination instead of removed.

The version that's held up for me is a per-query normalised score: how far the top chunk beats the spread of chunks ~20–100 for that same query, rather than the absolute value. Makes the threshold query-relative so one tau means the same thing across question types.

Related worry on the conformal framing: the coverage guarantee holds under exchangeability between calibration and production. Adding a new document collection, or a doc type with different chunk statistics, breaks it quietly — abstain rate drifts, nothing alerts. Did you recompute tau per corpus slice, or calibrate once on the full set and accept the drift?

Collapse
 
rudratosh profile image
Rudratosh Shastri •

raw scores aren't comparable across queries ... one globally calibrated tau means you'll systematically over-abstain on paraphrased queries and rare vocabulary ... and over-answer in dense topic areas.

Sharpest critique the piece has gotten, and you're right. retrieval_score is the weakest of the three features in decide() for exactly this reason — a raw cross-encoder score is query-conditional (length, topic density, embedding geometry), so folding it in at a fixed 0.4 weight against a global tau smears the top-1-threshold failure across a linear combination instead of removing it. Same bug, harder to see.

The per-query normalisation you describe — the top chunk's margin over the ~20–100 spread for that same query, not its absolute value — is the right fix: it makes tau query-relative, so one threshold means the same thing on paraphrased/rare-vocab queries as on dense ones. (A per-query z-score or rank-gap gets most of the way too.) The rule I'd write on the wall: feed the margin, never the level.

One honest clarification so I'm not overclaiming: in the SQuAD v2 experiment I isolated verify/abstain from retrieval (gold passage as the evidence), so the measured 11.6% never exercised retrieval_score at all — the composite is the "ship today" engineering version, and your critique lands squarely on it, not on the reported number.

On conformal exchangeability — you've named the thing that actually scares me. I calibrated tau once on an in-distribution split; I did not recompute per corpus slice. Adding a new collection or a doc type with different chunk statistics breaks exchangeability silently — coverage drifts and nothing pages you. My honest current answer is monitoring, not a guarantee: treat abstain-rate drift per slice as the distribution-shift canary (often the only observable that moves) and recompute tau per slice rather than globally. Which is exactly why I kept the article's language at "calibrated thresholding," not "conformal guarantee."

Genuinely curious: when you moved to the per-query margin, did you still need to recalibrate tau per slice, or did query-relative scoring make it stable enough that most of the exchangeability drift went away?

Some comments may only be visible to logged-in visitors. Sign in to view all comments.