2592 字
约 8 分钟
12
S20-X06 · RAG 为什么还会幻觉:五种成因与解法

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. 自测

  1. 五种成因里,哪一种应该最先排除?为什么?
  2. 只召回一条但置信度高,应该拒答吗?
  3. 两个版本冲突时,为什么要把旧版本留给模型而不是直接删掉?
  4. 上下文组装按什么顺序排列可以缓解中间遗忘?
  5. unknown_ids 非空说明了什么?
  6. 数值幻觉为什么适合用字符串级核对而不是语义核对?
S20-X06 · RAG 为什么还会幻觉:五种成因与解法
https://www.clxhxhhr.top/posts/5681/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。