
写在前面做 AI Agent 的同学最近应该都有同一个感受聊天记录越来越不好存了。以前做业务系统账号、订单、聊天记录全塞 MySQL 就完事。但现在做 Agent你得解决一个新问题 ——长时记忆。用户问我们上次聊的那个方案是啥来着你得靠语义检索把相关消息捞出来而不是靠关键词 LIKE 瞎碰。常见的做法是MySQL 存业务数据 Milvus/Pinecone 存向量。听起来很美好做起来你会发现数据要双写写 MySQL 成功后还得同步一份到向量库要维护两套系统一套连业务库一套连向量库连接池、监控、备份全翻倍要做数据一致性一条消息写主库成功、写向量库失败怎么办补偿对账查询要关联两库先向量检索拿 ID再回 MySQL 查详情中间一堆胶水代码说白了就是AI 应用真正需要的能力传统关系库给不了向量库又不懂关系。这篇文章就聊聊为什么我认为PostgreSQL 是 AI 时代最适合的数据库以及怎么用 NestJS TypeORM 把它落地成一套能直接跑的系统。一、先看清楚AI Agent 的记忆本质是关系数据别被向量数据库这四个字唬住。拆开看一个 Agent 的聊天记忆天然就是三层关系结构users (用户) └── conversations (会话) 一对多 └── messages (消息) 一对多对应的表设计也非常朴素表关键字段说明usersid,name用户主体conversationsid,user_id,title会话user_id外键messagesid,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 一次性治了先把两套方案的架构摆出来对比一下方案 AMySQL Milvus双写派业务写入 → MySQL ✅ → 同步 → Milvus ✅ ← 双写、补偿、对账 查询 → Milvus 拿 ID → 回 MySQL 查详情 ← 跨库关联方案 BPostgreSQL pgvector一张表派业务写入 → PG(业务字段 embedding) ✅ 查询 → PG 一条 SQL过滤 向量检索 ✅差距在哪我用一张表说清楚维度MySQL MilvusPG 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左表为主左表全保留右表没匹配补 NULLRIGHT JOIN右表为主右表全保留左表没匹配补 NULLFULL 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()一声哪儿要用哪儿注不用自己 newORM 操作的四步走第一步全局配置数据库// app.module.ts TypeOrmModule.forRoot({ type: postgres, url: process.env.DATABASE_URL, entities: [User, Conversation, Message], synchronize: false, // 生产环境一定关掉 });第二步一键生成资源模块nest g resource conversations --no-specNest 会帮你把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 兜住入参DTOData Transfer Object存在的意义就是 —— 前端提交的东西千奇百怪先把约束卡在入口不合格直接 400 退出export class CreateMessageDto { IsIn([user, assistant, system]) role: string; IsString() IsNotEmpty() content: string; IsOptional() IsBoolean() withEmbedding?: boolean; }六、完整代码骨架可直接抄1. 数据库连接池db.mjsimport 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.mjsimport 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.mjsimport { 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-small1536 维到text-embedding-3-large3072 维建表时的vector(1536)得跟着改否则写入报错。池子用完记得pool.end()。脚本跑完不关池子进程会挂着不退出。Lazy Init Embeddings。OpenAIEmbeddings放到模块顶层直接 new环境变量还没加载完就初始化了一定用懒加载就是上面代码里那个getEmbeddings写法。八、总结回到标题 —— 为什么说PostgreSQL 是 AI 时代最适合的数据库不是因为它性能最好也不是因为它功能最全而是因为它让你在保持关系型数据库成熟生态的同时用最小的架构改动拿到了 AI 应用最核心的向量检索能力。对比下来就一目了然MySQL Milvus功能齐全但系统翻倍、一致性难保、查询要跨库PG pgvector一套数据库、一条 SQL、一次写入传统关系查询 AI 长期记忆全拿下对中级开发者来说最实际的收益就是 ——少维护一套系统少一份半夜报警代码少写一半胶水逻辑。这就是读完就能用的价值。下次再做 AI Agent 的记忆模块别急着上 Milvus。先用 PG 试试大概率你会回不来。