RAG 混合检索深度分析

第 1 章:问题驱动——为什么需要混合检索?

1.1 纯向量检索的”词汇鸿沟”

向量检索的核心思路是:将文档和问题都转为高维向量,通过余弦相似度找到语义最接近的片段。这在语义泛化场景表现优秀——问”如何让身体更健康”,能召回”养生食谱”和”运动方案”。

但问题在于:向量模型学到的是语义相似度,而非字符精确性。面对专有名词、产品编号、版本号、人名时,召回率会显著下降——这是结构性缺陷,不是调参能解决的。

查询类型 向量检索效果 BM25 效果
语义问答(”如何提升免疫力”) 优秀 一般
同义词泛化(”汽车” vs “小轿车”) 优秀 较差
精确专有名词(”GPT-4o”) 一般 优秀
产品编号/型号(”ORD-9527”) 较差 优秀
代码方法名(”getUserById”) 较差 优秀

1.2 核心思路:两路召回,取长补短

既然每种检索各有所长,最直接的解法就是同时用两种方式检索,然后把结果合并——这就是混合检索的核心思想。


第 2 章:BM25——关键词检索的经典算法

2.1 一句话理解 BM25

BM25(Best Match 25)是 Elasticsearch、Lucene 的默认排序算法。可以把它想象成一位图书馆管理员:你说要找”苹果手机”,他会扫描所有书的索引,数一数每本书提到”苹果”和”手机”多少次,优先推荐那些反复提到这两个词、且整本书不太厚的书籍。

2.2 三个核心因子

因子 全称 作用 举例
TF Term Frequency(词频) 词在文档中出现越多,相关性越高 “苹果”出现 5 次 > 出现 1 次
IDF Inverse Document Frequency(逆文档频率) 词在所有文档中越罕见,越有辨别力 “苹果”比”的”更有价值
文档长度归一化 Document Length Normalization 防止长文档因词多而”霸榜” 长文档中词出现多次不一定更相关

2.3 BM25 vs 向量检索:互补而非替代

两者的关系不是”谁替代谁”,而是各有所长、互为补充


第 3 章:RRF——公平融合多路结果的算法

3.1 为什么不能直接加权相加?

当 BM25 和向量检索各返回结果后,面临一个棘手问题:两路分数不在同一量纲——

  • BM25 得分:12.7、8.3、5.1……(无上限)
  • 向量相似度:0.93、0.87、0.82……(0~1 之间)

直接加权相加(如 0.5 × BM25 + 0.5 × 向量)会让 BM25 的绝对量级淹没向量检索的贡献。

3.2 RRF 的核心思想

RRF(Reciprocal Rank Fusion,倒数排名融合) 由滑铁卢大学和 Google 联合提出。其核心思想极其优雅:

不管你的原始分数是多少,我只关心你在各自榜单里排第几名。

3.3 具体示例

假设检索 “苹果14 售价” ,两路各召回 5 条结果:

排名 BM25 结果 向量检索结果
第 1 名 文档A(iPhone 14 参数) 文档C(苹果产品历史)
第 2 名 文档B(手机价格比较) 文档A(iPhone 14 参数)
第 3 名 文档C(苹果产品历史) 文档D(苹果公司财报)
第 4 名 文档D(苹果公司财报) 文档B(手机价格比较)
第 5 名 文档E(Android 手机推荐) 文档E(Android 手机推荐)

使用 k=60 计算各文档的 RRF 得分:

文档 BM25 排名 向量排名 RRF 得分 最终排名
文档A 1 2 1/(60+1) + 1/(60+2) = 0.0321 第 1
文档C 3 1 1/(60+3) + 1/(60+1) = 0.0321 第 1(同分)
文档B 2 4 1/(60+2) + 1/(60+4) = 0.0317 第 3
文档D 4 3 1/(60+4) + 1/(60+3) = 0.0317 第 4
文档E 5 5 1/(60+5) + 1/(60+5) = 0.0308 第 5

关键洞察:文档 A(iPhone 14 参数)在两路结果中都排名靠前,RRF 将其综合排名拉到最高——**”两路都认可的文档更可信”**。k=60 让第 1 名和第 2 名的得分差距适中,防止排名过度集中。


第 4 章:Spring Boot + LangChain4j 代码实现

4.1 项目依赖

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
<properties>
<langchain4j.version>1.13.1</langchain4j.version>
</properties>

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j</artifactId>
<version>${langchain4j.version}</version>
</dependency>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-open-ai</artifactId>
<version>${langchain4j.version}</version>
</dependency>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-embeddings-all-minilm-l6-v2</artifactId>
<version>${langchain4j.version}</version>
</dependency>
</dependencies>

4.2 核心组件一:BM25 关键词检索器

设计说明:LangChain4j 1.x 未内置 BM25 ContentRetriever,需手动实现。生产环境建议替换为 Elasticsearch 的 BM25 接口或接入 IK/jieba 分词器处理中文。

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
public class BM25ContentRetriever implements ContentRetriever {
private static final double K1 = 1.5; // 词频饱和参数
private static final double B = 0.75; // 文档长度归一化系数
private final List<TextSegment> corpus = new ArrayList<>();
private final int topK;

public BM25ContentRetriever(int topK) { this.topK = topK; }

/** 文档入库时同步调用,建立索引 */
public void index(TextSegment segment) { corpus.add(segment); }

@Override
public List<Content> retrieve(Query query) {
if (corpus.isEmpty()) return Collections.emptyList();
String[] queryTerms = tokenize(query.text());
double avgdl = corpus.stream()
.mapToInt(s -> tokenize(s.text()).length)
.average().orElse(1.0);
return corpus.stream()
.map(seg -> Map.entry(seg, bm25Score(queryTerms, seg.text(), avgdl)))
.filter(e -> e.getValue() > 0)
.sorted(Map.Entry.comparingByValue().reversed())
.limit(topK)
.map(e -> Content.from(e.getKey()))
.collect(Collectors.toList());
}

private double bm25Score(String[] queryTerms, String docText, double avgdl) {
String[] docTerms = tokenize(docText);
Map<String, Long> tf = Arrays.stream(docTerms)
.collect(Collectors.groupingBy(t -> t, Collectors.counting()));
double score = 0.0;
int N = corpus.size();
for (String term : queryTerms) {
long df = corpus.stream()
.filter(s -> s.text().toLowerCase().contains(term))
.count();
if (df == 0) continue;
// IDF:文档频率越低,辨别力越强
double idf = Math.log((N - df + 0.5) / (df + 0.5) + 1);
double termFreq = tf.getOrDefault(term, 0L);
double numerator = termFreq * (K1 + 1);
double denominator = termFreq + K1 * (1 - B + B * (docTerms.length / avgdl));
score += idf * (numerator / denominator);
}
return score;
}

/** 简单分词(空格/标点);中文场景请替换为 jieba / HanLP / IK */
private String[] tokenize(String text) {
return text.toLowerCase().split("[\\s\\p{Punct}]+");
}
}

4.3 核心组件二:RRF 融合检索器

设计说明:支持任意数量的检索器融合,不依赖原始分数,只依赖排名。

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
31
32
33
34
35
36
37
public class RrfFusionContentRetriever implements ContentRetriever {
private final List<ContentRetriever> retrievers;
private final int rrfK; // 平滑常数,业界标准值 60

public RrfFusionContentRetriever(List<ContentRetriever> retrievers, int rrfK) {
this.retrievers = retrievers;
this.rrfK = rrfK;
}

@Override
public List<Content> retrieve(Query query) {
// 1. 依次调用各路检索器
List<List<Content>> allResults = retrievers.stream()
.map(r -> r.retrieve(query))
.collect(Collectors.toList());

// 2. 以文本内容为 Key,累加 RRF 得分
Map<String, Double> rrfScores = new LinkedHashMap<>();
Map<String, Content> contentMap = new LinkedHashMap<>();

for (List<Content> results : allResults) {
for (int rank = 0; rank < results.size(); rank++) {
Content content = results.get(rank);
String key = content.textSegment().text();
double score = 1.0 / (rrfK + rank + 1); // rank 从 0 开始
rrfScores.merge(key, score, Double::sum);
contentMap.putIfAbsent(key, content);
}
}

// 3. 按 RRF 得分降序返回融合结果(已去重)
return rrfScores.entrySet().stream()
.sorted(Map.Entry.comparingByValue().reversed())
.map(e -> contentMap.get(e.getKey()))
.collect(Collectors.toList());
}
}

4.4 Spring 配置:组装混合检索器

版本说明:LangChain4j 0.x 提供内置的 HybridRetriever,1.x 已移除,需手动组合。

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
@Configuration
public class HybridRetrieverConfig {
private static final int BM25_TOP_K = 20;
private static final int VECTOR_TOP_K = 20;
private static final int RRF_K = 60;

@Bean
public ChatModel chatModel() {
return OpenAiChatModel.builder()
.apiKey(apiKey)
.build();
}

@Bean
public EmbeddingModel embeddingModel() {
return new AllMiniLmL6V2EmbeddingModel();
}

@Bean
public EmbeddingStore<TextSegment> embeddingStore() {
return new InMemoryEmbeddingStore<>();
}

@Bean
public ContentRetriever hybridContentRetriever(
EmbeddingModel embeddingModel,
EmbeddingStore<TextSegment> embeddingStore) {

// 向量语义检索器
ContentRetriever vectorRetriever = EmbeddingStoreContentRetriever.builder()
.embeddingStore(embeddingStore)
.embeddingModel(embeddingModel)
.maxResults(VECTOR_TOP_K)
.build();

// BM25 关键词检索器(文档入库时需同步调用 bm25Retriever.index())
BM25ContentRetriever bm25Retriever = new BM25ContentRetriever(BM25_TOP_K);

// RRF 融合
return new RrfFusionContentRetriever(
List.of(bm25Retriever, vectorRetriever),
RRF_K
);
}
}

4.5 知识库服务与 REST API

AI Service 接口

1
2
3
4
5
6
7
8
public interface KnowledgeAssistant {
@SystemMessage("""
你是一个专业的知识库问答助手。
请严格根据提供的上下文内容来回答用户问题。
如果上下文中没有相关信息,请直接告知用户,不要编造答案。
""")
String answer(String userQuestion);
}

Service 层

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Service
public class KnowledgeBaseService {
private final KnowledgeAssistant assistant;

public KnowledgeBaseService(
ChatModel chatModel,
ContentRetriever hybridContentRetriever) {
this.assistant = AiServices.builder(KnowledgeAssistant.class)
.chatModel(chatModel)
.contentRetriever(hybridContentRetriever)
.build();
}

public String ask(String userQuestion) {
return assistant.answer(userQuestion);
}
}

REST Controller

1
2
3
4
5
6
7
8
9
10
11
12
13
@RestController
@RequestMapping("/api/knowledge")
public class KnowledgeController {
private final KnowledgeBaseService knowledgeBaseService;

@PostMapping("/ask")
public AnswerResponse ask(@RequestBody QuestionRequest request) {
return new AnswerResponse(knowledgeBaseService.ask(request.question()));
}

record QuestionRequest(String question) {}
record AnswerResponse(String answer) {}
}

第 5 章:调参与最佳实践

5.1 参数设置指南

参数 作用 过小的问题 过大的问题 推荐起点
bm25TopK BM25 侧召回候选数 漏掉关键词相关文档 噪声增加,重排负担重 20
vectorTopK 向量侧召回候选数 漏掉语义相关文档 同上 20
rrfK RRF 平滑常数 结果过度集中于两路都排名 #1 的文档 低排名噪声文档得分变高 60(业界标准)

经验法则bm25TopKvectorTopK 建议设为最终返回文档数的 4~5 倍。例如最终想给大模型 5 条上下文,两路各召回 20 条,经 RRF 融合后取前 5,保证候选池足够大。

5.2 常见错误与排查

错误做法:直接拼接,不做融合与去重

1
2
3
4
// 两路各 20 条,合并后 40 条全塞给大模型
List<Content> combined = new ArrayList<>();
combined.addAll(bm25Results);
combined.addAll(vectorResults); // 可能包含大量重复文档,且未按质量排序

正确做法:通过 hybridContentRetriever 统一调用

1
2
3
// 内部完成两路召回 + RRF 融合 + 去重
List<Content> results = hybridContentRetriever.retrieve(Query.from(userQuery));
// results 已按 RRF 分数降序排列,无重复文档

问题排查表

问题现象 可能原因 解决方案
专业术语始终召回不到 BM25 分词器不支持中文术语 接入 IK 分词器或使用 jieba 预处理
召回结果全是语义相关但不精确 bm25TopK 过低或 BM25 索引未初始化 检查 .index() 是否调用,适当增大 bm25TopK
两路结果高度重叠,混合意义不大 知识库内容同质化,或向量模型欠训练 评估语料多样性,换用更强的 Embedding 模型
RRF 后结果与预期偏差大 rrfK 设置不当 在 20~80 之间调参,较小的 $k$ 让前排文档优势更大

5.3 重要提醒

混合检索 ≠ 精排。生产环境建议在混合检索之后再接一层重排序(Reranker) ,使用 Cross-Encoder 模型对召回的 Top-20 候选进行精细打分,最终取 Top-5 送入大模型。


第 6 章:三种检索策略横向对比

对比维度 纯向量检索 纯 BM25 检索 混合检索(推荐)
语义泛化能力 ⭐⭐⭐⭐⭐ ⭐⭐ ⭐⭐⭐⭐⭐
专有名词精确匹配 ⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐
系统复杂度 ⭐⭐ ⭐⭐ ⭐⭐⭐(适中)
索引维护成本 向量库 倒排索引 两者均需
2026 年生产推荐 不建议单独使用 不建议单独使用 强烈推荐

何时必须用混合检索?

  • √ 知识库中有产品型号、订单编号、API 名称等精确标识符
  • √ 用户会搜索人名、地名、特定版本号
  • √ 知识库文档来源多样,语义分布广
  • × 如果知识库非常小(< 100 条) 且查询均为语义问题,纯向量检索已足够

混合检索架构全局视图


第 7 章:总结

核心概念 一句话解释
BM25 基于词频(TF)和逆文档频率(IDF)的关键词精确匹配算法,擅长专有名词召回
向量检索 将文本转为 Embedding 向量后计算语义相似度,擅长语义泛化
混合检索 同时运行 BM25 和向量检索两路召回,各取所长
RRF 算法 不依赖原始分数,仅通过各榜单排名的倒数加权来公平融合多路结果
rrfK=60 RRF 平滑常数的业界标准值,平衡顶部排名优势与尾部文档贡献

学习路径

  1. 先用纯向量检索搭起 RAG 基础框架,体验其优缺点
  2. 引入 BM25,对比两者在专业术语场景下的召回差异
  3. 接入混合检索 + RRF,观察综合召回率的提升
  4. 在混合检索之后再接 Reranker 重排模型,追求极致的答案质量

原文出处小杨技术笔记

打赏
  • 版权声明: 本博客所有文章除特别声明外,著作权归作者所有。转载请注明出处!

扫一扫,分享到微信

微信分享二维码
  • Copyrights © 2015-2026 Immanuel
  • 访问人数: | 浏览次数:

请我喝杯咖啡吧~

微信