☰
PostgreSQL+pgvector:AI应用混合检索的单表方案
2026/10/15 8:43:24 网站建设 项目流程

1. 项目概述:AI 记忆系统里的“双写”陷阱

最近几年做大模型应用,尤其是做 RAG(检索增强生成)、Agent 记忆这类项目,大家几乎都会遇到同一个头疼的问题:数据到底该往哪儿存。

关系型数据,比如用户信息、文档元数据、对话的会话 ID、权限标签,放 PostgreSQL 或者 MySQL 里,怎么查都顺。语义向量,也就是把文本塞进 embedding 模型得到的浮点数组,又得塞进专门的向量数据库。于是项目里就出现了经典的双写架构:一份数据写进关系库,一份写进向量库,中间还要靠消息队列、定时任务去同步。我见过不少团队,数据量没多大,同步逻辑倒是一大堆——有一边写失败就抓瞎,两边数据不一致就头大。

这个项目的核心思路其实特别朴素:既然 PostgreSQL 已经能存结构化数据,那为什么不把向量也放进去?pgvector 这个扩展就是干这件事的。它让 PostgreSQL 一张表里既能存订单、用户、标签这些常规字段,也能存 embedding 向量字段,并且原生支持按向量距离做排序和过滤。换句话说,语义检索和关系查询不再需要两套系统,一条 SQL 就能搞定。

这篇文章我会从双写问题的根源讲起,然后拆解 PostgreSQL + pgvector 的具体实现方案,包括建表、写入、混合检索、索引调优和常见坑。适合正在做 RAG 应用、Agent 记忆、知识库系统的开发者,也适合那些在纠结“到底要不要再引入一个专用向量库”的架构选型朋友。读完你至少能判断,什么场景下这个方案能帮你省掉一大半的同步代码。

2. 双写架构的痛点与拆解:“为 AI 多写一份数据”的代价

2.1 双写到底在解决什么问题

先说清楚,为什么很多人一开始就选择了双写。AI 应用里的数据天然分成两类:

第一类是“事实型数据”,比如用户资料、文档标题、发布时间、作者、标签。这类数据有明确的字段结构,适合用 SQL 做精确匹配和范围过滤。

第二类是“语义型数据”,比如一段文本的 embedding 向量。它不支持=或者>这种比较,只能用距离度量(余弦距离、欧氏距离、内积)去判断“跟哪个已有内容最接近”。

在很多 RAG 系统里,用户的真实需求是“先限定条件,再做语义找相似”。举个例子,用户问“帮我找去年发布的跟 PostgreSQL 索引优化相关的文章”,这里“去年发布”是一个关系型过滤条件,“跟 PostgreSQL 索引优化相关”是语义检索条件。如果只用向量库,你得先把所有文章向量化然后全量取回来再在应用层过滤;如果只用关系库,你根本没法做语义相似度匹配。所以早期方案就是两个库各干各的,然后用代码把结果拼起来。

2.2 双写带来的代价:不是多了张表,是多了个分布式系统

很多人觉得双写不就是多写一份数据吗?真跑起来你就知道,它带来的问题远不止“多写一次”这么简单:

  • 一致性裂痕:业务数据写成功了,向量数据写入失败,用户下次检索时发现内容“消失了”。没有事务保护,数据对不上。
  • 同步延迟:为了解耦,不少人引入消息队列异步同步 embedding,结果用户刚上传文档就去检索,向量还没写入,搜不到,体验很差。
  • 双份运维:一套向量库意味着多一套监控、多一套备份、多一套权限体系。小团队运维压力翻倍。
  • 成本浪费:把同样的文本重新过一遍 embedding 模型,不是免费的。而且很多向量库按实例收费,起步价不低。

我遇到过最典型的场景:一个知识库系统,PDF 解析、切片、向量化之后写入向量库,但原始文档和元数据在关系库里。后来产品要加“按作者+标签过滤”的检索功能,应用层代码写得越来越绕——先从向量库查出 top 200 候选,再回关系库 join 过滤,然后还要处理分页和去重。更难受的是,关系库里删了一条文档,向量库里那条对应的向量忘了删,用户就总能搜到一些幽灵内容。

2.3 为什么选择 PostgreSQL 而不是新增专用向量库

把向量塞进 PostgreSQL 并不是新想法,但之前大家普遍觉得“关系库干不了向量检索的活”。pgvector 的出现其实已经改变了这个局面,原因有几个:

第一,PostgreSQL 本身的事务、MVCC、备份恢复、权限体系都是现成的。向量字段和普通字段在同一行里,天然具备原子性。写一行,要么全成功,要么全失败,不存在“关系库有、向量库没有”的中间状态。

第二,PostgreSQL 的优化器可以同时处理结构化过滤条件和向量距离排序。你不需要在应用层做两段式拼接,一条 SQL 就能同时完成过滤和相似度排序。

第三,对于大多数中小规模应用(千万行以内),pgvector 的性能已经足够用了。专门的向量数据库在十亿级场景下有优势,但绝大多数 RAG 项目远没到那个量级,先用 PostgreSQL 撑住,省掉一套基础设施,是更务实的路线。

3. 实操:把关系和语义检索装进一张表

3.1 环境准备:安装 PostgreSQL 与 pgvector 扩展

我用的环境是 Ubuntu 22.04 + PostgreSQL 15。pgvector 的安装有两种常见方式,二选一就行。

如果你用的是 apt 安装的 PostgreSQL,可以直接装对应版本的扩展包:

sudo apt install postgresql-15-pgvector

如果是源码编译或者想手动装,从 GitHub 克隆编译也很简单:

git clone --branch v0.7.4 https://github.com/pgvector/pgvector.git cd pgvector make make install

编译前记得确保 pg_config 在 PATH 里,否则 make 会找不到 PostgreSQL 的头文件。装完之后,在目标数据库里启用扩展:

CREATE EXTENSION IF NOT EXISTS vector;

然后可以快速验证一下:

SELECT '[1,2,3]'::vector;

能正常返回向量字面量,就说明扩展生效了。这个步骤卡住的人不少,多半是安装的 PostgreSQL 版本和插件版本不匹配,后面我会再详细说。

3.2 表结构设计:一张表承载业务字段与向量字段

环境就绪之后,就是最核心的建表环节。拿一个典型的知识库场景举例:我们要存文章、作者、发布时间、标签和向量,一张表搞定:

CREATE TABLE documents ( id BIGSERIAL PRIMARY KEY, title TEXT NOT NULL, author TEXT, published_at TIMESTAMPTZ DEFAULT now(), tags TEXT[], content TEXT, embedding VECTOR(1536) );

这里embedding字段用了VECTOR(1536),1536 是 OpenAItext-embedding-3-small模型的输出维度。如果你用的 embedding 模型输出 1024 维、768 维,这里就写成对应的数字。

注意:VECTOR的维度在建表时就固定了。如果之后换了 embedding 模型导致向量维度变化,你需要重建列或者用ALTER TABLE ... ALTER COLUMN ... TYPE vector(新维度)转换,这在数据量大时会是一个不小的工程。

写入数据的时候,普通字段和向量字段用一条 INSERT 就能完成:

INSERT INTO documents (title, author, published_at, tags, content, embedding) VALUES ( 'PostgreSQL 索引调优指南', '张三', '2024-05-10', ARRAY['postgresql', '索引'], '全文内容……', '[0.012, 0.034, ...]'::vector );

对比双写方案,这里少了一整个向量的独立写入通道。这一行数据的任何字段更新,都是和向量一起提交的,事务边界清晰。

3.3 建索引与相似度排序:检索的核心玩法

表建好之后,纯 SQL 查询完全没有变化,常规过滤照常:

SELECT id, title FROM documents WHERE author = '张三' AND published_at >= '2024-01-01';

语义检索则靠向量距离运算符。pgvector 内置了三个距离函数:

  • <->:欧氏距离,适合数值型向量的精确匹配
  • <=>:余弦距离,文本语义检索最常用
  • <#>:负内积,适合某些需要对内积做索引的场景

最基础的语义检索是这么写的:

SELECT id, title, 1 - (embedding <=> '[0.011, ...]'::vector) AS similarity FROM documents ORDER BY embedding <=> '[0.011, ...]'::vector LIMIT 5;

注意<=>返回的是余弦距离,值越小越相似。想直接得到相似度分(0 到 1 之间),用1 - 距离换算。

3.4 混合检索:一条 SQL 同时过滤和排序

现在来到这个方案最精彩的部分——把前面两个查询合并成一条 SQL。需求还是那个:“找张三在 2024 年发布的和『索引优化』最相关的 5 篇文章”:

SELECT id, title, published_at, 1 - (embedding <=> '[0.011, ...]'::vector) AS similarity FROM documents WHERE author = '张三' AND published_at >= '2024-01-01' ORDER BY embedding <=> '[0.011, ...]'::vector LIMIT 5;

就这么简单。PostgreSQL 先按条件过滤出符合条件的行,再在剩余行里算向量距离并排序。不存在两套系统之间的数据搬运。而且因为过滤和排序发生在同一时刻、同一份数据快照上,所以结果永远是自洽的。

如果你需要先粗筛相似内容、再做精确的过滤,可以用子查询或者 CTE 嵌套。比如“先取全局最相似的 100 条,再看里面有多少属于张三”:

WITH similar AS ( SELECT id, title, author FROM documents ORDER BY embedding <=> '[0.011, ...]'::vector LIMIT 100 ) SELECT * FROM similar WHERE author = '张三';

这两种写法的语义不同——前者是先过滤再检索,后者是先检索再过滤。它们的性能特征差别很大,我放到后面索引和调优的部分细讲。

4. 核心细节:索引选型、参数配置与过滤策略

4.1 索引选型:HNSW 还是 IVFFlat

数据量几十条的时候,全表扫也没问题。但到了几十万上百万行,没有向量索引就完全跑不动了。pgvector 提供了两种索引类型。

IVFFlat(倒排文件索引)的思路是先对数据集做聚类,查询时只搜索距离最近的若干个聚类中心所在的桶。它的优点是构建快、内存占用低,缺点是有召回率损失,而且必须在数据量够大时构建才有意义,否则聚类中心分的桶里根本没什么数据。

HNSW(分层小世界图)的思路是构建一张多层图,查询从顶层开始逐层向下搜索。它的优点是召回率高、查询延迟低,缺点是索引构建慢、内存占用大。

我自己在绝大多数场景下建议直接用 HNSW,特别是对召回率有要求的 RAG 应用。IVFFlat 适合那种批量导入完就基本不更新、对内存敏感的场景。

创建 HNSW 索引的 SQL 如下:

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

参数说明:

  • m:每个节点的最大连接数,默认 16。增大它召回率会提升但构建变慢且更占内存。
  • ef_construction:构建索引时动态候选列表大小,默认 64。这是一个典型的“越大越慢但召回越好”的参数。

查询的时候还能通过SET hnsw.ef_search = 100控制搜索时的候选数量。这个设置是会话级的,每次连接都要重新设置,建议应用层连接池里统一处理。

提示:索引列的ops参数必须和查询时用的距离函数匹配。查询用余弦距离(<=>),索引就要用vector_cosine_ops;查询用欧氏距离(<->),索引就要用vector_l2_ops。用错了索引不生效,查询直接退化成全表扫描。

4.2 过滤策略:先过滤还是先检索,差别很大

这是 pgvector 混合查询里最容易被忽略的性能瓶颈。同一个业务需求,SQL 写法的先后顺序不同,执行计划可能差出几个数量级。

先说“先过滤再检索”:

SELECT ... FROM documents WHERE author = '张三' ORDER BY embedding <=> $1 LIMIT 5;

如果author上有普通 B-tree 索引,PostgreSQL 会先缩小到张三的文章集合,假设有 1000 篇,然后对这 1000 篇算向量距离并排序。这是最优的路径,因为向量距离计算量跟候选集大小成正比。

但问题来了:如果过滤条件是tags @> ARRAY['postgresql']这种筛选性不强的条件,可能滤完还剩几十万行,那向量距离计算压力依然很大。

再说“先检索再过滤”:

SELECT ... FROM ( SELECT * FROM documents ORDER BY embedding <=> $1 LIMIT 100 ) t WHERE author = '张三';

这种写法先利用 HNSW 索引快速取回 100 条候选,再做过滤。如果过滤条件非常严格(比如最后符合条件只有 1 条),很可能这 100 条里根本没有,导致召回失败。我把这个坑叫“截断窗口问题”。

实操建议是:能先过滤就先过滤,但过滤条件要尽量选择筛选性强的字段(比如author、published_at)。如果多个过滤条件组合后仍然存在“候选窗口不足”的风险,可以适当放大 LIMIT 值,或者把向量检索和关系过滤分开做,最后在应用层合并。没有一个银弹方案,只能按实际数据分布去调。

4.3 数据更新与向量同步的隐形问题

双写架构里最麻烦的两个场景,在 pgvector 方案里也依然存在,只是表现方式不同:一是改动文本内容,二是删除数据。

文本改了,embedding 必须跟着重新生成:

UPDATE documents SET content = '新内容', embedding = '[0.123, ...]'::vector WHERE id = 42;

这条 SQL 会更新整行,并且自动更新 HNSW 索引里的向量。问题在于,如果 embedding 生成是在应用层做的,你依然需要在应用里先调模型,再发 UPDATE。好消息是不用担心关系库和向量库之间的一致性了——这行数据要么是旧版本,要么是新版本,没有中间状态。

删除也一样:

DELETE FROM documents WHERE id = 42;

索引会随之更新。这在双写架构里对应的是“向量库删一条记录”,但你是靠 trigger 同步还是手动写代码,效果完全不一样。在 pgvector 里,删掉就是删掉了,事务提交后查询永远看不到。

我在生产环境里还踩过一个坑:批量更新 embedding 的时候,不要一次性发几百万条 UPDATE。建议分批,每批几百条,避免 HNSW 索引在大量写入时锁膨胀和 WAL 日志暴涨。

5. 常见问题与排查:实操中反复遇到的坑

5.1 安装与扩展启用失败

很多人装完 pgvector 之后执行CREATE EXTENSION vector报 “extension 'vector' is not available”。这个问题十有八九是插件版本和 PostgreSQL 版本不匹配。

排查方法很简单,先看当前实例版本:

pg_config --version

然后确认插件目录下有对应的.so文件:

ls /usr/lib/postgresql/15/lib/vector.so

如果插件目录和实例版本对不上,就用 pg_config 重新编译一遍。源码编译时最坑的是环境 PATH 里同时存在多个版本的 pg_config,编译出来的插件装到了别的版本目录下。保险做法是显式指定:

make PG_CONFIG=/usr/lib/postgresql/15/bin/pg_config

5.2 相似度结果全是 0 或完全不对

这个是新手最容易困惑的点。如果你发现1 - (embedding <=> $1)算出来总是相同值,或者排序结果明显不对,先检查三件事:

第一,查询向量维度必须跟表字段维度一致。维度不一致的时候 PostgreSQL 会直接报错,但如果你用程序拼接向量字符串,某个嵌入模型换了维度,报错信息可能会被吞掉,返回一个看似正常实则错误的结果。

第二,确认 embedding 是不是全零向量。全零向量的余弦距离计算会退化,而且在很多模型里,空内容切片容易出现全零输出。建议在写入前过滤掉过短的文本切片。

第三,确认你用了正确的距离运算符。我之前说过,余弦相似度用<=>,内积用<#>。如果误用<#>去对比余弦距离的阈值,那就是拿尺子量体重,结果没有意义。

5.3 查询性能差:索引没走上

混合查询最常见的性能问题,是明明建了 HNSW 索引,执行计划却显示Seq Scan。原因基本就两个:

一是过滤条件太强,优化器认为先过滤再算距离比走索引更便宜。这种情况可以尝试调整 SQL 写法,比如把 LIMIT 写清楚,或者用enable_seqscan = off临时测试(仅排查用,不建议生产环境长期关闭)。

二是索引 ops 类型跟查询运算符不匹配。HNSW 索引用的vector_cosine_ops,查询却用了<->,索引直接无法参与查询。用下面的 SQL 确认索引是否生效:

EXPLAIN ANALYZE SELECT id FROM documents ORDER BY embedding <=> '[0.1, 0.2]'::vector LIMIT 10;

执行计划里出现Index Scan using documents_embedding_idx就说明索引生效了。

5.4 过滤字段的普通索引和向量索引如何配合

混合查询里,PostgreSQL 会选择先走哪个索引。如果author上有 B-tree 索引,embedding上有 HNSW 索引,优化器的选择取决于它估算的过滤结果集大小。

想让优化器做出正确的选择,统计信息是关键。确保过滤字段上有准确的统计信息,必要的时候执行:

ANALYZE documents;

如果发现优化器频繁选错路径,可以将过滤字段和向量索引放在同一个复合索引的前缀位置,但 pgvector 目前对复合索引的支持有限,需要仔细测试。我自己的经验是:大部分场景下两个独立索引就够了,刻意去建复合索引往往会因为优化器选择错误而得不偿失。

6. 个人实操体会与建议

踩了那么多坑,说点掏心窝的话。pgvector 这个方案并不是万能的,选型的时候一定要认清楚自己的数据规模。如果项目注定要跑到亿级向量、需要分布式扩展,那专用向量数据库依然是更合理的选择。但如果像我做的多数 RAG 和 Agent 记忆项目一样,数据量在百万量级、团队没有专职的向量库运维,PostgreSQL + pgvector 确实是我现在最愿意推荐的起点。

另外一个很实际的好处是:团队里本来就会用 PostgreSQL 的同事,不需要再学一套新东西。SQL 还是熟悉的 SQL,只是多了一个新类型、几个新运算符。应用层代码里,同步消息队列、补偿任务、双写一致性检查这些历史包袱可以直接砍掉,整个系统的可观测性反而变好了。

最后分享一个小技巧:如果担心将来数据量涨到 pgvector 撑不住,设计表的时候就把向量字段单独放到一张附表里,主表和向量表通过外键关联。这样将来真需要迁移到专用向量库的时候,只需要迁移那一张向量表,业务表完全不用动。这个设计让我的两个项目在后续扩展时省了非常多的事,建议你现在就按这个思路建表。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询