Appearance
混合检索与 RRF 融合:纯向量检索的漏召问题
1. 本节产出
一个混合检索器:向量检索 + 关键词检索(BM25)并行执行,用 RRF 融合排序,并且能用评测集证明「召回率@k 相比纯向量提升了多少」。
2. 前置依赖
- 02-06 向量库选型
- 02-08 相似度与阈值
- Elasticsearch(或用 PG 全文检索替代 BM25)
3. 为什么纯向量检索会漏召
三类真实漏召案例:
| 查询 | 纯向量检索的表现 | 原因 |
|---|---|---|
| 「SO20260915001」 | 返回一堆订单文档,但都不是这个单号 | 订单号是标识符,语义向量捕捉不到精确匹配 |
| 「NullPointerException 怎么处理」 | 返回 Java 异常通用的文档 | 专业术语的字面匹配比语义更重要 |
| 「张三的工号是多少」 | 返回其他员工信息 | 人名/编号类查询需要精确匹配 |
根因:向量检索擅长「语义相似」,不擅长「字面精确匹配」。而真实业务查询里,精确匹配类占了相当比例(编号、人名、术语、错误码)。
反过来,纯关键词检索也有盲区:
| 查询 | 纯关键词的表现 |
|---|---|
| 「服务器挂了怎么办」 | 文档里写的是「服务不可用」,词面不匹配,搜不到 |
| 「怎么让员工少离职」 | 文档里是「降低人员流失率」,同义不同词 |
结论:两种检索是互补关系,不是替代关系。 这就是混合检索的全部理由。
4. 核心原理
4.1 混合检索架构
用户查询
│
┌──────────────┴──────────────┐
▼ ▼
向量检索(ANN) 关键词检索(BM25)
语义相似 字面匹配
topK × 2 topK × 2
│ │
└──────────────┬──────────────┘
▼
RRF 融合排序
(倒数排名融合)
▼
Top-K 结果为什么要 topK × 2:两边各取两倍候选,融合后有更大的挑选空间。只取 topK 会导致融合后可选范围太窄。
4.2 RRF:为什么不是加权平均
两种融合方式的对比:
| 方式 | 做法 | 问题 |
|---|---|---|
| 分数加权 | score = α·vectorScore + (1-α)·bm25Score | 分数不可比:向量分是 [0,1] 的余弦,BM25 分是无上界的词频统计,量纲完全不同 |
| RRF | score = Σ 1/(k + rank_i) | 只用排名,不用分数 |
RRF(Reciprocal Rank Fusion)只用排名,因此天然规避了量纲问题。这是它成为主流方案的原因。
RRF 计算公式:
score(doc) = Σ_over_lists 1 / (k + rank_in_list)
k 通常取 60(经验值,用于降低头部排名的权重差异)
举例:
文档 X 在向量列表排第 1,在关键词列表排第 3
score = 1/(60+1) + 1/(60+3) = 0.01639 + 0.01587 = 0.03226
文档 Y 只在向量列表排第 2
score = 1/(60+2) = 0.01613
→ X 排在 Y 前面(两个列表都出现会获得加成)RRF 的关键特性:出现在多个列表中的文档会获得加成。这正是我们想要的——既语义相关又字面匹配的文档,最可能是答案。
4.3 什么时候用混合检索
| 场景 | 建议 |
|---|---|
| 查询多为自然语言提问 | 向量为主,关键词为辅(权重可偏向量) |
| 查询含编号/术语/人名 | 必须混合 |
| 文档是纯散文、无术语 | 纯向量即可 |
| 文档含大量专有名词 | 混合收益明显 |
判断方法:把评测集里的查询分成「语义类」和「精确匹配类」两组,分别测两种检索的召回率。如果精确匹配类占比超过 20%,混合检索就有明确收益。
5. 代码走查
5.1 关键词检索(ES)
java
// ch02-rag/src/main/java/com/aitech/rag/retrieval/KeywordSearcher.java
@Component
public class KeywordSearcher {
private final ElasticsearchClient es;
public List<RetrievalResult> search(String query, String tenantId, int topK) {
SearchResponse<ChunkDoc> resp = es.search(s -> s
.index("rag_chunks")
.query(q -> q.bool(b -> b
.must(m -> m.match(mm -> mm.field("content").query(query)))
.filter(f -> f.term(t -> t.field("tenantId").value(tenantId)))))
.size(topK),
ChunkDoc.class);
return resp.hits().hits().stream()
.map(h -> new RetrievalResult(
h.id(),
h.source().content(),
h.score(), // BM25 分数
h.source().metadata(),
"bm25"))
.toList();
}
}提示:如果没有 ES,可以用 PostgreSQL 的全文检索(
to_tsvector/ts_rank)做简化替代。中文需要额外配置分词(如zhparser),效果不如 ES 但能跑通链路。
5.2 RRF 融合
java
// ch02-rag/src/main/java/com/aitech/rag/retrieval/RrfFusion.java
@Component
public class RrfFusion {
private static final int K = 60;
public List<RetrievalResult> merge(List<RetrievalResult> vectorHits,
List<RetrievalResult> keywordHits,
int topK) {
Map<String, Double> scores = new HashMap<>();
Map<String, RetrievalResult> docs = new HashMap<>();
accumulate(vectorHits, scores, docs);
accumulate(keywordHits, scores, docs);
return scores.entrySet().stream()
.sorted(Map.Entry.<String, Double>comparingByValue().reversed())
.limit(topK)
.map(e -> docs.get(e.getKey()).withScore(e.getValue()))
.toList();
}
private void accumulate(List<RetrievalResult> hits,
Map<String, Double> scores,
Map<String, RetrievalResult> docs) {
for (int i = 0; i < hits.size(); i++) {
RetrievalResult r = hits.get(i);
scores.merge(r.id(), 1.0 / (K + i + 1), Double::sum);
docs.putIfAbsent(r.id(), r);
}
}
}5.3 混合检索器
java
// ch02-rag/src/main/java/com/aitech/rag/retrieval/HybridRetriever.java
@Service
public class HybridRetriever {
private final VectorStore vectorStore;
private final KeywordSearcher keyword;
private final RrfFusion rrf;
private final ExecutorService pool = Executors.newFixedThreadPool(2);
public List<RetrievalResult> retrieve(String query, String tenantId,
List<String> acl, int topK) {
// 两边并行执行——串行会让延迟翻倍
Future<List<RetrievalResult>> fv = pool.submit(() ->
vectorSearch(query, tenantId, acl, topK * 2));
Future<List<RetrievalResult>> fk = pool.submit(() ->
keyword.search(query, tenantId, topK * 2));
List<RetrievalResult> v = get(fv);
List<RetrievalResult> k = get(fk);
return rrf.merge(v, k, topK);
}
private List<RetrievalResult> vectorSearch(String q, String tenantId,
List<String> acl, int topK) {
String filter = FilterBuilder.build(tenantId, acl, Instant.now());
return vectorStore.similaritySearch(SearchRequest.builder()
.query(q).topK(topK).filterExpression(filter).build())
.stream()
.map(HybridRetriever::toResult)
.toList();
}
}并行执行是关键。串行的话「混合检索 = 向量耗时 + 关键词耗时」,延迟直接翻倍;并行后等于两者取最大值。
5.4 降级:关键词服务挂了怎么办
java
private List<RetrievalResult> get(Future<List<RetrievalResult>> f) {
try {
return f.get(3, TimeUnit.SECONDS); // 超时就放弃这一路
} catch (Exception e) {
log.warn("检索分支失败,降级为单路", e);
return List.of();
}
}单路失败不能让整个检索失败。带超时的降级返回空列表,RRF 融合时该路不贡献分数,系统仍然可用——这是优雅降级的标准做法。
6. 跑起来
bash
git checkout ch02-09-hybrid-retrieval
docker compose up -d # 含 ES
mvn -q test -Dtest=HybridRetrievalEvaluationTest期望输出(用评测集对比三种方案):
方案 召回率@5 召回率@10 P95延迟
纯向量 0.68 0.79 42ms
纯 BM25 0.54 0.66 28ms
混合 + RRF 0.85 0.92 58ms| 检查项 | 通过标准 |
|---|---|
| 精确匹配类查询 | 混合检索明显优于纯向量(订单号、错误码能搜到) |
| 语义类查询 | 混合不劣于纯向量 |
| 融合生效 | 两路都命中的文档排名靠前 |
| 降级 | 停掉 ES,检索仍能返回结果(质量下降但不报错) |
| 延迟 | 并行执行,P95 < 两路耗时之和 |
7. 生产避坑
- 不要用分数加权融合,用 RRF。向量相似度(0~1 的余弦)和 BM25 分数(无上界的词频统计)量纲完全不同,加权要先归一化,而归一化参数又因查询而异——调不出来。RRF 只用排名,天然规避这个问题。
- 两路检索必须并行。串行实现是这个功能最常见的性能问题,而且很容易被忽略(因为功能测试全绿,只是慢)。用
CompletableFuture或线程池,并给每一路都设超时。 - ES 与向量库的数据一致性是新的运维负担。文档更新时两边都要更新,任何一边失败都会导致「搜得到但语义检索不到」这类诡异问题。做法:用同一个 sourceId 做对账,定时扫描差异(见 02-14 增量更新)。
8. 延伸与锚点
- 思考题:混合检索召回了 20 个候选,但真正相关的可能排在第 8 位。怎么把好的排到前面?(答案在下一课时)
- 代码锚点:
git checkout ch02-09-hybrid-retrieval - 下一课时:02-10 Rerank 二阶段排序
- 对应课件:L02-09 混合检索与 RRF