PGVector:让向量住进 Postgres
约 2573 字大约 9 分钟
2026-09-18
数据侧最后一篇。向量算出来了,得有个地方住,还得能被快速查到。今天讲 PGVector——以及为什么我敢在「向量数据库」这个各路大厂重兵押注的赛道上,选一个 Postgres 扩展。本篇对应 relax-admin 的「知识库配置 → 知识库列表」页——多知识库隔离(kb_id)这件事的管理端形态就在那一页。
先回答选型问题:为什么不是 Milvus
这是留言区问得最多的问题,值得正面刚一次。
网上聊向量库选型,画风基本是性能横评:谁 QPS 高、谁索引建得快。我说个可能反常识的判断:
选型的第一问题
多数知识库场景,选型的第一问题根本不是性能,而是「你的元数据过滤需求有多强」。
想想我们这个博客知识库要做的过滤:「只搜后端分类的文章」「排除草稿」「按 slug 全删全灌」「只搜今年写的」。再往上一层,relax-admin 是按多知识库设计的——「知识库列表」页里博客库(Gitee 拉取型)只是其中一个,还有上传型库、演示库,检索时必须按 kb_id 隔离,互不串门。这些全是结构化条件,在 PG 里就是一句 WHERE,跟向量检索无缝组合在一条 SQL 里,还能跟业务表做事务。换到专用向量库,这套东西要靠它自带的过滤表达式重新实现一遍,表达能力和事务性都要打折扣。
至于性能?个人博客撑死几十万向量,PGVector 舒适区是千万级以内。我的原则是:在到达 Milvus 的必要性之前,PGVector 的简单性就是性能——少一个中间件,就少一份运维、少一类故障、少一次深夜告警。真到了千万级以上再说,而且到时候大概率你该操心的早不是向量库了。
这个判断也有反面佐证:不少团队在强过滤场景(多租户隔离几乎每查必过滤)从专用库回迁 PG,因为过滤后的候选集太小,专用库的 ANN 索引根本使不上劲——这个话题下面讲过滤时还会回来。
建表:每个决定都有理由
直接上我博客的真实表结构,简化版:
CREATE TABLE chunk_embedding (
id bigserial PRIMARY KEY,
content text, -- 段落正文(含标题路径前缀)
metadata jsonb, -- slug/title/category/...
embedding vector(1024), -- 维度写死在类型里!
embedding_model varchar(64), -- 上一篇念叨了三遍的字段
created_at timestamptz DEFAULT now()
);几个值得停留的细节:
vector(1024) 维度是表定义的一部分。 这意味着这张表「只伺候一个维度」,Embedding 模型一换(维度或坐标系变了),就得建新表全量重灌——这是上一篇讲的架构约束在存储层的体现。所以我的建表脚本里表名带版本:chunk_embedding_v1,将来换模型就建 v2,数据迁完再切。
为什么不在线改列类型
因为旧数据还是旧模型的向量,改个列宽毫无意义。版本化分表 + 全量迁移 + 灰度切换,比任何「优雅的在线迁移」都靠谱。
元数据用 jsonb 而不是拆成一堆列。 原因是元数据字段会演进(后面加 parent_id 做父子切片、加 version 做文档版本),jsonb 加字段零成本。要过滤的字段(slug、category)可以再建表达式索引,鱼和熊掌都吃。
说说 Java AI 这边。 spring-ai-starter-vector-store-pgvector 引进来之后它会自动建一张 vector_store 表并提供 VectorStore 接口的实现,add 写入、similaritySearch 检索,开箱即用,配置大概长这样:
spring:
ai:
vectorstore:
pgvector:
initialize-schema: false # 生产关掉自动建表!
distance-type: COSINE
dimensions: 1024
index-type: HNSW注意那个 initialize-schema: false
生产环境让框架自动建表是危险动作,schema 应该攥在你自己的迁移脚本手里。自动建表适合本地快速起步,上线前一定要收权。
索引:ANN 的「近似」二字值多少
到了 PGVector 最有技术含量的一段:为什么查向量需要专门索引,以及 HNSW 到底是个啥。
先算笔账让你感受问题:不建索引,一句「找离这个向量最近的 5 条」等于全表扫描——50 万条向量,每条算一次 1024 维的距离。慢到没法用。
那建普通索引行不行?不行,B-tree 这类索引理解不了「距离最近」这种多维空间的查询。所以向量索引走的是另一条路,叫 ANN(Approximate Nearest Neighbor,近似最近邻)——注意「近似」两个字:它允许漏掉极少量真正的最近邻,换取几个数量级的加速。
这就是一个非常重要的认知:
ANN 的物理属性
「库里明明有、却没检索到」这种事,不全是 bug,有一部分是 ANN 索引的物理属性——你买速度的时候,就把那一点点召回率当筹码付出去了。明白这一点,你才不会在「偶尔漏一条」面前怀疑人生。
PGVector 主流两种 ANN 索引:
IVFFlat:先把整个空间聚成一堆簇,查询时只扫离问题最近的几个簇。省内存、建得快,但有个阴险的坑——空表上建这个索引,簇心是瞎聚的,之后数据灌进来,召回率长期偏低还没报错。要用它得先有数据再建。
HNSW(默认推荐):多层图结构,像跳表一样,上层稀疏负责快速导航,下层稠密负责精确定位。召回率高、支持边写边查,代价是建索引慢点、吃内存。
博客场景无脑 HNSW:
CREATE INDEX ON chunk_embedding_v1
USING hnsw (embedding vector_cosine_ops);看仔细:vector_cosine_ops 这个操作符类,和查询时用的 <=> 算子是一对。建索引用 cosine 算子类、查询却写 <->(欧氏),索引直接不生效或者结果错乱——这个坑值得反复念叨。
HNSW 有个参数日常会调:ef_search(查询时候选队列的宽度,默认 40)。直觉版理解:它决定查询时「多看几个备胎」。一个经验法则:
ef_search 的经验法则
ef_search 起码设成你 topK 的三到五倍——你想要 top 50,候选池只有 40,就成了「结果比候选还多」的荒诞剧。博客场景用默认值就行,先记着有这个旋钮。
混合过滤:向量与 WHERE 共舞
PGVector 最香的能力登场:结构化过滤和向量检索写在同一条 SQL 里。上一节说的 kb_id 隔离,最终就是这条 SQL 里的一个 WHERE 条件——「知识库列表」页建的每一个库,检索时都以这种方式圈定自己的地盘。
SELECT id, content, metadata,
embedding <=> $1 AS distance -- $1 是问题的向量
FROM chunk_embedding_v1
WHERE metadata->>'category' = '后端'
ORDER BY embedding <=> $1
LIMIT 5;Java AI 侧对应的是 similaritySearch 里的 filterExpression,它最终会被翻译成这样的 WHERE 子句:
SearchRequest.builder()
.query("怎么防止重复下单")
.topK(5)
.filterExpression("category == '后端'")
.build();这里藏着一个很多人不知道的深水区:过滤和向量索引的执行顺序。两种可能:
- 先 ANN 后过滤:先找全库最近 100 个,再按条件筛——筛完可能只剩俩,甚至空手(100 个近邻全不是「后端」类)。有些向量库原生就是这么干的,强过滤场景下效果稀烂
- 先过滤后算距离:
WHERE先把范围缩小,再在范围内算距离。过滤后行数多时这是正解;过滤后只剩几百行时,HNSW 图根本用不上,退化为小范围精确扫描——但扫描量小,反而也没问题
PG 的优化器会自己在这两种之间选(拿 EXPLAIN 一看便知它走没走向量索引)。
强过滤场景的认知
强过滤下 ANN 索引收益下降甚至帮倒忙,是 PGVector 的特性而非缺陷——这就是前面说的「有人从专用库回迁 PG」的技术底账。日常你只需要掌握一招:怀疑慢的时候 EXPLAIN 一下,看执行计划再说话。
一个要养成的习惯:rebuild 例行化
最后说个运维向的认知:HNSW 的删除是「标记式」的,删掉的向量并不立刻从图里消失,大量删除后图会变得稀稀拉拉,查询性能悄悄变差。而我们「按 slug 全删全灌」的更新策略意味着删除是家常便饭。
对策是把 rebuild 例行化:文章批量更新完之后,定期(或者每次全删全灌之后顺手)重建索引。数据量小的博客重建只要几秒,做成脚本挂在更新流程末尾就行。
今天的落地清单
📷 图片位(待补充)
应放什么图:「知识库配置 → 知识库列表」页截图,展示多个知识库卡片(博客 Gitee 型 + 至少一个上传型),能看出每库独立的切片量统计;也可以补一张 psql 里 EXPLAIN 带 kb_id 过滤检索的执行计划截图,呼应文末那条清单。
到这里,离线管道全线贯通:Markdown 文件进,带向量的数据库记录出。 数据侧完工,从下一篇起转向在线侧——先别急着搞什么混合检索、Rerank,第 08 篇我们用最朴素的方式把整条问答链路跑通。原因很简单:先有个能对照的「基线」,后面的每一层优化才有效果可讲。
下篇见。