摘要:围绕检索式 Memory,完整实现对话向量化、Milvus 存储、相似度搜索、上下文注入与新记忆回写,并说明短期历史、摘要和长期语义记忆如何协同。
上篇解决了三个问题:
- 用消息历史让模型拥有连续对话能力;
- 用文件让消息跨进程持久化;
- 用截断和摘要控制上下文长度。
但截断与摘要都围绕“最近发生了什么”管理上下文。假设用户进行了大量对话,现在突然问到很久以前讨论过的向量数据库,最近几条消息里未必包含相关内容;如果只保留近期上下文,那段旧信息已经不可见;如果每次传入完整历史,上下文又会持续膨胀。
检索式 Memory 提供了第三条路线:
历史对话持久化到 Milvus
↓
当前问题转换成向量
↓
按向量相似度检索相关历史
↓
相关历史与当前问题一起交给模型
↓
把本轮问答继续写回 Milvus
这样,长期记忆不必全部进入当前上下文。每一轮只取与问题语义最接近的少量记录,既控制输入规模,又能找回较早的信息。
本文将完成这条闭环:创建 Milvus 集合、生成 Embedding、插入历史对话、执行相似度检索、拼接模型上下文,并把新问答保存为下一轮可检索的记忆。
一、检索式 Memory 的组成
整个过程包含三个核心对象:
- Embedding 模型:把自然语言转换成固定维度的向量;
- Milvus:保存向量及其对应的对话正文、轮次和时间;
- 聊天模型:读取检索到的相关历史并回答当前问题。
依赖如下:
<span>{</span>
<span>"dependencies"</span><span>:</span> <span>{</span>
<span>"@langchain/core"</span><span>:</span> <span>"^1.2.11"</span><span>,</span>
<span>"@langchain/openai"</span><span>:</span> <span>"^1.5.13"</span><span>,</span>
<span>"@zilliz/milvus2-sdk-node"</span><span>:</span> <span>"^3.0.5"</span><span>,</span>
<span>"dotenv"</span><span>:</span> <span>"^17.4.2"</span>
<span>}</span>
<span>}</span>
环境变量需要同时描述聊天模型、Embedding 模型和 Milvus 地址:
MODEL_NAME=你的聊天模型名称
OPENAI_API_KEY=你的API-Key
OPENAI_BASE_URL=你的模型服务地址
EMBEDDINGS_MODEL_NAME=你的Embedding模型名称
MILVUS_ADDRESS=你的Milvus地址
这里让聊天模型与 Embedding 模型使用相同的 API Key 和基础地址,但模型名称分别配置。
二、初始化 Embedding 模型和 Milvus 客户端
先定义集合名称与向量维度:
<span>const</span> <span>COLLECTION_NAME</span> = <span>"conversations"</span>;
<span>const</span> <span>VECTOR_DIM</span> = <span>1024</span>;
再创建 Embedding 实例:
<span>import</span> <span>"dotenv/config"</span>;
<span>import</span> { <span>OpenAIEmbeddings</span> } <span>from</span> <span>"@langchain/openai"</span>;
<span>const</span> embeddings = <span>new</span> <span>OpenAIEmbeddings</span>({
<span>apiKey</span>: process.<span>env</span>.<span>OPENAI_API_KEY</span>,
<span>model</span>: process.<span>env</span>.<span>EMBEDDINGS_MODEL_NAME</span>,
<span>configuration</span>: {
<span>baseURL</span>: process.<span>env</span>.<span>OPENAI_BASE_URL</span>,
},
<span>dimensions</span>: <span>VECTOR_DIM</span>,
});
<span>async</span> <span>function</span> <span>getEmbedding</span>(<span>text</span>) {
<span>return</span> embeddings.<span>embedQuery</span>(text);
}
embedQuery(text) 接收一段文本,返回长度为 1024 的数字数组。这个数组本身不是给读者阅读的内容,而是后续相似度搜索的依据。
VECTOR_DIM 会同时用于 Embedding 配置和 Milvus 字段定义,两处必须一致:
Embedding 输出维度:1024
Milvus vector 字段维度:1024
然后创建 Milvus 客户端:
<span>import</span> { <span>MilvusClient</span> } <span>from</span> <span>"@zilliz/milvus2-sdk-node"</span>;
<span>const</span> client = <span>new</span> <span>MilvusClient</span>({
<span>address</span>: process.<span>env</span>.<span>MILVUS_ADDRESS</span>,
});
正式执行建表、写入或检索前,等待客户端连接:
<span>console</span>.<span>log</span>(<span>"连接到 Milvus..."</span>);
<span>await</span> client.<span>connectPromise</span>;
<span>console</span>.<span>log</span>(<span>"连接成功"</span>);
三、为长期对话设计集合结构
关系型数据库通常把结构化字段组织成表,而这里把长期对话保存在名为 conversations 的集合中。每条记录包含五个字段:
| 字段 | Milvus 类型 | 用途 |
|---|---|---|
| `id` | `VarChar` | 唯一标识一条对话记录,同时作为主键 |
| `vector` | `FloatVector` | 对话正文对应的 1024 维向量 |
| `content` | `VarChar` | 可读的用户或 AI 对话正文 |
| `round` | `Int64` | 对话轮次 |
| `timestamp` | `VarChar` | ISO 格式时间字符串 |
创建集合:
<span>import</span> {
<span>DataType</span>,
<span>IndexType</span>,
<span>MetricType</span>,
} <span>from</span> <span>"@zilliz/milvus2-sdk-node"</span>;
<span>await</span> client.<span>createCollection</span>({
<span>collection_name</span>: <span>COLLECTION_NAME</span>,
<span>fields</span>: [
{
<span>name</span>: <span>"id"</span>,
<span>data_type</span>: <span>DataType</span>.<span>VarChar</span>,
<span>max_length</span>: <span>50</span>,
<span>is_primary_key</span>: <span>true</span>,
},
{
<span>name</span>: <span>"vector"</span>,
<span>data_type</span>: <span>DataType</span>.<span>FloatVector</span>,
<span>dim</span>: <span>VECTOR_DIM</span>,
},
{
<span>name</span>: <span>"content"</span>,
<span>data_type</span>: <span>DataType</span>.<span>VarChar</span>,
<span>max_length</span>: <span>5000</span>,
},
{
<span>name</span>: <span>"round"</span>,
<span>data_type</span>: <span>DataType</span>.<span>Int64</span>,
},
{
<span>name</span>: <span>"timestamp"</span>,
<span>data_type</span>: <span>DataType</span>.<span>VarChar</span>,
<span>max_length</span>: <span>100</span>,
},
],
});
时间字段使用 new Date().toISOString() 生成字符串:
<span>timestamp</span>: <span>new</span> <span>Date</span>().<span>toISOString</span>()
因此 schema 中把它定义为 VarChar,而不是日期类型。
这个 schema 还体现了向量数据库记录的两部分:
vector用于机器计算相似度;content、round、timestamp用于把检索结果重新组织成人可以理解、模型也可以读取的上下文。
只保存向量而不保存正文,检索后就无法把真实对话交还给聊天模型。
四、为向量字段创建索引
集合创建后,为 vector 字段创建索引:
<span>await</span> client.<span>createIndex</span>({
<span>collection_name</span>: <span>COLLECTION_NAME</span>,
<span>field_name</span>: <span>"vector"</span>,
<span>index_type</span>: <span>IndexType</span>.<span>IVF_FLAT</span>,
<span>metric_type</span>: <span>MetricType</span>.<span>COSINE</span>,
});
<span>console</span>.<span>log</span>(<span>"索引已经创建"</span>);
这段配置表达了三件事:
- 检索目标是
vector字段; - 索引类型使用
IVF_FLAT; - 相似度度量使用
COSINE。
后续搜索也会明确写出 MetricType.COSINE,保证写入后的索引配置和查询时的度量方式保持一致。
集合和索引属于初始化工作。下面的初始化程序应在首次准备数据时执行;如果同名集合已经存在,再次创建会遇到名称冲突,因此不要把初始化逻辑混在每一轮聊天请求里反复执行。
五、把种子对话转换成向量并写入 Milvus
先准备三轮、六条对话:
<span>const</span> conversations = [
{
<span>id</span>: <span>"conversation_1"</span>,
<span>content</span>: <span>"用户:你好,我最近正在学习 Milvus 向量数据库。"</span>,
<span>round</span>: <span>1</span>,
<span>timestamp</span>: <span>new</span> <span>Date</span>().<span>toISOString</span>(),
},
{
<span>id</span>: <span>"conversation_2"</span>,
<span>content</span>: <span>"AI:Milvus 是一个向量数据库,常用于 RAG、语义搜索和推荐系统。"</span>,
<span>round</span>: <span>1</span>,
<span>timestamp</span>: <span>new</span> <span>Date</span>().<span>toISOString</span>(),
},
{
<span>id</span>: <span>"conversation_3"</span>,
<span>content</span>: <span>"用户:向量数据库和 MySQL 有什么区别?"</span>,
<span>round</span>: <span>2</span>,
<span>timestamp</span>: <span>new</span> <span>Date</span>().<span>toISOString</span>(),
},
{
<span>id</span>: <span>"conversation_4"</span>,
<span>content</span>: <span>"AI:MySQL 更适合结构化数据和精确查询,而 Milvus 更擅长根据向量相似度进行语义检索。"</span>,
<span>round</span>: <span>2</span>,
<span>timestamp</span>: <span>new</span> <span>Date</span>().<span>toISOString</span>(),
},
{
<span>id</span>: <span>"conversation_5"</span>,
<span>content</span>: <span>"用户:那 RAG 为什么要使用向量数据库?"</span>,
<span>round</span>: <span>3</span>,
<span>timestamp</span>: <span>new</span> <span>Date</span>().<span>toISOString</span>(),
},
{
<span>id</span>: <span>"conversation_6"</span>,
<span>content</span>: <span>"AI:因为 RAG 需要从大量文本中找到和用户问题语义最相近的内容,再交给大模型生成回答。"</span>,
<span>round</span>: <span>3</span>,
<span>timestamp</span>: <span>new</span> <span>Date</span>().<span>toISOString</span>(),
},
];
每条记录此时还缺少 vector。可以用 Promise.all() 并发生成所有向量,再保留原字段:
<span>const</span> conversationData = <span>await</span> <span>Promise</span>.<span>all</span>(
conversations.<span>map</span>(<span>async</span> (item) => ({
...item,
<span>vector</span>: <span>await</span> <span>getEmbedding</span>(item.<span>content</span>),
}))
);
转换前:
{
id,
content,
round,
timestamp
}
转换后:
{
id,
content,
round,
timestamp,
vector
}
最后一次性写入:
<span>await</span> client.<span>insert</span>({
<span>collection_name</span>: <span>COLLECTION_NAME</span>,
<span>data</span>: conversationData,
});
这里按单条用户消息或 AI 消息存储种子数据,所以一次检索可能返回某个问题,也可能返回与它对应的回答。后面保存新对话时会采用稍有不同的粒度:把一整轮“用户问题 + AI 回答”合并成一条记录。
六、完整的初始化程序
把连接、建集合、建索引和插入种子数据组合起来:
<span>import</span> <span>"dotenv/config"</span>;
<span>import</span> {
<span>MilvusClient</span>,
<span>DataType</span>,
<span>MetricType</span>,
<span>IndexType</span>,
} <span>from</span> <span>"@zilliz/milvus2-sdk-node"</span>;
<span>import</span> { <span>OpenAIEmbeddings</span> } <span>from</span> <span>"@langchain/openai"</span>;
<span>const</span> <span>COLLECTION_NAME</span> = <span>"conversations"</span>;
<span>const</span> <span>VECTOR_DIM</span> = <span>1024</span>;
<span>const</span> embeddings = <span>new</span> <span>OpenAIEmbeddings</span>({
<span>apiKey</span>: process.<span>env</span>.<span>OPENAI_API_KEY</span>,
<span>model</span>: process.<span>env</span>.<span>EMBEDDINGS_MODEL_NAME</span>,
<span>configuration</span>: {
<span>baseURL</span>: process.<span>env</span>.<span>OPENAI_BASE_URL</span>,
},
<span>dimensions</span>: <span>VECTOR_DIM</span>,
});
<span>async</span> <span>function</span> <span>getEmbedding</span>(<span>text</span>) {
<span>return</span> embeddings.<span>embedQuery</span>(text);
}
<span>const</span> client = <span>new</span> <span>MilvusClient</span>({
<span>address</span>: process.<span>env</span>.<span>MILVUS_ADDRESS</span>,
});
<span>async</span> <span>function</span> <span>main</span>(<span></span>) {
<span>try</span> {
<span>console</span>.<span>log</span>(<span>"连接到 Milvus..."</span>);
<span>await</span> client.<span>connectPromise</span>;
<span>console</span>.<span>log</span>(<span>"连接成功"</span>);
<span>await</span> client.<span>createCollection</span>({
<span>collection_name</span>: <span>COLLECTION_NAME</span>,
<span>fields</span>: [
{
<span>name</span>: <span>"id"</span>,
<span>data_type</span>: <span>DataType</span>.<span>VarChar</span>,
<span>max_length</span>: <span>50</span>,
<span>is_primary_key</span>: <span>true</span>,
},
{
<span>name</span>: <span>"vector"</span>,
<span>data_type</span>: <span>DataType</span>.<span>FloatVector</span>,
<span>dim</span>: <span>VECTOR_DIM</span>,
},
{
<span>name</span>: <span>"content"</span>,
<span>data_type</span>: <span>DataType</span>.<span>VarChar</span>,
<span>max_length</span>: <span>5000</span>,
},
{
<span>name</span>: <span>"round"</span>,
<span>data_type</span>: <span>DataType</span>.<span>Int64</span>,
},
{
<span>name</span>: <span>"timestamp"</span>,
<span>data_type</span>: <span>DataType</span>.<span>VarChar</span>,
<span>max_length</span>: <span>100</span>,
},
],
});
<span>await</span> client.<span>createIndex</span>({
<span>collection_name</span>: <span>COLLECTION_NAME</span>,
<span>field_name</span>: <span>"vector"</span>,
<span>index_type</span>: <span>IndexType</span>.<span>IVF_FLAT</span>,
<span>metric_type</span>: <span>MetricType</span>.<span>COSINE</span>,
});
<span>const</span> conversations = [
{
<span>id</span>: <span>"conversation_1"</span>,
<span>content</span>: <span>"用户:你好,我最近正在学习 Milvus 向量数据库。"</span>,
<span>round</span>: <span>1</span>,
<span>timestamp</span>: <span>new</span> <span>Date</span>().<span>toISOString</span>(),
},
{
<span>id</span>: <span>"conversation_2"</span>,
<span>content</span>: <span>"AI:Milvus 是一个向量数据库,常用于 RAG、语义搜索和推荐系统。"</span>,
<span>round</span>: <span>1</span>,
<span>timestamp</span>: <span>new</span> <span>Date</span>().<span>toISOString</span>(),
},
{
<span>id</span>: <span>"conversation_3"</span>,
<span>content</span>: <span>"用户:向量数据库和 MySQL 有什么区别?"</span>,
<span>round</span>: <span>2</span>,
<span>timestamp</span>: <span>new</span> <span>Date</span>().<span>toISOString</span>(),
},
{
<span>id</span>: <span>"conversation_4"</span>,
<span>content</span>: <span>"AI:MySQL 更适合结构化数据和精确查询,而 Milvus 更擅长根据向量相似度进行语义检索。"</span>,
<span>round</span>: <span>2</span>,
<span>timestamp</span>: <span>new</span> <span>Date</span>().<span>toISOString</span>(),
},
{
<span>id</span>: <span>"conversation_5"</span>,
<span>content</span>: <span>"用户:那 RAG 为什么要使用向量数据库?"</span>,
<span>round</span>: <span>3</span>,
<span>timestamp</span>: <span>new</span> <span>Date</span>().<span>toISOString</span>(),
},
{
<span>id</span>: <span>"conversation_6"</span>,
<span>content</span>: <span>"AI:因为 RAG 需要从大量文本中找到和用户问题语义最相近的内容,再交给大模型生成回答。"</span>,
<span>round</span>: <span>3</span>,
<span>timestamp</span>: <span>new</span> <span>Date</span>().<span>toISOString</span>(),
},
];
<span>const</span> conversationData = <span>await</span> <span>Promise</span>.<span>all</span>(
conversations.<span>map</span>(<span>async</span> (item) => ({
...item,
<span>vector</span>: <span>await</span> <span>getEmbedding</span>(item.<span>content</span>),
}))
);
<span>await</span> client.<span>insert</span>({
<span>collection_name</span>: <span>COLLECTION_NAME</span>,
<span>data</span>: conversationData,
});
<span>console</span>.<span>log</span>(<span>"初始对话已经写入 Milvus"</span>);
} <span>catch</span> (error) {
<span>console</span>.<span>error</span>(<span>"错误:"</span>, error);
}
}
<span>main</span>();
初始化完成后,conversations 集合便拥有可供检索的长期对话。
七、根据当前问题检索相关历史
检索函数接收当前问题和返回数量 k:
<span>import</span> { <span>MetricType</span> } <span>from</span> <span>"@zilliz/milvus2-sdk-node"</span>;
<span>async</span> <span>function</span> <span>retrieveRelevantConversations</span>(<span>query, k = <span>2</span></span>) {
<span>try</span> {
<span>const</span> queryVector = <span>await</span> <span>getEmbedding</span>(query);
<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>metric_type</span>: <span>MetricType</span>.<span>COSINE</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> (error) {
<span>console</span>.<span>error</span>(<span>"检索对话时出错:"</span>, error.<span>message</span>);
<span>return</span> [];
}
}
它的执行过程可以拆成三步。
1. 把当前问题转换成向量
<span>const</span> queryVector = <span>await</span> <span>getEmbedding</span>(query);
历史正文和当前问题使用同一个 Embedding 配置,产生相同维度的向量。
2. 在同一个向量字段上执行搜索
<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>: <span>2</span>,
<span>metric_type</span>: <span>MetricType</span>.<span>COSINE</span>,
<span>output_fields</span>: [<span>"id"</span>, <span>"content"</span>, <span>"round"</span>, <span>"timestamp"</span>],
});
limit: 2 表示只返回最相关的两条记录。控制 k,也就控制了注入模型的长期历史数量。
3. 返回可构造上下文的字段
vector 已经完成了检索任务,交给聊天模型时真正需要的是可读字段,因此通过 output_fields 取回 ID、正文、轮次和时间。
发生异常时返回空数组:
<span>async</span> <span>function</span> <span>retrieveRelevantConversations</span>(<span>query, k = <span>2</span></span>) {
<span>try</span> {
<span>// 执行向量检索</span>
} <span>catch</span> (error) {
<span>console</span>.<span>error</span>(<span>"检索对话时出错:"</span>, error.<span>message</span>);
<span>return</span> [];
}
}
这样上层聊天逻辑仍然可以退化为只使用当前用户问题,而不是因为没有检索结果就无法继续调用模型。
八、把检索结果注入模型上下文
假设要连续演示三个问题:
<span>const</span> conversations = [
{ <span>input</span>: <span>"为什么要学习向量数据库"</span> },
{ <span>input</span>: <span>"MySQL 和 Milvus 的区别"</span> },
{ <span>input</span>: <span>"RAG 和哪个数据库强相关"</span> },
];
每一轮先执行检索:
<span>const</span> retrievedConversations = <span>await</span> <span>retrieveRelevantConversations</span>(input, <span>2</span>);
再把结果转换成一段结构化文本:
<span>let</span> relevantHistory = <span>""</span>;
<span>if</span> (retrievedConversations.<span>length</span> > <span>0</span>) {
relevantHistory = retrievedConversations
.<span>map</span>(<span>(<span>item, index</span>) =></span> <span>`
[历史对话 <span>${index + <span>1</span>}</span>]
轮次:<span>${item.round}</span>
<span>${item.content}</span>`</span>)
.<span>join</span>(<span>"\n\n----------\n\n"</span>);
} <span>else</span> {
<span>console</span>.<span>log</span>(<span>"未找到相关历史对话"</span>);
}
如果成功找到历史,最终消息会同时包含检索内容和当前问题:
<span>const</span> contextMessages = relevantHistory
? [
<span>new</span> <span>HumanMessage</span>(<span>`相关历史对话:
<span>${relevantHistory}</span>
用户问题:<span>${input}</span>`</span>),
]
: [userMessage];
<span>const</span> response = <span>await</span> model.<span>invoke</span>(contextMessages);
模型实际接收到的内容大致如下:
相关历史对话:
[历史对话 1]
轮次:2
用户:向量数据库和 MySQL 有什么区别?
----------
[历史对话 2]
轮次:2
AI:MySQL 更适合结构化数据和精确查询,而 Milvus 更擅长根据向量相似度进行语义检索。
用户问题:MySQL 和 Milvus 的区别
这里并不是要求聊天模型自己访问 Milvus。检索发生在调用模型之前,应用把结果整理成 HumanMessage,模型只负责阅读这段上下文并生成回答。
如果没有结果,contextMessages 就退化为原始的 userMessage:
[<span>new</span> <span>HumanMessage</span>(input)]
九、短期 history 与模型上下文不是同一个概念
演示中还维护了一个内存历史:
<span>const</span> history = <span>new</span> <span>InMemoryChatMessageHistory</span>();
<span>await</span> history.<span>addMessage</span>(userMessage);
<span>await</span> history.<span>addMessage</span>(response);
但调用模型使用的是:
<span>await</span> model.<span>invoke</span>(contextMessages);
而不是:
<span>await</span> model.<span>invoke</span>(<span>await</span> history.<span>getMessages</span>());
这意味着当前实现里,history 负责记录本次程序运行期间发生的问答,但不会自动参与下一轮模型输入。真正进入模型上下文的是“Milvus 检索结果 + 当前问题”。
这一点很重要:
addMessage() 只是保存消息
getMessages() 并放入 model.invoke() 才会影响模型本轮回答
因此,这个示例突出的是检索式长期记忆,而不是把本次运行的全部短期消息也带入每一轮。如果需要同时使用两者,就要在构造 contextMessages 时明确合并,而不是只调用 history.addMessage()。
十、把新问答写回长期记忆
模型回答后,先把本轮用户问题和 AI 回答组合成一段文本:
<span>const</span> conversationText = <span>`用户:<span>${input}</span>
AI:<span>${response.content}</span>`</span>;
再生成唯一 ID、向量与时间:
<span>const</span> conversationId = <span>`conv_<span>${<span>Date</span>.now()}</span>_<span>${index + <span>1</span>}</span>`</span>;
<span>const</span> conversationVector = <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>: conversationId,
<span>content</span>: conversationText,
<span>vector</span>: conversationVector,
<span>round</span>: index + <span>1</span>,
<span>timestamp</span>: <span>new</span> <span>Date</span>().<span>toISOString</span>(),
},
],
});
至此,一轮对话形成了闭环:
当前问题
↓ Embedding
查询 Milvus
↓
相关历史 + 当前问题
↓
聊天模型回答
↓
用户问题 + AI 回答
↓ Embedding
写回 Milvus
下一轮检索不仅能找到最初插入的种子对话,也可能找到刚刚生成的新对话。
种子数据按单条消息存储,新数据则把一问一答合并成一条记录。这两种粒度都能被当前 schema 接收,不过理解检索结果时要注意区别:前者的一条结果只代表用户或 AI 的单条消息,后者的一条结果代表完整一轮问答。
十一、完整的检索式 Memory 示例
下面把检索、生成和写回组合成一个可以直接理解的完整流程:
<span>import</span> <span>"dotenv/config"</span>;
<span>import</span> { <span>OpenAIEmbeddings</span>, <span>ChatOpenAI</span> } <span>from</span> <span>"@langchain/openai"</span>;
<span>import</span> { <span>InMemoryChatMessageHistory</span> } <span>from</span> <span>"@langchain/core/chat_history"</span>;
<span>import</span> { <span>HumanMessage</span> } <span>from</span> <span>"@langchain/core/messages"</span>;
<span>import</span> {
<span>MilvusClient</span>,
<span>MetricType</span>,
} <span>from</span> <span>"@zilliz/milvus2-sdk-node"</span>;
<span>const</span> <span>COLLECTION_NAME</span> = <span>"conversations"</span>;
<span>const</span> <span>VECTOR_DIM</span> = <span>1024</span>;
<span>const</span> embeddings = <span>new</span> <span>OpenAIEmbeddings</span>({
<span>apiKey</span>: process.<span>env</span>.<span>OPENAI_API_KEY</span>,
<span>model</span>: process.<span>env</span>.<span>EMBEDDINGS_MODEL_NAME</span>,
<span>configuration</span>: {
<span>baseURL</span>: process.<span>env</span>.<span>OPENAI_BASE_URL</span>,
},
<span>dimensions</span>: <span>VECTOR_DIM</span>,
});
<span>async</span> <span>function</span> <span>getEmbedding</span>(<span>text</span>) {
<span>return</span> embeddings.<span>embedQuery</span>(text);
}
<span>const</span> model = <span>new</span> <span>ChatOpenAI</span>({
<span>modelName</span>: process.<span>env</span>.<span>MODEL_NAME</span>,
<span>apiKey</span>: process.<span>env</span>.<span>OPENAI_API_KEY</span>,
<span>temperature</span>: <span>0</span>,
<span>configuration</span>: {
<span>baseURL</span>: process.<span>env</span>.<span>OPENAI_BASE_URL</span>,
},
});
<span>const</span> client = <span>new</span> <span>MilvusClient</span>({
<span>address</span>: process.<span>env</span>.<span>MILVUS_ADDRESS</span>,
});
<span>async</span> <span>function</span> <span>retrieveRelevantConversations</span>(<span>query, k = <span>2</span></span>) {
<span>try</span> {
<span>const</span> queryVector = <span>await</span> <span>getEmbedding</span>(query);
<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>metric_type</span>: <span>MetricType</span>.<span>COSINE</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> (error) {
<span>console</span>.<span>error</span>(<span>"检索对话时出错:"</span>, error.<span>message</span>);
<span>return</span> [];
}
}
<span>async</span> <span>function</span> <span>retrievalMemoryDemo</span>(<span></span>) {
<span>try</span> {
<span>console</span>.<span>log</span>(<span>"连接到 Milvus..."</span>);
<span>await</span> client.<span>connectPromise</span>;
<span>console</span>.<span>log</span>(<span>"连接成功\n"</span>);
} <span>catch</span> (error) {
<span>console</span>.<span>error</span>(<span>"无法连接到 Milvus:"</span>, error.<span>message</span>);
<span>return</span>;
}
<span>const</span> history = <span>new</span> <span>InMemoryChatMessageHistory</span>();
<span>const</span> conversations = [
{ <span>input</span>: <span>"为什么要学习向量数据库"</span> },
{ <span>input</span>: <span>"MySQL 和 Milvus 的区别"</span> },
{ <span>input</span>: <span>"RAG 和哪个数据库强相关"</span> },
];
<span>for</span> (<span>let</span> index = <span>0</span>; index < conversations.<span>length</span>; index++) {
<span>const</span> { input } = conversations[index];
<span>const</span> userMessage = <span>new</span> <span>HumanMessage</span>(input);
<span>console</span>.<span>log</span>(<span>`第 <span>${index + <span>1</span>}</span> 轮对话,用户:<span>${input}</span>`</span>);
<span>console</span>.<span>log</span>(<span>"[检索相关历史对话]"</span>);
<span>const</span> retrievedConversations =
<span>await</span> <span>retrieveRelevantConversations</span>(input, <span>2</span>);
<span>let</span> relevantHistory = <span>""</span>;
<span>if</span> (retrievedConversations.<span>length</span> > <span>0</span>) {
relevantHistory = retrievedConversations
.<span>map</span>(<span>(<span>item, resultIndex</span>) =></span> <span>`
[历史对话 <span>${resultIndex + <span>1</span>}</span>]
轮次:<span>${item.round}</span>
<span>${item.content}</span>`</span>)
.<span>join</span>(<span>"\n\n----------\n\n"</span>);
} <span>else</span> {
<span>console</span>.<span>log</span>(<span>"未找到相关历史对话"</span>);
}
<span>const</span> contextMessages = relevantHistory
? [
<span>new</span> <span>HumanMessage</span>(<span>`相关历史对话:
<span>${relevantHistory}</span>
用户问题:<span>${input}</span>`</span>),
]
: [userMessage];
<span>const</span> response = <span>await</span> model.<span>invoke</span>(contextMessages);
<span>console</span>.<span>log</span>(response.<span>content</span>);
<span>await</span> history.<span>addMessage</span>(userMessage);
<span>await</span> history.<span>addMessage</span>(response);
<span>const</span> conversationText = <span>`用户:<span>${input}</span>
AI:<span>${response.content}</span>`</span>;
<span>const</span> conversationId = <span>`conv_<span>${<span>Date</span>.now()}</span>_<span>${index + <span>1</span>}</span>`</span>;
<span>const</span> conversationVector = <span>await</span> <span>getEmbedding</span>(conversationText);
<span>try</span> {
<span>await</span> client.<span>insert</span>({
<span>collection_name</span>: <span>COLLECTION_NAME</span>,
<span>data</span>: [
{
<span>id</span>: conversationId,
<span>content</span>: conversationText,
<span>vector</span>: conversationVector,
<span>round</span>: index + <span>1</span>,
<span>timestamp</span>: <span>new</span> <span>Date</span>().<span>toISOString</span>(),
},
],
});
} <span>catch</span> (error) {
<span>console</span>.<span>error</span>(<span>"插入失败:"</span>, error);
}
}
}
<span>retrievalMemoryDemo</span>().<span>catch</span>(<span>console</span>.<span>error</span>);
运行顺序是:先单独执行初始化,确保集合、索引和种子对话已经存在;然后再执行检索式聊天流程。
十二、三种上下文管理方式放在一起比较
到这里,已经得到三种不同的 Memory 管理策略:
| 策略 | 选择历史的依据 | 进入上下文的内容 | 主要特点 |
|---|---|---|---|
| 截断 | 时间顺序 | 最近若干消息 | 简单直接,旧信息被移除 |
| 总结 | 时间顺序 + 模型压缩 | 旧历史摘要 + 最近原文 | 能保留旧信息要点,但细节被压缩 |
| 检索 | 当前问题与历史的向量相似度 | 最相关的 K 条历史 | 能找回较早且相关的对话 |
它们回答的是三个不同问题:
截断:最近说了什么?
总结:过去总体说了什么?
检索:过去哪些内容与当前问题最相关?
因此,完整的 Memory 模块可以同时拥有这些能力,而不是只能三选一。例如:
当前会话保留最近消息
+
较早会话定期生成摘要
+
长期内容写入 Milvus
+
每轮按问题检索相关历史
一个自然的演进方向是:例如每积累 20 条对话就触发一次总结,把摘要或对话写入 Milvus;后续再从 Milvus 中取回相关历史,与近期消息共同构成上下文。这样 Memory 不只是一个不断增长的 messages 数组,而会成为 Agent Harness 中独立的存储与管理模块。
十三、实现时最容易混淆的几个点
1. 保存过,不等于模型看见了
无论消息保存在内存、文件还是 Milvus 中,都必须在调用前读取并放进 model.invoke() 的消息数组,才会影响当前回答。
2. Embedding 维度必须前后一致
示例明确使用 1024:
<span>dimensions</span>: <span>1024</span>
集合字段也必须是:
<span>dim</span>: <span>1024</span>
否则生成的向量不能按当前 schema 正常写入或搜索。
3. 建索引和搜索使用同一种度量
创建索引和搜索都使用 MetricType.COSINE。不要只改其中一处而让两边的配置表达不同的相似度标准。
4. content 才是最终注入聊天模型的内容
向量用于定位记录,聊天模型读取的是 content。所以记录中既要有机器检索用的 vector,也要有模型可读的正文。
5. 初始化和聊天循环职责不同
集合、字段和索引负责准备存储结构;聊天循环负责检索与追加数据。将两者分开,可以避免每次聊天都重复创建同名集合。
总结
从内存消息数组一路走到 Milvus,可以看到 Agent Memory 的重点从来不只是“把聊天记录存下来”,而是同时解决两个问题:
- 存储问题:历史放在内存、文件还是数据库;
- 管理问题:当前这一轮究竟选择哪些历史交给模型。
短期对话可以使用 InMemoryChatMessageHistory,跨进程恢复可以使用文件历史;上下文变长后,可以按消息数或 Token 数截断,也可以把旧对话总结成摘要;当长期历史更多时,则可以用 Embedding 与 Milvus 按语义取回相关记录。
最终形成的检索式 Memory 闭环是:
对话产生 → 向量化 → 持久化
↑ ↓
模型回答 ← 注入上下文 ← 相似度检索
到这一步,模型依然是无状态的,但 Agent 已经能够通过 Harness 中的 Memory 模块保存历史、控制上下文,并在需要时找回相关信息。
从存下来到找回来,文章把检索式 Memory 的工程闭环讲透了,并厘清短期 history 与长期语义记忆的边界,适合正为 Agent 上下文膨胀发愁、准备引入向量数据库的开发者参考。