The Generation Side: Ordering Context, Labeling Citations, When You Must Refuse to Answer, and Streaming Responses
Once retrieval brings back a pile of chunks, what actually decides the user experience is how you assemble them, how you get the model to cite its sources, and how it stays quiet when the evidence is thin. Today write a question-answering endpoint with verifiable citations and a refusal policy, and stream the answer.
Today's Goals
- Design a prompt structure that makes the model answer only from the given material and cite sources by chunk number
- Implement post-hoc citation verification that catches sources the model fabricated before they reach the user
- Explain the two triggers for refusing to answer — too-low retrieval scores and contradicting materials — and write a handling strategy for each
Five days were about fetching material. Today turns around and covers only what happens after it arrives — write this side badly and not one bit of those five days' work survives. Once you have read this and finished the lab, scroll back to the top and tick off the three goals.
Plain-Language Walkthrough
The chain of evidence in court
A courtroom has a rule: counsel may not state facts from memory. Saying "this contract was signed on the twelfth of March," they are not believed for sounding certain; the judge asks which exhibit and which page. Counsel gives the number, reads that line aloud, the clerk verifies it, and only then does the sentence enter the record. The point is not whether counsel is honest but that every sentence has a chain that can be independently checked.
Now map the roles. The model is counsel, the retrieved chunks are exhibits, and your server code is the clerk verifying the numbers. The first five days were evidence gathering: finding the material completely and accurately. Today is courtroom procedure: how material is laid on the table, how conclusions are attributed, how the clerk verifies, and what happens when verification fails.
One premise must be accepted first: the citations a model produces are untrustworthy by default. It is not lying but doing what it always does — predicting what the next stretch of text most likely looks like. A conclusion followed by a bracketed number is a pattern it has seen ten million times, so it writes them very naturally and very convincingly. Inventing a nonexistent id and inventing a nonexistent API parameter are the same behavior.
So today's real watershed is this sentence: a citation's credibility cannot come from the model's conscientiousness and can only come from the code's after-the-fact verification. Writing "please make sure the citations are accurate" ten times in the prompt gains nearly nothing; adding twenty lines of verification in code lets not one fabricated citation out. That judgment shapes every design that follows.
D1 left two shortcomings, and the second is repaid today: the corpus has two documents on the file upload limit, one saying 200 MB and one saying 100 MB, retrieval fetches them together, and faced with two clashing numbers the model's default behavior is to pick the more plausible one and state it with the same confidence. Courtroom procedure exists exactly for that moment.
Laying material on the table: ordering, deduplication, budget, and that middle ground
A clerk laying exhibits on the table does it with care. So does a pile of retrieved chunks, passing four steps between a raw hit and the prompt.
Step one is ordering. By retrieval score, high to low, with no suspense. But equal scores need a tie-breaker: without a stable order, chunk numbers drift between runs and "cited chunk 3" in the log no longer matches anything. Break ties by chunk id — pure engineering discipline.
Step two is deduplication. The same passage often appears once in the product manual and once in the support Q&A. Duplicate material wastes budget and makes the model believe this point was stressed repeatedly and must be important. Fingerprint the normalized text and dedupe.
Step three is the budget. Give the material an explicit token budget (1,200 in the lab) and fill from the highest score down. The detail: when something does not fit, do not stop — a smaller chunk further down may still fit; and of course do not try forever, so add a maximum chunk count as a backstop.
Step four is the most counter-intuitive: reordering. A chunk's position in the context affects whether the model reads it, with the beginning and the end noticeably more likely to be used and the exact middle likeliest to be missed. The remedy is putting the most important at both ends: first place at the start, second at the end, third in position two, working inward. That way the closer to the middle a chunk sits the less important it already was, and the cost of missing it is smallest.
// The number is the presentation index: the model may cite only numbers issued here, and verification looks them back up
export function assemble(scored, { budget = 1200, maxBlocks = 5 } = {}) {
// Break ties by chunkId, or chunk numbers drift between runs and the ids in the log stop matching
const ranked = [...scored].sort(
(a, b) => b.score - a.score || a.chunk.chunkId.localeCompare(b.chunk.chunkId)
)
const seen = new Set()
const picked = []
let usedTokens = 0
for (const item of ranked) {
if (picked.length >= maxBlocks) continue
const key = fingerprint(item.chunk.text)
if (seen.has(key)) continue // the manual and the support Q&A often carry the same passage
const cost = countTokens(item.chunk.text)
if (usedTokens + cost > budget) continue // not break: a smaller chunk further down may still fit
seen.add(key)
usedTokens += cost
picked.push(item)
}
// The most important at both ends, the less important working inward
const head = []
const tail = []
picked.forEach((item, i) => (i % 2 === 0 ? head.push(item) : tail.unshift(item)))
const ordered = [...head, ...tail]
return { blocks: ordered.map((item, i) => ({ n: i + 1, ...item })), usedTokens }
}def assemble(scored: list[Scored], budget: int = 1200, max_blocks: int = 5) -> AssembleResult:
"""The number is the presentation index: the model may cite only numbers issued here"""
# Break ties by chunk_id, or chunk numbers drift between runs and the ids in the log stop matching
ranked = sorted(scored, key=lambda s: (-s.score, s.chunk.chunk_id))
seen: set[str] = set()
picked: list[Scored] = []
used_tokens = 0
for item in ranked:
if len(picked) >= max_blocks:
continue
key = fingerprint(item.chunk.text)
if key in seen: # the manual and the support Q&A often carry the same passage
continue
cost = count_tokens(item.chunk.text)
if used_tokens + cost > budget: # not break: a smaller chunk further down may still fit
continue
seen.add(key)
used_tokens += cost
picked.append(item)
# The most important at both ends, the less important working inward
head = picked[0::2]
tail = picked[1::2][::-1]
ordered = head + tail
blocks = [Block(n=i + 1, chunk=it.chunk, score=it.score) for i, it in enumerate(ordered)]
return AssembleResult(blocks=blocks, used_tokens=used_tokens)Labeling citations so they can be verified
Now to today's core. Having the model attribute its sources has two forms, and they differ greatly.
The first is free text: it writes "according to Drive and File Management." Such a citation cannot be verified — a title is a string the model can produce offhand, and you would need fuzzy matching to know which document is meant, unable to tell when matching fails whether it invented the title or your matching is simply weak.
The second is a chunk number: assembly gives each piece of material a number, the prompt states plainly that only the numbers sent may be cited, and the model outputs "the recycle bin keeps items 30 days [2]". A number is a closed set — you issued 1 through 5, so 8 is certainly invented, a judgment needing no fuzzy matching and one line of code. Verifiability comes from a closed set, not from phrasing.
The output format has one more choice. Non-streaming is most robust as JSON: one conclusion with an array of numbers, validated once against a schema. Streaming cannot use JSON — it needs the closing brace to parse, so there is nothing to stream — and falls back to plain text with inline markers. Two contracts, and verification must be the same one.
Then the clerk's job, in two gates:
Gate one: the number must exist. The most common shape of a fabricated citation is a number slightly above the largest one you issued. That is easy to catch.
Gate two: the cited chunk must substantively overlap this sentence. That gate is the crucial one, because the subtler fabrication has a real number and false content — it cites chunk 2 while chunk 2 discusses database sharding, and an existence check waves it through. The criterion is an overlap ratio: tokenize the sentence and see what fraction can be found in the cited chunk's original text, failing below a threshold.
One detail that must be handled: remove tokens appearing in most chunks before computing overlap. Words like "Skyladder" and "file" are everywhere, and computing overlap with them lets any cited chunk pass. That is the same reasoning as BM25 suppressing common words with inverse document frequency (the formula is in D1).
// Substantive overlap: what fraction of this sentence's tokens can be found in the cited chunk
export function overlapRatio(sentence, blockText, common) {
// Deduplicate and remove tokens appearing in most chunks — otherwise any citation passes
const terms = [...new Set(tokenize(sentence))].filter((t) => !common.has(t))
if (terms.length === 0) return 0
const inBlock = new Set(tokenize(blockText))
return terms.filter((t) => inBlock.has(t)).length / terms.length
}
export function verifyAnswer(claims, blocks, minOverlap = 0.3) {
const byNumber = new Map(blocks.map((b) => [b.n, b]))
const common = commonTerms(blocks)
return claims.map((claim) => {
const issues = []
const verified = []
for (const n of claim.citations) {
const block = byNumber.get(n)
if (!block) {
issues.push(`cited a nonexistent chunk number [${n}]`) // gate one: the numbers are a closed set
continue
}
// Note the comparison uses the original chunk.text, not the compressed version put in the prompt
const ratio = overlapRatio(claim.text, block.chunk.text, common)
if (ratio < minOverlap) {
issues.push(`[${n}] has only ${ratio.toFixed(2)} substantive overlap with this sentence`) // gate two
continue
}
verified.push(n)
}
return { claim, ok: issues.length === 0, issues, verified }
})
}def overlap_ratio(sentence: str, block_text: str, common: set[str]) -> float:
"""Substantive overlap: what fraction of this sentence's tokens can be found in the cited chunk"""
# Deduplicate and remove tokens appearing in most chunks — otherwise any citation passes
terms = {t for t in tokenize(sentence) if t not in common}
if not terms:
return 0.0
in_block = set(tokenize(block_text))
return len(terms & in_block) / len(terms)
def verify_answer(claims: list[Claim], blocks: list[Block], min_overlap: float = 0.3):
by_number = {b.n: b for b in blocks}
common = common_terms(blocks)
verdicts = []
for claim in claims:
issues: list[str] = []
verified: list[int] = []
for n in claim.citations:
block = by_number.get(n)
if block is None:
issues.append(f"cited a nonexistent chunk number [{n}]") # gate one
continue
# Note the comparison uses the original chunk.text, not the compressed prompt version
ratio = overlap_ratio(claim.text, block.chunk.text, common)
if ratio < min_overlap:
issues.append(f"[{n}] has only {ratio:.2f} substantive overlap with this sentence")
continue
verified.append(n)
verdicts.append(Verdict(claim=claim, ok=not issues, issues=issues, verified=verified))
return verdictsWhat happens when verification fails? Write the specific reason as feedback and send it back with the original material for one regeneration. The feedback must be specific to the sentence: "this sentence cited a nonexistent chunk number 8." The second version is usually well behaved, but give only one chance — two fabricating versions in a row mean the material does not support it, and the next section's refusal applies.
One last thing: citations must be clickable. Passing verification is an internal conclusion, and only a user able to click the number and see that passage makes it genuinely verifiable, so the server needs an endpoint fetching original text by chunk id. Without it the evidence chain is broken at the user's end.
Refusal is not failure: three lines, three phrasings
"Could not find it" is not a fault in knowledge base question answering but correct output. The actual fault is inventing a passage after failing to find one. The three lines differ in trigger timing, criterion, and phrasing, and mashing them into one "sorry, I do not know" wastes the work.
Line one: retrieval scores too low. The top score below a threshold means the store holds no relevant material. This is judgeable before generation, and refusing early saves a whole model call. The phrasing should give the user a next action: rephrase and ask again, or confirm whether the material has been ingested. How is the threshold set? Set too high, answerable questions get blocked (a user seeing "could not find it" while the material is in the store is the most trust-damaging error); set too low, noise still enters the context. The only reliable method is running a batch of known-answerable and known-unanswerable questions and reading the score distribution, not guessing. Once D8 builds evaluation, it becomes a tunable number.
More importantly, this line cannot cover every case that should refuse. D1's baseline holds a ready-made counter-example pair: b08 (the team outing budget) has no answer in the corpus and scores around 3, so the threshold blocks it; b09 (expensing a phone bill) likewise has no answer, and a page full of "expense" pushes the score to 9.41 where the threshold cannot block it. (Both numbers come from D1's document-level baseline; today's retrieval unit is the chunk and the absolute scores differ.) D1 could only cover b09 with a refusal instruction in the prompt, and a prompt cannot cover it — today moves that gate out of the prompt and into code, which is line three.
Line two: materials contradict each other. Retrieval fetched both the 200 MB and the 100 MB documents, and the model's default is to pick one — and you cannot even see it picking. The right approach is not letting it pick at all: code first finds different numbers about the same thing across chunks. Three criteria must hold together: they come from different documents, the units match, the values differ, and the context before the two numbers shares at least two tokens — that last one is the key against false positives, since without it "the recycle bin keeps items 30 days" and "the trial period is 90 days" get judged a conflict.
Once detected, there are two ways to handle it, and which one is a product decision, not a technical one. One is presenting both: lay out both claims with their sources and update dates and hand the choice back to a person, which is what the lab does. The other is choosing one: take the newer by update date, but only when you have an additional authority signal — the corpus's doc-028 weekly meeting note points out this inconsistency and assigns an action item, and with backing like that choosing one holds up. Without backing, present both honestly. Picking for the user and not telling them another claim exists is this section's easiest mistake.
// Covers only conflicts of the numbers-with-units kind. Textual contradictions need a model, which costs more and misjudges more
export function detectConflict(blocks) {
const all = blocks.map((b) => ({ block: b, list: extractQuantities(b.chunk.text) }))
for (let i = 0; i < all.length; i += 1) {
for (let j = i + 1; j < all.length; j += 1) {
const [a, b] = [all[i], all[j]]
if (a.block.chunk.docId === b.block.chunk.docId) continue // different numbers in one document are usually not a conflict
for (const qa of a.list) {
for (const qb of b.list) {
if (qa.unit !== qb.unit || qa.value === qb.value) continue
// Context keys: tokens in the fourteen characters before the number. Without this, 30 days and 90 days read as a conflict
const shared = [...qa.keys].filter((k) => qb.keys.has(k))
if (shared.length < 2) continue
return { unit: qa.unit, left: describe(a.block, qa), right: describe(b.block, qb) }
}
}
}
}
return null
}def detect_conflict(blocks: list[Block]) -> ConflictEvidence | None:
"""Covers only conflicts of the numbers-with-units kind. Textual contradictions need a model"""
all_q = [(b, extract_quantities(b.chunk.text)) for b in blocks]
for i, (block_a, list_a) in enumerate(all_q):
for block_b, list_b in all_q[i + 1 :]:
if block_a.chunk.doc_id == block_b.chunk.doc_id:
continue # different numbers in one document are usually not a conflict
for qa in list_a:
for qb in list_b:
if qa.unit != qb.unit or qa.value == qb.value:
continue
# Context keys: tokens before the number. Without this, 30 days and 90 days read as a conflict
if len(qa.keys & qb.keys) < 2:
continue
return ConflictEvidence(
unit=qa.unit,
left=describe(block_a, qa),
right=describe(block_b, qb),
)
return NoneLine three: the question falls outside what the material covers. This can only be judged after generation, and it plugs exactly the b09 gap above: the material looks relevant and never mentions phone bills at all. The criterion follows naturally — after citation verification, not one valid citation remains, meaning no conclusion stands. The phrasing must differ clearly from line one: not "no relevant material found" but "relevant documents were found and none of them contains anything that directly answers this question," and those two give the user entirely different next actions.
Context compression: let a small model delete a pass first
There is one more optimization after material is on the table: before handing it to the expensive model, let a cheap small one delete the irrelevant sentences.
Retrieval returns whole chunks, and one chunk usually holds one or two sentences genuinely answering the question with the rest being neighbors from the same section. Saving money is incidental; the main gain is less noise and higher accuracy — the fewer irrelevant sentences, the smaller the chance the model attributes one passage's number to another. The lab uses the cheap claude-haiku-4-5-20251001 for this step with claude-sonnet-5 still generating.
Report all three bills together. Metric: a more focused answer, quantifiable only once D8 builds evaluation. Latency: one extra model call, and a small model is fast and still a real round trip, so compressing every chunk means several concurrent ones. Cost: saving the main model's input tokens and paying for one small model call — worthwhile with larger chunks and a more expensive main model, and a straight loss when the material was short anyway.
Self-test output (solution, MOCK=1):
✅ Context compression: irrelevant sentences deleted
651 tokens before → 530 after, saving 18.6%That 18.6% came from this thirty-document corpus and this question, and another corpus may well give a different number. Its significance is not the magnitude but that your system must have a place that can print that line — without it you have no idea whether compression saved anything or merely added a call.
Compression's most dangerous property is that the small model rewrites along the way: writing "preserve verbatim" in the prompt does not stop it, and it loves condensing two sentences into a smoother one. So the code needs a hard constraint: every retained sentence must be findable verbatim in the original, and failing that the whole chunk reverts to the uncompressed version. One equally important discipline: citation verification always compares against the original, never the compressed product. What the user opens is the original, and once the verification target is not what the user sees, "verification passed" guarantees nothing.
Streaming and citations are a contradiction
The final trade-off. Streaming's selling point is characters as early as possible, and citation verification requires a finished sentence before it can check — those two naturally conflict.
The server pushes the answer to the browser as a series of events over a text protocol such as SSE, and the messages look like this:
event: meta
data: {"blocks":[{"n":1,"chunkId":"doc-010#c02"}],"contextTokens":530}
event: sentence
data: {"text":"The open API's default rate limit is 600 requests per minute","citations":[{"n":1,"chunkId":"doc-010#c02"}]}
event: dropped
data: {"text":"An administrator can change this value to any size in the console","detail":["[2] has only 0.03 substantive overlap"]}
event: done
data: {"status":"answered","emitted":1,"dropped":1}The plainest approach is sending whatever arrives. That road does not work, because emitted characters cannot be withdrawn: by the time you discover at the end that the third sentence's citation was invented, that sentence is already on the user's screen and all you can do is pop up "please ignore that last sentence" — worse than not streaming.
The compromise is buffering by sentence. Accumulate a full sentence, verify it, emit it with its verified citations if it passes, and drop the whole sentence if it does not. The cost is explicit: time to first output goes from one token to one sentence, typically two or three hundred milliseconds, which users barely notice; whereas a wrong citation reaching the screen costs trust.
let buffer = ''
for await (const delta of streamText(question, blocks)) {
buffer += delta
// Handle only complete sentences. Half a sentence cannot be verified, since the citation marker precedes the full stop
const { sentences, rest } = takeSentences(buffer)
buffer = rest
for (const raw of sentences) {
// Strip [1][3] out of the text: the prose stays clean and the numbers go to verification separately
const claim = parseInlineSentence(raw)
const verdict = verifyAnswer([claim], blocks)[0]
if (claim.citations.length > 0 && verdict.verified.length === 0) {
send('dropped', { text: claim.text, detail: verdict.issues }) // the whole sentence is not emitted
continue
}
send('sentence', { text: claim.text, citations: citationsOf(blocks, verdict.verified) })
}
}buffer = ""
async for delta in stream_text(question, blocks):
buffer += delta
# Handle only complete sentences; half a sentence cannot be verified
sentences, buffer = take_sentences(buffer)
for raw in sentences:
# Strip [1][3] out of the text: the prose stays clean and the numbers go to verification separately
claim = parse_inline_sentence(raw)
verdict = verify_answer([claim], blocks)[0]
if claim.citations and not verdict.verified:
await send("dropped", {"text": claim.text, "detail": verdict.issues})
continue
await send(
"sentence",
{"text": claim.text, "citations": citations_of(blocks, verdict.verified)},
)There is one more ordering nicety: a refusal must be sent before the stream begins. Too-low scores and contradicting materials are both judged before generation, so push a refusal event and close the stream, and the user never sees half an answer withdrawn. Line three cannot be judged that early, but under sentence buffering it manifests as not one sentence emitted, with a refusal event appended at the close. That is why the three lines were separated by before or after generation — the separation is not only code structure; it decides directly what the user sees while streaming.
Source Reading
Hands-On Lab
Confirm one thing before starting: today's retrieval uses D1's BM25 unchanged, and everything to build is after the material returns. The starter is a complete running service with four gaps, each left with a default behavior that makes the problem visible — citation verification checking only existence, for instance, so the real-number-false-content fabrication sails through.
- Run the starter's self-test once, see 4 passing and 4 failing, and understand each failure's cause before touching anything.
- Complete the assembly function's budget control and end-weighted reordering, and watch the chunk number order become top score at the start and second place at the end.
- Complete substantive overlap and verify with a request carrying the fabrication switch: version 1 is rejected with two specific reasons and version 2 passes.
- Complete conflict detection, ask about the upload limit once, and watch the answer present both numbers with their update dates rather than picking one for you.
- Change the streaming route to buffer by sentence and observe the two fabricated sentences appearing as dropped events, with not one wrong number in the prose.
Interview Questions
Today's 4 questions are in the question bank below, weighted toward the effect of context assembly order, verifying citation credibility, setting the refusal threshold, and how not to let a wrong citation escape while streaming. Expand each one and read the analysis before the key points — practicing the derivation beats memorizing bullets. The high-frequency labels for the domestic and overseas markets are there so you can pick by target market.
Checklist and Tomorrow
- Design a prompt structure that makes the model answer only from the given material and cite sources by chunk number
- Implement post-hoc citation verification that catches sources the model fabricated before they reach the user
- Explain the two triggers for refusing to answer — too-low retrieval scores and contradicting materials — and write a handling strategy for each
- Explain why verifiability comes from a closed set rather than from phrasing, with a concrete reason free-text citations cannot be verified
- State each refusal line's criterion, trigger timing (before or after generation), and difference in phrasing
- All 5 acceptance criteria of the lab pass, with all 8 self-checks green
- Answer at least 3 of the 4 interview questions without looking at the key points
Tomorrow (D7) assembles six days of parts into a genuine service: one ingest command, one question-answering endpoint, one configuration document, brought up with container orchestration in a single command. Why that order? Because until today you hold six piles of individually runnable code, and between running and shippable lie three things: module boundaries, configuration, and startup order. Filling those in closes week one, and only then is it comfortable to set evaluation's ruler against it in week two.
Interview questions
How do you make sure a model's citations are real rather than fabricated? Describe a scheme that does not rely on the model behaving well.怎么让模型的引用是真的而不是编的?说出一个不依赖模型自觉的方案。
Common in ChinaCommon overseasIntermediate#citation-verification#grounding#hallucinationHow to reason about it · think before answering
- The phrase to catch is 'not relying on the model behaving well'. Any answer that boils down to 'tell the model to be accurate in the prompt' fails, because the prompt is exactly the part that cannot enforce this.
- Split the problem in two. Verifiability requires that a citation be a symbol from a closed set, not free text. So step one is numbering the blocks at assembly time and telling the model it may only cite the numbers it was given. 'According to the storage handbook' cannot be checked, because the title is a string the model can invent.
- Step two is post-hoc checking, with two gates. Gate one is existence: you handed out 1 through 5, so an 8 is fabricated, and that is a one-line check. Gate two is substantive overlap, which catches the sneakier case where the number is real but the block says something else. Measure what fraction of the sentence's terms appear in the cited block and reject below a threshold.
- Mention the trap in the overlap metric: drop terms that appear in most blocks first, otherwise generic words let any citation pass. It is the same reasoning behind inverse document frequency in BM25.
- On failure, feed the specific reason back and regenerate once, not repeatedly. Two fabricated drafts in a row means the material does not support the question, so refuse instead. Also verify against the original chunk text, never against a compressed or rewritten version, otherwise 'verified' says nothing about what the user sees.
- Expected follow-up: why not ask the model to self-check? Self-checking shares the generator's bias and has no independent source of truth, whereas number checking is deterministic, essentially free, and reproducible.
分析过程 · 先想清楚再作答
- 题眼在「不依赖模型自觉」这半句。回答里只要出现「在提示词里强调请确保引用准确」,这题就答砸了——面试官问的正是提示词管不住的那部分。
- 先把问题拆成两半:引用要能验证,前提是它是一个**闭集里的符号**,不是一段自由文本。所以第一步是组装上下文时给每块材料一个编号,提示词里明确只能引用发出去的编号。让模型写「根据《某某手册》」是没法验证的,标题是它可以随口生成的字符串。
- 第二步是事后核对,两道闸缺一不可。第一道查编号存在性:发出去的是 1 到 5,出现 8 就一定是编的,一行代码判掉。第二道查实质重合:编号是真的、内容却对不上,这类更隐蔽,要算这句话的词元有多大比例能在被引块原文里找到,低于阈值判不通过。
- 算重合度时有个坑要主动说出来:先剔掉在多数块里都出现的高频词元,否则「文件」「系统」这种词会让随便哪一块都及格。这跟 BM25 用逆文档频率压常见词是同一个道理。
- 校验不过怎么办:把具体原因写成反馈打回去重生成一次,只给一次机会;连着两版都编说明材料本来就不支持,该走拒答而不是第三次重试。另外校验必须拿原文比对,不能拿压缩或改写过的材料比对,否则「校验通过」保证不了用户点开看到的东西。
- 可预期的追问:为什么不让模型自己再检查一遍?因为自检和生成是同一个模型的同一种倾向,它对自己编的东西没有独立信息源;而编号核对是一个确定性判断,成本几乎为零、结果可复现,这两点自检都做不到。
Key points
- Citations must be closed-set symbols such as block numbers, not free-text titles: verifiability comes from the closed set, not from wording.
- Two gates: the number must exist, and the sentence must substantively overlap the cited block's original text, which is what catches real-number-wrong-content fabrication.
- Strip terms that occur in most blocks before scoring overlap, or any citation will pass.
- On failure, regenerate once with the concrete reason fed back; two bad drafts means refuse instead.
- Always verify against the original text the user can open, never against a compressed or rewritten copy.
答题要点
- 引用必须是块编号这种闭集符号,不能是自由文本的文档标题——可验证性来自闭集,不来自措辞。
- 两道闸:编号存在性,以及这句话与被引块原文的实质重合度,后者才拦得住「编号是真的、内容对不上」。
- 算重合度前剔掉在多数块里都出现的高频词元,否则随便引哪一块都能及格。
- 校验不过就带着具体原因打回重生成一次,只给一次机会,两版都编就转拒答。
- 校验对象必须是用户能点开看到的原文,不是压缩或改写后的材料。
Does the ordering of retrieved passages in the context affect answer quality? If so, how would you order them?上下文里材料的排列顺序会影响回答质量吗?如果会,你会怎么排?
Common in ChinaCommon overseasBasic#context-assembly#prompt-engineering#orderingHow to reason about it · think before answering
- This is a warm-up question, but 'sort by relevance descending' only earns half the credit. The interviewer wants to know whether you treat position itself as a variable.
- State the conclusion first: it does matter. Models attend more reliably to material at the start and the end of the context, and are most likely to miss what sits in the middle. Plain descending order therefore parks your second-best passage in the worst spot.
- Give the ordering: rank one first, rank two last, rank three second, rank four second-to-last, folding inward. Whatever ends up in the middle is by construction the least important, so the cost of it being skipped is smallest.
- Round it out with the other assembly steps, which shows you have written this code: a deterministic tiebreaker (otherwise block numbers drift between runs and your logs stop matching), dedupe on normalized text, and a token budget that skips rather than stops when a block does not fit.
- Expected follow-up: how would you verify this? Do not guess. Hold the question set fixed, vary only the ordering, and measure. Position effects differ by model and context length, so treat it as a parameter to measure on your own data rather than a universal law.
分析过程 · 先想清楚再作答
- 这是一道送分题,但答成「按相关性从高到低排」就只拿到一半分。面试官想听的是你知不知道位置本身是个变量。
- 结论先说:会影响。模型对上下文开头和结尾的材料明显更敏感,正中间的最容易被读漏。所以简单按分数从高到低顺排,等于把第二重要的材料放进了最不容易被读到的位置。
- 给出排法:第 1 名放开头、第 2 名放结尾、第 3 名放第二位、第 4 名放倒数第二位,依次往里收。这样按分数排下来越靠中间的块本来就越不重要,被读漏的代价最小。
- 顺带把排序之外的三道手续说全,显得你真的写过这段代码:同分要有决胜键(否则块编号会在两次运行之间飘,日志对不上)、要按归一化文本去重(同一段话常在手册和问答里各出现一次)、要有 token 预算并且塞不下时不要直接停。
- 可预期的追问:这个结论怎么验证?答案是别猜——固定一批问题,只改排列顺序跑对照,看指标差多少。位置效应在不同模型、不同上下文长度上强弱不一样,把它当成一个要在自己数据上量的参数,而不是一条普适定律。
Key points
- Yes: material at the head and tail is used more reliably, the middle is most often skipped.
- Put the strongest at both ends: rank one first, rank two last, rank three second, folding inward.
- Assembly also needs a deterministic tiebreaker for stable numbering, dedupe on normalized text, and a token budget that skips oversized blocks instead of stopping.
- The strength of the effect varies by model and context length, so measure it on your own data instead of quoting it as a law.
答题要点
- 会影响:开头和结尾的材料更容易被用上,正中间的最容易被读漏。
- 排法是最重要的放两端:第 1 名开头、第 2 名结尾、第 3 名第二位,依次往里收。
- 组装还要做三件事:同分给决胜键保证编号稳定、按归一化文本去重、控 token 预算且塞不下时跳过而不是终止。
- 位置效应的强弱因模型与上下文长度而异,要在自己的数据上做对照实验量出来,不能当普适定律照搬。
How do you set the refusal threshold for a knowledge-base assistant, and what does it cost you when the threshold is too high or too low?知识库问答的拒答阈值怎么定?定高了和定低了各自的代价是什么?
Common in ChinaCommon overseasDeep dive#refusal#thresholds#evaluationHow to reason about it · think before answering
- What is really being tested: do you know that refusal is several rules rather than one threshold, and do you set thresholds from data. An answer that mentions only a score cutoff shows you have only touched the surface.
- Break refusal into three rules with different timing. Score too low: decidable before generation, saving a model call. Sources conflict: also decidable before generation, by finding differing numbers about the same thing across blocks. You then either present both with their update dates, or pick the newer one when an authoritative signal backs it, such as meeting notes that flagged the discrepancy. Which of the two is a product decision, but silently letting the model pick is never an option. Question outside coverage: only decidable after generation, when citation verification leaves you with zero verified citations.
- Stress that the three responses must read differently. 'Nothing relevant in the knowledge base, try rephrasing or check whether the document was ingested' is a different instruction to the user than 'we found related documents but none of them answers this'. Collapsing both into 'sorry, I don't know' throws away information.
- Then the cost half. Too high: answerable questions get blocked, the user is told nothing was found while the material is in fact indexed. That is the most trust-damaging failure and it is nearly invisible in logs. Too low: weak passages enter the context and the model answers from irrelevant material, which is worse because the answer still looks cited.
- How to set it: run a set of questions with known answers and known non-answers, look at where the two score distributions separate, and pick a point according to which error you fear more. Scores have no absolute scale, so the deliverable is the procedure, not the number.
- Expected follow-up: what if one score threshold is not enough? Add signals rather than tuning the number: the gap between top and second score, the number of hits above threshold, and the post-generation verification result are all steadier than the raw score.
分析过程 · 先想清楚再作答
- 这题真正在考的是:你有没有意识到拒答不是一个阈值,而是好几条判据;以及你定阈值靠不靠数据。只谈一个分数阈值的回答,说明只做过最浅的一层。
- 先把拒答拆成三条线,它们的触发时机完全不同。检索分数太低:生成之前就能判,省一次模型调用。材料互相矛盾:也在生成之前判,代码在块之间找同一件事的不同数字,检出后要么并列两种说法与各自的更新日期,要么在有权威信号(比如一份点破了这条不一致的会议纪要)时按更新日期择一——选哪条是产品决策,但无论如何不能让模型自己悄悄挑一个。问题超出材料覆盖范围:只能在生成之后判,判据是跑完引用校验一条有效引用都没有。
- 强调三种话术必须不同。第一种要说「库里没有相关材料,换个说法或确认资料是否入库」,第三种要说「找到了相关文档但里面没有能直接回答的内容」——用户的下一步动作完全不同,混成一句「抱歉我不知道」等于把信息扔了。
- 再答代价这一半。定高了:能答的问题被挡在门外,用户看到查不到而材料其实在库里,这是最伤信任的一种错,而且它在日志里几乎不可见。定低了:低分噪声材料进上下文,模型拿着不相关的东西硬答,错误反而更隐蔽,因为回答看起来还带着引用。
- 怎么定:拿一批已知有答案和已知没答案的问题跑一遍,看两组的分数分布在哪里分开,按你更怕哪种错来取点。分数是没有绝对量纲的,换语料、换检索方式都要重定,所以真正要交付的是这套定阈值的流程,不是那个数字。
- 可预期的追问:单一分数阈值不够怎么办?答案是加判据而不是调数字——最高分与次高分的差、命中块数、以及生成后的引用校验结果,都是比原始分数更稳的信号。
Key points
- Refusal is three rules, not one: low score and source conflict decided before generation, out-of-coverage decided after generation from the verification result.
- On conflict, presenting both versions versus picking the newer one is a product decision; picking only holds up when an authoritative signal backs it.
- The three responses must be worded differently because each implies a different next action for the user.
- Too high blocks answerable questions; the user is told nothing exists while it does, which is the most damaging and least visible failure.
- Too low lets weak passages in, producing errors that are harder to spot because the answer still carries citations.
- Set it by comparing score distributions over answerable and unanswerable question sets, then choose based on which error is worse; re-tune whenever the corpus or retriever changes.
答题要点
- 拒答不是一条线而是三条:分数过低、材料冲突(都在生成前判)、超出材料覆盖范围(只能生成后按引用校验结果判)。
- 冲突检出后并列两说还是按更新日期择一,是产品决策;只有在有权威信号背书时择一才站得住,否则老实并列。
- 三种情况的话术必须不同,因为它们给用户的下一步动作不同。
- 定高了会把能答的问题挡住,用户看到查不到而材料其实在库里,最伤信任且日志里看不见。
- 定低了会让噪声材料进上下文,错误更隐蔽,因为回答看起来仍然带着引用。
- 定法是拿已知有答案与已知没答案的两组问题跑分数分布,按更怕哪种错取点;换语料或换检索方式都要重定。
In a streaming setup, how do you make sure nothing you have already sent needs to be retracted because its citation failed verification?流式输出的场景下,你怎么保证吐出去的内容不会因为引用校验失败而需要撤回?
Common in ChinaCommon overseasDeep dive#streaming#citation-verification#api-designHow to reason about it · think before answering
- This tests a real architectural conflict: streaming wants the first token out early, citation verification cannot run until a statement is complete. Listen for whether the candidate names the trade-off and prices it.
- Name the conflict: once a token reaches the browser you cannot take it back. Discovering at the end that the third sentence cited a fabricated block leaves you posting 'please ignore that last sentence', which is worse than not streaming at all.
- Give the solution: buffer by sentence. As soon as a complete sentence lands, verify it, and only then emit it together with its verified citations; drop the whole sentence otherwise. The cost is that time-to-first-token becomes time-to-first-sentence, typically a few hundred milliseconds, which users barely notice, whereas a bad citation on screen costs trust.
- Add two implementation details that prove you have built it. Streaming cannot use JSON output because JSON is only parseable once closed, so switch to plain text with inline markers, while keeping exactly the same verifier as the non-streaming path. Strip the markers out of the prose and send the numbers as structured data after verification.
- Add the ordering point: the two rules decidable before generation, low score and source conflict, should be emitted before the stream starts, so the user never sees half an answer being withdrawn. The rule that needs generation shows up as 'no sentence was ever emitted', so close the stream with a refusal event.
- Expected follow-up: does this kill the streaming feel? No. Sentence-level streaming is still visibly progressive on long answers. If you need finer granularity, stream a 'checking sources' placeholder, but never stream unverified prose.
分析过程 · 先想清楚再作答
- 这题在考一个真实的架构矛盾:流式要尽早出字,引用校验要等话说完才能核对。看回答里有没有出现「取舍」两个字,以及有没有把代价说清楚。
- 先说清矛盾在哪:一旦一个 token 发到了浏览器就撤不回来,你在末尾才发现第三句引用是编的,那句话已经在用户屏幕上了,只能补一句「刚才那句请忽略」,体验比不流式还糟。
- 给方案:按句缓冲。攒够一个完整句子就立刻校验一次,通过了才把这句连同已核实的引用发出去,没通过就整句丢掉。代价是首字延迟从一个 token 变成一句话,通常两三百毫秒,用户几乎察觉不到,而错误引用一旦上屏赔的是信任。
- 补两个实现细节,它们能证明你写过:流式模式没法用 JSON 输出(要等右花括号闭合才能解析),所以改成纯文本加行内标记,但校验必须和非流式共用同一套;标记要从正文里剥掉,正文保持干净,编号单独走校验再作为结构化数据发出去。
- 再补一条顺序上的讲究:生成前就能判的两条拒答线(分数过低、材料冲突)要在流开始之前发出去,用户不会先看到半句回答再被收回;生成后才能判的那条,在按句缓冲之下表现为一句都没发出来,收尾补一个拒答事件即可。
- 可预期的追问:那用户体验上的流式感是不是就没了?没有,句级流式在中文长回答里仍然是明显的渐进呈现;真要更细,可以在句子发出前先流一个「正在核对」的占位态,但不要流未校验的正文。
Key points
- The conflict: emitted text cannot be recalled, while a citation can only be checked once its sentence is complete.
- The fix is sentence-level buffering: verify each completed sentence, emit only if it passes, drop the whole sentence if it does not.
- The cost is time-to-first-sentence instead of time-to-first-token, which is affordable and worth paying.
- Streaming cannot use JSON, so use inline markers in plain text while sharing one verifier with the non-streaming path; strip markers from the prose and send numbers as structured data.
- Emit pre-generation refusals before the stream opens; the post-generation one manifests as an empty stream and is closed with a refusal event.
答题要点
- 矛盾在于发出去的内容撤不回来,而引用只有一句说完才能核对。
- 解法是按句缓冲:攒够一句校验一次,通过才发,没通过整句丢掉。
- 代价是首字延迟从一个 token 变成一句话,这个代价必须付也付得起。
- 流式用不了 JSON,改纯文本加行内标记,但校验逻辑与非流式共用同一套;标记从正文剥出,编号作为结构化数据单独发。
- 生成前能判的拒答要在流开始之前发出去,生成后才能判的那条以「一句都没发」的形式收尾补事件。