Docker + Milvus,数据库永久存放记忆,让对话历史永不丢失

0 阅读11分钟

全文导读:内存记忆会丢、文件记忆搜不准、截断总结丢信息——这是所有AI应用都会遇到的"记忆困境"。本文将带你从Docker环境启动开始,一步步用Milvus向量数据库构建一个可检索、可持久化、能语义搜索的"长期记忆系统"。特别地,本文会重点澄清三个新手最容易踩坑的概念:字段定义与索引的区别、为什么只需要一个索引、IVF_FLAT与COSINE到底是不是二选一。  读完本文,你将掌握企业级AI记忆架构的完整落地流程!


一、灵魂拷问:为什么还需要向量数据库?

前面我们讲了内存记忆、文件记忆、总结压缩,看起来已经够用了?别急,先看三个真实场景:

场景1:AI"翻脸不认人"

typescript

// 用户3天前说:"我叫赵六,是一名数据科学家"
// 3天后用户问:"你记得我是谁吗?"
// 文件记忆:能读到,但需要加载整个JSON文件
// 内存记忆:程序重启,早忘了 😭

场景2:记忆太多,找不过来

typescript

// 用户聊了1000轮对话
// 文件记忆:读整个JSON → 塞进prompt → token爆炸 💥
// 内存记忆:更不可能全部塞

场景3:语义检索需求

typescript

// 用户问:"我周末经常做什么?"
// 明明之前说过"我喜欢打篮球和看电影"
// 但关键词匹配找不到 —— 因为字面完全没有重合词!

这就是我们需要向量数据库的根本原因:

image.png

一句话总结:向量数据库 = 可持久化 + 语义检索 + 无限扩展的记忆系统。


二、Milvus架构解密:为什么它需要三个容器?

在写代码之前,先搞清楚Milvus的真实架构,这能帮你避免90%的启动坑。

2.1 Milvus Standalone的依赖关系

image.png

为什么这么设计?

组件职责类比
etcd存储集群元数据(集合schema、索引信息、节点状态)图书馆的索引卡片柜
minio存储实际的向量数据文件(对象存储)图书馆的书架
milvus-standalone计算核心,处理查询、索引、向量运算图书馆的管理员

⚠️ 关键点:Milvus启动时会立刻连接etcd和minio。如果依赖没准备好,Milvus会直接退出!这就是为什么启动顺序不能乱。


三、重难点①:Docker启动的"顺序陷阱"

3.1 常见错误:一把梭启动

powershell

# ❌ 错误示范:一条命令全启动
docker start milvus-etcd milvus-minio milvus-standalone
# 结果:milvus-standalone可能因为依赖没就绪而退出

为什么会失败?

image.png

3.2 正确姿势:按依赖顺序启动

powershell

# ✅ 第一步:先启动依赖服务
docker start milvus-etcd milvus-minio

# ✅ 第二步:等待依赖就绪
Start-Sleep -Seconds 5

# ✅ 第三步:再启动milvus
docker start milvus-standalone

# ✅ 第四步:验证
docker ps

一行流写法(含等待):

powershell

docker start milvus-etcd milvus-minio; Start-Sleep -Seconds 5; docker start milvus-standalone

3.3 更规范的做法:Docker Compose

官方推荐用docker compose up -d,因为yml文件里有depends_on字段声明依赖关系,Docker会自动等待依赖就绪:

yaml

# docker-compose.yml 节选
services:
  milvus-standalone:
    image: milvusdb/milvus:v2.6.22
    depends_on:
      - etcd
      - minio    # 👈 自动等待依赖启动

3.4 Docker命令辨析(新手必看)

命令作用常见误区
docker image ls查看本地所有镜像❌ 镜像没有"运行中"的说法!
docker ps查看正在运行的容器查不到已停止的容器
docker ps -a查看所有容器(含已停止)✅ 排查问题时必用
docker start <名>启动已停止的容器推荐,保留原配置
docker restart <名>先停再启对已停止容器不如start直接

💡 一句话记忆:镜像 = 类(Class),容器 = 实例(Instance)。类只能被实例化,不能被"运行"。

3.5 验证启动状态

powershell

# 期望看到的输出
docker ps

CONTAINER ID   IMAGE                            STATUS         NAMES
abc123...      milvusdb/milvus:v2.6.22          Up 2 minutes   milvus-standalone
def456...      minio/minio:RELEASE...           Up 5 minutes   milvus-minio
ghi789...      quay.io/coreos/etcd:v3.5.25     Up 5 minutes   milvus-etcd

STATUS列的含义

  • Up ... → 正在运行 ✅
  • Exited (...) → 已停止 ❌ 需要docker start

四、重难点②:Milvus Schema设计——字段定义 ≠ 索引(超重要!)

这是本文最核心、最容易混淆的部分,很多新手在这里会踩坑。先把概念掰开揉碎讲清楚。

4.1 三个概念的澄清

在Milvus里,有三件看似相似、实则完全不同的事情:

概念作用SQL类比
字段定义(Schema)告诉Milvus"要存什么数据"CREATE TABLE ... (col1, col2, ...)
索引(Index)加速检索的"目录"CREATE INDEX ON ...
加载(Load)把集合加载到内存供搜索SELECT ... 前的必要准备

新手最容易犯的错:以为"定义了字段,就等于建了索引"。大错特错!

typescript

// ❌ 错误认知
// "我定义了5个字段,所以Milvus会自动为这5个字段都建索引"
// 事实:Milvus只为你显式创建的字段建索引

// ✅ 正确认知
// 字段定义 = 定义数据表结构(存什么)
// 索引     = 针对特定字段建"加速目录"(怎么快速找)
// 二者完全独立!

4.2 字段定义:告诉Milvus存什么

typescript

const COLLECTION_NAME = 'conversations';
const VECTOR_DIM = 1024;  // 向量维度,必须与embedding模型一致

// 1️⃣ 定义集合的Schema —— 声明要存哪些字段
await client.createCollection({
  collection_name: COLLECTION_NAME,
  fields: [
    // 主键:用VarChar而非自增int,支持自定义id(如 conv_时间戳_轮次)
    { 
      name: 'id', 
      data_type: DataType.VarChar, 
      max_length: 50, 
      is_primary_key: true 
    },
    // 向量字段:核心!存储对话内容的embedding
    { 
      name: 'vector', 
      data_type: DataType.FloatVector, 
      dim: VECTOR_DIM 
    },
    // 原始对话文本:检索时返回给LLM的
    { 
      name: 'content', 
      data_type: DataType.VarChar, 
      max_length: 5000 
    },
    // 对话轮次:用于上下文排序
    { 
      name: 'round', 
      data_type: DataType.Int64 
    },
    // ⚠️ Milvus没有DateTime类型!必须用字符串
    { 
      name: 'timestamp', 
      data_type: DataType.VarChar, 
      max_length: 100 
    }
  ]
});

这就是"字段定义"阶段——它只做一件事:声明数据表长什么样。此时Milvus还不知道该怎么加速检索。

4.3 索引:独立创建,只为"检索字段"服务

typescript

// 2️⃣ 独立创建索引 —— 只对"需要被搜索"的字段建
await client.createIndex({
  collection_name: COLLECTION_NAME,
  field_name: 'vector',          // 👈 只对向量字段建索引
  index_type: IndexType.IVF_FLAT,
  metric_type: MetricType.COSINE
});

4.4 为什么只需要一个索引?

看到这里你可能会问:为什么只有vector字段需要索引?其他字段都不要吗?

答案:不是"不要",而是"不需要" 。看下面的对比表:

字段需要索引?原因
vector✅ 需要核心检索字段,ANN(近似最近邻)搜索依赖索引加速
id❌ 不需要主键,Milvus自动处理精确匹配(类似B+树主键索引,但不需要你手动建)
content❌ 不需要纯存储文本,从不作为检索条件,只作为output_fields返回
round❌ 不需要业务元数据,可用于过滤(filter),但过滤走的是元数据扫描,不依赖向量索引
timestamp❌ 不需要round,元数据用途,无需索引

💡 核心心法只有"参与相似度搜索"的字段才需要建索引。  在向量数据库里,这个字段几乎永远是向量字段

类比理解

  • 去图书馆找书,你靠书名/作者查目录(索引)
  • 但如果你要按 "内容主题相似度" 找书,就得给每本书的内容向量建一个特殊目录
  • 书本的"出版日期"、"页数"这些字段,你只是在借书时看一眼(output),并不用来查找,自然不需要建索引

4.5 灵魂辨析:IVF_FLAT 与 COSINE 到底是不是二选一?

这是新手最容易被绕晕的地方。答案是:不是!它们是完全不同维度的概念,互相配合。

先看这张表:

作用回答的问题类比
IVF_FLAT如何组织搜索空间"去哪些区域找?"图书馆按「类别」分区,找书只去对应区
COSINE如何判断相似度"区域内怎么比谁更近?"在对应区里,按「主题相关度」排序

把两者类比到图书馆找书:

image.png

再回头看代码,就豁然开朗了:

typescript

await client.createIndex({
  collection_name: COLLECTION_NAME,
  field_name: 'vector',
  index_type: IndexType.IVF_FLAT,   // 👈 怎么"分区":倒排文件+扁平量化
  metric_type: MetricType.COSINE    // 👈 怎么"比相似":余弦相似度
});
  • IVF_FLAT 告诉你"去哪些向量簇里找"(缩小范围
  • COSINE 告诉你"找到之后,怎么比谁更近"(精确排序

它们永远是成对出现的,一个负责"粗筛",一个负责"精排",缺一不可!

4.6 常见索引与度量方式速查

索引类型(IndexType)—— 决定"怎么快速找":

索引精度速度内存适用场景
FLAT100%数据量 < 10万
IVF_FLAT~99%数据量 10万~100万
HNSW~99.9%最快数据量 > 100万

度量方式(MetricType)—— 决定"怎么比相似":

typescript

MetricType.COSINE   // 余弦相似度:关注向量方向,范围[-1,1],文本embedding首选
MetricType.L2       // 欧氏距离:关注绝对距离,图像embedding常用
MetricType.IP       // 内积:向量已归一化时≈COSINE,性能略好

面试答题:文本检索首选COSINE,因为embedding模型通常训练时就优化了余弦相似度。

4.7 完整Schema设计流程

image.png

4.8 设计者为什么这么写?

问题:为什么主键用VarChar不用Int64?

typescript

// ❌ 自增ID的问题
{ id: 1 }, { id: 2 }, ...  // 多用户场景容易冲突

// ✅ 自定义字符串ID
id: `conv_${Date.now()}_${i+1}`  // 时间戳+轮次,天然唯一

问题:为什么要单独存content字段?

因为Milvus搜索时,只返回你指定的output_fields。如果不存原文,检索到向量也不知道对应什么对话内容!

typescript

// 检索时明确指定返回哪些字段
const searchResult = await client.search({
  collection_name: COLLECTION_NAME,
  vector: queryVector,
  output_fields: ['id', 'content', 'round', 'timestamp'],  // 👈 关键
});

五、重难点③:RAG式对话检索——最精巧的部分

这是整个系统的灵魂:把"历史对话"变成"可检索的知识库"。

5.1 核心代码

typescript

/**
 * 检索与当前输入最相关的k条历史对话
 */
async function retrievalRelevantConversations(input, k = 2) {
  try {
    // 1️⃣ 把用户问题转成向量
    const queryVector = await getEmbedding(input);
    
    // 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;
  } catch (err) {
    console.error('检索相关历史对话失败:', err);
    return [];  // ⚠️ 失败时返回空数组,而不是抛异常
  }
}

/**
 * 完整的RAG对话流程
 */
async function retrievalMemoryDemo() {
  await client.connectPromise;  // 确保连接成功
  
  const history = new InMemoryChatMessageHistory();
  const conversation = [
    { input: '我之前提到的机器学习项目进展如何' },
    { input: '我周末经常做什么?' },
    { input: '我的职业是什么?' },
  ];

  for (let i = 0; i < conversation.length; i++) {
    const { input } = conversation[i];
    const userMessage = new HumanMessage(input);

    // 🔍 关键步骤:检索相关历史
    const retrievalConversations = await retrievalRelevantConversations(input, 2);
    
    // 📝 拼装历史上下文
    let relevantHistory = '';
    if (retrievalConversations.length > 0) {
      relevantHistory = retrievalConversations.map((conv, index) => {
        return `[历史对话 ${index + 1}]
轮次: ${conv.round}
${conv.content}`;
      }).join('\n\n-------\n\n');
    }

    // 🎯 构造带历史上下文的prompt
    const contextMessages = relevantHistory
      ? [new HumanMessage(`相关历史对话:${relevantHistory}\n\n用户问题:${input}`)]
      : [userMessage];

    // 🤖 调用LLM生成回答
    const response = await model.invoke(contextMessages);
    
    // 💾 持久化到Milvus
    const conversationText = `用户问题:${input}\n助手回答:${response.content}`;
    const convId = `conv_${Date.now()}_${i + 1}`;
    const convVector = await getEmbedding(conversationText);

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

5.2 设计者为什么这么写?

核心思想对话历史 ≠ 顺序读,而是"按需检索"

image.png

为什么这么精妙?

传统方式RAG检索方式
把所有历史塞进prompt只塞最相关的几条
Token开销 O(n)Token开销 O(k),k固定
无关信息干扰LLM精准上下文
无法扩展到百万对话可承载海量历史

六、避坑指南

🕳️ 坑1:Docker daemon没启动

powershell

# 报错信息
failed to connect to the docker API at npipe:////./pipe/dockerDesktopLinuxEngine

原因:Docker Desktop服务没运行。

解决

powershell

docker desktop start

🕳️ 坑2:把"字段定义"当成了"索引"

typescript

// ❌ 错误认知
// "我在 fields 里定义了 vector 字段,Milvus 应该会自动建索引吧?"
await client.createCollection({ fields: [...] });
await client.search({...});  // 报错:collection has no index / not loaded

// ✅ 正确姿势:字段定义、索引、加载 三步走
await client.createCollection({ fields: [...] });           // 1. 定义Schema
await client.createIndex({ field_name: 'vector', ... });    // 2. 建索引
await client.loadCollection({ collection_name: ... });      // 3. 加载到内存
await client.search({...});                                 // 4. 开始检索

这是新手最常踩的坑 :以为定义Schema=万事俱备,实际还差索引创建集合加载两步!

🕳️ 坑3:以为"每个字段都要建索引"

typescript

// ❌ 错误:给每个字段都建索引
await client.createIndex({ field_name: 'id', ... });
await client.createIndex({ field_name: 'content', ... });
await client.createIndex({ field_name: 'round', ... });
// 浪费资源,且Milvus不支持对普通标量字段的"向量索引"

// ✅ 正确:只对vector字段建索引
await client.createIndex({
  field_name: 'vector',
  index_type: IndexType.IVF_FLAT,
  metric_type: MetricType.COSINE
});
// 其他字段作为元数据/输出字段即可

记住索引是给"检索入口"用的,不是给"展示内容"用的。

🕳️ 坑4:混淆IVF_FLAT和COSINE,以为只能选一个

typescript

// ❌ 错误理解
// "我想用COSINE相似度,所以index_type应该填COSINE?"
await client.createIndex({
  field_name: 'vector',
  index_type: MetricType.COSINE,   // ❌ 类型不匹配!
});

// ✅ 正确理解
await client.createIndex({
  field_name: 'vector',
  index_type: IndexType.IVF_FLAT,   // 👈 "怎么找"——分区算法
  metric_type: MetricType.COSINE    // 👈 "怎么比"——相似度度量
});
// 两者配合,缺一不可

🕳️ 坑5:向量维度不匹配

typescript

// ❌ 错误:embedding模型维度 与 Milvus字段维度不一致
const embeddings = new OpenAIEmbeddings({ model: 'text-embedding-v3' }); // 1024维
// Milvus中定义 dim: 768 ❌
// 插入时会报错:vector dimension mismatch

// ✅ 正确:两边保持一致
const VECTOR_DIM = 1024;
const embeddings = new OpenAIEmbeddings({ 
  model: 'text-embedding-v3',
  dimension: VECTOR_DIM  // 👈 明确指定
});
// Milvus: { name: 'vector', data_type: DataType.FloatVector, dim: 1024 }

🕳️ 坑6:忘记loadCollection

typescript

// ❌ 创建完索引就直接search
await client.createCollection({...});
await client.createIndex({...});
await client.search({...});  
// 报错:collection not loaded

// ✅ 必须先load
await client.createCollection({...});
await client.createIndex({...});
await client.loadCollection({ collection_name: COLLECTION_NAME });  // 👈 必须
await client.search({...});

🕳️ 坑7:时间戳类型踩雷

typescript

// ❌ Milvus不支持DateTime类型
{ name: 'timestamp', data_type: DataType.DateTime }  // 编译报错

// ✅ 用VarChar存ISO字符串
{ name: 'timestamp', data_type: DataType.VarChar, max_length: 100 }
// 存储: new Date().toISOString()

🕳️ 坑8:检索失败直接抛异常

typescript

// ❌ 检索失败让整个流程挂掉
async function retrievalRelevantConversations(input) {
  const result = await client.search({...});  // 网络抖动 = 全挂
  return result.results;
}

// ✅ 优雅降级:检索失败就当作"无历史"
async function retrievalRelevantConversations(input) {
  try {
    const result = await client.search({...});
    return result.results;
  } catch (err) {
    console.error('检索失败,降级处理:', err);
    return [];  // 👈 关键:返回空数组
  }
}

🕳️ 坑9:一条对话拆成两条向量

typescript

// ❌ 错误:用户问题 和 AI回答 分开存
await client.insert({ content: `用户:${input}` });
await client.insert({ content: `助手:${response.content}` });
// 问题:检索时可能只召回半条对话,语义不完整

// ✅ 正确:一条对话存成一个文档
const conversationText = `用户问题:${input}\n助手回答:${response.content}`;
await client.insert({ content: conversationText });
// 语义完整,检索召回更精准

七、生产环境最佳实践

7.1 每20轮触发一次"总结入库"

根据需求描述,标准做法是:

typescript

let conversationBuffer = [];

// 每轮对话后
conversationBuffer.push({ input, response });

// 攒够20轮
if (conversationBuffer.length >= 20) {
  // 1. AI生成摘要
  const summary = await summarizeHistory(conversationBuffer);
  
  // 2. 摘要入库(而非原始20条)
  const summaryVector = await getEmbedding(summary);
  await client.insert({
    collection_name: COLLECTION_NAME,
    data: [{
      id: `summary_${Date.now()}`,
      vector: summaryVector,
      content: summary,
      round: currentRound,
      timestamp: new Date().toISOString(),
    }]
  });
  
  // 3. 清空buffer
  conversationBuffer = [];
}

为什么这样设计?

image.png

7.2 混合记忆架构

image.png

三层记忆各司其职

  • 短期:保证对话流畅性(最近上下文)
  • 中期:保证关键信息不丢(总结压缩)
  • 长期:保证可语义检索(向量数据库)

八、面试高频考点

Q1:Milvus的"字段定义"和"索引"有什么区别?

答要点

  • 字段定义:声明集合存哪些字段,类比SQL的CREATE TABLE,只定义结构
  • 索引:为特定字段创建的加速结构,类比SQL的CREATE INDEX
  • 关键区别:字段定义是"存什么",索引是"怎么快速找",两者完全独立
  • Milvus特殊性:只有向量字段需要建索引;主键由Milvus自动处理;其他标量字段作为元数据即可

Q2:为什么Milvus只需要给vector字段建索引?

答要点

  • 向量数据库的核心检索入口就是向量字段(ANN搜索)
  • id作为主键,Milvus内部自动维护精确匹配能力
  • contentroundtimestamp等字段只作为输出/过滤条件,不参与相似度检索
  • 建无谓的索引浪费存储和写入性能

Q3:IVF_FLAT和COSINE是什么关系?能二选一吗?

答要点

  • 完全不同维度,必须配合使用:

    • IVF_FLAT(IndexType)= 如何组织搜索空间(怎么分区、怎么快速定位候选)
    • COSINE(MetricType)= 如何计算相似度(怎么判断谁更相似)
  • 类比:IVF_FLAT决定"去图书馆哪个区找",COSINE决定"区内按什么排序"

  • 不能二选一createIndex时两个参数都要传

Q4:Milvus为什么需要etcd和minio两个依赖?

答要点

  • etcd:存储元数据(集合schema、索引配置、节点状态),保证分布式一致性
  • minio:存储实际数据文件(向量数据、索引文件),用对象存储解耦计算与存储
  • 设计思想:存储与计算分离,便于水平扩展

Q5:COSINE、L2、IP三种距离度量怎么区分?

typescript

MetricType.COSINE   // 余弦相似度:关注方向,范围[-1,1],文本embedding首选
MetricType.L2       // 欧氏距离:关注绝对距离,图像embedding常用
MetricType.IP       // 内积:向量已归一化时≈COSINE,性能略好

面试答题:文本检索首选COSINE,因为embedding模型通常训练时就优化了余弦相似度。

Q6:RAG检索中为什么用"用户问题"而不是"完整对话"做query?

  • 用户当前问题最明确表达意图
  • 完整对话可能包含噪音,稀释检索精度
  • 实践验证:问题query召回率更高