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

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


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

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

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

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

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

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

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

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

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

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

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

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

markdown 复制代码
1. INSERT INTO mysql.messages ...
2. 调 Milvus API 插入向量 ...

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

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

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

readme 的解法就一句话:

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

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


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

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

"- 用户表 user_id

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

画成图就是:

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

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

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

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 里的列表查询:

javascript 复制代码
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:

javascript 复制代码
// 链接
// 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 复制代码
建立连接 → 执行 SQL → 关闭连接

那么:

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

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

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

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

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

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

query 函数:一个极简封装

javascript 复制代码
async function query(text, params) {
    return pool.query(text, params);
}

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

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


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

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

javascript 复制代码
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 而不是 ?

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

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

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

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

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

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

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

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

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

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

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

没有 RETURNING 的做法:

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

有 RETURNING 的做法:

markdown 复制代码
1. INSERT ... RETURNING *     ← 一条搞定

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

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

3. 完整的增删改查模板

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

javascript 复制代码
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;
}

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

操作 返回值 为什么
getUserById rows[0] ?? null 查不到返回 null,调用方好判断
getAllUsers rows 列表,直接返回数组
updateUser rows[0] 更新后的记录(靠 RETURNING *)
deleteUser rowCount > 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 是同一套模板换个表:

javascript 复制代码
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。

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

javascript 复制代码
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。

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

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

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

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

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

两三行做了三件事:

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

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

插入消息:两条路径

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

javascript 复制代码
    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 字段:

sql 复制代码
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) ------ 数组变字符串

javascript 复制代码
[JSON.stringify(vector)]

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

第三,RETURNING 的字段列表变了

sql 复制代码
RETURNING id, conversation_id, role, content, created_at

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

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

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

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

javascript 复制代码
INSERT INTO messages (conversation_id, role, content) 
VALUES ($1, $2, $3)

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

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

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

javascript 复制代码
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 就是整个"一张表方案"的答案。 逐句拆:

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

javascript 复制代码
const vector = await getEmbeddings().embedQuery(searchText);

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

第二句:算相似度

sql 复制代码
1 - (embedding <=> $1::vector) AS similarity

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

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

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

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

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

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

sql 复制代码
WHERE conversation_id = $2 AND embedding IS NOT NULL

两个条件:

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

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

第四句:排序 + 取前 N 条

sql 复制代码
ORDER BY embedding <=> $1::vector
LIMIT $3

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

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

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

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

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

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

这段 SQL 的完整读法

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

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

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


六、一句 SQL,四层过滤

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

sql 复制代码
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 知识也值得记下:

sql 复制代码
外连接
  left join    左边表为主,左边没有 NULL
  right join   右边表为主,右边没有 NULL
  full join

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

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

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

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

三层过滤 + 一次排序

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

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

readme 的结论一句话总结:

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

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


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

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

javascript 复制代码
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. 就知道这是消息相关的操作,比把几十个函数全解构出来清楚得多。

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

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

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

javascript 复制代码
    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 做向量检索"不是"阉割版"------该有的索引、距离度量都有。

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

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

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

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

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

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

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

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

收尾这两行也很干净:

javascript 复制代码
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 步:数据库全局配置

javascript 复制代码
// 1. 数据库全局配置

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

第 2 步:用 CLI 生成模块

bash 复制代码
nest g res conversation --no-spec

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

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

arduino 复制代码
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 数据操作------真正的读写逻辑

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

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

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

第 4 步:entities------表的映射

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

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

typescript 复制代码
@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,数据传输对象。

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

markdown 复制代码
前端提交的数据
    ↓
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 干的事,就是让它俩住进同一个房子------一张表、一次查询、一个事务。省下的不只是服务器,还有你写同步代码的那些深夜。

相关推荐
进击的明明1 小时前
TypeScript速通笔记(下)
前端·面试·typescript
lzhdim1 小时前
C#不为人知的10个魔法特性:资深开发者也会震惊的底层奥秘
java·前端·javascript·算法·c#
zach2 小时前
前端落地实战:Next.js 项目 Nginx + PM2 生产部署全流程(附踩坑总结)
前端·pm2·next.js
overmind2 小时前
oeasy教h5前端001 修改的乐趣_直接修改在线网页
前端
只睡四小时2 小时前
JS 手写 D-pad 空间导航:电视端焦点引擎实战
android·开发语言·前端·javascript·ecmascript·hls·空间导航
恋猫de小郭2 小时前
Android 原生的 Compose A2UI 也来了,你还抱着 XML 养老吗?
android·前端·flutter
庄园特聘拆椅狂魔2 小时前
从连连看到粒子消散——前端小游戏与视觉特效的技术选型
前端
JudithHuang2 小时前
React 常用 Hooks
前端·react.js·前端框架
sycmancia3 小时前
Qml——Window元素使用
前端