KnowFlow ② 八股文:Elasticsearch 混合检索(KNN + BM25 + RRF)

KnowFlow ② 八股文:Elasticsearch 混合检索

面向字节跳动 Java 后端暑期实习面试,从项目实现细节出发,每道题都结合代码讲清楚。


一、Elasticsearch 基础(5 道)

Q1:Elasticsearch 是什么?和 MySQL 有什么区别?

答:

Elasticsearch(简称 ES)是一个分布式全文搜索引擎,底层用 Lucene,擅长模糊搜索、全文检索、向量检索

对比项 MySQL(关系型数据库) Elasticsearch(搜索引擎)
擅长 精确查询、事务、JOIN 模糊搜索、全文检索、向量检索
索引结构 B+ 树(范围查询快) 倒排索引(关键词查询快)
模糊搜索 LIKE '%关键词%' 很慢(全表扫描) 天生支持,毫秒级
向量检索 不支持 原生支持 KNN(K 近邻)
事务 支持 ACID 不支持(最终一致性)

项目中为什么用 ES?

  • 用户搜”怎么配置 Redis 线程池”,关键词可能在文档的任何位置
  • MySQL 的 LIKE 很慢,且找不到语义相似的文档
  • ES 的倒排索引 + BM25 算法,毫秒级返回结果
  • ES 的 KNN 向量检索,能找到”意思相近但用词不同”的文档

Q2:倒排索引(Inverted Index)是什么?为什么搜索快?

答:

正排索引(MySQL 的 B+ 树):

1
2
3
4
文档ID → 文档内容
1"Redis 线程池配置方法"
2"怎么设置 Redis 连接池"
3"Redis 性能优化指南"

搜”Redis 线程池”,要逐个文档扫描,很慢。

倒排索引(ES 的核心):

1
2
3
4
5
关键词 → 出现的文档ID 列表
"Redis" → [1, 2, 3]
"线程池" → [1]
"配置" → [1]
"连接池" → [2]

搜”Redis 线程池”,先找到”Redis”对应的文档 [1,2,3],再找到”线程池”对应的文档 [1],取交集 → 文档 1。

为什么快?

  • 关键词 → 文档列表,是哈希查找,O(1)
  • 多个关键词取交集/并集,用位图(BitSet) 操作,极快

面试加分:倒排索引包含两个结构:

  1. Term Dictionary(关键词字典):存储所有关键词,用 FST(有限状态转换器)压缩存储
  2. Posting List(倒排列表):每个关键词对应的文档 ID 列表,用 Roaring Bitmap 压缩存储

Q3:BM25 算法是什么?和 TF-IDF 有什么区别?

答:

BM25 是 ES 默认的相关性评分算法,用来计算一个关键词在一个文档中有多重要

TF-IDF 的问题

1
2
3
4
5
TF(词频):一个词在文档中出现越多,越重要。
问题:某些词("的""是")出现很多,但不重要。

IDF(逆文档频率):一个词在越多文档中出现,越不重要。
问题:没考虑文档长度。长文档天然有更高词频。

BM25 的改进

1
2
3
4
5
6
7
8
9
1. TF 饱和:词频增加到一定程度,贡献不再线性增长
(避免某个词重复 100 次就得分极高)

2. 文档长度归一化:
长文档 ÷ 1.2,短文档 ÷ 0.8
(避免长文档因为词多而得分高)

3. 可调参数 k1 和 b:
k1=1.2(TF 饱和点),b=0.75(文档长度影响程度)

项目中的使用HybridSearchService.java):

1
2
3
4
5
6
7
// BM25 关键词匹配
s.query(q -> q.bool(b -> b
.must(m -> m.match(m -> m
.field("textContent")
.query(query)
))
))

面试回答话术

“BM25 是 ES 默认的相关性算法,解决了 TF-IDF 的两个问题:一是 TF 不会无限增长(饱和函数),二是考虑了文档长度归一化。我们项目中用 BM25 做关键词匹配,配合 KNN 向量检索,再用 RRF 融合两份结果。”


Q4:ES 的 Mapping 是什么?项目中怎么设计的?

答:

Mapping 是 ES 的表结构定义,类似 MySQL 的 CREATE TABLE

项目中的 ES MappingEsDocument.java 实体类 + EsIndexInitializer.java):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Document(indexName = "knowledge_base")
public class EsDocument {
@Id
private String id; // ES 文档 ID(通常用 chunkId)

@Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_smart")
private String textContent; // 文档内容,用 IK 分词器

@Field(type = FieldType.DenseVector, dims = 2048) // 2048 维向量
private float[] vector; // Embedding 向量

@Field(type = FieldType.Keyword) // 不分词,精确匹配
private String fileMd5; // 所属文件 MD5

@Field(type = FieldType.Keyword)
private String userId; // 上传用户 ID

@Field(type = FieldType.Boolean)
private Boolean isPublic; // 是否公开

@Field(type = FieldType.Keyword)
private String orgTag; // 组织标签(权限控制)
}

关键设计点

  1. textContentik_max_word 分词:中文要分词,”技术派项目” → [“技术”, “派”, “项目”],不然搜”技术”找不到
  2. vectorDenseVector 类型:专门存向量,支持 KNN 检索
  3. fileMd5userIdKeyword 类型:精确匹配,用于权限过滤

面试加分:为什么 textContent 的索引分词器和查询分词器不同?

  • ik_max_word:索引时最细粒度分词,尽量多分出词,提高召回率
  • ik_smart:查询时最粗粒度分词,分出最合理的词,提高准确率

Q5:ES 的 KNN 向量检索是什么?和 BM25 有什么区别?

答:

BM25(关键词匹配):

  • 基于词频统计,找包含查询词最多的文档
  • 问题:语义理解为零。搜”怎么配置 Redis”,找不到”Redis 线程池设置方法”(用词不同但意思一样)

KNN(K 近邻向量检索):

  • 把文档和查询都转成向量(2048 维浮点数)
  • 计算向量距离(余弦相似度或欧氏距离),距离越近越相关
  • 优势:语义理解。上面两个查询的向量很接近,能找到

项目中的 KNN 使用HybridSearchService.java 第 91~100 行):

1
2
3
4
5
6
7
8
9
10
// 生成查询向量
List<Float> queryVector = embedToVectorList(query);

// KNN 召回
s.knn(kn -> kn
.field("vector") // 在 vector 字段上做 KNN
.queryVector(queryVector) // 查询向量
.k(recallK) // 召回 K 个近邻
.numCandidates(recallK) // 候选集大小(越大越准,越慢)
);

面试加分:KNN 有两种实现:

  1. 暴力搜索(Brute Force):算查询向量和所有文档向量的距离,O(N),准确但慢
  2. HNSW(Hierarchical Navigable Small World):用图结构加速,O(log N),ES 8.x 默认用这个

二、混合检索架构(6 道)

Q6:什么是混合检索?为什么只用向量检索不够?

答:

只用向量检索的问题

1
2
3
4
5
6
7
用户查询:"Redis 线程池配置"
向量检索可能返回:
- "如何优化 Redis 性能"(语义相近,但没提配置)
- "Redis 连接池参数调优"(语义相近)

问题:没有强制要求包含"线程池""配置"关键词,
可能返回很多语义相关但答非所问的文档。

混合检索 = 向量检索 + 关键词检索,两份结果融合:

1
2
3
4
5
向量检索:找语义相近的文档(召回率高)
+
关键词检索(BM25):找包含关键词的文档(准确率高)

RRF 融合:综合两份结果,取 Top K

项目中的混合检索HybridSearchService.java):

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
// 第一步:KNN 向量召回(粗排)
s.knn(kn -> kn
.field("vector")
.queryVector(queryVector)
.k(topK * 30) // 召回窗口放大 30 倍
.numCandidates(topK * 30)
);

// 第二步:必须包含关键词(精准过滤)
s.query(q -> q.bool(b -> b
.must(m -> m.match(m -> m
.field("textContent")
.query(query)
))
))

// 第三步:RRF 重排序(精排)
s.rescore(r -> r
.windowSize(recallK)
.query(rq -> rq
.queryWeight(0.2d) // KNN 分占 20%
.rescoreQueryWeight(1.0d) // BM25 分占 80%
.query(rqq -> rqq.match(m -> m
.field("textContent")
.query(query)
))
)
);

Q7:RRF(倒数秩融合)算法是什么?公式是什么?

答:

RRF(Reciprocal Rank Fusion)是一种多路召回结果融合的算法。

核心思想:一个文档在多个召回列表中排名越靠前,最终得分越高。

公式

1
2
3
4
5
6
RRF 得分 = Σ (1 / (k + rank_i))

其中:
- rank_i:文档在第 i 个召回列表中的排名(从 1 开始)
- k:常数,通常取 60(ES 默认)
- Σ:对所有召回列表求和

举例

1
2
3
4
5
6
7
8
9
10
11
文档 A:
- 向量检索排名:第 3 名 → 得分 = 1/(60+3) = 0.0159
- BM25 排名:第 1 名 → 得分 = 1/(60+1) = 0.0164
→ RRF 总分 = 0.0323

文档 B:
- 向量检索排名:第 1 名 → 得分 = 1/(60+1) = 0.0164
- BM25 排名:第 10 名 → 得分 = 1/(60+10) = 0.0143
→ RRF 总分 = 0.0307

最终排名:文档 A > 文档 B

为什么比单纯”得分相加”好?

  • 防止某个检索方式刷分(比如向量得分普遍很高)
  • 排名比得分更稳定(不同检索方式的得分尺度不同)

Q8:项目中 KNN 召回窗口为什么设为 topK * 30?

答:

原因:KNN 是粗排,BM25 + RRF 是精排。如果 KNN 只召回 topK 个,可能漏掉 BM25 认为很重要但向量距离稍远的文档。

1
2
3
4
5
6
7
8
9
10
topK = 10(最终返回 10 个结果)
KNN 召回窗口 = 10 × 30 = 300

先召回 300 个向量近邻

用 BM25 给这 300 个打分

RRF 融合向量分 + BM25 分

取最终 Top 10

如果不放大召回窗口会怎样?

  • KNN 只召回 10 个 → BM25 只能在 10 个里挑 → 可能漏掉好文档
  • 放大到 300 个 → BM25 有更多选择 → 最终结果更准

代价:召回 300 个比 10 个慢。项目中 2048 维向量,HNSW 索引,300 个仍然在 50ms 内完成,可以接受。


Q9:混合检索的权限过滤是怎么实现的?

答:

企业知识库有多租户权限隔离:用户只能搜到自己有权限的文档。

权限规则

  1. 用户自己上传的文档 → 能搜到
  2. 标记为”公开”的文档 → 能搜到
  3. 同组织的文档 → 能搜到(通过 orgTag 判断)
  4. 其他用户的私有文档 → 搜不到

项目中的实现HybridSearchService.java 第 102~123 行):

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
s.query(q -> q.bool(b -> b
// 必须命中关键词
.must(m -> m.match(m -> m
.field("textContent")
.query(query)
))
// 权限过滤(三个条件满足一个即可)
.filter(f -> f.bool(bf -> bf
.should(s1 -> s1.term(t -> t
.field("userId")
.value(userDbId) // 条件1:自己的文档
))
.should(s2 -> s2.term(t -> t
.field("isPublic")
.value(true) // 条件2:公开的文档
))
.should(s3 -> {
// 条件3:同组织的文档
if (userEffectiveTags.isEmpty()) {
return s3.matchNone(mn -> mn);
} else if (userEffectiveTags.size() == 1) {
return s3.term(t -> t
.field("orgTag")
.value(userEffectiveTags.get(0)));
} else {
// 多个组织标签,用 should 组合
return s3.bool(inner -> {
userEffectiveTags.forEach(tag ->
inner.should(sh -> sh.term(t -> t
.field("orgTag")
.value(tag))));
return inner;
});
}
})
))
))

面试加分:为什么权限过滤要放在 filter 里,不放在 must 里?

  • filter 不计算相关性得分,只做是/否过滤,速度快
  • filter 的结果会被 ES 缓存(Filter Cache),相同权限的查询直接命中缓存

Q10:如果向量化失败了,项目有什么兜底方案?

答:

向量化(调用 Embedding API)可能失败的原因:

  • LLM 服务超时
  • 网络抖动
  • API 配额用完

项目中的兜底方案HybridSearchService.java 第 84~87 行):

1
2
3
4
5
6
7
8
// 生成查询向量
List<Float> queryVector = embedToVectorList(query);

// 如果向量生成失败,仅使用文本匹配
if (queryVector == null) {
logger.warn("向量生成失败,仅使用文本匹配进行搜索");
return textOnlySearchWithPermission(query, userDbId, userEffectiveTags, topK);
}

textOnlySearchWithPermission 方法(只用 BM25,不用向量):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private List<SearchResult> textOnlySearchWithPermission(
String query, String userDbId,
List<String> userEffectiveTags, int topK) {
SearchResponse<EsDocument> response = esClient.search(s -> s
.index("knowledge_base")
.query(q -> q.bool(b -> b
.must(m -> m.match(m -> m
.field("textContent")
.query(query)
))
.filter(/* 权限过滤,同上 */)
))
.minScore(0.3d) // 最低相关性得分,过滤掉完全不相关的
.size(topK),
EsDocument.class
);
// 解析结果...
}

面试回答话术

“向量化失败不影响核心功能。我们会 fallback 到纯文本检索(BM25),虽然语义理解能力弱一些,但关键词匹配仍然能返回相关文档。这在生产环境是很重要的降级策略。”


Q11:ES 的 min_score 是什么?为什么要用?

答:

min_score 是 ES 的最低相关性得分阈值,得分低于这个阈值的文档直接被过滤掉,不会返回。

为什么需要?

1
2
3
4
5
用户查询:"Redis 线程池配置"

如果不用 min_score,可能返回:
- "MySQL 索引优化指南"(完全不相关,但"配置"一词碰巧命中)
- 得分很低(0.05),但仍然是"命中"

项目中的使用(第 243 行):

1
.minScore(0.3d)  // 最低得分 0.3,低于这个的直接过滤

BM25 的得分范围

  • 完全不相关:~0
  • 有些相关:0.1 ~ 0.5
  • 高度相关:0.5 ~ 数

面试加分min_scorefilter 的区别?

  • filter:是/否过滤,不影响得分
  • min_score:得分阈值过滤,基于 BM25 或 RRF 的最终得分

三、Embedding 与向量化(4 道)

Q12:Embedding 是什么?怎么把文字变成向量?

答:

Embedding(嵌入)是把文字映射到高维向量空间的操作。

1
2
3
4
5
6
7
"Redis 线程池配置" → Embedding API → [0.023, -0.118, 0.045, ..., 0.201]

2048 维向量

"Redis 连接池设置" → Embedding API → [0.025, -0.110, 0.050, ..., 0.198]

和上一句很接近!语义相近 → 向量距离近

项目中调用的是谁的 Embedding API?

  • 代码中是 EmbeddingClient.java,封装了对 LLM 的调用
  • 可能是 DeepSeek、通义千问、或开源 Embedding 模型
  • 输出是 2048 维浮点数向量

为什么是 2048 维?

  • 维度越高,语义表达越精细,但存储和计算开销越大
  • 2048 维是效果和成本的平衡点
  • ES 的 DenseVector 类型支持最多 2048 维(ES 8.x 支持到 4096 维)

Q13:项目中 Embedding 的维度为什么选 2048?可以调吗?

答:

可以调,但要考虑:

维度 语义表达 存储开销(每个向量) 检索速度
384 维 一般 1.5 KB 很快
768 维 3 KB
1536 维 很好 6 KB 中等
2048 维 很好 8 KB 中等偏慢
4096 维 极好 16 KB

项目中的设定EsDocument.java):

1
2
@Field(type = FieldType.DenseVector, dims = 2048)
private float[] vector;

如果要调维度,要做的事

  1. 重新调用 Embedding API(不同维度是不同模型)
  2. 重新生成所有文档的向量(全量更新)
  3. 修改 ES Mapping(dims = 新维度
  4. 重建索引(Reindex)

面试回答话术

“我们选 2048 维是因为效果和成本的平衡点。如果追求极致性能,可以降到 768 维,存储和检索速度都能提升 2 倍,但语义表达能力会有所下降。这需要在实际业务场景中做 AB 测试来决定。”


Q14:Embedding API 调用失败怎么办?有重试吗?

答:

项目中的容错EmbeddingClient.java 应该有,但原代码没完全展示):

典型的实现是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public List<float[]> embed(List<String> texts) {
int retry = 0;
while (retry < 3) {
try {
// 调用 LLM Embedding API
return doEmbed(texts);
} catch (Exception e) {
retry++;
if (retry >= 3) {
logger.error("Embedding 失败,已重试 3 次", e);
return null; // 返回 null,上层会 fallback 到纯文本搜索
}
Thread.sleep(1000 * retry); // 指数退避
}
}
return null;
}

面试加分:为什么不用 Spring Retry 或 Resilience4j?

  • 可以用,但 Embedding API 的失败通常是限流(Rate Limit)超时
  • 需要**指数退避(Exponential Backoff)**重试,这些框架都支持

Q15:向量检索和关键词检索,哪个更适合中文搜索?

答:

中文搜索的痛点

  • 中文不分词,”技术派项目教程”是一整串
  • 要搜到,要么精确匹配每个字(BM25 + IK 分词)
  • 要么语义匹配(向量检索)

项目中用 IK 分词器

1
2
@Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_smart")
private String textContent;

IK 分词效果

1
2
3
"技术派项目教程"
ik_max_word: ["技术", "派", "项目", "教程", "技术派", "项目教程"]
ik_smart: ["技术派", "项目教程"]

向量检索对中文的优势

  • “Redis 线程池” 能匹配 “Redis 连接池配置”
  • 但可能对专有名词理解不准确(”技术派” 可能被拆成 “技术” + “派”)

面试回答话术

“中文搜索我们两者结合用。BM25 负责关键词精确匹配,IK 分词器保证专业术语(如”技术派”)被正确切分;向量检索负责语义相似度匹配。两者通过 RRF 融合,既保证准确率,又提高召回率。”


四、综合设计题(5 道)

Q16:如果 ES 索引里有 1 亿条数据,检索会变慢吗?怎么优化?

答:

会变慢,但 ES 是分布式的,可以优化。

优化手段

1. 增加 Primary Shard 数量

1
2
3
4
5
6
7
8
// 创建索引时设置分片数
esClient.indices().create(c -> c
.index("knowledge_base")
.settings(s -> s
.numberOfShards("12") // 12 个主分片
.numberOfReplicas("1") // 每个主分片 1 个副本
)
)
  • 1 亿条数据,12 个分片,每个分片约 800 万条
  • 查询时,12 个分片并行搜索,结果汇总

2. 用 HNSW 索引加速向量检索

1
2
向量检索如果不建索引:暴力计算每个向量和查询向量的距离 → O(N)1 亿条很慢
建了 HNSW 索引:用图结构加速 → O(log N)1 亿条仍然很快

3. 热数据预热(Warm Up)

  • ES 启动后,索引数据在磁盘,第一次查询要加载到内存(慢)
  • 配置 index.store.preload: ["nvd", "dvd"] 让热数据常驻内存

Q17:如果知识库文档量涨到 1000 万,你的系统还能撑住吗?

答:

V1(当前项目):单 ES 实例 + 单 MinIO 实例

  • 支持:100 万条向量,1000 个文档

V2(水平扩展)

  • ES 集群:3 个节点,12 个主分片,每个分片 1 个副本
  • MinIO 集群:纠删码 N=4, M=2
  • 应用无状态化:3 个实例,Nginx 负载均衡

V3(性能优化)

  • 向量量化(Quantization):把 2048 维 float 量化成 int8,存储和检索速度提升 4 倍
  • 分层检索:热数据(最近 30 天上传的)放内存;冷数据放磁盘
  • 预取(Prefetching):用户输入查询时,前端先发一个前缀请求,后端提前检索,等用户点”搜索”时直接返回

Q18:ES 和 Milvus / Chroma 等专业向量数据库比,有什么优劣?

答:

Elasticsearch Milvus / Chroma(专业向量 DB)
优势 同时支持 BM25 和 KNN,混合检索原生支持 向量检索性能极致(专门优化过)
劣势 向量检索性能不如专业向量 DB 关键词检索能力弱,混合检索要自己实现
适用场景 知识库、搜索引擎(需要混合检索) 纯向量检索(推荐系统、图像检索)
运维 成熟,生态好 相对新颖,踩坑多

面试回答话术

“我们选 ES 是因为需要做混合检索(BM25 + KNN),ES 原生支持这两者,RRF 融合也很方便。如果是纯向量检索场景(比如以图搜图),我会选 Milvus,它的向量检索性能比 ES 好很多。但知识库场景,ES 更合适。”


Q19:如果用户输入一段很长的查询(比如 500 字),你怎么处理?

答:

问题:查询太长,Embedding API 有 token 限制(通常 8192 token),可能超限。

项目中的处理HybridSearchService.java):

1
2
3
4
5
6
7
8
// 截断过长查询
if (query.length() > 1000) {
query = query.substring(0, 1000);
logger.warn("查询过长,已截断到 1000 字符");
}

// 生成查询向量
List<Float> queryVector = embedToVectorList(query);

更好的方案

  1. 查询压缩:用 LLM 把长查询压缩成核心关键词,再做检索
  2. 分段检索:把长查询分成多段,分别检索,结果 RRF 融合
  3. HyDE(Hypothetical Document Embedding):让 LLM 先生成一段”假设答案”,再用这段答案的向量去检索(效果很好!)

Q20:如果让你从零设计知识库系统的检索模块,你会怎么做?

答:

MVP(最小可行产品)

  1. 文档上传 → 解析 → 切块(500 字/块,重叠 50 字)
  2. Embedding → 存入 ES(vector 字段)
  3. 用户查询 → Embedding → KNN 检索 → 返回 Top 5

V2(加入混合检索)

  1. 加入 BM25 关键词检索
  2. RRF 融合两路结果
  3. 权限过滤(只能搜到自己有权限的)

V3(生产级)

  1. 查询改写(LLM 扩写查询,提高召回率)
  2. 重排序模型(Cross-Encoder,对 Top 50 结果精排)
  3. 检索结果缓存(Redis Cache,相同查询 5 分钟内直接返回)
  4. 降级策略(Embedding 失败时 fallback 到纯文本检索)

五、简历话术准备

面试官问:”你在简历里写了混合检索架构,能详细讲一下吗?”

回答模板(背下来!):

“这个问题我从四个方面来讲。

第一,为什么需要混合检索。纯向量检索语义理解好,但可能返回答非所问的文档;纯关键词检索准确率高,但语义理解为零。我们把两者结合,既保证准确率,又提高召回率。

第二,具体怎么实现的。用 ES 的 KNN 做向量召回(粗排),用 BM25 做关键词匹配,再用 RRF(倒数秩融合)算法对两路结果重排序(精排)。KNN 召回窗口设为最终返回数量的 30 倍,避免漏掉好文档。

第三,权限过滤怎么做的。用户只能搜到自己上传的、公开的、或同组织的文档。把这个过滤条件放在 ES 的 filter 子句里,不计算得分,还能利用 Filter Cache 加速。

第四,降级策略。如果 Embedding API 调用失败,系统会自动 fallback 到纯文本检索(BM25),不影响核心功能。这在生产环境是很重要的容错机制。”


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


KnowFlow ② 八股文:Elasticsearch 混合检索(KNN + BM25 + RRF)
https://whyalwaysme.lol/2026/06/08/2026-06-08-knowflow-search-qa/
作者
Cassiur
发布于
2026年6月8日
许可协议