RAG 把幻觉从"模型编事实"降级成"模型编引用",但没消灭它。很多人以为接上知识库就安全了,实际线上最常见的翻车是:检索到了正确文档,模型答出了另一个数字,并且语气比之前更自信——因为这次它"有据可依"。
幻觉不是一种病,是五种不同机制的表现。判断是哪一种,决定了该改 prompt、改检索还是改切分。
1. 五种成因与对应解法
| # | 成因 | 表现 | 根因位置 | 对应解法 |
|---|---|---|---|---|
| 1 | 检索没召回却强行作答 | 答案里没有任何文档支持的事实 | 检索层 | 无答案判定 + 拒答 |
| 2 | 召回了但上下文冲突 | 两个版本的数字混着说 | 语料/排序层 | 冲突检测 + 时效优先 |
| 3 | 指令让模型"补全"而非"引用" | 把文档里没有的步骤补齐 | prompt 层 | 强制引用 + 禁止外推 |
| 4 | 长上下文中间遗忘 | 中间段落的内容被忽略 | 上下文组装层 | 重排 + 截断 + 去重 |
| 5 | 引用标注缺失,难以核对 | 说得对但查不到出处 | 输出结构层 | 给 chunk 编号并要求标号 |
判断顺序建议从上往下:先排除第 1 种。如果检索根本没给到相关片段,后面所有 prompt 工程都是在给空手的人配更好的手套。
2. 成因一:没召回却作答
模型在空上下文下并不会沉默,它会用预训练知识补。这是它的默认行为,不是 bug。
| 检索情况 | 不设防的表现 | 设防后的表现 |
|---|---|---|
| top1 分数明显低 | 照答,且正确率不可控 | "知识库里没有找到相关内容" |
| 召回了不相关文档 | 强行解读无关片段 | 判定为无答案 |
| 召回了部分相关信息 | 用部分信息推全部 | 只答已覆盖部分,明确缺口 |
防线是两级的:检索层给置信度,生成层拒答。
def answer_or_refuse(question, hits, conf, min_conf=0.62, min_hits=1):
if not hits or conf < min_conf or len(hits) < min_hits:
return {
"answer": None,
"reject_reason": "no_support",
"hint": "换个说法,或补充型号 / 版本号再问",
}
return {"answer": generate(question, hits), "reject_reason": None}
注意 min_hits=1 不是凑数:只召回一条但置信度很高时答是合理的,问题出在"召回五条、分数都在 0.5 左右"——那种情况整体都不该答。
3. 成因二:召回了但上下文冲突
典型场景:文档里有 v2.3 和 v3.1 两套配置,模型把两套的参数混成一套。这不是幻觉,是它选择了融合而不是取舍。
| 冲突类型 | 例子 | 处理策略 |
|---|---|---|
| 版本冲突 | 新旧版本参数不同 | 按 doc_version 排序,旧版本降权或剔除 |
| 时间冲突 | 已废止的通知仍在库里 | 用 effective_to 字段过滤 |
| 主体冲突 | 两家客户的配置混在一起 | 用租户/项目字段硬过滤 |
| 表述冲突 | 同义不同说法 | 保留规范表述,去重 |
def resolve_conflict(hits, prefer_field="doc_version"):
"""同一 section 只保留最高版本,其余转为'历史版本'注记。"""
best, history = {}, []
for h in hits:
key = (h["doc_id"], h["section_path"])
if key not in best or h[prefer_field] > best[key][prefer_field]:
if key in best:
history.append(best[key])
best[key] = h
else:
history.append(h)
return list(best.values()), history
然后把历史版本显式告知模型,而不是丢弃——它会成为"为什么要用新版"的依据:
[3] 部署参数(来源:guide-v3.1 第 4.2 节)
默认超时 30s,重试 2 次。
[3-OLD] 同小节历史版本(guide-v2.3):默认超时 10s。
若用户引用旧值,指出该值已在 v3.1 变更。
4. 成因三:指令让模型"补全"
"请根据以下资料回答"这句话本身就埋了雷:资料不全时,模型要么拒答、要么补全。它通常选择补全,因为拒答在训练里被罚过。
| 指令写法 | 引导出的行为 | 改法 |
|---|---|---|
| "根据资料回答问题" | 补全缺失步骤 | "只使用资料中的事实,资料未覆盖的部分明确说明" |
| "总结以上内容" | 加入未见信息 | "逐条列出资料中出现的结论,不新增" |
| "给出完整流程" | 用常识填步骤 | "按资料顺序复述,缺哪步就标注缺失" |
| "回答要专业" | 用术语替代原文 | "术语保留原文说法,不改写专名" |
改成"引用式",让每个事实都有一个可核对的挂点:
你是资料检索助手。规则:
1. 每个事实性陈述后面必须标注来源编号,如 [2]。
2. 只允许使用编号资料中的信息;不得使用预训练知识补充。
3. 资料不足以回答时,输出:不足以回答:<缺什么>。
4. 若资料之间存在冲突,分别列出并说明差异,不要合并。
5. 不得推断资料中未出现的数值、版本号、日期。
第 5 条是专门针对"数字幻觉"的。模型最擅长编的一类事实就是数值,因为它看起来"结构化"。
5. 成因四:长上下文中间遗忘
上下文塞得越多,中间段落被利用的概率越低。位置效应表现为 U 形:开头和结尾的召回率高,中间最低。
| 上下文组装方式 | 中间遗忘风险 | 建议 |
|---|---|---|
| 按相似度从高到低平铺 | 低(关键在开头) | 默认可以 |
| 按文档顺序平铺 | 高 | 关键段提前 |
| 贪心堆满 token | 极高 | 裁到必要条数 |
| 重排后再组装 | 最低 | 首选 |
def pack_context(reranked, max_chunks=6, max_chars=6000):
"""重排后组装:强相关在前与在后,弱相关居中,缓解中间遗忘。"""
picked, size = [], 0
for h in reranked:
if len(picked) >= max_chunks or size + len(h["text"]) > max_chars:
break
picked.append(h)
size += len(h["text"])
if len(picked) <= 2:
return picked
return [picked[0], *picked[2:-1], picked[-1], picked[1]]
这个"首尾优先"的排列是对位置效应的直接利用:把最强证据放在模型真正会用到的位置上。
6. 成因五:引用缺失导致难以核对
没有引用的答案,人工复核只能靠通读,成本高到没人做。所以引用不是锦上添花,是让幻觉可被发现的前提。
| 输出结构 | 可核对性 | 代价 |
|---|---|---|
| 纯文本答案 | 无 | 低 |
| 答案 + 文末来源列表 | 弱(不知哪句对哪条) | 低 |
句级 [n] 标号 |
强 | 中 |
| 句级标号 + 原文摘录(quote) | 最强 | 中高 |
import json, re
CITE = re.compile(r"\[(\d+)\]")
def check_citations(answer: str, chunks: list[dict]) -> dict:
cited = {int(m) for m in CITE.findall(answer)}
valid = set(range(1, len(chunks) + 1))
unknown = cited - valid
uncited = [s for s in answer.split("。") if s.strip() and not CITE.search(s)]
return {
"unknown_ids": sorted(unknown), # 编造的编号
"sentences_without_cite": uncited, # 未标注的陈述
"coverage": round(len(cited & valid) / max(len(valid), 1), 2),
}
unknown_ids 非空是最强的幻觉信号:模型引用了一个不存在的编号,说明它在凭印象造结构。
7. 完整链路
def rag_answer(question, retriever, reranker, llm, cfg):
hits = retriever.search(question, k=cfg.recall_k)
if not hits:
return refusal("no_hits")
hits = reranker.rank(question, hits)[: cfg.rerank_keep]
kept, history = resolve_conflict(hits)
conf = confidence(question, hits)
if conf < cfg.min_conf:
return refusal("low_conf", conf)
ctx = pack_context(kept, max_chunks=cfg.max_chunks)
prompt = build_cited_prompt(question, ctx, history, cfg)
answer = llm(prompt)
return {"answer": answer, "citations": check_citations(answer, ctx), "conf": conf}
每一步的存在都对应前面一个成因:没有 hits 拦成因一,resolve_conflict 拦成因二,build_cited_prompt 拦成因三,pack_context 拦成因四,check_citations 让成因五可被发现。
8. 排错:五种"还是幻觉"
| 现象 | 先查什么 | 处理 |
|---|---|---|
| 编了个不存在的编号 | unknown_ids |
prompt 里明确编号取值范围,加后置校验重生成 |
| 数字差一位 | 上下文里是否有同类数字 | 要求数值必须带引用,且引用段落必须含该数字 |
| 把两版本混成一段 | resolve_conflict 是否生效 |
加版本硬过滤 |
| 步骤漏了一步 | 是否跨 chunk | 调重叠或改父子块 |
| 拒答率过高 | 置信阈值是否过严 | 用标注集重标阈值 |
第二条值得展开:数值幻觉的正确校验不是"让模型更小心",是字符串级核对——答案里的数字必须能在某个被引用 chunk 中找到。
9. 边界
| 场景 | 说明 |
|---|---|
| 开放域闲聊 | 无 RAG,幻觉问题由模型侧解决 |
| 语料本身错误 | RAG 会忠实传播错误,需要在入库时治理 |
| 需要推理链的问答 | 引用能标注依据,但推导过程仍需人工复核 |
| 强烈时效性(价格、库存) | 必须走 API,不走文档检索 |
| 多轮对话 | 上下文冲突要按会话内的"最新确认值"处理 |
最后一条常被忽略:多轮里用户说"我们用的是 v2.3",这个信息优先级应高于检索到的默认版本,否则每轮都会得到同一个"正确但不对"的答案。
10. 自测
- 五种成因里,哪一种应该最先排除?为什么?
- 只召回一条但置信度高,应该拒答吗?
- 两个版本冲突时,为什么要把旧版本留给模型而不是直接删掉?
- 上下文组装按什么顺序排列可以缓解中间遗忘?
unknown_ids非空说明了什么?- 数值幻觉为什么适合用字符串级核对而不是语义核对?