文章目录
-
- 前言
- [1、先聊聊:LLM 记性差是怎么治的](#1、先聊聊:LLM 记性差是怎么治的)
- [2、Milvus 是啥:三个容器搭乐高](#2、Milvus 是啥:三个容器搭乐高)
-
- [2.1 一键启动](#2.1 一键启动)
- [2.2 装依赖 + 配个可视化工具](#2.2 装依赖 + 配个可视化工具)
- [3、Embedding:把文字翻译成 1024 个数字](#3、Embedding:把文字翻译成 1024 个数字)
- 4、建集合:给书安个书架
- 5、建索引:图书馆的目录卡
-
- [5.1 IVF_FLAT:先分桶,再细找](#5.1 IVF_FLAT:先分桶,再细找)
- [5.2 COSINE:看夹角定亲疏](#5.2 COSINE:看夹角定亲疏)
- 6、插数据:把对话搬上书架
- 7、检索记忆:语义搜索怎么搜
- [8、RAG 四步曲:让金鱼"想起来"](#8、RAG 四步曲:让金鱼"想起来")
- [9、短期记忆 + 长期记忆:便利贴和档案馆的分工](#9、短期记忆 + 长期记忆:便利贴和档案馆的分工)
- [10、完整 Memory 架构:三层记忆,各司其职](#10、完整 Memory 架构:三层记忆,各司其职)
- 11、三种记忆策略,终极对比
P.S. 目前国内还是很缺AI人才的,希望更多人能真正加入到AI行业,共同促进行业进步,增强我国的AI竞争力。想要系统学习AI知识的朋友可以看看我精心打磨的教程 http://blog.csdn.net/jiangjunshow,教程通俗易懂,高中生都能看懂,还有各种段子风趣幽默,从深度学习基础原理到各领域实战应用都有讲解,我22年的AI积累全在里面了。注意,教程仅限真正想入门AI的朋友,否则看看零散的博文就够了。
前言
众所周知,鱼的记忆只有七秒。而我见过的 AI 助手更离谱------它的记忆以毫秒计,上一句刚说完,下一句它就敢失忆给你看。你跟它聊到第五十轮,再问它"我叫什么",它反手一个尴尬而不失礼貌的微笑:您哪位?
这不是它笨,是命。LLM 天生不带记忆,本质就是一条金鱼,游过去就忘。想让 AI 记住你,得给它建一座图书馆。今天这篇文章就干一件事:用 Milvus 向量数据库,给金鱼建一座正经图书馆,让它拥有长期记忆。
1、先聊聊:LLM 记性差是怎么治的
给 LLM 装记忆,江湖上流传着三板斧:截断、总结、存文件。听着都挺靠谱,用起来各有各的翻车姿势。
| 策略 | 问题 |
|---|---|
| 截断 | 老消息直接扔,信息全丢。这操作像极了公司裁员,先裁老员工,管你当年立过多少功,说没就没。 |
| 总结 | 老对话压成摘要,细节保不住。像极了年终总结:记得你是个好人,但你具体干了啥,好人不配拥有细节。 |
| 文件 | 全量存,但只能关键词匹配,这就要命了。 |
第三招最致命。你想啊,文件里存的是"我是一名数据科学家",用户问的是"我的职业是什么"------"职业"和"数据科学家"俩词,字面零重叠,关键词搜索当场愣住:这题超纲了。
正解来了:向量数据库。它干的事很朴素------按语义搜,不按字面搜。就像班上那个语文课代表,你说"吃的",它知道你说的是"饭"。
2、Milvus 是啥:三个容器搭乐高
先介绍主角。Milvus 是开源的向量数据库,专门干"按语义找相似"这档子事。它不像 MySQL 那样一个进程跑天下,它是三个容器组队出道:etcd 管元数据,MinIO 管对象存储,主服务管搜索。仨人配合,缺一不可。
三个容器手动一个个拉?那是没有图纸拼乐高的玩法------拼到一半发现少块砖,拆了重来,心态直接崩。好在有 Docker Compose,它就是那张"乐高模型图纸"。你只要照着图纸喊一声"一键启动",积木自己拼,拼完还会自己报数。
2.1 一键启动
docker compose -f ./milvus-standalone-docker-compose.yml up -d
参数逐个解释:compose 是"合成",把多个容器当一件事来操作;-f 指定文件;up 是启动;-d 是后台运行。一条命令,三兄弟全起来,Milvus 跑在 19530 端口。
2.2 装依赖 + 配个可视化工具
pnpm i @zilliz/milvus2-sdk-node
pnpm i @langchain/openai dotenv
@zilliz/milvus2-sdk-node 是官方 Node 客户端,@langchain/openai 负责调 embedding 模型,dotenv 管环境变量。
命令行操作 Milvus 不是不行,就是费眼睛,跟用记事本写小说一个体验。官方推荐配一个叫 Attu 的可视化 GUI------看集合、看数据、跑搜索,全点点点。它的江湖地位,相当于 Navicat 之于 MySQL。
3、Embedding:把文字翻译成 1024 个数字
向量数据库里存的不是文字,是向量。所以存之前、搜之前,都得先把文字"翻译"成向量,这一步叫 Embedding(嵌入)。
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 个数字,就是这句话的"语义坐标"。一开始我也觉得这是玄学,直到亲眼看见:语义相近的两句话,坐标也挨得近。这玩意儿比星座配对靠谱多了。
"我是一名数据科学家" → [0.12, -0.34, 0.56, ..., 0.78] // 1024 个数
"我的职业是什么" → [0.15, -0.31, 0.52, ..., 0.75] // 1024 个数
↑ 两个向量挨得很近 → 语义相似
看明白没?"我的职业是什么"之所以能搜到"我是一名数据科学家",不是因为俩句子有共同的字,而是它们向量的距离,近得像两栋贴脸的楼。
4、建集合:给书安个书架
关系型数据库里有表,Milvus 里有集合(Collection)。一个集合就是一个书架,专门放某一类书。你要建一个"对话"书架,声明五个字段:
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 | 时间戳 | 入库日期 |
俩细节值得划重点。第一,id 用 VarChar 而不是自增数字------生产环境一般用 UUID,更安全,不怕冲突也不怕被猜。示例代码里用 conv_001 这种,纯图省事。第二,Milvus 压根没有日期类型,所以 timestamp 只能拿 VarChar 存 ISO 格式的字符串。这就像有的同事不认 deadline,你只能把时间写纸上拍他面前,他才勉强看一眼。
5、建索引:图书馆的目录卡
书架有了,但还不能搜。为什么?因为你现在是在 1024 维空间里找"最近的向量",没索引就得跟书架上每一本书算一遍距离,数据一多,搜索直接变成图书馆闭馆前的最后一次大扫除------又慢又累。
await client.createIndex({
collection_name: COLLECTION_NAME,
field_name: 'vector',
index_name: 'vector_idx',
index_type: IndexType.IVF_FLAT,
metric_type: MetricType.COSINE,
});
索引的本质,就是提前把数据组织好,搜索时不用全量遍历------跟图书馆的目录卡一个道理,先查目录定位,再去对应区域拿书,而不是一本本翻。
5.1 IVF_FLAT:先分桶,再细找
IVF_FLAT 把向量空间切成一堆"桶",搜索时先定位最近的几个桶,只在桶里精确搜。牺牲一点点精度,换巨大速度提升。打个比方:去超市买酱油,正常人先走到调味品区再找,而不是把整个超市从薯片到洗衣液翻一遍。
5.2 COSINE:看夹角定亲疏
COSINE 是相似度度量方式,用两个向量的夹角说话。夹角越小,方向越一致,语义越像,值越接近 1;夹角越大,值越接近 0。像极了同事聊技术:一个 Python 一个 PHP,俩人夹角直奔 180°,友谊的小船当场翻。
6、插数据:把对话搬上书架
集合和索引都就绪,开始上货。先准备五条示例对话,基本信息都齐了:姓名、职业、爱好、近期研究。注意看,conv_001 说"数据科学家",conv_005 说"软件工程师"------同一个用户,两个职业。这年头干 AI 的,简历上谁还没两副面孔呢,懂的都懂。
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() },
];
然后给每条对话生成向量,再一次性插进去:
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 并行给五条对话生成向量,比一条条串着生成快得多;client.insert 再整体入库。每条记录长这样:
{
id: 'conv_001',
content: '用户:我叫赵六...',
round: 1,
timestamp: '2026-09-03T...',
vector: [0.12, -0.34, 0.56, ..., 0.78] // 1024 维向量
}
文字和向量存在同一条记录里,搜索时按向量找,找到后直接取 content------书和指纹放一起,扫指纹就能拿书,绝了。
7、检索记忆:语义搜索怎么搜
核心环节来了。用户问一句话,先把问题也变成向量,再去 Milvus 里找最相似的 k 条历史对话:
async function retrieveRelevantConversations(query, k = 2) {
const queryVector = await getEmbedding(query);
const searchResult = await client.search({
collection_name: COLLECTION_NAME,
vector: queryVector,
limit: k,
metric_type: MetricType.COSINE,
output_fields: ['id', 'content', 'round', 'timestamp']
});
return searchResult.results;
}
三步:把问题转成向量;用向量去搜;把命中的原文带回来。关键在第一步------用户问"我的职业是什么",转成向量后,Milvus 在全馆"闻"了一圈,把"数据科学家"和"软件工程师"两条都端上来了。它不识字,但它懂你。
limit: k 表示只取前 k 条,k=2 就是最相关的两条。别贪多,多了费 token,token 可都是钱。
搜索前还有一步不能省:
await client.loadCollection({
collection_name: COLLECTION_NAME,
});
Milvus 的设计是集合数据躺在磁盘上,搜索前要先加载进内存;一重启,集合自动回到未加载状态,得手动再拉一次。像极了你电脑重启后标签页全没,得挨个重新打开------Milvus 深谙当代打工人的痛。
8、RAG 四步曲:让金鱼"想起来"
先来三个测试问题,分别对应之前存的对话:
const conversations = [
{ input: '我之前提到的机器学习项目进展如何?' },
{ input: '我周末经常做什么?' },
{ input: '我的职业是什么?' },
];
| 问题 | 应该检索到的历史 |
|---|---|
| 机器学习项目 | conv_002:"我最近在研究机器学习算法" |
| 周末做什么 | conv_004:"我周末经常去电影院" |
| 职业是什么 | conv_001:"数据科学家" + conv_005:"软件工程师" |
接下来就是经典的 RAG 四步循环------检索、注入、回答、存储:
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 步检索,第 2 步把捞回来的历史拼进 prompt,格式大概是:
相关历史对话:
[历史对话1] 轮次:1
用户:我叫赵六,是一名数据科学家
助手:很高兴认识你,赵六!数据科学是一个很有趣的领域。
------
[历史对话2] 轮次:5
用户:我的职业是软件工程师
助手:软件工程师是个很有前景的职业!
用户问题:我的职业是什么?
LLM 一看,哦,这人既是数据科学家又沾软件工程,回答自然精准。这套路有个响当当的名字:RAG(检索增强生成)------先检索相关知识,再注入 prompt,最后基于增强的上下文回答。说人话:开卷考试,而且老师直接把重点糊你脸上,这能不高分吗。
第 3 步调用模型回答,第 4 步把新对话也 embedding 入库------下次你再问相关的问题,这次对话也会被捞出来。记忆,就这么一点点攒起来了。
9、短期记忆 + 长期记忆:便利贴和档案馆的分工
注意看,刚才的代码里其实同时用了两种记忆:
// 长期记忆:Milvus 向量数据库
const client = new MilvusClient({ address: 'localhost:19530' });
// 短期记忆:内存
const history = new InMemoryChatMessageHistory();
| 记忆类型 | 实现 | 存什么 | 怎么用 |
|---|---|---|---|
| 短期记忆 | InMemoryChatMessageHistory | 当前会话的连续对话 | 全量带入 prompt |
| 长期记忆 | Milvus 向量库 | 历史会话 / 跨会话知识 | 按语义检索相关片段 |
短期记忆管当下,保证对话连贯------你说"好吃吗",它知道你在说刚才那盘红烧肉;长期记忆管长远,新会话里问"我的职业",它从 Milvus 把三个月前那句"数据科学家"捞回来。一个像正在聊的微信群,一个像想得起密码的百度网盘。
10、完整 Memory 架构:三层记忆,各司其职
把整套串起来,就是一张完整的 Agent Memory 架构图:
用户说话
↓
┌─────────────────────────────────────────────┐
│ Memory 系统 │
│ ┌──────────────┐ ┌───────────────────┐ │
│ │ 短期记忆 │ │ 长期记忆 │ │
│ │ (内存/文件) │ │ (Milvus 向量库) │ │
│ │ 当前会话 │ │ 历史会话摘要 │ │
│ │ 最近N条消息 │ │ 用户画像 │ │
│ │ 管理策略: │ │ 跨会话知识 │ │
│ │ · 截断 │ │ 检索策略: │ │
│ │ · 总结 │ │ · 向量相似搜索 │ │
│ └──────┬───────┘ │ · top-k 召回 │ │
│ │ └────────┬──────────┘ │
│ └──────────┬───────────┘ │
│ ↓ │
│ 合并注入 prompt │
│ ↓ │
│ LLM 推理回答 │
│ ↓ │
│ 新消息存入 Memory │
│ ↓ │
│ 满20句?→ 总结 → 存入 Milvus │
└─────────────────────────────────────────────┘
这套设计的精髓是"总结 + 检索"的组合拳:每聊 20 句总结一次,摘要存进 Milvus;下次会话按需捞。总结负责压缩省 token,检索负责把细节捞回来------既省钱,又不忘事,比我的个人财务规划还合理。
白板管当下,笔记本管最近,图书馆管一辈子。说实话,这配置比我强------我连上周午饭吃的啥都记不住,AI 却记得我三个月前随口报过的职业。下次你的 AI 突然"想起"你随口说的一句话,别慌,它没成精,只是去向量数据库里做了一次余弦相似度搜索。
11、三种记忆策略,终极对比
| 策略 | 做法 | 信息保留 | 计算开销 | 存储位置 | 对应文件 |
|---|---|---|---|---|---|
| 截断 | 扔老消息 | 0% | 极低 | 内存 | truncation-memory.mjs |
| 总结 | 老对话 → 摘要 | ~60% | 中等 | 内存 / 文件 | summarization-memory.mjs |
| 检索 | 按语义搜索 | 100% | 较高 | 向量库 | retrieval-memory.mjs |
别问那 40% 去哪了,问就是被年终总结吃掉了。
三种策略不是三选一,而是黄金搭档:短期记忆用截断或总结管当前会话;长期记忆用检索从向量库召回历史;总结的产物也进向量库,当长期记忆用。总结负责压缩,检索负责召回,配合起来,token 省了,信息也没丢。
最后说句掏心窝的:给 AI 装记忆这件事,说到底就一个道理------它记得你,才谈得上懂你。下次你问它"我叫什么",它要是秒答,别感动,它只是在 Milvus 里翻了个书架而已。
P.S. 目前国内还是很缺AI人才的,希望更多人能真正加入到AI行业,共同促进行业进步,增强我国的AI竞争力。想要系统学习AI知识的朋友可以看看我精心打磨的教程 http://blog.csdn.net/jiangjunshow,教程通俗易懂,高中生都能看懂,还有各种段子风趣幽默,从深度学习基础原理到各领域实战应用都有讲解,我22年的AI积累全在里面了。注意,教程仅限真正想入门AI的朋友,否则看看零散的博文就够了。