Skip to main content

HNSW 还是 IVFFlat

多数 RAG 项目起步时不需要独立的向量数据库。语料在百万条切片以内、已经有 Postgres、还要按租户或权限做过滤——这三条里命中两条,pgvector 就够了,而且省掉一整套数据同步。

真正的分水岭是索引类型和参数。选错索引,表现是"数据量一上去查询就变慢,而且召回莫名其妙地差"。

HNSW分层近邻图 · 建完可持续插入查询快 · 召回高 · 内存与构建成本高IVFFlatk-means 分桶 · 先有数据再建构建快 · 省内存 · 数据漂移后召回下降默认选 HNSW;只有当构建时间或内存是硬约束时才考虑 IVFFlat
图:两种近似索引的结构差别,决定了它们在插入与召回上的表现

1. 先确认要不要索引​

没有索引时,pgvector 走顺序扫描:逐行算距离,结果 100% 准确,代价是延迟随数据量线性增长。

几万条以内这样跑完全没问题。超过这个量级就要建近似索引(ANN),代价是结果从"精确"变成"近似"——这是所有 ANN 索引的固有取舍,不是 pgvector 的问题。

1.1 先确认维度:这是一条硬约束​

pgvector 能建索引的维度是有上限的,超了不是变慢,是索引建不出来(列能建、数据能存,查询永久退化成顺序扫描):

类型索引维度上限
vector(float32)2000
halfvec(float16)4000
bit(二值)64000

这条限制会直接卡住模型选型:输出 3072 维的模型在 vector 上建不了索引——而 3072 维恰恰是多语言场景常见的选择(见《跨语言检索的三条路》里关于维度与存储的那笔账)。三条出路:改用支持截断到 1024 或 1536 维的模型、把列类型换成 halfvec 存、或者换掉 pgvector。

定模型之前先查它实际输出的维度,再决定列类型。 这个顺序反了的话,代价是全库重灌。

2. 两种索引,差别在结构​

HNSWIVFFlat
结构分层近邻图k-means 分桶
查询速度快较慢
召回上限高中等
构建速度慢快
内存占用高低
空表建索引可以不行,需要代表性数据
增量插入保持召回召回会退化,需定期重建
查询期可调参数hnsw.ef_searchivfflat.probes

默认选 HNSW,除非构建时间或内存是硬约束。理由不是"它更先进",而是它在运维上省事:可以在空表上建、插入后召回不退化、对数据漂移不敏感。IVFFlat 的召回依赖于"建索引时的数据分布",数据后来变了但索引没重建,召回会悄悄下降——这类问题很难发现。

3. HNSW 的三个参数​

3.1 构建期:m 和 ef_construction​

CREATE INDEX ON chunks USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
  • m:每个节点的连接数,默认 16。调大(24–48)提升召回,同时增大索引体积和构建时间。
  • ef_construction:构建时的候选池大小,默认 64,常用范围 64–400。它必须至少是 m 的两倍——小于 2 * m 时 pgvector 会直接报错拒绝建索引,不是"图质量差一点",是根本建不出来。

这两个参数一旦建完就改不了,要改只能重建索引。所以策略是:先用默认值建,用真实查询测召回,不够再调大重建。不要一上来就把参数拉满——构建时间和内存会跟着涨。

BEGIN;
SET LOCAL hnsw.ef_search = 100;
SELECT id, content FROM chunks ORDER BY embedding <=> $1 LIMIT 10;
COMMIT;

ef_search 默认值是 40,有效范围 1 到 1000。这是生产环境里唯一真正需要反复调的旋钮:调大提升召回,代价近似线性增加的延迟。

有一条硬规则容易踩:ef_search 必须不小于你要取的条数。取 50 条却设了 ef_search = 40,结果只会返回 40 条——不是质量变差,是条数不够,LIMIT 50 拿不满。反过来,因为默认值就是 40,任何 LIMIT 超过 40 的查询如果不显式调大它,都会悄悄少返回(这在没有过滤条件时最容易被忽略)。常见做法是 ef_search 取 LIMIT 的 2 到 4 倍起步。

3.3 构建期的资源​

HNSW 构建吃内存。如果图放不进 maintenance_work_mem,会退化到慢得多的磁盘路径,构建时间从分钟级变小时级:

SET maintenance_work_mem = '8GB';
SET max_parallel_maintenance_workers = 7;
CREATE INDEX CONCURRENTLY ON chunks USING hnsw (embedding vector_cosine_ops);

CONCURRENTLY 不阻塞写入,但耗时更长,失败会留下无效索引——建完记得查一次:

SELECT indexrelid::regclass FROM pg_index WHERE NOT indisvalid;

4. IVFFlat 什么时候还值得用​

两种索引都能用时,IVFFlat 的取舍在构建成本:

-- 必须有代表性数据之后再建
CREATE INDEX ON chunks USING ivfflat (embedding vector_cosine_ops) WITH (lists = 500);
SET ivfflat.probes = 22; -- 常用起点:sqrt(lists)
  • lists:聚类数。常用起点是"行数 / 1000"(百万行以内)或 sqrt(行数)(更大规模)。
  • probes:查询时搜索几个桶,默认 1,常用起点 sqrt(lists)。

一条必须记住的纪律:数据太少时不要建 IVFFlat。用 500 行数据建 lists = 1000 的索引,等于让 k-means 把 1000 个聚类中心塞进 500 个点里,聚类质量是垃圾,而且后面数据补上来也不会自动变好。

适合它的场景是:数据量很大、批量更新、内存吃紧、能接受定期重建。

5. 过滤查询:真正的难点​

纯向量检索在生产里很少见。绝大多数查询长这样:

SELECT id, content FROM chunks
WHERE tenant_id = $2 AND lang = 'zh'
ORDER BY embedding <=> $1
LIMIT 10;

问题在于:索引先按向量距离找出最近的 10 条,再拿 WHERE 去过滤——很可能过滤完只剩 3 条,甚至 0 条。返回行数不足是这类查询最典型的症状。

两个解法:

迭代扫描(pgvector 0.8+)。 让索引在候选被过滤掉后继续往下扫:

SET hnsw.iterative_scan = relaxed_order;  -- 或 strict_order
SET hnsw.max_scan_tuples = 20000;

strict_order 保证距离顺序严格,relaxed_order 更快、顺序略松——如果后面还有重排,用 relaxed 就行。

部分索引。 过滤条件基数低时(比如按租户),给每个租户建独立索引是另一种思路,代价是索引数量膨胀。

过滤和检索顺序这个话题在《混合检索与 RRF 融合》里从另一个角度讲过:过滤必须在生成候选之前,而不是之后。这里的迭代扫描,就是数据库层面的对应手段。

6. 三个上线前该做的事​

预热。 索引不在内存里,延迟尾巴会很长,而且这个尾巴不是调参能解决的。上线前用 pg_prewarm 把索引加载进缓存,再用真实查询跑几千次预热。

测召回,别猜。 召回是"近似搜索返回的真正最近邻比例"。测法是把索引关掉跑一次精确扫描当基准,再开索引跑一次,比较重合度:

SET enable_indexscan = off;
SET enable_bitmapscan = off;
-- 精确结果存进 truth
-- 再开索引跑一次存进 approx,比较两者重合比例

确认算子匹配。 ORDER BY 用的算子必须和建索引时的算子类一致,不一致会直接退化成顺序扫描。用 EXPLAIN 看执行计划里到底有没有走索引。

7. 选型矩阵:什么时候该换独立向量库​

pgvector 不适合的信号很清楚:切片量到了千万级且还在涨、需要多租户物理隔离、或者团队已经在用专门的检索服务(托管重排、托管的稀疏检索等)。

但在那之前,多一套系统就意味着多一份同步逻辑、多一份一致性问题和多一份运维。能少一套就少一套。

真到了要选的时候,主流几家的取舍大致是这样:

pgvectorQdrantMilvusWeaviatePinecone
形态Postgres 扩展独立服务,也可嵌入式跑独立的分布式服务独立服务全托管
运维负担最低,跟着数据库走中重(依赖 etcd、对象存储)中无
过滤能力最强,就是 SQLpayload 过滤,可建索引一般一般元数据过滤
混合检索要靠 tsvector 自己拼内置 sparse + dense内置多向量与混合内置 BM25 + 向量内置 sparse-dense
适合已有 Postgres、百万级以内、要复杂过滤想要独立服务又不想太重千万级以上、要水平扩展想用内置向量化与模块完全不想运维

选型的实际约束往往不在功能表上,而在两件表外的事:团队会不会运维它,以及它能不能和现有的权限/元数据体系对上。前者决定长期成本,后者决定过滤能不能前置到检索前(见《权限过滤要在检索前》)。

8. 换库之前:pgvector 之外的两个方向​

换库是最大的一步,中间还有两个方向能让你继续留在 Postgres 上。

8.1 pgvectorscale:换索引而不是换库​

pgvectorscale(Timescale)是 pgvector 的第三方扩展,把 DiskANN 那套图索引和 SBQ(Statistical Binary Quantization)量化带进 Postgres。它解决的是 pgvector 原生的两个痛点:HNSW 索引必须放进内存、体积随维度线性膨胀。官方基准主打的是 28 倍 p95 延迟与 16 倍吞吐(对标托管方案);第三方实测在 2.5 万条 3072 维上把索引压到约 HNSW 的九分之一——这个数字随压缩配置变化,不是固定结论,用之前在自己的数据上复测。另外两个值得知道的点:DiskANN 路径支持到 16000 维,绕开了 pgvector 原生 2000 维的上限;0.9.0 起索引也支持 CONCURRENTLY 构建。

代价是它是第三方扩展:托管 Postgres(RDS、Cloud SQL 一类)不一定允许安装,自建或支持扩展的托管服务才行。

8.2 量化:不换索引,只压向量​

更轻的一步是不动索引类型,只把向量本身压小:

方式做法压缩比召回损失
SQ 标量量化float32 压到 int8约 4 倍通常很小
PQ 乘积量化切段后各自聚类十倍以上明显,需要过取回 + 重排兜住
BQ 二值量化压到 1 bit32 倍大,只适合做粗召回

三种方式的共同前提是先测再上:用真实评测集测量化后的 Recall@K,掉得不多才用,并且把量化只用在粗召回那一层——这正是《重排修不了召回》里两阶段的划分,量化负责"圈出候选",精度损失交给后面的重排补。