1. 纯向量检索到底哪里不够用
1.1 从一个真实翻车案例说起
去年我帮一个团队调他们的知识库问答系统,场景是内部技术文档检索。他们用的是当时最流行的方案:文档切块、embedding 模型编码、灌进向量库、查询时做余弦相似度召回 Top-K。Demo 阶段效果惊艳,随便问个问题都能找到语义相近的段落。但上线两周后,反馈开始炸了。
最典型的一个 case:有人搜“接口超时重试配置”,系统返回了一堆讲“网络抖动”“服务降级”“熔断策略”的段落,语义上确实相关,但用户要的是那个具体的配置项名称和默认值。向量检索把“意思差不多”的内容排在了前面,而真正包含精确关键词“retry.maxAttempts=3”的那一段,因为表述方式不同,被排到了十名开外。
这不是个例。纯向量检索的本质是把文本映射到高维空间,用距离衡量语义相似度。它擅长的是“意思相近”,但它天然不擅长“字面精确匹配”。而实际业务里,大量查询恰恰是精确匹配需求:产品型号、错误码、函数名、配置项、人名、订单号。这些东西一旦被 embedding 模型“泛化”掉,召回结果就会跑偏。
1.2 向量检索的三个结构性缺陷
我把纯向量检索的问题归纳成三个层面,理解了这三个层面,你就能明白为什么大厂不会只用它。
第一,语义漂移导致精确信息丢失。embedding 模型在训练时学到的是“整体语义”,它会把“苹果手机”和“iPhone”映射到相近位置,这很好。但它同样会把“错误码 500”和“服务器内部错误”映射得很近,而用户可能就是要找那个数字 500。当查询里包含关键实体时,向量模型没有机制保证这个实体在召回结果中被优先保留。
第二,Top-K 的刚性截断。向量检索通常返回固定数量的结果,比如 Top-10。如果真正相关的文档排在第十一位,它就永远没机会进入后续的 rerank 或生成环节。更麻烦的是,这个 K 值很难调:调大了引入噪声,调小了漏召回。而实际查询的相关文档数量是动态变化的,有的查询只有一个精确答案,有的查询需要综合五六段内容。
第三,无法处理否定和逻辑约束。你搜“不包含缓存的方案”,向量检索会给你返回一堆讲缓存的段落,因为“缓存”这个词的语义权重太高了,模型很难理解“不包含”这个逻辑操作。类似地,“A 和 B 的区别”“除了 X 以外的 Y”这类查询,纯向量方案基本束手无策。
注意:这不是说向量检索没用。它在语义泛化、同义改写、跨语言检索上依然是不可替代的。问题在于,把它当成唯一召回通道,就像只用一把螺丝刀修所有东西。
1.3 大厂的真实查询分布长什么样
我后来做过一个统计,把某类知识库系统的查询日志拉出来,人工标注了查询意图。结果大致是这样的分布:
| 查询类型 | 占比 | 典型示例 | 纯向量表现 |
|---|---|---|---|
| 精确实体查询 | 约 35% | 错误码、配置项、函数名 | 差 |
| 语义理解查询 | 约 30% | “怎么优化启动速度” | 好 |
| 混合查询 | 约 25% | “Redis 连接超时怎么排查” | 中等 |
| 逻辑约束查询 | 约 10% | “不依赖第三方库的方案” | 差 |
这张表说明一个残酷事实:超过一半的查询,纯向量检索都不是最优解。大厂面对的是海量真实用户,他们不可能容忍 35% 的精确查询被语义漂移搞砸。所以他们的选择不是“向量 or 关键词”,而是“向量 and 关键词 and 更多”。
2. 混合检索:大厂 RAG 的标配架构
2.1 双路召回的基本设计
大厂 RAG 系统里,检索层几乎都是多路并行的。最常见的是两路:一路向量召回,一路关键词召回(通常是 BM25 或其变种)。两路各自返回 Top-N,然后合并、去重、重排序。
为什么是 BM25?因为 BM25 基于词频和逆文档频率,对精确词项匹配极其敏感。你搜“retry.maxAttempts”,BM25 会精确命中包含这个词的文档,不管它的语义 embedding 长什么样。它和向量检索形成完美互补:一个管“字面”,一个管“意思”。
我见过一些团队用 Elasticsearch 做 BM25 路,用 Milvus 或 Faiss 做向量路,然后用一个融合层把两路结果合并。融合策略有几种常见做法:
- 加权分数融合:把两路归一化后的分数按权重相加,比如向量 0.6、BM25 0.4。权重需要根据业务调。
- RRF(Reciprocal Rank Fusion):不看分数,只看排名,把两路排名做倒数求和。这个对分数不可比的情况特别稳。
- 级联召回:先用 BM25 粗筛,再用向量精排;或者反过来。适合对延迟敏感的场景。
2.2 为什么 RRF 在大厂更受欢迎
我一开始也觉得加权分数融合更直观,但实际调过之后发现一个大坑:向量相似度和 BM25 分数的量纲完全不可比。余弦相似度通常在 0.7-0.95 之间波动,而 BM25 分数可能是 0 到 30 多的任意值。你归一化吧,不同查询的分布还不一样,今天调好的权重明天就偏了。
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.0 / (k + rank + 1) for rank, doc_id in enumerate(bm25_results): scores[doc_id] = scores.get(doc_id, 0) + 1.0 / (k + rank + 1) return sorted(scores.items(), key=lambda x: x[1], reverse=True)这个 k 参数一般取 60,是原论文里的经验值。我试过 30 到 100 之间,对最终效果影响不大,说明 RRF 本身很鲁棒。
2.3 三路、四路召回的进阶玩法
双路是标配,但大厂往往不止两路。我见过比较完整的方案包含四路:
- 稠密向量召回:用双塔模型编码,管语义。
- 稀疏向量召回:用 SPLADE 这类模型生成学习型稀疏表示,兼顾语义和词项。
- BM25 召回:管精确词项。
- 结构化召回:如果文档有元数据(时间、来源、类型),直接按条件过滤。
这四路各有侧重,合并后进入 rerank 阶段。为什么要这么多路?因为单一路径的召回率有天花板。假设向量路召回率 70%,BM25 路 60%,两路合并去重后可能到 85%。再加一路稀疏向量,可能到 90%。每加一路,边际收益递减,但大厂愿意为那几个百分点买单,因为下游生成的质量对召回率极其敏感。
实操心得:不要一上来就堆四路。先从双路开始,用真实查询日志评估召回率,找到瓶颈后再加路。我见过团队盲目上四路,结果延迟翻倍,效果只涨了 2%,得不偿失。
3. 重排序:被低估的关键环节
3.1 召回和排序是两件事
很多团队把召回和排序混为一谈,觉得向量检索返回的 Top-K 就是最终结果。这是大错。召回阶段的目标是“别漏”,排序阶段的目标是“排准”。这两个目标天然冲突:召回要宽,排序要严。
大厂的标准做法是:召回阶段返回较多候选(比如 50-100 条),然后用一个 cross-encoder 重排序模型精排。cross-encoder 和双塔模型不同,它把查询和文档拼在一起过一遍模型,能捕捉细粒度的交互信息。代价是慢,没法预先计算,所以只能用在少量候选上。
我实测过一个对比:同一个查询,双塔向量召回 Top-10 的准确率是 62%,经过 cross-encoder 重排后 Top-3 准确率能到 81%。这个提升幅度,在生成式问答里就是“答对”和“答错”的区别。
3.2 重排序模型的选型考量
重排序模型的选择要看几个维度:
| 维度 | 轻量方案 | 重量方案 |
|---|---|---|
| 模型规模 | 小型 cross-encoder | 大型 cross-encoder |
| 延迟 | 10-30ms | 100-300ms |
| 准确率 | 中等 | 高 |
| 部署成本 | 低 | 高 |
| 适用场景 | 高并发、延迟敏感 | 质量优先 |
大厂通常会在不同场景用不同模型。比如搜索建议用轻量的,最终答案生成用重量的。有些团队还会做级联:先用轻量模型筛一遍,再用重量模型精排 Top-20。
3.3 重排序之外的后处理
重排完之后还有几件事要做。第一是去重,因为多路召回可能返回同一文档的不同片段。第二是多样性控制,避免返回的段落全来自同一篇文档,导致信息冗余。第三是元数据过滤,比如只保留最近一年的文档,或者只保留某个来源的。
这些后处理看起来琐碎,但实际影响很大。我遇到过用户问“最新的部署流程”,系统返回了三段内容,全是两年前的旧版本,因为向量相似度太高了。加一个时间过滤,问题立刻解决。
4. 查询理解与改写:让检索更懂人话
4.1 查询改写为什么重要
用户的查询往往很短、很模糊、有歧义。直接拿原始查询去检索,效果会打折扣。大厂 RAG 系统在检索前通常会做查询理解,包括几个操作:
- 查询扩展:补充同义词、相关词。比如“启动慢”扩展成“启动慢 启动时间长 初始化耗时”。
- 查询分解:把复杂查询拆成多个子查询。比如“A 和 B 的区别”拆成“A 是什么”“B 是什么”“A B 对比”。
- 查询改写:把口语化查询改写成更规范的表述。比如“这玩意儿咋用”改写成“使用方法”。
- 意图识别:判断查询是要找事实、找步骤、还是找对比,据此调整检索策略。
这些操作有的用规则,有的用小模型,有的用大模型。大厂倾向于用大模型做查询理解,因为泛化能力强,但会加缓存和限流控制成本。
4.2 HyDE:用假设文档做检索
HyDE(Hypothetical Document Embeddings)是个很有意思的技巧。它的思路是:与其用查询去检索,不如先让大模型根据查询生成一个“假设的答案文档”,然后用这个假设文档的 embedding 去检索。因为假设文档和真实文档在表述风格上更接近,检索效果往往更好。
我试过这个方案,在技术文档场景下召回率提升了大概 8 个百分点。代价是多了一次大模型调用,延迟增加 200-500ms。适合对质量要求高、对延迟不敏感的场景。
4.3 查询路由:不同查询走不同通道
不是所有查询都需要走完整流程。大厂会做查询路由,根据查询特征决定走哪条路。比如:
- 查询里包含明确的错误码或配置项 → 优先走 BM25 精确匹配。
- 查询是自然语言问句 → 走向量召回 + 重排序。
- 查询是简单的实体查找 → 直接走结构化查询或缓存。
- 查询涉及多跳推理 → 走迭代检索或 Agent 模式。
这个路由层通常用轻量分类模型或规则实现,目的是在保证质量的前提下降低平均延迟和成本。
5. 工程落地中的那些坑
5.1 分块策略决定召回上限
文档怎么切块,直接决定了检索的天花板。切太大,一个块里混了多个主题,向量表示被稀释;切太小,上下文丢失,生成时信息不足。
我踩过的坑:一开始按固定 512 token 切,结果把一张配置表切成了三段,每段都不完整。用户搜配置项,召回的是半张表,生成出来的答案缺胳膊少腿。后来改成按语义边界切,表格整体保留,段落按标题层级切,效果好很多。
大厂常用的分块策略是混合的:先按文档结构切(标题、段落、表格),再对超长段落做语义切分,最后给每个块加上父级标题作为上下文。这样既保证块内语义完整,又保留层级信息。
5.2 向量库的选型和调参
向量库的选择要考虑数据规模、查询并发、延迟要求。小规模用 Faiss 就够了,大规模上 Milvus 或类似方案。关键参数是索引类型和 nprobe:
- 索引类型:IVF_FLAT 适合中等规模,HNSW 适合低延迟高召回,PQ 适合内存受限场景。
- nprobe:查询时扫描的聚类数。调大召回率高但慢,调小快但可能漏。一般从 10 开始调。
我见过团队用 HNSW 但没调 efSearch 参数,召回率一直上不去。调完之后召回率涨了 15%。这些参数不是默认值就最优的,必须用真实数据评估。
5.3 评估体系不能省
没有评估,调优就是盲人摸象。大厂 RAG 系统一定有离线评估集,包含查询和标注的相关文档。核心指标是召回率、MRR、NDCG。上线前跑一遍,改动后再跑一遍,用数据说话。
我建议至少准备 200 条真实查询做评估集,覆盖各种查询类型。太少没有统计意义,太多维护成本高。评估集要定期更新,因为用户查询分布会变。
6. 常见问题速查
| 问题现象 | 可能原因 | 排查方向 |
|---|---|---|
| 精确查询召回不准 | 纯向量检索语义漂移 | 加 BM25 路,检查分词器 |
| 召回结果重复度高 | 多路召回未去重 | 加去重逻辑,按文档 ID 或内容哈希 |
| 最新文档排不上来 | 缺少时间权重 | 加时间衰减因子或元数据过滤 |
| 长查询效果差 | 查询语义被稀释 | 做查询分解或改写 |
| 延迟过高 | 重排序模型太重 | 换轻量模型或减少候选数 |
| 多跳问题答不全 | 单轮检索不够 | 上迭代检索或 Agent 模式 |
最后分享一个我常用的排查技巧:把召回结果和查询一起打印出来,人工看前 20 条。如果前 20 条里没有一条真正相关,那是召回问题;如果相关文档在但排名靠后,那是排序问题。这个简单方法能快速定位问题出在哪一层。
这套混合检索加多阶段排序的架构,不是某个团队的独创,而是行业在大量真实查询上反复打磨出来的共识。纯向量检索是个好工具,但它只是工具箱里的一把。真正的大厂 RAG,是把多把工具组合起来,让每一把在它擅长的地方发挥作用。