KnowFlow ②:ES 混合检索(KNN 向量 + BM25 关键词 + RRF 重排序)面试深挖

KnowFlow ②:ES 混合检索(KNN 向量 + BM25 关键词 + RRF 重排序)

项目:KnowFlow 企业级 RAG 知识库系统 | 整理时间:2026-06-07 | 适用于后端实习/校招面试


一、先搞懂:为什么单纯的关键词搜索不够用?

1.1 关键词搜索的死穴

假设你公司有个技术文档库,里面有一句话:

“MySQL 主从复制延迟过高的排查思路”

用户搜索:“MySQL 同步慢怎么查?”

BM25(关键词搜索)的结果:

1
2
3
4
5
6
7
8
"MySQL 同步慢怎么查?"

分词:["MySQL", "同步", "慢", "怎么", "查"]

去文档里找包含这些词的地方

"主从复制延迟过高" 里没有"同步慢"这个词 → 匹配不上 ❌
"排查思路" 里没有"怎么查" → 匹配不上 ❌

这就是语义鸿沟——用户说的词和文档里的词不一样,但意思是一样的,传统搜索找不到。

1.2 向量搜索怎么解决这个问题?

把每个文本块都变成一个 2048 维的向量(可以理解为一组 2048 个数字),意思相近的文本,它们的向量在空间里距离就很近。

1
2
3
4
"MySQL 同步慢怎么查?"  → 向量 A: [0.12, -0.34, ..., 0.78]  (2048维)
"MySQL 主从复制延迟过高的排查思路" → 向量 B: [0.11, -0.33, ..., 0.76] (2048维)

计算余弦相似度:A·B ≈ 0.92(非常接近!)→ 匹配上了 ✅

1.3 那为什么还要 BM25?(面试必问)

向量搜索也有死角:

场景 向量搜索 BM25 关键词搜索
“MySQL 8.0 新特性”(精确关键词) 可能把”PostgreSQL 14 新特性”也召回了,不够精确 ✅ 精准匹配”MySQL 8.0”,准确率极高
“怎么配置主从复制”(语义相似) ✅ 能理解语义,召回全面 ❌ 必须出现”配置””主从复制”这些词才行
企业专有名词(”XX 系统””YY 平台”) 训练数据里没有这些词,向量质量差 ✅ 精确匹配,不依赖训练数据

结论:两个都要,取长补短。


二、KnowFlow 的混合检索架构(核心设计)

2.1 整体流程图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
用户输入:"MySQL 同步慢怎么查?"

├─ 第一步:生成查询向量(调用 Embedding API
│ ↓
[0.12, -0.34, ..., 0.78] (2048)

├─ 第二步:KNN 向量召回(ESknn 查询)
│ 从 ES 里找向量最相似的 Top-K 个文本块
│ (K = topK × 30,放大召回窗口,比如要 5 个结果就先召回 150 个)

├─ 第三步:BM25 关键词重排序(ESrescore
│ 对 KNN 召回的 150 个结果,用 BM25 算关键词相关度
│ 把向量相似但关键词不匹配的往后排

├─ 第四步:RRF 融合打分
│ 综合向量分数和 BM25 分数,算出最终排序

└─ 第五步:权限过滤(不能让用户搜到别人的私有文档!)
只返回 userId 匹配 / isPublic=true / orgTag 匹配 的结果

2.2 ES 索引结构(面试可能问)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// knowledge_base 索引的 mapping(简化版)
{
"mappings": {
"properties": {
"textContent": { "type": "text", "analyzer": "ik_max_word" }, // 原文内容,用于 BM25
"vector": { "type": "dense_vector", "dims": 2048 }, // 向量,用于 KNN
"fileMd5": { "type": "keyword" }, // 所属文件
"chunkId": { "type": "keyword" }, // 文本块 ID
"userId": { "type": "keyword" }, // 上传者 ID
"orgTag": { "type": "keyword" }, // 组织标签
"public": { "type": "boolean" } // 是否公开
}
}
}

💡 为什么 textContentvector 存在同一个索引里?
答:这样 KNN 召回和 BM25 重排序可以在一次查询里完成,不用先向量搜一次、再关键词搜一次、再合并结果,省一次网络往返,延迟更低。


三、源码深度解析:HybridSearchService.java

3.1 KNN 向量召回 + 权限过滤

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
SearchResponse<EsDocument> response = esClient.search(s -> {
s.index("knowledge_base");

// ★ 核心:KNN 向量召回
s.knn(kn -> kn
.field("vector") // 对 vector 字段做最近邻搜索
.queryVector(queryVector) // 用户输入生成的查询向量
.k(recallK) // 召回窗口:topK × 30
.numCandidates(recallK) // 候选数,ES 内部用 HNSW 算法加速
);

// ★ 权限过滤:只能搜到有权限的文档
s.query(q -> q.bool(b -> b
.must(mst -> mst.match(m -> m
.field("textContent")
.query(query))) // 必须包含关键词(粗筛)
.filter(f -> f.bool(bf -> bf
// 三个条件满足任意一个即可(should = OR)
.should(s1 -> s1.term(t -> t.field("userId").value(userDbId))) // 自己的文档
.should(s2 -> s2.term(t -> t.field("public").value(true))) // 公开文档
.should(s3 -> { // 同组织的文档
if (userEffectiveTags.size() == 1) {
return s3.term(t -> t.field("orgTag").value(userEffectiveTags.get(0)));
} else {
// 多个组织标签,全部 should
userEffectiveTags.forEach(tag ->
bf.should(sh -> sh.term(t -> t.field("orgTag").value(tag))));
return bf;
}
})
))
));
s.size(topK);
return s;
}, EsDocument.class);

3.2 RRF(倒数秩融合)是什么鬼?

先说说为什么需要 RRF:

KNN 给每个结果打一个分数(向量相似度,范围 0~1),BM25 也给每个结果打一个分数(关键词匹配度,范围不确定)。这两个分数的量级不一样,不能直接相加。

RRF 的思路非常巧妙:不看具体分数,只看排名。

1
2
3
4
5
6
RRF 公式:
score = 1/(k + rank_kNN) + 1/(k + rank_BM25)

其中 k 是一个常数(通常取 60),防止排名第一的分数太大
rank_kNN = 这个结果在 KNN 召回中的排名(第 1 名=1,第 2 名=2...)
rank_BM25 = 这个结果在 BM25 排序中的排名

举个例子:

1
2
3
4
5
6
7
8
9
结果 X 在:
KNN 排名 = 第 3 名 → 1/(60+3) = 0.0159
BM25 排名 = 第 1 名 → 1/(60+1) = 0.0164
RRF 总分 = 0.0323 ← 两个排名都靠前,最终排名高!

结果 Y 在:
KNN 排名 = 第 1 名 → 1/(60+1) = 0.0164
BM25 排名 = 第 50 名 → 1/(60+50) = 0.0091
RRF 总分 = 0.0255 ← KNN 靠前但 BM25 不认可,最终排名被拉低

ES 里不需要自己实现 RRF,用 rescore 就能组合 KNN 分数和 BM25 分数:

1
2
3
4
5
6
7
8
9
10
11
12
13
// ES Java API 中的 rescore(倒数秩融合的近似实现)
s.rescore(r -> r
.windowSize(recallK) // 对召回的 150 个结果重新打分
.query(rq -> rq
.queryWeight(0.2d) // 保留 20% 的 KNN 分数
.rescoreQueryWeight(1.0d) // BM25 分数占 100%(主导)
.query(rqq -> rqq.match(m -> m
.field("textContent")
.query(query)
.operator(Operator.And) // 关键词取 AND,更严格
))
)
);

💡 面试追问:”queryWeight 和 rescoreQueryWeight 为什么要这样设?”
答:向量召回负责”找相关的”,BM25 负责”确认关键词匹配程度”。企业专有词汇在向量空间里可能训练不充分,所以 BM25 权重设高一点(1.0),向量分数作为辅助信号(0.2),这样能保证精确匹配不被语义相似但关键词不匹配的结果挤掉。


四、权限过滤的多租户设计(KnowFlow 的核心业务)

4.1 三种权限级别

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
用户能搜到哪些文档?

┌─ 自己上传的文档 ──────────────────────┐
WHERE userId = 当前用户数据库 ID │
└────────────────────────────────────────┘
OR
┌─ 标记为公开的文档 ────────────────────┐
WHERE isPublic = true
└────────────────────────────────────────┘
OR
┌─ 同组织的文档(含父级组织!)─────────┐
│ WHERE orgTag IN (用户所有有效标签)
│ 比如用户在 "技术部-后端组"
│ 他能看到:"技术部""后端组" 的文档│
└────────────────────────────────────────┘

4.2 组织标签的层级关系(源码解析)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// OrgTagCacheService.getUserEffectiveOrgTags()
// 递归收集用户所有有效标签(含父级)

Set<String> allEffectiveTags = new HashSet<>();

// 用户直接拥有的标签
allEffectiveTags.add("技术部-后端组");

// 递归找父标签
collectParentTags("技术部-后端组", allEffectiveTags);
// → 找到父标签 "技术部",加入
// → 再找 "技术部" 的父标签,如果有的话继续加入

// 最后 allEffectiveTags = ["技术部-后端组", "技术部", "DEFAULT"]
// DEFAULT 是内置的默认标签,所有人都有

ES 查询里怎么用:

1
2
3
4
5
6
7
8
// 用户的有效标签列表:["技术部-后端组", "技术部", "DEFAULT"]
// ES 查询:orgTag 命中任意一个就行
.should(s3 -> {
userEffectiveTags.forEach(tag ->
bf.should(sh -> sh.term(t -> t.field("orgTag").value(tag)))
);
return bf;
})

4.3 为什么权限过滤要放在 filter 里而不是 must 里?

filter 上下文不计算相关度分数,只做 true/false 判断,可以利用 ES 的缓存机制加速。权限过滤和条件过滤不需要参与分数计算,所以用 filter 而不是 must,性能更好。


五、Embedding 失败时的降级策略(系统的鲁棒性)

5.1 为什么向量生成会失败?

1
2
3
4
5
6
7
8
9
调用大模型 Embedding API

可能的原因:
- 网络超时(大模型服务不稳定)
- API Key 配额用完了
- 文本太长超过 Token 限制
- 大模型服务正在发版/维护

向量生成失败 → queryVector = null

5.2 降级方案:纯 BM25 搜索

1
2
3
4
5
6
7
8
// HybridSearchService.searchWithPermission()
List<Float> queryVector = embedToVectorList(query);

if (queryVector == null) {
// 向量生成失败!降级到纯关键词搜索
log.warn("向量生成失败,仅使用文本匹配进行搜索");
return textOnlySearchWithPermission(query, userDbId, userEffectiveTags, topK);
}

💡 面试亮点:这是一个很实际的工程设计。大模型依赖的服务不一定 100% 可靠,系统要有降级能力。纯 BM25 虽然召回质量低一些,但至少能用,总比给用户报 500 错误好。


六、ES 复合索引设计(面试八股文)

6.1 为什么要建复合索引?

1
2
3
4
5
6
7
-- 权限过滤是最常见的查询条件,不加索引会全表扫描!
WHERE userId = 'user123' OR isPublic = true OR orgTag IN ('tag1','tag2')

-- 复合索引设计
CREATE INDEX idx_permission ON knowledge_base (userId, orgTag, isPublic);
-- ES 里不需要手动建,keyword 类型默认可索引
-- 但要注意:text 类型不能用于 filter,必须用 keyword 类型

ES 里的注意事项:

字段 类型 能不能用于 term 过滤 原因
userId keyword keyword 类型是精确值,可用于 term 查询
orgTag keyword 同上
public boolean boolean 类型可精确匹配
textContent text text 类型会被分词,不能用 term 精确匹配

💡 面试追问:”如果要支持 textContent 的关键词精确过滤怎么办?”
答:用 multi-field,给 textContent 同时建 text 类型和 keyword 类型:"fields": {"keyword": {"type": "keyword"}},然后过滤用 textContent.keyword


七、面试八股文高频题

Q1:ES 的 KNN 搜索底层是什么算法?

:ES 8.x 的 dense_vector 字段使用 HNSW(Hierarchical Navigable Small World) 算法,是一种基于图的近似最近邻搜索算法。查询复杂度约 O(log N),比暴力搜索 O(N) 快很多。牺牲少量精度(recall 约 95~99%)换取大幅速度提升。

Q2:为什么 KNN 的 k 要设成 topK × 30?

:KNN 是近似搜索,会有一些误召回或漏召回。把召回窗口放大(比如要 5 个最终结果,先召回 150 个),然后在这 150 个里面用 BM25 精确重排序,这样最终Top-K 的质量更高。这叫 “召回+重排”两阶段检索,是搜索系统的标准做法。

Q3:RRF 和直接加权求和(score_kNN + score_BM25)比,好在哪?

:直接加权求和要求两个分数在相同的数值范围内(比如都是 0~1),但 KNN 距离分数和 BM25 分数的分布完全不同,直接相加需要手动做归一化,很麻烦而且容易调不好。RRF 基于排名,不需要归一化,更鲁棒,这也是 TREC 比赛里证明过的方法。

Q4:如果文档库里有 1 亿条数据,KNN 搜索会变慢吗?

:HNSW 的查询复杂度是 O(log N),1 亿条也只会访问图中很少的节点,速度影响不大。但索引会占用较多内存(向量要全部加载到内存里才能快)。解决思路:如果向量维度高(2048 维),可以考虑用 PQ(Product Quantization)压缩,或者升级到 ES 的 int8_hnsw 量化索引,内存占用降到 1/4。

Q5:多租户场景下,怎么防止 A 租户通过精心构造的查询”撞库”到 B 租户的数据?

:三层防护:

  1. 应用层:所有搜索接口先从 JWT Token 里取出 userId 和 orgTags,作为参数传入 ES 查询的 filter 里,用户无法伪造;
  2. ES 层:用 filter 做权限过滤,不匹配的文档直接不返回,不是靠分数压低,而是根本不出现在结果集里;
  3. 组织标签层级:即使 A 用户知道 B 用户的文档 ID,只要 orgTag 不匹配,就搜不到。

八、简历上怎么讲这句话(可直接用)

原句参考:”为解决单一语义检索在企业专有词汇场景下召回率不足的痛点,设计基于 Elasticsearch 的混合检索架构:结合大模型 Embedding(2048 维)实现 KNN 向量召回,配合 BM25 关键词匹配,最终通过 RRF(倒数秩融合)算法对多路召回进行重排序,显著提升私有知识库的检索准确度。”

面试时被问到,按这个顺序讲:

  1. 先说痛点:纯向量检索在企业场景不行,专有名词向量模型没见过,召回不准;
  2. 说方案:KNN 向量召回(负责语义相似)+ BM25 关键词匹配(负责精确匹配),两路结果用 RRF 融合;
  3. 说细节:KNN 的 k 放大 30 倍再重排,防止近似搜索的误差;权限过滤放 filter 上下文利用缓存;
  4. 说降级:Embedding 服务挂了自动降级到纯 BM25,系统不挂。

© 2026 KnowFlow 面试手册 · 转载请注明出处


KnowFlow ②:ES 混合检索(KNN 向量 + BM25 关键词 + RRF 重排序)面试深挖
https://whyalwaysme.lol/2026/06/07/2026-06-07-knowflow-hybrid-search/
作者
Cassiur
发布于
2026年6月7日
许可协议