向量检索找的是“语义相似”,不是“语义相关”——我被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. 相似度阈值过滤
最简单粗暴的方法:设定一个相似度分数门槛,低于这个分的文档直接扔掉。
# 伪代码示例
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,还会让模型过度关注某些信息,忽略其他重要上下文。我一般用两种方式处理:
- **精确去重**:对文档内容做 MD5 哈希,相同哈希的直接去重
- **模糊去重**:计算文档间的 Jaccard 相似度或 n-gram 重叠率,超过阈值(比如 0.8)只保留得分最高的那个
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。
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,让它判断文档是否相关,甚至让它打分。
你是一个文档相关性评估专家。请判断以下文档是否能够回答用户的问题。
只回答"相关"或"不相关",不要解释。
用户问题:{query}
文档内容:{document}
相关性判断:这种做法精度确实高,但成本和延迟也高。我一般只在最终候选文档数量很少(比如 3-5 个)的时候用,或者用在那些对准确性要求极高的场景,比如医疗、法律问答。
一个折中方案是用小模型做粗排,LLM 做精排。比如 Cross-Encoder 从 top-20 筛到 top-5,LLM 再从 top-5 筛到 top-3。这样既控制了成本,又保证了精度。据我了解,Cohere 在 2024 年底推出的 Compass 框架就是类似的思路,不过他们用的是自己的模型。
真实案例:一个金融问答系统的优化过程
分享一个我去年做的项目,某券商的内部研报问答系统。
初始状态:
- 检索:text-embedding-ada-002 + FAISS
- 直接取 top-5 喂给 GPT-4
- 准确率:71%
第一轮优化(2024年4月):
- 加入相似度阈值过滤(阈值 0.78)
- 加入重复文档去重
- 准确率:76%(提升 5 个点)
第二轮优化(2024年5月):
- 引入 bge-reranker-large 重排序
- 检索 top-20,重排序后取 top-5
- 准确率:87%(提升 11 个点)
第三轮优化(2024年6月):
- 针对研报特点调整文档切分策略(按段落+表格切分)
- 加入元数据过滤(只检索近一年的研报)
- 准确率:92%(提升 5 个点)
整个优化过程花了三周,准确率从 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 #向量检索 #大模型应用
读者评论 5