1. 从一个被问烂了的问题说起
如果你最近半年在搞大模型应用,大概率绕不开 RAG 这个词。招聘 JD 上写着“熟悉 RAG 检索增强生成”,技术群里天天有人讨论 chunk size 设多少、embedding 模型选哪个、向量库用哪家。但我发现一个很有意思的现象:真正把 RAG 做到线上稳定跑的大厂团队,几乎没有人用纯向量检索。
这话不是拍脑袋。我前后参与过三个不同规模的 RAG 项目,从内部知识库问答到面向用户的智能客服,踩过的坑足够写一本小册子。最开始我也是“向量检索一把梭”的信徒——文本切块、embedding、存向量库、余弦相似度召回,流程干净利落,Demo 效果惊艳。但一上真实流量,问题就来了:用户问“上个月的报销标准改了吗”,纯向量检索给我召回一堆“报销流程说明”“差旅费管理规定”,就是找不到那条“2024年3月修订”的具体条款。用户问一个精确的产品型号,向量检索返回的是语义相近但型号完全不同的文档。
这就是纯向量检索的命门:它擅长“意思差不多”,但搞不定“必须是这个”。
所以这篇内容我想把这件事彻底讲透。为什么大厂 RAG 从不用纯向量检索,不是因为向量检索不好,而是因为单一检索范式有结构性缺陷。我会从检索原理、混合检索架构、重排序、查询改写、工程落地几个层面拆开讲,每个环节都配上我实际用过的参数和踩过的坑。适合正在做 RAG 落地的工程师、正在选型的技术负责人,以及那些 Demo 跑得挺好但一上线就翻车的同行。看完你至少能明白:你的 RAG 该在哪个环节加什么,而不是盲目堆向量库。
2. 纯向量检索到底强在哪、又死在哪
2.1 向量检索的本质:语义空间的近似匹配
先把原理说清楚。向量检索的核心是把文本通过 embedding 模型映射到一个高维空间,语义相近的文本在这个空间里距离更近。你查“如何申请年假”,它能把“年休假申请流程”召回来,哪怕这两个字符串一个字都不重叠。这是传统关键词检索做不到的,也是向量检索最大的价值。
我用过一个内部技术文档库,大概两万多个 chunk,用某开源 embedding 模型做向量化,top-5 召回在语义类问题上的准确率能到 80% 左右。这个数字在 Demo 阶段非常好看。但注意,这是“语义类问题”,一旦问题类型变了,表现就断崖式下跌。
向量检索的相似度计算,无论用余弦相似度还是内积,本质都是在算两个向量在高维空间里的“方向接近程度”。这个机制决定了它对语义漂移很敏感。什么叫语义漂移?就是两段文本在字面上高度相关,但在向量空间里被拉远了;或者反过来,字面无关但语义相近的被拉近了。embedding 模型不是万能的,它的训练目标决定了它更关注“主题相似”而非“事实精确”。
2.2 三个让纯向量检索翻车的真实场景
我整理了三类最典型的翻车场景,都是实际遇到过的。
第一类:精确匹配需求。用户问“X2000 型号的额定功率是多少”。向量检索会把“X2000 产品手册”“X2000 系列介绍”“功率参数说明”都召回来,但很可能把“X3000 的功率”也带进来,因为这两个型号在语义空间里太近了。用户要的是一个精确的数字,你给他一堆相关文档,他得自己翻。这种场景下,关键词检索(比如 BM25)反而能精准命中“X2000”这个 token。
第二类:否定和条件查询。用户问“哪些情况不能申请退款”。向量检索对“不能”这种否定词的处理很弱,因为 embedding 模型在训练时,否定句和肯定句的向量距离往往很近。“可以申请退款”和“不能申请退款”在语义空间里可能就隔了一点点。结果就是召回一堆“退款条件”的文档,但分不清哪些是允许哪些是禁止。
第三类:时效性和版本敏感。用户问“最新的差旅标准”。向量检索没有时间概念,它不知道哪个 chunk 是“最新”的。如果知识库里有 2022、2023、2024 三个版本的标准,它可能把三个都召回来,而且排序还不一定对。这时候你需要的是元数据过滤加关键词匹配,而不是纯语义。
我把这三类问题整理成了一张对照表,方便你判断自己的场景:
| 问题类型 | 纯向量检索表现 | 根因 | 更适合的检索方式 |
|---|---|---|---|
| 语义相近但事实不同 | 容易混淆 | 向量空间主题聚类 | 关键词精确匹配 |
| 否定/条件查询 | 经常失效 | 否定词向量距离近 | 关键词+规则 |
| 时效/版本敏感 | 无法区分 | 无时间元数据感知 | 元数据过滤+关键词 |
| 专有名词/型号 | 召回噪声大 | token 级信息丢失 | BM25/倒排索引 |
| 长尾低频词 | 召回率低 | 训练语料覆盖不足 | 关键词兜底 |
这张表是我踩了无数次坑之后总结的。你会发现,纯向量检索的短板集中在“精确性”和“可控性”上,而这恰恰是企业级应用最看重的。
2.3 一个容易被忽略的数学细节
再说一个很多人没注意到的点:向量相似度的分数分布。余弦相似度的值域是 [-1, 1],但实际文本 embedding 的相似度往往集中在 0.6 到 0.95 之间。这意味着什么?意味着 top-1 和 top-5 的分数差距可能只有 0.02。你设一个阈值 0.8 来过滤,结果要么全过要么全不过,阈值根本卡不住。
我实测过一个场景,同一个 query 下,正确文档的相似度是 0.847,错误文档是 0.831。差了 0.016。你靠这个分数做排序,基本等于抛硬币。这就是为什么大厂要在向量召回之后加一个重排序(rerank)阶段,用交叉编码器(cross-encoder)重新算相关性。向量检索负责“广撒网”,rerank 负责“精挑拣”,这个组合才是标配。
3. 大厂真正在用的混合检索架构
3.1 混合检索的核心思想:让不同的检索器各司其职
混合检索(Hybrid Retrieval)这个词听起来高大上,说白了就是:别指望一个检索器解决所有问题,让关键词检索和向量检索各干各擅长的活,然后把结果融合。
关键词检索(通常是 BM25 或其变体)擅长什么?精确 token 匹配、低频词、专有名词、否定词。它的打分基于词频和逆文档频率,一个词在文档里出现得越多、在整个语料里出现得越少,得分越高。这个机制天然适合“X2000”“2024年3月”“不能”这类查询。
向量检索擅长什么?语义泛化、同义替换、跨语言、模糊意图。用户说“我想请个假”,它能召回“休假申请流程”,哪怕字面不重叠。
把两者结合,召回率能提升一大截。我做过对比测试,同一个知识库,纯向量 top-10 召回率 72%,纯 BM25 top-10 召回率 65%,混合检索 top-10 召回率能到 89%。这个提升在真实业务里就是“能用”和“不能用”的区别。
3.2 融合策略:RRF 为什么成了事实标准
混合检索的关键问题是:两路召回的结果怎么合并?最简单的做法是加权求和,但权重很难调,而且两路分数的量纲不一样,BM25 分数可能是 12.3,余弦相似度是 0.85,直接加权没有意义。
目前业界最常用的融合算法是RRF(Reciprocal Rank Fusion,倒数排名融合)。它的公式很简单:
RRF_score(d) = Σ 1 / (k + rank_i(d))其中rank_i(d)是文档 d 在第 i 路召回中的排名,k是一个平滑常数,通常取 60。这个公式的妙处在于:它只看排名,不看原始分数,天然解决了量纲不一致的问题。而且排名靠前的文档会被显著加权,排名靠后的影响很小。
我实际用下来,k 取 60 是个比较稳的默认值。k 太小(比如 10),头部文档权重过高,容易受单路召回噪声影响;k 太大(比如 100),排名差异被抹平,融合效果变差。你可以从 60 开始调,根据业务反馈微调。
用 Python 实现 RRF 大概长这样:
def rrf_fusion(vector_results, bm25_results, k=60): scores = {} for rank, doc_id in enumerate(vector_results): scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank + 1) for rank, doc_id in enumerate(bm25_results): scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank + 1) return sorted(scores.items(), key=lambda x: x[1], reverse=True)这段代码我用了很多次,简单可靠。注意rank + 1是因为排名从 0 开始,而 RRF 公式里 rank 通常从 1 开始。
3.3 检索器选型:BM25 不是唯一选择
关键词检索这一路,BM25 是最经典的,但也不是唯一。我见过几种不同的做法:
- BM25(Elasticsearch/Lucene 内置):最成熟,工程上最稳,支持中文分词(需要配 IK 分词器或类似方案)。适合大多数场景。
- Splade 等学习型稀疏检索:用模型生成稀疏向量,兼顾关键词精确性和语义泛化。效果比 BM25 好,但工程复杂度高,推理成本也高。
- n-gram 匹配:对短查询和专有名词特别有效,实现简单,可以作为 BM25 的补充。
我的建议是:如果没有特殊需求,BM25 起步就够了。别一上来就上学习型稀疏检索,那个调优成本很高,而且收益在多数场景下没有想象中大。先把混合检索的框架搭起来,跑通再优化。
3.4 一个完整的混合检索流程
我把实际用的流程画成文字版,方便你对照实现:
- 用户 query 进来,先做查询改写(后面会细讲),生成 1-3 个变体。
- 每个变体分别走向量检索和BM25 检索,各取 top-20。
- 用RRF 融合两路结果,得到 top-20 的候选集。
- 用rerank 模型对候选集重新打分,取 top-5。
- 把 top-5 的 chunk 拼进 prompt,送给大模型生成答案。
这个流程里,向量检索和 BM25 是并行的,RRF 是轻量级的,rerank 是重头戏。整个链路延迟大概在 200-500ms(取决于 rerank 模型大小和候选集数量),线上完全可接受。
4. 重排序:被低估的 RAG 质量杠杆
4.1 为什么召回之后必须 rerank
前面说了,向量相似度的分数区分度很低,top-1 和 top-5 可能只差 0.02。这意味着召回阶段给你的排序基本不可靠。rerank 的作用就是用更精细的模型重新算相关性。
召回阶段用的模型(双编码器,bi-encoder)是把 query 和 document 分别编码成向量,然后算相似度。这个架构快,但精度有限,因为 query 和 document 在编码时没有交互。rerank 阶段用的模型(交叉编码器,cross-encoder)是把 query 和 document 拼在一起送进模型,让它们充分交互,精度高很多,但速度慢,所以只能用在少量候选上。
这个“先粗后精”的思路,和推荐系统里的“召回-排序”两阶段是一模一样的。大厂做推荐做了十几年,这套架构早就验证过了。RAG 本质上也是一个检索排序问题,自然沿用同样的思路。
4.2 rerank 模型怎么选
目前主流的 rerank 方案有几类:
- 开源交叉编码器:比如 BGE-reranker 系列、Cohere rerank 的开源替代。效果不错,可以本地部署,成本可控。
- 商业 rerank API:按调用量计费,省事但长期成本高,而且数据要出域,很多企业不接受。
- LLM 做 rerank:直接用大模型给候选文档打分。效果最好,但延迟和成本都高,适合对质量要求极高的场景。
我实测下来,BGE-reranker-base 在中文场景下的效果已经相当能打,推理延迟在 50ms 左右(batch size 20,GPU 推理)。如果你的场景对延迟不敏感,可以上 large 版本,效果更好。
这里有个经验:rerank 的候选集不要太大,20-30 个就够了。候选集太大,延迟线性增长,而且收益递减。我试过把候选集从 20 加到 50,最终 top-5 的准确率只提升了 2%,但延迟翻了一倍多。不划算。
4.3 rerank 的阈值策略
rerank 之后,你需要决定哪些文档真正送进 prompt。这里有个坑:不要把所有 top-k 都塞进去。如果 rerank 分数普遍很低,说明这批文档都不相关,硬塞进去只会让大模型产生幻觉。
我的做法是设一个动态阈值:取 rerank 最高分,然后保留分数在最高分 85% 以上的文档,最多 5 个。这样既能保证相关性,又能避免噪声。如果最高分本身就很低(比如低于某个绝对阈值),那就直接返回“没有找到相关信息”,而不是硬答。
这个策略在客服场景特别重要。用户问一个知识库里根本没有的问题,纯向量检索会召回一堆“看起来相关”的文档,大模型基于这些文档编一个答案,用户信以为真。加了 rerank 和阈值之后,这种情况能减少 70% 以上。
5. 查询改写:让检索器听懂人话
5.1 用户 query 和文档语言之间的鸿沟
用户提问的方式和文档写作的方式往往不一样。用户问“这个月工资怎么还没到”,文档写的是“薪资发放时间及异常处理流程”。向量检索能处理一部分这种差异,但不够。查询改写(Query Rewriting)就是在这中间搭桥。
查询改写有几个层次:
- 同义扩展:把“工资”扩展成“薪资、薪酬、薪水”。
- 意图澄清:把模糊 query 改写成明确的检索 query。
- 多查询生成:生成多个不同角度的 query,分别检索后融合。
- 对话历史融合:多轮对话里,把历史上下文融进当前 query。
5.2 多查询生成的实际效果
我重点说说多查询生成(Multi-Query),这是我觉得性价比最高的改写策略。做法很简单:用一个小模型(或者大模型的一次调用)把用户 query 改写成 3 个不同表述的 query,分别检索,然后 RRF 融合。
比如用户问“报销要多久到账”,生成的三个 query 可能是:
- “报销到账时间”
- “费用报销处理周期”
- “报销款项发放时效”
这三个 query 分别检索,能覆盖到不同表述的文档。实测下来,多查询生成能把召回率再提升 10-15 个百分点。代价是检索次数变成 3 倍,延迟增加,但如果你用并行检索,延迟增加其实很有限。
代码上大概是这样:
def generate_queries(original_query, llm_client): prompt = f"""请将以下问题改写成3个不同表述的检索查询,每行一个: 问题:{original_query} 查询:""" response = llm_client.generate(prompt) queries = [q.strip() for q in response.split("\n") if q.strip()] return [original_query] + queries[:3]注意,改写用的模型不需要太大,7B 级别的模型就够用。我用过一个 3B 的模型做改写,效果和 70B 的差别不大,但延迟低了一个数量级。
5.3 查询改写的注意事项
有几个坑要避开:
- 别改得偏离原意。改写模型有时候会“过度发挥”,把“退款”改成“退货”,这就跑偏了。建议在 prompt 里强调“保持原意”。
- 别生成太多 query。3-5 个就够了,再多收益递减,延迟线性增长。
- 保留原始 query。改写后的 query 是补充,不是替代。原始 query 一定要参与检索,否则可能丢掉最直接的匹配。
6. 工程落地:从 Demo 到线上的那些坑
6.1 分块策略比 embedding 模型更重要
很多人花大量时间选 embedding 模型,却忽略了分块(chunking)策略。我的经验是:分块策略对最终效果的影响,比 embedding 模型选型大得多。
固定长度分块(比如 512 token)是最简单的,但会切断语义单元。一个完整的条款被切成两半,检索到一半也没用。更好的做法是按语义边界分块:按段落、按标题、按列表项。如果文档有结构(Markdown、HTML),优先按结构分。
我实际用的策略是:先按标题层级切分,如果某个 section 超过 800 token,再按段落切;如果段落还超,才按固定长度切。每个 chunk 前面加上所属标题的路径,比如“差旅管理 > 报销标准 > 国内差旅”。这个路径信息在检索时很有用,能提供额外的上下文。
另外,chunk 之间要有重叠。我一般设 10-15% 的重叠,防止关键信息正好落在边界上被切断。
6.2 元数据过滤:被低估的精确性武器
前面提到时效性和版本敏感的问题,解法就是元数据过滤。每个 chunk 存的时候,带上时间、版本、部门、文档类型等元数据。检索时先按元数据过滤,再做向量和关键词检索。
比如用户问“最新的差旅标准”,你可以先过滤出version = latest的 chunk,再在里面检索。这样就不会召回旧版本了。
元数据过滤在 Elasticsearch 里就是 filter 查询,在向量库里通常也支持。关键是在检索前过滤,而不是检索后过滤。检索后过滤会导致召回数量不足,因为过滤掉的文档已经占用了 top-k 名额。
6.3 缓存和降级:线上稳定的保障
线上系统必须考虑缓存和降级。RAG 的链路很长,任何一环出问题都会导致整个请求失败。
- 查询缓存:相同的 query 直接返回缓存结果,命中率在客服场景能到 30% 以上。
- 检索结果缓存:query 改写后的检索结果可以缓存,因为改写是确定性的。
- 降级策略:如果 rerank 服务挂了,直接跳过 rerank,用 RRF 融合结果;如果向量库挂了,降级到纯 BM25。保证系统始终能返回结果,哪怕质量下降。
我踩过最大的坑就是没做降级。有一次向量库集群扩容,短暂不可用,整个问答系统直接 500。后来加了降级逻辑,向量库挂了自动切 BM25,用户基本无感知。
6.4 评估:没有评估就没有优化
最后说评估。RAG 系统最怕的就是“感觉还行”,没有量化指标就没法优化。
我建议至少跟踪这几个指标:
| 指标 | 含义 | 目标值 |
|---|---|---|
| 召回率@k | top-k 里包含正确文档的比例 | >85% |
| 精确率@k | top-k 里相关文档的比例 | >60% |
| MRR | 正确文档的平均倒数排名 | >0.7 |
| 答案准确率 | 人工评估答案正确的比例 | >80% |
| 拒答率 | 正确拒答(无相关信息时)的比例 | >90% |
评估集要覆盖各种问题类型:语义类、精确类、否定类、时效类。每类至少 50 条。每次改动检索策略,都跑一遍评估集,看指标变化。别凭感觉调参。
7. 几个常见问题的排查思路
7.1 召回不准怎么办
先定位是召回问题还是排序问题。把 top-20 的召回结果打出来看,如果正确文档根本不在里面,是召回问题;如果在里面但排名靠后,是排序问题。
召回问题:检查分块策略、embedding 模型、查询改写。排序问题:加 rerank,调 RRF 的 k 值。
7.2 大模型幻觉严重怎么办
幻觉通常是因为 prompt 里塞了不相关的文档。检查 rerank 阈值,把低分文档过滤掉。另外在 prompt 里明确要求“只基于提供的文档回答,不知道就说不知道”。
7.3 延迟太高怎么办
先测各阶段耗时。通常是 rerank 和 LLM 生成占大头。rerank 可以换小模型、减候选集;LLM 生成可以换小模型、流式输出。检索阶段一般不是瓶颈。
7.4 多轮对话效果差怎么办
多轮对话的关键是 query 改写,把历史上下文融进当前 query。比如用户先问“差旅标准”,再问“那住宿呢”,第二个 query 要改写成“差旅住宿标准”。这个用 LLM 做很简单,但一定要做。
8. 我个人在实际操作中的体会
说了这么多,最后分享几点个人体会。
第一,别迷信单一技术。向量检索、BM25、rerank、查询改写,每个都有它的位置。大厂的 RAG 不是用了什么黑科技,而是把每个环节都做扎实了,组合起来效果就好。
第二,工程细节决定成败。分块策略、元数据设计、缓存降级,这些看起来不“AI”的东西,恰恰是线上稳定的关键。我见过太多团队模型选得很 fancy,但分块一塌糊涂,效果还不如老老实实做 BM25。
第三,评估要趁早。别等系统上线了才想起来做评估。从第一天就建评估集,每次改动都跑一遍。这样你才知道自己的优化到底有没有用。
第四,从简单开始。如果你刚开始做 RAG,别一上来就搞多查询生成加 rerank 加混合检索。先用 BM25 加向量做混合检索,跑通再说。效果不够再加 rerank,还不够再加查询改写。每一步都验证收益,别盲目堆组件。
这个领域变化很快,新的 embedding 模型、新的 rerank 方案、新的检索架构层出不穷。但底层的检索原理和工程思路是相对稳定的。把原理搞透,把工程做扎实,比追新更重要。