跳转至

pgvector/pgvector:给 PostgreSQL 加装向量检索能力的扩展

先用大白话说清楚它是什么:pgvector 是给 PostgreSQL 加装向量检索能力的扩展。有了它,你不用再额外单独部署一套 Milvus、Pinecone 这样的专用向量数据库——直接在你已经在用的 Postgres 里新建一个 vector 类型的字段,把文本/图片的 embedding 存进去,再用几个新增的操作符做"相似度搜索":从表里找出哪几行的向量离目标向量最近。

先看一个最小例子,装好扩展之后只需要几行 SQL:

CREATE EXTENSION vector;

CREATE TABLE items (
  id bigserial PRIMARY KEY,
  embedding vector(3)
);

INSERT INTO items (embedding) VALUES ('[1,2,3]'), ('[4,5,6]');

-- 按 L2 距离,找出离目标向量最近的 5 条
SELECT * FROM items ORDER BY embedding <-> '[3,1,2]' LIMIT 5;

装扩展、建一张带 vector 列的表、插入几条向量、用 <-> 按距离排序取最近的几条——这就是 pgvector 最基础的用法,本质上和给普通字段做 ORDER BY ... LIMIT 没什么两样,只是排序依据换成了向量距离。

但只这样用,数据量一大就会很慢:没有索引的话,每次查询都要把目标向量和表里每一行都算一遍距离,百万行数据一次查询就是百万次浮点运算。真实的企业级 RAG(检索增强生成)系统里,知识的向量索引也不能脱离业务关系型数据独立存在,所以 PostgreSQL 官方这个向量插件才会在成熟的 ACID 关系型数据库内核里,专门补上"给向量字段建索引"这一课——这也是它能成为主流高可用向量存储选型的原因。这篇主要拆三块:索引算法本身怎么工作(IVFFlat 和 HNSW 两条路线的取舍)、调参时都有哪些旋钮可以拧、以及一个几乎所有团队做多租户场景都会踩到的性能坑。


1. 核心思路:给向量字段建索引,避免逐行比对全表

说人话版本的核心设计思路:pgvector 解决相似度搜索慢的办法,跟给普通字段建 B-Tree 索引是一个道理——不再是查询时把目标向量和全表逐行比对,而是提前把向量组织成一种能快速跳过大部分无关数据的结构,查询时只在很小一部分候选里做精确计算。pgvector 提供了两种这样的索引结构:IVFFlat(先分堆,只搜相关的堆)和 HNSW(分层图,从粗到细逐层逼近)。具体这两种结构长什么样、怎么工作,看下面的原理和源码:

pgvector 支持两种高维空间近似最近邻(ANN, Approximate Nearest Neighbor)检索算法索引:

========================================================================================
【IVFFlat 倒排文件索引】                           【HNSW 分层导航小世界图索引】
  基于 K-Means 将向量空间划分为 C 个 Voronoi 聚类胞腔。    分层图结构。顶层链路稀疏进行长距离快速粗定位;
  查询时只遍历与 Query 最近的 N 个胞腔 (probes)。         底层链路稠密进行细粒度精准收敛。

      +---------+---------+                            [Layer 2 (Sparse)] o========o
      |  * *    |   *     |                                                |        |
      | * (c1) *|  (c2) * |                            [Layer 1 (Medium)] o===o====o===o
      |  * *    |   * *   |                                               /   |    |    \
      +---------+---------+                            [Layer 0 (Dense)] o--o-o-o--o-o--o
========================================================================================

1.1 IVFFlat 倒排文件扁平索引 (Inverted File Flat)

  • 算法核心:在构建索引时,使用 K-Means 算法在空间中聚类出 \(C\) 个质心。每个输入向量会被归入离其最近的质心所对应的倒排列表(Inverted List)。
  • 查询检索:查询时首先计算 Query 与所有质心的距离,仅对离 Query 最近的 \(N\) 个列表(通过参数 probes 决定)执行暴力的欧氏/余弦计算。
  • 优缺点:构建时间极快、内存开销极低。但在高并发高召回率要求下性能衰退明显,当数据持续写入导致质心偏移时,检索精度会发生断崖式下跌,必须定期触发 REINDEX

1.2 HNSW 分层导航小世界图索引 (Hierarchical Navigable Small World)

  • 算法核心:借鉴跳表(Skip List)设计思想构建多层图。最顶层图连接极其稀疏,用于快速进行长距离“大步跨越”寻路;越往下层图节点越稠密,底层图(Layer 0)包含所有高维向量实体节点。
  • 查询检索:从顶层贪婪搜索最近邻节点,然后跳入下层以该节点为起点继续贪婪搜索,逐层逼近,在 Layer 0 上完成局部最优点锁定。
  • 优缺点:提供极佳的检索召回率(\(>95\%\))与超低的 P99 查询时延。但索引构建极慢,需要消耗数倍于 IVFFlat 的内存(因为需要存储每层图结构中节点之间的指针连接关系)。

源码印证:图上说的"每个节点的指针连接关系"在 pgvector(当前 0.8.5 版本,src/hnsw.h)里对应的是 HnswElementData 这个结构体:

struct HnswElementData
{
    HnswElementPtr   next;
    ItemPointerData  heaptids[HNSW_HEAPTIDS];
    uint8            level;      /* 该节点所在的最高层 */
    HnswNeighborsPtr neighbors;  /* 各层邻居列表的指针 */
    BlockNumber      blkno;
    ...
};
level 字段就是节点被随机分配到的最高层号(决定它在几层图里出现),neighbors 则指向按层组织的邻居数组(HnswNeighborArray),这也是为什么 m 越大、层数越高,索引占用的共享内存越多——每个节点都要额外存一份跨层的指针表。图构建的入口函数是 src/hnswbuild.c 里的 BuildGraph()


2. 怎么建索引、怎么调参数(DDL 与调优实践)

在 pgvector 中,相似度检索由特定的操作符驱动: * <->欧氏距离 (L2 Distance),对应索引类 vector_l2_ops。 * <#>负内积距离 (Negative Inner Product),对应索引类 vector_ip_ops(常用于已执行归一化的向量,检索速度极快)。 * <=>余弦距离 (Cosine Distance),对应索引类 vector_cosine_ops

这三个操作符最终都落在 src/vector.c 里几个很直白的 C 函数上,没有什么黑魔法:

// src/vector.c
l2_distance(...)                    // <-> ,内部调用 VectorL2SquaredDistance 后开方
inner_product(...)                  // 原始内积
vector_negative_inner_product(...)  // <#> ,直接对内积取负号
cosine_distance(...)                // <=> ,内部是 VectorCosineSimilarity 再做 1 - similarity
值得一提的是 vector_negative_inner_product 只是把 inner_product 的结果取反,这也是为什么官方文档反复强调"用 <#> 前一定要先对向量做归一化"——否则这个负号本身不代表真正的相似度排序含义,只是为了让 ORDER BY 能复用同一套"越小越相似"的索引扫描逻辑。

2.1 HNSW 索引调优实践

构建索引时,应根据业务的维度与对召回精度的要求调整超参数:

-- DDL 构建 HNSW 索引示例
CREATE INDEX ON items USING hnsw (embedding vector_cosine_ops) 
WITH (
    m = 16,               -- 每个图节点的最大连接数。维数越高 (如 1536d) 建议值越大 (建议 16~64)
    ef_construction = 64  -- 构建图索引时评估的最近邻候选集大小。该值越大索引召回率越高,但编译时间成倍增加
);
经查证当前 0.8.5 版本源码(src/hnsw.h),这两个参数的默认值与取值范围分别是:m 默认 16,范围 [2, 100]ef_construction 默认 64,范围 [4, 1000]——和上面 DDL 里写的默认值完全一致。

2.2 运行时参数调优

在查询前,可通过 Session 级别调整 ef_search 参数。该值决定了查询时在图中搜索最近邻的动态列表容量:

-- 调大 ef_search 提升检索精度 (以牺牲少量 CPU 检索延迟为代价,源码中默认值为 40,范围 [1, 1000])
SET hnsw.ef_search = 32;

-- 快速向量检索 SQL (借助 <=> 使用 HNSW 索引加速)
SELECT id, title, 1 - (embedding <=> '[0.012, 0.43, ..., -0.09]') AS similarity
FROM items
ORDER BY embedding <=> '[0.012, 0.43, ..., -0.09]'
LIMIT 10;

3. 多租户场景下,向量索引为什么会突然“失灵”

在企业 Saas 架构下,多租户隔离是一道硬红线。pgvector 在结合业务过滤字段时存在经典的“性能塌陷”问题:

3.1 ❌ 过滤逻辑塌陷失效模式

如果简单地采用以下 SQL 查询:

SELECT * FROM documents 
WHERE tenant_id = 'tenant_10086' 
ORDER BY embedding <=> '[0.012, 0.43, ..., -0.09]' 
LIMIT 5;
如果建立的是全局向量索引,Postgres 会执行以下执行计划取舍: * 策略 A (先检索后过滤):首先利用 HNSW 索引搜索出全局最相似的 5 个向量,然后检查它们的 tenant_id。如果这 5 个向量全不属于 tenant_10086,最终返回空结果,引发召回率严重丢失崩溃! * 策略 B (先过滤后检索):如果 Postgres 优化器发现租户过滤条件基数极高,它会放弃向量索引,在 tenant_id = 'tenant_10086' 的几万条关系型数据上执行暴力全表扫描,导致查询耗时暴增。

3.2 多租户最佳工程解法:物理分区表 (Table Partitioning)

为了实现物理级隔离并确保向量索引发挥 100% 效能,建议使用 PostgreSQL 的声明式物理分区表

-- 1. 创建基于主表的多租户物理分区母表
CREATE TABLE tenant_documents (
    id uuid NOT NULL,
    tenant_id varchar(64) NOT NULL,
    content text,
    embedding vector(1536),
    PRIMARY KEY (tenant_id, id)
) PARTITION BY LIST (tenant_id);

-- 2. 为特定大租户创建专属的物理子表
CREATE TABLE doc_tenant_10086 PARTITION OF tenant_documents FOR VALUES IN ('tenant_10086');

-- 3. 【核心步骤】:在物理子表上创建局部 HNSW 向量索引
CREATE INDEX ON doc_tenant_10086 USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);

工程成效:当用户传入 tenant_id = 'tenant_10086' 执行检索时,Postgres 优化器会自动触发分区裁剪(Partition Pruning),直接将查询路径路由到 doc_tenant_10086 子表上,在局部的子表向量索引内执行检索。这彻底避开了全局噪声污染,消除了召回率丢失,且保障了严格的租户级物理隔离安全性。


4. 常见故障排查

故障模式 底层诱因 系统级现象 预防与排查手段
Postgres 节点 OOM 奔溃 高并发下 HNSW 图索引极其消耗物理内存,超出操作系统限制引发 Out Of Memory Postgres 守护进程意外收到 SIGKILL 崩溃重启,连带所有业务链路断裂。 1. 计算 HNSW 索引所需内存,预留 50%+ 物理空余内存。
2. 调整 Postgres 参数 shared_bufferswork_mem,给系统预留足够的 OS Page Cache。
向量维度错配崩溃 客户端写入的 Vector 长度与表定义的 vector(1536) 长度不匹配。 抛出错误:ERROR: value for type vector must be...,写入中断。 1. 在入库前使用 API 拦截或 Gateway 验证机制强校验 Float 数组长度。
2. 强制使用同一版本的 Embedding 客户端。
IVFFlat 召回率滑坡 数据大规模高频写入或修改,使得最初聚类生成的质心分布失衡。 相似查询能跑通,但搜出来的结果相关性极差,用户反馈系统“变傻”。 1. 定期执行 REINDEX INDEX index_name; 重构质心。
2. 保证 IVFFlat 的聚类中心数 lists 设置合适(通常按 \(\text{Rows} / 1000\) 级别估算,超过 100 万行则改用 \(\sqrt{\text{Rows}}\))。

源码印证:质心聚类的实现是 src/ivfkmeans.c 里的 IvfflatKmeans(Relation index, VectorArray samples, VectorArray centers, const IvfflatTypeInfo *typeInfo, Size memoryUsed),本质就是对采样向量跑一版 K-Means;查询时决定"扫几个倒排列表"的逻辑在 src/ivfscan.cGetScanLists() 里,核心就是 probes = Min(ivfflat_probes, lists) 这一步裁剪。经核实当前 0.8.5 版本里 lists 默认值为 100(范围 [1, 32768]),probes 默认值为 1——和上文的调优建议、以及本文 SQL 示例中的取值均一致。


5. 资深系统架构师面试表达方案

面试提问:pgvector 是如何实现向量检索的?在面对上亿级别数据或强 Saas 隔离场景时,你们是如何做索引调优和多租户设计来防止性能退化的?

回答模版: pgvector 选索引这块,我们一开始图省事直接上了 HNSW,构建速度和召回率都还不错,mef_construction 按官方推荐值起步,再结合 Session 级的 ef_search 做检索时的精度/延迟权衡,这部分调起来没太大意外。

真正让我们栽过跟头的是多租户隔离。上线初期就是简单粗暴地在全局表上 WHERE tenant_id = ... ORDER BY embedding <=>,测试环境数据量小的时候一切正常,数据量上来之后开始出现某些租户查询“什么都搜不到”的诡异反馈——后来才搞清楚是优化器先用 HNSW 索引捞出全局最相似的几条,再拿 tenant_id 一过滤,发现全不是这个租户的数据,直接返回空。反过来,如果租户过滤条件命中率太低,优化器又可能直接放弃向量索引改成暴力扫全表,查询延迟又炸了。这个“先过滤还是先检索”的两难,不是靠调参能绕开的。

最后我们是按 tenant_id 做了物理分区表,每个租户的向量索引各自独立建在自己的分区上,查询时靠分区裁剪直接定位到对应子表,检索路径里根本不会混进别的租户的数据。这个方案改造成本比想象中大,但至少现在心里踏实——不用再赌优化器这次会不会选对执行计划。