原生 HNSW 搜索 #
Easysearch 2.4.0 内置基于 Lucene 的 HNSW 向量索引。新建原生 HNSW 索引不需要安装插件,使用 dense_vector mapping 和
标准 _search API 即可完成写入和检索。
旧 k-NN 插件继续使用 knn_dense_float_vector、knn_sparse_bool_vector 和 knn_nearest_neighbors。旧接口的使用方法参考
旧 k-NN 查询 API。不要在同一个字段上混用两套语法。
完整示例 #
创建索引 #
PUT /native-hnsw-demo
{
"settings": {
"number_of_shards": 1,
"number_of_replicas": 0
},
"mappings": {
"properties": {
"title": {
"type": "text"
},
"tenant": {
"type": "keyword"
},
"embedding": {
"type": "dense_vector",
"dims": 4,
"element_type": "float",
"index": true,
"similarity": "cosine",
"index_options": {
"type": "hnsw",
"m": 16,
"ef_construction": 100
}
}
}
}
}
完整的 mapping 参数参考 dense_vector 字段类型。
批量写入 #
POST /_bulk?refresh=true
{ "index": { "_index": "native-hnsw-demo", "_id": "v01" } }
{ "title": "向量文档一", "tenant": "a", "embedding": [1.0, 0.0, 0.0, 0.0] }
{ "index": { "_index": "native-hnsw-demo", "_id": "v02" } }
{ "title": "向量文档二", "tenant": "a", "embedding": [0.9, 0.1, 0.0, 0.0] }
{ "index": { "_index": "native-hnsw-demo", "_id": "v03" } }
{ "title": "向量文档三", "tenant": "b", "embedding": [0.7, 0.7, 0.0, 0.0] }
{ "index": { "_index": "native-hnsw-demo", "_id": "v04" } }
{ "title": "向量文档四", "tenant": "b", "embedding": [0.0, 1.0, 0.0, 0.0] }
Bulk 请求使用 NDJSON,每行必须以换行符结束。
query-level knn #
将 knn 放在 query 中,可以单独执行,也可以嵌入受支持的复合查询。
POST /native-hnsw-demo/_search
{
"size": 3,
"query": {
"knn": {
"field": "embedding",
"query_vector": [1.0, 0.0, 0.0, 0.0],
"k": 3,
"num_candidates": 4
}
}
}
查询参数 #
| 参数 | 是否必填 | 说明 |
|---|---|---|
field | 是 | 已索引的 dense_vector 字段名 |
query_vector | 是 | 查询向量,维度必须与字段 mapping 一致 |
k | 否 | 每个分片返回的最近邻数量,必须大于 0;省略时使用请求的 size |
num_candidates | 否 | 每个分片搜索的候选数量,必须不小于 k,最大为 10000 |
filter | 否 | 在 HNSW 搜索过程中应用的预过滤查询,可传单个查询或查询数组 |
similarity | 否 | 最低相似度阈值,含义取决于字段 mapping 的 similarity |
boost | 否 | 对向量得分应用的权重 |
_name | 否 | 命名查询,命中结果会返回对应的 matched_queries |
省略 num_candidates 时,其值按 min(1.5 × k, 10000) 计算并四舍五入。增大候选数量通常可以提高召回率,但会增加查询开销。
query-level k 是每个分片的候选数量,最终响应条数仍由外层 size 控制。需要跨分片严格执行全局 k 时,使用顶层 knn。
带过滤条件 #
filter 是向量搜索的预过滤条件,而不是在召回完成后再删除结果:
POST /native-hnsw-demo/_search
{
"size": 2,
"query": {
"knn": {
"field": "embedding",
"query_vector": [1.0, 0.0, 0.0, 0.0],
"k": 2,
"num_candidates": 4,
"filter": {
"term": {
"tenant": "a"
}
}
}
}
}
filter 也可以是查询数组,数组中的条件全部作为过滤条件执行。
与复合查询组合 #
query-level knn 可以用于 bool、constant_score、dis_max、boosting 和 function_score 等核心复合查询。以下示例要求
文档属于租户 a,并使用向量相似度排序:
POST /native-hnsw-demo/_search
{
"size": 2,
"query": {
"bool": {
"must": {
"knn": {
"field": "embedding",
"query_vector": [1.0, 0.0, 0.0, 0.0],
"k": 2,
"num_candidates": 4
}
},
"filter": {
"term": {
"tenant": "a"
}
}
}
}
}
顶层 knn #
将 knn 放在搜索请求顶层,可以在多分片搜索中协调候选结果并返回全局 top-k。
Easysearch 2.4.0 不支持在同一个请求中同时使用顶层
knn和普通query。需要组合 lexical 查询和向量查询时, 请将 query-levelknn放入bool等复合查询;不要把query与顶层knn并列发送。
例如,以下请求会失败:
POST /native-hnsw-demo/_search
{
"query": {
"match": {
"title": "向量"
}
},
"knn": {
"field": "embedding",
"query_vector": [1.0, 0.0, 0.0, 0.0],
"k": 3
}
}
错误响应的 reason 为:
P0 does not support combining a lexical [query] with top-level [knn]
正确的顶层 knn #
POST /native-hnsw-demo/_search
{
"size": 3,
"knn": {
"field": "embedding",
"query_vector": [1.0, 0.0, 0.0, 0.0],
"k": 3,
"num_candidates": 4
}
}
顶层 knn 使用与 query-level knn 相同的参数,但 k 表示协调后的全局最近邻数量。Easysearch 2.4.0 支持单个顶层
knn 对象,也接受只包含一个对象的数组:
POST /native-hnsw-demo/_search
{
"size": 3,
"knn": [
{
"field": "embedding",
"query_vector": [1.0, 0.0, 0.0, 0.0],
"k": 3,
"num_candidates": 4
}
]
}
数组中不能包含多个 knn 对象。多路 kNN,以及在同一请求中同时使用顶层 knn 和 lexical query,不属于 2.4.0 的原生 HNSW 支持范围。
相似度阈值 #
查询参数 similarity 使用 mapping 中相似度函数的原始度量空间:
cosine和dot_product使用最小相似度;l2_norm使用最大允许距离;max_inner_product使用最小内积。
例如,只返回 cosine similarity 不低于 0.8 的候选结果:
POST /native-hnsw-demo/_search
{
"size": 10,
"query": {
"knn": {
"field": "embedding",
"query_vector": [1.0, 0.0, 0.0, 0.0],
"k": 10,
"num_candidates": 100,
"similarity": 0.8
}
}
}
阈值可能使最终命中数量少于 k 或 size。
得分计算 #
响应中的 _score 是由 mapping 选择的原始相似度或距离转换得到的正数。转换规则如下:
mapping similarity | 原始度量 | 未应用 boost 的 _score |
|---|---|---|
cosine | 余弦相似度 s | max((1 + s) / 2, 0) |
dot_product | 单位向量点积 s | max((1 + s) / 2, 0) |
l2_norm | 欧氏距离 d | 1 / (1 + d²) |
max_inner_product | 内积 s < 0 | 1 / (1 - s) |
max_inner_product | 内积 s >= 0 | s + 1 |
对于单独的 knn 子句,该子句贡献的分数等于转换后的分数乘以查询的 boost;放入复合查询后,最终 _score 还会按
复合查询规则组合其他子句的分数。similarity 阈值始终在原始度量空间中判断,并且在应用 boost 之前完成。因此,提高
boost 只影响命中后的排序分数,不会让低于阈值的向量通过过滤。
使用本页示例数据和 cosine 查询时,响应的前三个命中如下:
{
"hits": {
"hits": [
{ "_id": "v01", "_score": 1.0 },
{ "_id": "v02", "_score": 0.9969419 },
{ "_id": "v03", "_score": 0.8535534 }
]
}
}
浮点计算和 HNSW 近似搜索可能产生很小的分数差异,不要依赖 _score 的字符串完全相等。
选择查询形式 #
| 场景 | 推荐形式 |
|---|---|
| 单分片索引或需要嵌入复合查询 | query-level knn |
| 多分片索引并要求严格全局 top-k | 顶层 knn |
| 需要在 HNSW 搜索中预过滤 | 两种形式均可使用 filter |
多路 kNN,或顶层 knn 与 lexical query 并列 | 2.4.0 暂不支持 |
生产调优 #
原生 HNSW 的召回率、查询成本、写入成本和存储占用由 mapping 参数、查询参数和 segment 拓扑共同决定。先使用代表真实业务的 向量和查询集建立 Recall 与延迟基线,再逐项调整参数;不要只依据单次查询耗时调优。
| 参数 | 增大后的通常影响 | 调整建议 |
|---|---|---|
m | 提高图连通性和 Recall,同时增加构图 CPU、内存和存储 | 建索引前确定;更新后只影响新 segment,减小需要新索引 |
ef_construction | 提高新图质量,同时降低写入吞吐并增加构图开销 | 写入吞吐和 Recall 一起验证;更新只影响新 segment |
num_candidates | 通常提高 Recall,同时增加每次查询的 CPU 和延迟 | 从 k 的数倍开始,用真实查询集逐步增加 |
k | 返回更多邻居,也会增加分片候选和协调成本 | 只设置业务真正需要的邻居数量 |
写入建议 #
- 使用 Bulk 写入,检查每个 Bulk item 的状态,不能只检查 HTTP 状态码。
- 大批量初始导入时可适当延长
refresh_interval,导入完成后恢复生产值并执行一次 refresh。 - 在业务允许且已有其他数据保护措施时,可在初始导入期间暂时将副本数设为
0;验收完成后恢复副本并等待集群健康。 - 避免大量小 Bulk 和过于频繁的 refresh。每次 refresh 都可能产生新 segment,而每个 segment 都有独立的 HNSW 图。
- 同时观察写入吞吐、merge 时间、JVM/进程内存和磁盘增长;HNSW 构图成本不应只用 Bulk 请求延迟判断。
Segment 与 merge #
HNSW 图按 segment 构建。查询需要访问 shard 中所有相关 segment,再合并各 segment 的候选结果。segment 数量会直接影响查询 CPU 和延迟,后台 merge 则会消耗 CPU、I/O 和额外临时磁盘空间。
Easysearch 2.4.0 尚未提供与 Elasticsearch 8.19 对齐的 shard 内 segment 并行搜索。自然多 segment 索引上的查询吞吐可能低于 单 segment 索引,也可能低于相同合同下的 Elasticsearch 8.19。上线前必须使用生产预期的 segment 拓扑进行压测,不能用 force merge 后的单 segment 结果代表持续写入场景。
force merge 适合不再写入的只读索引维护,不应作为活跃写入索引的日常性能手段。它可能产生很大的 segment,并在执行期间消耗 大量 CPU、I/O 和临时磁盘。优先通过合理的 Bulk、refresh、分片大小和生命周期策略控制 segment 数量。
容量与监控 #
容量评估至少要包含原始 _source、浮点向量、HNSW 图、其他字段、segment 合并临时空间和所有副本。不同维度、m、文档数和
数据分布的占用差异很大,应使用真实数据实测 store size、进程 RSS 和文件系统缓存需求。
常用只读检查:
GET /native-hnsw-demo/_segments
GET /native-hnsw-demo/_stats/store,indexing,merges,refresh
GET /_nodes/stats/jvm,process,fs,thread_pool
重点关注 segment 数持续增长、merge backlog、磁盘水位、JVM GC、进程 RSS、search/write thread pool rejection,以及 Recall、 查询 P50/P95/P99 和写入吞吐的长期变化。Easysearch 2.4.0 没有单独的 HNSW 图内存统计项,不能只看 JVM heap 判断容量。
常见问题 #
| 现象 | 优先检查 | 处理方向 |
|---|---|---|
| Recall 不足 | num_candidates、m、ef_construction、过滤条件和评测真值 | 先增加 num_candidates;仍不足时用新索引验证构图参数 |
| 查询延迟或 CPU 高 | segment 数、候选数、并发、过滤选择性和后台 merge | 降低无效候选;控制 refresh 和 segment;隔离写入/查询基准 |
| 写入吞吐下降 | Bulk 大小、refresh、副本、merge、GC、磁盘 I/O | 减少小 Bulk 和频繁 refresh;排查 merge/I/O,而不是降低正确性校验 |
| store 增长过快 | 维度、m、副本、segment 和 _source | 用同合同 A/B 测量;调整参数需重新评估 Recall |
命中少于 k | similarity 阈值、filter、可用文档数 | 放宽阈值或过滤条件;确认符合条件的文档数 |
| 写入被拒绝 | 维度、有限数值、cosine 零向量、dot product 单位长度 | 根据具体错误修正向量生成和归一化流程 |
迁移现有索引 #
不能通过更新旧索引 mapping 把旧 k-NN 字段或其他字段原地转换成原生 dense_vector。以下流程使用新索引、Reindex 和原子 alias
切换;前提是源 _source 中的向量已经是与目标 dims 一致的 JSON 浮点数组。
- 创建带有目标
dense_vectormapping 的新索引,例如products-vector-v2。 - 从当前索引 Reindex 到新索引:
POST /_reindex?wait_for_completion=false
{
"source": {
"index": "products-vector-v1"
},
"dest": {
"index": "products-vector-v2"
}
}
- 检查任务结果和 Bulk failure,核对文档数、mapping、代表性查询、Recall、错误率和资源占用。文档数相同不能替代查询验收。
- 在短暂停写窗口内同步增量数据,或提前使用双写保证两边一致。
- 如果应用通过
products-vectoralias 访问索引,使用一个_aliases请求完成原子切换:
POST /_aliases
{
"actions": [
{
"remove": {
"index": "products-vector-v1",
"alias": "products-vector"
}
},
{
"add": {
"index": "products-vector-v2",
"alias": "products-vector",
"is_write_index": true
}
}
]
}
- 保留旧索引一段观察期。需要回滚时,用相反的 alias 操作切回,并处理切换后写入新索引的增量数据。确认不再回滚后再删除旧索引。
如果应用直接使用物理索引名,先改为 alias 或在应用配置层完成切换。迁移前应创建可恢复的快照,并预留新旧索引与 merge 所需的 磁盘空间。完整 API 参考 重建索引和 别名。
Elasticsearch 8.19 客户端 #
Easysearch 2.4.0 已使用 Elasticsearch Java 8.19.17 和 Python 8.19.3 官方客户端验证原生 HNSW mapping、Bulk、
query-level kNN、顶层 kNN、认证和错误响应。节点需要开启 Elasticsearch API 兼容模式:
elasticsearch.api_compatibility: true
elasticsearch.api_compatibility_version: "8.19.17"
这些是节点启动配置,默认分别为 false 和 7.10.2,不能通过集群设置动态修改。修改后按部署流程重启节点,并先通过客户端的
集群信息请求验证连接到了预期集群。完整默认值、生效方式和升级说明见
Elasticsearch 兼容性配置。
Python 8.19 #
from elasticsearch import Elasticsearch
client = Elasticsearch(
"https://easysearch.example.com:9200",
basic_auth=("admin", "<password>"),
ca_certs="/path/to/http-ca.crt",
)
response = client.search(
index="native-hnsw-demo",
size=3,
query={
"knn": {
"field": "embedding",
"query_vector": [1.0, 0.0, 0.0, 0.0],
"k": 3,
"num_candidates": 100,
}
},
)
for hit in response["hits"]["hits"]:
print(hit["_id"], hit["_score"])
Java 8.19 #
依赖版本应保持一致:
co.elastic.clients:elasticsearch-java:8.19.17
org.elasticsearch.client:elasticsearch-rest-client:8.19.17
以下示例使用 query-level kNN;生产环境还需要在 RestClient 上配置认证和 CA 信任:
import co.elastic.clients.elasticsearch.ElasticsearchClient;
import co.elastic.clients.elasticsearch._types.KnnQuery;
import co.elastic.clients.elasticsearch.core.SearchResponse;
import co.elastic.clients.json.jackson.JacksonJsonpMapper;
import co.elastic.clients.transport.rest_client.RestClientTransport;
import org.apache.http.HttpHost;
import org.elasticsearch.client.RestClient;
import java.util.List;
import java.util.Map;
try (RestClientTransport transport = new RestClientTransport(
RestClient.builder(HttpHost.create("https://easysearch.example.com:9200")).build(),
new JacksonJsonpMapper()
)) {
ElasticsearchClient client = new ElasticsearchClient(transport);
KnnQuery knn = KnnQuery.of(k -> k
.field("embedding")
.queryVector(List.of(1.0f, 0.0f, 0.0f, 0.0f))
.k(3)
.numCandidates(100)
);
SearchResponse<Map> response = client.search(s -> s
.index("native-hnsw-demo")
.size(3)
.query(q -> q.knn(knn)), Map.class);
}
客户端会发送 Elasticsearch 8.x vendor media type。不要手工删除或改写兼容请求头;连接失败时先检查节点兼容配置、TLS 信任、 认证信息和响应中的产品标识。
当前不支持的接口 #
Easysearch 2.4.0 原生 HNSW 不提供以下 Elasticsearch 向量接口:
- 已弃用的
/_knn_search; knnretriever;- 多个顶层
knn子句; sub_searches和rank;_source.exclude_vectors;- nested kNN 和
inner_hits; - byte、bit 和量化向量查询。
这些限制只针对本页的原生 HNSW。旧 k-NN 插件能力和语法继续按旧插件文档使用。