MySQL + Milvus = PostgreSQL?一张表搞定 AI Agent 的长时记忆(附完整代码)
豆包、ChatGPT、Claude、Codex...... 这些 AI Agent 都有一个共同需求:长期记忆 。聊天记录不能只活在一次会话里,用户隔天回来,Agent 要能接着聊,更要能"回忆"起之前说过的话。本文从零开始,讲清楚如何用 PostgreSQL + pgvector 在一张表上同时搞定「传统关系查询 + AI 语义检索」,并给出 Node.js(pg 驱动) 与 NestJS + TypeORM 的完整工程代码,建议收藏后边看边练。
关系型数据库是互联网应用的基石------账号信息、订单数据、聊天记录、业务数据,几乎都靠它存储。而到了 AI 时代,这个"基石"要被重新审视了。
一、为什么说 MySQL + Milvus = PostgreSQL?
先看传统做法。要让 Agent 拥有"记忆",需要两类能力:
- 精确查询:按用户查会话列表、按会话查消息、按时间排序------这是关系型数据库的强项(MySQL)。
- 语义检索 :用户说"上次讨论的向量检索方案是什么",系统要能理解意图,在历史消息里做相似度搜索------这是向量数据库的强项(Milvus)。
于是常见架构是「MySQL 存数据 + Milvus 存向量」,这带来三个麻烦:
- 双写:一条消息要写两个库,一致性靠业务代码保证,写挂了就是数据对不上;
- 同步:MySQL 的数据要同步给 Milvus,要么定时任务,要么监听 binlog,多一套链路多一堆故障;
- 两套系统:部署、扩容、监控、备份,成本翻倍。
PostgreSQL 的解法简单粗暴:在原来的消息表上多加一个向量字段。
| 维度 | MySQL + Milvus 双写 | PostgreSQL + pgvector |
|---|---|---|
| 存储 | 两套系统 | 一张表 |
| 写入 | 双写 + 手动保证一致 | 单写 |
| 语义检索 | 单独查 Milvus | SQL 里直接用 <=> |
| 维护成本 | 高 | 低 |
不需要拆架构、不需要同步数据、不需要写复杂的关联逻辑------一张表,搞定传统关系查询 + AI 长期记忆。这就是 AI 时代 PostgreSQL 的核心优势。
二、表结构设计:用户 → 会话 → 消息
Agent 的聊天记忆,三张表就够了,关系非常典型:
text
users (用户表) 1 ────< conversations (会话表) 1 ────< messages (消息表)
一对多 一对多
- users :用户表,核心字段
user_id(自增主键); - conversations :会话表,通过
user_id关联用户表,一条用户记录对应多条会话(一对多);取会话列表就是按user_id过滤; - messages :消息表,通过
conversation_id关联会话表;点击某个会话,就取出当前会话的全部消息。
DDL 如下(注意消息表多了一个 embedding 向量字段,这是 AI 记忆的关键):
sql
-- 1. 启用 pgvector 扩展
CREATE EXTENSION IF NOT EXISTS vector;
-- 2. 用户表
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
-- 3. 会话表(与用户一对多)
CREATE TABLE conversations (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
title TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
-- 4. 消息表(多一个向量字段 embedding)
CREATE TABLE messages (
id BIGSERIAL PRIMARY KEY,
conversation_id BIGINT NOT NULL REFERENCES conversations(id),
role TEXT NOT NULL CHECK (role IN ('user', 'assistant', 'system')),
content TEXT NOT NULL,
-- 向量字段:维度必须与 embedding 模型输出对齐(如 text-embedding-v3 默认 1024 维)
embedding vector(1024),
created_at TIMESTAMPTZ DEFAULT now()
);
注意:
embedding的维度必须和你用的 embedding 模型输出维度一致,写错了存不进去。这里的vector(1024)对应text-embedding-v3的默认维度。
三、最基础的 SQL:查会话、查消息
设计好了表,先做最传统的查询------这正是关系型数据库的本职。
某用户的全部会话列表(按创建时间倒序,最新的在最上面):
sql
SELECT *
FROM conversations
WHERE user_id = '你的用户ID'
ORDER BY created_at DESC;
某会话的全部消息(按时间正序,还原对话顺序,给 LLM 当上下文):
sql
SELECT *
FROM messages
WHERE conversation_id = '你的会话ID'
ORDER BY created_at ASC;
到这一步,MySQL、PostgreSQL 都能做,看不出差别。差别在下一节。
四、JOIN 复习:内连接与外连接
当查询要跨多张表时,就要用到 JOIN。这里顺带把四种连接复习一遍,后面语义检索的 SQL 会用到:
sql
-- INNER JOIN:取交集,两边都有匹配才返回
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.created_at ASC;
| 连接类型 | 语义 | 特点 |
|---|---|---|
INNER JOIN |
交集 | 两边都有匹配才返回 |
LEFT JOIN |
左表为主 | 右表没有匹配则补 NULL |
RIGHT JOIN |
右表为主 | 左表没有匹配则补 NULL |
FULL JOIN |
并集 | 两边都保留,无匹配补 NULL |
上面这条 SQL 的意义在于:先按用户过滤(会话属于谁),再按会话过滤(消息属于哪场对话),最后按时间排序------"按用户过滤、按会话过滤、按时间过滤",这就是 AI 时代消息系统最需要的能力。
五、升级:给消息表加一个向量字段(pgvector)
现在到了分水岭。Agent 的"记忆"不只是把消息存下来,还要能在历史里按语义找到相关内容------比如用户问"我之前提到的向量索引方案是什么",系统要检索出语义上最接近的消息,而不是靠关键词匹配。
这需要把每条消息转成**向量(embedding)**存进数据库。PostgreSQL 的解法:
sql
-- 建好扩展后,直接给已有表加向量列
ALTER TABLE messages ADD COLUMN embedding vector(1024);
写入消息时,先用 embedding 模型(如 text-embedding-v3)把文本转成向量,连同消息一起写入:
js
// 伪代码示意:真正实现见第七、九节的完整代码
const vector = await getEmbeddings().embedQuery(content);
await query(
`INSERT INTO messages (conversation_id, role, content, embedding)
VALUES ($1, $2, $3, $4::vector)`,
[conversationId, role, content, JSON.stringify(vector)]
);
这里有两个关键点:
$4::vector:把传入的第 4 个参数强制类型转换为 PostgreSQL 的 vector 向量类型;JSON.stringify(vector):向量数组以 JSON 字符串形式传给驱动,pgvector 会解析成真正的向量。
MySQL 原生不支持向量类型(虽有 HeatWave 等方案,但生态和成熟度远不如 PG 的 pgvector 扩展)。这就是"AI 时代,PG 优势更大"的原因------不需要额外引入向量数据库。
六、语义检索:<=> 余弦距离运算符
向量存进去了,怎么检索?pgvector 提供了一套向量运算操作符:
| 操作符 | 含义 |
|---|---|
<=> |
余弦距离(cosine distance) |
<-> |
欧氏距离(L2) |
<#> |
内积(inner product) |
其中 <=> 最常用,因为余弦距离对文本语义相似度最友好:
向量余弦距离 = 1 - 向量余弦相似度
距离越小,语义越接近。所以"找最相似的消息"就是:
sql
SELECT id, conversation_id, role, content, created_at,
1 - (embedding <=> '[1.2, 0.5, 0.8, ...]') AS similarity
FROM messages
WHERE conversation_id = '你的会话ID'
AND embedding IS NOT NULL
ORDER BY embedding <=> '[1.2, 0.5, 0.8, ...]' -- 按余弦距离升序 = 按相似度降序
LIMIT 5;
把这条 SQL 和第四节的 JOIN 合并,就是"AI 时代长时记忆"的完整形态------一次查询同时做到:按用户过滤 + 按会话过滤 + 按语义检索:
sql
SELECT m.*, 1 - (m.embedding <=> '[1.2, 0.5, 0.8, ...]') AS similarity
FROM messages m
JOIN conversations c ON m.conversation_id = c.id
WHERE c.user_id = '你的用户id'
AND c.id = '你的会话ID'
AND m.embedding IS NOT NULL
ORDER BY m.embedding <=> '[1.2, 0.5, 0.8, ...]'
LIMIT 5;
数据量大了之后,别忘建 HNSW 索引加速向量检索(这也是 pgvector 的核心卖点之一):
sqlCREATE INDEX ON messages USING hnsw (embedding vector_cosine_ops);
七、Node.js + pg 实战:连接池与参数化查询
理论知识讲完,开始写代码。第一步是数据库连接层。服务器代码和数据库是相互独立 的进程,而数据库能同时服务的连接是有限的------如果每个 SQL 请求都新建连接,很快就把数据库打爆。所以要用连接池:SQL 需求多就排队等待,执行完的连接归还复用。
js
// db.mjs ------ 全局唯一的数据库连接与 SQL 执行封装
import "dotenv/config";
import pg from "pg"; // PostgreSQL 官方 Node 驱动
const { Pool } = pg;
const pool = new Pool({
connectionString: process.env.DATABASE_URL, // 从 .env 读取连接串
});
// 任何 SQL 执行都走这里:text 是 SQL,params 是参数数组
async function query(text, params) {
return pool.query(text, params);
}
export { pool, query };
注意这里的两个 PG 特色,和 MySQL 完全不同:
1. 参数占位符是 $1、$2,不是 ?
js
// MySQL 风格:?
// INSERT INTO users (name) VALUES (?), [name]
// PostgreSQL 风格:$1
await query("INSERT INTO users (name) VALUES ($1) RETURNING *", [name]);
2. RETURNING * 子句
插入/更新成功后,直接把这一条记录的所有字段返回,省掉一次回查:
js
const { rows } = await query(
"INSERT INTO users (name) VALUES ($1) RETURNING *",
[name]
);
return rows[0]; // { id: 1, name: '张三', created_at: ... }
使用
$1参数占位符而不是字符串拼接 SQL,是防 SQL 注入的第一道防线------永远不要把用户输入拼进 SQL 字符串。
八、完整 CRUD:users / conversations / messages
有了连接层,CRUD 就是模板化的活儿。以用户模块为例(会话、消息模块结构完全一致):
js
// users.mjs
import { query } from "./db.mjs";
// 创建用户:RETURNING * 直接拿回整条记录
async function createUser(name) {
const { rows } = await query(
"INSERT INTO users (name) VALUES ($1) RETURNING *",
[name]
);
return rows[0];
}
// 按 id 查询:查不到返回 null
async function getUserById(id) {
const { rows } = await query("SELECT * FROM users WHERE id = $1", [id]);
return rows[0] ?? null;
}
async function getAllUsers() {
const { rows } = await query("SELECT * FROM users ORDER BY id");
return rows;
}
async function updateUser(id, name) {
const { rows } = await query(
"UPDATE users SET name = $1 WHERE id = $2 RETURNING *",
[name, id]
);
return rows[0] ?? null;
}
// 删除:rowCount > 0 说明真的删掉了
async function deleteUser(id) {
const { rowCount } = await query("DELETE FROM users WHERE id = $1", [id]);
return rowCount > 0;
}
export { createUser, getUserById, getAllUsers, updateUser, deleteUser };
会话模块同理,注意"按用户查会话列表"要按时间倒序:
js
// conversations.mjs(节选)
async function createConversation(userId, title = null) {
const { rows } = await query(
"INSERT INTO conversations (user_id, title) VALUES ($1, $2) RETURNING *",
[userId, title]
);
return rows[0];
}
async function getConversationsByUserId(userId) {
const { rows } = await query(
"SELECT * FROM conversations WHERE user_id = $1 ORDER BY created_at DESC",
[userId]
);
return rows;
}
九、语义搜索落地:Embedding + pgvector
消息模块是整个系统的灵魂:写入时可选地生成向量,查询时支持语义检索。使用 @langchain/openai 调用 OpenAI 兼容的 embedding 接口(text-embedding-v3),配合 pgvector 的 <=> 运算符:
js
// messages.mjs
import "dotenv/config";
import { OpenAIEmbeddings } from "@langchain/openai";
import { query } from "./db.mjs";
const VALID_ROLES = ["user", "assistant", "system"];
let embeddings;
// 懒加载单例: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;
}
// 创建消息:withEmbedding 为 true 时同时写入向量
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];
}
// 语义检索:把查询文本转向量,按余弦距离升序取最相似的 N 条
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;
}
export {
createMessage,
getMessageById,
getMessagesByConversationId,
updateMessage,
deleteMessage,
searchSimilarMessages,
};
主入口串联整个流程,验证"语义检索"的效果:
js
// index.mjs
import { pool } from "./db.mjs";
import * as users from "./users.mjs";
import * as conversations from "./conversations.mjs";
import * as messages from "./messages.mjs";
async function run() {
// 1. 造一个用户和会话
const user = await users.createUser("张三");
const conversation = await conversations.createConversation(user.id, "第一次对话");
// 2. 写入带向量的种子消息
const seedMessages = [
{ role: "user", content: "PostgreSQL 支持哪些数据类型?" },
{ role: "assistant", content: "PostgreSQL 支持整数、文本、JSON、数组,以及 pgvector 扩展提供的向量类型。" },
{ role: "user", content: "怎么做相似度搜索?" },
{ role: "assistant", content: "可以使用 pgvector 的 cosine 距离运算符 <=>,配合 hnsw 索引加速向量检索。" },
];
for (const msg of seedMessages) {
await messages.createMessage(conversation.id, msg.role, msg.content, true);
}
// 3. 语义检索:"向量相似度怎么查" ------ 不包含任何关键词,但能命中第 4 条
const query = "向量相似度怎么查";
const results = await messages.searchSimilarMessages(conversation.id, query, 2);
console.log(results);
}
run()
.catch((err) => {
console.error("运行失败", err.message);
process.exit(1);
})
.finally(() => pool.end()); // 记得关池,进程才能正常退出
这就是"记忆":用户问的语句和历史上任何一条都不完全一样,但语义最接近的那条消息被捞了出来------接下来把它塞进 LLM 的上下文,Agent 就"想起来了"。
十、工程化:NestJS + TypeORM 的 MVC 分层
脚本化演示跑通了,但真实项目要工程化。这里用 NestJS (Node 生态最流行的后端框架)+ TypeORM 把上面的能力封装成 RESTful API。
10.1 一条命令生成资源模块
bash
nest g res conversations --no-spec
这会自动创建资源型的 conversations 模块,自带 RESTful CRUD 的骨架。NestJS 的两大特色贯穿始终:
- MVC 模块化:Module 声明 → Controller 路由 → Service 数据操作;
- 依赖注入 :Service 等对象不用手动
new,框架自动注入,各层解耦。
ts
// conversations.module.ts ------ 模块的"声明书"
import { Module } from '@nestjs/common';
import { ConversationsService } from './conversations.service';
import { ConversationsController } from './conversations.controller';
@Module({
controllers: [ConversationsController],
providers: [ConversationsService],
})
export class ConversationsModule {}
10.2 Controller:装饰器驱动的路由层
Controller 只负责"接请求、发响应",用装饰器声明路由,逻辑全部下沉到 Service:
ts
// conversations.controller.ts
import {
Controller, Get, Post, Body,
Patch, Param, Query, Delete,
DefaultValuePipe, ParseIntPipe,
} from '@nestjs/common';
import { SemanticSearchDto } from './dto/semantic-search.dto';
import { ConversationsService } from './conversations.service';
@Controller('conversations')
export class ConversationsController {
constructor(private readonly conversationsService: ConversationsService) {}
// 查询某用户的全部会话(含消息)
@Get('/users/:userId')
findByUser(@Param('userId', ParseIntPipe) userId: number) {
return this.conversationsService.findConversationsByUserId(userId);
}
// 语义检索:POST /conversations/:id/search
@Post(':id/search')
search(
@Param('id', ParseIntPipe) id: number,
@Body() dto: SemanticSearchDto,
@Query('limit', new DefaultValuePipe(5), ParseIntPipe) queryLimit: number,
) {
const limit = dto.limit ?? queryLimit;
return this.conversationsService.searchSimilarMessages(id, dto.query, limit);
}
@Get()
findAll() {
return this.conversationsService.findAll();
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.conversationsService.findOne(+id); // +id:字符串转数字的小技巧
}
@Patch(':id')
update(@Param('id') id: string, @Body() dto: UpdateConversationDto) {
return this.conversationsService.update(+id, dto);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.conversationsService.remove(+id);
}
}
值得记的三个点:
ParseIntPipe:把路径参数/查询参数自动转成数字,用户传了非法值直接报 400,不用自己在代码里判断;DefaultValuePipe(5)+ParseIntPipe组合:limit没传时给默认值 5,再转数字------注意管道按声明顺序执行;@Get(':id')这类动态路由要放在具体路由后面,否则可能"抢走"请求。
10.3 Service:依赖注入 + TypeORM
Service 是真正的数据操作层。NestJS 的特色是依赖注入 :通过 @InjectEntityManager() 把 TypeORM 的统一操作对象 EntityManager 注入进来,直接用面向对象的方式代替命令式 SQL:
ts
// conversations.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectEntityManager } from '@nestjs/typeorm';
import { EntityManager } from 'typeorm';
import { User } from './entities/user.entity';
@Injectable()
export class ConversationsService {
constructor(
@InjectEntityManager()
private readonly em: EntityManager,
) {}
// 用面向对象代替命令式 SQL:查用户,连带着把会话一对多关系加载出来
async findConversationsByUserId(userId: number) {
const user = await this.em.findOne(User, {
where: { id: userId },
relations: { conversations: true }, // 预加载关联会话
order: { conversations: { createdAt: 'DESC' } },
});
if (!user) {
throw new NotFoundException(`User #${userId} not found`);
}
return user;
}
// 语义检索:TypeORM 不原生支持 pgvector 的 <=>,用原生 SQL 最直接
async searchSimilarMessages(conversationId: number, query: string, limit: number) {
const vector = await getEmbedding(query); // 复用第九节的 embedding 逻辑
return this.em.query(
`SELECT 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],
);
}
}
实体类是 TypeORM 的"八股文"------把表结构映射成类,ORM 全靠它工作:
ts
// entities/user.entity.ts
import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from 'typeorm';
import { Conversation } from './conversation.entity';
@Entity('users') // 对应 users 表
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
// 一对多:一个用户有多条会话
@OneToMany(() => Conversation, (conversation) => conversation.user)
conversations: Conversation[];
}
ts
// entities/conversation.entity.ts(节选)
@Entity('conversations')
export class Conversation {
@PrimaryGeneratedColumn()
id: number;
@Column()
title: string;
@ManyToOne(() => User, (user) => user.conversations)
user: User;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
}
10.4 DTO:约束提交规则,不合法直接报错退出
DTO(Data Transfer Object,数据传输对象)负责约束前端提交的规则------无论表单、params 还是 queryString,先过校验,不合法就直接报错退出,绝不进入业务逻辑:
ts
// dto/semantic-search.dto.ts
import { IsString, IsInt, IsOptional, Min, Max } from 'class-validator';
export class SemanticSearchDto {
@IsString()
query: string; // 必填:要检索的文本
@IsOptional()
@IsInt()
@Min(1)
@Max(20)
limit?: number; // 可选:返回条数,默认由管道兜底为 5
}
别忘在入口全局启用
ValidationPipe,否则 DTO 装饰器不会生效:
tsapp.useGlobalPipes(new ValidationPipe({ whitelist: true }));
至此,一条完整的链路打通了:前端 POST 一个搜索词 → Controller 接参并校验 → Service 注入 EntityManager → 文本转向量 → pgvector <=> 余弦距离检索 → 最相似的历史消息返回给 LLM 作为记忆上下文。
十一、知识点速查表(复习专用)
| 主题 | 核心要点 |
|---|---|
| 表设计 | users 1---N conversations 1---N messages,层层一对多 |
| 参数化查询 | PostgreSQL 用 $1 占位符(MySQL 是 ?),防 SQL 注入 |
RETURNING * |
插入/更新后直接返回记录字段,省一次回查 |
| 连接池 | 数据库连接有限,SQL 多就排队,用后归还复用 |
| pgvector | CREATE EXTENSION vector,加一个 vector(维度) 字段即可 |
| 余弦距离 | 1 - 余弦相似度,越小越相似 |
<=> 运算符 |
pgvector 余弦距离运算符,ORDER BY embedding <=> 查询向量 LIMIT n |
| 向量写入 | $4::vector 强制转类型,JSON.stringify(vector) 传数组 |
| JOIN | INNER 交集 / LEFT 左为主 / RIGHT 右为主 / FULL 并集 |
| NestJS | Module 声明、Controller 路由(装饰器)、Service 数据操作 |
| 依赖注入 | @InjectEntityManager() 注入 TypeORM 的 EntityManager |
| Entity | 实体类 = 表的映射,ORM 工作基础 |
| DTO | 约束前端提交规则,不合法直接报错退出 |
总结
回到开头的公式:MySQL + Milvus = PostgreSQL。
在 AI Agent 的场景里,PostgreSQL + pgvector 用一个扩展、一个向量字段,同时承接了关系查询和语义检索两类诉求------不用拆架构、不用双写同步、不用维护两套系统。Node.js 侧用 pg 驱动的连接池和参数化查询保证稳定与安全;工程侧用 NestJS 的 MVC 分层(Module / Controller / Service)+ TypeORM 的实体映射与 DTO 校验,把"长时记忆"封装成标准的 RESTful API。
这套思路不只能存聊天记录------知识库问答、RAG 检索、日志语义分析,都是同一套模式。从一张消息表出发,你已经掌握了 AI 应用后端最核心的一块拼图。
参考文档