ES 系列 #08:ES 向量检索最佳实践

发布于 2026-05-26 10:25 👁 12 次阅读
#elasticsearch#best-practice#vector-search#knn#hnsw

覆盖 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

es vector search flow


端到端示例:从文本到向量检索

用 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

查询配置

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 默认情况下,主副本各自独立进行写入和段合并:

解决方案

方案 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

调优步骤

  1. 确定业务召回率目标(如 Top-10 中至少 9 个准确,即 90%)
  2. 准备测试数据集,构建 ground truth(暴力枚举精确结果)
  3. 调整 mef_construction 和查询时的 num_candidates
  4. 对每组参数测量:召回率、写入 QPS、查询延迟
  5. 找到满足召回率目标时性能最优的参数组合

容量估算参考

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)

暂无评论,来留下第一条吧。

发表评论