Skip to content

混合检索与 RRF 融合:纯向量检索的漏召问题 ​

1. 本节产出 ​

一个混合检索器:向量检索 + 关键词检索(BM25)并行执行,用 RRF 融合排序,并且能用评测集证明「召回率@k 相比纯向量提升了多少」。

2. 前置依赖 ​

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 分是无上界的词频统计,量纲完全不同
RRFscore = Σ 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
// src/main/java/com/example/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
// src/main/java/com/example/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
// src/main/java/com/example/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. 生产避坑 ​

  1. 不要用分数加权融合,用 RRF。向量相似度(0~1 的余弦)和 BM25 分数(无上界的词频统计)量纲完全不同,加权要先归一化,而归一化参数又因查询而异——调不出来。RRF 只用排名,天然规避这个问题。
  2. 两路检索必须并行。串行实现是这个功能最常见的性能问题,而且很容易被忽略(因为功能测试全绿,只是慢)。用 CompletableFuture 或线程池,并给每一路都设超时。
  3. ES 与向量库的数据一致性是新的运维负担。文档更新时两边都要更新,任何一边失败都会导致「搜得到但语义检索不到」这类诡异问题。做法:用同一个 sourceId 做对账,定时扫描差异(见 02-14 增量更新)。

8. 延伸与锚点 ​

  • 思考题:混合检索召回了 20 个候选,但真正相关的可能排在第 8 位。怎么把好的排到前面?(答案在下一课时)
  • 代码锚点:git checkout ch02-09-hybrid-retrieval
  • 下一课时:02-10 Rerank 二阶段排序
  • 对应课件:L02-09 混合检索与 RRF