HNSW(Malkov & Yashunin, 2016)由于在工程实践中能以较小的资源达到高召回和低延迟的查询表现,已成为在线高性能向量检索的事实标准。SelectDB 自 5.x 版本起支持基于 HNSW 的 Ann Index,本文将从 HNSW 算法原理出发,结合参数与工程实践,讲如何在 SelectDB 生产集群中使用与调优基于 HNSW 算法的 ANN 索引。
HNSW 背景
HNSW In SelectDB
SelectDB 从 5.0 版本开始支持用户建立基于 HNSW 算法的索引。
索引构建
这类索引的具体类型是 ANN。创建 ANN 索引的方式有两种。一种是建表时指定,还有一种是通过 CREATE/BUILD INDEX 语法。我们会说明这两种创建索引的区别以及应用场景。
方式一,建表时指定在某个向量列上创建索引。建表后导入数据时会随着每个 segment 的创建,构造作用范围为该 segment 的 ANN 索引。这种方式的好处是随着数据导入完成,索引同步完成构造,因此后续的查询立刻就可以使用 ANN 索引进行加速。这种方式的缺点是索引的同步构造过程会导致导入过程变得比较慢,并且在 compaction 过程中会引入索引的重复构建,有一定程度的资源浪费。
CREATE TABLE sift_1M (
id int NOT NULL,
embedding array<float> NOT NULL COMMENT "",
INDEX ann_index (embedding) USING ANN PROPERTIES(
"index_type"="hnsw",
"metric_type"="l2_distance",
"dim"="128"
)
) ENGINE=OLAP
DUPLICATE KEY(id) COMMENT "OLAP"
DISTRIBUTED BY HASH(id) BUCKETS 1
PROPERTIES (
"replication_num" = "1"
);
INSERT INTO sift_1M
SELECT *
FROM S3(
"uri" = "https://selectdb-customers-tools-bj.oss-cn-beijing.aliyuncs.com/sift_database.tsv",
"format" = "csv");
CREATE/BUILD INDEX
方式二,CREATE/BUILD INDEX
CREATE TABLE sift_1M (
id int NOT NULL,
embedding array<float> NOT NULL COMMENT ""
) ENGINE=OLAP
DUPLICATE KEY(id) COMMENT "OLAP"
DISTRIBUTED BY HASH(id) BUCKETS 1
PROPERTIES (
"replication_num" = "1"
);
INSERT INTO sift_1M
SELECT *
FROM S3(
"uri" = "https://selectdb-customers-tools-bj.oss-cn-beijing.aliyuncs.com/sift_database.tsv",
"format" = "csv");
导入数据后 CREATE INDEX,此时 table 上已经有了 index 的定义,但是没有真正在存量数据上构建索引。
CREATE INDEX idx_test_ann ON sift_1M (`embedding`) USING ANN PROPERTIES (
"index_type"="hnsw",
"metric_type"="l2_distance",
"dim"="128"
);
SHOW DATA ALL FROM sift_1M
+-----------+-----------+--------------+----------+----------------+---------------+----------------+-----------------+----------------+-----------------+
| TableName | IndexName | ReplicaCount | RowCount | LocalTotalSize | LocalDataSize | LocalIndexSize | RemoteTotalSize | RemoteDataSize | RemoteIndexSize |
+-----------+-----------+--------------+----------+----------------+---------------+----------------+-----------------+----------------+-----------------+
| sift_1M | sift_1M | 1 | 1000000 | 170.001 MB | 170.001 MB | 0.000 | 0.000 | 0.000 | 0.000 |
| | Total | 1 | | 170.001 MB | 170.001 MB | 0.000 | 0.000 | 0.000 | 0.000 |
+-----------+-----------+--------------+----------+----------------+---------------+----------------+-----------------+----------------+-----------------+
2 rows in set (0.01 sec)
通过 BUILD INDEX 语句来完成索引的构建工作:
BUILD INDEX idx_test_ann ON sift_1M;
BUILD INDEX 是异步执行的,需要通过 SHOW ALTER 来查看任务的执行状态。
SHOW BUILD INDEX WHERE TableName = "sift_1M"
--------------
+---------------+-----------+---------------+------------------------------------------------------------------------------------------------------------------------------------+-------------------------+-------------------------+---------------+----------+------+----------+
| JobId | TableName | PartitionName | AlterInvertedIndexes | CreateTime | FinishTime | TransactionId | State | Msg | Progress |
+---------------+-----------+---------------+------------------------------------------------------------------------------------------------------------------------------------+-------------------------+-------------------------+---------------+----------+------+----------+
| 1763603913428 | sift_1M | sift_1M | [ADD INDEX idx_test_ann (`embedding`) USING ANN PROPERTIES("dim" = "128", "index_type" = "hnsw", "metric_type" = "l2_distance")], | 2025-11-20 11:14:55.253 | 2025-11-20 11:15:10.622 | 126128 | FINISHED | | NULL |
+---------------+-----------+---------------+------------------------------------------------------------------------------------------------------------------------------------+-------------------------+-------------------------+---------------+----------+------+----------+
DROP INDEX
同样可以通过 ALTER TABLE sift_1M DROP INDEX idx_test_ann 来删除不合适的 Ann 索引。DROP INDEX 通常发生在索引的超参数调优阶段,为了确保足够的召回率需要测试不同的参数组合,需要灵活的索引管理。
进行查询
ANN 索引支持对 topn search 还有 range search 进行加速。
当向量列是高维向量时,用于描述查询向量的字符串本身会引入额外的解析开销,因此不建议在生产环境中,尤其是高并发场景里,直接使用原始 SQL 执行向量搜索查询。使用 prepare statement 来提前对 sql 进行解析是一个能够提高查询性能的做法,所以建议使用 SelectDB 的向量搜索 python library,在这个 python library 里面封装了基于 prepare statement 对 SelectDB 进行向量搜索的必要的操作,并且集成了相关的数据转化流程,可以直接将 SelectDB 的查询结果转为 pandas 的 DataFrame,方便用户基于 SelectDB 开发 AI 应用。
from SelectDB_vector_search import SelectDBVectorClient, AuthOptions
auth = AuthOptions(
host="localhost",
query_port=9030,
user="root",
password="",
)
client = SelectDBVectorClient(database="demo", auth_options=auth)
tbl = client.open_table("sift_1M")
query = [0.1] * 128 # Example 128-dimensional vector
# SELECT id FROM sift_1M ORDER BY l2_distance_approximate(embedding, query) LIMIT 10;
result = tbl.search(query, metric_type="l2_distance").limit(10).select(["id"]).to_pandas()
print(result)
上面的 python 脚本执行结果为:
id
0 123911
1 11743
2 108584
3 123739
4 73311
5 124746
6 620941
7 124493
8 177392
9 153178
召回率优化
向量搜索场景里面最重要的指标是召回率,一切性能数据只有在满足一定的召回率的前提下才有意义。影响召回率的因素主要包括:
-
HNSW 索引阶段的参数(max_degree, ef_construction)以及查询阶段的参数(ef_search)
-
索引向量量化
-
segment 的大小与数量
这篇文章里我们将会讨论 1,3 对于召回率的影响,关于向量量化会在其他的文章里进行介绍。
索引超参数
HNSW 索引以分层图的形式组织向量。在构建阶段,向量逐个插入索引中,并在多层结构中寻找邻居节点。构建流程包括:
-
多层随机赋级(Layer assignment):每个向量被随机分配到多个层中,较高层节点更稀疏,用于快速导航。
-
使用 ef_construction 搜索候选邻居: 在每一层,HNSW 使用一个最大长度为 ef_construction 的候选队列来执行广度优先的局部搜索。 更大的 ef_construction 能找到更准确的邻居,使图结构更合理、搜索质量更高,但构建时间会更长。
-
使用 max_degree 限制连接数: 每个节点的邻居数受到 max_degree 的限制,保证图结构不会过于稠密。
在查询阶段:
-
高层贪心搜索(Coarse search): 从入口点开始,自顶向下在高层执行贪心搜索,快速找到接近目标区域的节点。
-
底层使用 ef_search 执行广度搜索(Fine search): 在第 0 层,HNSW 使用最大长度为 ef_search 的候选队列进行更全面的邻域扩展。
总之:
-
max_degree定义了图中每个节点保存的双向边的个数,该参数会影响召回率,内存使用率以及查询性能。更大的max_degree会提高召回率,但是会降低查询性能。 -
ef_construction定义了索引阶段用来保存候选节点的队列的最大长度,增大ef_construction可以提高图的质量,获得更高的召回率,但是也会导致索引构建时间变长。 -
对应
ef_search定义了查询阶段候选节点队列的最大长度,更大的ef_search也会提高召回率,但是会导致搜索时距离计算次数变多,查询延迟变高,并且 CPU 开销变大。
SelectDB 默认的 max_degree 为 32,默认的 ef_construction 和 ef_search 分别为 40 和 32。
上述测试都是对这三个超参数定性的分析,通过实际实验,在 SIFT_1M 数据集上有如下的测试结果
|
max_degree |
ef_construction |
ef_search |
recall_at_1 |
recall_at_100 |
|
32 |
80 |
32 |
0.955 |
0.75335 |
|
32 |
80 |
64 |
0.98 |
0.88015 |
|
32 |
80 |
96 |
0.995 |
0.9328 |
|
32 |
120 |
32 |
0.96 |
0.7736 |
|
32 |
120 |
64 |
0.975 |
0.89865 |
|
32 |
120 |
96 |
0.99 |
0.94575 |
|
32 |
160 |
32 |
0.955 |
0.78745 |
|
32 |
160 |
64 |
0.98 |
0.9097 |
|
32 |
160 |
96 |
0.995 |
0.95485 |
|
48 |
80 |
32 |
0.985 |
0.85895 |
|
48 |
80 |
64 |
0.99 |
0.9453 |
|
48 |
80 |
96 |
1 |
0.97325 |
|
48 |
120 |
32 |
0.97 |
0.78335 |
|
48 |
120 |
64 |
1 |
0.9089 |
|
48 |
120 |
96 |
1 |
0.95325 |
|
48 |
160 |
32 |
0.975 |
0.79745 |
|
48 |
160 |
64 |
0.995 |
0.9192 |
|
48 |
160 |
96 |
0.995 |
0.9601 |
|
64 |
80 |
32 |
1 |
0.9026 |
|
64 |
80 |
64 |
1 |
0.97025 |
|
64 |
80 |
96 |
1 |
0.9862 |
|
64 |
120 |
32 |
0.985 |
0.8548 |
|
64 |
120 |
64 |
0.99 |
0.94755 |
|
64 |
120 |
96 |
0.995 |
0.97645 |
|
64 |
160 |
32 |
0.97 |
0.80585 |
|
64 |
160 |
64 |
0.99 |
0.91925 |
|
64 |
160 |
96 |
0.995 |
0.96165 |
从实际测试结果来看,为了达到同一水平的召回率,可以有不同的超参数组合方案。比如假设目标是 top 100 的召回率大于 95%,满足条件的组合有:
|
max_degree |
ef_construction |
ef_search |
recall_at_1 |
recall_at_100 |
|
32 |
160 |
96 |
0.995 |
0.95485 |
|
48 |
80 |
96 |
1 |
0.97325 |
|
48 |
120 |
96 |
1 |
0.95325 |
|
48 |
160 |
96 |
0.995 |
0.9601 |
|
64 |
80 |
64 |
1 |
0.97025 |
|
64 |
80 |
96 |
1 |
0.9862 |
|
64 |
120 |
96 |
0.995 |
0.97645 |
|
64 |
160 |
96 |
0.995 |
0.96165 |
虽然很难事先给出超参数的具体取值,但是我们可以给出一个关于如何选取超参数的实践方法:
-
建立一张无索引的表 table_multi_index,table_multi_index 可以有 2 或者 3 个向量列。
-
通过 stream load 等方式将数据导入到无索引的 table_multi_index。
-
通过
CREATE INDEX和BUILD INDEX在所有的向量列上构建索引。 -
不同的列选择不同的索引参数,等索引构建完成后在不同的列上计算召回率,找到最合适的超参数组合。
索引覆盖的行数
SelectDB 内表的数据是分层组织的。最高层级的概念是 Table,Table 按照分桶键把原始数据尽可能均匀地分布到 N 个 tablets 里面,tablet 是用来进行数据迁移与rebalance的基本单位。每次导入或者compaction会在tablet下新增一个rowset,rowset是进行版本管理的单位,其本身只是代表一组具有版本号的数据,这组数据真正存储在 segment 文件里。
与倒排索引一样,向量索引也作用在 segment 粒度上。segment 本身的大小取决于 be conf 中的 write_buffer_size 和 vertical_compaction_max_segment_size,在导入和 compaction 过程中,当内存中 memtable 的积累到一定大小后就会下刷生成一个 segment 文件,并且为该 segment 构造一个向量索引(如果有多个索引列那么就有多个索引),该索引能够覆盖的范围就是这个 segment 中对应列的行数。根据前面对 HNSW 算法搜索与构建过程的介绍,对于某组索引参数,其能够有效覆盖的数据范围是有限的,当数据量超过某个阈值后,召回率就无法满足要求。
这里我们给出一些索引超参数和其能够覆盖的 segment 行数的经验值
|
max_degree |
ef_construction |
ef_search |
num_segment |
recall_at_100 |
|
32 |
160 |
96 |
1M |
0.95485 |
|
48 |
80 |
96 |
1M |
0.97325 |
|
32 |
160 |
32 |
3M |
0.66983 |
|
128 |
512 |
128 |
3M |
0.9931 |
通过 SHOW TABLETS FROM table 可以看到某张表的 Compaction 状态,点开对应的 URL 可以看到这张表有多少个 segment。
Compaction 对召回率的影响
Compaction 之所以会影响召回率是因为 compaction 有时会生成更大的 segment,导致原先的索引超参数无法在新的更大的 segment 上保障覆盖率。因此建议在 BUILD INDEX 之前触发一次 FULL COMPACTION,在充分合并过的 segment 上构建索引不光可以保持召回率稳定,还可以减少索引构建引入的写放大。
查询性能
索引文件的冷加载
SelectDB 的 ANN 索引是基于 Meta 开源的 faiss实现的。HNSW 索引需要在完整的图结构全部被加载进内存后才能进行查询加速,因此建议在高并发查询之前,先进行一次冷查询,确保涉及到的 segment 的索引文件全部加载进了内存,否则对于查询性能会有较大影响。
内存空间与性能
HNSW 索引(无量化压缩)占用的内存空间近似等于其所能检索的向量的内存大小的 1.2 倍。比如对于 128 维,1M 的数据集,HNSW FLAT 索引需要的内存空间大约为 128 * 4 * 1000000 * 1.3 约等于 650 MB。
|
dim |
rows |
预估内存 |
|
128 |
1M |
650MB |
|
768 |
10M |
48GB |
|
768 |
100M |
110GB |
为了保证查询性能,需要 BE 有足够的内存空间,否则索引的频繁 IO 会导致查询性能大幅衰减。

