💫 给金鱼建图书馆(下):Milvus 向量数据库,让 AI 拥有长期记忆

写在前面:上篇我们给金鱼(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,
});

两步:

  1. Promise.all + map :并行给 5 条对话生成向量。Promise.all 等所有 embedding 完成后一起返回------并行比串行快得多。
  2. 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;
}

三步:

  1. getEmbedding(query) --- 把用户的问题转成向量
  2. client.search --- 用这个向量去 Milvus 里找最相似的 k 条
  3. 返回结果,包含原始 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 助手突然"想起"你三个月前说过的话,别惊讶------它只是在向量数据库里做了一次余弦相似度搜索。

相关推荐
liangshanbo12152 小时前
手写 Function.prototype.call 与 Function.prototype.apply 面试题满分答案
开发语言·javascript·原型模式
EatFan2 小时前
HTML前端利用flex解决input定位的问题
前端·css·html·css3
CoderYanger3 小时前
前端基础——JavaScript(WebAPI)(下篇)
java·开发语言·前端·javascript·程序人生·面试·职场和发展
kyriewen3 小时前
AI 改祖传模块后,代码评审总绕不开两个问题
前端·程序员·ai编程
飞翔的熊blabla3 小时前
# Webpack 5 + Jenkins 持久化缓存实战:前端构建从 288 秒降到 30 秒>
前端·webpack·jenkins
风骏时光牛马3 小时前
AI多模态:跨越感官边界的智能新体验 要不要开启工作任务模式,帮你配套对应的文案和配图思路?
前端
IT_陈寒3 小时前
JavaScript的这个隐式转换特性差点让我加班到凌晨
前端·人工智能·后端
Loadings3 小时前
AI时代下的前端安全加固实践:安全、体积与性能之间的取舍
前端