pgvector 向量检索与混合查询

pgvector 实战:vector / halfvec / bit 类型、余弦 / L2 / 内积距离运算符、IVFFlat 与 HNSW 索引选型、lists / probes / m / ef_search 参数调优、标量过滤下的向量检索、向量与全文检索的混合排序(RRF)、以及大规模向量表的性能与运维要点。

检索增强生成(RAG)、语义搜索、推荐召回这些场景都需要向量检索(Vector Search):把文本、图片编码成高维向量,再用「最近邻(Nearest Neighbor)」查询找出语义相近的条目。过去这类需求要引入专用向量数据库(Milvus、Qdrant、Weaviate),但代价是多一套系统、多一份运维、多一次数据同步。

pgvector 把这个能力做成了 PostgreSQL 扩展:向量就是一个列类型,相似度查询就是一条带运算符的 SQL,索引就是 CREATE INDEX ... USING hnsw。它的杀手级优势是混合查询——同一条 SQL 里既能做向量近邻,又能做 WHERE tenant_id = 42 AND created_at > ... 的标量过滤,还能和全文检索的结果融合排序。当向量规模在千万级以内、又需要事务与复杂过滤时,pgvector 往往比独立向量库更省心。

一、安装与基础类型

1.1 安装扩展

CREATE EXTENSION vector;
SELECT extversion FROM pg_extension WHERE extname = 'vector';

pgvector 是标准的 PostgreSQL 扩展,安装与管理方式和其它扩展一致——编译、CREATE EXTENSION、随大版本升级而升级。托管数据库通常内置了 pgvector,自建实例则需要装好对应大版本的二进制包,并在升级 PostgreSQL 大版本时同步升级扩展。

1.2 三种向量类型

类型存储适用
vector(n)float4 数组,4 字节/维通用,默认选择
halfvec(n)float2,2 字节/维省一半空间,精度略降
bit(n)位串二值化向量,配合汉明距离
sparsevec(n)稀疏存储稀疏向量(如 BM25 词权重)
CREATE TABLE documents (
    id         bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    tenant_id  int  NOT NULL,
    content    text NOT NULL,
    embedding  vector(1536) NOT NULL,
    created_at timestamptz NOT NULL DEFAULT now()
);

维度必须与嵌入模型一致(OpenAI text-embedding-3-small 是 1536 维,bge-large 是 1024 维)。维度一旦建表就不能改,换模型意味着重建列与索引。

halfvec 是省空间的实用选项:1536 维的 vector 每行占 6KB,halfvec 只占 3KB。在召回率几乎无损的前提下把索引体积减半,对内存受限的实例很划算。

1.3 距离运算符

pgvector 用运算符表达距离,索引必须与运算符匹配:

运算符距离索引 opclass
<->L2(欧氏距离)vector_l2_ops
<#>负内积vector_ip_ops
<=>余弦距离vector_cosine_ops
<+>L1(曼哈顿)vector_l1_ops

余弦距离是最常用的(语义相似度),查询写法:

SELECT id, content
FROM documents
WHERE tenant_id = 42
ORDER BY embedding <=> '[0.1, 0.2, ...]'::vector
LIMIT 10;

注意 <#> 返回的是负内积,值越小越相似(ORDER BY embedding <#> q ASC 等价于内积最大)。这个「负号」是新手最容易搞错的地方。

1.4 归一化与距离选择

余弦距离与内积、L2 之间有一个关键关系:当所有向量都归一化到单位长度时,余弦相似度、内积、L2 距离三者单调等价。

||a - b||² = ||a||² + ||b||² - 2·a·b

若 ||a|| = ||b|| = 1,则 ||a - b||² = 2 - 2·a·b,即 L2 距离随内积单调递减。因此在写入前把向量归一化,用 <->(L2)或 <#>(内积)都能得到与余弦一致的结果,且内积/L2 的索引实现往往更快。

pgvector 提供了归一化函数:

UPDATE documents SET embedding = l2_normalize(embedding);

建议在应用侧统一做归一化(很多嵌入模型本身就返回单位向量),并在写入时校验 vector_norm(embedding) ≈ 1。若向量已归一化,可以安全地把索引从 vector_cosine_ops 换成 vector_ip_ops,省下每次查询的归一化开销。

二、索引:IVFFlat 与 HNSW

没有索引时,向量查询是精确但全表扫描的:计算每一行的距离再排序,O(N)。百万行以上就不可接受。pgvector 提供两种近似最近邻(ANN)索引。

2.1 IVFFlat(倒排文件)

IVFFlat 把向量聚类成 lists 个簇,查询时只搜索最近的 probes 个簇。

CREATE INDEX idx_docs_embedding_ivfflat
ON documents USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);

关键:IVFFlat 必须在表里已有数据后建索引。它依赖聚类,空表建索引会得到无效的簇划分。正确顺序是「先灌数据,再建 IVFFlat 索引」,或用 ANALYZE 后再建。

查询时控制搜索范围:

SET ivfflat.probes = 10;

lists 的推荐值:行数 < 100 万用 rows/1000,超过 100 万用 sqrt(rows)。probes 越大越精确但越慢,通常取 sqrt(lists)。

2.2 HNSW(分层可导航小世界图)

HNSW 构建一个多层图,查询从顶层稀疏图向下逐层导航。

CREATE INDEX idx_docs_embedding_hnsw
ON documents USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);

HNSW 的优势是可以在空表上建索引(增量插入自动维护图),且查询更快、召回更高;代价是索引更大、构建更慢、插入更贵。

两种索引的对照:

维度IVFFlatHNSW
建索引时机必须已有数据随时
构建速度快慢(数倍于 IVFFlat)
查询速度中快
召回率依赖 probes高
索引体积小大
增量插入不更新簇自动维护图
内存占用低高

选型建议:写多读少或数据持续增长选 HNSW(增量友好),读多写少且能接受离线建索引选 IVFFlat(省内存)。生产上大多数 RAG 场景选 HNSW,因为文档会持续入库。

2.3 查询时调优参数

HNSW 的查询精度由 hnsw.ef_search 控制:

SET hnsw.ef_search = 100;   -- 默认 40,越大越精确越慢

ef_search 必须 ≥ LIMIT,否则召回会明显下降。经验值:ef_search = max(40, 2 × LIMIT)。

-- 对比不同 ef_search 的召回与耗时
SET hnsw.ef_search = 40;
EXPLAIN (ANALYZE) SELECT id FROM documents
ORDER BY embedding <=> $1 LIMIT 10;

SET hnsw.ef_search = 200;
EXPLAIN (ANALYZE) SELECT id FROM documents
ORDER BY embedding <=> $1 LIMIT 10;

观察 Index Scan using idx_docs_embedding_hnsw 的实际时间与返回行数。向量索引的 EXPLAIN 与常规 B-tree 索引差异较大,通用解读方法可参考 PostgreSQL 索引类型深度实战 。

三、标量过滤下的向量检索

真实查询几乎不会只按向量排序,总带标量条件(租户、时间、分类)。这是 pgvector 最容易踩坑的地方。

3.1 过滤与索引的执行顺序

PostgreSQL 有两种执行策略:

  • 先过滤后检索(pre-filter):先按 WHERE 筛出候选集,再在候选集上算距离。当过滤选择性高时正确。
  • 先检索后过滤(post-filter):先用向量索引取回 Top-K,再套 WHERE。当过滤选择性低时可能返回不足 K 条。

规划器根据统计信息自动选择。但向量索引本身不感知 WHERE 条件,如果规划器错误地选了 post-filter,可能返回的行数远少于 LIMIT,甚至为空。

3.2 强制正确的执行顺序

当过滤选择性很高(如单租户数据只占全表 0.1%),应强制先过滤:

SET enable_indexscan = off;   -- 禁用向量索引,走顺序扫描 + 过滤
SET enable_seqscan = on;

或者反过来,对高选择性过滤建 B-tree 索引并配合 SET enable_seqscan = off,让规划器先用 B-tree 缩小候选集。

更稳的做法是用子查询显式分离两阶段:

WITH filtered AS (
    SELECT id, embedding
    FROM documents
    WHERE tenant_id = 42 AND created_at > now() - interval '30 days'
)
SELECT id FROM filtered
ORDER BY embedding <=> $1
LIMIT 10;

PostgreSQL 可能把 CTE 内联,因此加 MATERIALIZED 可强制物化:

WITH filtered AS MATERIALIZED (
    SELECT id, embedding FROM documents WHERE tenant_id = 42
)
SELECT id FROM filtered ORDER BY embedding <=> $1 LIMIT 10;

3.3 分区 + 向量索引

对多租户场景,按 tenant_id 分区是最优雅的方案:每个分区有独立的向量索引,查询先分区裁剪,再在小分区上做向量检索,天然实现「先过滤后检索」。

CREATE TABLE documents (
    id bigint, tenant_id int NOT NULL, embedding vector(1536)
) PARTITION BY LIST (tenant_id);

CREATE TABLE documents_t42 PARTITION OF documents FOR VALUES IN (42);
CREATE INDEX ON documents_t42 USING hnsw (embedding vector_cosine_ops);

这样既避免了 post-filter 的召回不足,又让每个分区的索引更小、更快。分区键天然成为向量检索的「前置过滤」,是最值得优先考虑的优化方向。分区表上建向量索引时要注意,父表上的 CREATE INDEX 会传播到每个分区,新增分区时务必确认索引已自动建立。

四、混合检索:向量 + 全文

纯向量检索有个弱点:对精确关键词(型号、专有名词、ID)不敏感。BM25/全文检索恰好擅长精确匹配。把两者结果融合,就是混合检索(Hybrid Search),召回质量通常明显优于单一路径。

4.1 全文检索基础

PostgreSQL 内建全文检索(tsvector / tsquery):

ALTER TABLE documents ADD COLUMN tsv tsvector
    GENERATED ALWAYS AS (to_tsvector('simple', content)) STORED;
CREATE INDEX idx_docs_tsv ON documents USING gin (tsv);

中文场景需要分词方案(zhparser / pg_jieba),配置与调优细节见 PostgreSQL 全文搜索实战 。

4.2 倒数排序融合(RRF)

把向量检索与全文检索各取 Top-N,用 RRF(Reciprocal Rank Fusion) 融合:

WITH vector_hits AS (
    SELECT id, row_number() OVER (ORDER BY embedding <=> $1) AS rank
    FROM documents WHERE tenant_id = 42
    ORDER BY embedding <=> $1 LIMIT 50
),
text_hits AS (
    SELECT id, row_number() OVER (ORDER BY ts_rank_cd(tsv, query) DESC) AS rank
    FROM documents, to_tsquery('simple', $2) query
    WHERE tenant_id = 42 AND tsv @@ query
    LIMIT 50
)
SELECT COALESCE(v.id, t.id) AS id,
       COALESCE(1.0/(60 + v.rank), 0) + COALESCE(1.0/(60 + t.rank), 0) AS rrf_score
FROM vector_hits v
FULL OUTER JOIN text_hits t ON v.id = t.id
ORDER BY rrf_score DESC
LIMIT 10;

常数 60 是 RRF 的经验平滑因子。RRF 的好处是不需要归一化两路分数(向量距离与 BM25 分值量纲完全不同),只用排名融合,鲁棒性高。

4.3 加权分数融合

如果两路分数可归一化,也可以加权求和:

SELECT id,
       0.7 * (1 - (embedding <=> $1)) + 0.3 * ts_rank_cd(tsv, query) AS score
FROM documents, to_tsquery('simple', $2) query
WHERE tsv @@ query
ORDER BY score DESC LIMIT 10;

权重需要按业务调参(0.7/0.3 是常见起点)。相比 RRF,加权融合更依赖分数分布的一致性,跨查询稳定性较差。

五、性能与运维

5.1 内存与索引体积

向量索引对内存极度敏感。HNSW 索引若放不进 shared_buffers,查询会频繁读盘,延迟骤增。估算:1536 维 vector 的 HNSW 索引,每行约占 4 × 1536 + m × 2 × 4 字节,100 万行约 6~7GB。

对策:

  • 用 halfvec 把索引减半。
  • 用二值化(bit 类型 + 汉明距离)做粗筛,再用原始向量精排(两阶段检索)。
  • 按租户/时间分区,让每个分区的索引可放进内存。

5.2 批量插入与索引维护

HNSW 的插入代价高(每次插入都要更新图)。大批量导入时,先删索引、灌数据、再重建远比逐行插入快:

DROP INDEX idx_docs_embedding_hnsw;
COPY documents (tenant_id, content, embedding) FROM '/data/docs.csv' WITH (FORMAT csv);
CREATE INDEX idx_docs_embedding_hnsw
    ON documents USING hnsw (embedding vector_cosine_ops);

CREATE INDEX 期间可调大 maintenance_work_mem 加速:

SET maintenance_work_mem = '2GB';
SET max_parallel_maintenance_workers = 4;

批量加载的整体策略与 COPY 参数调优,思路与 PostgreSQL 性能调优 中描述的大表加载一致:把索引维护从写入路径里剥离,集中到一次离线构建。

5.3 召回率评估

ANN 索引是近似的,必须评估召回率。方法是对同一批查询,比较 ANN 结果与精确结果(SET enable_indexscan = off 强制全表扫描)的重合度:

-- 精确结果(作为 ground truth)
SET enable_indexscan = off;
SELECT id FROM documents WHERE tenant_id = 42 ORDER BY embedding <=> $1 LIMIT 10;

-- ANN 结果
SET enable_indexscan = on;
SET hnsw.ef_search = 40;
SELECT id FROM documents WHERE tenant_id = 42 ORDER BY embedding <=> $1 LIMIT 10;

对一组代表性查询计算「前 10 命中重合数 / 10」,召回率低于 90% 就应调大 ef_search 或 m。这个评估应作为上线前的必做项,并在数据分布变化后复测。

5.4 二值化两阶段检索

当数据规模超过内存上限时,一个高效的折中是两阶段检索:先用二值化向量做粗筛,再用原始向量精排。

ALTER TABLE documents ADD COLUMN embedding_bin bit(1536)
    GENERATED ALWAYS AS (binary_quantize(embedding)::bit(1536)) STORED;

CREATE INDEX idx_docs_bin ON documents
    USING hnsw (embedding_bin bit_hamming_ops);

粗筛阶段用汉明距离取 Top-200(embedding_bin <~> $1),精排阶段在这 200 条上用 <-> 重新排序取 Top-10。二值向量只占 n/8 字节,索引体积缩小约 30 倍,可以轻松放进内存;代价是粗筛阶段需要取更多的候选(200 而非 10)来补偿精度损失。

5.5 监控向量查询

用 pg_stat_statements 找出最耗时的向量查询:

SELECT calls, mean_exec_time, rows,
       left(query, 80) AS query
FROM pg_stat_statements
WHERE query ILIKE '%<=>%' OR query ILIKE '%<->%' OR query ILIKE '%<#>%'
ORDER BY mean_exec_time DESC
LIMIT 10;

重点观察:mean_exec_time 是否随数据增长而恶化(说明索引不再有效,可能退化成了全表扫描)、rows 是否远小于 LIMIT(说明 post-filter 导致召回不足)。这两个信号分别对应「索引失效」与「过滤顺序错误」两类典型故障。

5.4 应用侧集成

pgvector 与应用框架的集成非常直接——它只是 PostgreSQL 的一个列类型,任何 PostgreSQL 驱动都能用。用 Node.js 生态做 RAG 时,Prisma 的原生类型支持可以省去手工 SQL,具体做法见 Prisma 与 PostgreSQL 集成 。关键是在应用层统一嵌入模型与维度,并把「生成向量」与「写入/检索」的调用封装在同一处,避免模型版本漂移导致检索质量下降。

六、常见问题速查

症状根因处置
查询不走向量索引运算符与 opclass 不匹配确认 <=> ↔ vector_cosine_ops
返回行数少于 LIMITpost-filter 把结果过滤掉了改用分区或 MATERIALIZED CTE
建索引报错空表建 IVFFlat先灌数据再建索引
召回率低ef_search / probes 太小调大并复测召回
查询随数据量变慢索引放不进内存,退化成顺序扫描用 halfvec / 二值化 / 分区
插入很慢HNSW 每次插入都更新图批量导入时先删索引再重建
换嵌入模型后检索变差新旧向量混在同一列重建列与索引,全量重算

向量检索的问题大多收敛到「索引是否被用上」与「过滤顺序是否正确」两点。排查时先用 EXPLAIN (ANALYZE) 确认走了 Index Scan using ...hnsw 还是 Seq Scan,再看返回行数与 LIMIT 的关系,基本能定位到具体原因。

小结

pgvector 把向量检索做成了「PostgreSQL 的一个列 + 一个索引」,最大价值在于能在同一条 SQL 里融合向量近邻、标量过滤与全文检索。落地要点:类型选 vector 或省空间的 halfvec,索引在「写多读少」时选 HNSW、「读多写少」时选 IVFFlat,务必搞清过滤与检索的执行顺序(高选择性过滤优先用分区或 MATERIALIZED CTE),混合检索用 RRF 融合向量与全文两路排名。性能上,向量索引是内存密集型的,务必先估算索引体积是否放得进 shared_buffers,批量导入时删索引重建,上线前用精确结果评估召回率。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「database」更多文章

  1. PostgreSQL 锁与阻塞分析
  2. COPY 与批量数据加载优化
  3. Citus 分布式分片与水平扩展