做文档问答的人大概都有过这种体验检索回来的片段明明就在那儿模型却一本正经地给你编了一条文档里根本没有的条款语气还很笃定。我最近一段时间在拿 Gemini 3.8 Flash 做企业内部的文档问答前期上线测试的准确率大概只有六成出头剩下的四成里真正因为“检索没召回”导致的失败只占一小部分大头全是幻觉——编条款、串数字、把别处的结论硬套过来。这篇就把我从“高幻觉”一路调到“文档问答稳定输出”的整个过程拆开讲清楚哪些是检索层的锅哪些是提示词没约束死哪些是采样参数在捣乱以及后置校验该怎么兜底。内容偏实战适合已经在跑 RAG、但回答质量忽高忽低的朋友也适合刚接触这类轻量模型、想少走弯路的新手。整套思路不依赖特定行业文档问答、知识库客服、合规条款查询都能套用。1. 先把“高幻觉”拆开看别急着换模型很多人一遇到胡说第一反应是模型不行换更大的。我一开始也这么想试过之后发现成本翻了三四倍幻觉只是从“编得很离谱”变成“编得很像真的”反而更难被发现。真正有效的做法是先分类把每一次错误回答打上标签看清楚它到底是哪一类问题。我前后人工标注了大约 300 条错误样本分完类之后结论很清晰模型本身不是主要矛盾是我们给它的“自由度”太大了。1.1 文档问答里幻觉的四种典型形态我把错误样本归成四类这个分类后来直接决定了后面每一层防线的设计重点。第一类是无中生有。问“合同解除需要提前多少天通知”文档里压根没写这一条模型自己补了个“30 天”。这类最危险因为答案格式完美、语气自然用户根本不会怀疑。第二类是张冠李戴。文档 A 规定普通员工报销上限 2000文档 B 规定部门负责人 8000用户问普通员工模型答 8000。数字是对的出处也是真的就是对应关系错了。这类错误在检索片段里同时包含多个相似实体时高发。第三类是过度概括。文档里写的是“在试点阶段适用”模型答成“普遍适用”。丢掉了限定条件结论就从局部变成了全局。第四类是引用造假。要求它标注来源它给你编一个看起来很像的章节号比如“依据第 4.2 条”实际文档只有 3 章。这类最隐蔽因为用户看到有引用往往就信了。这四类的成因完全不同第一类靠拒答机制解决第二类靠检索精度和实体对齐第三类靠提示词里的“限定条件必须原样保留”第四类靠后置校验。如果混在一起笼统地说“模型爱胡说”你就只能靠调温度这种粗暴手段效果有限。注意别把“答错”和“幻觉”混为一谈。检索没召回导致的答错本质是信息缺失检索召回了但模型没用对才是幻觉。两者的修法完全相反前者要加召回后者要加约束。混着修会互相抵消。1.2 为什么轻量模型在这个场景里更容易放飞Gemini 3.8 Flash 这类定位是快、便宜的模型它有个很明确的倾向倾向于给出一个答案而不是承认不知道。这跟训练目标有关你让它续写它就会续写得很顺。在开放对话场景这是优点在文档问答里就是灾难。我做过一组对照同样的检索片段、同样的提示词只改采样温度从 0.7 一路降到 0.1。温度 0.7 时20 道“文档里没有答案”的题有 15 道它硬编了内容温度降到 0.1编的降到 7 道但依然有一半在编。这说明温度只能压一部分“随机性发散”压不住“结构性幻觉”——也就是说问题出在它没有“拒绝回答”这个选项被激活。后来我把提示词改成一个显式的两分支结构先判断证据是否充分充分才回答不充分就输出固定话术。就这一改“无答案”题目的正确拒答率从 25% 提到了 88%。这个改动比换模型便宜太多也是我整套方案里性价比最高的一步。1.3 别忽略上下文里的“历史污染”还有一个坑我踩得比较深多轮对话。用户第一轮问“试用期多久”模型答了“三个月”第二轮问“那转正后呢”模型很容易顺着上一轮自己编的答案往下推。哪怕第一轮答错了第二轮它也会把错误当事实继续用。我的处理方式是每轮独立检索、独立构造上下文历史消息只保留用户的问题原话用于指代消解不把模型自己上一轮的回答塞回上下文。代价是多花一点检索开销收益是错误不会滚雪球。这一条看着简单但线上多轮场景的幻觉率能降一大截。2. 整体调优思路把“自由发挥”改成“有条件填空”调优这件事我最大的感受是别把它当“优化措辞”要当成“设计一个受限的输出通道”。模型的输出空间越小、越可验证稳定性就越高。整条链路我拆成四层每层管一件事互不越界。2.1 四层防线的分工与边界层级主要职责解决什么不负责什么检索层只把相关且干净的片段送进去张冠李戴、信息缺失不负责约束措辞提示层定义回答契约、拒答协议无中生有、过度概括不负责纠错参数层控制发散度与输出长度随机发散、答一半截断压不住结构性幻觉校验层核对引用与关键数字引用造假、数字串位不能修复语义错误分层的意义在于出问题时你能快速定位是哪一层漏了。我见过不少方案把拒答规则、格式要求、语气要求全堆在一个超长提示词里结果改了一处崩三处。分层之后每层的输入输出都有明确契约调起来踏实多了。2.2 为什么最后没走微调这条路有人会问直接拿几千条问答对微调一下不行吗。我评估过结论是现阶段不划算理由有三个。一是文档在变。知识库每周都在更新微调模型的知识是冻结的每改一版都要重训成本和时间都不现实。二是幻觉的根因不在知识在行为。它其实“知道”文档里有什么只是倾向于编这属于输出行为的约束问题提示工程加校验更直接。三是可解释性。文档问答这种场景用户往往要看到“依据哪一段”微调后的模型你很难保证它引用的是真片段而检索加锚点的方式天然可追溯。真要说微调什么时候值得上我觉得是当你已经有稳定的检索链路、稳定的提示模板剩下那部分错是“领域表达习惯”导致的——比如法务口径、医疗术语的固定说法。这时候再做小样本微调补最后几个点性价比才出来。上来就微调等于把检索层和提示层的问题固化进权重里后面更难调。2.3 一个反直觉的发现约束越多回答反而越自然我原本担心加了这么多硬约束回答会变得像填表一样僵硬。实际跑下来完全相反。因为约束主要约束的是“依据”不是“表达”。我允许它用自己的话组织语言只要求每个结论后面挂锚点。结果回答的可读性没降用户满意度反而升了因为每条结论都能点开看原文信任感上来了。这里的关键是把约束加在“事实来源”上把自由留给“表达方式”。反过来做——放任事实、约束句式——就完全是另一个结果你会得到一堆格式工整但内容飘忽的答案。3. 检索层实操先把噪声挡在门外提示词再强也救不了脏输入。我前期有个误区觉得召回越多越保险top_k 直接拉到 10结果模型被一堆半相关片段干扰答案里经常混进隔壁文档的内容。后来把召回压下来、把重排加上去幻觉率掉了将近一半。3.1 分块大小怎么定给个能直接抄的算法分块这件事没有万能值但有个推导方法。我的做法是先统计你文档里“一个完整语义单元”的平均长度。比如制度类文档一条条款平均 180 到 250 字产品手册里一个功能说明平均 300 到 400 字。把 chunk_size 定在这个区间的 1.2 倍左右保证一个语义单元尽量不被切断。我最终用的是chunk_size 480 tokenoverlap 96 tokenoverlap 取 chunk 的 20%。为什么是 20% 而不是常见的 10%因为条款类文本经常在边界处出现“前一条的例外说明”10% 的 overlap 兜不住这种跨界的补充句模型看到半截话就容易自己脑补结尾。还有一个细节按结构切不按固定长度切。我优先按标题层级切其次按段落最后才按长度硬切。硬切出来的片段经常以“并且……”开头这种片段喂给模型它必然要补全前半句补全就是幻觉的温床。3.2 召回数量、重排与顺序摆放top_k 我最后定在4 到 6之间动态调整如果第一名的重排分数明显高于第二名比如高出 0.15 以上就只取前 3如果前几名分数咬得很紧取到 6。顺序摆放也有讲究。模型对上下文开头和结尾的信息利用率明显高于中间这是老问题了。我的做法是把重排后分数最高的片段放在最前面第二高的放在最后面中间的按分数降序排。实测这个调整让“答案落在中间片段”的题目正确率提升了大约 8 个百分点。另外一定要加重排。纯向量召回在“数字类”“否定句”上很容易翻车。比如问“哪些情况不予受理”向量召回经常把“予以受理”的段落排前面因为语义太接近了。加一个交叉编码器重排这类问题能救回来一大半。def build_context(chunks, top_k6, high_score_gap0.15): # chunks 已按重排分数降序 if len(chunks) 2 and chunks[0].score - chunks[1].score high_score_gap: picked chunks[:3] else: picked chunks[:top_k] if len(picked) 2: return picked # 最高分放头次高分放尾其余居中 ordered [picked[0]] picked[2:] [picked[1]] return ordered提示召回的片段里最好带上来源标识和章节路径比如[制度手册/第3章/3.2]。这不只是为了展示引用更重要的是让模型在生成时有个“我现在说的是哪一段”的锚能明显减少跨文档串味。3.3 检索层最容易忽略的一步查询改写用户的提问往往很口语甚至带错别字直接拿去检索效果很差。我加了一步轻量改写把问题改写成 2 到 3 个检索式分别召回后合并去重。比如“报销要几天到账”会改写成“报销 到账 时效”“费用报销 处理周期”。这一步不追求改写得多漂亮只求覆盖面。改写用同一个小模型跑就行成本很低。实测召回率提升在 15% 左右尤其是那种用户用词和文档用词完全对不上的情况收益特别明显。4. 提示词层把“回答契约”写死这是整套方案里我改得最多的一层前后迭代了十几版。核心思路是不要让模型“自由作答”而是让它走一个判断—作答—标注的固定流程。下面这版是线上稳定跑的版本可以直接拿去改。4.1 系统提示的结构模板我把系统提示分成五块顺序不能乱因为模型对靠前的内容遵循度更高。第一块是角色与边界一句话说清它是干什么的以及它只能依据给定材料。第二块是判断规则明确什么情况算证据充分、什么情况算不充分。第三块是作答格式规定每条结论后必须挂锚点。第四块是拒答协议给出固定话术。第五块是禁止事项列出绝对不能做的事。你是文档问答助手。你只能依据【材料】中的内容作答 不得使用材料之外的任何知识不得进行推测和补全。 判断规则 1. 若材料中存在能直接回答问题的原文按作答格式输出。 2. 若材料只覆盖问题的一部分只回答被覆盖的部分 并明确说明其余部分在材料中未提及。 3. 若材料完全不涉及该问题直接输出拒答话术不要解释。 作答格式 - 每条结论独立成句句末标注来源格式为 [来源标识]。 - 涉及数字、期限、比例、条件的必须原样引用不得改写。 - 原文中的限定词如仅限暂定试点期间必须保留。 拒答话术 根据现有文档无法确定该问题的答案。 禁止事项 - 禁止编造条款编号、章节号、文件名。 - 禁止把多个来源的结论合并成一条。 - 禁止使用通常一般来说一般而言这类概括表述。这段提示词里我认为最关键的是**“限定词必须保留”和“禁止概括表述”这两条**。第三类幻觉过度概括基本就是靠这两句压下去的。之前问“这个政策适用范围”模型老是把“试点期间”丢掉加上这两条之后同类错误从 30% 降到 6%。4.2 锚点机制与拒答话术的写法细节锚点标识不要用模型自己生成的内容要用检索时带进来的固定 ID。我在每个片段前面加一行【片段ID: D07-3.2】要求模型引用时原样抄这个 ID。这样后置校验时可以直接做字符串匹配不需要语义判断准确率接近 100%。拒答话术也有讲究。我试过好几种写法最后选了最简短的那句。原因是话术越长模型越容易在话术后面“顺便补一句”而补的那句往往就是幻觉。短话术它没空间发挥。另外不要在拒答话术里加“请提供更多信息”这类引导语加了之后模型会倾向于先猜一个答案再让你确认反而更糟。还有一个实操细节把拒答话术在提示词里出现两次一次在规则里一次在结尾。我发现这个重复对轻量模型的效果挺明显结尾那次相当于临出门前的最后一次提醒。4.3 少样本示例怎么放才不帮倒忙示例有用但放错了会起反作用。我一开始放了 5 个正例结果模型开始模仿示例的句式遇到稍微不同的题也往上套。后来改成3 个正例 2 个拒答例并且正例覆盖不同类型一个是直接给数字一个是带条件判断一个是要跨片段拼接拒答例一个是完全无关一个是只沾一点边。这样模型能看到“什么该拒”的边界在哪。示例的写法上我建议示例里也带上锚点让模型看到锚点是格式的一部分不是可选项。另外示例不要写太长每条控制在 100 字以内太长会挤占上下文。5. 采样参数与输出控制把发散压到最低参数这块很多人凭感觉调。我的建议是先把每个参数的物理含义搞清楚再针对文档问答这个场景定向设置别用通用对话的默认值。5.1 参数取值与背后的理由参数我用的值为什么这么设temperature0.1文档问答不需要创造性越低越稳再低到 0 有时会导致重复输出top_p0.85配合低温度收窄候选集同时保留一点表达多样性top_k20太小会让措辞机械太大等于没限制max_output_tokens按题型分档事实型 300分析型 800避免答一半被截断后模型硬凑candidate_count1不要多候选多候选只会增加挑选成本关于max_output_tokens我要多说一句。截断是幻觉的隐形推手模型答到一半被切断用户看到的是半句话但更麻烦的是有些方案会做“续写补全”一补就容易编。我的做法是按题型预估长度宁可给多一点也不要卡在边界上。还有一个容易忽略的点停止序列。我在某些场景里设了停止符比如要求它答完就停不要加“希望对你有帮助”这类尾巴。加了停止序列之后输出更干净后置解析也更好做。5.2 输出格式约束与解析我要求输出走一个固定的轻结构方便程序解析结论句一。[D07-3.2] 结论句二。[D12-1.4]不用严格的 JSON。原因很实际轻量模型在长上下文下输出 JSON 偶尔会漏括号一漏整个解析就崩反而要重试。用行级结构解析容错高得多正则就能搞定。import re ANCHOR re.compile(r\[(D\d{2}-[\d.])\]) def parse_answer(text): lines [l.strip() for l in text.splitlines() if l.strip()] items [] for line in lines: anchors ANCHOR.findall(line) body ANCHOR.sub(, line).strip() if body: items.append({text: body, anchors: anchors}) return items如果确实需要结构化字段我的做法是先让它输出自然语言加锚点再用一次规则提取而不是让它直接吐 JSON。多花一次解析换来的是稳定性值。5.3 长文档场景下的上下文预算上下文不是塞满就好。我做过测试同样 6 个片段塞满到 90% 上下文和只用到 40%后者准确率高。原因是长上下文里模型对中段的注意力会衰减无关内容越多干扰越大。我的预算分配是系统提示占 15%示例占 10%检索片段占 55%留 20% 给输出。超过这个比例就得分批处理或者先做摘要压缩。压缩的时候要注意保留数字和限定词这两类信息一旦在压缩中丢了后面怎么调都救不回来。6. 后置校验最后一道闸门做到前面几步幻觉率大概能从 40% 降到 12% 左右。剩下这 12% 靠提示词很难再压得上程序校验。这一层的思路很朴素凡是能机器核对的就别信模型。6.1 引用核验与数字核验引用核验最简单把答案里所有锚点 ID 抽出来跟本轮检索片段 ID 集合做交集有任何一个不在集合里就判定为引用造假直接降级处理。数字核验稍微麻烦一点但收益很大。做法是把答案里的数字全部抽出来逐个去对应的片段原文里找找不到就标记可疑。这里要注意单位换算比如原文写“三个月”答案写“90 天”字面匹配不上但语义是对的。我的处理是维护一个小的等价表覆盖常见的期限、比例表达匹配不上等价表才判可疑。NUM re.compile(r\d(?:\.\d)?%?) def verify_numbers(answer_items, chunk_map): suspects [] for item in answer_items: nums NUM.findall(item[text]) if not nums: continue source_text .join(chunk_map.get(a, ) for a in item[anchors]) for n in nums: if n not in source_text and not in_equivalence(n, source_text): suspects.append((item[text], n)) return suspects6.2 校验不通过之后怎么办这一步的设计比校验本身更重要。我见过两种极端一种是一发现问题就整个答案丢弃用户体验很差另一种是发现了也不管等于没做。我的分级策略是这样的引用造假整条结论删除。因为来源都编了内容不可信。数字对不上该条结论标注“待核实”同时把原文片段一并展示给用户让它自己判断。全部结论都被删降级为拒答话术走人工兜底入口。这套分级跑下来线上“错误答案直接呈现给用户”的比例从 12% 压到了 3% 以内。剩下的 3% 主要是语义层面的错误比如把两个都对的事实错误关联这类靠规则确实抓不住得靠人工抽检持续积累样本。注意校验层不要做得太重否则延迟上去了用户体验一样差。我的整条校验链路耗时控制在 200ms 以内主要是字符串匹配没有调用模型。如果要用模型做二次校验一定要设超时超时就放行原始答案并标记不要让整个请求挂住。7. 常见问题与排查速查表下面是这几个月踩过的坑整理成表遇到类似症状可以直接对照。症状大概率原因排查动作处理方式答得很流畅但文档里没有拒答协议没生效检查提示词里判断规则的位置把判断规则提到角色描述之后、格式要求之前数字总是差一点检索片段跨块切断打印实际送入的片段原文加大 overlap 到 20%或改按段落切引用 ID 全对但内容不对模型把多个片段拼接检查锚点与结论的对应关系加“禁止合并多来源”约束多轮对话越答越偏历史回答被当事实检查上下文构造逻辑每轮独立检索不塞历史答案相同问题两次答案不同温度偏高或候选随机记录两次的采样参数温度降到 0.1固定 seed若支持答到一半断掉max_output_tokens 不足统计答案长度分布按题型分档设置上限拒答率突然变高检索召回质量下降看召回片段的分数分布检查索引是否更新、重排服务是否正常表格类问题答不好表格转文本时结构丢失打印片段原文表格单独存为结构化字段转成“列名: 值”形式除了表里这些还有两个我觉得值得单独说。一个是**“拒答过度”**。加完拒答协议之后有一段时间拒答率飙到 30%用户抱怨“明明文档里有”。查下来是判断规则写得太严要求“必须存在完整原文才能回答”。后来改成“存在能回答问题的关键信息即可”拒答率回到 8% 左右。阈值这个东西一定要用测试集去卡别凭感觉定。另一个是测试集的构建。我一开始用随机抽题发现问题根本暴露不出来。后来改成三类题各占三分之一文档里有明确答案的、需要跨两段拼接的、文档里完全没有的。第三类最重要它专门用来测拒答没有这一类题你根本不知道拒答机制有没有生效。测试集我建议至少 100 题起每改一次提示词就全量跑一遍把每次的结果记下来才能看出改动到底有没有用。最后分享一个我自己的习惯每次调整参数或提示词我只改一个变量。听起来很笨但这是唯一能搞清楚“到底是哪一处改动起了作用”的办法。有一次我同时改了分块大小和温度效果变好了我以为是大分块的功劳回头单独测才发现主要是温度降下来起作用分块改了反而让召回变差。这种归因错误如果没被发现后面会一直背着错误的经验往下走。文档问答这个活儿慢就是快。
阅读完成 · 觉得有帮助?