别再 MySQL + Milvus 双写了:PostgreSQL + pgvector 一张表搞定 AI 长期记忆

0 阅读8分钟

写在前面

做 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 一次性治了

先把两套方案的架构摆出来对比一下:

方案 A:MySQL + Milvus(双写派)

业务写入 → MySQL ✅
         → 同步 → Milvus ✅    ← 双写、补偿、对账
查询   → Milvus 拿 ID → 回 MySQL 查详情   ← 跨库关联

方案 B:PostgreSQL + 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左表为主左表全保留,右表没匹配补 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 同时干了四件事:

  1. 按用户过滤 —— c.user_id = $1,防止查到别人的会话
  2. 按会话过滤 —— c.id = $2,锁定上下文
  3. 按语义检索 —— ORDER BY embedding <=> $3
  4. 按时间过滤 —— 需要的话加个 AND m.created_at > $4 就完事

💡 金句:在 PG 里,"过滤 + 向量召回"是一条 SQL 的事;在 MySQL + Milvus 里,是一段第二天就会出 bug 的胶水代码。

这就是 AI 时代最需要的能力 —— 不用拆分架构、不用同步数据、不用写复杂的关联逻辑。一张表,搞定传统关系查询 + AI 长期记忆。


五、ORM 层落地:NestJS + TypeORM

开发同学日常肯定不写裸 SQL,都是 ORM 操作数据库。这里以 NestJS + TypeORM 为例,把上面的方案落到代码层。

NestJS 有两个特色你要记住:

  1. MVC 模块化:module / controller / service 三层分明
  2. 依赖注入:@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,没有双写,没有胶水代码。


七、踩坑记录(血泪版)

整理几个笔者真实踩过的坑,看完能少熬几个夜:

  1. ::vector 千万别漏。字符串塞进 vector 列时,PG 不会自动转换,漏了就报类型错误。
  2. HNSW 索引要建对。用余弦距离就建 vector_cosine_ops,用错操作符索引不生效,查一次扫一张表。
  3. synchronize: true 别带到生产。TypeORM 的自动同步在开发期爽,生产环境会改表结构,直接翻车。
  4. embedding 维度要对齐。模型换 text-embedding-3-small(1536 维)到 text-embedding-3-large(3072 维),建表时的 vector(1536) 得跟着改,否则写入报错。
  5. 池子用完记得 pool.end() 。脚本跑完不关池子,进程会挂着不退出。
  6. Lazy Init Embeddings。OpenAIEmbeddings 放到模块顶层直接 new,环境变量还没加载完就初始化了,一定用懒加载(就是上面代码里那个 getEmbeddings 写法)。

八、总结

回到标题 —— 为什么说 PostgreSQL 是 AI 时代最适合的数据库?

不是因为它性能最好,也不是因为它功能最全,而是因为:

它让你在保持关系型数据库成熟生态的同时,用最小的架构改动,拿到了 AI 应用最核心的向量检索能力。

对比下来就一目了然:

  • MySQL + Milvus:功能齐全,但系统翻倍、一致性难保、查询要跨库
  • PG + pgvector:一套数据库、一条 SQL、一次写入,传统关系查询 + AI 长期记忆全拿下

对中级开发者来说,最实际的收益就是 —— 少维护一套系统,少一份半夜报警,代码少写一半胶水逻辑。

这就是"读完就能用"的价值。

下次再做 AI Agent 的记忆模块,别急着上 Milvus。先用 PG 试试,大概率你会回不来。