我最近一直在折腾 AI 应用的记忆层,实测下来发现一个特别反直觉的现象:很多团队做 AI Agent 或者知识库问答,数据还是要写两份——一份进 PostgreSQL 管业务关系,一份进向量数据库管语义检索。同步逻辑、一致性校验、重复代码,写一套架一套。其实 PostgreSQL 本身就能把语义检索装进同一张表,用 pgvector 扩展,一条 SQL 同时做关系查询和向量相似度匹配。这篇文章就把我验证过的方案讲清楚,包括安装、建表、混合检索 SQL、索引调优和踩坑记录,适合正在做 AI 应用后端、想省掉一套运维的同学参考。
1. 先想清楚一个问题:AI 的记忆为什么一直在“写两遍”
1.1 两种查询需求,本质上是两种存储逻辑
AI 应用里的数据查询需求,我总结下来其实就两类。一类是传统的关系查询,比如“找出用户 ID 为 42 的所有订单,按时间倒序”,这类查询讲究精确匹配、范围过滤、排序聚合,表结构是固定的,字段是明确的,结果是要可复现的,这是 PostgreSQL 这类关系数据库的看家本事。
另一类是语义检索,比如“找出于‘如何配置 PostgreSQL 连接池’这句话意思相近的所有文档片段”,这时候你不能指望关键词匹配,因为用户可能说的是“连接池”,文档里写的是“connection pool”,字面完全不同但语义相近。这类查询需要把文本转成向量,然后在向量空间里做最近邻搜索,这是向量数据库的强项。
问题在于,现实里的 AI 应用几乎都是两类查询混着来的。用户问“帮我查一下上周哪些订单的客服反馈在投诉物流慢”,你要先按“上周、订单、投诉物流”把范围缩到一个很小集合,再对这个集合做语义匹配。如果关系数据和向量数据各自放一套系统,这种混合查询就得先在一套系统里捞数据,再跑到另一套系统里去算相似度,要么拼 SQL 拼得想吐,要么应用层写一堆胶水代码来回搬运。
1.2 双写架构的真实成本,远不止多存一份数据
我见过很多团队最初选的方案是:业务数据留在 PostgreSQL,文本切片后的向量扔进 Milvus 或者 Chroma,两边通过应用层做同步。一开始数据量小,一切看起来都挺美好,等到数据量上来,问题就一个接一个冒出来。
首先是数据一致性。写入一条业务记录,要先落 PostgreSQL,然后切片、生成向量,再写入向量库,任何一个环节失败,两边数据就对不上了。我见过最头疼的就是 PostgreSQL 事务回滚了,向量库里还残留着半截数据,用户检索的时候突然冒出几条已经删除的文档。解决它就得靠补偿事务、重试队列,这些代码写出来比业务本身还复杂。
其次是事务能力。关系库里有事务,一条 UPDATE 语句可以原子地改掉一百万条记录,但向量库普遍没有这种概念。涉及两个系统的操作,应用层想做原子性,只能加分布式事务中间件,那是把问题搞大的方向。
第三是运维复杂度。多一套系统就多一套安装、升级、监控、备份、权限管理的活儿。热词里大家都在搜 PostgreSQL 怎么装、装哪个版本,这本身就说明问题:很多人只想把数据库老老实实用起来,根本不想再引入第二个存储系统去做运维。PostgreSQL 加一个扩展就能把向量检索能力装进来,运维面反而更小,这一点在长线项目里价值很大。
2. 一张表解决两件事:pgvector 的设计思路和适用边界
2.1 向量不是一种特殊数据,而是一种普通列类型
pgvector 这个扩展做的事情,说起来其实不复杂:给 PostgreSQL 增加一种叫做 vector 的数据类型,本质就是一个浮点数数组,比如vector(1536)表示 1536 维的向量。你可以在 CREATE TABLE 的时候直接定义一个向量列,像定义price numeric、title text一样定义embedding vector(1536),也可以给这个列建索引、做过滤、排序,它就是一个普通的列,只是它的比较运算走的是向量距离函数而不是等值比较。
这种设计带来的最大好处是:关系查询和语义检索共用同一个存储引擎、同一套事务、同一份备份。业务表和向量数据可以在同一个 schema 里,JOIN、WHERE、GROUP BY 都可以直接带着向量条件一起做。应用层不需要维护两个 client,不需要处理两套连接串,也不再需要纠结“先查数据库还是先查向量库”这种规划问题。
我拿实际的表结构举例。假设你做一个文档问答系统,正常的库表设计里有 users、documents、chunks 三张表,chunks 表存的是文档切片后的文本。用 pgvector 之后,你只需要在 chunks 表上多加一列:
CREATE TABLE chunks ( id bigserial PRIMARY KEY, document_id bigint REFERENCES documents(id), content text NOT NULL, embedding vector(1536) );这一列 embedding 就是那套语义检索能力的载体。你要查第 3 篇文档里和某句话最像的 5 个片段,SQL 就是一条,完全不用去第二套系统里倒腾。
2.2 一条 SQL 里同时做倒排和向量检索,是靠索引协调的
很多人会担心:把向量和普通字段放一张表,查询是不是会变慢?其实不会,PostgreSQL 的查询规划器会同时利用多种索引来解决这个问题。
SELECT 的 WHERE 子句如果有document_id = 42这样的等值条件,PostgreSQL 会走 document_id 上的普通 B-tree 索引,先把数据范围收窄;ORDER BY 里的向量距离计算会走 embedding 列上的 HNSW 向量索引或者 IVFFlat 索引,做最近邻搜索。规划器会把两个条件拆开,分别用各自的索引扫描,然后做一个 BitmapAnd 合并结果集,再排序输出。
这么做复杂度上是有保障的。向量索引只扫描“经过关系条件过滤后”的候选集,如果 document_id 过滤下来只有 200 条,那距离计算就是在这 200 条里做精确排序,耗时几乎为零。换句话说,关系条件的过滤能力是提前发挥作用的,不像独立向量库那样,向量库默认只能在全量向量集合上做 ANN 搜索,过滤后再精排是一件很别扭的事。
日常开发里最常见的理解误区是:以为向量索引只能用在没有任何其他条件的裸查询里。实际上 pgvector 的索引扫描可以直接接受其他列的过滤条件下推。你写 ORDER BY embedding <=> $1 LIMIT 5,条件再叠加 WHERE category = 'help_doc' AND created_at > now() - interval '30 days',改进后的版在 0.7 版本之后对过滤查询的支持友好度明显提升了一大截。
2.3 边界在哪里:这个方案不适用什么场景
PG 加 pgvector 不是银弹,我在实际项目里也明确拒绝过几个场景。
一是数据集规模到了亿级以上,且查询 QPS 还要求极高的场景。pgvector 的 HNSW 实现虽然不错,但和专门的向量数据库原生分布式架构相比,在极端规模下还是拼不过。百万级以内,PG 完全无压力;千万级以上,就得认真做压测再定。
二是需要专门的向量索引加速能力,比如 IVF-PQ 产品级量化、基于 GPU 的暴力检索优化这些,即便 pgvector 慢慢提高了,但跟专有系统的算法深度比,仍有差距。
三是企业里面已经规模部署了多个大数据节点、要求水平扩展的场景。PostgreSQL 的原生玩法是主从,而不是分布式,向量列本身不会改变这一基本性质,想要水平扩展得靠 Citus 这类扩展来改造,这会动到既有架构。
我的经验是:中小型项目、AI 应用的原型验证、内部知识库、RAG 流程的 MVP,这些场景用 PG 加 pgvector 是最舒服的。反正以后数据量真上去了,向量数据还是能导出到专门的向量库,迁移成本并不高,但前期能少维护一套系统,收益是实实在在的。
3. 从装好到能用:环境准备里最容易被卡住的几个点
3.1 版本怎么选、扩展怎么装
现在 PostgreSQL 的版本迭代很快,16 和 17 都是稳定版本。我给的建议是:新项目直接上 PostgreSQL 16 或者 17,尽量别用 15 及更老版本,因为 pgvector 很多新特性是跟着 PG 版本走的,比如 0.7 版本的迭代索引扫描,就是要搭配 PG 的 versioned index 能力。pgvector 扩展本身选 0.7.x 以上的版本,各个方面都成熟很多。
安装这块,很多人搜“ubuntu 源码编译 postgresql”、“linux 离线安装 postgresql”,都是想碰运气找最省事的方法。实际上流程很固定:
# Ubuntu/Debian 系,直接用官方 APT 源 sudo apt install postgresql-16 postgresql-16-pgvector # 如果用的是源码编译方式,要先装好依赖 sudo apt install build-essential libreadline-dev zlib1g-dev wget https://ftp.postgresql.org/pub/source/v16.3/postgresql-16.3.tar.gz tar xzf postgresql-16.3.tar.gz && cd postgresql-16.3 ./configure --prefix=/usr/local/pgsql make -j$(nproc) && sudo make install源码编译 PG 本身不算难,难点是安装完 pgvector。源码编译 PG 之后,pgvector 要用 PG 的开发头文件去编译安装,不然版本对不上就会报 extension control file 缺失之类的错误。我的建议是:除非你有特殊原因必须源码编译,否则一律用官方二进制包或者发行版仓库装,能省掉大概半天到一天的折腾。
离线安装的话,思路是找一台相同系统和相同 PG 版本的机器上先下载好 deb 包,或者把 pgvector 的源码包整个拷过去,在目标机上编译。注意 PG 的pg_config路径要指到目标机的 PostgreSQL 安装目录,否则 pgvector 的 Makefile 会去默认路径找,对不上就白搭。
3.2 装完后必须做的几个检查
装完扩展不是CREATE EXTENSION vector;就跑,建议先做一个简单检查:
SHOW shared_preload_libraries;pgvector 本身不需要预加载,但如果你以后要用 pg_stat_statements 这类插件,这个变量就值得看一眼。更好的是直接看看这个扩展能不能用起来:
SELECT * FROM pg_available_extensions WHERE name = 'vector'; CREATE EXTENSION IF NOT EXISTS vector; SELECT vector('[1,2,3]') <-> vector('[4,5,6]') AS distance;如果第三条语句能算出距离值,说明扩展装好了。我在新环境里经常干的第二个检查是确认默认数据库的编码,pgvector 对 UTF8 编码的兼容性更好,如果库是 SQL_ASCII 或 LATIN1 编码,后面存中文向量相关数据时可能出问题,最好就在建库阶段选UTF8。
还有一个小坑在权限。很多团队会让应用连接用户只持有 DML 权限,用来建扩展的账号往往是超级用户。CREATE EXTENSION是实例级操作,建议由管理员账号执行,普通业务账号只拥有对已有表的读写权限即可。后面在迁移脚本里把 CREATE EXTENSION 放进初始化阶段,别写到业务代码的自动执行里去。
4. 实操核心:列表设计、向量填充和混合检索 SQL 的写法
4.1 字段设计直接决定检索质量,别只看向量
刚开始做这种方案的时候,很多人一门心思放在向量列上,反而忽略了普通列的设计。实际上,一张表能同时承担关系查询和语义检索,靠的是每一列各司其职,向量列只是其中一列而已。
我强烈建议在 chunks 这类表上,除了 content 和 embedding,至少把 document_id、chunk_order、created_at、source_type 这几个字段一起设计进去。document_id 用来做文档粒度过滤,chunk_order 用来做切片顺序恢复,source_type 用来区分文档类别,created_at 用来做时间范围过滤。看起来多写几个字段,但混合查询的效率会因为这几个普通字段的过滤能力直线上升。
举个例子。用户问“最近一周上传的运维手册里,有哪些内容和‘数据库连接超时’相关”,你的 SQL 应该是这样:
SELECT c.id, c.content, c.document_id, 1 - (c.embedding <=> $1) AS similarity FROM chunks c JOIN documents d ON d.id = c.document_id WHERE d.source_type = 'ops_manual' AND d.created_at >= now() - interval '7 days' ORDER BY c.embedding <=> $1 LIMIT 10;注意这里的 ORDER BY 用的是向量距离<=>(余弦距离),而 WHERE 的条件是普通字段的过滤。索引方面,document_id 和 source_type 建普通 B-tree 索引,embedding 列建 HNSW 索引。整个 SQL 的执行速度,取决于最严格的那个过滤条件下能达到的选择率,这就是把关系查询和语义检索放进一张表的最大收益。
4.2 向量生成的姿势:批量写入比逐条硬写聪明得多
向量不是平白冒出来的,需要把文本喂给 embedding 模型。这边我先提醒一下:线上环境的向量写入,尽量批量做,不要一条一条地调接口生成再 INSERT。原因很简单,模型 API 的时延通常是几十到几百毫秒,逐条插入的时候,数据库这边反而没有性能问题,瓶颈在网络调用上。
批量方式一般长这样:
import psycopg2 from psycopg2.extras import execute_values # 假设 docs 是一个 [(content, embedding), ...] 的列表 records = [(content, embedding, doc_id) for content, embedding, doc_id in chunk_list] conn = psycopg2.connect("dbname=ragtest user=postgres") cur = conn.cursor() execute_values( cur, """ INSERT INTO chunks (content, embedding, document_id) VALUES %s """, records, page_size=500 ) conn.commit()如果嵌入向量是 float 数组,注意把它们转成 PostgreSQL 能直接识别的字符串格式,比如[0.1, 0.2, 0.3],psycopg2 的 execute_values 对 array 类型适配得不错。pgvector 提供了 Python 的包装包pgvector-python,用它的Vector类型会更保险,能够避免很多字符串格式的边界问题。
4.3 距离函数别乱选,三种算法的差异一览
pgvector 支持三种距离:L2 距离<->、内积<#>、余弦距离<=>。选哪个很重要,因为直接决定匹配效果,虽然看起来只是符号不一样。
先说结论:用 OpenAI 的 text-embedding-ada-002 或 text-embedding-3 系列,官方建议用余弦相似度。但是 pgvector 没有直接的“余弦相似度”函数,只有“余弦距离”,所以要写成1 - (embedding <=> $1)来转换成相似度。
用 BGE 系列的中文向量模型,很多人也推荐余弦距离。一些开源的 sentence transformer 模型在数学上做的是向量内积最大化,那内积距离<#>就更契合。但有一个非常常见的坑:内积距离对向量模长很敏感,如果 embedding 模型没有做归一化,直接上内积容易出现“长向量的文档永远排在前面”的假象。所以我的经验是,优先余弦,除非你明确知道模型输出是 L2 归一化过的。
刚才那个例子里的1 - (embedding <=> $1) AS similarity就是余弦相似度,范围从 -1 到 1,便于展示,也方便做阈值过滤,比如WHERE similarity > 0.8。
5. 实践中的调优和坑:从“能跑”到“跑得稳”
5.1 索引怎么选、参数怎么调,才能支撑高并发
pgvector 有两种索引:IVFFlat 和 HNSW。IVFFlat 是倒排文件索引,把向量聚类分成若干簇,查询时只搜最相关的几个簇。HNSW 是层级小世界图索引,查询时沿图做近似搜索,从 0.5.0 版本开始支持。
我的选择是:数据在百万级以内,直接上 HNSW。HNSW 的构建时间和内存占用比 IVFFlat 高一些,但查询精度高、参数敏感度低,不需要像 IVFFlat 那样先确认聚类数量。IVFFlat 有一个很阴的坑:lists 参数设得不好,构建时把数据全塞进少数几个簇,线上召回率会惨不忍睹。HNSW 没有这个问题,省心。
建索引的 SQL 长这样:
CREATE INDEX ON chunks USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);m 表示每个节点的最大连接数,ef_construction 是构建时的搜索宽度。这两个值越大,索引质量越高,但构建时间和存储体积也越大。实际项目里 m=16、ef_construction=64 是性价比较好的起步配置,线上偏召回优先可以调到 m=32、ef_construction=128。
查询侧还有SET hnsw.ef_search = 100;这样的参数,控制每次搜索时考察的候选节点数量。我的经验是,先把它设成 40 左右跑一轮看效果,不够再往上加,最大值不用超过 200。这个参数对召回率的影响非常直接,调它比调 m 的收益大得多。
5.2 典型问题速查表:这些坑我基本都踩过
| 问题现象 | 根本原因 | 解决方案 |
|---|---|---|
| 查询很慢,向量列没走索引 | 数据量小,规划器觉得全表扫描反而快 | 用 EXPLAIN ANALYZE 看执行计划,强制SET enable_seqscan = off测试索引实际效果 |
| 余弦相似度返回全是负数或很奇怪 | 用了内积<#>未做归一化 | 换成余弦距离<=>,结果更稳定 |
| 插入大量向量后查询变慢 | HNSW 索引构建时机不对 | 先插入数据,再建索引,避免边插边 build |
| 有过滤条件时检索精度骤降 | 向量索引在过滤场景下扫描受限 | 升级 pgvector 到 0.7+,或者考虑对过滤字段建 GIN 索引做联合过滤 |
| 维度不匹配报错 | 向量维度和建表时写的不一致 | embedding 模型输出维度固定,建表时写对,比如 OpenAI 3 small 是 1536 |
| 中文字符串乱码 | 数据库编码不是 UTF8 | 重建库,选择 UTF8 编码,这是老库遗留问题 |
第一个坑特别典型。小数据量下,几十万条以内的向量表,PostgreSQL 的规划器经常觉得走顺序扫描比分两步走索引更快,毕竟借助索引多了一个取数据的环节。这时候别慌,用 EXPLAIN ANALYZE 看一眼执行计划,真实数据量大上去之后,规划器自己会切到索引路径。
过滤条件下的精度问题我也遇到过。在 pgvector 0.6 时代,WHERE 带一个普通字段的条件,HNSW 索引搜索会在没有过滤的情况下先把候选集找出来再做过滤,如果符合条件的数据只占总量的 0.1%,那召回率很容易掉。0.7 版本的 iterated scan 解决了扫描阶段就把过滤条件下推的问题,所以再次强调一下:版本落后是大坑。
5.3 应用层接入地最大化利用现有生态
做 AI 应用的同学现在很多在搜 PostgreSQL 有没有合适的 MCP server 或 skill,因为现在 AI Agent 开发确实频繁要用数据库。实际上 PostgreSQL 生态里这块比大多数人想象得完善:postgres-mcp这类社区项目可以把 PostgreSQL 以 MCP 工具的形式暴露给 Agent 调用,支持自然语言转 SQL、查询 schema 等。如果你正在折腾 AI Agent,直接在 MCP 配置里加一个 PostgreSQL server,Agent 就能自己查询库里的关系数据和向量数据,不需要给 Agent 专门开什么旁路通道。
另外说一个最近流行的做法:把 pgvector 和 RAG 工具链放进同一个项目里。LangChain 里已经有 PostgreSQL 的 vector store 封装,直接把 Connection String 指向你的 PG 实例就能用,看起来和用独立向量库的接口差不多,但底层数据都进到那一张表里。加上 PG 自己的 row-level security,你甚至可以做出“不同用户只能检索到自己的文档切片”的效果,这在独立向量库里实现起来非常费劲。
6. 最后的几张管理层底牌:备份、监控和将来路线
6.1 备份策略的简化是最容易被低估的收益
我们回过头看最初的问题:“别让 AI 的记忆写两遍”。独立向量库方案里,备份向量库是一个很微妙的问题,很多产品没有成熟的备份恢复工具,或者备份格式不兼容跨版本恢复。而这一切在 PostgreSQL 里根本就不是问题。
pg_dump 天然支持带 vector 类型的数据库。我测试过,一个含 HNSW 索引的库,pg_dump 会正常导出表数据和索引,恢复的时候也会自动重建索引。只是恢复时间会比逻辑备份耗时长一些,毕竟是重建索引,但考虑到一致性、完整性、可恢复性这三点,这个代价完全可以接受。
更稳的是物理备份,也就是用基础备份加 WAL 归档的方式,PostgreSQL 的 PITR 恢复能力依然适用。大小可以压到很小,恢复点可以精确到指定时间戳。AI 应用真要出事故了,能恢复到 5 分钟前和只能恢复到“昨天的某个时刻”,差别太大了。
6.2 什么时候需要升级到独立向量库
我前面说过这个方案适合大多数场景,但也得给出清晰的升级信号,免得读者在数据量爆发后措手不及。
当你发现 pgvector 的单实例方案出现以下三个现象时,就该认真考虑迁移了:单表向量数据量超过五千万到一亿;单机查询负载下 CPU 长期超过 80%,优化配置也降不下来;团队对水平扩展有硬性需求,必须多节点分摊负载。
这时候的做法是:先做数据集导出,把 vector 列转成数组或二进制文件,在新向量库里批量导入,同时应用层做好双读切换。因为前期只有一套系统,迁移窗口反而是可控的,不存在两套系统之间持续同步的复杂度,这也是“先别写两遍”的长期价值。真到了要写两遍的那一天,你也会比一开始就双写的人轻松得多。
我在实际项目里最深的体会是:技术选型少即是多。AI 应用的存储需求一直在膨胀,但基础设施应该是收敛的。PostgreSQL 把关系查询和语义检索装进一张表,不是把功能吹得天花乱坠的魔法,而是让现有团队用熟悉的 SQL、熟悉的事务、熟悉的运维方式,去承接新场景。你自己动手试的时候,建议先拿一个小数据集跑通全文流程,再用自己的业务数据做精度对比,多踩几个坑,自然就明白这套方案的甜头了。