vss
扩展是 DuckDB 的一个实验性扩展,它增加了索引支持,以加速使用 DuckDB 的新固定大小 ARRAY
类型的向量相似性搜索查询。
请参阅公告博客文章和“向量相似性搜索扩展中的新功能?”文章。
Usage
要在具有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;
此外,如果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) AS result FROM my_vector_table;
---- [{'vec': [1.0, 2.0, 3.0]}, {'vec': [1.0, 2.0, 4.0]}, {'vec': [2.0, 2.0, 3.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
扩展还提供了一些表宏来简化多个向量之间的匹配,即所谓的“模糊连接”。这些宏包括:
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) AS 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]} │
└───────┴─────────────────────────────────┴─────────────────────────────────────┘
-- Alternatively, we can use the vss_match macro as a "lateral join"
-- to get the matches already grouped by the left table.
-- Note that this requires us to specify the left table first, and then
-- the vss_match macro which references the search column from the left
-- table (in this case, `search_vec`).
SELECT * FROM needle, vss_match(haystack, search_vec, vec, 3) AS 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]}}] │
└─────────────────┴──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
Limitations
- 目前仅支持由
FLOAT
(32位,单精度)组成的向量。 - 索引本身不受缓冲区管理,必须能够放入RAM内存中。
- 内存中索引的大小不计入DuckDB的
memory_limit
配置参数。 HNSW
索引只能在内存数据库中的表上创建,除非将SET hnsw_enable_experimental_persistence = ⟨bool⟩
配置选项设置为true
,更多信息请参见 Persistence。- 向量连接表宏(
vss_join
和vss_match
)不需要或使用HNSW
索引。