用 PostgreSQL + pgvector 保存 Agent 长期记忆:从三表模型到语义检索
前一篇 Agent Memory 使用 Milvus 保存长期对话。继续做下去后,我发现另一个工程问题:用户、会话和消息在关系数据库里,向量却在独立向量库里。新增一条消息时要写两套系统,删除用户时也要同步清理两边。
如果规模和检索需求还没有复杂到必须拆分,PostgreSQL 加 pgvector 是一个更简单的选择:关系数据、事务、权限过滤和向量字段都在同一个数据库中。
我分别做了两套尝试:
- 使用 Node.js
pg驱动写参数化 SQL; - 使用 NestJS + TypeORM 映射用户、会话和消息实体。
原生 pg 的 CRUD 与向量检索链路相对完整;TypeORM 项目多数接口仍是脚手架占位。因此本文以前者为主,只把 TypeORM 实体用于说明数据模型,不把半成品包装成完整项目。
1. 为什么是用户、会话、消息三张表
Agent 对话至少有三层关系:
text
users
└─ conversations
└─ messages
- 一个用户可以有多个会话;
- 一个会话包含多条有顺序的消息;
- 每条消息有 role、content、created_at;
- 需要语义检索的消息额外保存 embedding。
TypeORM 实体可以把这层关系表达得很直观:
ts
@Entity("messages")
export class Message {
@PrimaryGeneratedColumn()
id: number;
@Column({ name: "conversation_id" })
conversationId: number;
@Column({ type: "text", enum: MessageRole })
role: MessageRole;
@Column({ name: "text", type: "text" })
content: string;
@Column("vector", { length: 1024, nullable: true })
embedding: number[] | null;
@CreateDateColumn({
type: "timestamptz",
name: "created_at",
})
createdAt: Date;
@ManyToOne(
() => Conversation,
(conversation) => conversation.messages,
{ onDelete: "CASCADE" },
)
@JoinColumn({ name: "conversation_id" })
conversation: Conversation;
}
向量直接跟消息放在一起,语义检索前可以先用 conversation_id、user_id 做权限和业务过滤。这一点比"把所有人的对话都放进一个无租户字段的向量集合"安全得多。
2. 用 pgvector 镜像启动开发环境
本地环境使用 pgvector/pgvector:pg16,并搭配 pgAdmin。密码通过环境变量传入:
yaml
services:
postgres:
image: pgvector/pgvector:pg16
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
ports:
- "5432:5432"
volumes:
- ./volumes/postgres:/var/lib/postgresql/data
- ./init-scripts:/docker-entrypoint-initdb.d
healthcheck:
test:
- CMD-SHELL
- pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}
init-scripts 目录目前还是空的,因此 Compose 启动后不会自动创建扩展和三张表。实际使用前还要补齐初始化 SQL,这是下一步必须完成的工作。
3. 连接池与参数化查询
Node.js 端使用 pg.Pool。连接池复用有限数量的数据库连接,避免每次查询都重新建立连接:
js
import "dotenv/config";
import pg from "pg";
const { Pool } = pg;
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
async function query(text, params) {
return pool.query(text, params);
}
export { pool, query };
所有动态值都通过 $1、$2 参数传入,而不是拼接字符串:
js
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;
}
方案 A 是字符串拼接,写起来短但有 SQL 注入风险;方案 B 是参数化查询,把 SQL 结构和数据分开。只要值来自用户输入,我不会选择 A。
参数化也不能替代权限检查。拿到 conversationId 后直接查询消息,仍然要确认这个会话属于当前用户。
4. 写消息时按需生成 Embedding
不是每条消息都一定需要向量。应用层用 withEmbedding 控制是否调用外部 Embedding 模型:
js
async function createMessage(
conversationId,
role,
content,
withEmbedding = false,
) {
if (!["user", "assistant", "system"].includes(role)) {
throw new Error("非法消息角色");
}
if (!withEmbedding) {
const { rows } = await query(
`INSERT INTO messages
(conversation_id, role, content)
VALUES ($1, $2, $3)
RETURNING *`,
[conversationId, role, content],
);
return rows[0];
}
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];
}
这里的 $4::vector 把字符串表示的数组转换为 pgvector 类型。
按需生成能减少外部调用,但也会带来"部分消息没有向量"的状态。查询时必须过滤 embedding IS NOT NULL,并明确哪些角色值得进入长期记忆。把每个工具日志都向量化,通常只会增加噪声。
5. 在关系过滤之后做向量排序
原项目的相似消息检索先限定会话,再按余弦距离排序:
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 - distance 转成更直观的相似度。
这条 SQL 的价值不只是"PostgreSQL 也能查向量",而是可以在同一条语句里增加关系条件:
- 当前用户;
- 当前会话;
- 时间范围;
- 消息角色;
- 业务标签。
方案 A 是 PostgreSQL 与独立向量库双写,适合向量规模很大或需要专用能力的系统;方案 B 是 pgvector 单库,架构简单、事务一致性更好。当前学习项目的数据规模和需求没有证明必须拆库,因此单库更符合最小复杂度原则。
6. 原生 SQL 与 TypeORM 怎么选
原生 pg 版本的优点是 SQL 透明,<=>、类型转换和过滤条件一眼可见。缺点是表结构、DTO 和业务代码需要自己组织。
TypeORM 版本把实体关系表达得更清楚,但当前实现仍有明显未完成部分:
- Service 中多数 CRUD 返回占位字符串;
- 相似度搜索直接返回空数组;
- Create DTO 为空;
- 数据库密码硬编码;
synchronize: true只适合开发,不应代替生产迁移。
所以文章不做"ORM 已经完整实现"的结论。我的取舍是:普通 CRUD 可以交给 ORM;pgvector 距离、复杂过滤和性能敏感查询可以保留显式 SQL。没必要为了形式统一,把清晰的数据库能力藏进难读的 ORM 表达式。
7. 这套实现还缺什么
这套实现已经能说明应用层思路,但还需要补齐:
- 可重复执行的扩展与建表脚本;
- 向量维度与 Embedding 模型的一致性校验;
- HNSW 或 IVFFlat 索引及对应查询计划;
- 用户对会话的所有权检查;
- 消息写入与 Embedding 失败的补偿策略;
- 使用迁移替代生产环境
synchronize; - 不把任何数据库口令写进代码或 Compose。
目前还没有 EXPLAIN ANALYZE 或 benchmark 数据,因此不能声称 pgvector 在这个项目里比 Milvus 更快。本文比较的是架构复杂度与一致性,而不是性能。
结尾
用 PostgreSQL + pgvector 保存 Agent 长期记忆时,我会遵守几条原则:
- 先按用户和会话过滤,再做向量排序;
- 关系字段和向量字段放在同一条消息记录中;
- SQL 值始终参数化;
- 不是所有消息都值得生成 Embedding;
- 简单规模优先单库,出现明确瓶颈后再拆专用向量库;
- ORM 负责常规关系映射,复杂向量查询可以保留原生 SQL;
- 配置全部走环境变量,文章和日志不出现真实凭据。
下一步最值得补的不是更多 Controller,而是初始化迁移、索引和查询计划。只有把这些记录下来,才能进一步讨论 pgvector 与独立向量数据库的真实取舍。