用 PostgreSQL + pgvector 保存 Agent 长期记忆:从三表模型到语义检索

用 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. 这套实现还缺什么

这套实现已经能说明应用层思路,但还需要补齐:

  1. 可重复执行的扩展与建表脚本;
  2. 向量维度与 Embedding 模型的一致性校验;
  3. HNSW 或 IVFFlat 索引及对应查询计划;
  4. 用户对会话的所有权检查;
  5. 消息写入与 Embedding 失败的补偿策略;
  6. 使用迁移替代生产环境 synchronize;
  7. 不把任何数据库口令写进代码或 Compose。

目前还没有 EXPLAIN ANALYZE 或 benchmark 数据,因此不能声称 pgvector 在这个项目里比 Milvus 更快。本文比较的是架构复杂度与一致性,而不是性能。

结尾

用 PostgreSQL + pgvector 保存 Agent 长期记忆时,我会遵守几条原则:

  • 先按用户和会话过滤,再做向量排序;
  • 关系字段和向量字段放在同一条消息记录中;
  • SQL 值始终参数化;
  • 不是所有消息都值得生成 Embedding;
  • 简单规模优先单库,出现明确瓶颈后再拆专用向量库;
  • ORM 负责常规关系映射,复杂向量查询可以保留原生 SQL;
  • 配置全部走环境变量,文章和日志不出现真实凭据。

下一步最值得补的不是更多 Controller,而是初始化迁移、索引和查询计划。只有把这些记录下来,才能进一步讨论 pgvector 与独立向量数据库的真实取舍。

相关推荐
枫叶丹42 小时前
AGENTS.md 越写越长之后:如何管理 AI 的上下文
人工智能·chatgpt·开源·agent·codex
墨心@2 小时前
CreateWorkflow 工具
agent·coding agent·harness·zcode
武子康2 小时前
ESP32 唤醒词标称 98%,为什么还要自己做验收?
人工智能·后端·agent
墨心@3 小时前
AmendWorkflow 工具
人工智能·agent·harness
老唐7773 小时前
Agent 开发入门教程:从零搭建一个 Python AI Agent
人工智能·python·深度学习·langchain·大模型·agent·智能体
九卷13 小时前
企业级Agent系统整体技术架构介绍
ai·架构·大模型·llm·agent·智能体
Csvn14 小时前
提示词即代码:有版本、能评审、可回滚(R01)
人工智能·aigc·agent
浮生望15 小时前
Agentic RAG(下):ES + Milvus 多路召回,再用 Rerank 去噪
llm·agent