生成这一侧:上下文怎么排、引用怎么标、什么时候必须拒答,以及流式回答
检索拿回一堆块之后,真正决定用户体验的是怎么组装、怎么让模型标出处、怎么在证据不足时闭嘴。这一天写出一个带可验证引用与拒答策略的问答接口,并把回答做成流式输出。
今日目标
- 能设计一份提示词结构,让模型只依据给定材料回答并按块编号标出处
- 能实现引用的事后校验,把模型编造的出处在返回给用户之前拦下来
- 能说明拒答的两条触发线:检索分数太低和材料之间互相矛盾,并各写出处理方式
前五天全在讲怎么把材料找回来。今天调转方向,只讲材料回来之后的事——这一侧写坏了,前五天的功夫一分都留不住。读完并做完实验之后,回到页面顶部把这三条目标逐一勾掉。
小白版讲解
法庭上的证据链
法庭上有一条规矩:律师不能凭记忆陈述事实。他说"这份合同在三月十二日签署",法官不会因为他说得笃定就采信,一定要问"依据是哪一份证据、第几页"。律师报出编号、当庭念出那一行,书记员核对无误,这句话才进笔录。重点不在律师诚不诚实,而在于每一句话都有一条能被独立核对的链子。
现在把角色对上号。模型是律师,检索回来的块是证据,你的服务端代码是那个核对编号的书记员。前五天做的是取证:把材料找齐找准。今天做的是庭审规程:材料怎么摆上桌、结论怎么标出处、书记员怎么核对、核不上时怎么办。
这里有一个必须先接受的前提:模型标出来的引用,默认是不可信的。 它不是在撒谎,只是在做它一直在做的事——预测下一段文字最可能长什么样。一句结论后面跟一个方括号编号,这个模式它见过千万次,所以会写得非常自然、非常像真的。它编一个不存在的编号,和编一个不存在的 API 参数,是同一种行为。
所以今天真正的分水岭是这一句:引用的可信度不能来自模型的自觉,只能来自代码的事后核对。 提示词里写十遍"请确保引用准确",增益接近于零;代码里加二十行核对逻辑,编造的引用一条都出不去。这条判断决定了后面所有的设计。
D1 的最后留了两个短板,第二个今天就还:语料里那两篇关于文件上传上限的文档,一篇说 200 MB、一篇说 100 MB,检索会把它们一起捞回来,而模型面对两个打架的数字,默认行为是挑一个更像的说出来,还说得同样笃定。庭审规程要解决的正是这种时刻。
材料怎么摆上桌:排序、去重、预算,和中间那块地
书记员把证据摆上桌是有讲究的。检索回来的一堆块也一样,从原始命中到进提示词要过四道手续。
第一道是排序。 按检索分从高到低,没有悬念。但同分时要给一个决胜键:不定序的话块编号会在两次运行之间飘,日志里的"引用了块 3"就再也对不上了。同分按块 id 定序,这是纯工程的纪律。
第二道是去重。 同一段话经常在产品手册和客服问答里各出现一次。重复材料不只浪费预算,还会让模型误以为"这件事被反复强调过,一定很重要"。按归一化文本做指纹去重即可。
第三道是预算。 给材料定一个明确的 token 预算(实验里是 1200),按分数从高往低塞。细节是:塞不下时不要直接停,后面可能还有一块小的塞得进去;当然也不能无限试,再加一条"最多几块"兜底。
第四道最反直觉:重排。 材料在上下文里的位置会影响模型读不读得到它,开头和结尾明显更容易被用上,正中间的最容易被读漏。应对办法是把最重要的放到两端:第 1 名放开头、第 2 名放结尾、第 3 名放第二位,依次往里收。这样越靠中间的块本来就越不重要,被读漏的代价最小。
// 编号是呈现顺序的序号:模型只能引用这里发出去的编号,校验时再拿编号回查原文
export function assemble(scored, { budget = 1200, maxBlocks = 5 } = {}) {
// 同分时按 chunkId 定序,否则两次运行的块编号会飘,日志里的编号就对不上了
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 // 手册和客服问答经常写着同一段话
const cost = countTokens(item.chunk.text)
if (usedTokens + cost > budget) continue // 不 break:后面可能还有一块小的塞得下
seen.add(key)
usedTokens += cost
picked.push(item)
}
// 最重要的放两端,越不重要的越往中间收
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:
"""编号是呈现顺序的序号:模型只能引用这里发出去的编号,校验时再拿编号回查原文"""
# 同分时按 chunk_id 定序,否则两次运行的块编号会飘,日志里的编号就对不上了
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: # 手册和客服问答经常写着同一段话
continue
cost = count_tokens(item.chunk.text)
if used_tokens + cost > budget: # 不 break:后面可能还有一块小的塞得下
continue
seen.add(key)
used_tokens += cost
picked.append(item)
# 最重要的放两端,越不重要的越往中间收
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)引用怎么标才可验证
现在到今天的核心。让模型标出处有两种写法,差别很大。
第一种是自由文本:让它写"根据《网盘与文件管理》一文"。这种引用无法验证——标题是模型可以随口生成的字符串,你要做模糊匹配才知道指哪一篇,匹配不上时又分不清是它编的还是你的匹配算法太笨。
第二种是块编号:组装时给每块材料一个编号,提示词里明确"只能引用发给你的这些编号",模型输出"回收站保留 30 天[2]"。编号是一个闭集——你发出去了 1 到 5,那么 8 就一定是编的,这个判断不需要任何模糊匹配,一行代码就能下。可验证性来自闭集,不来自措辞。
输出格式还有一层选择。非流式用 JSON 最稳:一句结论配一个编号数组,schema 校验一次。流式用不了 JSON——它要等右花括号闭合才能解析,那就没得流了——只能退回纯文本加行内标记。两种契约不同,但校验必须是同一套。
然后是书记员的活,核对分两道闸:
第一道:编号必须存在。 模型编引用最常见的形态就是编一个比你发出去的最大编号再大一点的数字。这一道很好抓。
第二道:被引块必须与这句话有实质重合。 这一道才是关键,因为更隐蔽的编造是编号真、内容假——它引了块 2,而块 2 讲的是数据库分片,光查存在性会一路放行。判据是算重合度:把这句话切成词元,看有多大比例能在被引块原文里找到,低于阈值判不通过。
一个必须处理的细节:算重合度前要先剔掉在多数块里都出现的词元。"云梯""文件"这种词到处都是,用它们算重合随便引哪一块都能及格。这跟 BM25 用逆文档频率压常见词是同一个道理(公式见 D1)。
// 实质重合度:这句话的词元有多大比例能在被引块里找到
export function overlapRatio(sentence, blockText, common) {
// 去重,并剔掉在多数块里都出现的词元——否则随便引哪一块都能及格
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(`引用了不存在的块编号 [${n}]`) // 第一道闸:编号是个闭集
continue
}
// 注意比对的是原文 chunk.text,不是压缩后放进提示词的那一版
const ratio = overlapRatio(claim.text, block.chunk.text, common)
if (ratio < minOverlap) {
issues.push(`[${n}] 与这句话的实质重合度只有 ${ratio.toFixed(2)}`) // 第二道闸
continue
}
verified.push(n)
}
return { claim, ok: issues.length === 0, issues, verified }
})
}def overlap_ratio(sentence: str, block_text: str, common: set[str]) -> float:
"""实质重合度:这句话的词元有多大比例能在被引块里找到"""
# 去重,并剔掉在多数块里都出现的词元——否则随便引哪一块都能及格
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"引用了不存在的块编号 [{n}]") # 第一道闸:编号是个闭集
continue
# 注意比对的是原文 chunk.text,不是压缩后放进提示词的那一版
ratio = overlap_ratio(claim.text, block.chunk.text, common)
if ratio < min_overlap:
issues.append(f"[{n}] 与这句话的实质重合度只有 {ratio:.2f}") # 第二道闸
continue
verified.append(n)
verdicts.append(Verdict(claim=claim, ok=not issues, issues=issues, verified=verified))
return verdicts校验不通过怎么办?把具体原因写成反馈,连同原材料打回去重生成一次。 反馈要具体到句:"这一句引用了不存在的块编号 8"。第二版通常就规矩了,但只给一次机会——连着两版都编,说明材料本来就不支持,该走下一节的拒答。
最后一件事:引用要可点击。 校验通过只是内部结论,用户要能点开编号看到那段原文才算真的可验证,所以服务端要有一个按块 id 回查原文的接口。少了它,整条证据链在用户那一端是断的。
拒答不是失败:三条线,三种话术
"查不到"在知识库问答里不是故障,是正确输出。真正的故障是查不到还编一段。三条线的触发时机、判据、话术都不一样,混成一句"抱歉我不知道"就白做了。
第一条:检索分数太低。 最高分低于阈值,说明库里根本没有相关材料。这一条在生成之前就能判,早拒答省下一整次模型调用。话术要给用户下一步动作:换个说法再问,或者确认资料是不是还没入库。阈值怎么定?定高了把能答的问题挡在门外(用户看到"查不到"而材料其实在库里,这是最伤信任的一种错),定低了噪声照样进上下文。唯一靠谱的定法是拿一批已知有答案和已知没答案的问题跑一遍看分数分布,不是拍脑袋。D8 建起评估之后,它会变成一个可以调优的数字。
更要紧的是:这条线管不了所有该拒答的情况。D1 的基线里就有一对现成的反例——b08(团建预算)语料里没答案,分数只有 3 分上下,阈值直接挡住;b09(报销手机话费)同样没答案,但满篇"报销"把分数顶到 9.41,阈值根本挡不住。(这两个数来自 D1 的文档级基线,今天检索单位是块,绝对分数不一样。)D1 当时只能靠提示词里的拒答指令兜住 b09,而提示词是兜不住的——今天要把这道闸从提示词搬进代码,那就是第三条线。
第二条:材料互相矛盾。 检索把 200 MB 和 100 MB 两篇一起捞回来了,模型的默认行为是挑一个说出来——你甚至看不出它挑过。正确做法是根本不让它挑:代码先在块之间找同一件事的不同数字。判据三条同时成立才算:来自不同文档、单位相同、数值不同,并且两个数字前面的上下文至少共享两个词元——最后这条是防误报的关键,少了它"回收站保留 30 天"和"试用期 90 天"会被判成冲突。
检出之后怎么处置有两条路,选哪条是产品决策,不是技术决策。一条是并列:两种说法都摆出来、各自标出处与更新日期,把选择权交回给人,实验里走的是这条。另一条是择一:按更新日期取新的,但前提是你有额外的权威信号——语料里 doc-028 那份周会纪要就点破了这条不一致并派了行动项,有这种背书时择一站得住。没有背书就老实并列。替用户挑一个、又不告诉他还有另一种说法,是这一节最容易犯的错。
// 只覆盖「带单位的数字」这一类冲突。文字层面的说法相反要靠模型判,贵得多、也更容易误判
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 // 同一篇里的不同数字通常不是冲突
for (const qa of a.list) {
for (const qb of b.list) {
if (qa.unit !== qb.unit || qa.value === qb.value) continue
// 上下文键:数字前面十四个字里的词元。少了这一条,30 天和 90 天会被判成冲突
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:
"""只覆盖「带单位的数字」这一类冲突。文字层面的说法相反要靠模型判,贵得多、也更容易误判"""
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 # 同一篇里的不同数字通常不是冲突
for qa in list_a:
for qb in list_b:
if qa.unit != qb.unit or qa.value == qb.value:
continue
# 上下文键:数字前面十四个字里的词元。少了这一条,30 天和 90 天会被判成冲突
if len(qa.keys & qb.keys) < 2:
continue
return ConflictEvidence(
unit=qa.unit,
left=describe(block_a, qa),
right=describe(block_b, qb),
)
return None第三条:问题超出材料覆盖范围。 只能放在生成之后判,接的就是上面 b09 那个缺口:材料看着相关,可从头到尾没提过话费。判据很自然——跑完引用校验,一条有效引用都没有,说明没有一句结论站得住。话术要跟第一条明确区分:不是"没找到相关材料",而是"找到了相关文档,但里面没有能直接回答这个问题的内容",这两句给用户的下一步动作完全不同。
上下文压缩:先让小模型删一遍
材料摆上桌之后还有一步优化:在交给贵模型之前,先让一个便宜的小模型把无关句子删掉。
检索是按块回来的,一块里通常只有一两句真正在回答问题,剩下的都是同一节里的邻居。省钱只是顺带收益,主要收益是噪声变少、准确率变高:无关句子越少,模型把两段材料的数字张冠李戴的机会就越小。实验里这一步用便宜的 claude-haiku-4-5-20251001,生成仍然用 claude-sonnet-5。
三笔账要一起报。指标:回答更聚焦,要等 D8 建起评估才能量化。延迟:多一次模型调用,小模型虽快也是实打实的往返,每块都压就是并发多次。成本:省的是主模型输入 token,付的是小模型一次调用——块越大、主模型越贵越划算,材料本来就短的话纯属亏本。
自测输出(solution,MOCK=1):
✅ 上下文压缩:无关句子被删掉
压缩前 651 token → 压缩后 530 token,省了 18.6%这个 18.6% 是在这份三十篇语料、这个问题上跑出来的,换一份语料完全可能是另一个数。它的意义不在数字大小,而在于你的系统里必须有一个地方能打印出这一行——没有它,压缩到底是省了还是白白多了一次调用,你根本不知道。
压缩最危险的地方是小模型会顺手改写:提示词里写了"逐字保留"也拦不住,它很喜欢把两句缩成一句更通顺的。所以代码里必须加一道硬约束:保留下来的每一句都要能在原文里逐字找到,找不到就整块回退成未压缩版本。 还有一条同样重要的纪律:引用校验永远拿原文比对,不拿压缩产物比对。 用户点开看到的是原文,校验对象一旦跟用户看到的不是同一份,"校验通过"就什么都保证不了。
流式和引用是一对矛盾
最后一个取舍。流式的卖点是尽早出字,引用校验的前提是话说完了才能核对——这两件事天然打架。
服务端把回答切成一个个事件推给浏览器,用的是 SSE 这类文本协议,报文长这样:
event: meta
data: {"blocks":[{"n":1,"chunkId":"doc-010#c02"}],"contextTokens":530}
event: sentence
data: {"text":"开放 API 的默认限流是每分钟 600 次","citations":[{"n":1,"chunkId":"doc-010#c02"}]}
event: dropped
data: {"text":"管理员在后台可以把这个数值改成任意大小","detail":["[2] 的实质重合度只有 0.03"]}
event: done
data: {"status":"answered","emitted":1,"dropped":1}最朴素的做法是收到什么发什么。 这条路走不通,因为吐出去的字撤不回来:等你在末尾发现第三句的引用是编的,那句话已经在用户屏幕上了,你只能弹一个"刚才那句请忽略"——比不流式还糟。
折中办法是按句缓冲。 攒够一句就校验一次,通过了才把这句连同已核实的引用发出去,没通过就整句丢掉。代价很明确:首字延迟从"一个 token"变成"一句话",通常两三百毫秒,用户几乎察觉不到;而错误引用一旦上屏,赔的是信任。
let buffer = ''
for await (const delta of streamText(question, blocks)) {
buffer += delta
// 攒够完整句子才处理。半句话没法校验,因为引用标记通常在句号前面
const { sentences, rest } = takeSentences(buffer)
buffer = rest
for (const raw of sentences) {
// 把 [1][3] 从正文里剥出来:正文保持干净,编号单独走校验
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 }) // 整句不发
continue
}
send('sentence', { text: claim.text, citations: citationsOf(blocks, verdict.verified) })
}
}buffer = ""
async for delta in stream_text(question, blocks):
buffer += delta
# 攒够完整句子才处理。半句话没法校验,因为引用标记通常在句号前面
sentences, buffer = take_sentences(buffer)
for raw in sentences:
# 把 [1][3] 从正文里剥出来:正文保持干净,编号单独走校验
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)},
)还有一个顺序上的讲究:拒答要在流开始之前就发出去。 分数太低和材料冲突这两条在生成之前就判完了,直接推一个拒答事件然后收流,用户不会先看到半句回答再被收回。第三条判不了那么早,但按句缓冲之下它的表现就是"一句都没发出来",收尾补一个拒答事件即可。这就是为什么前面要把三条线按"生成前 / 生成后"分开——分开不只是代码结构问题,它直接决定了流式下用户看到什么。
源码导读
动手实验
动手之前先确认一件事:今天的检索直接用 D1 的 BM25,一行都不用改,要动的全在材料回来之后。starter 是一个能跑起来的完整服务,只有四处被挖空,挖空处都留了会让你一眼看到问题的默认行为——比如引用校验只查编号存在性,于是"编号真、内容假"那一类编造会一路放行。
- 跑一次 starter 的自测,看到 4 项通过、4 项失败,先把每一项失败的原因读懂再动手。
- 补完组装函数的预算控制与首尾重排,看到块编号顺序变成最高分在开头、第二名在结尾。
- 补完实质重合度,用带编造开关的请求验证:第 1 版被拒并附上两条具体原因,第 2 版通过。
- 补完冲突检测,问一次上传上限,看到回答并列出两个数字和各自的更新日期,而不是替你挑一个。
- 把流式路由改成按句缓冲,观察编造的那两句以丢弃事件出现,正文里一个错误编号都没有。
面试题
今天 4 道题在下方题库区,侧重上下文组装顺序的影响、引用可信度的校验、拒答阈值的确定,以及流式下怎么不放跑错误引用。展开后先看"分析过程"再看要点——照着推导练,比背要点管用。标注"国内高频 / 海外高频"方便按目标市场取舍。
检查清单与明日预告
- 能设计一份提示词结构,让模型只依据给定材料回答并按块编号标出处
- 能实现引用的事后校验,把模型编造的出处在返回给用户之前拦下来
- 能说明拒答的两条触发线:检索分数太低和材料之间互相矛盾,并各写出处理方式
- 能说清为什么"可验证性来自闭集,不来自措辞",并举出自由文本引用无法验证的具体原因
- 能讲出三条拒答线各自的判据、触发时机(生成前还是生成后)与话术差别
- 实验的 5 条验收标准全部通过,自测 8 项全绿
- 4 道面试题不看要点也能答出至少 3 道
明天(D7)我们把前六天的零件装成一个真正的服务:一条摄取命令、一个问答接口、一份配置说明,用容器编排一键起来。为什么是这个顺序?因为今天之前你手里是六堆各自能跑的代码,而"能跑"和"能交付"之间隔着模块边界、配置化和启动顺序这三件事。补完它们,第一周才算收口,第二周才好把评估这把尺子架上去量它。
面试题库
怎么让模型的引用是真的而不是编的?说出一个不依赖模型自觉的方案。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.
国内高频海外高频进阶#citation-verification#grounding#hallucination分析过程 · 先想清楚再作答
- 题眼在「不依赖模型自觉」这半句。回答里只要出现「在提示词里强调请确保引用准确」,这题就答砸了——面试官问的正是提示词管不住的那部分。
- 先把问题拆成两半:引用要能验证,前提是它是一个**闭集里的符号**,不是一段自由文本。所以第一步是组装上下文时给每块材料一个编号,提示词里明确只能引用发出去的编号。让模型写「根据《某某手册》」是没法验证的,标题是它可以随口生成的字符串。
- 第二步是事后核对,两道闸缺一不可。第一道查编号存在性:发出去的是 1 到 5,出现 8 就一定是编的,一行代码判掉。第二道查实质重合:编号是真的、内容却对不上,这类更隐蔽,要算这句话的词元有多大比例能在被引块原文里找到,低于阈值判不通过。
- 算重合度时有个坑要主动说出来:先剔掉在多数块里都出现的高频词元,否则「文件」「系统」这种词会让随便哪一块都及格。这跟 BM25 用逆文档频率压常见词是同一个道理。
- 校验不过怎么办:把具体原因写成反馈打回去重生成一次,只给一次机会;连着两版都编说明材料本来就不支持,该走拒答而不是第三次重试。另外校验必须拿原文比对,不能拿压缩或改写过的材料比对,否则「校验通过」保证不了用户点开看到的东西。
- 可预期的追问:为什么不让模型自己再检查一遍?因为自检和生成是同一个模型的同一种倾向,它对自己编的东西没有独立信息源;而编号核对是一个确定性判断,成本几乎为零、结果可复现,这两点自检都做不到。
How 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.
答题要点
- 引用必须是块编号这种闭集符号,不能是自由文本的文档标题——可验证性来自闭集,不来自措辞。
- 两道闸:编号存在性,以及这句话与被引块原文的实质重合度,后者才拦得住「编号是真的、内容对不上」。
- 算重合度前剔掉在多数块里都出现的高频词元,否则随便引哪一块都能及格。
- 校验不过就带着具体原因打回重生成一次,只给一次机会,两版都编就转拒答。
- 校验对象必须是用户能点开看到的原文,不是压缩或改写后的材料。
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?
国内高频海外高频基础#context-assembly#prompt-engineering#ordering分析过程 · 先想清楚再作答
- 这是一道送分题,但答成「按相关性从高到低排」就只拿到一半分。面试官想听的是你知不知道位置本身是个变量。
- 结论先说:会影响。模型对上下文开头和结尾的材料明显更敏感,正中间的最容易被读漏。所以简单按分数从高到低顺排,等于把第二重要的材料放进了最不容易被读到的位置。
- 给出排法:第 1 名放开头、第 2 名放结尾、第 3 名放第二位、第 4 名放倒数第二位,依次往里收。这样按分数排下来越靠中间的块本来就越不重要,被读漏的代价最小。
- 顺带把排序之外的三道手续说全,显得你真的写过这段代码:同分要有决胜键(否则块编号会在两次运行之间飘,日志对不上)、要按归一化文本去重(同一段话常在手册和问答里各出现一次)、要有 token 预算并且塞不下时不要直接停。
- 可预期的追问:这个结论怎么验证?答案是别猜——固定一批问题,只改排列顺序跑对照,看指标差多少。位置效应在不同模型、不同上下文长度上强弱不一样,把它当成一个要在自己数据上量的参数,而不是一条普适定律。
How 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 normalised 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 名第二位,依次往里收。
- 组装还要做三件事:同分给决胜键保证编号稳定、按归一化文本去重、控 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 normalised 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.
知识库问答的拒答阈值怎么定?定高了和定低了各自的代价是什么?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?
国内高频海外高频深入#refusal#thresholds#evaluation分析过程 · 先想清楚再作答
- 这题真正在考的是:你有没有意识到拒答不是一个阈值,而是好几条判据;以及你定阈值靠不靠数据。只谈一个分数阈值的回答,说明只做过最浅的一层。
- 先把拒答拆成三条线,它们的触发时机完全不同。检索分数太低:生成之前就能判,省一次模型调用。材料互相矛盾:也在生成之前判,代码在块之间找同一件事的不同数字,检出后要么并列两种说法与各自的更新日期,要么在有权威信号(比如一份点破了这条不一致的会议纪要)时按更新日期择一——选哪条是产品决策,但无论如何不能让模型自己悄悄挑一个。问题超出材料覆盖范围:只能在生成之后判,判据是跑完引用校验一条有效引用都没有。
- 强调三种话术必须不同。第一种要说「库里没有相关材料,换个说法或确认资料是否入库」,第三种要说「找到了相关文档但里面没有能直接回答的内容」——用户的下一步动作完全不同,混成一句「抱歉我不知道」等于把信息扔了。
- 再答代价这一半。定高了:能答的问题被挡在门外,用户看到查不到而材料其实在库里,这是最伤信任的一种错,而且它在日志里几乎不可见。定低了:低分噪声材料进上下文,模型拿着不相关的东西硬答,错误反而更隐蔽,因为回答看起来还带着引用。
- 怎么定:拿一批已知有答案和已知没答案的问题跑一遍,看两组的分数分布在哪里分开,按你更怕哪种错来取点。分数是没有绝对量纲的,换语料、换检索方式都要重定,所以真正要交付的是这套定阈值的流程,不是那个数字。
- 可预期的追问:单一分数阈值不够怎么办?答案是加判据而不是调数字——最高分与次高分的差、命中块数、以及生成后的引用校验结果,都是比原始分数更稳的信号。
How 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?
国内高频海外高频深入#streaming#citation-verification#api-design分析过程 · 先想清楚再作答
- 这题在考一个真实的架构矛盾:流式要尽早出字,引用校验要等话说完才能核对。看回答里有没有出现「取舍」两个字,以及有没有把代价说清楚。
- 先说清矛盾在哪:一旦一个 token 发到了浏览器就撤不回来,你在末尾才发现第三句引用是编的,那句话已经在用户屏幕上了,只能补一句「刚才那句请忽略」,体验比不流式还糟。
- 给方案:按句缓冲。攒够一个完整句子就立刻校验一次,通过了才把这句连同已核实的引用发出去,没通过就整句丢掉。代价是首字延迟从一个 token 变成一句话,通常两三百毫秒,用户几乎察觉不到,而错误引用一旦上屏赔的是信任。
- 补两个实现细节,它们能证明你写过:流式模式没法用 JSON 输出(要等右花括号闭合才能解析),所以改成纯文本加行内标记,但校验必须和非流式共用同一套;标记要从正文里剥掉,正文保持干净,编号单独走校验再作为结构化数据发出去。
- 再补一条顺序上的讲究:生成前就能判的两条拒答线(分数过低、材料冲突)要在流开始之前发出去,用户不会先看到半句回答再被收回;生成后才能判的那条,在按句缓冲之下表现为一句都没发出来,收尾补一个拒答事件即可。
- 可预期的追问:那用户体验上的流式感是不是就没了?没有,句级流式在中文长回答里仍然是明显的渐进呈现;真要更细,可以在句子发出前先流一个「正在核对」的占位态,但不要流未校验的正文。
How 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 变成一句话,这个代价必须付也付得起。
- 流式用不了 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.
评论
登录后即可参与讨论
还没有评论,来说第一句。