Grounded Answer Generation and Abstention
Consider a hypothetical expense assistant that answers questions from a company’s travel policy. An employee asks, “What is the nightly hotel limit in Paris?” Retrieval does its job: it returns the passage that sets 180 dollars per night for standard cities and the passage that sets 260 dollars for high-cost cities. Neither names Paris, and nothing in the retrieved text says which group Paris belongs to. A fluent model will usually pick one number anyway and attach a citation to the passage it came from. The citation is real, the passage is real, and the answer is a guess.
Nothing went wrong in search. The failure sits between retrieval and the user: choosing which passages the model may rely on, requiring that every claim point to its support, deciding what to do when sources disagree, noticing that the evidence does not answer the question, and checking the answer before it is shown. After hybrid retrieval and reranking have produced a ranked list of candidate chunks (the prerequisite for everything below), these are the decisions that determine whether the system is trustworthy. The overview of retrieval-augmented generation introduces the whole workflow; this article works through the generation half in depth.
The ideas are demonstrated on a small hypothetical policy corpus and a deterministic stand-in for the model, so that every behavior can be traced. The corpus, the toy relevance score and the scripted “model” are written for this article. They are not a retriever or a language model, and the numbers they produce illustrate mechanisms, not the performance of any real system. All code was run with Python 3.12 using only the standard library.
What a grounded answer promises
An answer is grounded when each claim in it is supported by evidence the application supplied for this request. Support is a relation between a claim and a passage: the passage, read in its context, says or entails the claim. Three properties are easy to confuse and must be kept apart.
- Grounded: the claim follows from the supplied evidence. A claim from the model’s memory is ungrounded even when it is true.
- Correct: the claim is true of the world. Grounded and correct diverge when the evidence is outdated, wrong, or applies to someone else, as with a superseded policy version.
- Cited: the answer points to the passage that supports the claim. A citation is a pointer. It is accurate only if the passage really supports the claim, and a pointer to a real passage can still be wrong.
The term hallucination covers several of these failures at once. Separating them matters because each has a different detector and a different fix. An ungrounded claim is caught by comparing the answer with the evidence. An incorrect but grounded claim is a corpus or selection problem. A claim with a wrong citation is a bookkeeping problem the application can often catch mechanically. This is also why a model that “knows when it does not know” is not enough: language models trained on next-token prediction produce a plausible continuation whether or not the context contains the answer, so the application has to enforce the boundary itself.
The contract the generator is held to
The design below rests on one contract: the generator returns either an answer made of claims, each with citations and a supporting quote, or an abstention. Each claim is a small object:
{"claims": [{"text": "The nightly hotel limit in standard cities is $180.",
"cites": ["c1"],
"quote": "The nightly hotel limit in standard cities is $180 per night."}]}
A structured shape of this kind is checkable by code, which is the point. It follows the same approach as the schema validation in integrating model APIs: the model proposes, and the application validates before anything reaches the user. The chunk identifiers in cites are the application’s own stable IDs, assigned when the context was built (the prompt side of this is covered in designing context and prompt interfaces). The quote field asks the model to copy the words that support the claim, which gives the application an exact string to look for.
Between the ranked candidates and the shown answer, the pipeline makes five decisions in order.
candidates (ranked chunks with scores)
| 1. select evidence: filter by version, audience, duplicates, relevance floor, budget
v
evidence set
| 2. resolve conflicts: apply source rules, flag what is left over
v
resolved evidence + open conflicts
| 3. gate: answer, or abstain (no evidence / insufficient / conflict)
v
| 4. generate claims with citations and quotes
v
draft answer
| 5. verify: schema, citation IDs, quotes, numbers, support; repair, fall back, or abstain
v
shown answer or abstention message
The toy corpus has eleven chunks from a hypothetical travel policy and neighboring documents. Each chunk carries metadata that, in a real system, comes from ingestion (the retrieval index article covers keeping provenance and versions with chunks): the document and version, the effective date, whether the version is current, the audience it applies to, an authority rank for the source type, and a key and value that name the fact the chunk states. Extracting that key and value is itself a modeling task; here they are written by hand. The authority rank, with 3 for the official policy, 2 for the FAQ and 1 for the team wiki, is a design choice that the application owner makes.
| ID | Source (version) | Effective | Audience | Authority | Fact (key = value) |
|---|---|---|---|---|---|
| c1 | travel-policy v3 (current) | 2024-07-01 | employee | 3 | hotel_standard = 180 |
| c2 | travel-policy v2 (superseded) | 2023-01-01 | employee | 3 | hotel_standard = 150 |
| c3 | travel-policy v3 | 2024-07-01 | employee | 3 | hotel_high_cost = 260 |
| c4 | travel-policy v3 | 2024-07-01 | employee | 3 | receipt_threshold = 25 |
| c5 | expense-faq v1 | 2024-08-01 | employee | 2 | receipt_threshold = 25 |
| c6 | contractor-addendum v1 | 2024-07-01 | contractor | 3 | hotel_standard = 120 |
| c7 | travel-policy v3 | 2024-07-01 | employee | 3 | submit_deadline_days = 30 |
| c8 | team-wiki v1 | 2024-09-01 | employee | 1 | submit_deadline_days = 60 |
| c9 | annex-a v1 | 2024-07-01 | employee | 3 | mileage_rate = 0.60 |
| c10 | annex-b v1 | 2024-07-01 | employee | 3 | mileage_rate = 0.65 |
| c11 | travel-policy v3 | 2024-07-01 | employee | 3 | airfare_advance_days = 14 |
The retriever stand-in scores a chunk by the fraction of the question’s content words (lowercased, stop words removed, a light plural stemmer) that appear in the chunk. It is a lexical toy, adequate to produce a ranked list with scores. The ranked lists in the traces below are what a real retriever and reranker would supply.
Evidence selection
Retrieval answers “which chunks look related to the question.” Evidence selection answers a stricter question: which chunks may the model rely on to answer this requester, now. The two lists differ for reasons that retrieval scoring cannot see, and the model should not be left to untangle them from raw text.
Relevance is not applicability
A chunk can match the question very well and still be the wrong chunk. Four filters cover the common cases, and each needs a field that was recorded at ingestion.
- Version. A superseded version describes a rule that no longer holds. Excluding it before generation is safer than asking the model to prefer the newer text. Some questions need the old version (“what was the limit last year?”), so the filter should follow an effective date that the request can set, with “now” as the default.
- Audience or scope. A contractor addendum is relevant to a hotel question and not applicable to an employee. Scope fields such as region, product tier or contract type work the same way.
- Near-duplicates. The same sentence in a policy and in an FAQ would otherwise occupy two evidence slots and, worse, look like independent confirmation. Collapsing duplicates keeps the budget for distinct information.
- Relevance floor and budget. Chunks below a minimum score add tokens and distraction without adding support. The budget arithmetic and packing order are covered in the context interface article; the point here is that the cut is recorded with a reason.
Permission filtering belongs before all of these. A chunk the requester may not read must never reach the model, and the same holds for any cache or expansion step; preserving access controls in retrieval data covers how to carry permissions through the pipeline.
def jaccard(a, b):
a, b = set(terms(a)), set(terms(b))
return len(a & b) / len(a | b)
def select_evidence(cands, audience, max_items=4, dup=0.7, floor=0.4):
kept, log = [], []
for c in cands:
if c["score"] < floor:
log.append((c["id"], f"dropped: score {c['score']} below floor {floor}")); continue
if c["status"] != "current":
log.append((c["id"], "dropped: superseded by a newer version")); continue
if c["audience"] != audience:
log.append((c["id"], f"dropped: applies to {c['audience']}, requester is {audience}")); continue
twin = next((k for k in kept if jaccard(k["text"], c["text"]) >= dup), None)
if twin:
log.append((c["id"], f"dropped: near-duplicate of {twin['id']}")); continue
if len(kept) == max_items:
log.append((c["id"], "dropped: budget")); continue
kept.append(c); log.append((c["id"], "kept"))
return kept, log
select_evidence walks the ranked candidates in order and records one decision per chunk. Duplicates are detected by the Jaccard overlap of their content words, \(|A\cap B|/|A\cup B|\) for word sets \(A\) and \(B\), which is 1 for identical sets and 0 for disjoint ones. The 0.7 threshold is a toy value; real near-duplicate detection uses shingling or embeddings and has its own error rate. The traces for three questions follow.
Q: What is the nightly hotel limit in a standard city?
candidates: [('c1', 1.0), ('c2', 1.0), ('c6', 1.0), ('c3', 0.8)]
c1 kept
c2 dropped: superseded by a newer version
c6 dropped: applies to contractor, requester is employee
c3 kept
conflicts: []
evidence: ['c1', 'c3'] coverage: 1.0 missing: []
decision: answer
Q: When are receipts required?
candidates: [('c4', 1.0), ('c5', 1.0)]
c4 kept
c5 dropped: near-duplicate of c4
conflicts: []
evidence: ['c4'] coverage: 1.0 missing: []
decision: answer
Q: When must reimbursement requests be submitted?
candidates: [('c7', 1.0), ('c8', 0.5), ('c11', 0.25)]
c7 kept
c8 kept
c11 dropped: score 0.25 below floor 0.4
conflicts: [('submit_deadline_days', 'resolved', 'c7', "authority 3 outranks ['c8']")]
evidence: ['c7'] coverage: 1.0 missing: []
decision: answer
Read the first trace from the top. The retriever returns four chunks for the standard-city question. The superseded c2 and the contractor-only c6 have a perfect lexical score, which is exactly why they are dangerous: a ranking by relevance ties them with the applicable chunk c1. Both are dropped with a reason that goes into the trace. The high-cost chunk c3 stays, because it is a legitimate, current, applicable chunk about hotel limits. The model will see two numbers and must choose the one that matches the question, which is a job the later verification step has to check. The second trace removes c5 as a duplicate of c4. In the third, the wiki chunk c8 is kept at this stage because it is current and applicable; its disagreement with c7 is the conflict handled in the next section.
Relevance is not sufficiency
The last selection question is whether the chosen evidence is enough. A single passage can fully answer a lookup question. A question with several parts needs support for each part, and a comparison needs both sides. Sufficiency is the property the abstention gate tests later, and it is best computed over the whole evidence set, not chunk by chunk. The toy version, shown with the gate, measures how many of the question’s content words appear anywhere in the selected evidence. It is a stand-in for the questions that matter in a real system: is every sub-question covered, and is every entity or condition in the question present in the evidence?
Two limits of selection are worth stating plainly. Filters are only as good as the metadata: a chunk without an effective date or an audience tag passes every filter, so the filters give no protection against records that were never labeled. And selection can discard a chunk that the answer needed. Because every drop is logged with its reason, a missing-evidence failure can be traced to the rule that caused it.
Citations
What a citation has to carry
A citation lets a reader, or a program, check a claim against its source. That requires three things: an identifier that resolves to one chunk and its source version, a way to locate the supporting words, and a claim small enough that the support is unambiguous. The application should hold the mapping from display numbers to chunk IDs and resolve links from its own records, as described in the overview article; a link written by the model is a string the model made up.
Granularity is a trade-off. A citation at the answer level (“sources: [1], [2]”) is cheap and tells the reader almost nothing, since any claim could come from either source. A citation per sentence or per claim lets the reader and the checker pair each claim with its support, at the cost of a more structured output and more tokens. A quoted span makes the pairing exact: the application searches for the string, and a quote that is not in the cited chunk is a fabricated or altered citation. The cost of quotes is that the model must copy faithfully, so very long quotes raise the chance of a harmless mismatch (whitespace, a dropped word) that fails the check. Short quotes of the decisive phrase work better than whole paragraphs, and the comparison should normalize whitespace and case.
What citations do and do not show
A citation proves less than it appears to. Three different checks are folded into the one idea of a “correct citation”:
- The cited ID exists among the chunks supplied for this request. This is a lookup, and a failure means a fabricated reference.
- The quoted words appear in the cited chunk. This is a string match, and a failure means an altered or invented quote.
- The chunk supports the claim. This is semantic. A chunk can contain the quoted words and still not support the claim, because the claim changes the scope, the condition, or the quantifier.
The first two are cheap, deterministic and should always run. The third needs a judgment of entailment, which is the expensive and fallible part, and it is treated in the verification section.
The research literature measures this directly. Rashkin and colleagues propose “attributable to identified sources” (AIS) as an evaluation framework: human raters judge whether a statement is supported by the identified source passages. Gao and colleagues build automatic metrics for citation quality alongside fluency and correctness, and report that on the ELI5 question-answering dataset even the best models they tested lack complete citation support about half the time. Liu, Zhang and Liang audited four commercial generative search engines with human raters in 2023 and found that 51.5% of generated sentences were fully supported by their citations and 74.5% of citations supported the statement they were attached to. Those two numbers correspond to two metrics worth defining for your own system, citation recall and precision, which the glossary entry works through on a small example:
- Citation recall: the share of statements that are fully supported by the passages cited for them. Low recall means unsupported or uncited claims.
- Citation precision: the share of citations that actually support (or at least are relevant to) the statement they are attached to. Low precision means decorative or misdirected citations.
The two move independently. Citing everything at the end of the answer raises recall artificially and destroys precision. Citing only the strongest passage for every claim raises precision and lowers recall. Both need a rater, a rubric and a labeled sample to measure; how to build that evaluation is the subject of evaluating retrieval and answers separately.
The consequence for design is that a citation should be treated as a claim by the model about its own answer, to be verified like any other output. The user interface should also avoid overstating it: a numbered marker next to a sentence reads as “this is established,” and a verified citation is the only thing that earns that reading.
Conflicting sources
Evidence sets contain disagreements more often than a clean corpus suggests. Xu and colleagues distinguish context-memory conflicts (the context disagrees with what the model learned in training), inter-context conflicts (passages within the context disagree) and intra-memory conflicts (the model’s own knowledge is inconsistent). This section deals with the inter-context case, where the application has the information needed to reduce the problem before the model sees it. The context-memory case shows up as a model that answers from memory despite the evidence; the instruction to answer only from the supplied evidence, and the verification step, are the defenses.
Four kinds of disagreement
| Kind | Example | Right response |
|---|---|---|
| Supersession | v2 says 150 dollars, v3 says 180 | Use the current version; the older one is not a competing claim. |
| Different scope | Employees get 180 dollars, contractors 120 | Both are correct; select by the requester’s scope. Not a conflict once scope is applied. |
| Different authority | The policy says 30 days, a team wiki says 60 | A rule decides, normally the higher-authority source; record that the lower one disagrees. |
| Genuine contradiction | Two annexes of equal standing give different mileage rates | No rule decides. Surface the disagreement or escalate to the owner of the documents. |
Most apparent conflicts are the first three kinds, and they can be resolved by deterministic rules over metadata, before generation. That is preferable to asking the model to adjudicate: a model has no reliable way to know that a document is superseded unless the text says so, and when it must pick between two fluent passages it may favor one by position or wording and not by authority.
def resolve_conflicts(evidence):
by_key, report = {}, []
for c in evidence: by_key.setdefault(c["key"], []).append(c)
resolved = []
for key, group in by_key.items():
values = {c["value"] for c in group}
if len(values) == 1:
resolved.append(group[0]); continue
top = max(c["authority"] for c in group)
group2 = == top]
if len({c["value"] for c in group2}) == 1:
win = group2[0]
lose = for c in group if c["id"] != win["id"] and c["value"] != win["value"]]
report.append((key, "resolved", win["id"], f"authority {top} outranks {lose}"))
resolved.append(win); continue
latest = max(c["eff"] for c in group2)
group3 = == latest]
if len({c["value"] for c in group3}) == 1:
report.append((key, "resolved", group3[0]["id"], f"newest effective date {latest}"))
resolved.append(group3[0]); continue
report.append((key, "unresolved", for c in group3], "same authority and date, different values"))
return resolved, report
The function groups evidence by the fact it states. If all chunks for a key agree, there is no conflict. If they disagree, it keeps the highest-authority sources. If only one value remains, it wins. Otherwise it falls back to the most recent effective date, and if the values still differ it reports the conflict as unresolved and keeps none of the chunks. The order of the rules is a policy decision with consequences. Here authority is checked before recency, so the newer wiki page (effective 2024-09-01) does not override the older official policy. In a domain where the newest announcement does override older rules, recency would come first. The rule order should be written down and reviewed by whoever owns the documents, not left as an accident of code.
Q: What is the mileage rate?
candidates: [('c10', 0.5), ('c9', 0.5)]
c10 kept
c9 kept
conflicts: [('mileage_rate', 'unresolved', ['c10', 'c9'], 'same authority and date, different values')]
evidence: [] coverage: 0.0 missing: ['mileage', 'rate']
decision: abstain:conflict
The deadline question, traced in the previous section, shows the resolved case: the trace records that authority 3 outranked c8, and only c7 goes forward. The answer can say “30 days” and the logged conflict tells the content owner that the team wiki contradicts the policy, which is a corpus defect worth fixing. The mileage question shows the unresolved case. Two annexes of equal authority and date give 0.60 and 0.65, and nothing in the metadata says which governs, so the evidence set is emptied and the gate abstains with the conflict as the reason.
When no key exists, for example because the chunks are free text that was never decomposed into facts, the application cannot detect conflicts by rule. Two options remain. The generation prompt can instruct the model to report any disagreement among the supplied passages and cite both, and a verifier can be run over the pair. Both are weaker than metadata rules and should be tested on a set of deliberately conflicting passages. For an unresolved conflict, the useful outputs are to present each source with its attribution and date and let the user decide, or to route to a person. A single confident number is the one response that is wrong in both cases.
Abstention
Abstention means producing no answer, and a message that says why. Treating it as a first-class outcome, with its own reasons, logs and tests, changes how the whole system is built. A system that must always answer will answer when it should not.
Why the model alone is not the abstention mechanism
An instruction such as “say you do not know if the context does not contain the answer” helps and is worth including, but it cannot be the only safeguard. The model decides on the basis of the same text that misled it, it tends toward answering because fluent completion is what it was trained to do, and its refusals vary with phrasing. Rajpurkar, Jia and Liang made this a measurable problem for reading comprehension in SQuAD 2.0 by adding over 50,000 adversarially written unanswerable questions to the dataset; a model that scored 86% F1 on the original dropped to 66% F1 on the version with unanswerable questions, because it kept proposing answers. The application therefore places a gate of its own in front of the model and tests the model’s behavior on unanswerable questions separately.
Reasons to abstain
Four different situations call for abstention, and the message and next step differ for each.
| Reason | Signal | Message and next step |
|---|---|---|
| No evidence | Nothing relevant survives selection | Say the documents searched do not cover it; offer a human contact. |
| Insufficient evidence | Evidence covers part of the question; a named entity or condition is absent | Say what is covered and what is missing; ask a clarifying question if one would help. |
| Unresolved conflict | Sources of equal standing disagree | Say the sources disagree; show them with attribution or route to the document owner. |
| Out of scope or not permitted | The question is outside the assistant’s task, or the evidence is restricted | Decline without revealing restricted content or whether it exists. |
The last row has a trap. If a restricted document answers the question and the response says “I cannot show you that document,” the response has disclosed that the document exists. When permission filtering has already removed the chunk, the system sees no evidence and says so in the same words it would use for a question nobody has answered.
The cost rule
Abstaining is not free. A user who gets no answer to a question the documents could answer has been failed, too, and a system that abstains too often is not used. The decision is a trade between two errors, and the task framing article describes how to price them with a cost matrix. For a single decision, the arithmetic is short.
Let \(p\) be the probability that the question is answerable from the evidence and that the draft answer would be right. Let \(C_w\) be the cost of showing a wrong answer and \(C_a\) the cost of abstaining when a right answer was available. Showing a right answer costs 0, and abstaining on a question that has no right answer costs 0. The expected cost of answering is \((1-p)\,C_w\), because it is incurred only when the answer is wrong. The expected cost of abstaining is \(p\,C_a\), because it is incurred only when an answer was available. Answering is better when
\[ (1-p)\,C_w \;<\; p\,C_a \quad\Longleftrightarrow\quad p \;>\; \frac{C_w}{C_w + C_a}. \]
In words: answer when the probability of being right exceeds the cost of a wrong answer relative to the total of the two costs. With \(C_w=5\) and \(C_a=1\), the threshold is \(5/6\approx0.833\). If a wrong answer is as costly as a missed one, the threshold is 0.5. As \(C_w\) grows relative to \(C_a\) the threshold approaches 1 and the system answers only when nearly certain, and if \(C_a=0\) it never answers. This is the reject option studied in selective classification (Geifman and El-Yaniv), applied to answers; that glossary entry works through the same cost rule on a synthetic example. The rule holds for a single binary choice; a third action such as “send to a human at cost \(C_h\)” adds a second comparison, and the system picks the cheapest of the three.
The rule needs \(p\), and no component hands the application a probability. Retrieval scores, reranker scores and verifier scores are rankings that become probabilities only after calibration against labeled examples. Probability calibration covers how to estimate \(p\) from a score by binning or fitting a monotone map; the labeled examples must include unanswerable questions in realistic proportion, because the proportion changes \(p\).
Choosing a threshold from data
The simulation below makes the rule concrete. It is synthetic. Each query has a score from the sufficiency signal, drawn from a Beta distribution that depends on whether the query is answerable: Beta(5, 2) for answerable queries and Beta(2, 4) for unanswerable ones, with 75% of traffic answerable. These distributions were chosen to overlap, as real scores do. They are not measurements of any system. Because the generating densities are known here, the true posterior \(P(\text{answerable}\mid s)\) can be computed by Bayes’ rule and compared with what a sample-based threshold finds. For simplicity, an answer to an answerable question is assumed to be right; generation errors would lower \(p\) further and are the job of the verification step.
import math, random
random.seed(7)
PRIOR_ANS = 0.75 # share of answerable queries in the synthetic traffic
A_ANS, B_ANS = 5, 2 # score | answerable ~ Beta(5, 2)
A_UN, B_UN = 2, 4 # score | unanswerable ~ Beta(2, 4)
C_WRONG, C_ABSTAIN = 5.0, 1.0 # answering an unanswerable query vs abstaining on an answerable one
def sample(n):
data = []
for _ in range(n):
ans = random.random() < PRIOR_ANS
s = random.betavariate(A_ANS, B_ANS) if ans else random.betavariate(A_UN, B_UN)
data.append((s, ans))
return data
def beta_pdf(x, a, b):
return math.gamma(a + b) / (math.gamma(a) * math.gamma(b)) * x ** (a - 1) * (1 - x) ** (b - 1)
def posterior(s): # P(answerable | score), by Bayes' rule from the generating densities
pa = PRIOR_ANS * beta_pdf(s, A_ANS, B_ANS)
pu = (1 - PRIOR_ANS) * beta_pdf(s, A_UN, B_UN)
return pa / (pa + pu)
def evaluate(data, t):
answered = [(s, a) for s, a in data if s >= t]
wrong = sum(1 for s, a in answered if not a) # answered but unanswerable
missed = sum(1 for s, a in data if s < t and a) # abstained but answerable
cov = len(answered) / len(data)
risk = wrong / len(answered) if answered else 0.0
cost = (C_WRONG * wrong + C_ABSTAIN * missed) / len(data)
return cov, risk, missed / len(data), cost
p_star = C_WRONG / (C_WRONG + C_ABSTAIN)
lo, hi = 0.0, 1.0
for _ in range(60):
mid = (lo + hi) / 2
if posterior(mid) < p_star: lo = mid
else: hi = mid
print(f"answer iff P(answerable|score) > {p_star:.3f}; posterior crosses it at score {hi:.3f}")
dev, test = sample(2000), sample(2000)
print("\n threshold coverage risk(answered) missed cost/query (dev set)")
for t in (0.0, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9):
cov, risk, missed, cost = evaluate(dev, t)
print(f" {t:.1f} {cov:.3f} {risk:.3f} {missed:.3f} {cost:.3f}")
grid = [i / 100 for i in range(0, 101)]
best = min(grid, key=lambda t: evaluate(dev, t)[3])
print(f"\nbest dev threshold {best:.2f}; dev cost {evaluate(dev, best)[3]:.3f}; "
f"test cost at that threshold {evaluate(test, best)[3]:.3f}")
print(f"test cost at theory threshold {hi:.2f}: {evaluate(test, hi)[3]:.3f}; "
f"answer-everything cost {evaluate(test, 0.0)[3]:.3f}")
answer iff P(answerable|score) > 0.833; posterior crosses it at score 0.580
threshold coverage risk(answered) missed cost/query (dev set)
0.0 1.000 0.247 0.000 1.238
0.3 0.877 0.150 0.006 0.664
0.4 0.806 0.104 0.029 0.447
0.5 0.728 0.069 0.074 0.325
0.6 0.600 0.044 0.179 0.311
0.7 0.449 0.019 0.312 0.354
0.8 0.275 0.005 0.479 0.486
0.9 0.083 0.006 0.670 0.673
best dev threshold 0.60; dev cost 0.311; test cost at that threshold 0.265
test cost at theory threshold 0.58: 0.264; answer-everything cost 1.185
The first line is the theory. The posterior probability of answerable reaches 5/6 at a score of 0.58, so the cost rule says to answer at scores above 0.58. The table sweeps the threshold on a 2,000-query development sample. Coverage is the share of queries answered, risk is the share of answered queries that should not have been (the unanswerable ones), and missed is the share of all queries that were answerable but abstained. Raising the threshold buys lower risk (0.247 with no gate, 0.044 at 0.6, 0.005 at 0.8) and pays for it in coverage (1.000 to 0.600 to 0.275) and in missed answers. Cost per query, which weights the two errors by \(C_w=5\) and \(C_a=1\), is lowest around 0.6: 0.311 compared with 1.238 for answering everything.
The best development threshold (0.60) lands next to the theoretical 0.58, and on a fresh 2,000-query test sample the two give nearly the same cost (0.265 and 0.264), against 1.185 for no gate. The test cost is lower than the development cost because of sampling variation, not because the threshold improved. The gate is not free of risk: at the cost-optimal setting about 4% of the answered queries are still unanswerable ones, and about 18% of all queries are abstentions on questions that had an answer. If those numbers are unacceptable, the fix lies in a better signal that separates the two score distributions, or in different costs, not in a cleverer threshold.
Several cautions apply when the same procedure is run on a real system. The threshold must be fit on labeled data that resembles production traffic, and re-fit when the corpus, the retriever or the model changes, because the score distributions move. The labeled set needs enough unanswerable examples to estimate the risk at the chosen threshold, which is a rare-event estimate and needs a confidence interval. And the sufficiency score must come from something that actually reflects whether the evidence supports an answer. A raw similarity score does not, because a question about Paris and a passage about hotel limits are similar and still insufficient.
A gate and its messages
The toy gate in the code uses the signal available in this example: the share of the question’s content words found in the selected evidence, with a floor of 0.8. It runs after selection and conflict resolution and returns one of four decisions.
# --- abstention gate ---
def sufficiency(q_terms, evidence):
covered = set()
for c in evidence: covered |= q_terms & set(terms(c["text"]))
return len(covered) / len(q_terms), sorted(q_terms - covered)
def answer_or_abstain(question, audience="employee", min_cov=0.8):
cands, q = retrieve(question)
ev, log = select_evidence(cands, audience)
ev, conflicts = resolve_conflicts(ev)
cov, missing = sufficiency(q, ev) if ev else (0.0, sorted(q))
unresolved = == "unresolved"]
if unresolved: decision = "abstain:conflict"
elif not ev: decision = "abstain:no_evidence"
elif cov < min_cov: decision = "abstain:insufficient"
else: decision = "answer"
return dict(question=question, candidates=[(c["id"], c["score"]) for c in cands], log=log,
evidence= for c in ev], conflicts=conflicts, coverage=round(cov, 2),
missing=missing, decision=decision), ev
Q: What is the nightly hotel limit in Paris?
candidates: [('c1', 0.75), ('c2', 0.75), ('c3', 0.75), ('c6', 0.75)]
c1 kept
c2 dropped: superseded by a newer version
c3 kept
c6 dropped: applies to contractor, requester is employee
conflicts: []
evidence: ['c1', 'c3'] coverage: 0.75 missing: ['paris']
decision: abstain:insufficient
Q: What is the meal allowance?
candidates: []
conflicts: []
evidence: [] coverage: 0.0 missing: ['allowance', 'meal']
decision: abstain:no_evidence
The Paris question is the opening scenario. Two chunks about hotel limits survive selection, and the coverage is 0.75 because the word “paris” appears in no chunk. The gate abstains as insufficient and names the missing term, which is information the user can act on (“is Paris a high-cost city?”). The meal-allowance question returns nothing and gets the no-evidence reason. The lexical coverage signal is deliberately crude. It cannot tell that a passage mentions a word without answering the question, and it cannot see that a question about “standard hotels in Tokyo” needs a classification of Tokyo. Real systems use a reranker score, an entailment or sufficiency judgment, or a prompted check of whether the evidence answers the question, and all of those need the calibration step above.
The user-facing text comes from the decision, so that it says what the system knows without exposing internals or restricted material.
from pipeline import answer_or_abstain
def abstention_message(d):
"""Build the user-facing text from the gate's decision. Names only what the requester may see."""
kind = d["decision"]
if kind == "abstain:no_evidence":
return {"text": "I could not find this in the policy documents I can search.",
"next": "ask_human", "log": {"reason": kind, "missing": d["missing"]}}
if kind == "abstain:insufficient":
return {"text": "The documents cover part of this question but not " + ", ".join(d["missing"]) + ".",
"next": "clarify_or_ask_human", "log": {"reason": kind, "missing": d["missing"]}}
if kind == "abstain:conflict":
keys = for c in d["conflicts"] if c[1] == "unresolved"]
return {"text": "Two current documents disagree on this and I cannot tell which applies.",
"next": "ask_document_owner", "log": {"reason": kind, "keys": keys}}
return None
What is the nightly hotel limit in Paris?
{'text': 'The documents cover part of this question but not paris.', 'next': 'clarify_or_ask_human', 'log': {'reason': 'abstain:insufficient', 'missing': ['paris']}}
What is the mileage rate?
{'text': 'Two current documents disagree on this and I cannot tell which applies.', 'next': 'ask_document_owner', 'log': {'reason': 'abstain:conflict', 'keys': ['mileage_rate']}}
What is the meal allowance?
{'text': 'I could not find this in the policy documents I can search.', 'next': 'ask_human', 'log': {'reason': 'abstain:no_evidence', 'missing': ['allowance', 'meal']}}
Each outcome carries a different next action, and a structured log with the reason. The log is the basis for operating the gate: the rate of each reason, the questions behind “no evidence” (which show gaps in the corpus), and the conflicts (which show defects in the documents). A rising abstention rate can mean that the corpus lost documents, that an index update broke retrieval, or that the threshold no longer fits; the reason codes make the three distinguishable.
Abstention is not the only response to missing evidence. A clarifying question is better when the missing piece is something the user can supply, as with the city in the Paris example. A partial answer is better when part of a multi-part question is supported and part is not, provided the response states which part is missing. A handoff to a human fits when the cost of a wrong answer is high and the user needs one. The right mix depends on the task; the acceptance criteria written for the application should say which of these the product allows.
Verifying the answer and handling failure
A draft answer has passed through a model whose behavior the application cannot guarantee, so it is checked before it is shown. Verification is layered, ordered from cheap and exact to expensive and approximate, and it stops at the first layer that fails.
| Layer | What it checks | Cost | Catches | Misses |
|---|---|---|---|---|
| Schema | Claims, citations and quotes are present and well-formed | Negligible | Malformed output, missing citations | Anything about content |
| Citation IDs | Every cited ID was supplied for this request | Negligible | Fabricated references | Real IDs that do not support the claim |
| Quotes | The quote is a substring of the cited chunks | Negligible | Altered or invented quotes | A faithful quote that does not support the claim |
| Numbers and entities | Numbers in the claim appear in the cited chunks | Negligible | Wrong figures, invented amounts | Right numbers in the wrong context |
| Support (entailment) | A judge decides whether the cited chunks entail the claim | One extra model call per claim or per answer | Changed scope, quantifiers and conditions | Judge errors in either direction |
The cheap layers in code
def verify(answer, evidence_ids, min_overlap=0.6):
"""Return a list of (claim_index, check, detail) failures. Empty list = all cheap checks passed."""
fails = []
if not isinstance(answer, dict) or not isinstance(answer.get("claims"), list) or not answer["claims"]:
return [(None, "schema", "answer needs a non-empty claims list")]
for i, cl in enumerate(answer["claims"]):
if not (isinstance(cl.get("text"), str) and isinstance(cl.get("cites"), list)
and isinstance(cl.get("quote"), str) and cl["cites"]):
fails.append((i, "schema", "claim needs text, quote and at least one cite")); continue
unknown = if c not in evidence_ids]
if unknown:
fails.append((i, "citation_id", f"cited ids not supplied: {unknown}")); continue
cited_text = " ".join(BY_ID["text"] for c in cl["cites"])
if norm(cl["quote"]) not in norm(cited_text):
fails.append((i, "quote", "quote is not a substring of the cited chunks")); continue
extra = numbers(cl["text"]) - numbers(cited_text)
if extra:
fails.append((i, "numbers", f"numbers not in cited chunks: {sorted(extra)}")); continue
claim_terms = set(terms(cl["text"]))
overlap = len(claim_terms & set(terms(cited_text))) / len(claim_terms)
if overlap < min_overlap:
fails.append((i, "term_overlap", f"only {overlap:.2f} of claim terms appear in cited chunks"))
return fails
verify returns a list of failures, each with the claim index, the check and a detail the repair step can show to the model. The term-overlap check is a weak proxy for support: it requires that most of the claim’s content words occur somewhere in the cited chunks, which catches a claim that has little to do with its citation and nothing more. To exercise the checks, a stub generator returns scripted answers. Each script reproduces one failure: a fabricated ID, an altered quote, a wrong number, a claim attached to the wrong chunk, and a claim whose meaning was changed. Two of the scripts are shown to illustrate the format; the others differ only in the field they corrupt.
"faithful": {"claims": [{"text": "The nightly hotel limit in standard cities is $180.", "cites": ["c1"],
"quote": "The nightly hotel limit in standard cities is $180 per night."}]},
"wrong_chunk": {"claims": [{"text": "The nightly hotel limit in standard cities is $260.", "cites": ["c3"],
"quote": "The nightly hotel limit in high-cost cities is $260 per night."}]},
faithful passed all checks
fabricated_id [(0, 'citation_id', "cited ids not supplied: ['c14']")]
altered_quote [(0, 'quote', 'quote is not a substring of the cited chunks')]
wrong_number [(0, 'numbers', "numbers not in cited chunks: ['200']")]
wrong_chunk passed all checks
changed_meaning passed all checks
The stub is a script, not a model: it shows what the checks do with the answers, not how often a real model produces each kind. The first four lines behave as intended. The fabricated ID fails the lookup, the altered quote fails the substring check, and the wrong figure fails the number check. The last two rows are the important ones. wrong_chunk quotes the high-cost chunk correctly and uses its number correctly, and the claim is still wrong, because the claim says “standard cities” and the chunk is about high-cost cities. changed_meaning turns “is 180 dollars” into “is at least 180 dollars”, a different rule, and every cheap check passes. This is the opening scenario in miniature: well-formed, cited, quoted and wrong. Catching it needs a check that reads the claim and the chunk together.
The support check and its limits
The support layer asks whether the cited text entails the claim. It can be implemented with a natural-language-inference classifier or with a prompted model acting as a judge. Either one is a model, and it errs. A false negative (marking a supported claim as unsupported) triggers needless repair or abstention. A false positive lets an unsupported claim through. Judges are sensitive to wording, may be fooled by fluent claims that echo the cited text, and add latency and cost. Checking each claim separately against only the chunks it cites gives sharper verdicts than checking a whole paragraph against the whole context, at the price of more calls.
Two practices keep the support layer honest. First, measure it: collect answers whose support has been labeled by people, including claims like wrong_chunk and changed_meaning, and measure how often the judge agrees, separately for the supported and unsupported cases. Second, treat its verdict as a signal with a known error rate in the cost rule above, not as ground truth. When the cost of a wrong answer is high, combine it with the deterministic layers and with the abstention gate, which are independent sources of evidence. A judge adds the most when the cheap layers already pass, which is where it earns its cost.
When verification fails
A failed check has to lead somewhere, and the options differ in what they cost and what they risk.
- Repair. Send the failures back to the model once, with the specific detail (“cited ID c14 was not supplied”), and verify the new draft. Repair works for mechanical defects. Limit it to one or two attempts, and count those calls against the latency budget, as the retry discussion in the model API article does for other retried calls.
- Extractive fallback. Show the best chunk verbatim with its source, with no generated text. The result is less helpful and cannot contain a claim that is not in the source. It also reads as a search result, which is accurate.
- Abstain. If the checks fail twice or the failure is semantic, report that no verified answer is available, with the same reasons-and-next-step structure as the gate.
- Escalate. Route to a person, with the evidence set and the failed checks attached, when the stakes justify it.
from pipeline import answer_or_abstain
from verify import verify, SCRIPTS, BY_ID
def extractive_fallback(evidence_ids):
c = BY_ID[evidence_ids[0]]
return {"claims": [{"text": c["text"], "cites": ], "quote": c["text"]}]}
def handle(question, attempts):
"""attempts: script names the stub model will emit on attempt 1, 2, ... (stands in for model calls)."""
trace = []
decision, ev = answer_or_abstain(question)
trace.append(f"gate: {decision['decision']} evidence={decision['evidence']}")
if decision["decision"] != "answer":
return {"status": decision["decision"], "trace": trace}
ids = decision["evidence"]
for n, name in enumerate(attempts, start=1):
ans = SCRIPTS[name]
fails = verify(ans, ids)
trace.append(f"attempt {n} ({name}): " + (str(fails) if fails else "verified"))
if not fails:
return {"status": "answered", "answer": ans, "trace": trace}
fb = extractive_fallback(ids)
trace.append("fallback: quote the top-ranked evidence chunk with its source")
return {"status": "extractive_fallback", "answer": fb, "trace": trace}
handle composes the pieces. It runs the gate, and if the gate says answer, it verifies attempts in order and returns the first one that passes; after the scripted attempts are exhausted it falls back to quoting the top chunk. The attempts argument stands in for successive model calls. A fixed set of four scenarios shows the four outcomes.
What is the nightly hotel limit in a standard city? -> answered
gate: answer evidence=['c1', 'c3']
attempt 1 (faithful): verified
delivered: The nightly hotel limit in standard cities is $180. ['c1']
What is the nightly hotel limit in a standard city? -> answered
gate: answer evidence=['c1', 'c3']
attempt 1 (fabricated_id): [(0, 'citation_id', "cited ids not supplied: ['c14']")]
attempt 2 (faithful): verified
delivered: The nightly hotel limit in standard cities is $180. ['c1']
What is the nightly hotel limit in a standard city? -> extractive_fallback
gate: answer evidence=['c1', 'c3']
attempt 1 (wrong_number): [(0, 'numbers', "numbers not in cited chunks: ['200']")]
attempt 2 (altered_quote): [(0, 'quote', 'quote is not a substring of the cited chunks')]
fallback: quote the top-ranked evidence chunk with its source
delivered: The nightly hotel limit in standard cities is $180 per night. ['c1']
What is the mileage rate? -> abstain:conflict
gate: abstain:conflict evidence=[]
The first request passes on the first attempt. The second is a repair: the fabricated ID fails and the second draft verifies, so the user sees only the good answer, and the trace records the first failure. The third exhausts its attempts, with a wrong figure and then an altered quote, and ends in the extractive fallback, which delivers the policy sentence for c1. The fourth never reaches generation, because the gate abstains on the unresolved mileage conflict. The trace lines are what an on-call engineer reads to find out why a user received a fallback.
The traces also show what the loop does not do. It does not decide that wrong_chunk or changed_meaning is wrong, since those pass every layer in the code. The support layer is what would catch them, and its absence from the code is the gap between this sketch and a system that is ready to run. Verification also cannot make a wrong source right: an answer that is faithful to an outdated passage that survived selection passes every check and is still wrong, which is why the metadata filters and conflict rules come first.
Operating it
Three kinds of failure need separate monitoring, because they have different owners. Corpus failures (missing documents, stale versions, conflicts) show up as abstentions with reason no_evidence or conflict and belong to the content owners. Selection and gating failures (a floor too high, a threshold stale) show up as shifts in coverage and in the missed-answer rate and belong to the retrieval owners. Generation failures show up as verification failures, repair rates and fallbacks and belong to whoever owns the prompt and model choice. Logging the reason with every non-answer lets a dashboard split a change in the abstention rate into those causes.
The prompt-side defenses matter here too. Retrieved text is untrusted, and a passage can contain instructions aimed at the model, which the prompt injection article takes up in detail. A verified citation shows that the answer follows the passage, and it says nothing about whether the passage was trying to steer the model, so the gate, the verification and the injection defenses are separate controls.
Where this design stops
- Aggregation and multi-hop questions. “How many trips exceeded the limit last quarter?” needs every record, and a top-k text search cannot promise completeness. Such a question belongs to a structured query tool, and sufficiency cannot be judged from retrieved text alone. The overview article discusses this boundary.
- Metadata quality. Version, audience and key-value fields are the control surface for selection and conflict rules. Where ingestion cannot produce them reliably, the rules become guesses and the remaining protection is the model and the verifier.
- Soft judgments. Questions that ask for interpretation or advice have no single supporting sentence, and quote-based verification fits them poorly. Support has to be judged at the level of the argument, and the cheap layers do less.
- Evaluation. Choosing thresholds, measuring citation precision and recall, and checking the judge all require labeled data. Retrieval quality and answer quality should be measured separately so a failure can be placed in the right stage, which is the subject of the evaluation article.
References
- Rashkin et al. (2021). Measuring Attribution in Natural Language Generation Models.
- Gao, Yen, Yu, and Chen (2023). Enabling Large Language Models to Generate Text with Citations.
- Liu, Zhang, and Liang (2023). Evaluating Verifiability in Generative Search Engines.
- Xu et al. (2024). Knowledge Conflicts for LLMs: A Survey.
- Rajpurkar, Jia, and Liang (2018). Know What You Don’t Know: Unanswerable Questions for SQuAD.
- Geifman and El-Yaniv (2017). Selective Classification for Deep Neural Networks.
Discover more from Insightful Data Lab
Subscribe to get the latest posts sent to your email.
