写在前面:上篇我们给金鱼(LLM)装了白板(内存记忆)和笔记本(文件记忆),还学会了撕旧页(截断)和写目录(总结)。但有个问题没解决------老对话被总结或截断后,细节就丢了。 用户第 1 轮说"我叫赵六,是数据科学家",到第 50 轮时早就被压缩成一句"用户叫赵六"。如果你想问"我的职业是什么",总结里可能有,也可能没有。readme 给出了终极方案------向量数据库。今天下篇专攻 Milvus:建集合、插数据、向量检索、把长期记忆接入 Agent。以下所有代码均来自课堂真实文件。
一、为什么需要向量数据库?
上篇的三种管理策略各有短板:
| 策略 | 问题 |
|---|---|
| 截断 | 老消息直接扔,信息全丢 |
| 总结 | 压缩后有信息损失,细节保不住 |
| 文件 | 存了全量,但搜索靠关键词匹配,"我做什么工作"匹配不到"数据科学家" |
第三种最致命------文件存了所有对话,但你要找的时候怎么找?按关键词?用户问"我的职业是什么",文件里存的是"我是一名数据科学家"------"职业"和"数据科学家"没有字面重叠,关键词搜索找不到。
向量数据库解决的就是这个问题------按语义搜索,不按字面匹配。
readme 说的:
"长时记忆:文件、向量数据库。"
"开发一个聊天应用 codex。每聊 20 句就发出一次总结,生成摘要,存入 milvus 向量数据库。从 milvus 取出历史对话,接着回答,Agent 更懂我们。"
向量数据库不是替代文件,而是在文件之上加一层语义索引------每条对话都被转成向量(一串数字),搜索时按向量相似度排序,语义越接近排越前面。
二、Milvus:向量数据库的"乐高图纸"
readme 用了一个精准的比喻:
"如果把一个个 Docker 容器比作'乐高积木',那 Docker Compose 就是那张'乐高模型图纸'。以前你想搭个复杂的应用,得自己一个个找积木、手动拼,还容易拼错;现在你只需要照着这张'图纸'(配置文件),喊一声'一键启动',它就能自动帮你把所有积木完美拼成一个完整的模型。"
Milvus 不是单容器应用------它由 etcd(元数据存储)、MinIO(对象存储)、Milvus 主服务三个容器组成。上一节课的 Docker 知识这里直接用上了。
一键启动
bash
docker compose -f ./milvus-standalone-docker-compose.yml up -d
readme 逐字解释了每个参数:
diff
compose 合成,多容器应用进行操作
-f --file 指定文件
up 启动命令
-d 后台运行
一条命令,三个容器全部拉起。Milvus 跑在 19530 端口。
连接 Milvus
bash
pnpm i @zilliz/milvus2-sdk-node
pnpm i @langchain/openai dotenv
@zilliz/milvus2-sdk-node 是 Milvus 官方 Node SDK,@langchain/openai 提供 embedding 模型,dotenv 管理环境变量。
Attu:可视化 GUI
readme 还推荐了 GUI 工具:
"Attu 是 Milvus 生态最好的 GUI 工具。"
就像 Navicat 之于 MySQL------Milvus 命令行操作不够直观,Attu 提供了可视化界面,看集合、看数据、跑搜索,全点点点。
三、Embedding:把文字变成向量
向量数据库存的是向量,不是文字。所以在存入和搜索之前,都要先把文字转成向量------这个过程叫 Embedding(嵌入)。
insert-conversations.mjs 里的 embedding 配置:
javascript
import { OpenAIEmbeddings } from '@langchain/openai';
const VECTOR_DIM = 1024; // 维度
const embeddings = new OpenAIEmbeddings({
apiKey: process.env.OPENAI_API_KEY,
model: process.env.EMBEDDING_MODEL_NAME,
configuration: {
baseURL: process.env.OPENAI_BASE_URL,
},
dimension: VECTOR_DIM,
});
async function getEmbedding(text) {
const result = await embeddings.embedQuery(text);
return result;
}
embedQuery(text) 把一段文字转成 1024 维的浮点数向量。这 1024 个数字就是这段文字的"语义坐标"------语义相近的文字,坐标也相近。
arduino
"我是一名数据科学家" → [0.12, -0.34, 0.56, ..., 0.78] // 1024个数
"我的职业是什么" → [0.15, -0.31, 0.52, ..., 0.75] // 1024个数
↑
两个向量很接近 → 语义相似
这就是为什么"我的职业是什么"能搜到"我是一名数据科学家"------它们的向量距离很近。
四、建集合:图书馆的书架
insert-conversations.mjs 的第一步------在 Milvus 里建一个集合(Collection)。
什么是集合?
关系型数据库里有"表"(Table),Milvus 里有"集合"(Collection)。一个集合就像图书馆里的一个书架------专门放某一类书。
readme 代码注释里也提到了对比:
"SQL 关系型,NO SQL 非关系型数据库。"
集合结构
javascript
const COLLECTION_NAME = 'conversations'; // 集合名
await client.createCollection({
collection_name: COLLECTION_NAME,
fields: [
{ name: 'id', data_type: DataType.VarChar, is_primary_key: true, max_length: 50 },
{ name: 'vector', data_type: DataType.FloatVector, dim: VECTOR_DIM },
{ name: 'content', data_type: DataType.VarChar, max_length: 5000 },
{ name: 'round', data_type: DataType.Int64 },
{ name: 'timestamp', data_type: DataType.VarChar, max_length: 100 }
],
});
五个字段,各司其职:
| 字段 | 类型 | 作用 | 类比 |
|---|---|---|---|
id |
VarChar(主键) | 唯一标识 | 书的条形码 |
vector |
FloatVector | 语义向量 | 书的"内容指纹" |
content |
VarChar | 原文 | 书的正文 |
round |
Int64 | 第几轮对话 | 书的页码 |
timestamp |
VarChar | 时间戳 | 入库日期 |
readme 注释里有一个值得注意的细节:
"uuid 唯一的 id,更安全。"
id 用 VarChar 而不是自增数字------因为实际项目用 UUID 更安全,避免冲突和猜测。课堂示例用的是 conv_001 这种简单 ID,但注释点明了生产实践应该用 UUID。
另一个注释:
"没有 Date / DateTime。"
Milvus 不支持日期类型,所以 timestamp 用 VarChar 存 ISO 格式的时间字符串。这是 Milvus 的限制------它不是通用数据库,是专为向量搜索设计的。
五、建索引:图书馆的目录卡
集合建好了,但还不能直接搜索------需要先建索引。
javascript
await client.createIndex({
collection_name: COLLECTION_NAME,
field_name: 'vector', // 给哪个字段建索引
index_name: 'vector_idx', // 索引名
index_type: IndexType.IVF_FLAT, // 索引类型
metric_type: MetricType.COSINE, // 相似度度量
});
为什么需要索引?
1024 维空间里找"最相近的向量"------如果没有索引,就得跟集合里每一条数据算距离,数据量一大就慢得不可接受。
索引就是预先组织数据,让搜索时不用遍历全部------就像图书馆的目录卡,按类别分好,找书时先查目录,不用一本本翻。
IVF_FLAT:分桶搜索
IndexType.IVF_FLAT 是一种索引类型------把向量空间分成多个"桶"(cluster),搜索时先找到最近的几个桶,只在桶内精确搜索。牺牲一点精度换取巨大的速度提升。
COSINE:余弦相似度
MetricType.COSINE 是相似度度量方式------用两个向量的夹角来衡量相似度。
夹角越小 → 方向越一致 → 语义越相似 → COSINE 值越接近 1
夹角越大 → 方向越偏离 → 语义越不相关 → COSINE 值越接近 0
readme 注释说了一句:
"index_name: 'vector_idx' // 最频繁。"
向量字段是搜索最频繁的字段,所以给它建索引。其他字段(content、round 等)不参与向量搜索,不需要索引。
六、插入对话:往书架上放书
集合和索引都就绪了,开始往里放数据。
五条示例对话
javascript
const conversations = [
{ id: 'conv_001', content: '用户:我叫赵六,是一名数据科学家\n助手:很高兴认识你,赵六!数据科学是一个很有趣的领域。', round: 1, timestamp: new Date().toISOString() },
{ id: 'conv_002', content: '用户:我最近在研究机器学习算法\n助手:机器学习确实很有意思,你在研究哪些算法呢?', round: 2, timestamp: new Date().toISOString() },
{ id: 'conv_003', content: '用户:我喜欢打篮球和看电影\n助手:运动和文化娱乐都是很好的爱好!', round: 3, timestamp: new Date().toISOString() },
{ id: 'conv_004', content: '用户:我周末经常去电影院\n助手:看电影是很好的放松方式。', round: 4, timestamp: new Date().toISOString() },
{ id: 'conv_005', content: '用户:我的职业是软件工程师\n助手:软件工程师是个很有前景的职业!', round: 5, timestamp: new Date().toISOString() },
];
五条对话,包含了用户的基本信息:姓名、职业、爱好、近期研究。注意 conv_001 说的是"数据科学家",conv_005 说的是"软件工程师"------这两个职业信息后面检索时会用到。
Embedding + 插入
javascript
const conversationData = await Promise.all(
conversations.map(async (conv) => ({
...conv,
vector: await getEmbedding(conv.content),
}))
);
const insertResult = await client.insert({
collection_name: COLLECTION_NAME,
data: conversationData,
});
两步:
Promise.all+map:并行给 5 条对话生成向量。Promise.all等所有 embedding 完成后一起返回------并行比串行快得多。client.insert:把带向量的数据一次性插入 Milvus。
每条数据的结构:
javascript
{
id: 'conv_001',
content: '用户:我叫赵六...',
round: 1,
timestamp: '2026-09-03T...',
vector: [0.12, -0.34, 0.56, ..., 0.78] // 1024维向量
}
文字和向量存在同一条记录里------搜索时按向量找,找到后直接取 content 字段。
七、检索记忆:图书馆的语义搜索
retrieval-memory.mjs 是下篇的核心------从 Milvus 中按语义检索相关历史对话,注入到当前对话的 prompt 里。
检索函数
javascript
async function retrieveRelevantConversations(query, k = 2) {
const queryVector = await getEmbedding(query); // 把问题转成向量
const searchResult = await client.search({
collection_name: COLLECTION_NAME,
vector: queryVector, // 用问题向量去搜索
limit: k, // 返回最相似的k条
metric_type: MetricType.COSINE,
output_fields: ['id', 'content', 'round', 'timestamp']
});
return searchResult.results;
}
三步:
getEmbedding(query)--- 把用户的问题转成向量client.search--- 用这个向量去 Milvus 里找最相似的 k 条- 返回结果,包含原始 content
关键在第一步------用户的问题"我的职业是什么"被转成向量后,Milvus 自动找到向量最接近的记录。由于"数据科学家"和"软件工程师"的语义跟"职业"相关,它们会被排在前面。
limit: k 表示只返回前 k 条------k=2 意味着取最相关的 2 条。不要贪多,多了浪费 token。
集合加载
javascript
await client.loadCollection({
collection_name: COLLECTION_NAME,
});
readme 注释解释了为什么需要这步:
"查询前确保集合已加载(Milvus 重启后集合会回到未加载状态)。"
Milvus 的设计是------集合数据存在磁盘上,搜索前需要先加载到内存。重启后自动卸载,得手动加载。这是性能和内存的权衡------不用的集合不占内存。
八、检索增强对话:让金鱼"想起来"
三个测试问题
javascript
const conversations = [
{ input: '我之前提到的机器学习项目进展如何?' },
{ input: '我周末经常做什么?' },
{ input: '我的职业是什么?' },
];
这三个问题分别对应之前插入的不同对话:
| 问题 | 应该检索到的历史 |
|---|---|
| 机器学习项目 | conv_002:"我最近在研究机器学习算法" |
| 周末做什么 | conv_004:"我周末经常去电影院" |
| 职业是什么 | conv_001:"数据科学家" + conv_005:"软件工程师" |
检索 + 注入 + 回答 + 存储
javascript
for (let i = 0; i < conversations.length; i++) {
const { input } = conversations[i];
const userMessage = new HumanMessage(input);
// 1. 检索相关历史对话
const retrievedConversations = await retrieveRelevantConversations(input, 2);
let relevantHistory = '';
if (retrievedConversations.length > 0) {
relevantHistory = retrievedConversations
.map((conv, idx) => {
return `[历史对话${idx + 1}]
轮次:${conv.round}
${conv.content}`
}).join('\n\n------\n\n');
}
// 2. 把历史对话注入到 prompt 里
const contextMessages = relevantHistory ?
[new HumanMessage(`相关历史对话:\n${relevantHistory}\n\n
用户问题:${input}`)] : [userMessage];
// 3. 调用 LLM 回答
const response = await model.invoke(contextMessages);
await history.addMessages([userMessage]);
await history.addMessages([response]);
// 4. 把新对话存入 Milvus
const conversationText = `用户:${input} \n助手:${response.content}`;
const convId = `conv_${Date.now()}_conv_${i + 1}`;
const conVector = await getEmbedding(conversationText);
await client.insert({
collection_name: COLLECTION_NAME,
data: [{
id: convId,
content: conversationText,
vector: conVector,
round: i + 1,
timestamp: new Date().toISOString(),
}],
});
}
四步循环------检索、注入、回答、存储。
步骤 1:检索
用户问"我的职业是什么",先去 Milvus 搜。向量相似度排序后,conv_001(数据科学家)和 conv_005(软件工程师)排在最前面。
步骤 2:注入
把检索到的历史对话格式化后拼进 prompt:
css
相关历史对话:
[历史对话1]
轮次:1
用户:我叫赵六,是一名数据科学家
助手:很高兴认识你,赵六!数据科学是一个很有趣的领域。
------
[历史对话2]
轮次:5
用户:我的职业是软件工程师
助手:软件工程师是个很有前景的职业!
用户问题:我的职业是什么?
LLM 看到这段 prompt,就知道用户既是数据科学家又涉及软件工程------它能给出准确的回答。
这就是 RAG(Retrieval-Augmented Generation,检索增强生成)的核心模式------先检索相关知识,再注入 prompt,最后让 LLM 基于增强的上下文回答。readme 开头说的"RAG,基于 query 获取向量数据库相关的知识放入 prompt",就是这个流程。
步骤 3:回答
model.invoke(contextMessages) --- LLM 基于"历史对话 + 当前问题"生成回答。
步骤 4:存储
javascript
const convId = `conv_${Date.now()}_conv_${i + 1}`; // 时间 + i 唯一ID
readme 注释说了:
"uuid。"
课堂用时间戳 + 序号生成 ID,注释点明生产环境应该用 UUID。
新对话被 embedding 后插入 Milvus------下次用户问相关问题时,这次对话也会被检索到。 记忆在持续积累。
九、短期记忆 + 长期记忆的协作
注意 retrieval-memory.mjs 里同时用了两种记忆:
javascript
// 长期记忆:Milvus 向量数据库
const client = new MilvusClient({ address: 'localhost:19530' });
// 短期记忆:内存
const history = new InMemoryChatMessageHistory();
它们各管各的:
| 记忆类型 | 实现 | 存什么 | 怎么用 |
|---|---|---|---|
| 短期记忆 | InMemoryChatMessageHistory | 当前会话的连续对话 | 全量带入 prompt |
| 长期记忆 | Milvus 向量库 | 历史会话 / 跨会话 | 按语义检索相关片段 |
短期记忆保证当前对话的连贯性------"好吃吗"能接上"红烧肉"。
长期记忆保证跨会话的知识回忆------新会话里问"我的职业",能从 Milvus 里检索到之前会话说过的"数据科学家"。
readme 的目标完美对应了这种双记忆架构:
"每聊 20 句就发出一次总结,生成摘要,存入 milvus 向量数据库。从 milvus 取出历史对话,接着回答,Agent 更懂我们。"
- 短期记忆管当前 20 句
- 满 20 句后总结一次,存入 Milvus(长期记忆)
- 下次会话从 Milvus 检索相关历史,注入 prompt
十、完整的 Memory 架构
把上下两篇串起来,就是一个完整的 Agent Memory 系统:
scss
用户说话
↓
┌─────────────────────────────────────────────┐
│ Memory 系统 │
│ │
│ ┌──────────────┐ ┌───────────────────┐ │
│ │ 短期记忆 │ │ 长期记忆 │ │
│ │ (内存/文件) │ │ (Milvus 向量库) │ │
│ │ │ │ │ │
│ │ 当前会话 │ │ 历史会话摘要 │ │
│ │ 最近N条消息 │ │ 用户画像 │ │
│ │ │ │ 跨会话知识 │ │
│ │ │ │ │ │
│ │ 管理策略: │ │ 检索策略: │ │
│ │ · 截断 │ │ · 向量相似搜索 │ │
│ │ · 总结 │ │ · top-k 召回 │ │
│ └──────┬───────┘ └────────┬──────────┘ │
│ │ │ │
│ └──────────┬──────────┘ │
│ ↓ │
│ 合并注入 prompt │
│ ↓ │
│ LLM 推理回答 │
│ ↓ │
│ 新消息存入 Memory │
│ ↓ │
│ 满20句?→ 总结 → 存入 Milvus │
└─────────────────────────────────────────────┘
readme 最后一句话点明了全局:
"Agent 更懂我们,Harness 的 Memory 模块。"
Memory 是 Harness(外骨骼)的核心模块之一。没有 Memory,Agent 只能处理当前这一句话;有了 Memory,Agent 能记住你是谁、你喜欢什么、你之前聊过什么------从"一次性问答机器"变成"懂你的助手"。
十一、Memory 三种管理策略的完整对比
回到上篇的表格,补全检索策略:
| 策略 | 做法 | 信息保留 | 计算开销 | 存储位置 | 课堂文件 |
|---|---|---|---|---|---|
| 截断 | 扔老消息 | 0% | 极低 | 内存 | truncation-memory.mjs |
| 总结 | 老→摘要 | ~60% | 中等 | 内存/文件 | summarization-memory.mjs |
| 检索 | 按语义搜索 | 100% | 较高 | 向量库 | retrieval-memory.mjs |
三种策略不是互斥的------好的 Memory 系统三者结合:
- 短期记忆用截断或总结管理当前会话
- 长期记忆用检索从向量库召回历史
- 总结的产物(摘要)也存入向量库,作为长期记忆的一部分
readme 说的"每聊 20 句总结一次存入 Milvus"------总结 + 检索的组合拳。 总结负责压缩,检索负责召回,两者配合,既控制了 token 开销,又保留了长期信息。
PS:上篇给金鱼装了白板和笔记本,下篇给它建了图书馆。白板管当下,笔记本管最近,图书馆管一辈子。三层记忆,各司其职------这就是 AI Agent 的记忆架构。下次你的 AI 助手突然"想起"你三个月前说过的话,别惊讶------它只是在向量数据库里做了一次余弦相似度搜索。