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

文章来源声明: 原文作者:玉宇夕落; 来源站点:掘金; 原文链接:https://juejin.cn/post/7684201164354732084; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

适合想让 AI 对话历史不丢失、能语义搜索的开发者。文章把 Milvus 启动顺序、Schema、索引与 RAG 检索讲透,能有效避开新手高频坑。

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

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

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

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

typescript

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

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

typescript

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

场景3:语义检索需求

typescript

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

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

image.png

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


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

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

2.1 Milvus Standalone的依赖关系

image.png

为什么这么设计?

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

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


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

3.1 常见错误:一把梭启动

powershell

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

为什么会失败?

image.png

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

powershell

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

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

<span># ✅ 第三步:再启动milvus</span>
docker start milvus-standalone

<span># ✅ 第四步:验证</span>
docker ps

一行流写法(含等待):

powershell

docker <span>start</span> milvus<span>-</span>etcd milvus<span>-</span>minio; <span>Start</span><span>-</span>Sleep <span>-</span>Seconds <span>5</span>; docker <span>start</span> milvus<span>-</span>standalone

3.3 更规范的做法:Docker Compose

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

yaml

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

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

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

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

3.5 验证启动状态

powershell

<span># 期望看到的输出</span>
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

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

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

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

typescript

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

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

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

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

typescript

<span>// 2️⃣ 独立创建索引 —— 只对"需要被搜索"的字段建</span>
await client.<span>createIndex</span>({
  <span>collection_name</span>: COLLECTION_NAME,
  <span>field_name</span>: <span>'vector'</span>,          // 👈 只对向量字段建索引
  <span>index_type</span>: IndexType.IVF_FLAT,
  <span>metric_type</span>: 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.<span>createIndex</span>({
  <span>collection_name</span>: COLLECTION_NAME,
  <span>field_name</span>: <span>'vector'</span>,
  <span>index_type</span>: IndexType.IVF_FLAT,   // 👈 怎么<span>"分区"</span>:倒排文件+扁平量化
  <span>metric_type</span>: MetricType.COSINE    // 👈 怎么<span>"比相似"</span>:余弦相似度
});

  • IVF_FLAT 告诉你"去哪些向量簇里找"(缩小范围
  • COSINE 告诉你"找到之后,怎么比谁更近"(精确排序

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

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

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

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

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

typescript

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

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

4.7 完整Schema设计流程

image.png

4.8 设计者为什么这么写?

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

typescript

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

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

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

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

typescript

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


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

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

5.1 核心代码

typescript

<span>/**
 * 检索与当前输入最相关的k条历史对话
 */</span>
<span>async</span> <span>function</span> <span>retrievalRelevantConversations</span>(<span>input, k = <span>2</span></span>) {
  <span>try</span> {
    <span>// 1️⃣ 把用户问题转成向量</span>
    <span>const</span> queryVector = <span>await</span> <span>getEmbedding</span>(input);
    
    <span>// 2️⃣ 在Milvus中做相似度搜索</span>
    <span>const</span> searchResult = <span>await</span> client.<span>search</span>({
      <span>collection_name</span>: <span>COLLECTION_NAME</span>,
      <span>vector</span>: queryVector,
      <span>limit</span>: k,                        <span>// 返回最相似的k条</span>
      <span>metric_type</span>: <span>MetricType</span>.<span>COSINE</span>,  <span>// 余弦相似度</span>
      <span>output_fields</span>: [<span>'id'</span>, <span>'content'</span>, <span>'round'</span>, <span>'timestamp'</span>],
    });
    
    <span>return</span> searchResult.<span>results</span>;
  } <span>catch</span> (err) {
    <span>console</span>.<span>error</span>(<span>'检索相关历史对话失败:'</span>, err);
    <span>return</span> [];  <span>// ⚠️ 失败时返回空数组,而不是抛异常</span>
  }
}

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

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

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

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

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

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

5.2 设计者为什么这么写?

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

image.png

为什么这么精妙?

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

六、避坑指南

🕳️ 坑1:Docker daemon没启动

powershell

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

原因:Docker Desktop服务没运行。

解决

powershell

docker desktop <span>start</span>

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

typescript

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

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

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

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

typescript

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

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

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

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

typescript

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

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

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

typescript

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

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

🕳️ 坑6:忘记loadCollection

typescript

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

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

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

typescript

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

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

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

typescript

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

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

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

typescript

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

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


七、生产环境最佳实践

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

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

typescript

<span>let</span> conversationBuffer = [];

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

<span>// 攒够20轮</span>
<span>if</span> (conversationBuffer.<span>length</span> >= <span>20</span>) {
  <span>// 1. AI生成摘要</span>
  <span>const</span> summary = <span>await</span> <span>summarizeHistory</span>(conversationBuffer);
  
  <span>// 2. 摘要入库(而非原始20条)</span>
  <span>const</span> summaryVector = <span>await</span> <span>getEmbedding</span>(summary);
  <span>await</span> client.<span>insert</span>({
    <span>collection_name</span>: <span>COLLECTION_NAME</span>,
    <span>data</span>: [{
      <span>id</span>: <span>`summary_<span>${<span>Date</span>.now()}</span>`</span>,
      <span>vector</span>: summaryVector,
      <span>content</span>: summary,
      <span>round</span>: currentRound,
      <span>timestamp</span>: <span>new</span> <span>Date</span>().<span>toISOString</span>(),
    }]
  });
  
  <span>// 3. 清空buffer</span>
  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   <span>// 余弦相似度:关注方向,范围[-1,1],文本embedding首选</span>
MetricType.L2       <span>// 欧氏距离:关注绝对距离,图像embedding常用</span>
MetricType.IP       <span>// 内积:向量已归一化时≈COSINE,性能略好</span>

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

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

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