问题:为什么我的向量检索慢?

你接入了 ES 8.x 的 kNN 检索做 RAG,写了第一个版本:

1
2
3
4
5
6
7
8
{
"knn": {
"field": "embedding",
"query_vector": [0.1, 0.2, ...],
"k": 10,
"num_candidates": 100
}
}

跑通。但生产上拿到百万级文档后,开始抖:

  • P99 延迟 800ms,业务方反馈”问答卡顿”
  • 单次查询 CPU 飙到 80%
  • 加机器不解决问题(瓶颈是单 shard 的 ANN 搜索时间)

问题不在于 ES 不行,在于你没调参。 ES 的 HNSW 索引有 4-5 个核心参数,每个都直接影响性能和召回率。这篇文章讲清楚每个参数怎么调。

ES kNN 检索的执行流程

ES 用的也是 HNSW(图算法)。一次 kNN 查询的过程:

1
2
3
4
5
1. 加载 HNSW 索引(如果没缓存)
2. 从顶层稀疏图开始搜
3. 沿着图遍历,找最近的 num_candidates 个候选
4. 在候选中取 top k
5. 返回结果

**关键点:num_candidates 是你”愿意让 ES 搜多少候选”,k 是”最终返回多少”**。num_candidates >= k,且通常大很多(10-100 倍)。

HNSW 算法的两个核心参数:

  • M:每个节点的连接数(索引创建时定)
  • ef_construction:构建时的搜索深度(索引创建时定)
  • ef_search(或 num_candidates):查询时的搜索深度

参数 1:num_candidates(查询时最重要的参数)

1
2
3
4
5
6
7
8
{
"knn": {
"field": "embedding",
"query_vector": [0.1, 0.2, ...],
"k": 10,
"num_candidates": 100
}
}

含义:ES 从 HNSW 图中至少访问 100 个候选节点,再从中选 10 个最近的。

num_candidates 性能 召回率
50 极快 可能漏掉真正相关的(召回 < 0.9)
100 较快 召回通常 > 0.95
500 慢一些 召回 > 0.99
1000 显著慢 几乎 100% 召回

经验值

  • 通用 RAG 场景:num_candidates = max(k * 10, 100)
  • 召回率要求 > 0.99:num_candidates = max(k * 50, 500)
  • 延迟敏感(如实时推荐):num_candidates = max(k * 5, 50),配合 rerank 兜底

调优方法:固定 k=10,从 50 拉到 1000,跑 100 次查询看 latency 和召回率曲线。选曲线拐点附近的值。

参数 2:ef_search(HNSW 内部参数)

ef_search 是 HNSW 算法自己的搜索深度参数,和 num_candidates 含义类似但层级不同:

  • num_candidates 是 ES 暴露给用户的 API 参数
  • ef_search 是底层 HNSW 算法的搜索宽度

ES 8.x 默认 ef_search = num_candidates,但你可以显式覆盖:

1
2
3
4
PUT my-index/_settings
{
"index.knn.algo_param.ef_search": 200
}

一般不需要手动改 ef_search,除非:

  • 你的 num_candidates 已经被 RAG 应用层逻辑锁死
  • 想要全局调整所有查询的 HNSW 搜索宽度

参数 3:M(创建索引时定)

M 是 HNSW 图中每个节点的最大连接数。值越大:

  • 索引越准(召回率高)
  • 索引越大(占用内存多)
  • 索引构建越慢
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
PUT my-index
{
"settings": {
"index.knn": true
},
"mappings": {
"properties": {
"embedding": {
"type": "dense_vector",
"dims": 768,
"index": true,
"similarity": "cosine",
"index_options": {
"type": "hnsw",
"m": 16,
"ef_construction": 100
}
}
}
}
}

经验值:

  • 默认 m=16 适用于大多数场景
  • 召回率要求高:m=32m=48
  • 内存紧张:m=8(牺牲召回率换内存)

注意:M 改不动。改 M 必须 reindex。所以建索引时就要想清楚。

参数 4:ef_construction(创建索引时定)

构建 HNSW 图时的搜索深度。值越大:

  • 索引质量越高(搜索时召回率高)
  • 索引构建越慢
  • 索引文件略大

经验值:

  • ef_construction=100:默认,绝大多数场景够用
  • ef_construction=200:质量要求高、构建时间可接受
  • ef_construction=300+:通常是过度优化

混合检索:BM25 + kNN 怎么融合权重?

RAG 场景下,纯向量检索经常漏掉关键词明确匹配的文档。比如用户问”Redis 的 RDB 持久化机制”,向量能找到语义相关的,但 top 结果可能是”AOF 持久化”。

混合检索(Hybrid Search) 同时跑 BM25 + kNN,融合排序。ES 8.x 的写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
"query": {
"match": {
"content": "Redis RDB 持久化"
}
},
"knn": {
"field": "embedding",
"query_vector": [0.1, 0.2, ...],
"k": 10,
"num_candidates": 100,
"boost": 0.7 // 关键:kNN 分数权重
},
"rank": {
"rrf": { // Reciprocal Rank Fusion
"window_size": 50,
"rank_constant": 60
}
}
}

两种融合方式

1. RRF(Reciprocal Rank Fusion)

ES 8.8+ 默认方式。boost 不再是简单的”加权求和”,而是基于排名的融合:

1
score(d) = sum(1 / (rank_constant + rank_i(d)))
  • 文档在 BM25 中排第 1 → 加 1/61 分
  • 文档在 kNN 中排第 1 → 加 1/61 分
  • 文档同时在两者排前 → 总分高

优点:不需要归一化分数(不同检索系统的分数不可比)
缺点:无法直接控制”更偏 BM25 还是更偏 kNN”

2. 加权求和(手动)

1
2
3
4
5
6
7
8
9
10
{
"query": {
"script_score": {
"query": {"match": {"content": "..."}},
"script": {
"source": "0.3 * _score + 0.7 * cosineSimilarity(params.q, 'embedding')"
}
}
}
}

0.3 是 BM25 权重,0.7 是向量权重。这两个值需要根据业务调

我的经验值

场景 BM25 权重 kNN 权重
内部知识库问答 0.3 0.7
客服 RAG(用户口语化提问) 0.2 0.8
文档精确检索(用户用专业术语) 0.6 0.4
代码搜索 0.7 0.3

调优方法

  1. 准备 50-100 个测试 query,每个 query 标注”相关文档”列表
  2. 用不同权重组合跑 NDCG@10 / MRR@10 指标
  3. 找指标最高的一组

性能调优的 4 个工程实践

1. 用过滤(filter)缩小 kNN 范围

1
2
3
4
5
6
7
8
9
10
11
{
"knn": {
"field": "embedding",
"query_vector": [...],
"k": 10,
"num_candidates": 100,
"filter": {
"term": {"category": "java"}
}
}
}

这是最有效的加速手段。如果你的 RAG 检索在某个分类下,filter 提前缩小数据集,kNN 搜索范围从百万级降到十万级,速度提升 5-10 倍。

2. 减少 vector 维度

1
2
3
4
"embedding": {
"type": "dense_vector",
"dims": 384 // 而不是 1536
}

768 → 384 维:召回率下降 1-3%,内存和检索时间减少 50%。

如果用 bge-small-zh 这类小模型,384 维对中文 RAG 完全够用。

3. 量化压缩

ES 8.12+ 支持 int8 量化:

1
2
3
4
5
"index_options": {
"type": "int8_hnsw",
"m": 16,
"ef_construction": 100
}
  • 内存减少 4 倍
  • 召回率下降 1-2%
  • 检索速度提升 1.5-2 倍

生产强烈推荐开量化

4. 监控和告警

1
GET _nodes/stats/indices/search

关注:

  • indices.search.query_time_in_millis
  • knn.query_time_in_millis(ES 8.x 单独统计)
  • knn.cached_queries(缓存命中率)

如果 P99 > 200ms 持续,先看是不是某个 shard 数据量过大——rebalance 或加 shard。

实战:一个完整的 RAG 检索请求

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
POST rag_documents/_search
{
"size": 10,
"query": {
"bool": {
"must": [
{
"match": {
"content": "Spring AI 2.0 升级"
}
}
],
"filter": [
{"term": {"category": "tech-doc"}},
{"range": {"created_at": {"gte": "2026-01-01"}}}
]
}
},
"knn": {
"field": "embedding",
"query_vector": [0.1, 0.2, ...],
"k": 10,
"num_candidates": 200,
"filter": [
{"term": {"category": "tech-doc"}}
]
},
"rank": {
"rrf": {
"window_size": 50,
"rank_constant": 60
}
}
}

关键设计:

  • filter 同时应用到 BM25 和 kNN,确保两者都在同一集合内召回
  • num_candidates=200k=10 的 20 倍,召回率足够
  • window_size=50 让 RRF 融合时只看前 50 个候选
  • rank_constant=60 是 RRF 经典值

意味着什么?

ES kNN 不是”建索引就能用”,需要根据业务调

  • num_candidates 是查询时最重要的调参旋钮
  • Mef_construction 是建索引时定的,不能改
  • 混合检索的权重需要有评测集调,不能拍脑袋
  • 量化(int8_hnsw)是性价比最高的优化
  • filter 是性能优化的”银弹”,能提前缩小数据集就提前

数据密集型 AI 后端的工程师,对向量检索的理解深度决定了 RAG 系统的质量。这套参数调过一遍,下次新项目你就有直觉——知道瓶颈在哪、往哪个方向调。

关联阅读