vss 扩展是 DuckDB 的一个实验性扩展,它增加了索引支持,利用 DuckDB 新的定长 ARRAY(数组)类型来加速向量相似度搜索查询。
请参阅发布博文以及“向量相似度搜索扩展有哪些新功能?”文章。
用法
要在包含 ARRAY 列的表上创建新的 HNSW(分层可导航小世界)索引,请使用带有 USING HNSW 子句的 CREATE INDEX 语句。例如:
INSTALL vss;
LOAD vss;
CREATE TABLE my_vector_table (vec FLOAT[3]);
INSERT INTO my_vector_table
SELECT array_value(a, b, c)
FROM range(1, 10) ra(a), range(1, 10) rb(b), range(1, 10) rc(c);
CREATE INDEX my_hnsw_index ON my_vector_table USING HNSW (vec);
索引随后将用于加速查询,这些查询在 ORDER BY 子句中针对索引列和一个常量向量评估支持的距离度量函数,并紧随 LIMIT 子句。例如:
SELECT *
FROM my_vector_table
ORDER BY array_distance(vec, [1, 2, 3]::FLOAT[3])
LIMIT 3;
此外,重载的 min_by(col, arg, n) 函数如果 arg 参数是匹配的距离度量函数,也可以通过 HNSW 索引加速。这可用于进行快速的一次性最近邻搜索。例如,要获取与 [1, 2, 3] 最相似的 3 行向量:
SELECT min_by(my_vector_table, array_distance(vec, [1, 2, 3]::FLOAT[3]), 3 ORDER BY vec) AS result
FROM my_vector_table;
[{'vec': [1.0, 2.0, 3.0]}, {'vec': [2.0, 2.0, 3.0]}, {'vec': [1.0, 2.0, 4.0]}]
请注意,我们将表名作为第一个参数传递给 min_by,以返回包含整个匹配行的结构体。
我们可以通过检查 EXPLAIN 输出并查找计划中的 HNSW_INDEX_SCAN 节点,来验证索引是否正在使用:
EXPLAIN
SELECT *
FROM my_vector_table
ORDER BY array_distance(vec, [1, 2, 3]::FLOAT[3])
LIMIT 3;
┌───────────────────────────┐
│ PROJECTION │
│ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │
│ #0 │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│ PROJECTION │
│ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │
│ vec │
│array_distance(vec, [1.0, 2│
│ .0, 3.0]) │
└─────────────┬─────────────┘
┌─────────────┴─────────────┐
│ HNSW_INDEX_SCAN │
│ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │
│ t1 (HNSW INDEX SCAN : │
│ my_idx) │
│ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │
│ vec │
│ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │
│ EC: 3 │
└───────────────────────────┘
默认情况下,HNSW 索引将使用欧几里得距离 l2sq(L2 范数的平方)度量创建,这与 DuckDB 的 array_distance 函数相匹配。但在创建索引时,通过指定 metric 选项可以使用其他距离度量。例如:
CREATE INDEX my_hnsw_cosine_index
ON my_vector_table
USING HNSW (vec)
WITH (metric = 'cosine');
下表显示了支持的距离度量及其对应的 DuckDB 函数:
| 度量 | 函数 | 描述 |
|---|---|---|
l2sq |
array_distance |
欧几里得距离 |
cosine |
array_cosine_distance |
余弦相似度距离 |
ip |
array_negative_inner_product |
负内积 |
请注意,虽然每个 HNSW 索引仅适用于单个列,但您可以在同一张表上创建多个 HNSW 索引,每个索引单独索引不同的列。此外,您还可以在同一列上创建多个 HNSW 索引,每个索引支持不同的距离度量。
索引选项
除了 metric 选项外,HNSW 索引创建语句还支持以下选项来控制索引构建和搜索过程的超参数:
| 选项 | 默认值 | 描述 |
|---|---|---|
ef_construction |
128 | 索引构建期间要考虑的候选顶点数量。数值越高,生成的索引越精确,但构建索引所需的时间也会增加。 |
ef_search |
64 | 索引搜索阶段要考虑的候选顶点数量。数值越高,搜索结果越精确,但执行搜索所需的时间也会增加。 |
M |
16 | 每个顶点在图中保留的最大邻居数。数值越高,生成的索引越精确,但构建索引所需的时间也会增加。 |
M0 |
2 * M |
基础连接数,即图中第 0 层每个顶点保留的邻居数。数值越高,生成的索引越精确,但构建索引所需的时间也会增加。 |
此外,您还可以通过在运行时设置 SET hnsw_ef_search = int 配置选项,来覆盖在索引创建时设置的 ef_search 参数。如果您想在每个连接的基础上权衡搜索性能与准确性,这非常有用。您也可以通过调用 RESET hnsw_ef_search 来取消该覆盖设置。
持久性
由于与自定义扩展索引持久化相关的一些已知问题,默认情况下 HNSW 索引只能在内存数据库的表上创建,除非将 SET hnsw_enable_experimental_persistence = bool 配置选项设置为 true。
将此功能锁定在实验性标志后的原因是,“WAL”恢复尚未针对自定义索引正确实现。这意味着如果发生崩溃或数据库在对 HNSW 索引表有未提交更改时意外关闭,可能会导致数据丢失或索引损坏。
如果您启用了此选项并遇到了意外关机,可以尝试通过以下方式恢复索引:首先单独启动 DuckDB,加载 vss 扩展,然后 ATTACH 数据库文件。这确保了在 WAL 回放期间可以使用 HNSW 索引功能,从而使 DuckDB 的恢复过程顺利进行。但我们仍然建议您不要在生产环境中使用此功能。
启用 hnsw_enable_experimental_persistence 选项后,索引将被持久化到 DuckDB 数据库文件中(如果您使用基于磁盘的数据库文件运行 DuckDB),这意味着在数据库重启后,索引可以从磁盘加载回内存,而无需重新创建。需要注意的是,持久化索引存储没有增量更新,因此每当 DuckDB 执行检查点时,整个索引都会被序列化到磁盘并覆盖自身。同样,数据库重启后,索引将全部反序列化回主内存。尽管这会推迟到您第一次访问与索引关联的表时进行。根据索引的大小,反序列化过程可能需要一些时间,但这仍应比直接删除并重新创建索引要快。
插入、更新、删除与重新压缩
HNSW 索引支持在创建索引后对表进行插入、更新和删除行操作。但是,有两点需要注意:
- 在表填充数据后再创建索引速度更快,因为初始批量加载可以更好地利用大型表上的并行处理能力。
- 删除操作不会立即反映在索引中,而是被“标记”为已删除,这可能会导致索引随时间推移变得陈旧,并对查询质量和性能产生负面影响。
为了解决最后一点,您可以调用 PRAGMA hnsw_compact_index('index_name') pragma 函数来触发索引的重新压缩以修剪已删除的项目,或者在大量更新后重新创建索引。
奖励:向量相似度搜索连接(Joins)
vss 扩展还提供了一些表宏来简化多个向量之间的匹配,即所谓的“模糊连接”。这些包括:
vss_join(left_table, right_table, left_col, right_col, k, metric := 'l2sq')vss_match(right_table", left_col, right_col, k, metric := 'l2sq')
这些目前不使用 HNSW 索引,但作为方便的实用工具函数提供给那些愿意进行暴力向量相似度搜索,而无需自行编写连接逻辑的用户。将来这些也可能成为基于索引优化的目标。
这些函数的使用方法如下:
CREATE TABLE haystack (id int, vec FLOAT[3]);
CREATE TABLE needle (search_vec FLOAT[3]);
INSERT INTO haystack
SELECT row_number() OVER (), array_value(a, b, c)
FROM range(1, 10) ra(a), range(1, 10) rb(b), range(1, 10) rc(c);
INSERT INTO needle
VALUES ([5, 5, 5]), ([1, 1, 1]);
SELECT *
FROM vss_join(needle, haystack, search_vec, vec, 3) res;
┌───────┬─────────────────────────────────┬─────────────────────────────────────┐
│ score │ left_tbl │ right_tbl │
│ float │ struct(search_vec float[3]) │ struct(id integer, vec float[3]) │
├───────┼─────────────────────────────────┼─────────────────────────────────────┤
│ 0.0 │ {'search_vec': [5.0, 5.0, 5.0]} │ {'id': 365, 'vec': [5.0, 5.0, 5.0]} │
│ 1.0 │ {'search_vec': [5.0, 5.0, 5.0]} │ {'id': 364, 'vec': [5.0, 4.0, 5.0]} │
│ 1.0 │ {'search_vec': [5.0, 5.0, 5.0]} │ {'id': 356, 'vec': [4.0, 5.0, 5.0]} │
│ 0.0 │ {'search_vec': [1.0, 1.0, 1.0]} │ {'id': 1, 'vec': [1.0, 1.0, 1.0]} │
│ 1.0 │ {'search_vec': [1.0, 1.0, 1.0]} │ {'id': 10, 'vec': [2.0, 1.0, 1.0]} │
│ 1.0 │ {'search_vec': [1.0, 1.0, 1.0]} │ {'id': 2, 'vec': [1.0, 2.0, 1.0]} │
└───────┴─────────────────────────────────┴─────────────────────────────────────┘
或者,我们可以使用 vss_match 宏作为“横向连接(lateral join)”来获取已经按左表分组的匹配项。请注意,这要求我们首先指定左表,然后是引用左表搜索列(在此例中为 search_vec)的 vss_match 宏。
SELECT *
FROM needle, vss_match(haystack, search_vec, vec, 3) res;
┌─────────────────┬──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ search_vec │ matches │
│ float[3] │ struct(score float, "row" struct(id integer, vec float[3]))[] │
├─────────────────┼──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ [5.0, 5.0, 5.0] │ [{'score': 0.0, 'row': {'id': 365, 'vec': [5.0, 5.0, 5.0]}}, {'score': 1.0, 'row': {'id': 364, 'vec': [5.0, 4.0, 5.0]}}, {'score': 1.0, 'row': {'id': 356, 'vec': [4.0, 5.0, 5.0]}}] │
│ [1.0, 1.0, 1.0] │ [{'score': 0.0, 'row': {'id': 1, 'vec': [1.0, 1.0, 1.0]}}, {'score': 1.0, 'row': {'id': 10, 'vec': [2.0, 1.0, 1.0]}}, {'score': 1.0, 'row': {'id': 2, 'vec': [1.0, 2.0, 1.0]}}] │
└─────────────────┴──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
限制
- 目前仅支持由
FLOAT(32 位,单精度)组成的向量。 - 索引本身不受缓冲管理,必须能够放入 RAM 内存中。
- 内存中索引的大小不计入 DuckDB 的
memory_limit配置参数。 HNSW索引只能在内存数据库的表上创建,除非将SET hnsw_enable_experimental_persistence = bool配置选项设置为true,详情请参阅持久化。- 向量连接表宏(
vss_join和vss_match)不需要也不使用HNSW索引。