写在前面
做 AI Agent 的同学最近应该都有同一个感受:聊天记录越来越不好存了。
以前做业务系统,账号、订单、聊天记录,全塞 MySQL 就完事。但现在做 Agent,你得解决一个新问题 —— 长时记忆。用户问"我们上次聊的那个方案是啥来着",你得靠语义检索把相关消息捞出来,而不是靠关键词 LIKE 瞎碰。
常见的做法是:MySQL 存业务数据 + Milvus/Pinecone 存向量。听起来很美好,做起来你会发现:
- 数据要双写:写 MySQL 成功后,还得同步一份到向量库
- 要维护两套系统:一套连业务库,一套连向量库,连接池、监控、备份全翻倍
- 要做数据一致性:一条消息写主库成功、写向量库失败,怎么办?补偿?对账?
- 查询要关联两库:先向量检索拿 ID,再回 MySQL 查详情,中间一堆胶水代码
说白了就是:AI 应用真正需要的能力,传统关系库给不了,向量库又不懂关系。
这篇文章就聊聊为什么我认为 PostgreSQL 是 AI 时代最适合的数据库,以及怎么用 NestJS + TypeORM 把它落地成一套能直接跑的系统。
一、先看清楚:AI Agent 的记忆,本质是关系数据
别被"向量数据库"这四个字唬住。拆开看,一个 Agent 的聊天记忆,天然就是三层关系结构:
users (用户)
└── conversations (会话) 一对多
└── messages (消息) 一对多
对应的表设计也非常朴素:
| 表 | 关键字段 | 说明 |
|---|---|---|
users | id, name | 用户主体 |
conversations | id, user_id, title | 会话,user_id 外键 |
messages | id, conversation_id, role, content, embedding | 消息,conversation_id 外键 |
日常查询长这样:
-- 用户的所有会话列表
SELECT * FROM conversations
WHERE user_id = $1
ORDER BY created_at DESC;
-- 某个会话的历史消息
SELECT * FROM messages
WHERE conversation_id = $1
ORDER BY created_at ASC;
你看,这部分和 MySQL 时代一模一样。关系型数据库依然是互联网应用的基石 —— 账号、订单、聊天记录,谁也绕不开它。
关键就在 messages 表最后那一列:embedding。
这一列就是整篇文章的分水岭。MySQL 加不了(准确说没原生向量类型),PG 加一个扩展就有了。
二、MySQL + Milvus 的痛,PG 一次性治了
先把两套方案的架构摆出来对比一下:
方案 A:MySQL + Milvus(双写派)
业务写入 → MySQL ✅
→ 同步 → Milvus ✅ ← 双写、补偿、对账
查询 → Milvus 拿 ID → 回 MySQL 查详情 ← 跨库关联
方案 B:PostgreSQL + pgvector(一张表派)
业务写入 → PG(业务字段 + embedding) ✅
查询 → PG 一条 SQL:过滤 + 向量检索 ✅
差距在哪?我用一张表说清楚:
| 维度 | MySQL + Milvus | PG + pgvector |
|---|---|---|
| 数据写入 | 双写,需保证一致性 | 一次写入 |
| 系统数 | 2 套 | 1 套 |
| 向量检索 + 关系过滤 | 跨库胶水代码 | 一条 SQL |
| 备份/监控/运维 | ×2 | ×1 |
| 事务 | 跨库无事务 | 原生事务 |
💡 金句:架构里的每一个组件,都是一个未来会半夜叫醒你的闹钟。能用一张表解决的,就别拆两套系统。
PG 的优势就在这:你只需要在原来的消息表上多加一个 embedding 字段,不用双写,不用同步数据,不用维护两套系统。 传统关系查询和 AI 语义检索,一张表全搞定。
三、pgvector 实操:给消息表加一个向量字段
1. 装扩展
CREATE EXTENSION IF NOT EXISTS vector;
2. 建表
CREATE TABLE messages (
id SERIAL PRIMARY KEY,
conversation_id INTEGER REFERENCES conversations(id) ON DELETE CASCADE,
role VARCHAR(20) NOT NULL,
content TEXT NOT NULL,
embedding vector(1536), -- OpenAI text-embedding-3-small 维度
created_at TIMESTAMPTZ DEFAULT NOW()
);
3. 建 HNSW 索引(关键)
向量检索不加索引,数据量一上来直接全表扫描。生产环境必加:
CREATE INDEX idx_messages_embedding
ON messages
USING hnsw (embedding vector_cosine_ops);
这里用的是
vector_cosine_ops,对应余弦距离。想用欧氏距离就换vector_l2_ops,但 NLP 场景余弦是标配。
4. <=> 到底是个啥?
笔记里这句话我一直很欣赏,直接抄过来:
<=>是 pgvector 里的向量余弦距离运算符,向量余弦距离 = 1 - 向量余弦。
所以想拿"相似度"(越大越相似),就 1 - (embedding <=> $1)。
四、SQL 玩明白:JOIN + 向量检索一把梭
PG 真正让人上头的地方,是关系过滤和向量检索能写在同一条 SQL 里。
先复习下四种 JOIN(面试常考,实战也天天用):
| JOIN 类型 | 语义 | 结果 |
|---|---|---|
INNER JOIN | 交集 | 两张表都匹配上的行 |
LEFT JOIN | 左表为主 | 左表全保留,右表没匹配补 NULL |
RIGHT JOIN | 右表为主 | 右表全保留,左表没匹配补 NULL |
FULL JOIN | 并集 | 两边都保留 |
现在把向量检索和 JOIN 拼起来:
SELECT m.*
FROM messages m
JOIN conversations c
ON m.conversation_id = c.id
WHERE c.user_id = $1 -- 按用户过滤
AND c.id = $2 -- 按会话过滤
AND m.embedding IS NOT NULL
ORDER BY m.embedding <=> $3 -- 按语义检索
LIMIT 5;
这条 SQL 同时干了四件事:
- 按用户过滤 ——
c.user_id = $1,防止查到别人的会话 - 按会话过滤 ——
c.id = $2,锁定上下文 - 按语义检索 ——
ORDER BY embedding <=> $3 - 按时间过滤 —— 需要的话加个
AND m.created_at > $4就完事
💡 金句:在 PG 里,"过滤 + 向量召回"是一条 SQL 的事;在 MySQL + Milvus 里,是一段第二天就会出 bug 的胶水代码。
这就是 AI 时代最需要的能力 —— 不用拆分架构、不用同步数据、不用写复杂的关联逻辑。一张表,搞定传统关系查询 + AI 长期记忆。
五、ORM 层落地:NestJS + TypeORM
开发同学日常肯定不写裸 SQL,都是 ORM 操作数据库。这里以 NestJS + TypeORM 为例,把上面的方案落到代码层。
NestJS 有两个特色你要记住:
- MVC 模块化:
module/controller/service三层分明 - 依赖注入:
@Injectable()一声,哪儿要用哪儿注,不用自己 new
ORM 操作的四步走
第一步:全局配置数据库
// app.module.ts
TypeOrmModule.forRoot({
type: 'postgres',
url: process.env.DATABASE_URL,
entities: [User, Conversation, Message],
synchronize: false, // 生产环境一定关掉!
});
第二步:一键生成资源模块
nest g resource conversations --no-spec
Nest 会帮你把 module / controller / service / dto / entities 全套骨架生成好,RESTful CRUD 方法现成摆着。
第三步:定义实体(Entity)
实体类就是"八股文"——表结构在 TS 里的映射:
@Entity('messages')
export class Message {
@PrimaryGeneratedColumn()
id: number;
@Column({ name: 'conversation_id' })
conversationId: number;
@Column()
role: string;
@Column('text')
content: string;
@Column('vector', { length: 1536, nullable: true })
embedding: string;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
}
第四步:DTO 兜住入参
DTO(Data Transfer Object)存在的意义就是 —— 前端提交的东西千奇百怪,先把约束卡在入口,不合格直接 400 退出:
export class CreateMessageDto {
@IsIn(['user', 'assistant', 'system'])
role: string;
@IsString()
@IsNotEmpty()
content: string;
@IsOptional()
@IsBoolean()
withEmbedding?: boolean;
}
六、完整代码骨架(可直接抄)
1. 数据库连接池 db.mjs
import 'dotenv/config';
import pg from 'pg';
const { Pool } = pg;
// 服务器同时能服务的连接是有限的,用连接池兜住
// 每次执行 SQL 都要从池里拿到(或排队拿到)连接对象
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
async function query(text, params) {
return pool.query(text, params);
}
export { pool, query };
2. 消息 CRUD + 向量检索 messages.mjs
import 'dotenv/config';
import { OpenAIEmbeddings } from '@langchain/openai';
import { query } from './db.mjs';
const VALID_ROLES = ['user', 'assistant', 'system'];
let embeddings; // 懒加载,避免模块加载时就初始化
function getEmbeddings() {
if (!embeddings) {
embeddings = new OpenAIEmbeddings({
model: process.env.EMBEDDING_MODEL || 'text-embedding-v3',
apiKey: process.env.OPENAI_API_KEY,
configuration: { baseURL: process.env.OPENAI_BASE_URL },
});
}
return embeddings;
}
async function createMessage(conversationId, role, content, withEmbedding = false) {
if (!VALID_ROLES.includes(role)) {
throw new Error(`role 必须是 ${VALID_ROLES.join(', ')} 之一`);
}
if (withEmbedding) {
const vector = await getEmbeddings().embedQuery(content);
const { rows } = await query(
`
INSERT INTO messages (conversation_id, role, content, embedding)
VALUES ($1, $2, $3, $4::vector)
RETURNING id, conversation_id, role, content, created_at
`,
[conversationId, role, content, JSON.stringify(vector)]
);
return rows[0];
}
const { rows } = await query(
`INSERT INTO messages (conversation_id, role, content)
VALUES ($1, $2, $3) RETURNING *`,
[conversationId, role, content]
);
return rows[0];
}
async function searchSimilaryMessage(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;
}
export { createMessage, searchSimilaryMessage };
🔍 两个细节注意:
::vector是 PG 的强制类型转换语法,字符串转向量一定要带JSON.stringify(vector)把数组序列化成 PG 能识别的向量字面量
3. 用户 / 会话模块
users.mjs 和 conversations.mjs 就是标准 CRUD,没什么花活。挑几个重点:
// users.mjs
async function createUser(name) {
// PG 用 $1 占位符;RETURNING * 返回插入后的完整记录
const { rows } = await query(
'INSERT INTO users (name) VALUES ($1) RETURNING *',
[name]
);
return rows[0];
}
// conversations.mjs
async function getConversationsByUserId(userId) {
const { rows } = await query(
'SELECT * FROM conversations WHERE user_id = $1 ORDER BY created_at DESC',
[userId]
);
return rows;
}
4. 跑一遍主流程 index.mjs
import { pool } from './db.mjs';
import * as messages from './messages.mjs';
async function run() {
const query = '向量相似度怎么查';
const results = await messages.searchSimilaryMessage(1, query, 2);
console.log('查询结果', results);
}
run()
.catch((err) => {
console.error('运行失败', err.message);
process.exit(1);
})
.finally(() => pool.end());
跑完你会看到:一次向量召回,直接把当前会话里语义最相关的消息捞出来了,还带着 similarity 分数。 没有 Milvus,没有双写,没有胶水代码。
七、踩坑记录(血泪版)
整理几个笔者真实踩过的坑,看完能少熬几个夜:
::vector千万别漏。字符串塞进vector列时,PG 不会自动转换,漏了就报类型错误。- HNSW 索引要建对。用余弦距离就建
vector_cosine_ops,用错操作符索引不生效,查一次扫一张表。 synchronize: true别带到生产。TypeORM 的自动同步在开发期爽,生产环境会改表结构,直接翻车。- embedding 维度要对齐。模型换
text-embedding-3-small(1536 维)到text-embedding-3-large(3072 维),建表时的vector(1536)得跟着改,否则写入报错。 - 池子用完记得
pool.end()。脚本跑完不关池子,进程会挂着不退出。 - Lazy Init Embeddings。
OpenAIEmbeddings放到模块顶层直接 new,环境变量还没加载完就初始化了,一定用懒加载(就是上面代码里那个getEmbeddings写法)。
八、总结
回到标题 —— 为什么说 PostgreSQL 是 AI 时代最适合的数据库?
不是因为它性能最好,也不是因为它功能最全,而是因为:
它让你在保持关系型数据库成熟生态的同时,用最小的架构改动,拿到了 AI 应用最核心的向量检索能力。
对比下来就一目了然:
- MySQL + Milvus:功能齐全,但系统翻倍、一致性难保、查询要跨库
- PG + pgvector:一套数据库、一条 SQL、一次写入,传统关系查询 + AI 长期记忆全拿下
对中级开发者来说,最实际的收益就是 —— 少维护一套系统,少一份半夜报警,代码少写一半胶水逻辑。
这就是"读完就能用"的价值。
下次再做 AI Agent 的记忆模块,别急着上 Milvus。先用 PG 试试,大概率你会回不来。