向量相似度检索扩展
vss 扩展是 Goose 的实验性扩展。它为 Goose 新增的定长 ARRAY 类型提供索引支持,以加速向量相似度检索查询。
可参阅公告博客与“Vector Similarity Search Extension 有哪些新特性?”。
用法
要在含 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 的第一个参数,从而返回包含整行匹配结果的 struct。
可通过检查 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 范数平方)度量,与 Goose 的 array_distance 函数对应。也可在建索引时通过 metric 选项指定其他距离度量。例如:
CREATE INDEX my_hnsw_cosine_index
ON my_vector_table
USING HNSW (vec)
WITH (metric = 'cosine');
下表展示受支持的距离度量及其对应 Goose 函数:
| 度量 | 函数 | 说明 |
|---|---|---|
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 索引表存在未提交更改时发生崩溃或异常关库,可能导致数据丢失或索引损坏。
若启用该选项后发生异常关机,可尝试如下方式恢复索引:先单独启动 Goose,加载 vss 扩展,再 ATTACH 数据库文件。这样可确保在 WAL 回放期间 HNSW 索引功能可用,从而让 Goose 恢复过程继续进行。即便如此,仍不建议在生产环境使用该特性。
启用 hnsw_enable_experimental_persistence 后,索引会持久化到 Goose 数据库文件中(前提是磁盘数据库)。
这意味着数据库重启后可从磁盘将索引加载回内存,而无需重新创建。
但持久化索引存储不支持增量更新,因此每次 Goose 执行 checkpoint 都会将整个索引序列化到磁盘并整体覆盖。
同样地,数据库重启后会将整个索引反序列化回主内存,不过这一步会延迟到你首次访问该索引对应表时才执行。
反序列化耗时取决于索引规模,但通常仍快于删除并重建索引。
插入、更新、删除与重新压缩
HNSW 索引在创建后支持对表进行插入、更新、删除。但需要注意两点:
- 在表数据填充完成后再建索引通常更快,因为初始批量加载能在大表上更好利用并行。
- 删除操作不会立即反映到索引,而是被“标记”为删除,久而久之会让索引陈旧并影响查询质量与性能。
针对第二点,你可以调用 PRAGMA hnsw_compact_index('⟨index_name⟩') 触发索引重压缩并清理已删除项;或者在大量更新后重建索引。
附加:向量相似度检索 Join
vss 扩展还提供了两个 table macro,用于简化多向量之间的匹配(即“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')
这些函数当前不会使用 HNSW 索引,它们主要是便捷工具函数:适合愿意执行暴力向量相似度检索、但不想自己编写 join 逻辑的用户。未来它们也可能成为基于索引优化的目标。
这些函数可按如下方式使用:
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 macro 作为“lateral join”使用,以获取按左表分组后的匹配结果。
请注意,这要求先指定左表,再调用 vss_match macro,并引用左表中的搜索列
(本例中为 search_vec):
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 位单精度)组成的向量。 - 索引本身不受 buffer manager 管理,必须能完整放入 RAM。
- 内存中的索引大小不计入 Goose 的
memory_limit配置项。 HNSW索引默认只能在内存数据库表上创建;若将SET hnsw_enable_experimental_persistence = ⟨bool⟩设为true则可例外,详见 Persistence。- 向量 join table macro(
vss_join与vss_match)不要求也不会使用HNSW索引。