检索工程 · RAG

只靠向量检索找不到答案:混合检索与重排怎么做

向量擅长"找相似",不擅长"找精确"。要找到答案,靠的不是换个更贵的模型,是分工。

2026-10-13检索工程约 10 分钟

先看一个失败现场

客服知识库里,用户粘贴了一串报错:ERR_PAYMENT_GATEWAY_TIMEOUT。

系统返回的第一条,是 ERR_PAYMENT_GATEWAY_REJECTED。

两个报错只差最后几个字母,含义差得远。一个是超时,一个是拒付。

系统很有把握,相似度算得很高。它没算错——它只是答错了问题。你要的不是"像",是"就是这一个"。

这不是模型不够大。哪怕换上当下最好的 embedding,这类查询照样翻车。原因很简单:向量的工作方式是"把意思近的放到近处"。两个报错共享一长串前缀,语义上确实近;最后那几个区分性的字母,在向量里几乎没分量。

"只靠向量检索找不到答案"不是事故,是原理。这篇文章只干一件事:把这件事拆开,看每一步该由谁来补。主线是一条问题链——向量漏在哪,谁来补,两边怎么合,合完还差什么,重排是不是万能的,最后怎么量、怎么搭。

结论先放这:向量检索管"找相似",不管"找精确"。要找到答案,靠的不是换模型,是分工。

第 1 问:向量检索到底漏在哪?

把三种不同的查询塞进同一个检索器,是问题的起点。

先把"相似"这个词说清楚。embedding 把文本压成一个向量,意思越近,向量越近。它是个"近似引擎"。

它擅长找概念。Feature flag、kill switch、rollout gate、config toggle,这四个词指向同一个东西,向量会把它们聚成一簇。你搜"怎么关掉某个功能开关",四个词都会浮上来。这非常好用。

它不擅长找实体。版本号 v3.2、错误码、feature flag 名、药品 NDC 码、剂量、产品编号——这些"精确到一串字符"的东西,一旦周围文本很长,区分性的那几个字符就会被抹平。

失效模式一句话概括:当区分两个东西的只是相对很小的一小段文本时,embedding 会把这点区别坍缩掉。

换个角度更好理解。查询其实分三类。

第一类,语义查询。"区域宕机时的处理流程是什么?"——稠密检索的主场。

第二类,精确查询。直接粘贴一个错误码。这时候"语义相似"恰恰是你不要的,向量甚至会帮倒忙。

第三类,混合查询。"v3.2 那次部署的回滚 runbook"——既要语义,又要精确 token。

第三类在生产 RAG 里是大多数,而它们被同一个检索器处理了。

还有一个更隐蔽的原因。稠密向量对常见词和罕见词,判定方式差不多。可现实恰恰相反:越罕见的词,越可能是关键词。这一点,BM25 天生就懂。

第 2 问:那补上 BM25 就行了吗?

关键词检索凭什么救场——它有三件向量没有的武器。

BM25 是打分了快三十年的关键词算法,也是 Elasticsearch、OpenSearch 的默认打分器。它靠三件事。

一、IDF:越罕见的词,权重越高

IDF 是逆文档频率。一个词在越少的文档里出现,区分度越高,权重越高。v3.2、ERR_...、payment_v2_enforce 这类 token 在库里几乎不重复,BM25 会给它们顶格的权重。这正是"找精确"的关键。向量把这层权重平均掉了,BM25 把它单独拎出来。

二、TF 饱和:第一次出现最值钱

一个词出现得越多,单次出现带来的增益越小,很快饱和。这条防的是关键词堆砌。

三、长度归一化:长文档不给便宜

长文档里某个词出现多次是正常的,BM25 会把文档长度算进去,避免长文档靠"词多"占便宜。RAG 里 chunk 长短不齐,这条很要命。

三件武器合起来,正好对着向量的三个软肋。但两边各有各的短板。

维度BM25 / 稀疏检索稠密向量检索
强项精确 token、罕见词、错误码、专有名词;可解释;便宜语义与概念匹配、近义改写、跨表述召回
短板词法鸿沟:查询里没出现的词召不回;忽略词序近似而非精确;对版本号、错误码、缩写、数字代码系统性失败
代表系统Elasticsearch、OpenSearch各类向量库

著名的 BEIR 基准(18 个数据集、9 类任务)给过一个很清醒的结论:BM25 是个很强的零样本基线,稠密和稀疏模型"经常不如它";更重要的是——没有任何一种方法,能在所有数据集上稳定最强。

听上去像是劝你两样都留着。对,就是这个意思。但麻烦也来了:它们交回来的不是一个榜,是两个榜,分数还不是一个尺度。

第 3 问:两路结果怎么合成一个榜?

直接加分不行——这是 RRF 存在的原因。

最直觉的做法是加权求和:α × 向量分 + (1−α) × BM25 分。听起来合理,实际很难用。

第一,两个分数不同尺度。余弦相似度被夹在 −1 到 1 之间,BM25 的分数没有上界。不做归一化直接加,其中一方会碾压另一方。

第二,就算归一化了,最优权重还是"看查询下菜"的。同一套代码,这个查询 BM25 该占 0.7,下一个查询可能只该占 0.3。想在有监督下跑赢更稳的方法,光一个数据集就得有大约 40 条标注查询;放到生产,要几百条,还得随数据漂移定期刷新。没有标注数据,这条路基本走不通。

于是有了 RRF,倒数排名融合。

RRF 公式:score(d) = Σ 1 / (k + rank(d))。一个文档在某一路里排第几,就贡献 1/(k+名次);不在该路结果里则不贡献。

它不看分数,只看名次。k 是平滑常数,通常取 60。这个默认值来自 2009 年 Cormack 等人的原始论文,Elasticsearch 的 rank_constant 默认就是 60,Milvus、LangChain 也沿用同一个数。

算个数。k 取 60 时,排第 1 贡献 1/61 ≈ 0.0164,排第 10 贡献 1/70 ≈ 0.0143。差距很小。

这正是 RRF 的用意:它不奖励"某一路给的高分",只奖励"两路都排在前面"。一个文档如果在 BM25 和向量里同时靠前,就能拿到两份贡献,被推上去。

换成 k=1 的更直观的例子(Elasticsearch 官方文档里的):文档 3 得分 1/(1+2)+1/(1+1)=0.8333,文档 2 得 0.5833,文档 4 得 0.5000。文档 3 在两路里都靠前,于是胜出。

好处是不用归一化、不用调参、跨模型跨领域都稳。代价是它丢掉了分数信息,天花板比调好的加权求和低。

用数字对比。Elasticsearch 用 BEIR 做过一轮实验,平均 nDCG@10:

融合方法相对 BM25 单独相对 ELSER 稀疏向量单独
RRF(k=20,候选 1000)约 +18%约 +1.4%
线性加权(每数据集最优校准)约 +24%约 +6%

结论很直白。加权融合上限更高,但它"不是即插即用",必须在你自己的数据上调。RRF 反过来:拿到就能用,效果稳定,代价是少几个点。

口径提示:上面的百分比来自 Elasticsearch 官方博客,实验口径是 BEIR 多数据集平均 nDCG@10,k 和候选数取最优组合。别和别的厂商数字混着比。

融合解决的是"两边都别漏"。它没有解决另一件事:把最该看的那条,排到第一位。

第 4 问:融合之后,重排又在做什么?

召回负责别漏,重排负责别乱。它们是两种完全不同的模型。

召回用的是 bi-encoder,双编码器。query 和文档各自单独编码成向量,再比相似度。文档向量可以离线算好、提前建索引,查询时几乎 O(1)。这是它能扛住百万级库的原因。

重排用的是 cross-encoder,交叉编码器。它把 query 和文档拼成一段输入,一起送进模型,让 query 的每个 token 都能关注文档的每个 token,最后直接吐一个相关性分数。

区别在于"有没有碰面"。双编码器里,query 和文档从没见过面,只能靠向量距离猜;交叉编码器里,它们逐字比对。所以它更准——"Python 语言"和"蟒蛇"这种嵌入容易搞混的,它分得清。

代价是它没法建索引。每个"query + 文档"组合都要单独跑一遍模型,复杂度是 O(n)。一个极端例子:100 万篇文档,每对 30 毫秒,一次查询要跑 8 个多小时。所以交叉编码器只能做精排,做不了全库检索。它只能看一小撮候选。

这一小撮是多少?工程上的标准配置:召回取 top 50 到 200,延迟约 10 到 50 毫秒;重排逐篇打分,单篇约 30 毫秒,50 篇约 20 到 100 毫秒;最终取 top 3 到 10 给模型或用户;端到端大约 50 到 200 毫秒。

中间还有一条路线值得一提:ColBERT 那一类的 late interaction,晚交互。它给文档的每个 token 都留一个向量,用 MaxSim 做匹配,既保留 token 级的细粒度,又能离线预计算。听起来两全其美,但存储成本是单向量索引的 50 到 150 倍。多数团队不部署它,就是因为存储算不过来。ColBERTv2 用残差压缩把占用降到五分之一到八分之一,才让这条路重新可用。

重排到底能带来多少?看两组数。

第一组来自 Cohere 官方的 Rerank 发布实测,指标是 Accuracy@3,也就是前三条里出现高相关结果的比例:

方法MIRACL(15 语)Natural QuestionsTREC-DL平均
词法检索(Elasticsearch 8.6)44.733.754.644.3
向量检索65.552.276.364.7
词法 top100 + 重排73.961.679.471.6

纯关键词检索,平均只有约 44% 的查询能进前三;换成向量,65%;加上重排,72%。重排是在"已经不错的向量检索"之上又抬了一截。

第二组更有名,来自 Anthropic 的 Contextual Retrieval 实验。他们的口径是 1 − recall@20,也就是"前 20 块里没召回相关文档"的失败率,基线 5.7%:只做上下文 embedding 降到 3.7%,降幅 35%;上下文 embedding 加上下文 BM25(混合)降到 2.9%,降幅 49%;再加重排降到 1.9%,降幅 67%。

三件事一层层叠上去:混合检索把失败率砍掉一半,重排再砍掉一截。这就是"增益可叠加"。

口径提示:Anthropic 的 35%/49%/67% 是同一篇博客、同一口径下的数字,不能和 Cohere 的 Accuracy@3 直接比大小。

到这儿,重排看起来是稳赚的买卖。它有个前提,而且这个前提经常被忽略。

第 5 问:重排是万能药吗?

有实测反例:重排让结果变差了。

先说反例。AWS 在 OpenSearch 上做过一组评测,指标是 nDCG@10:

配置nDCG@10加重排后变化
BM25 单独0.5600.666+18.9%
混合检索0.7120.685−3.8%

同一套重排模型,加在 BM25 上是好药,加在混合检索上反而掉分。为什么?

因为重排只能在"候选集"里重新排序,它看不到候选集外面的东西。如果正确答案根本没进候选,重排再准也没用。混合检索本身已经把候选质量做得很高了,重排某些时候反而打乱了它已经排对的顺序。

这暴露出一个更容易被忽略的问题:召回宽度。

标准做法是召回 50 到 200 篇再重排到 3 到 10。为什么不能只召回 10 篇?因为如果最优答案排在第 15 位,而你只召回了 10 篇,重排压根没看到那条。重排的价值,建立在"答案大概率已经在候选里、只是排得靠后"这个前提上。前提不成立,重排就是白花钱。

再说成本账。交叉编码器每篇文档约 30 毫秒,而且是逐对的。候选从 50 提到 200,延迟和费用是四倍。Cohere、Voyage 这类 API 按量计费;本地跑 BGE 这类模型要 GPU,而且必须批量推理,一条条跑会把显存利用率拖垮。

还有一个坑:序列长度。交叉编码器常见的上下文上限是 512 token,长文档会被截断,截掉的可能正好是关键那段。Cohere 的 max_tokens_per_doc 默认 4096,BGE 可以配到 1024。文档更长的,得先切再排。

所以正确的顺序是:先修召回,再看重排。如果相关文档根本没被召回,问题在分块、在查询改写、在混合检索,不在重排器。

第 6 问:怎么知道该不该加、加了有没有用?

别拍脑袋,先量。

第一步是建评测集。检索评测的标准前提,是一批"query + 标注了哪些文档相关"的答案。三条路各有毛病:人工标注是金标准,但慢、贵、主观,复杂问题往往没有唯一答案;合成数据便宜,但语言多样性差,还会继承生成模型的偏差;公开基准跑分高,不代表你的业务数据上就好。

稳妥的做法是:用一小批人工金标来校准,用 LLM-as-judge 做规模化的回归测试。但要记住 judge 的系统性偏差——它偏好长回答、有位置偏好、偏好自己生成的答案。

第二步是选对指标。指标选错,优化方向就会跑偏。

指标定义什么时候用它
Precision@k前 k 条里相关文档的比例结果直接喂给模型、容错低时
Recall@k前 k 条命中数占全部相关文档的比例漏信息代价高时(召回是答案质量的上限)
Hit Rate@k前 k 条里是否至少有一条相关基础健康检查
MRR第一条相关结果排名的倒数均值事实型问答,第一条对就够
nDCG考虑位置与相关性等级的归一化增益评估整体排序质量

生产里有个很好用的口径:1 − recall@20,也就是"前 20 块里没找到相关文档"的失败率。Anthropic 用的就是这个。它比单纯看准确率更能暴露漏召回。

第三个提醒很关键:别混口径。Anthropic 的失败率、Cohere 的 Accuracy@3、Voyage 的 NDCG@10,是三套不同的尺子。拿一家博客的数字去否定另一家的结论,是最常见的错。

最后一条经验,来自踩过坑的团队:先做失败分析,让每个改动"挣来它自己的位置"。检索优化有个大致的性价比顺序——早期回报最高的是先上混合检索加重排;再看召回够不够宽;然后才轮到调分块、调查询改写。每次只改一个变量,测一遍,再决定留不留。

第 7 问:一条能跑的流水线长什么样?

把前面所有零件装起来。

从查询到答案:一条两阶段检索流水线

查询 用户原话 改写 / HyDE 补全语义 并行召回 BM25 稀疏 向量 稠密 RRF 融合 k=60 重排 cross-encoder 取 top-k → 生成 宽召回:别漏(top 50–200,10–50ms) 精排序:别乱(每篇约 30ms) 端到端检索链路约 50–200ms,取 top 3–10 送入生成

召回阶段要宽,融合阶段要稳,精排阶段要准。任何一段缺位,整条链路的质量都会漏。

查询改写:便宜,但有效

用户的原话往往又短又口语化,而语料是正式的。HyDE(假设文档嵌入)的做法是:先让模型编一段"假想的答案",用它的 embedding 去检索真文档,假文档本身用完就丢。Elasticsearch 的实测里,在"短口语查询 vs 正式语料"这种不对齐的情况下,语义检索的精度和召回提升了约 50%,而且不用重建索引、不用换 embedding 模型。代价是多一次模型生成的延迟。

元数据过滤:把搜索空间先砍一半

用 key-value 元数据(来源、日期、类型)在检索时先限定范围。空间小了,噪声就少。这一步在多数系统里被低估了。

分块:检索质量的隐形地基

分块大小、边界、重叠比例都会影响检索。传统 RAG 最大的毛病是切块之后丢了上下文——SEC 文件里一句"公司营收增长 3%",切出来后就不知道是哪家公司、哪个季度。Anthropic 的上下文检索正是冲这个来的:给每个块前面补 50 到 100 token 的"块级上下文",成本约 1.02 美元 / 百万文档 token(依赖 prompt 缓存),失败率却砍掉三分之一。

延迟预算:把重排放进预算表

一份有代表性的预算:端到端 p95 约 2.5 秒,其中生成占 1.5 到 2.0 秒,检索加 embedding 要在 400 毫秒内收完。重排可以做条件化——只在对质量敏感、对延迟不敏感的路径上开。工程上还要准备降级:重排服务 p95 超阈值时,直接回退到 BM25 或向量分排序,质量降一点,但不至于挂掉。

最后:各家实现对照

同一件事,各家的做法不同。

系统混合检索实现融合方式与默认值
Elasticsearchrrf retriever,至少两个子检索器(BM25 + kNN 或 ELSER)RRF,rank_constant 默认 60
OpenSearch多子句合并RRF,只看排名不看分数
Weaviate并行跑向量与 BM25alpha:0=纯 BM25,1=纯向量,0.5=各半;提供 rankedFusion 与 relativeScoreFusion
Milvus多路搜索RRF Ranker 或 Weighted Scoring,默认 RRF,k=60
Qdrant稠密 + 稀疏支持 RRF 与 Relative Score Fusion,集成中默认后者
LangChainEnsembleRetrieverRRF,融合常数 c 默认 60,weights 可加权
pgvector无原生 BM25常配 Postgres 全文检索 tsvector + SQL 里 RRF 合并;要真 BM25 得加 pg_search
版本提示:Weaviate 的默认融合方式在不同时期的官方资料里表述不一致(一说 rankedFusion,一说 relativeScoreFusion)。用之前以当前版本官方文档为准。

收束

回到开头那个错误码。系统返回了邻居,因为它被设计成"找相似"。这不是换个更贵的模型能解决的。

混合检索解决"别漏",重排解决"别乱",评测解决"别猜"。三件事各管一段,缺一段都跑不顺。

如果非要留一句实在话:大多数时候,"检索不行"不是模型不行,是分工没排好。先量,再调。别急着换 embedding。

总结

  • 纯向量检索失败是原理性的:它擅长找相似,不擅长找精确。版本号、错误码、专有名词最先翻车。
  • BM25 用 IDF、TF 饱和、长度归一化补上精确匹配;它和向量互补,不是替代。
  • 两路结果别再"分数相加"。RRF 只看名次,k 默认 60,即插即用、免归一化,代价是天花板略低。
  • 重排用交叉编码器重新排序,更准但复杂度 O(n) 且慢。标准配置:召回 50–200,重排到 3–10。
  • 重排不是万能。它只能在候选集内排序,召回太窄就没救;AWS 实测里混合检索加重排甚至掉分。
  • 先修召回,再谈重排。用 1−recall@20、nDCG 这类指标量,别拍脑袋。
  • 增益可叠加:混合检索 + 上下文分块 + 重排,Anthropic 的失败率从 5.7% 一路降到 1.9%。

参考资料