纯向量搜索的天花板

在之前的文章里,我讨论过用 ES 的 kNN 做语义检索,以及用 MySQL 9.2 的 VECTOR 类型做 RAG。这些方案在中小规模场景下够用,但有一个容易被忽略的问题:纯向量搜索的召回率不够高。

举一个真实的例子。用户问:”Spring Cloud 微服务网关的限流策略”。

  • 向量检索召回:理解了”微服务”和”限流”的语义关系,但可能漏掉了精确匹配”Spring Cloud Gateway”这个词的文档。
  • BM25 关键词检索召回:精确匹配了”限流”这个词项,但无法理解”网关”和”Gateway”是同一个东西。

两条路各有盲区。在企业知识库场景,80% 的查询同时需要语义理解和关键词精确匹配。

参考数据(基于某企业客服知识库,5 万文档):

检索方式 Recall@10 Precision@5
纯向量检索 ~68% ~64%
纯 BM25 ~72% ~78%
混合检索 ~91% ~87%

混合检索把召回率从 68% 提到 91%,提升 31%。这就是为什么”混合检索 + 重排序”正在成为企业级 RAG 的标配。

混合检索:BM25 + kNN 双路召回

基本思路

混合检索的核心是多路召回 + 融合排序

1
2
3
4
用户查询
├── BM25 关键词检索 → Top-K 结果
├── kNN 向量检索 → Top-K 结果
└── 融合排序 → 最终 Top-K

两路独立检索,各召回 K 个结果(通常 K=2050),然后融合排序选出最终 Top-K(通常 K=510)。

ES 里的实现

ES 天然支持混合检索——一个查询里同时走 BM25 和 kNN:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
"query": {
"bool": {
"should": [
{ "match": { "content": "Spring Cloud 网关限流" } },
{
"knn": {
"field": "content_vector",
"query_vector": [0.1, 0.2, ...],
"k": 20,
"num_candidates": 200
}
}
]
}
},
"size": 10
}

ES 内部会自动做分数融合。但这里有个问题:BM25 分数和 kNN 余弦相似度分数的量级完全不同。BM25 可能是 015,kNN 相似度是 01。直接相加,BM25 会压倒 kNN。

怎么解决分数不可比

两种主流方案:

方案 1:ES 内置 RRF(推荐)

ES 8.8+ 支持 RRF(Reciprocal Rank Fusion),直接在查询里用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
{
"size": 10,
"retriever": {
"rrf": {
"retrievers": [
{
"standard": {
"query": { "match": { "content": "Spring Cloud 网关限流" } }
}
},
{
"knn": {
"field": "content_vector",
"query_vector": [0.1, 0.2, ...],
"k": 20,
"num_candidates": 200
}
}
],
"rank_window_size": 50,
"rank_constant": 60
}
}
}

方案 2:应用层手动融合

如果你的 ES 版本不支持 RRF retriever,可以在应用层做:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public List<Document> hybridSearch(String query, float[] queryVector) {
// 1. BM25 检索
List<SearchHit> bm25Hits = esClient.search(
s -> s.query(q -> q.match(m -> m.field("content").query(query)))
.size(50),
Document.class
).hits().hits();

// 2. kNN 检索
List<SearchHit> knnHits = esClient.search(
s -> s.query(q -> q.knn(k -> k.field("content_vector")
.queryVector(queryVector).k(20).numCandidates(200)))
.size(50),
Document.class
).hits().hits();

// 3. RRF 融合
return rrfFusion(bm25Hits, knnHits, 10);
}

RRF 融合:为什么比线性加权好

RRF 公式

RRF(Reciprocal Rank Fusion)的核心思想:不看分数,只看排名。

1
RRF_Score(doc) = Σ  1 / (K + rank_i(doc))

其中 K 是衰减因子(通常取 60),rank_i(doc) 是文档在第 i 路检索结果中的排名。

为什么 RRF 比线性加权好

线性加权Score = 0.6 × vectorScore + 0.4 × bm25Score

问题:分数分布不均匀。向量检索的第 1 名和第 2 名分数差可能只有 0.01,但 BM25 的第 1 名和第 2 名差 3 分。线性加权后 BM25 的排名波动会主导最终结果。

RRFScore = 1/(60+1) + 1/(60+5) = 0.0164 + 0.0154 = 0.0318

第 1 名和第 5 名的 RRF 分数差很小(0.0164 vs 0.0154),不会因为某一路检索的分数波动而剧烈影响最终排名。

融合方式 优点 缺点
线性加权 简单直观 分数不可比,需归一化
RRF 排名融合,分数不可比也无所谓 丢失分数信息(只看排名)
曼哈顿距离 保留更多信息 实现复杂,收益有限

结论:大多数场景下 RRF 就够了。 它简单、鲁棒、已在 ES 和多种搜索引擎中验证。

权重调节

有时候两路检索的权重不一样。比如内部知识库场景,语义检索更重要(用户描述模糊),权重 0.6:0.4;而文档精确检索场景,关键词更重要,权重 0.3:0.7。

RRF 可以加权重:

1
Weighted_RRF_Score(doc) = Σ  w_i / (K + rank_i(doc))

参考值:

场景 向量权重 BM25 权重
内部知识库 0.6 0.4
客服 RAG 0.7 0.3
文档精确检索 0.3 0.7
通用场景(不知道选什么) 0.5 0.5

重排序:Cross-Encoder 的精确打击

为什么混合检索还不够

混合检索解决了召回率问题(从 68% 到 91%),但 Precision@5 只到 87%。这意味着前 5 个结果里还有 1 个不太相关。

原因:双编码器(Bi-Encoder)的精度天花板。 向量检索用的是双编码器——查询和文档分别编码成向量,然后算余弦相似度。这种方式速度快,但无法捕捉查询和文档之间的细粒度交互。

Cross-Encoder 怎么不同

重排序用的是交叉编码器(Cross-Encoder)——把查询和文档拼在一起,一次性输入模型:

1
[CLS] 用户查询 [SEP] 文档内容 [SEP] → 模型 → 相关性分数

Cross-Encoder 直接对 (Query, Document) 对做相关性评分,精度远超双编码器的余弦相似度。

代价是速度慢:每个 (Query, Document) 对都要一次完整的模型推理。所以重排序只对混合检索的 Top-K(通常 20~50 个)做,不对全库做。

重排序模型选择

模型 部署方式 延迟 适合场景
Cohere Rerank v3 API 调用 ~50ms/对 快速接入,无本地部署
BGE-Reranker-base 本地 ONNX ~5ms/对 数据敏感,需要本地化
BGE-Reranker-large 本地 GPU ~2ms/对 高性能场景
ms-marco-MiniLM 本地 ONNX ~8ms/对 英文场景

推荐:先用 Cohere Rerank API 跑通流程,再考虑本地部署 BGE-Reranker。

在 Spring AI 里怎么接

Spring AI 2.0 的 Advisor 链是接入重排序的天然位置:

1
2
3
4
5
6
7
8
9
// 概念设计(非生产代码)
// 重排序 Advisor 在 RAG Advisor 之后执行

AdvisorChain chain = AdvisorChain.builder()
.addAdvisor(new ChatMemoryAdvisor(chatMemory)) // 1. 对话记忆
.addAdvisor(new RAGAdvisor(vectorStore)) // 2. 混合检索召回
.addAdvisor(new RerankerAdvisor(rerankerModel, 5)) // 3. 重排序精排
.addAdvisor(new ToolCallingAdvisor(toolCallbacks)) // 4. 工具调用
.build();

RerankerAdvisor 的核心逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class RerankerAdvisor implements Advisor {

private final RerankerModel rerankerModel;
private final int topK;

@Override
public AdvisorResponse adviseRequest(AdvisorRequest request) {
// 1. 从上下文取出混合检索的结果(Top 20~50)
List<Document> candidates = request.context()
.get("retrieved_documents");

// 2. 对每个候选文档做重排序打分
String query = request.userText();
List<ScoredDocument> scored = candidates.stream()
.map(doc -> new ScoredDocument(
doc,
rerankerModel.score(query, doc.getText())
))
.sorted(Comparator.comparingDouble(ScoredDocument::score)
.reversed())
.limit(topK)
.toList();

// 3. 只保留 Top-K 重排序后的结果
request.context().put("reranked_documents",
scored.stream().map(ScoredDocument::document).toList());

return request.next();
}
}

关键设计决策

  1. 重排序只对 Top 20~50 做,不对全库做——Cross-Encoder 慢
  2. 重排序后只保留 Top 5~10——给 LLM 的 context 不能太长
  3. 放在 RAG Advisor 之后——先召回再精排

完整 RAG 管道的成本账

环节 延迟 成本
BM25 检索 ~10ms 低(ES 内置)
kNN 检索 ~45ms 中(HNSW 查询)
RRF 融合 <1ms 几乎为零
Cross-Encoder 重排序 ~50ms × 20对 = ~100ms 中高(模型推理)
LLM 生成 ~500ms 高(token 费用)

混合检索 + 重排序增加了约 150ms 延迟,换来召回率从 68% → 91%、精度从 64% → 87%。

这个代价值不值? 取决于场景:

  • 如果 LLM 生成本身要 500ms+,多 150ms 做精排是值得的——垃圾进垃圾出,召回不准,LLM 生成再快也没用。
  • 如果是实时对话场景(要求 P99 < 200ms),可以跳过重排序,只做混合检索 + RRF。

什么时候该上重排序

场景 推荐
内部知识库(5万文档以下) 混合检索 + RRF 就够,跳过重排序
客服 RAG(精度要求高) 混合检索 + 重排序
法律/医疗文档检索(精度极其关键) 混合检索 + 重排序 + 人工审核
实时对话(延迟敏感) 只做混合检索,跳过重排序
文档量 > 100 万 混合检索 + 异步重排序

核心判断:重排序解决的是”前 5 个结果里混入了不相关文档”的问题。如果你的 RAG 经常出现”LLM 答非所问”,第一步不是换大模型,而是检查召回质量。

意味着什么

  1. RAG 的瓶颈不在生成,在检索。 很多人调 RAG 的第一反应是”换更大的模型”或”优化 prompt”。但真正的问题往往是召回质量——如果检索回来的文档不对,GPT-5 也答不对。

  2. 混合检索是企业级 RAG 的最低门槛。 纯向量搜索的 68% 召回率在生产环境是不可接受的。BM25 + kNN + RRF 是性价比最高的改进。

  3. 重排序是”最后 10% 的精度”的标配。 如果业务对精度有要求(客服、法律、医疗),重排序不是可选项。

  4. Spring AI 的 Advisor 链是做这件事的正确位置。 不要在业务代码里硬编码检索逻辑,而是把混合检索和重排序封装成 Advisor,通过链式组合控制管道。

一句话:RAG 不是”向量搜索 + LLM”这么简单。召回(多路 + 融合)→ 精排(Cross-Encoder)→ 生成(LLM),这三步管道才是企业级 RAG 的完整形态。

关联阅读