写在前面: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("、")} 之一`);
}
两三行做了三件事:
includes(role)判断合法性- 不合法就抛异常
- 报错信息里用
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 干的事,就是让它俩住进同一个房子------一张表、一次查询、一个事务。省下的不只是服务器,还有你写同步代码的那些深夜。