PostgreSQL:AI 时代原生的"隐型"向量数据库 --- 用 pgvector 给消息表加上长期记忆
关系型数据库是互联网应用的基石。账号信息、订单数据、聊天记录、业务数据,几乎都依赖它存储。 但当 AI Agent 需要"回忆" 时,传统关系型数据库突然"不够用"了------直到 pgvector 出现。
一、痛点:AI Agent 需要一个"有记忆"的消息表
豆包、Codex 这类 AI Agent,几乎都要长期存储聊天记录。
数据表怎么设计?这是经典的三张表结构:
scss
用户表 users
├── user_id 主键
会话表 conversations ── 一对多 ──◎ users
├── conversation_id
├── title
└── user_id (关联用户表)
消息表 messages ── 一对多 ──◎ conversations
├── message_id
├── content
└── conversation_id (关联会话表)
SQL 就能解决传统查询:
sql
-- 拿到某用户的所有会话
SELECT * FROM conversations WHERE user_id = '你的用户ID';
-- 拿到某会话的所有消息(按时间升序)
SELECT * FROM messages
WHERE conversation_id = '你的会话ID'
ORDER BY created_at ASC;
但问题来了:"按时间找消息" 很简单,"按语义找消息" 怎么做?
比如用户 3 天前说过一句"我把路由器放客厅了",今天问"我把网线放哪了?"------关键词完全不一样,但语义相关。这就要 embedding + 向量检索,普通 SQL 干不了。
二、常规方案:MySQL + Milvus,和它的烦恼
传统上你会引入专门向量数据库(如 Milvus):
MySQL(业务数据) + Milvus(向量数据) = 双库双写
麻烦在于:
- ❌ 双写:插入一条消息,要同时写 MySQL 和 Milvus,事务难保证
- ❌ 双系统维护:两套部署、两套监控、两套备份
- ❌ 关联查询撕裂:业务关系和向量检索是分离的,跨库 JOIN 做不了
一句话:
Mysql + Milvus = Pg,用一个 PostgreSQL 全搞定。
三、pgvector:给普通列加一个"向量字段"
PostgreSQL + pgvector 扩展的最大优势,就是在原来的消息表上,多加一个向量字段:
在 psql_test 里,这一行就是把向量塞进普通表:
js
// messages.mjs - 创建消息时写入 embedding
// 把 OpenAI embedding 结果以 $4::vector 写入 PostgreSQL vector 字段
const { rows } = await query(
`INSERT INTO messages (conversation_id, role, content, embedding, created_at)
VALUES ($1, $2, $3, $4::vector, $5)
RETURNING *`,
[conversationId, role, content, JSON.stringify(vector), new Date().toISOString()]
);
字段类型是 vector,但它在的还是同一张 messages 表。所以你可以------
四、一张表同时做"关系查询 + 语义检索"
这是 pgvector 最惊艳的地方:不用拆分架构、不用同步数据、不用写复杂的关联逻辑。
一条 SQL 完成 4 层过滤:
sql
SELECT m.*
FROM messages m
JOIN conversations c ON m.conversation_id = c.id
WHERE
c.user_id = '你的用户id' -- ① 按用户过滤
AND c.id = '你的会话ID' -- ② 按会话过滤
ORDER BY
m.embedding <=> '[1.2, 0.5, 0.8, ....]' -- ③ 按语义检索
LIMIT 5; -- ④ 取最相似 5 条
语义检索的完整实现(psql_test 里真正跑通的代码):
js
async function searchSimilarMessages(conversationId, searchText, limit = 5) {
const vector = await getEmbeddings().embedQuery(searchText);
const { rows } = await query(
`SELECT id, conversation_id, role, content, created_at,
1 - (embedding <=> $1::vector) AS similarity
FROM messages
WHERE conversation_id = $2 AND embedding IS NOT NULL
ORDER BY embedding <=> $1::vector
LIMIT $3`,
[JSON.stringify(vector), conversationId, limit]
);
return rows;
}
关键就是那个 <=> 运算符。
五、<=> 到底是什么?
这是 pgvector 里的向量余弦距离运算符:
向量余弦距离 = 1 - 向量余弦
| 运算符 | 含义 | 用途 |
|---|---|---|
<=> |
余弦距离 | 语义相似度,最常用 |
<-> |
欧氏距离 | 几何距离 |
<#> |
点积 | 内积相似度 |
所以 ORDER BY embedding <=> '[0.1,0.2,...]' 就是把某条消息和查询向量余弦距离从小到大排------距离越小越相似。
而 1 - (embedding <=> $1) 则把"距离"转成"相似度",越大越相关。这一条 SQL,就把 AI 最需要的能力------按用户过滤、按会话过滤、按时间过滤、按语义检索------全包含了。
这是单靠 MySQL 做不到的:MySQL 不支持
vector类型,也就没有<=>运算符。
六、工程化:从手写 SQL 到 TypeORM
psql 目录下还有一个更工程化的版本 typeorm-pg-crud ------ 用 NestJS + TypeORM 抹平手写 SQL。
架构路径
rust
sql -> ORM -> psql
开发不写 SQL,用 ORM 操作数据库。TypeORM 是 Node 生态主流 ORM,NestJS 的两大特色是 MVC 模块化 和 依赖注入。
实体:SQL 表在 TypeScript 世界的映射
messages 表就映射成一个类,embedding 映射成 vector 类型:
ts
// message.entity.ts
@Entity('messages')
export class Message {
@PrimaryGeneratedColumn()
id: number;
@Column({ type: 'text' })
content: string;
@Column({ type: 'vector', nullable: true })
embedding: number[] | null; // ← pgvector 列在 TypeORM 里的映射
}
ORM 操作流程一览(对应 NestJS 的资源模块)
- 数据库全局配置 ---
TypeOrmModule.forRoot({ type: 'postgres', ... }) - 生成资源模块 ---
nest g res conversations --no-spec,自动创建 RESTful CRUD - MVC 模块化 ---
module声明 /controller(装饰器路由)/service(数据操作) - entities --- 实体类,表的映射,需在模块全局注册
- dto --- Data Transfer Object,前端
params/query的提交规则约束,不对就直接报错退出
专业提示:TypeORM 实体涉及 循环依赖 + ESM 时,装饰器用实体名字符串 (
@ManyToOne('User', 'conversations'))+ 属性用Relation<T>+import type导入,才能既打破编译期循环又满足isolatedModules要求。
七、总结
| 对比项 | MySQL + Milvus | PostgreSQL + pgvector |
|---|---|---|
| 向量存储 | 需额外向量库 | 普通表加一列 |
| 数据写入 | 双写两套 | 单表写入 |
| 部署维护 | 两套系统 | 一套搞定 |
| 关系+语义查询 | 跨库做不了 | 一条 SQL 搞定 |
| 关键词 | 需要分词/倒排 | <=> 余弦距离 |
MySQL 没向量列、而 PG 有 ------ 这就是 AI 时代,PostgreSQL 被称为"最适合的数据库"的根本原因:
不需要额外的数据库,不需要双写,不需要维护两套系统------一张表,搞定传统关系查询 + AI 长期记忆。
如果你想给 Agent 造一个有长期记忆的后端,不妨把 psql_test 里的 searchSimilarMessages 跑起来,感受一下 "隐型向量数据库" 的威力。