HNSW 还是 IVFFlat
多数 RAG 项目起步时不需要独立的向量数据库。语料在百万条切片以内、已经有 Postgres、还要按租户或权限做过滤——这三条里命中两条,pgvector 就够了,而且省掉一整套数据同步。
真正的分水岭是索引类型和参数。选错索引,表现是"数据量一上去查询就变慢,而且召回莫名其妙地差"。
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. 两种索引,差别在结构
| HNSW | IVFFlat | |
|---|---|---|
| 结构 | 分层近邻图 | k-means 分桶 |
| 查询速度 | 快 | 较慢 |
| 召回上限 | 高 | 中等 |
| 构建速度 | 慢 | 快 |
| 内存占用 | 高 | 低 |
| 空表建索引 | 可以 | 不行,需要代表性数据 |
| 增量插入 | 保持召回 | 召回会退化,需定期重建 |
| 查询期可调参数 | hnsw.ef_search | ivfflat.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 会直接报错拒绝建索引,不是"图质量差一点",是根本建不出来。
这两个参数一旦建完就改不了,要改只能重建索引。所以策略是:先用默认值建,用真实查询测召回,不够再调大重建。不要一上来就把参数拉满——构建时间和内存会跟着涨。
3.2 查询期:ef_search
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 不适合的信号很清楚:切片量到了千万级且还在涨、需要多租户物理隔离、或者团队已经在用专门的检索服务(托管重排、托管的稀疏检索等)。
但在那之前,多一套系统就意味着多一份同步逻辑、多一份一致性问题和多一份运维。能少一套就少一套。
真到了要选的时候,主流几家的取舍大致是这样:
| pgvector | Qdrant | Milvus | Weaviate | Pinecone | |
|---|---|---|---|---|---|
| 形态 | Postgres 扩展 | 独立服务,也可嵌入式跑 | 独立的分布式服务 | 独立服务 | 全托管 |
| 运维负担 | 最低,跟着数据库走 | 中 | 重(依赖 etcd、对象存储) | 中 | 无 |
| 过滤能力 | 最强,就是 SQL | payload 过滤,可建索引 | 一般 | 一般 | 元数据过滤 |
| 混合检索 | 要靠 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 bit | 32 倍 | 大,只适合做粗召回 |
三种方式的共同前提是先测再上:用真实评测集测量化后的 Recall@K,掉得不多才用,并且把量化只用在粗召回那一层——这正是《重排修不了召回》里两阶段的划分,量化负责"圈出候选",精度损失交给后面的重排补。