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(业界标准)

经验法则:bm25TopK 和 vectorTopK 建议设为最终返回文档数的 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
  • 访问人数: | 浏览次数:

请我喝杯咖啡吧~

微信