覆盖 ES 向量检索(dense_vector + HNSW/kNN)从索引配置到查询调优的完整实践,重点解决召回率、性能和存储三者之间的权衡问题。
核心术语
| 缩写/术语 | 全称 | 一句话解释 |
|---|---|---|
| Embedding | 向量嵌入 | 将文本/图像等非结构化数据映射为一组浮点数(向量),语义相近的内容在向量空间中距离也近 |
| kNN | k-Nearest Neighbors,k 近邻 | 在向量空间中找距离最近的 k 个向量,即"语义最相似的 k 条文档" |
| ANN | Approximate Nearest Neighbor,近似最近邻 | kNN 的近似版本,牺牲少量精度换取大幅性能提升,ES 的向量检索底层即为 ANN |
| HNSW | Hierarchical Navigable Small World,分层可导航小世界图 | 构建多层图结构来加速 ANN 搜索:高层图稀疏用于快速定位,低层图密集用于精确搜索 |
| m | - | HNSW 每个节点的最大连接边数,影响图的密度和搜索精度 |
| ef_construction | - | 构建 HNSW 索引时每步探索的候选节点数,越大图质量越高 |
| num_candidates | - | 查询时每个分片探索的候选节点数,越大召回率越高 |
| 量化(Quantization) | - | 将 float32 向量压缩为 int8 等低精度表示,以 ~4× 压缩比换取轻微的召回率损失 |
| 召回率(Recall@K) | Recall at K | 暴力精确搜索的 Top-K(ground truth)中,有多少被 ANN 成功找到。例如精确 Top-10 是 [A…J],ANN 返回了其中 9 个,则 Recall@10 = 90%。⚠️ 与传统 IR 召回率不同:kNN 场景中 ground truth 大小 = 返回数量 = K,因此 Recall@K 与 Precision@K 数值上等价,但含义是"真正的 K 个近邻找回来了多少" |
目录
| 章节 | 说明 |
|---|---|
| 向量索引配置 | HNSW 参数、相似度、量化类型 |
| 存储优化 | source 处理、docvalue_fields、文件预加载 |
| 查询配置 | knn search vs knn query、过滤器位置 |
| 主副本一致性 | HNSW 随机性导致的结果不一致问题 |
| 性能调优 | 召回率 vs 性能的 Trade-off |
端到端示例:从文本到向量检索
用 4 维向量代替真实的 768 维,让数字直观可读。实际项目中向量由 Embedding 模型自动生成,流程完全一致。
第一步:理解"向量"是什么
Embedding 模型把文本映射到向量空间,语义相近的文本,向量距离也近。
假设 4 个维度分别代表:
[编程语言, 系统架构, 机器学习, 数据存储]
手动为 5 本书分配向量(模拟 Embedding 模型的输出):
| 书名 | 向量 | 直觉 |
|---|---|---|
| Python 编程入门 | [0.9, 0.2, 0.3, 0.1] | 偏编程语言 |
| Java 高并发编程 | [0.8, 0.7, 0.1, 0.2] | 编程 + 架构 |
| 机器学习实战 | [0.3, 0.2, 0.9, 0.2] | 偏机器学习 |
| 深度学习 | [0.2, 0.1, 0.95, 0.3] | 强机器学习 |
| Redis 实战 | [0.3, 0.4, 0.1, 0.9] | 偏数据存储 |
第二步:建索引 + 写入数据
# 建索引(4 维向量,cosine 相似度)
curl -X PUT "localhost:9200/books" -H 'Content-Type: application/json' -d '{
"mappings": {
"properties": {
"title": { "type": "keyword" },
"category": { "type": "keyword" },
"embedding": {
"type": "dense_vector",
"dims": 4,
"index": true,
"similarity": "cosine"
}
}
}
}'
# 写入 5 条数据
curl -X POST "localhost:9200/_bulk" -H 'Content-Type: application/x-ndjson' --data-binary '
{ "index": { "_index": "books", "_id": "1" } }
{ "title": "Python 编程入门", "category": "编程", "embedding": [0.9, 0.2, 0.3, 0.1] }
{ "index": { "_index": "books", "_id": "2" } }
{ "title": "Java 高并发编程", "category": "编程", "embedding": [0.8, 0.7, 0.1, 0.2] }
{ "index": { "_index": "books", "_id": "3" } }
{ "title": "机器学习实战", "category": "AI", "embedding": [0.3, 0.2, 0.9, 0.2] }
{ "index": { "_index": "books", "_id": "4" } }
{ "title": "深度学习", "category": "AI", "embedding": [0.2, 0.1, 0.95, 0.3] }
{ "index": { "_index": "books", "_id": "5" } }
{ "title": "Redis 实战", "category": "数据库", "embedding": [0.3, 0.4, 0.1, 0.9] }
'
第三步:向量查询
用户问:"我想学 Python 机器学习",Embedding 模型把这句话转成向量:[0.6, 0.1, 0.7, 0.1](同时偏向编程语言和机器学习)。
curl -X GET "localhost:9200/books/_search" -H 'Content-Type: application/json' -d '{
"knn": {
"field": "embedding",
"query_vector": [0.6, 0.1, 0.7, 0.1],
"k": 3,
"num_candidates": 5
},
"_source": ["title", "category"]
}'
第四步:理解返回结果与评分
{
"hits": [
{ "_score": 0.960, "_source": { "title": "机器学习实战", "category": "AI" } },
{ "_score": 0.933, "_source": { "title": "深度学习", "category": "AI" } },
{ "_score": 0.929, "_source": { "title": "Python 编程入门","category": "编程" } }
]
}
为什么是这个顺序? 用余弦相似度手算验证(cos = 向量点积 / 两向量模长之积):
查询向量 q = [0.6, 0.1, 0.7, 0.1],模长 = √(0.36+0.01+0.49+0.01) = 0.933
机器学习实战 d3 = [0.3, 0.2, 0.9, 0.2]
点积 = 0.6×0.3 + 0.1×0.2 + 0.7×0.9 + 0.1×0.2 = 0.18+0.02+0.63+0.02 = 0.85
cos = 0.85 / (0.933 × 0.990) = 0.920 → score = (1 + 0.920) / 2 ≈ 0.960 ✅
深度学习 d4 = [0.2, 0.1, 0.95, 0.3]
点积 = 0.12+0.01+0.665+0.03 = 0.825
cos = 0.825 / (0.933 × 1.021) = 0.866 → score ≈ 0.933 ✅
Python 编程入门 d1 = [0.9, 0.2, 0.3, 0.1]
点积 = 0.54+0.02+0.21+0.01 = 0.78
cos = 0.78 / (0.933 × 0.975) = 0.858 → score ≈ 0.929 ✅
Redis 实战 d5 = [0.3, 0.4, 0.1, 0.9]
点积 = 0.18+0.04+0.07+0.09 = 0.38
cos ≈ 0.394 → score ≈ 0.697 ← 最不相关,排在最后 ✅
ES 的
_score=(1 + cosine) / 2,把 [-1, 1] 映射到 [0, 1],不是原始余弦值。
第五步:加标量过滤
如果用户只想看编程类书籍中最相关的:
curl -X GET "localhost:9200/books/_search" -H 'Content-Type: application/json' -d '{
"knn": {
"field": "embedding",
"query_vector": [0.6, 0.1, 0.7, 0.1],
"k": 3,
"num_candidates": 5,
"filter": { "term": { "category": "编程" } }
},
"_source": ["title", "category"]
}'
// 结果只在 category=编程 的文档中做 kNN → 返回 Python/Java,不返回 ML 书
实际项目中的差异
以上示例用手写向量模拟,真实项目的区别只在于向量从哪来:
用户输入文本
↓
调用 Embedding 模型(如 text-embedding-3-small、BGE-M3)
↓
得到 768/1536 维向量
↓
传入 query_vector → ES kNN 查询(流程与上面完全一致)
向量索引配置
dense_vector 字段定义
PUT /my_vector_index
{
"mappings": {
"properties": {
"embedding": {
"type": "dense_vector",
"dims": 768,
"index": true,
"similarity": "dot_product",
"index_options": {
"type": "hnsw",
"m": 16,
"ef_construction": 100
}
},
"title": { "type": "keyword" },
"category": { "type": "keyword" }
},
"_source": {
"excludes": ["embedding"]
}
}
}
HNSW 工作原理
HNSW 的核心思想:把图分成多层,上层稀疏负责"快速定位",下层密集负责"精确搜索",类似跳表。
用 7 个文档(向量已降到 1 维方便展示)举例:
文档:A=0.1 B=0.3 C=0.5 D=0.6 E=0.7 F=0.8 G=0.9
Layer 2(最稀疏,仅少数节点,大步跳跃):
A ←————————————→ D ←————————————→ G
Layer 1(中等密度):
A ←———→ B ←———→ D ←———→ E ←———→ G
Layer 0(底层,全部节点,最密集):
A ←→ B ←→ C ←→ D ←→ E ←→ F ←→ G
查询 q=0.55 的过程:
① 从 Layer2 的入口节点 A 出发,贪心找最近邻
A(0.1) → D(0.6) → 比较邻居,G(0.9) 更远,停在 D
② 下降到 Layer1,从 D 继续搜索
D(0.6) → 比较邻居 B(0.3) E(0.7),E 更近,但再往右更远,停在 D/E 之间
③ 下降到 Layer0,从 D 精细搜索
D(0.6) → C(0.5) → 与 q=0.55 距离=0.05,确认 C 和 D 是最近邻
总共访问约 5-6 个节点,而暴力扫描需要访问全部 7 个
文档量越大(百万级),HNSW 优势越显著:O(log N) vs O(N)
HNSW 核心参数
| 参数 | 说明 | 默认值 | 调优建议 |
|---|---|---|---|
m |
每个节点在 Layer0 的最大连接边数 | 16 | 增大 → 图更密 → 召回率更高,但存储和写入耗时增加 |
ef_construction |
构建索引时每步保留的候选节点数 | 100 | 增大 → 找到更好的邻居 → 图质量更高,但写入更慢 |
num_candidates(查询时) |
查询时每个分片维护的动态候选集大小 | - | 增大 → 搜索更彻底 → 召回率更高,但查询延迟增加 |
m 的直觉(m=2 vs m=4):
m=2:每个节点最多 2 条边(图稀疏,搜索快但可能漏掉近邻)
A ←→ C ←→ E ←→ G
m=4:每个节点最多 4 条边(图密,搜索慢但近邻更难遗漏)
A ←→ B ←→ C ←→ E
↕ ↕
D ←→ E ←→ F ←→ G
ef_construction 的直觉(新插入节点 X=0.45 时):
ef_construction=2:只考察 2 个候选,可能选了 C(0.5) 和 E(0.7) 作为邻居
ef_construction=10:考察 10 个候选,更可能找到 C(0.5) 和 D(0.6) 这两个真正最近邻
→ ef_construction 越大,图质量越好,但写入越慢
参数越大召回率越高,但写入耗时越长、查询越慢。需要根据业务场景自行压测确定最佳参数。
相似度函数选择
| 相似度 | 中文名 | 说明 | 推荐场景 |
|---|---|---|---|
cosine |
余弦相似度 | 计算两向量夹角的余弦值,只关注方向不关注长度 | 通用场景 |
dot_product |
点积 / 内积 | 向量对应维度相乘再求和,要求向量归一化(模长为 1) | 推荐:性能更好,磁盘占用更小,归一化后与 cosine 等价 |
l2_norm |
欧氏距离 / L2 范数 | 向量空间中两点之间的直线距离 | 需要绝对距离时 |
如果业务在写入和查询前对向量进行了归一化(模长为 1),优先用
dot_product:性能更好、磁盘占用更小,与cosine结果等价。
量化类型
// int8_hnsw:8 位整型量化,显著降低内存占用,轻微降低召回率
{
"index_options": {
"type": "int8_hnsw",
"m": 16,
"ef_construction": 100
}
}
量化的直觉:float32 → int8 压缩了什么
原始 float32 向量(768 维,每个值 4 字节):
[0.1234, -0.9876, 0.5432, -0.3210, ...]
4bytes 4bytes 4bytes 4bytes
共 768 × 4 = 3,072 bytes ≈ 3 KB / 条
int8 量化后(每个值 1 字节,范围 [-128, 127]):
[16, -126, 69, -41, ...] ← 按比例缩放到整数
1byte 1byte 1byte 1byte
共 768 × 1 = 768 bytes ≈ 0.75 KB / 条
压缩比:4×,精度损失:微小(相似度误差通常 < 1%)
1000 万条文档(768 维):
float32:1000万 × 3KB = 约 30 GB
int8: 1000万 × 0.75KB = 约 7.5 GB
| 类型 | 内存占用 | 召回率 | 适用场景 |
|---|---|---|---|
hnsw(float) |
高 | 最高 | 召回率要求极高 |
int8_hnsw |
低(约 1/4) | 略低 | 推荐:大多数业务场景 |
存储优化
向量字段与 _source
向量字段(如 768 维 float)占用大量磁盘空间。从 _source 获取字段时,ES 需要加载整个文档的 source。
推荐方案:用 docvalue_fields 代替 _source 获取字段
// ✅ 推荐:docvalue_fields 只加载需要的字段,不触发 source 加载
GET /my_vector_index/_search
{
"knn": {
"field": "embedding",
"query_vector": [0.1, 0.2, ...],
"k": 10,
"num_candidates": 100
},
"docvalue_fields": ["title", "category", "score"],
"_source": false
}
备选方案:source 中 exclude 向量字段
// mapping 中配置
{
"mappings": {
"_source": {
"excludes": ["embedding"]
}
}
}
⚠️ exclude 向量字段后的副作用:无法使用
update接口更新文档,无法reindex,无法高亮。使用前务必确认业务没有这些需求。可用 script_fields + painless 脚本在查询时获取向量字段值。
文件预加载到系统缓存
PUT /my_vector_index/_settings
{
"index.store.preload": ["vec", "veq", "vex", "vem", "vemf", "vemq"]
}
| 文件类型 | 含义 |
|---|---|
vec, veq |
向量值数据 |
vex |
HNSW 图结构 |
vem, vemf, vemq |
向量元数据 |
注意:这是非动态配置,需要先关闭索引再更新,重新打开后生效。系统内存必须足够大才能预加载。
段合并对向量检索的影响
段文件越多,向量检索遍历的 HNSW 图越多,性能越差。对不再写入的索引执行强制合并:
POST /my_vector_index/_forcemerge?max_num_segments=1
查询配置
knn search vs knn query
ES 提供两种向量查询方式:
| 方式 | 位置 | 推荐程度 | 说明 |
|---|---|---|---|
knn search |
请求最外层,与 query 并列 |
✅ 推荐 | 性能更好,适合大多数场景 |
knn query |
在 query 内部使用 |
⚠️ 仅特定场景 | 仅适合后置过滤场景 |
纯向量查询
GET /my_vector_index/_search
{
"knn": {
"field": "embedding",
"query_vector": [0.1, 0.2, 0.3, ...],
"k": 10,
"num_candidates": 100
}
}
带标量过滤的向量查询(推荐写法)
// ✅ 正确:过滤条件放在 knn 的 filter 参数里(前置过滤,性能好)
GET /my_vector_index/_search
{
"knn": {
"field": "embedding",
"query_vector": [0.1, 0.2, ...],
"k": 10,
"num_candidates": 100,
"filter": {
"term": { "category": "electronics" }
}
}
}
// ⚠️ 不同语义:过滤条件放在最外层 query(混合查询,向量分数 + 标量分数融合)
GET /my_vector_index/_search
{
"knn": {
"field": "embedding",
"query_vector": [0.1, 0.2, ...],
"k": 10,
"num_candidates": 100
},
"query": {
"term": { "category": "electronics" }
}
}
关键区别:
- 过滤条件在
knn.filter:先过滤再向量检索(前置过滤),只在满足条件的文档中做 kNN,性能更好- 过滤条件在外层
query:向量检索和标量查询分别执行,结果融合(标量向量混合查询)
关于 _score 的说明
向量检索结果中的 _score 不是原始相似度值,而是经过转化的分数:
dot_product(点积 / 内积) → score = (1 + dot_product) / 2
cosine(余弦相似度) → score = (1 + cosine) / 2
l2_norm(欧氏距离 / L2 范数) → score = 1 / (1 + l2_norm²)
如需获取原始相似度值,需要通过脚本字段额外计算。
主副本一致性
问题根因
ES 默认情况下,主副本各自独立进行写入和段合并:
- 写入顺序不同 → HNSW 图构建的随机性 → 主副本图结构不同
- 同一查询在主分片和副本分片上可能返回不同的 Top-K 结果
解决方案
方案 A(推荐):提高 HNSW 参数,降低不一致概率
{
"index_options": {
"type": "hnsw",
"m": 32,
"ef_construction": 200
}
}
查询时也调高 num_candidates:
{
"knn": {
"num_candidates": 200
}
}
方案 B:使用物理复制(副本直接复制主分片的段文件,结果完全一致)
性能调优
召回率 vs 性能权衡
graph LR
A["增大 m<br/>ef_construction<br/>num_candidates"] -->|提升| B["召回率更高"]
A -->|代价| C["写入更慢<br/>查询更慢<br/>内存更大"]
style B fill:#cfc,stroke:#060
style C fill:#fcc,stroke:#c00
调优步骤
- 确定业务召回率目标(如 Top-10 中至少 9 个准确,即 90%)
- 准备测试数据集,构建 ground truth(暴力枚举精确结果)
- 调整
m、ef_construction和查询时的num_candidates - 对每组参数测量:召回率、写入 QPS、查询延迟
- 找到满足召回率目标时性能最优的参数组合
容量估算参考
float32 向量(768 维):768 × 4 bytes = 3 KB/条
int8 量化后:768 × 1 byte = 768 bytes/条
1 亿条数据(768 维,float32):
向量数据约 300 GB
HNSW 图额外占用约 50-100 GB
合计约 350-400 GB(不含副本)
参考资料
评论 (0)
发表评论