原生 HNSW 搜索

原生 HNSW 搜索 #

Easysearch 2.4.0 内置基于 Lucene 的 HNSW 向量索引。新建原生 HNSW 索引不需要安装插件,使用 dense_vector mapping 和 标准 _search API 即可完成写入和检索。

旧 k-NN 插件继续使用 knn_dense_float_vectorknn_sparse_bool_vectorknn_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 可以用于 boolconstant_scoredis_maxboostingfunction_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-level knn 放入 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 中相似度函数的原始度量空间:

  • cosinedot_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
    }
  }
}

阈值可能使最终命中数量少于 ksize

得分计算 #

响应中的 _score 是由 mapping 选择的原始相似度或距离转换得到的正数。转换规则如下:

mapping similarity原始度量未应用 boost_score
cosine余弦相似度 smax((1 + s) / 2, 0)
dot_product单位向量点积 smax((1 + s) / 2, 0)
l2_norm欧氏距离 d1 / (1 + d²)
max_inner_product内积 s < 01 / (1 - s)
max_inner_product内积 s >= 0s + 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_candidatesmef_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
命中少于 ksimilarity 阈值、filter、可用文档数放宽阈值或过滤条件;确认符合条件的文档数
写入被拒绝维度、有限数值、cosine 零向量、dot product 单位长度根据具体错误修正向量生成和归一化流程

迁移现有索引 #

不能通过更新旧索引 mapping 把旧 k-NN 字段或其他字段原地转换成原生 dense_vector。以下流程使用新索引、Reindex 和原子 alias 切换;前提是源 _source 中的向量已经是与目标 dims 一致的 JSON 浮点数组。

  1. 创建带有目标 dense_vector mapping 的新索引,例如 products-vector-v2
  2. 从当前索引 Reindex 到新索引:
POST /_reindex?wait_for_completion=false
{
  "source": {
    "index": "products-vector-v1"
  },
  "dest": {
    "index": "products-vector-v2"
  }
}
  1. 检查任务结果和 Bulk failure,核对文档数、mapping、代表性查询、Recall、错误率和资源占用。文档数相同不能替代查询验收。
  2. 在短暂停写窗口内同步增量数据,或提前使用双写保证两边一致。
  3. 如果应用通过 products-vector alias 访问索引,使用一个 _aliases 请求完成原子切换:
POST /_aliases
{
  "actions": [
    {
      "remove": {
        "index": "products-vector-v1",
        "alias": "products-vector"
      }
    },
    {
      "add": {
        "index": "products-vector-v2",
        "alias": "products-vector",
        "is_write_index": true
      }
    }
  ]
}
  1. 保留旧索引一段观察期。需要回滚时,用相反的 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"

这些是节点启动配置,默认分别为 false7.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
  • knn retriever;
  • 多个顶层 knn 子句;
  • sub_searchesrank
  • _source.exclude_vectors
  • nested kNN 和 inner_hits
  • byte、bit 和量化向量查询。

这些限制只针对本页的原生 HNSW。旧 k-NN 插件能力和语法继续按旧插件文档使用。