🚊 别再让 AI 的记忆写两遍了:PostgreSQL 把关系查询和语义检索装进一张表

42 阅读17分钟

写在前面:readme 第一句话就下了个很重的判断——"PostgreSQL:AI 时代最适合的数据库。" 紧接着是一个等式:"Mysql + Milvus = Pg"。这两句话背后是一个很实在的工程问题:AI Agent 要长期记住聊天记录(豆包、Codex 都是这么干的),可"聊天记录"这种东西,一半是结构化数据(谁、哪个会话、什么时候),一半是语义数据(说了什么、意思相近吗)。 以前要两个数据库伺候,今天这篇看 PG 怎么用一个解决。以下所有代码均来自课堂真实文件。


一、先问一个问题:AI 的记忆该存哪

readme 有个很好的起手式——先别聊技术,先看谁在这么干:

"豆包、Codex 等 Agent,都要长期存储聊天记录。数据表怎么设计?"

AI 产品的长期记忆,本质就是"把聊天记录存下来,并且能搜到"。 而"存"和"搜"这两个动作,需求完全不同:

动作你需要什么
存一个可靠的关系型数据库(谁、会话、时间、顺序)
搜一个能做语义相似度的向量数据库

于是传统的方案就出来了——两个都用:

MySQL(或任意关系库)   存用户、会话、消息正文
        +
Milvus                  存同一批消息的向量

readme 把这个方案的代价列得很清楚:

"只需要在原来的消息表上,多加一个向量字段(Mysql 不支持),不需要额外的数据库,不需要双写,不需要维护两套系统。"

反过来读,这就是传统方案的三宗罪:

代价具体是什么
双写同一条消息,既要写 MySQL,又要写 Milvus
两套系统两个服务的部署、备份、监控、故障处理
同步逻辑两边数据要保持一致,得自己写代码保证

"双写"这个词值得单独拎出来说。 想象一下写入一条消息的过程:

1. INSERT INTO mysql.messages ...
2. 调 Milvus API 插入向量 ...

如果第 1 步成功、第 2 步失败呢?——数据库里有这条消息,但语义检索搜不到它。

如果反过来呢?——搜得到,但点进去打不开。

要解决这个问题,你得引入事务、重试队列、补偿任务……一堆分布式系统才需要的复杂度,就为了存个聊天记录。

readme 的解法就一句话:

"一张表、搞定传统关系查询 + AI 长期记忆。"

不用双写,因为压根只有一个库。


二、三张表:聊天记录的"户籍系统"

技术方案说清了,接下来是数据怎么组织。readme 给了完整的三层结构:

"- 用户表 user_id

  • 会话表 title,一对多,user_id 关联用户表
  • 消息表 messages,点击某个会话,取出当前会话的所有 messages"

画成图就是:

users(用户)
  id ──┐
       │ 一对多
       ↓
conversations(会话)
  id ──┬── user_id      ← 属于哪个用户
  title               ← 会话标题("测试对话")
       │ 一对多
       ↓
messages(消息)
  id
  conversation_id      ← 属于哪个会话
  role                 ← user / assistant / system
  content              ← 消息正文
  embedding            ← 向量(重点!)
  created_at

这是所有 AI 聊天产品的标准结构——微信、豆包、ChatGPT,本质上都是这三层。

readme 里还配了两条最基础的查询 SQL:

SELECT * 
FROM conversations
WHERE user_id = "你的用户ID";

SELECT *
FROM messages
WHERE conversation_id = "你的会话ID"
ORDER BY created_at ASC;

第一条:列出我的所有会话(左侧边栏那个列表)。

第二条:点进某个会话,按时间正序取出所有消息。

注意 ORDER BY created_at ASC 里的 ASC(升序)——聊天记录必须从早到晚排,这样用户才能从上面读下来。对比一下 conversation.mjs 里的列表查询:

async function getConversationsByUserId(userId) {
  const { rows } = await query(
    "SELECT * FROM conversations WHERE user_id = $1 ORDER BY created_at DESC",
    [userId]
  );
  return rows;
}

这里是 DESC(降序)。 为什么?

查询排序原因
会话列表DESC最近聊的排最前面(用户最可能点)
会话内的消息ASC从早读到晚(阅读顺序)

同样一张表,同样的 created_at,两个方向。 这个细节不写出来很容易搞混,但用户一眼就能感知到——排序错了,产品就是别扭的。


三、连接池:为什么不直接连数据库

在写 CRUD 之前,先看最底层的那个文件——db.mjs:

// 链接
// aql 执行
import "dotenv/config";
import pg from "pg"; // 驱动
// 服务器代码 node 数据库独立
// 瓶颈,同时能服务的链接是有限的 连接池,
// 如果sql 需求过多,等待
// 如果要执行sql 一定要拿到,或排队拿到pool中的链接
const { Pool } = pg;

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
});
// 任何的sql 的执行 text 拼接的sql,params 是参数
async function query(text, params) {
    return pool.query(text, params);
}

export { pool, query }

这个文件只有十几行,但那几句注释是全篇最有价值的内容之一。

为什么要"池"

注释里说得非常直白:

"瓶颈,同时能服务的链接是有限的 连接池。如果 sql 需求过多,等待。如果要执行 sql 一定要拿到,或排队拿到 pool 中的链接。"

关键认知:数据库的连接数是有限资源。

假设你的 Node 服务每秒收到 1000 个请求,每个请求都需要查一次数据库。如果每个请求都:

建立连接 → 执行 SQL → 关闭连接

那么:

  • 建连接本身有开销(TCP 握手 + 数据库认证)
  • 并发连接数会瞬间飙到 1000,直接把数据库拖垮

连接池的解决思路:预先建好一批连接,大家轮流用。

       ┌──────────────────────────────┐
       │  连接池(比如 20 个连接)      │
       │  [conn1] [conn2] ... [conn20] │
       └──────────────────────────────┘
              ↑                    ↓
        请求来了,借一个      用完了,还回去
        (没空的就排队)

注意注释里那个"排队"——这才是连接池的真正价值:

它把"无限并发"变成了"有限并发 + 排队"。听起来像是变慢了,实际是用一个可控的等待,换整个系统的稳定。

这跟餐厅排队一个道理:不限人数放进去,厨房直接爆炸;让大家在门口排队,前厅后厨都从容。

query 函数:一个极简封装

async function query(text, params) {
    return pool.query(text, params);
}

两行,但它做了一件很重要的事——把"连接管理"这件事收进了一个函数。

所有业务代码只认 query(sql, params) 这个接口,谁去借连接、谁去还连接,业务代码完全不用管。这也是注释里那句"服务器代码 node 数据库独立"的含义——你的 Node 服务和数据库是两个独立进程,它们之间靠连接通信。


四、PG 的语法特色:$1 和 RETURNING

users.mjs 是最标准的 CRUD 模板,我们用它看 PG 的写法特点:

async function createUser(name) {
    // MYSQL 使用的标准SQL ?
    // pg 有些自己的规则 $1
    // RETURNING * 插入成功后的记录的所有字段
    const { rows } = await query(
        "INSERT INTO users (name) VALUES ($1) RETURNING *",
        [name]
    );
    return rows[0];
}

三行注释,三个知识点:

1. 占位符是 $1 而不是 ?

// MYSQL 使用的标准SQL ?
// pg 有些自己的规则 $1
数据库占位符写法
MySQL?
PostgreSQL$1, $2, $3

PG 用的是"带编号"的占位符——$1 是第一个参数,$2 是第二个。

这比 ? 更明确,因为它允许同一个参数用多次:

-- 用 ? 的写法(MySQL),同一个值要传两遍
SELECT * FROM t WHERE a = ? OR b = ?

-- 用 $1 的写法(PG),同一个值只用传一次
SELECT * FROM t WHERE a = $1 OR b = $1

参数少传一遍,不只是省事——更重要的是不容易传错顺序。

而且这种写法天然防 SQL 注入——参数和 SQL 语句是分开传输的,数据库先编译语句结构,再填入数据,数据永远不可能被当成 SQL 执行。

2. RETURNING *:插入完顺便把数据给你

// RETURNING * 插入成功后的记录的所有字段
"INSERT INTO users (name) VALUES ($1) RETURNING *"

这是 PG 一个特别好用的特性。

想象一下:你插入了 {name: "zzy"},但你想知道这条记录的 id 和 created_at(这两个字段是数据库自动生成的)。

没有 RETURNING 的做法:

1. INSERT ...
2. 查一下刚才插入的 ID(MySQL 用 0,还得再查一次才能拿到完整记录)

有 RETURNING 的做法:

1. INSERT ... RETURNING *     ← 一条搞定

注意注释里的"所有字段"——RETURNING * 会返回整条记录,包括数据库生成的那些字段。所以 rows[0] 就是一个完整可用的对象。

这一下就解决了上一节说的"仓库里那本货的完整登记表"的问题——你插完数据,立刻拿到了它的完整档案。

3. 完整的增删改查模板

其余四个操作,都是同一个套路:

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];
}

async function deleteUser(id) {
    const { rowCount } = await query(
        "DELETE FROM users WHERE id = $1 RETURNING *",
        [id]
    );
    return rowCount > 0;
}

四个操作,三种返回形态,各有讲究:

操作返回值为什么
getUserByIdrows[0] ?? null查不到返回 null,调用方好判断
getAllUsersrows列表,直接返回数组
updateUserrows[0]更新后的记录(靠 RETURNING *)
deleteUserrowCount > 0删除不需要数据,只需要"删掉了吗"

rows[0] ?? null 里的 ?? 是空值合并运算符——rows[0] 是 undefined 时转成 null。

为什么要把 undefined 转成 null? 因为 undefined 是 JS 语言层的"没有这个变量",而 null 是业务层的"查不到这条记录"。换个更明确的信号给调用方,代码更清晰。

deleteUser 的返回值设计也很到位——布尔值 true/false 比返回空数组直观得多。 调用方写 if (await deleteUser(5)) { ... } 就完事了。

有意思的是——这个删除语句里还写着 RETURNING *,但函数返回的却是 rowCount > 0。 说明这个 RETURNING 是冗余的(可能是从其他函数复制过来忘了删)。不过反过来说,rowCount 这个字段本身也很有用:

rowCount 大于 0,说明确实删掉了东西。

这个判断很必要——如果删的是一个不存在的 ID,rowCount 就是 0,你应该返回"删除失败"而不是"成功"。

会话模块:一模一样的套路

conversation.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];
}

注意 title = null 这个默认值——创建会话时标题可以为空(很多时候用户还没开始聊,标题是空的,等第一句话说完再自动生成标题)。

这也是真实产品的常见设计——先建个空壳会话,标题后补。


五、核心:给消息表加一个向量字段

前面都是普通的 CRUD,现在到重点了——messages.mjs。

延迟初始化:一个实用技巧

import { OpenAIEmbeddings } from "@langchain/openai";

const VALID_RULES = ["user", "assistant", "system"];
let embeddings; // 推迟

function getEmbeddings() {
    if (!embeddings) {
        embeddings = new OpenAIEmbeddings({
            model: process.env.EMBEDDINGS_MODEL || "text-embedding-v3",
            apiKey: process.env.OPENAI_API_KEY,
            configuration: {
                baseURL: process.env.OPENAI_BASE_URL,
            }
        });
    }
    return embeddings;
}

注意那个 let embeddings; 加注释 // 推迟。

为什么不直接 const embeddings = new OpenAIEmbeddings({...})?

因为模块加载 ≠ 业务开始。如果写成顶层常量,那么只要这个文件被 import,embedding 客户端就会被创建——哪怕这次运行根本用不到向量功能。

"延迟初始化"(lazy initialization)的好处:

写法时机问题
顶层 const文件被 import 时立刻创建用不上也创建;配置缺失时会直接报错
getEmbeddings()第一次真正要用时创建按需、可控

而且用 getEmbeddings() 后,每次调用都保证拿到同一个实例(if (!embeddings) 判断过)——不会重复创建,也不会返回 undefined。

这个模式在需要"读环境变量 + 建连接"的场景里特别好用——因为环境变量可能在某些脚本里还没加载,延迟到真正调用时再读,更安全。

校验:角色只能是三个值之一

async function createMessage(conversationId, role, content, withEmbedding=false) {
    if (!VALID_RULES.includes(role)) {
        throw new Error(`role 必须是 ${VALID_RULES.join("、")} 之一`);
    }
const VALID_RULES = ["user", "assistant", "system"];

role 只能是 user、assistant、system 三个值之一——这是前面学的结构化输出里 z.enum 的"手写版":

if (!VALID_RULES.includes(role)) {
    throw new Error(`role 必须是 ${VALID_RULES.join("、")} 之一`);
}

两三行做了三件事:

  1. includes(role) 判断合法性
  2. 不合法就抛异常
  3. 报错信息里用 join("、") 列出所有合法值——用户看到"role 必须是 user、assistant、system 之一",立刻知道该怎么改

那个中文顿号 、 是细节——报错信息写成给人看的,而不是给机器看的。

插入消息:两条路径

接下来是这段代码的核心——同一条消息,有两种存法:

    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];

路径 A(带向量) ——多了一个 embedding 字段:

INSERT INTO messages (conversation_id, role, content, embedding)
VALUES ($1, $2, $3, $4::vector)

三个技术点:

第一,$4::vector —— 类型转换

:: 是 PG 的类型转换语法,$4::vector 意思是"把第 4 个参数当成 vector 类型"。

为什么必须转? 因为参数传进去的时候是个字符串(JSON.stringify(vector) 的结果),PG 不知道它是数组还是向量类型,需要显式告诉它。

第二,JSON.stringify(vector) —— 数组变字符串

[JSON.stringify(vector)]

embeddings 返回的是一个 JS 数组 [0.12, -0.34, ...],而 PG 的参数需要字符串。JSON.stringify 把它转成 "[0.12,-0.34,...]"——正好是 pgvector 能识别的输入格式。

第三,RETURNING 的字段列表变了

RETURNING id, conversation_id, role, content, created_at

注意这条没有 RETURNING *,而是明确列出了要返回的字段——为什么不返回 embedding?

因为向量是 1024 个浮点数,把一整串浮点数字返回给业务代码没有意义,还占带宽。 显式列字段,把向量排除掉。

对比路径 B(不带向量)用的是 RETURNING * —— 因为它本来就只有一个 embedding 字段没有,返回全部也无所谓。

路径 B(不带向量) ——什么时候用?

INSERT INTO messages (conversation_id, role, content) 
VALUES ($1, $2, $3)

批量导入、或者先存后算向量的场景。生成一次 embedding 要调用远程 API,有成本、有延迟——批量灌数据时可以先跳过,之后再补。

一个小提醒:这个参数名叫 withEmbedding,但实际语义是反的——默认 false 时会生成向量,传 true 反而跳过。 读代码时容易被名字带偏,实际项目里建议改成 skipEmbedding 之类更贴切的命名。

相似度检索:整篇最精华的一段

async function searchSimilaryMessages(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;
}

这段 SQL 就是整个"一张表方案"的答案。 逐句拆:

第一句:把查询文字也转向量

const vector = await getEmbeddings().embedQuery(searchText);

要搜"向量相似度怎么查",得先把它变成向量——因为要比的是向量,不是文字。

第二句:算相似度

1 - (embedding <=> $1::vector) AS similarity

<=> 是 pgvector 的余弦距离运算符(readme 里专门标了:"<=> 是 pgvector 里的向量余弦距离运算符")。

关键在于距离和相似度是反的:

概念含义值域
余弦距离两个向量差多远0(完全一样)→ 2
余弦相似度两个向量多像1(完全一样)→ -1

所以 1 - 距离 = 相似度——这就是那个 1- 的用处:把"距离"翻译成人能理解的"相似度"。

不转也行(排序结果一样),但输出一个 0.15 的"相似度"比输出一个 0.85 的"距离"直观得多——尤其当你要把这个分数显示给用户或者写进日志的时候。

第三句:过滤 undefined 掉没向量的消息

WHERE conversation_id = $2 AND embedding IS NOT NULL

两个条件:

条件作用
conversation_id = $2只在当前会话里搜
embedding IS NOT NULL跳过"没算向量"的消息

第二条是必要的——因为路径 B 存进去的消息就是没有向量的。 如果不过滤,那些行参与距离计算时会得到 NULL 或者报错。

第四句:排序 + 取前 N 条

ORDER BY embedding <=> $1::vector
LIMIT $3

注意这里又用了一次 <=> ——但这次是排序。

ORDER BY 距离 ASC(默认升序)= 距离最小的排最前面 = 最相似的排最前面。

为什么这里用的是原生的距离而不是 1 - 距离 的别名 similarity?

因为要按"最相似优先"排序,等价于"距离最小优先"。用距离升序排,正好就是这个顺序。(如果写 ORDER BY similarity DESC 效果也一样。)

LIMIT $3 就是那个 limit=5 ——只取最相似的前 5 条。

为什么不用全拿? 两个原因:一是相似度低的结果本来就没用;二是这些结果最终要拼进 prompt 喂给大模型——前面讲过,噪声太多反而会干扰判断。

这段 SQL 的完整读法

把整段连起来看,它其实回答了四个问题:

FROM messages                      ← 从哪查(一张表)
WHERE conversation_id = $2         ← 限定范围(这个会话)
  AND embedding IS NOT NULL        ← 排除无效数据
ORDER BY embedding <=> $1::vector  ← 按语义相似度排序
LIMIT $3                           ← 只取最相关的 N 条

注意这里没有 JOIN、没有子查询、没有跨库调用——就是一张表上的一次查询。


六、一句 SQL,四层过滤

readme 里还给出了一个"终极形态"的查询——把权限过滤和语义检索合在一起:

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.embedding <=> '[1.2, 0.5, 0.8, ...]'
LIMIT 5;

readme 对它的总结,把价值说透了:

"按用户过滤、按会话过滤、按时间过滤,按语义检索——AI 时代最需要的能力。"

这条 SQL 为什么是"最重要的一条"?因为它同时做了两件性质完全不同的事:

干的活属于哪一类需求
JOIN + WHERE user_id + WHERE conversation_id权限与业务过滤(关系型数据库的强项)
ORDER BY embedding <=>语义检索(向量数据库的强项)

在双库方案里,这两件事根本不可能写在一起——一个在 MySQL、一个在 Milvus,你得分别查完,再回代码里手动合并、过滤。

而在 PG 里,它就是一条 SQL。

readme 里顺带复习的 JOIN 知识也值得记下:

外连接
  left join    左边表为主,左边没有 NULL
  right join   右边表为主,右边没有 NULL
  full join

这里用的是 JOIN(内连接)——只保留两边都能匹配上的数据。

为什么这里用内连接就够了?因为 messages.conversation_id 指向的会话必然存在——一条消息不可能属于一个不存在的会话。 内连接正好:

messages 里的每条消息 → 找到它所属的 conversation → 检查 conversation.user_id 是不是这个用户

这就是"权限校验"和"数据检索"用一次查询完成的方式——顺带还防了个安全隐患:如果用户 A 去查用户 B 的会话,SQL 层面直接查不出来。

三层过滤 + 一次排序

把 readme 那句总结展开,这条 SQL 实际做了四件事:

第 1 层:JOIN conversations        绑定会话(确保消息属于某个有效会话)
第 2 层:WHERE c.user_id = ...     权限过滤(只能看自己的)
第 3 层:WHERE c.id = ...          范围过滤(只在当前会话里)
第 4 层:ORDER BY embedding <=>    语义排序(最相关的排前面)

readme 的结论一句话总结:

"不用拆分架构、不用同步数据、不用写复杂的关联逻辑。"

三个"不用",对应传统方案的三个"痛"。


七、跑一遍:index.mjs 里藏着的调试现场

index.mjs 是演示入口,但它记录的信息比"入口"多得多:

import { pool } from "./db.mjs";
import * as users from "./users.mjs";
import * as conversations from "./conversation.mjs";
import * as messages from "./messages.mjs";

async function run(){
    // const user = await users.createUser("zzy");
    // console.log("创建用户", user);
    // const fetchedUser = await users.getUserById(2);
    // console.log("查询用户", fetchedUser);
    // const updatedUser = await users.updateUser(2, "zzy");
    // console.log("更新用户", updatedUser);
    // ...

前四行 import 是"模块化的教科书示范"——用 import * as xxx 把三个业务模块整体引入,调用时写 users.createUser(...)、messages.createMessage(...)。

命名空间前缀让代码自解释——看到 messages. 就知道这是消息相关的操作,比把几十个函数全解构出来清楚得多。

然后是一大片注释——这是调试现场的真实痕迹。

能看出来整个测试顺序是:先建用户 → 查用户 → 改用户 → 建会话 → 查会话列表 → 建消息 → 搜消息。这是一条完整的数据流验证路径——从最上游的用户,一层层往下测到最下游的检索。

然后是被注释掉的种子数据:

    const seedMessages = [
        { role: "user", content: "PostgreSQL 支持哪些数据类型?" },
        {
            role: "assistant",
            content:
                "PostgreSQL 支持整数、文本、JSON、数组,以及 pgvector 扩展提供的向量类型。",
        },
        { role: "user", content: "怎么做相似度搜索?" },
        {
            role: "assistant",
            content:
                "可以使用 pgvector 的 cosine 距离运算符 <=>,配合 hnsw 索引加速向量检索。",
        },  
    ];

这段数据的选材很有意思——四条消息本身就是"自指"的:聊的是 PG 的数据类型、pgvector 的向量类型、余弦距离运算符、HNSW 索引。

也就是说,这段测试数据介绍的就是它自己正在用的技术。 用它来测"语义检索",效果特别好——因为内容和技术高度相关,检索结果一眼能看出对不对。

而且注意第二条消息里那个知识点:"配合 hnsw 索引加速向量检索" ——HNSW 是 pgvector 支持的索引类型(另一种是 IVFFlat)。前面几篇讲过 HNSW 的原理(多层近邻图),这里它在 PG 里同样可用。

所以"用 PG 做向量检索"不是"阉割版"——该有的索引、距离度量都有。

最后是真正执行的那两句:

    const query = "向量相似度怎么查"
    const resluts = await messages.searchSimilaryMessages(
        1,
        query,
        2
    );
    console.log("查询相似消息", resluts);
}

查询词是"向量相似度怎么查",要 2 条结果。

对照种子数据看——这个查询最该命中的是哪两条?

  • "怎么做相似度搜索?" ← 语义最接近
  • "可以使用 pgvector 的 cosine 距离运算符 <=> ..." ← 紧接着的答案

而且注意:种子数据里的问法是"怎么做相似度搜索",查询用的是"向量相似度怎么查"——两个句子的字面重合度并不高。

这恰恰是向量检索的用武之地——如果用 LIKE '%向量相似度怎么查%',一条都搜不到;但语义检索能认出它们说的是同一件事。

这个测试用例的设计,是为了证明"语义"这两个字不是吹的。

收尾这两行也很干净:

run()
    .catch(err => {
        console.error("运行失败", err.message);
        process.exit(1);
    })
    .finally(() => pool.end())
处理作用
.catch()报错时打印信息,process.exit(1) 明确告诉系统"失败了"
.finally(() => pool.end())不管成功失败,关闭连接池

.finally + pool.end() 是个容易忘的关键动作。

前面说过连接池维护着一批活跃连接——如果脚本跑完不关,Node 进程会因为"还有活跃的 handle"而挂住不退出。 很多人第一次写这种脚本会遇到"跑完了但命令行没返回"的现象,就是因为漏了这一步。

pool.end() 就是告诉连接池:"活干完了,连接都放了吧。"


八、ORM:当你不写 SQL 的时候

readme 后半部分换了个话题——如果不想手写 SQL 呢?

"ORM:开发不写 SQL,ORM 操作数据库。typeorm node orm 库。"

ORM(Object-Relational Mapping,对象关系映射)的核心思路是:把"表"映射成"类",把"行"映射成"对象"。 你不用再拼接 SQL 字符串,而是操作对象——ORM 帮你翻译成 SQL。

readme 正好借用 NestJS 项目讲了一遍完整流程:

NestJS 的两个特色

"nestjs 特色 1. MVC 模块化 2. 依赖注入"

这两个前面 NescJS 那篇专门学过——MVC 模块化(module / controller / service 分层)和依赖注入(不用手动 new,框架给你)。

五步流程

readme 给了一个清晰的 checklist:

第 1 步:数据库全局配置

// 1. 数据库全局配置

连接信息、实体注册——跟前面 db.mjs 里的 new Pool({...}) 是一个目的,只是换成了 ORM 的写法。

第 2 步:用 CLI 生成模块

nest g res conversation --no-spec

"自动创建资源型的 conversations 模块,restful CRUD 基本方法"

g res = generate resource。一条命令,NestJS 给你生成一整套 CRUD 骨架:

conversations/
├── conversations.module.ts      模块声明
├── conversations.controller.ts  控制器(路由)
├── conversations.service.ts     服务(数据操作)
├── dto/                         数据传输对象
└── entities/                    实体类

--no-spec 的意思是不生成测试文件——因为默认会生成 .spec.ts,初学阶段往往用不上。

这条命令的价值在于"约定优于配置"——你不用纠结文件该叫什么、放哪,框架给你定好了。

第 3 步:MVC 分层声明

"nest mvc 声明:module 声明、controller 控制器(装饰器 路由)、service(数据操作)"

层职责
module声明这个模块有哪些东西
controller路由——哪个 URL 走哪个方法(用装饰器)
service数据操作——真正的读写逻辑

对照一下今天手写的代码,你会发现结构完全一样:

手写版                       NestJS ORM 版
users.mjs          ←→      users.service.ts
(导出 CRUD 函数)            (用 @Injectable() 的方法)
+ index.mjs        ←→      users.controller.ts
(调用函数)                  (@Get/@Post 装饰器路由)

区别只是:手写版你自己组织文件,NestJS 版框架帮你分好了文件夹。

第 4 步:entities——表的映射

"entities:实体类,八股文,表的映射。orm 需要,在全局注册。"

"八股文"这个词用得很传神——实体类的写法高度模板化:

@Entity()
export class Message {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  role: string;

  @Column('text')
  content: string;
  // ...
}

一堆装饰器,每一个都对应数据库里的一列。 写法固定,改动不多,所以是"八股文"。

"在全局注册"是个容易踩的坑——你写好了实体类,但不注册,ORM 就不知道它的存在,运行时才发现"表没找到"。这是新手最常见的报错来源之一。

第 5 步:dto——约束前端提交的数据

"dto:data transfer object,前端提交表单,前端 params、queryString,约束提交规则,如果不行就直接报错,退出。"

DTO = Data Transfer Object,数据传输对象。

它的作用跟我们前两篇讲的请求体校验是一回事:

前端提交的数据
    ↓
DTO 校验(类型对不对?必填项填了吗?长度合规吗?)
    ↓
不合格 → 直接报错、退出
合格   → 交给 service 处理

readme 特意点明了数据来源:"前端提交表单、前端 params、queryString" ——也就是请求体、路径参数、查询参数这三处。

"如果不行就直接报错,退出"——这就是"校验前置"的思想。

在第 5 步拦住错误数据,好过在第 8 步发现数据库里存了一堆脏数据。


九、为什么说"AI 时代 PG 优势更大"

最后回看 readme 那句判断:

"MYSQL, PG 流行的关系型数据库。AI 时代,PG 优势更大。"

以前 PG 和 MySQL 各有拥趸——MySQL 简单、生态广、上手快;PG 功能强、类型丰富、标准度高。在传统 web 应用里,两者基本是"看团队习惯"的选择。

但 AI 应用改变了这个平衡,因为 AI 应用对数据库提了两个新要求:

新需求PG 的应对
存向量、做语义检索pgvector 扩展
一张表同时装结构化 + 语义数据多一个字段就行

而这两个需求,恰好踩在 MySQL 的短板上——readme 那句话点得很准:

"只需要在原来的消息表上,多加一个向量字段(Mysql 不支持)"

一个字段的差距,演化成了两种截然不同的架构:

MySQL 方案:
  MySQL(业务)+ Milvus(向量)
  → 双写、同步、两套系统、一致性难题

PG 方案:
  PostgreSQL 一张表
  → 不用拆分架构、不用同步数据、不用写复杂关联

这就是"Mysql + Milvus = Pg"这个等式的含金量——它不是"PG 也能存向量"这么简单,而是**"PG 让整套向量基础设施消失了"。**

readme 那句总结,放在最后读刚刚好:

"一张表、搞定传统关系查询 + AI 长期记忆。"

"按用户过滤、按会话过滤、按时间过滤、按语义检索——AI 时代最需要的能力。"


十、这一篇的四个收获

收获一:架构的复杂度,常常来自"数据的分散"。

双写的所有麻烦——一致性、重试、补偿、监控——根源都是"同一份数据存在两个地方"。 把它们放回一个地方,这些问题会成片消失。

收获二:"距离"和"相似度"是两个方向的概念。

<=> 给的是距离(越小越像),业务上想要的是相似度(越大越像),1 - 距离 就是那座桥。弄不清方向,排序就反了。

收获三:列字段比 SELECT * 更专业。

RETURNING id, conversation_id, role, content, created_at 里故意排除了 1024 维的向量——不必要的字段不返回,带宽和可读性都是收益。

收获四:注释是代码里最便宜的知识管理。

db.mjs 那几句"瓶颈、连接有限、排队拿连接"、users.mjs 那几句"PG 有些自己的规则 $1"——总共不到十行注释,把"为什么这么写"讲清楚了。 半年后再看这段代码,这几行注释比代码本身值钱。


PS:这篇的核心其实就一句话——别让同一份数据住在两个地方。 以前做 AI 应用,向量库和业务库像一对分居的夫妻:住得远、见一面费劲、还得天天打电话同步状态。pgvector 干的事,就是让它俩住进同一个房子——一张表、一次查询、一个事务。省下的不只是服务器,还有你写同步代码的那些深夜。