从内存占用、构建时间、召回率角度量化对比pgvector两种索引类型,给出实际查询计划分析方法,避免选型误区。
pgvector 有两种索引类型,需要调节的参数一共四个。本文将推导每个参数的代价——内存占用可以精确计算,构建时间则遵循一种标度律(从一个小样本外推即可)——并提供用于测量召回率的 SQL,这个指标没有任何公式能给你,只能实测。
HNSW(pgvector 0.5.0 及以上)构建的是一种分层邻近图。查询时从稀疏的顶层向下走到稠密的底层。它在几乎所有运行点上都能以更少的查询时间达到比 IVFFlat 更高的召回率,而且能优雅地处理插入操作。它的代价是构建缓慢,且索引是向量数据的完整副本。
IVFFlat 通过 k-means 将向量划分为若干列表聚类,查询时只搜索最近的若干个探针(probe)。它的构建速度快得多,内存占用也低得多。但它有两个实际问题:必须在有数据的基础上构建,因为空表没有东西可供聚类;随着数据逐渐偏离它学到的聚类中心,召回率会持续下降,因此需要定期重建索引。参见 embedding drift 了解导致这种漂移的原因。
诚实的默认选择是 HNSW。在以下情况下选择 IVFFlat:索引必须频繁且快速地重建、内存是刚性约束、或者在数千万行数据上创建索引而机器无法容纳 HNSW 图。除此之外,多花的构建时间都是值得的。
这是可以推导的,而且推导结果比人人都在传的那条经验规则更有用。pgvector 中的 HNSW 索引,每行存储:一份向量副本、一组邻居指针,以及每条元组的页面开销。
vector(d) 是 4d + 8 字节——每个 float32 分量 4 字节,加一个 8 字节的头部。
在标准 HNSW 中,一个元素在第 0 层最多有 2m 个连接,上层每层有 m 个。层级是按几何分布抽取的,抵达第 l 层的概率是 m^(-l),因此除零层以上的期望层级数为该级数的和:1/(m-1)。于是每个元素的期望连接槽位数为:
slots(m) = 2m + m/(m−1)
m = 16 -> 32 + 1.07 = 33.1 slots
m = 32 -> 64 + 1.03 = 65.0 slots
m = 64 -> 128 + 1.02 = 129.0 slots
每个槽位存一个元组指针,6 字节。加上每个元素约 32 字节的索引元组和行指针开销。整体为:
bytes_per_row(d, m) ≈ (4d + 8) 向量有效载荷
+ 6 × slots(m) 邻居指针
+ 32 元组开销
假设全部可验证:float32 向量、6 字节元组指针、
标准 HNSW 层级分布、未计入页面填充余量。
以 d = 1536 为例——这是若干主流 embedding 模型的维度——取 m = 16:
(4 × 1536 + 8) + 6 × 33.1 + 32
= 6152 + 199 + 32
= 6383 bytes per row
× 1,000,000 rows = 6.38 GB of index
而有趣的部分来了——如果把 m 增加到 64(调优指南建议这样做以提高召回率),会发生什么:
d = 1536, m = 64: 6152 + 774 + 32 = 6958 B -> 6.96 GB (+9%)
d = 384, m = 16: 1544 + 199 + 32 = 1775 B -> 1.78 GB
d = 384, m = 64: 1544 + 774 + 32 = 2350 B -> 2.35 GB (+32%)
所以那个常见的警告——增大 m 内存代价很高——在 384 维时成立,而在 1536 维时基本不成立,因为此时向量副本占据主导,整个图结构只占索引的百分之三。如果你在高维模型上且召回率不够,m 是一个比指南暗示的更廉价的调节杠杆。如果你用的是低维模型,则不是。
只需要一条查询,如果结果与公式相差超过约百分之十五,请相信查询结果:
SELECT pg_size_pretty(pg_relation_size('chunks_embedding_hnsw')) AS index_size,
pg_size_pretty(pg_relation_size('chunks')) AS heap_size,
(SELECT count(*) FROM chunks) AS rows;
索引要快,就必须驻留在内存中。合理设置 shared_buffers,让机器的内存足以容纳上面的数字加上你堆的 working set。HNSW 遍历是一连串随机访问,而随机访问磁盘正是 RAM 与 NVMe 差异在每一条查询上都可见的场景。
没有人能告诉你构建需要多久,因为这取决于你的 CPU、内存带宽和并行 worker 数量。但可以陈述的是它如何随规模伸缩,这就足够规划一次迁移了。
构建图的过程是插入 N 个元素。每次插入做一次贪婪下降穿过上层,然后是第 0 层搜索,期间维持 ef_construction 个候选元组存活,并从每个候选最多扩展 m 个邻居,每次扩展都要计算一个 d 维距离。因此每次插入的工作量正比于 ef_construction × m × d,而跳数随 log N 增长:
build_time ∝ N × log N × ef_construction × m × d
由此得到四条实用规则。将 ef_construction 加倍大约使构建时间翻倍,但完全不改变索引大小。将 m 加倍大约使构建时间翻倍,索引大小变化如前文推导。将维度加倍大约使构建时间翻倍。将行数加倍则略多于翻倍。
在一个 10 万行的副本上构建索引,然后按比例外推:
T(N) ≈ T(n) × (N/n) × (ln N / ln n)
样本:100,000 行构建耗时 95 秒。
目标:10,000,000 行。
T = 95 × (10,000,000 / 100,000) × (ln 10^7 / ln 10^5)
= 95 × 100 × (16.12 / 11.51)
= 95 × 100 × 1.40
= 13,300 s ≈ 3 h 42 min
此估算仅在目标构建也能容纳在 maintenance_work_mem 中时有效。
如果发生溢出,此估算毫无意义,实际时间是它的若干倍。
这个警告才是关键。在开始大规模构建之前,把 maintenance_work_mem 设置为前一小节约来的内存值之上——要留足余量,因为构建过程持有的内存比最终索引更多——并将 max_parallel_maintenance_workers 设置为你能腾出的核心数。用以下方式监控构建过程:
SELECT phase,
round(100.0 * blocks_done / nullif(blocks_total, 0), 1) AS pct
FROM pg_stat_progress_create_index;
召回率无法推导。它取决于你 embedding 的内在维度和聚类特性,这是你的语料和模型的属性,别人数据上的召回率数字跟你毫无关系。所以,去测量它。以下在一次 psql 会话中对你的实际表运行:
BEGIN;
-- 200 个探针向量。用留出的真实查询更好;
-- 如果你有查询日志,嵌入其中 200 条来使用,而不要用表行。
CREATE TEMP TABLE probes AS
SELECT id, embedding FROM chunks ORDER BY random() LIMIT 200;
-- ground truth:精确 top-10,强制禁用索引。
SET LOCAL enable_indexscan = off;
CREATE TEMP TABLE truth AS
SELECT p.id AS probe, t.id AS hit
FROM probes p
CROSS JOIN LATERAL (
SELECT c.id FROM chunks c
WHERE c.id <> p.id
ORDER BY c.embedding <=> p.embedding
LIMIT 10
) t;
-- 近似:用索引,在生产中计划的 ef_search 下。
SET LOCAL enable_indexscan = on;
SET LOCAL enable_seqscan = off;
SET LOCAL hnsw.ef_search = 40;
CREATE TEMP TABLE approx AS
SELECT p.id AS probe, t.id AS hit
FROM probes p
CROSS JOIN LATERAL (
SELECT c.id FROM chunks c
WHERE c.id <> p.id
ORDER BY c.embedding <=> p.embedding
LIMIT 10
) t;
SELECT round(100.0 * count(a.hit) / count(*), 2) AS recall_at_10_pct
FROM truth t
LEFT JOIN approx a ON a.probe = t.probe AND a.hit = t.hit;
ROLLBACK;
在 hnsw.ef_search 为 20、40、100、200 和 400 时各跑一遍,你就有了在真实数据上、约十分钟内得到的自己的召回率-延迟曲线。这条曲线是选择这些参数的唯一依据。
从索引表中抽取的探针是一种乐观测试:每个探针本身就是图中一个连接度很高的节点,它的邻域很容易到达。真实查询落在文档之间的缝隙上,得分更低。如果这些数字对你的决策重要,用你日志中的真实查询来嵌入。
m 和 ef_construction 在构建时固定,改任何一个都得重建索引。hnsw.ef_search 是一个会话级 GUC,每条查询都可以改,而这才是你几乎所有调优工作应该发生的地方。
SET LOCAL hnsw.ef_search = 100; -- 默认 40,最大 1000
SELECT id, content
FROM chunks
ORDER BY embedding <=> $1
LIMIT 10;
它控制第 0 层搜索维持多少个候选。查询代价大致与它线性相关,召回率则呈边际递减上升。它至少要等于你的 LIMIT;如果查 top-50 却把它设为 10,是一个静默的召回率灾难。请用 SET LOCAL 而不是 SET,否则这个值会在连接池中保留并作用于下一条无关的请求——在 AI 工作负载的连接池一文中有详细讨论这个陷阱。
IVFFlat 有一个构建参数和一个查询参数,官方自己的指导是一个不错的起点:百万行以下按 rows/1000 作为 lists 数,之后按 sqrt(rows);probes 从 sqrt(lists) 起步。
-- 5,000,000 行:lists = sqrt(5e6) ≈ 2236
CREATE INDEX chunks_embedding_ivf
ON chunks USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 2236);
SET LOCAL ivfflat.probes = 47; -- sqrt(2236)
背后的算术需要理解:N 行数据划分到 lists 个分区,每个分区约含 N/lists 个向量,搜索 probes 个分区时精确扫描约 probes × N / lists 个向量。按上面的数字,约为 47 × 5,000,000 / 2236 ≈ 105,000 个向量每次查询,即表的百分之二。把 probes 加到等于 lists,你就用更多的步骤重新发明了顺序扫描。
真实最近邻落在了一个你没有探到的聚类中,再跑多少遍也找不到它。在任何大批量加载后重建索引,用上面的召回率测试套件(它不关心你用哪种索引类型),如果在你的延迟预算内无法达到可接受的召回率,这就是切换到 HNSW 的信号。
四个参数对你注意力的价值并不均等,上面的推导解释了原因。按以下顺序逐个尝试,在召回率测试报告出一个你能接受的数字时停下。
改它没有代价,按查询生效,不需要重建。查询代价大致与它线性相关,所以从 40 加到 100 花的是大约 2.5 倍的遍历工作量——而对于一个驻留在内存中的索引,是几毫秒变成了稍微多几毫秒。大多数以为自己在做索引调优的团队,ef_search 是 40,而他们的召回率需求需要的是 200。
它改变的是图的质量而非搜索的努力程度,所以它提升的是 ef_search 所要冲击的天花板。它使构建时间线性增长,但不改变索引大小,这是两个需要重建的参数中更便宜的一个。从 64 加到 200 是常见且站得住脚的做法。
提高它会增加连接数,在 embedding 高维且聚类不佳时帮助最大。用前文的内存推导来决定是否负担得起:在 1536 维时,把 m 翻两番(16→64)花费索引大小的百分之九;在 384 维时花费三分之一。这种不对称性正是做算术而不是follow经验规则的的全部理由。
维度减半,索引内存减半,构建时间减半,查询代价大致也减半,同时发生。如果你的模型支持截断,这是本文所有参数中最大的杠杆,而且应该在任何上面三个参数之前就被纳入讨论。
重建足够昂贵,把它们打包的冲动很强烈,但这会让你无法归因结果。改一个,用测试套件测量,把数字记下来。四个测量点比一个靠直觉得出且现在无法辩护的配置有价值得多。
pgvector From Install to First Query
Storing Embeddings: Types, Precision and Row Size
Migrations on a Table With 50 Million Vectors