一文搞懂 AI 聊天应用的 Memory 模块:Milvus 向量数据库实战

17 阅读6分钟

每聊 20 条自动总结,Agent 终于能"记住"你了。

前言

你有没有发现,和 ChatGPT 聊到第 50 轮的时候,它就开始"失忆"了?你之前说过的职业、爱好、项目进度,它全忘了。

这不是模型的问题,而是上下文窗口有限。把所有历史对话塞进 prompt,token 爆炸;不塞,Agent 就是金鱼记忆。

解决方案:Memory 模块 —— 用向量数据库存储对话历史,每次提问时语义检索最相关的几条,让 Agent "想起"你之前说过的话。

本文基于 Milvus 向量数据库,带你实现一个完整的聊天记忆系统。


一、整体架构

用户提问
   ↓
向量化(Embedding)
   ↓
Milvus 语义检索 → 返回最相关的历史对话
   ↓
拼接上下文(历史 + 当前问题)
   ↓
LLM 生成回答
   ↓
本轮对话向量化 → 存入 Milvus
   ↓
每 20 条触发一次摘要总结 → 摘要也存入 Milvus

核心思想:不存全部原文,而是存向量;不检索全部历史,而是只取最相关的几条。


二、为什么是 Milvus?

数据库类型适合场景
MySQL关系型结构化数据,精确查询
MongoDB文档型JSON 数据,灵活 schema
Redis缓存KV 存储,高速读写
Milvus向量型语义搜索,相似度匹配

Milvus 是专门为向量检索设计的数据库,支持亿级向量的毫秒级搜索。对比把向量存 JSON 文件或者 MySQL 里用暴力计算,性能差距是数量级的。


三、Milvus 本地安装

Docker 一键启动(推荐)

# 拉取镜像
docker pull milvusdb/milvus:v2.4-latest

# 启动容器
docker run -d \
  --name milvus \
  -p 19530:19530 \
  -p 9091:9091 \
  milvusdb/milvus:v2.4-latest

启动后,Milvus 监听 localhost:19530。

验证连接

# 安装 SDK
npm install @zilliz/milvus2-sdk-node

四、代码实现

4.1 初始化:创建集合与索引

insert-conversations.mjs —— 建表 + 插入测试数据

import 'dotenv/config';
import {
  MilvusClient,
  DataType,
  MetricType,
  IndexType
} from '@zilliz/milvus2-sdk-node';
import { OpenAIEmbeddings } from '@langchain/openai';

const COLLECTION_NAME = 'conversations';
const VECTOR_DIM = 1024;

// 初始化 Embedding 模型
const embeddings = new OpenAIEmbeddings({
  apiKey: process.env.OPENAI_API_KEY,
  model: 'text-embedding-v3',
  configuration: {
    baseURL: process.env.OPENAI_BASE_URL
  },
  dimension: VECTOR_DIM
});

async function getEmbedding(text) {
  return await embeddings.embedQuery(text);
}

const client = new MilvusClient({
  address: 'localhost:19530'
});

字段定义相当于关系型数据库的建表:

await client.createCollection({
  collection_name: COLLECTION_NAME,
  fields: [
    // UUID 作为主键,比自增 ID 更安全
    // 自增 ID 可预测、可遍历,UUID 无法猜测
    { name: "id", data_type: DataType.VarChar, max_length: 50,
      is_primary_key: true },

    // 向量字段,1024 维浮点数组
    { name: 'vector', data_type: DataType.FloatVector, dim: VECTOR_DIM },

    // 对话内容原文
    { name: 'content', data_type: DataType.VarChar, max_length: 5000 },

    // 对话轮次
    { name: 'round', data_type: DataType.Int64 },

    // 时间戳,Milvus 没有原生 Date 类型,用字符串存
    { name: 'timestamp', data_type: DataType.VarChar, max_length: 100 }
  ]
});

创建索引,加速向量搜索:

await client.createIndex({
  collection_name: COLLECTION_NAME,
  field_name: 'vector',
  index_type: IndexType.IVF_FLAT,   // 聚类索引,先分桶再搜索
  metric_type: MetricType.COSINE    // 余弦相似度,适合文本语义
});

// 加载集合到内存,搜索前必须
await client.loadCollection({ collection_name: COLLECTION_NAME });

字段定义 vs 索引创建:字段定义决定"能存什么",索引创建决定"搜得多快"。


4.2 插入测试对话

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()
  },
  // ... 更多对话
];

// 批量向量化并插入
const conversationData = await Promise.all(
  conversations.map(async (conv) => ({
    ...conv,
    vector: await getEmbedding(conv.content)
  }))
);

await client.insert({
  collection_name: COLLECTION_NAME,
  data: conversationData
});

每条对话都会被转成 1024 维向量,连同原文一起存入 Milvus。


4.3 语义检索 + 对话生成

retrieval-memory.mjs —— 检索相关历史,拼接上下文,生成回答

import { ChatOpenAI } from '@langchain/openai';
import { InMemoryChatMessageHistory } from '@langchain/core/chat_history';
import { MilvusClient, MetricType } from '@zilliz/milvus2-sdk-node';
import { HumanMessage } from "@langchain/core/messages";

const model = new ChatOpenAI({
  modelName: process.env.MODEL_NAME,
  temperature: 0   // 确定性输出
});

核心检索函数:

async function retrieveRelevantConversations(query, k = 2) {
  // 1. 用户问题 → 向量
  const queryVector = await getEmbedding(query);

  // 2. 在 Milvus 中做相似度搜索
  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;
}

主循环:每轮对话做 4 件事

for (let i = 0; i < conversations.length; i++) {
  const { input } = conversations[i];

  // ① 检索:找到最相关的历史对话
  const retrieved = await retrieveRelevantConversations(input, 2);

  // ② 拼接:把历史塞进 prompt
  let relevantHistory = '';
  if (retrieved.length > 0) {
    relevantHistory = retrieved
      .map((conv, idx) => `[历史对话 ${idx + 1}]\n轮次:${conv.round}\n${conv.content}`)
      .join('\n\n------\n\n');
  }

  const contextMessages = relevantHistory
    ? [new HumanMessage(`相关历史对话:\n${relevantHistory}\n\n用户问题:${input}`)]
    : [new HumanMessage(input)];

  // ③ 生成:LLM 基于上下文回答
  const response = await model.invoke(contextMessages);
  console.log(response.content);

  // ④ 存储:本轮对话向量化后存入 Milvus
  const conversationText = `用户:${input}\n助手:${response.content}`;
  const convVector = await getEmbedding(conversationText);

  await client.insert({
    collection_name: COLLECTION_NAME,
    data: [{
      id: `conv_${Date.now()}_${i + 1}`,
      content: conversationText,
      vector: convVector,
      round: i + 1,
      timestamp: new Date().toISOString()
    }]
  });
}

效果:

  • 第 1 轮:"我之前提到的机器学习项目进展如何?" → 无历史,直接回答
  • 第 2 轮:"我周末经常做什么?" → 检索到第 1 轮内容,结合回答
  • 第 3 轮:"我的职业是什么?" → 检索到最相关的对话,精准回答

五、每 20 条自动摘要(替换原文)

当对话越来越多,逐条存储会有问题:

  1. 向量库数据膨胀
  2. 检索到的都是碎片化信息

解决方案:每 20 条对话触发一次摘要,用摘要替换原文。

第 1-20 条对话 → 生成 summary_001 → 删掉 conv_001~020
第 21-40 条对话 → 生成 summary_002 → 删掉 conv_021~040

Milvus 里永远只有:
- 最近 20 条原文(还没到总结周期)
- 之前的摘要(压缩过的历史)
// 存储 ID 追踪,用于后续删除
const recentIds = [];

async function maybeSummarize(history, round) {
  if (round % SUMMARIZE_THRESHOLD !== 0) return;

  // 取出最近 20 条对话
  const messages = await history.getMessages();
  const recent = messages.slice(-SUMMARIZE_THRESHOLD);
  const conversationText = recent
    .map(m => `${m._getType()}: ${m.content}`)
    .join('\n');

  // LLM 生成摘要
  const summaryPrompt = `请将以下对话总结为一段简洁的摘要,保留关键信息(用户身份、偏好、重要决定):\n\n${conversationText}`;
  const summary = await model.invoke([new HumanMessage(summaryPrompt)]);

  // 摘要向量化后存入 Milvus
  const summaryVector = await getEmbedding(summary.content);
  await client.insert({
    collection_name: COLLECTION_NAME,
    data: [{
      id: `summary_${Date.now()}`,
      content: `[对话摘要] ${summary.content}`,
      vector: summaryVector,
      round: round,
      timestamp: new Date().toISOString()
    }]
  });

  // 关键:删掉被摘要的原文,防止数据膨胀
  const idsToDelete = recentIds.splice(0, SUMMARIZE_THRESHOLD);
  if (idsToDelete.length > 0) {
    await client.delete({
      collection_name: COLLECTION_NAME,
      ids: idsToDelete
    });
  }

  console.log(`✅ 已生成摘要,删除 ${idsToDelete.length} 条原文`);
}

存储量对比:

❌ 不删原文:
  第1轮:20条原文 + 1条摘要 = 21条
  第2轮:40条原文 + 2条摘要 = 42条
  第N轮:20N条原文 + N条摘要 → 越来越大

✅ 删掉原文:
  任意时刻:最多20条原文 + N条摘要 → 增长很慢

摘要的好处:

  • 一条摘要涵盖 20 条对话的精华
  • 检索时更容易命中关键信息
  • 删掉原文后向量库数据量可控

六、业界参考:小龙虾、WorkBuddy 的 Memory 模块

这种"向量存储 + 语义检索"的记忆方案并不是我发明的,业界已经有很多成熟实践:

产品Memory 实现
小龙虾(Lobster)对话历史存向量库,检索后注入 prompt
WorkBuddy用户偏好 + 工作上下文双通道记忆
Harness Memory持久化记忆文件 + 向量索引,跨会话保持

核心思路都一样:不存全部原文,只存向量;不检索全部历史,只取最相关。


七、总结

本文实现了一个完整的 AI 聊天记忆系统:

模块技术作用
向量化OpenAI Embedding (text-embedding-v3)文本 → 1024 维向量
存储Milvus 向量数据库高效存储和检索向量
检索余弦相似度搜索找到语义最相关的历史对话
生成ChatOpenAI基于上下文生成回答
摘要每 20 条自动总结压缩历史,提升检索质量

Agent 不是金鱼,它可以记住你。只要给它一个 Memory 模块。


完整代码


觉得有用的话,点个赞👍再走吧!