你搭好了 RAG:切分、向量化、检索、重排,四件套齐活。结果一上线,大模型还是给你一本正经地胡说八道。

问题多半不在召回,而在最后那一关没人把关。向量相似度 0.55 的段落,你不知道它是"真相关"还是"只是字面像"——而这恰恰是幻觉的温床。

这篇讲我在开源项目 feishu-kb-qa 里用生产级代码实现的一套反幻觉方案:向量粗排 → LLM 判官打分 → 对抗式复核 → fail-closed 兜底。用真实数据告诉你,怎么把无关文档误报从 13 条压到 0

适合人群:已经搭过 RAG demo、想上生产但被幻觉困扰的工程师。源码获取方式见文末。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传


一、先承认一个事实:向量分数根本分不清"相关"和"无关"

很多人搭 RAG 时默认信一个假设:向量相似度高 = 相关,低 = 不相关,设个 0.7 阈值就能过滤

这是 RAG 里最大的一个坑。我用自己项目的真实数据给你看(嵌入模型是智谱 embedding-3):

段落类型 余弦相似度区间
真相关(真能回答问题的段落) 0.55 ~ 0.76
不相关(只是字面/话题沾边) 0.25 ~ 0.55
重叠区 0.55 附近

看出来了吗?相关的和不相关的,在 0.55 附近是重叠的。意思是:一条 0.56 的段落,它可能是真答案,也可能是个"看着像但其实没用"的干扰项——光看分数,你根本分不清

📌 关键认知:如果你照搬网上"设 0.7 阈值"的经验,真答案(0.55~0.76)会被误杀一大半;如果你不设阈值全放进去,干扰项会带偏大模型。向量相似度只能用来"排序",不能用来"判定相关"

在这里插入图片描述

▲ 图 1 | 真相关和不相关在 0.55 附近重叠——光看分数,分不清"真能答"和"只是字面像"

那怎么办?我的答案:让 LLM 来当判官


二、核心思路:给 RAG 加一道"判官"工序

先看这张图,理解"判官"在整个 RAG 链路里的位置:

在这里插入图片描述

▲ 图 2 | 判官不是替代重排,而是叠在重排之后——向量负责"别漏",判官负责"别错"

经典 RAG vs 加判官的 RAG

经典 RAG(向量+重排) 我的方案(向量+判官+复核)
召回 向量/BM25 向量/BM25(一样)
粗排 向量余弦排序 向量余弦排序(一样)
精排 rerank 模型或直接 Top-K LLM 判官逐篇打 0~3 分
兜底 fail-closed,宁可留空不硬塞
结果 分不清"字面像"和"真相关" 只有"真能回答"的才喂给大模型

一句话总结这条链路的设计哲学:

🎯 心法:检索是核心,宁可找不到、也不能找错。

向量负责"广撒网别漏"(recall),判官负责"严把关别错"(precision)。两件事分工,各管一头


三、判官怎么打分:0~3 分的证据价值标准

判官不是让 LLM 随便说"相关/不相关",而是给它一套严格的 0~3 分评分标准——评的是"证据价值",不是"话题重合度"。

这是我项目里判官 prompt 的核心(逐字摘自源码):

分数 含义
3 直接且充分地回答了问题
2 含与问题直接相关的实质信息,可支撑部分回答
1 仅同主题/同领域,但不含可回答的信息(如复述问题、只表达相同诉求、仅有标题)
0 与问题无关,或属于另一主题

这里有两条规则,是整套设计的灵魂:

📌 关键认知 1:评分标准是"是否提供了可回答问题的信息",而不是"词语或话题重合度"。

📌 关键认知 2:不确定是否含有效信息时,取较低分(宁可误杀,不可错放)。

在这里插入图片描述

▲ 图 3 | 判官看的是"答得上答不上",不是"像不像"——一个像看脸,一个像看身份证

为什么"证据价值"比"相似度"准

举个真实例子。用户问:“实时电价波动怎么分析?”

候选段落 向量相似度 判官打分 为什么
《负电价出现的场景和影响》正文 0.585 3 直接回答了电价波动
《电力现货市场未来技术需求.pdf》 0.521 0 只是同领域,根本没回答波动分析
一条聊天记录"现在电价多少?" 0.56 1 只是追问同一问题,不是答案

看第三条——向量相似度 0.56,看着挺高吧?但它只是用户在问同一个问题,根本没有答案。判官一眼就能识破:“这是复述问题,判 1 分”。

📍 小结:向量看的是"长得像不像",判官看的是"答得上答不上"。一个像看脸,一个像看身份证——判断"能不能用",得看身份证


四、判官还不够:再加一道"对抗式复核"

判官很强,但不完美。我实测它在真实流量上的准确率大概是 90~95%

它有个软肋:偶尔会对"话题相邻、沾了点边"的文档心软,给个 2 分放进来。残留的那几条误报,几乎都出在这种"2 分边界档"上。

复核策略:只盯"压线档",换"怀疑者"口吻

我没有让判官对所有结果再判一遍(太贵),而是只挑"刚好压线(2 分)"的边界档,换一个"怀疑者"的口吻再严判一遍:

在这里插入图片描述

▲ 图 4 | 判 3 分的直接信任、判 1/0 的直接丢,只对压线 2 分换怀疑者严判——省调用又提精度

怀疑者的 prompt 长这样(逐字摘自源码):

你是严格的相关性复核者。判断它是否确实为回答该问题提供了有效信息(不是只沾了话题的边)。
抱持怀疑:只有当它真的包含可回答该问题的信息时才判 true;泛泛相关、只提到话题、只是同类事项、拿不准,一律 false

对比一下两个角色的口吻:

角色 口吻 倾向
判官(初判) 客观评估证据价值 不确定取较低分
怀疑者(复核) 抱持怀疑,拿不准就毙 极度严格

为什么不全量复核

误区:很多人一想到"复核",就让所有结果都过一遍。

正确做法:只对压线 2 分复核。判 3 分的(很确定)直接信任省一次调用;判 1/0 的直接丢掉也不用复核。复核是给"模棱两可"的边界档准备的"二审"。

📍 小结:判官解决 90% 的问题,复核专治那 5~10% 的"心软误报"。好钢用在刀刃上,复核只用在边界档


五、最关键的设计:fail-closed(宁可留空,不硬塞)

整套方案里,我个人觉得最值钱的是这条原则:fail-closed。

什么是 fail-closed?就是当精排失败、或者判官判定一篇都不相关时,宁可给用户"没找到",也绝不把召回的内容硬塞给大模型

三个 fail-closed 的落地点

场景 错误做法(放任幻觉) 正确做法(fail-closed)
精排代码报错 把召回的全部原样展示 退化为"过阈值的",都没有就留空
判官判定一篇都不相关 还是喂点东西给大模型让它编 触发"没找到相关内容"兜底卡
判官自身调用失败 给 0 分全杀 给 3 分"不误杀"(外层有兜底)

注意第三条是个微妙的设计:判官内部失败是"fail-open"(给 3 分保留),但外层精排是"fail-closed"(留空)。两层方向相反,是为了应对不同的失败——判官偶发失败时别误杀真答案,整条精排崩溃时别放毒。

在这里插入图片描述

▲ 图 5 | 宁可留空不硬塞——判官内部 fail-open(别错杀),外层精排 fail-closed(别放毒)

为什么 fail-closed 是反幻觉的命门

幻觉的本质是大模型基于错误/无关的上下文"编"出看似合理的答案。你给它的每一段无关内容,都是在递给它"编造的素材"。

所以最朴素的反幻觉逻辑就是:别给它喂不确定的东西。找不到比找错强——用户看到"没找到"会换个问法,看到"一本正经的胡说"会失去信任。

🎯 心法:检索是核心,宁可找不到、也不能找错。


六、真实战绩:误报从 13 条降到 0

光讲设计没用,得看数据。我给项目搭了一套多维审计:用一批各类真实问题跑完整链路,再用独立严格评审逐条判"展示的每条是不是真相关",把它做成回归测试。

一轮优化的战果

指标 优化前 优化后
召回率(recall) 60% 80%
精确率(clean,展示内容全部真相关) 80% 100%
无关文档误报 13 条 0 条

13 条误报是怎么归零的(诚实拆解)

这里我要诚实说:误报从 13 降到 0,不是判官+复核一家之功,是三件套合力的结果:

手段 干掉的误报数 说明
砍掉"读不到正文的相关文件"展示 9 条 13 条里 9 条是 PDF/Office,只凭标题相似度误判
LLM 判官过滤 ~3 条 0~1 分的直接挡掉
对抗式复核 ~1 条 压线 2 分的心软误报

📌 关键认知:最大的提升来自一个反直觉的决策——砍掉一条展示路径(只展示能按正文验证相关性的内容)。有时候反幻觉不是加算法,而是做减法。这个教训我会在后续文章里专门展开。


七、完整链路代码(可直接抄的结构)

这是我项目里精排这一段的核心代码(精简到 30 行,带注释):

// 第一层:向量把候选切块、按语义排序,取出每篇"与问题最相关的那一块"(_anchorText)
const ranked = await rankDocs(cleanQ, llmDocs, {
  embedConfig, topChunks: Math.max(24, readPool * 3), vectorFloor: minScore
});

// 第二层:LLM 判官逐篇打 0~3 分(交给判官的就是每篇的 _anchorText)
const judgeItems = ranked.docs.map(d => ({ text: d._anchorText || d.content }));
const scores = await judgeRelevance(cleanQ, judgeItems, provider);
const scored = ranked.docs.map((d, i) => ({ d, score: scores[i] ?? 3 }));

// 第三层:对抗式复核——只对"刚好压线 2 分"的边界档换怀疑者严判
if (config.adversarial !== false) {
  const borderline = scored.filter(x => x.score === judgeMin);  // 只挑压线档
  if (borderline.length) {
    const verdicts = await verifyRelevance(cleanQ,
      borderline.map(x => ({ text: x.d._anchorText })), provider);
    borderline.forEach((x, i) => {
      if (verdicts[i] === false) { x.score = judgeMin - 1; x.rechecked = true; }
    });
  }
}

// 第四层:只留复核后 ≥2 分的;全低分 → 留空(fail-closed,不硬塞)
const kept = scored.filter(x => x.score >= judgeMin);
llmDocs = kept.map(x => x.d);

// fail-closed 兜底:精排任何环节报错,都绝不"把召回的全部放出来"
// } catch (_e) { llmDocs = ranked.docs?.slice(0,5) ?? []; }

四件事一气呵成:向量粗排 → 判官精排 → 边界档复核 → 阈值过滤


八、几个你可能想问的问题

Q1:判官要调一次 LLM,不会很慢很贵吗?
会,但有解。① 判官只判候选里"每篇的最相关那块"(doc 级判定),不是所有 chunk,数量可控(通常 5~12 篇)。② 打分结果按 model+sha1(问题+段落) 做缓存,重复问题秒出。③ 复核只对压线档,不是全量。

Q2:为什么不直接用 rerank 模型(如 bge-reranker)?
可以,但 rerank 还是基于相似度打分,本质上还是"看脸",遇到"字面像但没答上"的干扰项照样翻车。LLM 判官是基于推理判断"能不能答上",维度更高。实测判官比通用 rerank 准得多。

Q3:阈值到底怎么定?
我的 minScore 实测定在 0.35(代码默认值;文档里写的 0.40 是旧版)。但关键认知是:向量阈值只管粗筛别漏,真正的"相关判定"交给判官。所以向量阈值宁可低一点(0.3~0.4),让候选都进判官,判官再严判。

Q4:这套方案只适合有 LLM API 的项目吧?
对,判官和复核都要调 LLM。但成本可控——按上面 Q1 的优化,一次问答大概多 1~2 次 LLM 调用(判官 1 次 + 复核 0~1 次),相比生成的 token 成本,判官那点开销很值。


九、总结:记住这三条

# 要点 一句话
1 向量只排序,不判定 分不清相关无关,别拿相似度当阈值
2 LLM 判官 + 边界复核 判官评证据价值,复核专治压线档
3 fail-closed 宁可留空 找不到比找错强,别给大模型递毒药

整条链路的核心就一句话:

向量广撒网别漏,判官严把关别错,复核专治模棱两可,失败就留空——宁可找不到,也不能找错。


名词速查表

术语 一句话解释
RAG 检索增强生成,先查文档再回答
判官(Judge) LLM 给候选段落打 0~3 分,评"证据价值"
证据价值 是否提供可回答问题的信息(不是话题重合度)
对抗式复核 对压线 2 分的边界档换"怀疑者"严判一遍
fail-closed 失败时宁可留空,不硬塞不确定的内容
minScore 向量粗筛阈值,只管"别漏",不管"判定"
judgeMin 判官入选阈值(默认 2 分),低于此分丢弃
证据价值重叠区 真相关和假相关在向量分数上的重叠区间
_anchorText 每篇文档里与问题最相关的那一块原文
多维审计 真实问题跑全链路 + 独立评审,做成回归测试

写在最后

RAG 反幻觉这件事,最反直觉的一个认知是:幻觉的根源往往不在大模型,而在你喂给它什么。你喂它一堆"看着像但没用"的段落,它当然会编——它只是在尽力完成"基于上下文回答"的任务。

判官工序的本质,是在"召回"和"生成"之间,加一道人类审核工序:不是看像不像,而是看能不能用。这恰恰是生产系统和 demo 的分水岭——demo 追求"能答出来",生产追求"答得对、答不上来就老实说不知道"。

这套方案我已经在开源项目 feishu-kb-qa 里用生产级代码完整实现了(飞书知识库 QA 系统,带权限隔离、多路召回、流式生成)。判官和复核的完整 prompt、动态阈值、fail-closed 兜底,都能在源码里看到。

🔧 源码获取:项目还在整理中,暂时没传 GitHub。想要源码的读者,可以文末扫码关注公众号,留言"RAG 源码",我会发给你。
📚 系列文章:这是《AI 大模型》专栏的实战篇,上一篇讲了 RAG 全链路原理,这一篇讲反幻觉,后续会拆解项目里的切分、检索、产品化设计。

如果觉得有用,欢迎点赞收藏,做项目时随时回查。下一篇会讲 《别照抄 0.7:我的 RAG 阈值是怎么用真实数据标定出来的》,感兴趣可以关注。

在这里插入图片描述


关注公众号,获取源码和后续连载

👇 想要 feishu-kb-qa 完整源码,或者持续追更这个 RAG 实战系列?欢迎关注公众号,留言 “RAG 源码” 即可获取。

在这里插入图片描述

公众号会第一时间发布系列连载、踩坑记录和源码更新,也欢迎来交流 RAG / 大模型应用开发的问题。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐