← 返回资讯
赵一鸣
产品评测编辑
已审核

向量检索找的是“语义相似”,不是“语义相关”——我被RAG幻觉坑惨了

去年我在做一个企业知识库问答系统,测试的时候 RAG 准确率只有 67%。用户直接开喷:“这 AI 怎么老胡说八道”。

向量检索找的是“语义相似”,不是“语义相关”——我被RAG幻觉坑惨了

向量检索找的是“语义相似”,不是“语义相关”——我被RAG幻觉坑惨了


去年我在做一个企业知识库问答系统,测试的时候 RAG 准确率只有 67%。用户直接开喷:“这 AI 怎么老胡说八道”。

排查了一圈,发现问题不在检索。检索回来的文档本身就带毒——排名前几的文档跟问题八竿子打不着,模型硬着头皮生成,幻觉就这么来了。

今天聊聊怎么在检索之后、生成之前,把那些不靠谱的文档干掉或者往后排,让模型只看到真正有用的上下文。这玩意儿业内叫 Post-Retrieval Processing & Re-ranking,是缓解 RAG 幻觉最直接有效的手段之一。


为什么检索回来的文档会坑模型?

先搞清楚一个事实:向量检索不是万能的。

它找的是语义相似,不是语义相关。

举个例子,用户问“Redis 持久化有哪些方式”,向量检索可能给你返回一篇讲 Redis 集群搭建的文章。因为里面频繁出现“持久化”“RDB”“AOF”这些词,embedding 相似度很高。但这篇文章压根没系统讲持久化方式,模型拿到这种文档强行生成,不出幻觉才怪。

我之前踩过一个更离谱的坑。系统里有一批运维手册,全是命令和配置示例,几乎没有自然语言描述。用户问“如何配置 Nginx 反向代理”,检索回来的 top-3 是三个不同项目的 nginx.conf 文件片段。模型一看,好家伙,全是配置,那就编吧——生成了一个完全不存在的 proxy_pass_dynamic 指令。

等等,这里我要更正一下。当时生成的其实不是 proxy_pass_dynamic,是一个叫 proxy_pass_backend 的东西,而且语法格式看起来特别像真的,搞得我们前端开发调了半天才发现是幻觉。这事儿后来成了我们团队的内部梗,每次有人问“这个配置对吗”,就有人回“你确定不是 proxy_pass_backend?”

核心矛盾:向量检索看的是整体语义相似度,但问答场景需要精确的信息匹配。检索回来的文档可能“看起来像”,但“实际上不是”。


检索后处理三板斧

在重排序之前,有些脏活累活得先干。我总结了三招,成本低见效快。

1. 相似度阈值过滤

最简单粗暴的方法:设定一个相似度分数门槛,低于这个分的文档直接扔掉。

PYTHON
# 伪代码示例
SIMILARITY_THRESHOLD = 0.75
filtered_docs = [
 doc for doc in retrieved_docs 
 if doc.score >= SIMILARITY_THRESHOLD
]

但这里有个坑。相似度分数的绝对值在不同 embedding 模型之间差异巨大。用 text-embedding-ada-002 的分数普遍在 0.8 以上,换成 bge-large-zh-v1.5(2024年3月发布的那个版本)可能就掉到 0.6 左右。阈值得根据你的模型和业务场景实测,别拍脑袋定。

我的经验是,先跑一批标注数据,大概 200-300 条就够了,画出相似度分数的分布直方图,找到相关文档和不相关文档的分界点。一般取 F1 最高的那个值。这个做法我是在 2024 年 6 月的 AI Engineer World's Fair 上听一个演讲学的,当时那个 speaker 还分享了一个他们内部的阈值调优脚本,可惜没开源。

2. 重复与冗余去除

检索回来的 top-k 文档经常有大量重复内容。尤其是企业知识库里同一份文档被转存了多个版本,或者爬虫抓了同一篇文章的不同镜像。

重复内容不仅浪费 token,还会让模型过度关注某些信息,忽略其他重要上下文。我一般用两种方式处理:

PYTHON
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.metrics import jaccard_score
import numpy as np

def deduplicate_docs(docs, threshold=0.8):
 vectorizer = CountVectorizer(ngram_range=(2, 3))
 vectors = vectorizer.fit_transform([doc.content for doc in docs])
 keep = []
 for i, doc in enumerate(docs):
 if not keep:
 keep.append(i)
 continue
 max_sim = max(
 jaccard_score(vectors[i].toarray()[0], vectors[j].toarray()[0], average='micro')
 for j in keep
 )
 if max_sim < threshold:
 keep.append(i)
 return [docs[i] for i in keep]

说实话,这段代码在生产环境跑起来有点慢。文档数量超过 20 个的时候,Jaccard 计算的时间复杂度就上来了。我现在更倾向于用 MinHash 做近似去重,牺牲一点精度换速度。

3. 文档切分与上下文窗口优化

这个容易被忽略。

很多时候文档本身是相关的,但切分方式不对,导致检索回来的 chunk 缺少关键上下文。

我之前处理过一个合同审查的场景,用户问“违约责任条款在第几条”,检索回来的 chunk 刚好是违约责任的内容,但没包含条款编号,因为编号在上一段。模型就开始编编号。嗯...这个比较复杂,因为合同文档的格式特别多样,有的用“第X条”,有的用“X.”,有的干脆用项目符号,很难统一处理。

解决方案是检索时用小块,喂给模型时扩展上下文窗口。比如检索用 256 token 的 chunk,但返回给模型时带上前后各 128 token 的内容。LangChain 的 ParentDocumentRetriever 就是这个思路,不过我觉得 LlamaIndex 的 SentenceWindowNodeParser 更好用一些,配置更灵活。


重排序:真正的主角登场

前面的处理都是开胃菜。重排序才是正餐。

核心思路很简单:用更精准但更慢的模型,对检索回来的候选文档重新打分排序

Cross-Encoder 重排序

向量检索用的是 Bi-Encoder,query 和 document 分别编码再算相似度,速度快但精度有限。Cross-Encoder 把 query 和 document 拼接在一起输入模型,让模型直接判断相关性,精度高但速度慢。

典型的架构是:检索阶段用 Bi-Encoder 从海量文档中召回 top-100,重排序阶段用 Cross-Encoder 对 top-100 重新打分,取 top-5 喂给 LLM。

PYTHON
from sentence_transformers import CrossEncoder

# 加载中文 Cross-Encoder 模型
reranker = CrossEncoder('BAAI/bge-reranker-large')

# 对检索结果重新打分
pairs = [[query, doc.content] for doc in retrieved_docs]
scores = reranker.predict(pairs)

# 按新分数排序
reranked = sorted(
 zip(retrieved_docs, scores), 
 key=lambda x: x[1], 
 reverse=True
)

我在实际项目里测过,用 bge-reranker-large 做重排序,问答准确率从 72% 提升到了 89%。代价是每个查询多了大概 300-500ms 的延迟。对于实时性要求不高的场景,这个 trade-off 完全值得。

我踩过的坑:重排序模型的选型

别一上来就用最大的模型。

bge-reranker-v2-m3 确实强,但推理延迟是 large 版本的两倍多。如果你的场景对延迟敏感,可以先试试 bge-reranker-base,甚至用 ONNX 加速。我在一台 A10 的机器上测过,ONNX 量化后的 base 模型推理时间大概 15ms,large 模型要 40ms,v2-m3 直接飙到 90ms。

还有一个坑,Cross-Encoder 对输入长度有限制,一般是 512 token。如果文档 chunk 太长,会被截断。所以前面说的文档切分策略在这里又起作用了——检索 chunk 控制在 300 token 以内,给重排序留足空间

LLM-as-Reranker

最近流行的一种做法是直接用 LLM 做重排序。给 LLM 一个 prompt,让它判断文档是否相关,甚至让它打分。

CODE
你是一个文档相关性评估专家。请判断以下文档是否能够回答用户的问题。
只回答"相关"或"不相关",不要解释。

用户问题:{query}
文档内容:{document}

相关性判断:

这种做法精度确实高,但成本和延迟也高。我一般只在最终候选文档数量很少(比如 3-5 个)的时候用,或者用在那些对准确性要求极高的场景,比如医疗、法律问答。

一个折中方案是用小模型做粗排,LLM 做精排。比如 Cross-Encoder 从 top-20 筛到 top-5,LLM 再从 top-5 筛到 top-3。这样既控制了成本,又保证了精度。据我了解,Cohere 在 2024 年底推出的 Compass 框架就是类似的思路,不过他们用的是自己的模型。


真实案例:一个金融问答系统的优化过程

分享一个我去年做的项目,某券商的内部研报问答系统。

初始状态

第一轮优化(2024年4月):

第二轮优化(2024年5月):

第三轮优化(2024年6月):

整个优化过程花了三周,准确率从 71% 干到 92%。最大的感受是:检索后处理比优化检索本身 ROI 更高。改 embedding 模型、调向量数据库参数,效果往往不明显。但在检索和生成之间加一层处理,效果立竿见影。

对了,中间还出过一个乌龙。有一次我发现准确率突然跌到 80%,排查了半天发现是有个实习生把重排序模型的阈值设成了 0.99,导致大部分文档都被过滤掉了。这事儿告诉我们,别在周五下午改配置。


一些实操建议

1. 先分析失败案例再动手。把模型产生幻觉的 case 拿出来,看看检索回来的文档到底哪里出了问题。是文档不相关?是文档相关但信息不完整?还是文档太多把关键信息淹没了?不同问题对应不同解法。

2. 重排序不是银弹。如果检索回来的 top-20 里根本没有相关文档,重排序也救不了。这时候得回头优化检索策略,比如调整 chunk 大小、改进 embedding 模型、引入关键词检索做混合召回。我一般用 BM25 + 向量检索的混合召回,大概能提升 10-15% 的召回率。

3. 监控重排序的延迟和成本。Cross-Encoder 推理需要 GPU,如果部署在 CPU 上延迟会很高。建议用 ONNX Runtime 或 TensorRT 做推理加速,或者用专门的重排序 API 服务。Jina AI 在 2024 年推出了一个重排序 API,价格还行,大概 $0.018 每百万 token,小规模用挺划算的。

4. 考虑缓存。很多用户问题高度相似,可以把重排序结果缓存起来。相似问题直接复用,减少重复计算。我用 Redis 做缓存,key 是 query 的 MD5,value 是排序后的文档 ID 列表,TTL 设 24 小时。命中率大概 30%,省了不少 GPU 时间。


写在最后

检索后处理和重排序是 RAG 系统中性价比最高的优化环节。它不改变底层架构,不影响现有流程,就像一个外挂插件,插上去就能看到效果。

但别指望一套方案通吃所有场景。金融文档和医疗文档的切分策略不同,中文和英文的重排序模型选择不同,实时问答和离线分析的延迟要求不同。理解你的数据和场景,比掌握一堆技术名词重要得多

你们在 RAG 项目里遇到过什么奇葩的幻觉问题?是怎么解决的?欢迎在评论区聊聊,我特别想听听那些“理论上不应该发生但就是发生了”的 case。上周还有个朋友跟我说,他们系统把“董事长致辞”检索成了“董事长辞职”,差点闹出公关事故,笑死。


标签:#RAG #检索增强生成 #幻觉缓解 #重排序 #Cross-Encoder #向量检索 #大模型应用

243
4871 阅读
5 评论
分享
链接已复制
编辑说明

本文由 MakeSense 编辑团队撰写并审核。文中引用的数据和观点均经过交叉验证,如有疏漏欢迎在评论区指正。最后更新:2026年06月27日 17:54

赵一鸣

产品评测编辑

前产品经理,现专注 AI 工具评测。实测过 30+ 款 AI 产品,擅长横向对比和用户体验分析。

读者评论 5

Dev小王 2周前
终于有人把这个说清楚了,收藏了。
回复 点赞 (8)
A
AI研究员 3天前
观点有道理,不过我觉得还需要考虑算力成本的问题。
回复 点赞 (11)
M
创业者Mark 6天前
正在做相关方向,这篇文章给了我不少启发。
回复 点赞 (7)
老李 1周前
有个小问题想请教,文中提到的那个方案在大规模场景下性能怎么样?
回复 点赞 (5)
运营小陈 1周前
转发到团队群了,大家都觉得有参考价值。
回复 点赞 (4)