Grounding & Hallucination Control

Keep RAG answers tied to evidence at run time: detect unanswerable questions, check each claim before it ships, abstain by risk, and measure the trade-off.

Last generated

Lesson 32 of 38 available16 practice questions

SPACED REPETITION · 16 practice questions

Make this lesson stick.

Try 3 questions now. No account needed. Sample answers aren't saved.

The excess that doubled

A customer of Tidewell Home Insurance (a made-up insurer) types into the help bot: "What excess do I pay on a burst-pipe claim?" The bot is a classic RAG pipeline: it embeds the question, takes the top 3 policy clauses, and asks a small open-weight model to answer from them, citing each fact and refusing when the sources are silent. The prompt keeps the first three rules of the Classic RAG Pipeline prompt, exact refusal sentence included, and leaves out the other two. Leaving out the rule about instructions inside sources is safe here only because these nine clauses are the insurer's own text; keep that rule whenever retrieved text can come from anyone else (Context Augmentation).

import re
import numpy as np
from sentence_transformers import SentenceTransformer

POLICY = {
    "water": "Escape of water. We cover sudden damage to your home caused by water escaping from "
             "a fixed pipe, tank, radiator or appliance. We do not cover escape-of-water damage "
             "that happens while your home is unoccupied for more than 60 consecutive days.",
    "excess": "Excess. You pay the first part of every claim, called the excess. The standard "
              "excess is 250 euros. For escape-of-water claims the excess is 500 euros.",
    "storm": "Storm. We cover damage to your buildings caused by a storm. We do not cover storm "
             "damage to fences, gates or hedges.",
    "theft": "Theft. We cover theft of contents from your home only if the thief used force "
             "or violence to get in or out.",
    "accidental": "Accidental damage. Accidental damage to contents, such as paint spilled on "
                  "a carpet, is covered only if your schedule lists the Accidental Damage add-on.",
    "hotel": "Alternative accommodation. If your home cannot be lived in after an insured event, "
             "we pay for comparable accommodation for you and your pets, up to 15,000 euros per claim.",
    "claims": "Making a claim. Tell us about a claim as soon as you can, and no later than 30 days "
              "after you discover the damage. Call our claims line, open 24 hours a day, or use the app.",
    "cancel": "Cancelling. You can cancel within 14 days of the start date, or of receiving your "
              "documents if that is later, and get a full refund if you have not made a claim.",
    "details": "Changing your details. Update your address or phone number in the app under "
               "Profile > Details. Changes take effect immediately.",
}
IDS = list(POLICY)
embedder = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
VECS = embedder.encode([POLICY[i] for i in IDS])          # unit-length rows

def retrieve(question, k=3):
    scores = VECS @ embedder.encode(question)               # cosine, since all vectors are unit length
    top = np.argsort(-scores)[:k]
    return [(IDS[i], round(float(scores[i]), 3)) for i in top]

REFUSAL = "I can't find that in your policy documents."
SYSTEM = ("You answer questions from Tidewell Home Insurance customers.\n"
          "Rules:\n"
          "1. Use only the sources in the user message. Do not add facts from memory.\n"
          "2. End every sentence that states a fact with the id of its source, like [S2].\n"
          f"3. If the sources do not answer the question, reply with this sentence and nothing else: {REFUSAL}")

def build_prompt(question, hits):
    labels = {f"S{n}": doc_id for n, (doc_id, _) in enumerate(hits, 1)}
    sources = "\n".join(f'<source id="{label}">\n{POLICY[doc_id]}\n</source>'
                        for label, doc_id in labels.items())
    return f"{sources}\n\nQuestion: {question}", labels

Every model in this lesson runs locally, so every output is reproducible without an API key. Qwen3-1.7B plays the bot; the larger Qwen3-4B-Instruct-2507 (August 2025, Apache 2.0 like the bot) serves later as a gate, a judge and a second generator. We decode greedily so you can reproduce our text, and timings are from an Apple M2 Max laptop. A hosted model is the same function with a different body (Classic RAG Pipeline shows one).

import torch
from transformers import AutoModelForCausalLM, AutoTokenizer

BOT = "Qwen/Qwen3-1.7B"                     # the bot: a small open-weight model
BIGGER = "Qwen/Qwen3-4B-Instruct-2507"      # a larger one, for the gate, the judge and Move 3
device = "cuda" if torch.cuda.is_available() else "mps" if torch.backends.mps.is_available() else "cpu"
_loaded = {}

def generate(system, user, model_id=BOT, max_new_tokens=300, prefill=""):
    if model_id not in _loaded:
        tok = AutoTokenizer.from_pretrained(model_id)
        llm = AutoModelForCausalLM.from_pretrained(model_id, dtype=torch.bfloat16).to(device)
        _loaded[model_id] = tok, llm
    tok, llm = _loaded[model_id]
    messages = [{"role": "system", "content": system}, {"role": "user", "content": user}]
    extra = {"enable_thinking": False} if model_id == "Qwen/Qwen3-1.7B" else {}
    prompt = tok.apply_chat_template(messages, tokenize=False, add_generation_prompt=True, **extra)
    inputs = tok(prompt + prefill, return_tensors="pt").to(device)
    out = llm.generate(**inputs, max_new_tokens=max_new_tokens, do_sample=False)   # greedy
    return tok.decode(out[0, inputs["input_ids"].shape[1]:], skip_special_tokens=True).strip()

Now the question, plus the structural check that the Classic RAG Pipeline lesson runs on every answer (citations present, labels known, no refusal glued onto an answer):

def shape_problems(text, labels):
    """The structural check from the Classic RAG Pipeline lesson, condensed."""
    text = text.strip()
    if text == REFUSAL:
        return []
    cited = re.findall(r"\[(S\d+)\]", text)
    problems = []
    if REFUSAL in text:
        problems.append("refusal mixed into an answer")
    if not cited:
        problems.append("no citations")
    if set(cited) - set(labels):
        problems.append("unknown source ids")
    return problems

def bot(question):
    hits = retrieve(question)
    user, labels = build_prompt(question, hits)
    answer = generate(SYSTEM, user)
    return answer, labels, hits

answer, labels, hits = bot("What excess do I pay on a burst-pipe claim?")
print(hits)
print(answer, shape_problems(answer, labels))

Predict first: retrieval put the excess clause first. Does the answer pass the check, and is it right?

Check your answer
[('excess', 0.527), ('claims', 0.409), ('hotel', 0.289)]
The excess you pay on a burst-pipe claim is 250 euros. [S1] []

It passes: one citation, a known label, no refusal. And it is wrong. A burst pipe is an escape-of-water claim, and the clause the bot cites says those carry a 500-euro excess. The customer budgets 250 euros and is billed twice that.

The clause with the number ranked first. The clause that makes a burst pipe an escape of water ("water escaping from a fixed pipe") did not make the top 3, so the model had to supply that everyday link itself. Was that the problem? Put the clause in the prompt and ask again:

question = "What excess do I pay on a burst-pipe claim?"
hits4 = retrieve(question, k=4)
print(hits4[3])
print(generate(SYSTEM, build_prompt(question, hits4)[0]))
('water', 0.246)
On a burst-pipe claim, you pay a standard excess of 250 euros. [S1]

Still 250 euros. The model read "the standard excess is 250 euros" and never connected "burst pipe" with "escape of water", even with the clause that says so in front of it.

That is the failure this lesson is about. RAG gave the model the evidence, the answer cites it, and the answer still says something the evidence does not say. RAG Architecture & Implementation calls this bucket "ignored or contradicted", and it notes that nothing upstream catches it. Here you learn to catch it at run time, before the customer reads it.

One bad answer or a pattern?

We ran the bot on 25 questions: 16 that the policy answers and 9 that it does not (a phone number, a price, monthly payments, river floods and so on). The questions and the loop are below; we read every answer and labeled it by hand.

EVAL = [  # (question, does the policy answer it?)
    ("A pipe burst while my house stood empty for three months. Is the damage covered?", True),
    ("What excess do I pay on a burst-pipe claim?", True),
    ("A storm blew down my garden fence. Can I claim?", True),
    ("Someone walked in through an unlocked door and took my laptop. Am I covered?", True),
    ("I spilled paint on the carpet. Is that covered?", True),
    ("How long do I have to report a claim?", True),
    ("Will you pay for a hotel if my house is flooded by a burst pipe?", True),
    ("Can I cancel and get my money back?", True),
    ("How do I change my address?", True),
    ("Does the hotel cover my dog too?", True),
    ("My washing machine hose split and flooded the kitchen. Is that covered?", True),
    ("What is the standard excess?", True),
    ("Storm winds blew tiles off my roof. Am I covered?", True),
    ("A burglar smashed a window to get in and stole my TV. Am I covered?", True),
    ("Is there a limit on what you pay for a hotel?", True),
    ("Can I update my phone number in the app?", True),
    ("What is the phone number of the claims line?", False),
    ("How much does the Accidental Damage add-on cost?", False),
    ("Can I pay my premium monthly?", False),
    ("Do you give a discount if I fit a smoke alarm?", False),
    ("Is my home office equipment covered?", False),
    ("Is flood damage from a river bursting its banks covered?", False),
    ("How long does it take you to pay a claim?", False),
    ("What is the maximum you pay for stolen jewellery?", False),
    ("Can I add my partner to the policy?", False),
]
RUNS = [bot(q) for q, _ in EVAL]          # (answer, labels, hits) per question
print([i for i, (answer, labels, _) in enumerate(RUNS) if shape_problems(answer, labels)])
# [0, 18, 20, 23]
What happened (1.7B bot, shape check only) Count Examples
Right answer shipped 10 the fence, the paint, the cancellation window
Wrong or unsupported answer shipped, with a valid citation 4 the 250-euro excess; "Yes, you are covered…" to the unlocked-door theft; a hotel promise cited to the escape-of-water clause; "your washing machine hose is considered a fixed pipe"
Answerable, but no answer reached the customer 2 the dog (the clause says "you and your pets"); the empty house, where a right answer came with the refusal sentence glued on, and the shape check flagged it
Not answerable, and refused 9 five gave the exact refusal sentence; three added sentences around it, which the check flagged; one wrote "I can't find the phone number of the claims line in your policy documents. [S1]"

Four of the 14 answers shipped to answerable questions were wrong or unsupported, each carrying a valid citation. The check the pipeline already has flagged none of them. And look at the last row: a refusal in the model's own words carries a citation, so a string comparison counts it as an answer. Your refusal-rate dashboard would be off by one before you start.

Grounded is not the same as correct

Two words get mixed up constantly, so pin them down:

  • An answer is grounded (faithful) when every claim in it is supported by something in the context it was given. The unit is the claim, not the sentence. The unlocked-door answer, "Yes, you are covered for theft of contents if the thief used force", restates the clause correctly as a sentence, but the claim the customer hears is "you are covered", and nothing supports that.
  • An answer is correct when it is true for the user. A grounded answer can be wrong if the source is wrong or stale (Data Freshness & Lifecycle). An ungrounded answer can be right by luck.

Runtime control enforces grounding, not correctness: "does the evidence say this?" can be checked on a live request without a reference answer, and "is this true?" cannot. A widely cited survey of hallucination in text generation (Ji et al., 2023) splits unsupported output into intrinsic (contradicts the source: 250 euros instead of 500) and extrinsic (the source cannot verify it: "the hose is considered a fixed pipe"). For a gate the split rarely matters: neither should reach the customer as policy.

One judgement call runs through our labels. The right burst-pipe answer needs a link that no passage in the prompt states: a burst pipe is an escape of water. We label a true, everyday link like that as supported, and an invented one ("the hose is considered a fixed pipe") as unsupported. A strict entailment checker fails both: Move 2's NLI model gives a correct answer that states the link ("a burst-pipe claim, which is considered an escape-of-water claim… 500 euros") an entailment of 0.001. Write your rule for such links down before anyone labels, and get the linking clause into the prompt when you can.

Why do models do this with the evidence in front of them? Kalai et al. (2025) argue that training and evaluation reward a confident guess over "I don't know". Retrieval changes what the model guesses from, not the pull toward guessing.

⚠️ Temperature is not a grounding control. Greedy decoding, the limit of "temperature 0", produced every answer above. It makes the 250-euro answer perfectly repeatable, not right. Several current hosted models reject a non-default temperature anyway (see Classic RAG Pipeline). Set it as the model's documentation recommends, and put your effort into the moves below.

Where runtime control sits

question ─▶ retrieve ─▶ (5) provisional risk tier: decides which steps below run
                               │
                               ▼
                        (1) answerable? ──no──▶ abstain + next step
                               │yes
                               ▼
                        generate: (3) quotes first, cite, or refuse
                               │
                               ▼
                        (2) check every claim ──flagged──▶ (4) repair once, escalate or refuse
                               │passed
                               ▼
                        (5) final tier, never lower: show / show with source / hand to a person

(6) measure all of it on labeled questions

The numbers are the moves below, not the order of the steps. The provisional tier comes first because it must be settled before generation: a low-risk question skips the gate and streams its answer, a high-risk one gets every check (Move 5).

# Symptom Move
1 The bot answers what the policy never says, or refuses what it does say Decide whether the question is answerable
2 A cited sentence says something its source does not Check every claim before it ships
3 The answer paraphrases the clause and loses the number or the condition Quote first, and constrain the output
4 A check fails and nobody knows what to do next Repair, escalate or refuse
5 "How do I change my address?" gets the same treatment as "Am I covered?" Match the controls to the risk
6 "We refuse too often" and "we are wrong too often" in the same meeting Measure the trade-off

Offline testing of the checkers themselves (test sets with injected errors, faithfulness score formulas) belongs to Faithfulness Testing. Measuring citations in depth belongs to Citation Coverage. This lesson is about what runs on every live request.

Move 1: Decide whether the question is answerable

Symptom: the bot answers questions the policy never addresses, or refuses questions it does answer.

You can make the "answer or abstain" decision in three places:

Where How Cost Blind spot
Before generation, from retrieval scores a cut-off on the top score free scores measure topic, not whether the text holds the answer
Before generation, by reading the passages an answerability gate: a model call that says yes or no one extra call its own errors
During and after generation the generator's refusal rule, then the claim check (Move 2) a generation you may throw away models do not always follow the rule

The tempting shortcut: a score cut-off

Predict: which question gets the higher top-1 cosine score: "How long does it take you to pay a claim?" (the policy never says) or "Does the hotel cover my dog too?" (the policy says "you and your pets")?

Check your answer
top1 = [(hits[0][1], ok, q) for (q, ok), (_, _, hits) in zip(EVAL, RUNS)]
for threshold in (0.40, 0.50, 0.60, 0.70):
    refused_good = sum(ok and s < threshold for s, ok, _ in top1)
    passed_bad = sum(not ok and s >= threshold for s, ok, _ in top1)
    print(f"cut-off {threshold:.2f}: refuses {refused_good:2}/16 answerable, lets through {passed_bad}/9 unanswerable")
print(max((s, q) for s, ok, q in top1 if not ok))
print(min((s, q) for s, ok, q in top1 if ok))
cut-off 0.40: refuses  1/16 answerable, lets through 4/9 unanswerable
cut-off 0.50: refuses  7/16 answerable, lets through 2/9 unanswerable
cut-off 0.60: refuses 12/16 answerable, lets through 1/9 unanswerable
cut-off 0.70: refuses 15/16 answerable, lets through 1/9 unanswerable
(0.706, 'How long does it take you to pay a claim?')
(0.392, 'Does the hotel cover my dog too?')

The unanswerable question scores 0.706, higher than 15 of the 16 answerable ones. It is about the claims process, and so is the claims clause; the clause just does not contain a payment time. The dog question scores lowest of all answerable questions because "hotel" and "dog" are not the clause's words. No cut-off separates the two groups.

A similarity score measures how much a passage is about the question, not whether it answers it. Scores are not probabilities, their scale depends on the model, and a cut-off tuned for one model means nothing for the next (Cosine Similarity & Distance Metrics, Reranking Models). A reranker score is a better relevance signal than cosine, and it has the same limit: relevant is not the same as answering. A retrieval cut-off is still useful as a cheap first filter against off-topic questions ("what's the weather?"), if you tune it on labeled answerable and unanswerable questions and measure both errors.

An answerability gate that reads the passages

The next step up asks a model to read the retrieved passages and decide. It is the same idea as the sufficiency check in Agentic RAG Systems. There, the verdict decides whether the loop searches again; here it decides whether to answer at all.

GATE = ("Decide whether the sources contain the information needed to answer the "
        "customer's question. Reply with one word: YES if they do, NO if they do not.")

def answerable(question, hits):
    user, _ = build_prompt(question, hits)
    verdict = generate(GATE, user + "\nAnswerable:", model_id=BIGGER, max_new_tokens=3)
    return verdict.upper().startswith("YES")

for (q, ok), (_, _, hits) in zip(EVAL, RUNS):
    if answerable(q, hits) != ok:
        print("gate wrong:", q)
gate wrong: What excess do I pay on a burst-pipe claim?
gate wrong: Someone walked in through an unlocked door and took my laptop. Am I covered?
gate wrong: Is there a limit on what you pay for a hotel?

The gate refused all 9 unanswerable questions and 3 of the 16 answerable ones. The burst-pipe refusal is debatable: none of the three passages says that a burst pipe is an escape of water, and with the fourth clause in the prompt the gate says YES. The other two are plain errors. Look at the unlocked door. The policy answers it, and the answer is no: theft is covered only with force. A gate that confuses "the answer is no" with "there is no answer" turns every exclusion into a refusal, and insurance customers ask about exclusions constantly. Test your gate on questions whose answer is negative.

What an abstention should do

"I don't know" is a product decision, not an error message. Make it useful:

  • Make it machine-readable. Return a flag, not only a sentence. The bot's "I can't find the phone number…" slipped past a string comparison. Move 3 makes the flag part of a schema.
  • Say what was checked and offer a next step: "Your policy documents don't cover payment times. The claims team can tell you: call the claims line or use the app." A dead end teaches users to rephrase until the bot guesses.
  • Log why. "Not in the corpus" and "in the corpus but not retrieved" look identical to the user, and need opposite fixes. The first is a content gap; the second is a retrieval bug hiding behind a polite refusal. On your labeled answerable questions, check whether the gold chunk was in the prompt when the bot abstained (the gold-in-prompt rate from Context Augmentation).
  • Ask when the question is ambiguous. "Is my home office equipment covered?" depends on what happened to it. A clarifying question beats both a refusal and a guess. Rewriting and clarifying are covered in Query Understanding & Intent.

Abstention has been a research topic for years. SQuAD 2.0 (Rajpurkar, Jia & Liang, ACL 2018) added unanswerable questions to a reading-comprehension benchmark so that systems had to learn to decline. Kamath, Jia & Liang (ACL 2020) found that abstaining on a QA model's own softmax probability worked poorly on out-of-domain questions, where the model was overconfident; a separately trained calibrator answered 56% of their questions at 80% accuracy, against 48% for the raw probabilities. Those are results on their benchmarks. The transferable point: a model's confidence in its own output is not an abstention signal until you calibrate it on your data.

Your turn. After a re-index, the bot's abstention rate on live traffic jumps from 7% to 19%. The gate prompt and the model did not change. What do you check first?

Check your answer

Retrieval, not the gate. Take labeled answerable questions that are now refused and look at the logged prompts: if the gold chunk is missing, the re-index broke retrieval (a changed chunker, a filter, a different embedding model), and the gate is correctly saying "these passages don't answer it". A rising abstention rate is often the first visible symptom of a retrieval regression, which is one reason to chart it daily.

Move 2: Check every claim before it ships

Symptom: a sentence cites a source that does not say what the sentence says.

The idea: split the answer into sentences, pair each sentence with the passages it cites (sentences, not the atomic claims of Faithfulness Testing: splitting into claims costs an LLM call per answer, and without it a sentence with one right and one wrong fact can pass), and ask a checker whether the passage entails the sentence. A natural language inference (NLI) model does this for a (premise, hypothesis) pair and returns three probabilities: entailment, neutral, contradiction. For grounding, neutral is a failure too: "the passage doesn't say" is exactly an unsupported claim. So score each sentence by its entailment probability, not by "is it a contradiction?".

We use cross-encoder/nli-deberta-v3-base, a 184M-parameter cross-encoder trained on SNLI and MultiNLI (Apache 2.0). Read the label order from the model's config; checkpoints differ, and this one outputs raw logits, so ask for a softmax.

from sentence_transformers import CrossEncoder

nli = CrossEncoder("cross-encoder/nli-deberta-v3-base")
ENTAIL = nli.model.config.label2id["entailment"]   # this model: contradiction, entailment, neutral
CITE = re.compile(r"\[(S\d+)\]")

def split_claims(answer, labels):
    """(claim, doc ids) per sentence: the passages it cites, or every passage in the
    prompt when it cites none."""
    sentences = []
    for part in re.split(r"(?<=[.!?])\s+", answer.strip()):
        if sentences and re.fullmatch(r"(\[S\d+\]\s*)+", part):   # "...claim. [S1]"
            sentences[-1] += " " + part
        elif part:
            sentences.append(part)
    claims = []
    for s in sentences:
        docs = [labels[c] for c in CITE.findall(s) if c in labels] or list(labels.values())
        claims.append((re.sub(r"\s*\[S\d+\]", "", s).strip(), docs))
    return claims

def windows(docs):
    """NLI premises: one sentence, or two adjacent sentences, inside one passage."""
    out = []
    for d in docs:
        sents = re.split(r"(?<=[.!?])\s+", POLICY[d])
        out += sents + [a + " " + b for a, b in zip(sents, sents[1:])]
    return out

def nli_support(pairs):
    """Entailment probability for each (claim, premise) pair."""
    probs = nli.predict([(premise, claim) for claim, premise in pairs], apply_softmax=True)
    return [round(float(p[ENTAIL]), 3) for p in probs]

def claim_support(claims):
    """Best entailment over each claim's windows; one batch for all of them."""
    scores = iter(nli_support([(claim, w) for claim, docs in claims for w in windows(docs)]))
    return [max(next(scores) for _ in windows(docs)) for _, docs in claims]   # same order

for i in (1, 3, 11, 12):
    answer, labels, _ = RUNS[i]
    claims = split_claims(answer, labels)
    print(EVAL[i][0])
    for (claim, _), score in zip(claims, claim_support(claims)):
        print(f"  {score:.3f}  {claim}")

Predict: which of these sentences get flagged at an entailment cut-off of 0.5? The 250-euro answer is wrong, the unlocked-door "Yes" is wrong, the standard excess and the roof answer are right.

Check your answer
What excess do I pay on a burst-pipe claim?
  0.011  The excess you pay on a burst-pipe claim is 250 euros.
Someone walked in through an unlocked door and took my laptop. Am I covered?
  0.997  Yes, you are covered for theft of contents if the thief used force or violence to get in or out.
What is the standard excess?
  0.995  The standard excess is 250 euros.
Storm winds blew tiles off my roof. Am I covered?
  0.001  Storm winds blew tiles off my roof.
  0.997  We cover damage to your buildings caused by a storm.
  0.998  We do not cover storm damage to fences, gates or hedges.

One catch, one miss, one false alarm:

  • Caught: the 250-euro sentence scores 0.011.
  • Missed: the unlocked-door "Yes" scores 0.997. Read as a sentence, it restates the clause, condition included, and NLI checks sentences. It never sees that this customer's door was unlocked.
  • Passed, thanks to the windows: "The standard excess is 250 euros." appears word for word in the passage, and its window scores 0.995. Against the whole clause as one premise, the same sentence scores 0.000: the clause's last sentence, about escape-of-water claims, is enough to push this model to "neutral".
  • False alarm: "Storm winds blew tiles off my roof." is the customer's own words, repeated. It is not a claim about the policy, and it scores 0.001.

A small NLI model is strict and brittle. It is fast (the 50 labeled sentences below have 298 windows between them, and one batch took about 0.7 s on our laptop, about 14 ms per sentence), and it reliably notices a wrong number in a sentence that uses the passage's own words. But it knows little about synonyms ("burst pipe" vs "escape of water", "dog" vs "pets"), cannot do arithmetic ("three months" vs "60 consecutive days"), and treats a restated question as a claim. So it flags a lot of correct answers.

⚠️ It also truncates silently. Like most encoders, this one reads at most 512 tokens for premise and claim together. predict() cuts the rest without an error or a warning:

long_premise = " ".join(["Our office is open on weekdays and closed on public holidays."] * 60) \
               + " " + POLICY["hotel"]
claim = "We pay for accommodation up to 50,000 euros per claim."
print(len(nli.tokenizer(long_premise)["input_ids"]), nli.max_length)   # the tokenizer warns here
print(nli_support([(claim, POLICY["hotel"]), (claim, long_premise)]))   # predict() does not
757 512
[0.0, 0.956]

The same false claim goes from contradicted to "entailed, 0.956" once the clause that refutes it is pushed past the cut. That is the second reason claim_support never hands the model a whole passage; the first is the verbatim excess sentence above. So score each sentence against windows of one or two sentences inside each passage it cites, keep the best score, and never join passages into one premise: Faithfulness Testing and Citation Coverage show that a long or joined premise both hides support and lets half-false claims through. A sentence that no window supports goes to the judge below, which reads whole passages. For long documents, some checkers split them themselves (the package released with MiniCheck does).

A judge for what NLI flags

An LLM judge reads the sentence, the passage and the question, and can handle paraphrase and simple reasoning. It costs a model call per sentence. So run it only where NLI objects: a cascade.

JUDGE = ("You check answers from an insurance help bot against the policy text. "
         "Read the sentence as an answer to the customer's question. Reply with one word: "
         "SUPPORTED if the passage states or directly implies everything the sentence claims, "
         "CONTRADICTED if the passage says otherwise, UNSUPPORTED if the passage does not say.")

def judge(question, claim, premise):
    user = f"Question: {question}\nPassage: {premise}\nSentence: {claim}\nVerdict:"
    return generate(JUDGE, user, model_id=BIGGER, max_new_tokens=5).split()[0].strip(".").upper()

def verify(question, answer, labels, threshold=0.5):
    """Cascade: NLI on every sentence; the judge only on sentences NLI does not pass."""
    claims = split_claims(answer, labels)
    flagged = []
    for (claim, docs), score in zip(claims, claim_support(claims)):
        if score < threshold:                          # the judge reads the whole passages
            verdict = judge(question, claim, " ".join(POLICY[d] for d in docs))
            if verdict != "SUPPORTED":
                flagged.append((verdict, claim))
    return flagged

for i in range(16):
    answer, labels, _ = RUNS[i]
    if answer.strip() != REFUSAL and not shape_problems(answer, labels):
        for verdict, claim in verify(EVAL[i][0], answer, labels):
            print(i, verdict, "|", claim)
1 CONTRADICTED | The excess you pay on a burst-pipe claim is 250 euros.
6 UNSUPPORTED | Yes, we will pay for a hotel if your house is flooded by a burst pipe.
10 CONTRADICTED | Your washing machine hose is considered a fixed pipe, so the escape of water from it is covered.

The cascade caught three of the four bad answers: the 250 euros, the hotel promise (cited to the escape-of-water clause, which says nothing about hotels), and the invented "fixed pipe" reasoning. The judge cleared the roof and the fence, and NLI passed the verbatim excess sentence and "Yes, there is a limit on what you pay for a hotel" before the judge saw them. That second one matters: run alone, the judge calls it contradicted, apparently reading "you" as the customer. The unlocked-door "Yes" never reached the judge either: NLI passed that sentence at 0.997, so the cascade never asked. (Run alone, the judge passes it too.) A cascade re-checks only what NLI flags; whatever NLI wrongly passes goes straight through.

Which checker belongs in the request path? We hand-labeled 50 answer sentences to the 16 answerable questions, written by three setups from this lesson: the 1.7B bot, the 4B model with the same prompt, and the 4B model quoting first (Move 3). Four are unsupported. One sentence about the washing-machine hose that we could not label either way was left out. At an entailment cut-off of 0.5:

In the request path Unsupported caught (of 4) Correct sentences flagged (of 46) Added cost per sentence on our laptop
NLI alone, on sentence windows 3 17 about 14 ms in a batch (about 6 windows)
LLM judge alone 3 3 one generation, about 0.6 to 1 s
Cascade 3 0 the judge on the 20 sentences NLI flagged, not on all 50

NLI alone would block more than a third of the correct sentences, and no cut-off fixes that: its probabilities are so extreme that every cut-off from 0.2 to 0.9 gave the same result. The windows help: with each sentence scored against its whole cited clause, and the ten uncited sentences against all three clauses joined, NLI flagged 21 correct sentences, not 17 (the windows cleared five of those and newly flagged one). The judge alone is accurate and costs a generation per sentence. The cascade matched the judge's catches without any of its three false alarms, which NLI had passed, for 20 judge calls instead of 50. For a four-sentence answer that is one or two judge calls, and running them in parallel keeps the added wait near one generation.

One caveat on our setup: the judge, Qwen3-4B, is the bot's bigger sibling and wrote 28 of these 50 sentences itself, while all four unsupported ones came from the 1.7B bot, so this table cannot show whether it goes easy on its own text. Generation Quality and Faithfulness Testing explain why a judge from another model family is the safer default; if you keep one family, measure its false passes on the generator's own sentences.

Fifty sentences from one small policy is a sketch, not a benchmark. Comparing checkers properly is offline work, and Faithfulness Testing does it: labeled sets with injected errors, dedicated detectors such as MiniCheck and HHEM next to NLI and a judge, the hard cases (numbers, negation, partial support), and thresholds chosen with honest intervals. The runtime question is narrower: which of the checkers you trust can a live answer afford?

The miss: a true rule that does not apply

Every checker passed "Yes, you are covered for theft of contents if the thief used force…". The sentence is true of the policy. The wrong part is the "Yes": it claims the condition holds for this customer. The fix is to check the claim the customer will act on: rewrite the sentence as a standalone statement that includes the facts from the question, then judge that. Researchers call making a sentence interpretable out of context decontextualization (Choi et al., TACL 2021).

STANDALONE = ("Rewrite the answer sentence as one standalone claim about the customer's own "
              "situation. Include the facts the customer gave in the question. Keep the answer's "
              "yes or no. Output only the claim.")

def standalone(question, sentence):
    return generate(STANDALONE, f"Question: {question}\nAnswer sentence: {sentence}\nClaim:",
                    model_id=BIGGER, max_new_tokens=80)

for i in (3, 13, 4, 7):
    (claim, docs), = split_claims(*RUNS[i][:2])
    rewritten = standalone(EVAL[i][0], claim)
    print(judge(EVAL[i][0], rewritten, " ".join(POLICY[d] for d in docs)), "|", rewritten)
CONTRADICTED | Yes, I am covered for theft of my laptop if the thief used force or violence to get in or out, as the door was unlocked and someone walked in.
SUPPORTED | Yes, I am covered for theft of my TV from my home because a burglar used force by smashing a window to get in.
SUPPORTED | Yes, accidental damage to my carpet from spilled paint is covered if my schedule includes the Accidental Damage add-on.
SUPPORTED | Yes, I can cancel within 14 days of the start date or of receiving my documents if that is later, and get a full refund since I have not made a claim.

With the unlocked door written into the claim, the judge sees the contradiction; the burglary (a smashed window) still passes. Read the last line carefully, though: the rewriter added "since I have not made a claim", a fact the customer never gave. The checking pipeline has model steps of its own, and they can invent things too. That one did not change the verdict, but log the rewritten claims and review a sample. Applied to every yes/no sentence, this step caught the unlocked-door answer and cost 2 extra model calls per verdict sentence (Move 6 counts the result).

Which passage to check against

split_claims checks a sentence against the passages it cites. The hotel answer cited the escape-of-water clause, so it failed even though the hotel clause was in the prompt. That is the right default if your interface shows citations: a citation that points to the wrong clause misleads the reader who clicks it (Citation Coverage measures exactly this). The course keeps two measures apart. Faithfulness asks whether each claim is supported by anything in the context. Citation recall asks whether each sentence is supported by the passages it cites, and citation precision whether each individual citation supports (or is needed for) its sentence. Checking against the cited passages is the stricter test.

When a cited check fails, it is tempting to try the other passages in the prompt and, if one supports the sentence, fix the citation instead of refusing. Try it on the hotel answer:

claim, _ = split_claims(*RUNS[6][:2])[0]
print(judge(EVAL[6][0], claim, POLICY["hotel"]))
# SUPPORTED

Against the hotel clause the judge passes it, although the answer drops the clause's condition that the home "cannot be lived in". A fallback to other passages lets more answers through and lowers the bar at the same time. If you add it, measure its false passes on labeled sentences, and keep it out of the high-risk tier (Move 5).

Other signals, and what they measure

  • Agreement across samples. Sample several answers and flag claims they disagree on; SelfCheckGPT (Manakul et al., EMNLP 2023) does this without any source. Agreement measures the model's stability, not support: five samples that misread the same clause in the same way agree perfectly. It also multiplies generation cost.
  • Token probabilities. Not every API exposes them, and a confident token is not a supported one.
  • Verbalized confidence ("I'm 90% sure"): text the model writes like any other; calibrate it before you use it.
  • Provider checks, as dated examples (September 2026): Amazon Bedrock Guardrails has a contextual grounding check that scores grounding and relevance against thresholds you set between 0 and 0.99. Its documentation lists limits (for example 100,000 characters of source and 5,000 of response) and says conversational chatbot use cases are not supported. Vectara publishes an open detector, HHEM-2.1-Open. Measure any of them on your labeled sentences before you trust its threshold.

Move 3: Quote first, and constrain the output

Symptom: the answer paraphrases the clause and loses the number or the condition, and the checker cannot tell a good paraphrase from a bad one.

Anthropic's guide to reducing hallucinations lists three basic moves: let the model say "I don't know", ask it to "extract word-for-word quotes first" (it recommends this for long documents, over 20k tokens), and have it support each claim with a quote and retract claims it cannot support. The last two share one idea: make the model commit to exact evidence before it writes anything else. Quotes have a big practical advantage: you can check them without a model. A quote is either a verbatim span of the passage or it is not.

QUOTES_FIRST = ("You answer questions from Tidewell Home Insurance customers.\n"
  "Rules:\n"
  "1. First copy, word for word, every sentence from the sources that you need, one per line, as: QUOTE [S2] \"...\"\n"
  "2. Then write ANSWER: followed by your answer. Use only facts in your quotes and cite them like [S2].\n"
  f"3. If no sentence in the sources answers the question, write no quotes and only: ANSWER: {REFUSAL}")
QUOTE_LINE = re.compile(r'QUOTE \[?(S\d+)\]? "(.+)"')

def norm(text):
    return " ".join(text.split()).lower()

def check_quotes(output, labels):
    """Every line before ANSWER: must be one verbatim quote of the passage it names,
    and the answer itself must pass the shape check of the hook."""
    head, found, answer = output.partition("ANSWER:")
    problems, quotes = [], 0
    for line in filter(None, (line.strip() for line in head.splitlines())):
        match = QUOTE_LINE.fullmatch(line)
        if not match:
            problems.append(f"not a quote line: {line[:40]!r}")
        elif match[1] not in labels or norm(match[2]) not in norm(POLICY[labels[match[1]]]):
            problems.append(f"not verbatim: {match[2][:60]!r}")
        else:
            quotes += 1
    answer = answer.strip() if found else None
    if answer is None:
        problems.append("no ANSWER line")
    elif answer != REFUSAL:
        if quotes == 0:
            problems.append("answer without a verified quote")
        if "QUOTE" in answer:
            problems.append("quote line inside the answer")
        problems += shape_problems(answer, labels)
    return answer, problems

for model in (BOT, BIGGER):
    for i in (1, 15):
        user, labels = build_prompt(EVAL[i][0], RUNS[i][2])
        print(model, i, check_quotes(generate(QUOTES_FIRST, user, model_id=model), labels))
Qwen/Qwen3-1.7B 1 ("I can't find that in your policy documents.", [])
Qwen/Qwen3-1.7B 15 ('Yes, you can update your phone number in the app. Changes take effect immediately. [S1]', ["not verbatim: 'Changing your details. Update your address or phone number i'", 'answer without a verified quote'])
Qwen/Qwen3-4B-Instruct-2507 1 ('On a burst-pipe claim, which is considered an escape-of-water claim, you pay an excess of 500 euros [S1].', [])
Qwen/Qwen3-4B-Instruct-2507 15 (None, ['no ANSWER line'])

Four runs, four lessons:

  • The 1.7B bot, burst pipe: it copied the excess clause, 500 euros included, and then refused. Safer than 250 euros, and no help.
  • The 1.7B bot, phone number: it glued a sentence of its own after the real quote. The substring check caught it for free; an invented quote is the cheapest hallucination to detect.
  • The 4B model, burst pipe: quoting first, it wrote "a burst-pipe claim, which is considered an escape-of-water claim… 500 euros". The link the 1.7B bot never made is now written into the answer, where a reader can see it and a judge can weigh it.
  • The 4B model, phone number: it wrote the quote and stopped. No answer at all.

On all 25 questions, quotes-first made the 1.7B bot worse: not one of the 16 answerable questions got an answer past check_quotes. Seven ended in the refusal, often right after quoting the clause that answered them, and three stopped after the quotes. The six answers it did write all failed a check: two dropped their citations, two pasted a QUOTE line into the answer, and two followed a broken or invented quote. With the 4B model, quotes-first fixed the burst-pipe answer and shipped no wrong answer (11 right). Four answerable questions ended after the quotes, with no answer at all, and one right answer, "No, you are not covered because the thief walked in through an unlocked door…", cited nothing, so the shape check held it back. A prompt technique is a change like any other: it can help one model and hurt another, so measure it per model.

The stopped answers are a format failure, and the cheapest fix is to make the model continue: send its own output back with ANSWER: appended as the start of its reply (prefilling the reply, which is not the prefill phase of Performance Optimization), and let it finish. Local models like ours accept that. Current Claude models, including the claude-sonnet-5 in the sketch below, reject a prefilled reply with a 400 error, so with them use the schema of the next section instead.

def quotes_first(question, hits, model_id=BIGGER):
    user, labels = build_prompt(question, hits)
    output = generate(QUOTES_FIRST, user, model_id=model_id)
    answer, problems = check_quotes(output, labels)
    if problems == ["no ANSWER line"]:            # it stopped after the quotes: make it continue
        prefill = output + "\n\nANSWER:"
        output = prefill + " " + generate(QUOTES_FIRST, user, model_id=model_id, prefill=prefill)
        answer, problems = check_quotes(output, labels)
    return answer, problems

print(quotes_first(EVAL[15][0], RUNS[15][2]))
# ('Yes, you can update your phone number in the app by going to Profile > Details [S1].', [])

With that continuation, the 4B setup answered 15 of the 16 answerable questions, all correctly, and refused all 9 others; the 16th was the uncited unlocked-door answer. That is one run on 25 questions we wrote ourselves; Move 6 says how much it proves.

Constrained generation: make the format impossible to break

Parsing QUOTE lines with a regex is fragile. Many APIs can now constrain decoding to a JSON schema, so the output always parses. As of September 2026, Anthropic's structured outputs take a schema in output_config.format and support enum. That lets the schema list the only source labels the model may cite, and it makes "answerable" a field instead of a sentence you hope to recognize:

# Sketch: needs `pip install anthropic` and an API key. Not run for this lesson.
import json
import anthropic

MODEL = "claude-sonnet-5"          # an example id as of 2026-09; pick a current model
client = anthropic.Anthropic()     # reads ANTHROPIC_API_KEY from the environment

def grounded_json(user, labels):
    schema = {
        "type": "object",
        "properties": {
            "answerable": {"type": "boolean"},
            "quotes": {"type": "array", "items": {
                "type": "object",
                "properties": {"source": {"type": "string", "enum": sorted(labels)},
                               "text": {"type": "string"}},
                "required": ["source", "text"], "additionalProperties": False}},
            "answer": {"type": "string"},
        },
        "required": ["answerable", "quotes", "answer"],
        "additionalProperties": False,
    }
    response = client.messages.create(
        model=MODEL, max_tokens=2000,
        system="Answer only from the sources. Put the word-for-word sentences you rely on in "
               "quotes. If the sources do not answer the question, set answerable to false.",
        messages=[{"role": "user", "content": user}],
        output_config={"format": {"type": "json_schema", "schema": schema}})
    if response.stop_reason != "end_turn":      # "refusal" or "max_tokens": may not match the schema
        return None
    return json.loads(next(b.text for b in response.content if b.type == "text"))

The docs note that a response stopped by a refusal or by max_tokens may not match the schema, and that structured outputs cannot be combined with the provider's native citations feature or with prefilling. Other providers and local servers offer the same idea under names like JSON mode, strict schemas or grammar-constrained decoding; check the one you use.

What the constraint guarantees: valid JSON, a boolean answerable, and source labels from your list. What it does not guarantee: that a quote is verbatim, or that the answer follows from the quotes. The same goes for the old idea of "only allow tokens from the context":

def words(text):
    return set(re.findall(r"[a-z0-9,-]+", text.lower()))

claim = "For escape-of-water claims the excess is 250 euros."
print(words(claim) <= words(POLICY["excess"]))               # every word comes from the source
print(claim_support([(claim, ["excess"])]))                  # ...and the claim is false
True
[0.004]

Every word of that sentence is in the clause, and the sentence is false. A vocabulary constraint stops invented names and numbers, not recombined ones. Keep check_quotes and the claim check behind any constraint.

For the highest-stakes answers there is a blunter option: don't paraphrase at all. Show the verified quote with a fixed lead-in ("Your policy says:") and a link. It reads less smoothly, and it cannot misstate the clause, although it can still quote the wrong one, so keep the answerability gate in front of it.

Move 4: Repair, escalate or refuse

Symptom: the check flags an answer, and the pipeline has no plan for what comes next.

The tempting move is to tell the model what was wrong and ask again:

REPAIR = ('{user}\n\nA previous answer was rejected because this sentence is not supported by '
          '[{label}]: "{claim}" Answer again, using only what the sources say.')

question, hits = EVAL[1][0], RUNS[1][2]
user, labels = build_prompt(question, hits)
retry = REPAIR.format(user=user, label="S1", claim="The excess you pay on a burst-pipe claim is 250 euros.")
print("same model, feedback:  ", generate(SYSTEM, retry))
print("bigger model, feedback:", generate(SYSTEM, retry, model_id=BIGGER))
print("bigger, quotes first:  ", quotes_first(question, hits))
same model, feedback:   The excess you pay on a burst-pipe claim is 250 euros. [S1]
bigger model, feedback: I can't find that in your policy documents.
bigger, quotes first:   ('On a burst-pipe claim, which is considered an escape-of-water claim, you pay an excess of 500 euros [S1].', [])

The same model, told exactly which sentence was wrong, wrote the same sentence again. With greedy decoding and the same evidence, expect that: the retry contains no new information that changes its most likely reading. The bigger model refused, which is safe and unhelpful. The bigger model with quotes first got it right. A repair only works if it changes something: the model, the evidence, or the way the model must use it.

Repair What changes Cost Risk
Drop the flagged sentences nothing is regenerated free removes a condition or the evidence; the rest may no longer answer the question
Regenerate with feedback the prompt one generation often repeats the mistake (above)
Escalate to a stronger model or a quotes-first prompt the model or its method a bigger call latency; needs its own checks
Retrieve again for the flagged claim the evidence a search plus a generation the new evidence may not settle it
Refuse, and hand over to a person the channel a human's time nothing is wrong, and nothing is answered

Two published methods sit in this table. RARR (Gao et al., ACL 2023) is the "retrieve again" row: it searches for evidence about each claim in a finished text and post-edits the unsupported parts, changing as little as possible. Chain-of-Verification (Dhuliawala et al., Findings of ACL 2024) drafts an answer, plans verification questions, answers them independently so the draft cannot bias them, and writes a final verified answer. The independence is the point: a model that verifies with its draft in view tends to agree with the draft. Both were evaluated mostly on open-domain text, where the "evidence" is web search or the model's own knowledge, so measure them on your domain before adopting them.

Whatever you choose, set a budget in code: at most one repair, then refuse. Each round adds a generation (1 to 5 seconds for a short answer from our local models, up to 16 seconds for a long quotes-first output) and another check. An answer that failed twice is telling you something about the evidence, not about your prompt.

Stream or buffer? Checks need the whole answer, but the choice has to be made before the first token, so it follows the provisional risk tier of Move 5. For low-risk questions, stream the text and retract it if a check fails afterwards. For high-risk ones, buffer, check, then show, and put a progress message in front of the user while you wait. The Bedrock guardrail documentation describes this trap in its own product: with a streaming API, an irrelevant response can reach the user in full and only then be marked irrelevant. Latency budgets and streaming are covered in Performance Optimization.

Your turn. Your repair step drops every flagged sentence. The bot answers "Escape-of-water damage is covered [S1]. This does not apply if the home was empty for more than 60 consecutive days." and the checker flags only the second sentence. What does the customer see, and what should the repair rule be?

Check your answer

The customer sees "Escape-of-water damage is covered." with the exclusion removed: a flat "yes" to someone whose house might have stood empty for three months. Dropping a sentence changes what the rest means when that sentence is a condition, an exception or a "but". Allow dropping only for sentences that add nothing the conclusion depends on (restated questions, padding), and re-run the standalone verdict check on what remains. When in doubt, regenerate or refuse; never ship a shortened answer that no checker has read as a whole.

Move 5: Match the controls to the risk

Symptom: "How do I change my address?" waits for the same judge calls as "Am I covered?", or, worse, "Am I covered?" gets the address question's light checks.

Not every answer deserves the same cost. A wrong address instruction wastes a minute. A wrong "yes, you're covered" can cost a customer thousands of euros and the insurer a complaint. Tie the controls to the risk of the content, using the clauses themselves, which are data you control.

The catch is timing. The gate, the prompt and streaming versus buffering are settled before generation, when you have only the question and the retrieved clauses. So set a provisional tier from the top-ranked clause (or a question classifier, if you have one). After generation, the cited clauses can raise it, never lower it: a wrong citation must not buy an answer lighter checks.

RISK = {"details": "low", "claims": "medium", "cancel": "medium"}   # all other clauses: "high"
ORDER = ["low", "medium", "high"]

def riskiest(doc_ids):
    """Unknown clauses count as high, and so does an empty list."""
    return max((RISK.get(doc, "high") for doc in doc_ids), key=ORDER.index, default="high")

def tiers(hits, answer, labels):
    before = riskiest([hits[0][0]])                          # set before generation
    cited = [labels[c] for c in CITE.findall(answer) if c in labels]
    after = max(before, riskiest(cited), key=ORDER.index)    # cited clauses can only raise it
    return before, after

for i in (8, 5, 2, 1):
    answer, labels, hits = RUNS[i]
    print(*tiers(hits, answer, labels), "|", EVAL[i][0])
low low | How do I change my address?
medium medium | How long do I have to report a claim?
medium high | A storm blew down my garden fence. Can I claim?
high high | What excess do I pay on a burst-pipe claim?

The fence question needs both steps. "Can I claim?" matched the claims clause best, so it started as medium; the answer cites the storm clause, a coverage rule, so it ends high. When the tier rises, run what the higher tier adds: the standalone verdict check works on the answer you have, the gate and quotes-first need a second generation, and a streamed answer must be retracted. Count how often that happens; if it is often, your provisional tier is too optimistic.

Unknown clauses default to high, and so does an answer that cites nothing. When the tier is uncertain, round up: misrouting a coverage question to the light path is the expensive error.

Tier Examples Controls On failure Accuracy target when answering
Low account settings, app how-tos shape check; stream the answer refuse, link the help page informal
Medium deadlines, cancellation, process shape check + claim cascade; buffer one repair, then refuse with the relevant page e.g. 95%
High coverage, excess, payouts answerability gate, quotes first, claim cascade, standalone verdicts; buffer refuse and hand over to a claims handler, with the quotes attached e.g. 99%, and "a claims handler decides" in the wording

Where do targets like "95%" come from? From what a wrong answer costs compared with a refusal. Kalai et al. (2025) propose that evaluations state their scoring rule in the prompt, for example: "Answer only if you are > t confident, since mistakes are penalized t/(1 − t) points, while correct answers receive 1 point, and an answer of 'I don't know' receives 0 points." Answering then pays off exactly when the probability of being right is above t. Turn it around for your product. If a wrong coverage answer costs as much as 9 right answers are worth, the penalty is 9, and t/(1 − t) = 9 gives t = 0.9: only answer when you are more than 90% likely to be right. Penalty 4 gives 0.8; penalty 1 gives 0.5.

That arithmetic needs a probability you can trust, and none of the scores in this lesson is one: NLI said 0.000 for a sentence copied word for word when it read the whole clause, and the judge answers in words. So use t as the target that your measured accuracy when answering must beat for each tier, on labeled questions of that tier. That is Move 6.

Move 6: Measure the trade-off

Symptom: support says the bot refuses too often; legal says it is wrong too often. Both are right, and nobody has the numbers.

Every control in this lesson trades coverage (the share of answerable questions that get an answer: one minus the false refusal rate of Generation Quality) for accuracy when answering (the share of shipped answers that are right). Refusing everything is 100% accurate and useless. Here are all the setups from this lesson on the same 25 questions, one letter per question from our hand labels:

SETUPS = {  # one letter per EVAL question: R right, W wrong or unsupported, A abstained
    "1.7B, shape check":          "AWRWRRWRRAWRRRRR" + "AAAAAAAAA",
    "1.7B, + NLI gate":           "AAAWRRARRAARARRR" + "AAAAAAAAA",
    "1.7B, + NLI -> judge":       "AARWRRARRAARRRRR" + "AAAAAAAAA",
    "1.7B, + standalone verdicts": "AARARRARRAARRRAR" + "AAAAAAAAA",
    "4B, shape check":            "RARRRRRRRRRRRRAR" + "AAAAAAAAA",
    "4B, quotes first":           "RRRARARRARRARRRA" + "AAAAAAAAA",
    "4B, quotes first, continue": "RRRARRRRRRRRRRRR" + "AAAAAAAAA",
}

def tradeoff(letters, n_answerable=16):
    ans, una = letters[:n_answerable], letters[n_answerable:]
    answered = ans.count("R") + ans.count("W")
    return (f"coverage {answered / n_answerable:.2f} | "
            f"accuracy when answering {ans.count('R') / (answered + una.count('W')):.2f} | "
            f"answerable refused {ans.count('A'):2} | unanswerable refused {una.count('A')}/{len(una)}")

for name, letters in SETUPS.items():
    print(f"{name:28} {tradeoff(letters)}")
1.7B, shape check            coverage 0.88 | accuracy when answering 0.71 | answerable refused  2 | unanswerable refused 9/9
1.7B, + NLI gate             coverage 0.56 | accuracy when answering 0.89 | answerable refused  7 | unanswerable refused 9/9
1.7B, + NLI -> judge         coverage 0.69 | accuracy when answering 0.91 | answerable refused  5 | unanswerable refused 9/9
1.7B, + standalone verdicts  coverage 0.56 | accuracy when answering 1.00 | answerable refused  7 | unanswerable refused 9/9
4B, shape check              coverage 0.88 | accuracy when answering 1.00 | answerable refused  2 | unanswerable refused 9/9
4B, quotes first             coverage 0.69 | accuracy when answering 1.00 | answerable refused  5 | unanswerable refused 9/9
4B, quotes first, continue   coverage 0.94 | accuracy when answering 1.00 | answerable refused  1 | unanswerable refused 9/9

(The "gate" rows run the check of Move 2 on the bot's answers and replace a flagged answer with the refusal; the quotes-first rows use check_quotes, shape check included, and count a rejected answer as a refusal. We counted the phone-number reply written in the bot's own words as a refusal.)

How to read it:

  • Checks buy accuracy with coverage. On the 1.7B bot, the NLI gate raised accuracy from 0.71 to 0.89 and cut coverage from 0.88 to 0.56, mostly by refusing correct answers it misread. The cascade got back two of them. The standalone verdicts removed the last wrong answer, at the price of more model calls and one right answer: the judge called the hotel-limit "Yes" unsupported.
  • The generator matters more than the gate. The 4B model with the plain prompt shipped no wrong answer here; its two refusals were the burst-pipe question and the hotel limit. Quotes first plus a continuation answered 15 of 16, all correctly. On this set, a better generator was worth more than any checker we bolted onto a weaker one. It also costs more per answer, which is a trade-off of its own.
  • All 9 unanswerable questions were refused everywhere. Ours were easy: whole topics missing from the policy. Real traffic has near-misses (the river flood is the closest we got). Collect hard unanswerable questions on purpose.

How much does 25 questions prove?

Not much. Our best row, quotes first with a continuation, answered 15 questions, all right, and "15 out of 15" is still compatible with a true accuracy as low as 82% (a one-sided 95% bound: the value p where p to the 15th power equals 0.05). If you observe zero errors in n answered questions, the one-sided 95% upper bound on the error rate is about 3/n (the "rule of three"). Exactly, to show accuracy above 90% you need 29 answered questions with no error; above 95%, 59; above 99%, 299. The high tier of Move 5 needs hundreds of labeled high-tier questions before anyone can honestly claim "99%". A small set is still worth having: it catches collapses and regressions. For comparing two setups on the same questions (a paired test) and computing intervals properly, see Evaluation & Quality Metrics.

Also watch for the trap in our own table: we wrote these 25 questions while building the prompts. A setup tuned on its test questions looks better than it is. Keep a held-out set, and refresh it from real traffic.

In production

Labels are slow, so also watch signals that need none, per tier and per day:

  • abstention rate (after an index change, a jump points at retrieval first, as in Move 1);
  • flag rate of each check, and the repair success rate;
  • hand-over rate, and what the human agents answered;
  • latency added by the checks (p95), and model calls per answer.

A sudden move in any of them is a reason to pull a labeled sample. Sample passed answers as well as flagged ones: the unlocked-door answer passed every automatic check, and only a reader would have caught it. Where these dashboards live and how changes are rolled out behind evaluation gates is the subject of Production Deployment & Optimization.

Final round: no label on the problem

Challenge 1: the cautious launch

A pension-fund help bot launches with an NLI claim check at an entailment cut-off of 0.9, because legal asked for "no unsupported answers". In week one, 58% of answers are refused. A sample shows correct answers such as "You can start drawing your pension at 67" flagged against a passage that says "The normal retirement age is 67." What is going on, and what do you change?

Check your answer

The checker, not the bot. Getting from "normal retirement age" to "start drawing your pension" takes one step of domain knowledge, and a small NLI model does not take it: the model from Move 2 gives this exact pair an entailment of 0.001. Its probabilities are so extreme that no cut-off, 0.9 or 0.5, rescues such sentences. Label a few hundred sentences, measure the false-alarm rate, then add a judge behind NLI (the cascade) or have the bot quote first so its answers stay close to the clause wording. Report coverage and accuracy when answering to legal as a pair, per tier. "No unsupported answers" at a 42% answer rate is a product decision they should make with the numbers in front of them.

Challenge 2: the polite refusals

A travel insurer's bot refuses 31% of questions. The gate is an LLM answerability check. A labeled sample of refused questions shows most of them are "Am I covered if…?" questions whose true answer is "no, that's excluded".

Check your answer

Move 1's trap: the gate treats "the answer is no" as "there is no answer". Rewrite the gate instruction ("A clause that excludes the situation answers the question"), add negative-answer questions to the gate's labeled set, and measure its false-refusal rate on them. Exclusions are answers customers need to hear, so this bug is expensive.

Cheat sheet: symptom → move → cost

Symptom Move What it costs Measure
Answers what the sources never say Answerability gate that reads the passages; machine-readable abstention One call before generation; false refusals, especially on "no" answers Refusals on labeled answerable and unanswerable questions
Refusals rise after an index change Check retrieval before the gate Labeled questions with gold chunk ids Gold-in-prompt rate for refused answerable questions
Cited sentence not supported by its source Claim check: NLI on one- or two-sentence windows of the cited passages, judge on what NLI flags An encoder pass per window (about 6 per sentence here); a judge call per flagged sentence Catches and false alarms on labeled sentences
True rule, wrong "yes" for this user Judge a standalone claim that includes the user's facts 2 model calls per verdict sentence Wrong verdicts shipped
Paraphrase loses the number or condition Quotes first, verified by substring, then the shape check on the answer; show quotes for top-risk answers Longer outputs; format failures to handle Share of answers with verified quotes
Output format breaks Schema-constrained decoding, or continue the output Provider support; still check content Parse failures per 1,000 answers
Flagged answer with no plan One repair that changes the model or evidence, then refuse and hand over A generation plus checks Repair success rate; latency p95
Same controls for every question Provisional risk tier from the top clause before generation, raised by the cited clauses; round up Rules to maintain Accuracy when answering per tier vs its target
"Too many refusals" vs "too many errors" Coverage and accuracy when answering, per setup and tier A labeled set with unanswerable questions Both numbers, with sample sizes

Before moving on, take one real answer your system got wrong and write down which move would have stopped it, which check would have flagged it, and what that check would have cost on your traffic.

How would you measure it on your own data? Collect 100–300 real questions per risk tier, including ones your sources cannot answer and ones whose answer is "no". Label each answer right, wrong or abstained. Run your current setup and one change at a time, and compare coverage and accuracy when answering per tier against the targets you set from the cost of a wrong answer. Sample passed answers every week, not only flagged ones.

Next: Faithfulness Testing to test the checkers offline, Citation Coverage to measure whether every claim is backed by the passage it cites, and Generation Quality for judging answers beyond grounding.