跳到正文

pgvector 向量检索:把召回放进一条 SQL ​

一句话:pgvector 把向量存成普通列、用 ANN 索引加速距离排序,让向量召回和业务过滤在同一个事务里完成;代价是单机内存和索引构建时间。

适用 PostgreSQL 16/17、pgvector 0.8.x。扩展需先安装到数据库实例,CREATE EXTENSION 只负责在库内启用。

为什么需要 ​

RAG 的检索不是「给一个向量、返回最近的 K 条」这么简单。真实查询通常带过滤条件:租户、文档权限、发布时间、状态。用独立向量库时,这些条件要么冗余进向量库,要么先召回再回业务库过滤;两种做法都有双写、回表和一致性问题。

把向量放进 PostgreSQL 后,切片内容、向量、权限字段在同一张表里,写入是一个事务,查询是一条 SQL。代价是:单机扩展性、索引内存、写入吞吐不如专用向量库。

核心机制 ​

类型与距离 ​

类型每维占用用途
vector(n)4 字节(float32)默认选择
halfvec(n)2 字节(float16)省一半内存,召回通常略降
bit(n) / sparsevec(n)1 bit / 稀疏二值量化粗排、稀疏关键词向量

距离运算符必须和索引 opclass 对上,否则索引不生效:

运算符含义opclass
<->L2 欧氏距离vector_l2_ops
<=>余弦距离vector_cosine_ops
<#>负内积vector_ip_ops

<#> 返回负内积,排序仍是 ORDER BY ... ASC,但分数的方向与余弦相反;L1 距离用 vector_l1_ops。选哪种距离取决于 embedding 模型的训练方式,不要凭习惯。

HNSW 与 IVFFlat ​

  • HNSW:分层小世界图。查询快、召回高,索引常驻内存,构建慢。适合在线检索。
  • IVFFlat:K-means 聚类 + 倒排。构建快、内存省,但必须先有数据才能训练,召回依赖 lists 和查询时的 probes。适合批量/离线。
参数默认值作用调大后的影响
m16每个节点的连接数召回↑、内存↑、构建↑
ef_construction64建索引时的候选队列召回↑、构建↑
ef_search40查询时的候选队列(会话级)召回↑、延迟↑

ef_construction=64 是 pgvector 默认值,200 是常见调优值;不要把它和 ef_search 混为一谈。ef_search 至少要覆盖 LIMIT,否则可能返回不足 K 条。

lists 常按「行数 / 1000(百万以内)」或「sqrt(行数)(百万以上)」估算;probes 越大召回越高、扫描越多。

过滤与召回的冲突 ​

朴素做法是先 ANN 取 topK,再按 WHERE 过滤。过滤条件一严,topK 里可能一条都不剩,召回直接崩。

pgvector 0.8 的迭代索引扫描(iterative index scan)让索引扫描不一次取完,凑不够就继续扫:

sql
SET hnsw.iterative_scan = relaxed_order;   -- off / relaxed_order / strict_order
SET hnsw.max_scan_tuples = 50000;          -- 默认 20000

代价是延迟上升。另一种做法是给高选择性条件建 B-tree,让优化器先过滤再算距离;0.8 改进了代价估算,但仍要用 EXPLAIN 确认实际走了哪条路径。

召回怎么测 ​

不要用「看起来差不多」判断召回。正确做法是以精确扫描(暴力全扫)的 topK 为基准,计算近似结果的 recall@K:

sql
-- 把 '[...]' 换成实际查询向量;tenant_id 换成实际过滤条件
BEGIN;
SET LOCAL enable_indexscan = off;
SET LOCAL enable_bitmapscan = off;
SET LOCAL enable_indexonlyscan = off;
CREATE TEMP TABLE exact_top AS
SELECT id FROM doc_chunk
WHERE tenant_id = 1
ORDER BY embedding <=> '[...]'::vector
LIMIT 20;
SET LOCAL enable_indexscan = on;
SET LOCAL enable_bitmapscan = on;
SET LOCAL enable_indexonlyscan = on;
SELECT count(*)::numeric / 20 AS recall_at_20
FROM (
  SELECT id FROM doc_chunk
  WHERE tenant_id = 1
  ORDER BY embedding <=> '[...]'::vector
  LIMIT 20
) approx
WHERE EXISTS (SELECT 1 FROM exact_top e WHERE e.id = approx.id);
COMMIT;

召回不够时,优先调 ef_search,再考虑 ef_construction/m 并重建索引。每次改参数都要重新测,不能只看单条查询。

最小上手 ​

sql
CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE doc_chunk (
  id        bigserial PRIMARY KEY,
  tenant_id bigint NOT NULL,
  doc_id    bigint NOT NULL,
  content   text   NOT NULL,
  embedding vector(3) NOT NULL          -- 演示用 3 维,生产按模型改
);

INSERT INTO doc_chunk (tenant_id, doc_id, content, embedding) VALUES
  (1, 100, '退款政策', '[0.1, 0.2, 0.3]'),
  (1, 101, '物流时效', '[0.9, 0.1, 0.1]'),
  (2, 200, '发票开具', '[0.2, 0.8, 0.2]');

-- HNSW:64 是默认 ef_construction,200 是常用调优值
CREATE INDEX idx_chunk_embedding ON doc_chunk
  USING hnsw (embedding vector_cosine_ops)
  WITH (m = 16, ef_construction = 64);

SET hnsw.ef_search = 100;   -- 默认 40;>= LIMIT

SELECT id, content, embedding <=> '[0.1, 0.2, 0.3]'::vector AS distance
FROM doc_chunk
WHERE tenant_id = 1
ORDER BY embedding <=> '[0.1, 0.2, 0.3]'::vector
LIMIT 2;

EXPLAIN (ANALYZE, BUFFERS)
SELECT id FROM doc_chunk
WHERE tenant_id = 1
ORDER BY embedding <=> '[0.1, 0.2, 0.3]'::vector
LIMIT 2;

期望看到 Index Scan using idx_chunk_embedding。如果显示 Seq Scan,检查距离运算符和 opclass 是否匹配、表是否太小(小表优化器可能认为全扫更便宜)。

IVFFlat 需要表里已有数据才能训练,建索引用 CREATE INDEX ... USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);,查询前 SET ivfflat.probes = 10;(默认 1)。

生产动作 ​

  • 建索引内存:HNSW 构建吃 maintenance_work_mem(默认 64MB);大表先临时调大,避免落盘变慢。max_parallel_maintenance_workers 可以并行构建,但要评估 CPU 和 IO。
  • 建索引阻塞:普通 CREATE INDEX 拿 SHARE 锁,会阻塞写入;在线表用 CREATE INDEX CONCURRENTLY,构建更慢,先在测试环境验证时间和内存。
  • ef_search 按查询调:它是会话级参数,不要全局拉满。用 SET LOCAL hnsw.ef_search = 100; 只影响当前事务;不同查询可以有不同的召回/延迟取舍。
  • 索引体积与量化降本:pg_relation_size('idx_chunk_embedding') 看体积,索引膨胀后 REINDEX INDEX CONCURRENTLY;HNSW 索引常驻内存,内存不够时延迟会断崖式上升,不会优雅降级。量化先换 halfvec(1536) + halfvec_cosine_ops,内存减半;再不够用二值量化 + 过采样精排。量化会损失召回,必须用 recall@K 验证。
  • 模型换维:加新列 embedding_v2 vector(3072),回填后建新索引再切换;不要原地 ALTER TYPE,也不要让新旧向量混在同一列。
  • 排障路径:现象「带过滤条件的向量查询返回不足 K 条」→ EXPLAIN 看是否先 ANN 后 Filter → 开 hnsw.iterative_scan、给过滤列建 B-tree,或把过滤条件下推 → 再用 recall@K 验证召回是否恢复。

边界 ​

  • 单机不是分布式:pgvector 跑在单个 PostgreSQL 实例上;分片要靠 Citus 或按租户分区,跨分片 topK 归并要自己做。
  • 内存是硬约束:HNSW 索引和向量数据常驻内存;1536 维 float32 约 6KB/条,1000 万条光原始向量就约 57GB,还没算索引和缓存。
  • 规模分界是经验值:百万级、千万级以内、QPS 几百,单机 pgvector 通常够用;上亿条、高并发、多租户弹性扩缩,用专用向量库。具体分界必须用真实数据和查询压测,不能照搬。
  • 索引构建与事务:建索引期间写入仍可用(CONCURRENTLY),但新数据要等索引追上;事务只覆盖表数据,不覆盖索引构建过程。

讲清楚 ​

pgvector 的价值不是「Postgres 也能做向量检索」,而是「向量不用离开业务库」。RAG 的切片天然带租户、权限、时间这些过滤条件,放在两个系统里就要处理双写和回表;放在一张表里,写入、删除、过滤、召回是一个事务。检索靠 HNSW 或 IVFFlat:HNSW 召回高但吃内存,IVFFlat 省内存但要先有数据训练。真正的坑在过滤——先 ANN 再过滤,条件一严 topK 就被筛空,0.8 的迭代扫描就是为这个加的。选型看规模:千万级以内、过滤复杂、要求事务一致,PostgreSQL 是最省事的选择;上亿条、高 QPS、要弹性扩缩,交给 Milvus 这类专用库,并用 recall@K 和 p99 而不是感觉来做决定。

把一门技术讲透到既懂也会用