DuckDB 的 vss
扩展是一个实验性扩展,它添加了索引支持,以使用 DuckDB 新的固定大小 ARRAY
类型加速向量相似度搜索查询。
请参阅公告博客文章和“向量相似度搜索扩展有什么新功能?”文章。
用法
要在包含 ARRAY
列的表上创建新的 HNSW(Hierarchical Navigable Small Worlds)索引,请使用带 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;
此外,如果 arg
参数是匹配的距离度量函数,则重载的 min_by(col, arg, n)
也可以通过 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 |
基础连接性,即图中零层每个顶点保留的邻居数量。值越高,索引越准确,但构建索引所需的时间也会增加。 |
此外,您还可以在运行时通过设置 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 函数来触发索引的重新压缩,修剪已删除的项,或者在大量更新后重新创建索引。
附赠:向量相似度搜索连接
vss
扩展还提供了一些表宏,以简化多个向量之间的匹配,即所谓的“模糊连接”(fuzzy joins)。它们是
vss_join(left_table, right_table, left_col, right_col, k, metric := 'l2sq')
vss_match(right_table", left_col, right_col, k, metric := 'l2sq')
k
是从right_table
中为每个left_table
记录选择的记录数量,按分数排序。
这些目前不使用 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, 2) 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': 356, 'vec': [5.0, 5.0, 4.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': 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, 2) 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': 356, 'vec': [5.0, 5.0, 4.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': 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
索引。