RAG 防幻觉三板斧:Prompt 约束、低温度、空检索兜底(附超纲题实测)

0 阅读9分钟

企业知识库场景里,幻觉是致命的:员工问"年假几天",系统编一个"15 天",人力部门是要背锅的。我给自己的知识库问答系统设计了三道防线,并且专门出了 3 道知识库里根本没有的超纲题做实测——全部正确拒答,没有一个字是编的。这篇文章讲每道防线的原理、实现和它防住的是哪一类错误。

t3_三道防线.png

先定义问题:编造有三种形态

  • 编内容:文档里没有的规则,它给你现编一条;
  • 编数字:文档里有 5 天,它说成 10 天;
  • 答非所问:检索回来的资料不对,模型硬拿不相关的内容拼一个答案。

三种形态的根源不同,所以防线也要分层。编内容和编数字发生在生成侧,靠提示词和温度压;答非所问发生在检索侧,靠检索质量和兜底逻辑拦。下面按顺序过三道防线,最后用超纲题的完整回答原文检验它们到底有没有用。

第一板斧:系统提示词立规矩

这是最直接的一道。我的系统提示词就三条规则,核心是第一条:

SYSTEM_PROMPT = """你是企业内部知识库助手。规则:
1. 只能根据下面【参考资料】回答,参考资料里没有的信息,明确说"知识库中未找到相关内容",禁止编造。
2. 回答中引用资料时,用 [资料1]、[资料2] 这样的标注指明出处。
3. 回答用简体中文,条理清晰,必要时分点。
"""

光有规则不够,还得给模型"材料可指"的结构。检索回来的切片在拼进提示词之前,会先编号——这段实现很短,但它是引用标注能闭环的前提:

def _build_context(self, hits: list[dict]) -> str:
    """把检索到的切片拼成编号上下文,编号就是回答里的引用标记。"""
    blocks = []
    for i, h in enumerate(hits, start=1):
        blocks.append(f"【资料{i}】(来源:{h['source']} 第{h['page']}页)\n{h['content']}")
    return "\n\n".join(blocks)

然后整体组装进 messages:

messages = [
    {"role": "system", "content": SYSTEM_PROMPT + "\n\n【参考资料】\n" + self._build_context(hits)},
    *history.get_history(session_id),  # 多轮记忆:把最近几轮对话带上
    {"role": "user", "content": question},
]

这里有个设计闭环:上下文里每个切片被标成【资料1】【资料2】,提示词规则又要求回答用 [资料1] 标注出处——两边编号对得上,引用就从"口头承诺"变成了可程序断言的东西。我的评测脚本能自动检查"答案标注的来源文档是否等于期望文档",就是吃到了这个闭环的红利。提示词写得好不好,判据不是读起来顺不顺,而是它产出的行为能不能被机器验证。

顺带说下为什么规矩放在 system 消息里而不是拼进用户问题后面:system 角色在对话结构里就是"给模型立人设和纪律"的位置,指令遵循的权重最高;拼在用户消息里,规则会和用户问题混在同一段文本里,长问题时规则容易被稀释。这也是我照搬后端习惯的又一例——配置和输入分开,别揉在一起。

实测里我专门验证过它的效果:问"公司年终奖什么时候发"(三份文档里都没提年终奖),回答原文是——

知识库中未找到相关内容。

现有参考资料仅涉及员工考勤管理制度 [资料1]、请假与休假管理办法 [资料2][资料3] 以及差旅费报销管理规定 [资料4],均未提及年终奖发放时间的相关信息。建议您咨询人力资源部或查阅薪酬福利相关的制度文件。

不仅拒答了,还告诉用户库里有什么,这个体验比干巴巴一句"不知道"好。

第二板斧:温度压到 0.3

知识库问答要的是稳定复述,不是创造力。代码里这行注释就是态度:

stream = self._client.chat.completions.create(
    model=config.LLM_MODEL,
    messages=messages,
    temperature=0.3,  # 知识库问答要稳不要浪,温度调低
    stream=True,
)

温度的机制说穿了很简单:它缩放采样分布,值越高,低概率的词被选中的机会越大。0.7 和 0.3 的差别不在"对不对",而在输出轨迹上——

  • 温度 0.7(聊天场景的常用值,我一开始就是从这里抄的配置):第一批测试就出现了同一问题两种说法。措辞漂移还算小事,更麻烦的是结构不稳定——这次分点、下次不分点,标注 [资料1] 的位置也飘。
  • 温度 0.3:同样的检索结果,多次提问的答案措辞基本一致。复述类任务的答案本来就没有"多样性"的价值,随机性在这里是纯噪声。

还有一层不那么直观的好处:引用标注 [资料1] 是强格式约定,采样随机性越低,格式越稳,我的溯源断言才能当回归信号用——如果格式本身都在飘,断言失败就分不清是"检索坏了"还是"格式抖了"。

边界也要说清楚:温度不是越低越好,压到 0 附近有些模型会陷入重复循环;0.3 是"稳定但不僵"的经验起点。我没有对温度做系统扫描,所以它在我这里是经验配置,不是调参结论。

第三板斧:检索为空直接兜底,不调模型

前两道防线都在生成侧,这第三道在它们之前。它位于 RagChain.stream_answer 的最开头,是整个问答流程的第 1、2 步,任何模型调用发生之前:

# 1. 检索:拿问题去向量库找最相关的切片
hits = self._store.search(question, top_k=top_k)

# 2. 检索为空:直接兜底,不浪费 token 调模型
if not hits:
    yield {"type": "sources", "data": []}
    yield {"type": "content", "data": "知识库中未找到相关内容,请先上传相关文档。"}
    history.append_message(session_id, "user", question)
    history.append_message(session_id, "assistant", "知识库中未找到相关内容")
    return

这一步同时省了钱和心。省心:检索都空了,把空上下文发给模型只会诱导它瞎编,不如直接返回引导文案;省钱:一次模型调用都不发生。注意最后两行——问题和兜底话术照常写进会话历史,用户补传文档后再追问,会话仍然是连贯的。

这里要较真一个细节:Top-K 检索在库非空时永远会凑满 K 条,那 if not hits 什么时候才会真触发?翻一下 store.search 的实现就知道——只有向量库整个集合为空的时候。所以这行兜底的真实身份是空库防御:刚部署还没传文档、或者文档被删光的状态下,用户发起的第一句提问就落在这里,得到的是"请先上传相关文档"的引导,而不是一场必然翻车的模型调用。它防的是"系统冷启动"这一种形态,和超纲拒答(库里有东西但内容不相关)不是一回事,别指望它。

超纲题实测:三段拒答原文逐条对账

三道超纲题的完整回答和它为什么对,逐条过。

1. "公司食堂晚餐几点开饭?"

知识库中未找到相关内容。

现有参考资料仅涉及考勤管理(工作时间、打卡、迟到、加班、外勤出差等)和请假休假管理办法,未包含公司食堂供餐时间的信息 [资料1][资料2][资料3][资料4]。建议咨询行政或后勤相关部门。

对在哪:首句给出标准拒答语(评测脚本用正则 未找到|没有找到|... 判定,命中);第二句复述了库内范围,帮用户校准"该问什么";第三句给出转介方向。这题检索回来的 4 个切片相似度分数是 0.4193、0.3985、0.3914、0.3880——全是考勤和请假文档里"最高分的不相关内容",模型顶住了这些低质量资料的诱导,没有拿"考勤制度"硬拼一个答案,也就是防住了第三种形态"答非所问"。

2. "试用期工资打几折?"

知识库中未找到相关内容。

现有参考资料仅涉及考勤、加班、外勤出差、请假与休假(年休假、病假、事假、婚假、产假、陪产假、丧假)等规定 [资料1][资料2][资料3][资料4],未包含试用期工资的相关条款。建议咨询人力资源部确认。

对在哪:这题最容易被"脑补"——试用期工资是大众话题,模型预训练里见过无数相关内容,编一个"通常为转正工资的 80%"毫无难度。它没有。这题的检索分数是 0.4452、0.4450、0.4263、0.4202,三道超纲题里最高的一组,依然全部低于 0.45。

3. "年终奖什么时候发?"

回答原文在第一板斧那节已经逐字贴过。补充检索侧数据:4 个切片分数 0.4281、0.4209、0.4116、0.4078。同样拒答,且指出了该去哪查(人力资源部、薪酬福利制度文件)。

把分数放在一起看会更有意思:库内题的 top1 分数普遍在 0.6 以上(比如"一线城市住宿费上限"那题 top1 到了 0.8227,"报销时限"0.7789),而三道超纲题的全部切片分数都在 0.45 以下。库内和超纲之间在相似度分数上存在明显间隔——这个间隔就是下一步加相似度阈值的数据依据。

一个必须诚实说的边界

我的检索是 Top-K,没有相似度阈值——也就是说检索永远会返回 K 个结果(哪怕都不相关),拒答实际全靠第一板斧的提示词约束。3 道超纲题实测全对,但这不是机制性保证,换一个更强的模型或者更刁钻的问法就可能漏。上面的分数间隔说明阈值有可做的空间:低于阈值的检索结果直接走第三板斧的兜底话术,拒答就从"提示词求它"变成"代码保证它"。这是我列在下一步的明确改进项。

宁可拒答、不要瞎答——在知识库场景里,拒答率是正向指标,用户重新组织问题或者转人工,都比拿到一条编造的制度强。

代码、评测明细和复现命令都在仓库里: