LangChain.js + Milvus 向量长期记忆:对话写入、语义检索与 RAG 增强
- 前言
- [1. 先建立知识地图:向量长期记忆究竟在做什么](#1. 先建立知识地图:向量长期记忆究竟在做什么)
-
- [1.1 短期 History 与向量长期记忆各自解决什么问题](#1.1 短期 History 与向量长期记忆各自解决什么问题)
- [1.2 Embedding、向量与余弦相似度](#1.2 Embedding、向量与余弦相似度)
- [1.3 Milvus 中的 Collection、Index 与 Load](#1.3 Milvus 中的 Collection、Index 与 Load)
- [2. 项目准备与完整数据流](#2. 项目准备与完整数据流)
-
- [2.1 初始化、依赖与配置边界](#2.1 初始化、依赖与配置边界)
- [2.2 一次完整问答如何流动](#2.2 一次完整问答如何流动)
- [3. 第一段核心代码:把历史对话写入 Milvus](#3. 第一段核心代码:把历史对话写入 Milvus)
-
- [3.1 连接客户端与定义 Collection Schema](#3.1 连接客户端与定义 Collection Schema)
- [3.2 对话文本如何变成可插入向量](#3.2 对话文本如何变成可插入向量)
- [3.3 写入阶段最容易踩中的三处边界](#3.3 写入阶段最容易踩中的三处边界)
- [4. 第二段核心代码:查询向量、召回历史并增强模型输入](#4. 第二段核心代码:查询向量、召回历史并增强模型输入)
-
- [4.1 `retrieveRelevantConversations()`:把自然语言问题变成 Top-K 记忆](#4.1
retrieveRelevantConversations():把自然语言问题变成 Top-K 记忆) - [4.2 把命中结果转成模型能使用的参考上下文](#4.2 把命中结果转成模型能使用的参考上下文)
- [4.3 生成后回写:让新对话成为下一次可召回的记忆](#4.3 生成后回写:让新对话成为下一次可召回的记忆)
- [4.1 `retrieveRelevantConversations()`:把自然语言问题变成 Top-K 记忆](#4.1
- [5. 把两段代码串成一个完整的混合 Memory 闭环](#5. 把两段代码串成一个完整的混合 Memory 闭环)
-
- [5.1 从用户问题到新记忆写回的完整实现](#5.1 从用户问题到新记忆写回的完整实现)
- [6. 效果评估与工程取舍](#6. 效果评估与工程取舍)
-
- [6.1 影响召回质量的关键不是只有模型](#6.1 影响召回质量的关键不是只有模型)
- [6.2 长期记忆的边界与安全要求](#6.2 长期记忆的边界与安全要求)
- 总结
前言
短期 History 能让模型记住刚刚发生的几轮对话,却很难承担跨天、跨会话乃至海量用户信息的记忆任务。把所有消息原样放进提示词,会同时碰到上下文窗口、调用成本和注意力稀释三个瓶颈;只用关键词检索又无法理解"我周末进场做什么"与"我经常去电影院"之间的语义关联。
向量长期记忆为这个问题提供了另一条路径:把每段对话转换为向量写入 Milvus;下一次提问时,将问题也转换为向量,检索语义最接近的历史片段,再把它们作为参考上下文交给大模型。本文先建立必要的知识框架,再分块拆解"写入历史对话"和"检索增强对话"两段核心代码,最后将它们串成一个可复用的 Memory 闭环。
1. 先建立知识地图:向量长期记忆究竟在做什么
1.1 短期 History 与向量长期记忆各自解决什么问题
InMemoryChatMessageHistory 保存的是按时间排列的连续对话 ,适合回答"刚才那句话里的它指什么"。但它通常只保留当前进程中的消息,而且历史越长,输入模型的 Token 越多。向量数据库保存的则是可按语义检索的记忆片段,适合从大量旧记录中找出"和当前问题最相关的少数内容"。
二者不应互相替代,而应组合:短期 History 保证当前会话的连贯性,向量库负责跨会话和长时间跨度的召回。检索出来的记录不是自动被模型记住的事实,仍必须被放入本次 model.invoke() 的消息数组中。
| 维度 | 短期 History | 向量长期记忆 |
|---|---|---|
| 组织方式 | 按时间顺序保存 Message | 按向量相似度检索片段 |
| 擅长回答 | 最近几轮的指代、追问 | 很久以前的偏好、经历、事实 |
| 规模 | 受上下文窗口直接限制 | 可保存大量记录,只取 Top-K |
| 主要风险 | Token 膨胀、旧信息被截断 | 召回不准、把无关内容带入提示词 |
| 最佳实践 | 保留最近的原始问答 | 检索相关旧记忆后补充到上下文 |
向量长期记忆不是"把所有对话永久塞给模型",而是"在需要时,从大量历史中召回少量语义相关的证据"。
1.2 Embedding、向量与余弦相似度
Embedding(嵌入)模型会把一段文本映射为固定长度的浮点数数组,例如 [0.12, -0.08, ...]。数组中的每一维没有可单独解释的自然语言含义,但整条向量在高维空间中的位置编码了文本语义。意思相近的句子通常距离更近,因此"周末去哪放松"和"我经常去电影院"即使没有共享关键词,也可能被检索到一起。
本例使用 OpenAIEmbeddings:写入阶段将 content 转成文档向量,查询阶段使用同一模型把用户问题转成查询向量。集合的 FloatVector 维度必须与 Embedding 返回数组长度完全相同 ;若 Milvus 声明为 1024 维,实际写入 1536 维或其他长度的向量会失败。
余弦相似度关注两个向量的夹角而不是绝对长度,常用表达式如下:
text
cosine(a, b) = (a · b) / (||a|| × ||b||)
当 MetricType.COSINE 用于检索时,分数通常越高,方向越相近,语义也越相关;它不是"回答正确率",更不是概率。检索代码中的 k = 2 只表示最多取两条,并不保证这两条一定足够相关,因此生产代码还需要根据真实数据观察并校准分数阈值。
| 度量方式 | 关注点 | 分数/距离的典型判断 | 本例适配性 |
|---|---|---|---|
COSINE |
向量方向是否一致 | 通常越大越相似 | 适合通用文本语义检索 |
IP(内积) |
向量点积 | 通常越大越相似 | 依赖向量构造与归一化策略 |
L2 |
欧氏距离 | 越小越接近 | 更适合以距离为目标的场景 |
1.3 Milvus 中的 Collection、Index 与 Load
Milvus 的 Collection 可以理解为面向向量的"表",但它不仅存标量字段,还存储用于相似度搜索的向量字段。本例的 conversations 集合包含主键 id、向量 vector、原始文本 content、轮次 round 和时间 timestamp。检索命中后,只有向量不足以构造提示词,所以必须通过 output_fields 一并取回 content 等标量字段。
写入向量之后,Milvus 需要索引来高效搜索。示例选择 IndexType.IVF_FLAT,它先把向量划分到若干簇,检索时优先扫描接近的簇;FLAT 表示簇内采用精确比较。创建索引后还需要 loadCollection(),将集合加载到可检索状态。这个"建集合 → 建索引 → 加载集合"的生命周期,是向量检索区别于普通数组比较的重要部分。
2. 项目准备与完整数据流
2.1 初始化、依赖与配置边界
先初始化 Node.js 项目,并安装向量数据库、环境变量、LangChain 核心抽象和模型适配器。
bash
npm init -y
# Milvus 客户端与环境变量加载能力
pnpm i @zilliz/milvus2-sdk-node dotenv
# Message、History、Embedding 与聊天模型能力
pnpm i @langchain/core @langchain/openai
| 依赖 | 在项目中的职责 |
|---|---|
dotenv |
通过 import 'dotenv/config' 加载密钥、模型名和服务地址 |
@langchain/openai |
提供 OpenAIEmbeddings 与 ChatOpenAI 两个模型适配器 |
@langchain/core |
提供 HumanMessage、SystemMessage 与短期 History 抽象 |
@zilliz/milvus2-sdk-node |
连接 Milvus、创建集合、插入向量并执行相似度搜索 |
Milvus 服务需要先运行并能通过 localhost:19530 访问。.env 只保存配置名和值,不应提交真实密钥;尤其不能把密钥写进代码或文章。
dotenv
MODEL_NAME=你的聊天模型名
EMBEDDING_MODEL_NAME=text-embedding-v3
OPENAI_API_KEY=你的密钥
OPENAI_BASE_URL=https://你的兼容接口地址/v1
这里有一个容易忽略的版本细节:当前 @langchain/openai 使用的参数名是 dimensions ,而不是 dimension。若服务支持自定义输出维度,应让它和 VECTOR_DIM 一致;若服务不支持自定义维度,则应以模型的原生输出维度设置 VECTOR_DIM。否则创建集合虽然成功,插入向量时仍会因长度不匹配报错。
javascript
import 'dotenv/config';
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,
// 字段为 dimensions;其值必须与 Milvus FloatVector 的 dim 相同
dimensions: VECTOR_DIM,
configuration: { baseURL: process.env.OPENAI_BASE_URL },
});
2.2 一次完整问答如何流动
写入脚本先准备历史种子数据,检索脚本再处理新的用户问题。把两段代码串起来,完整数据流如下:
text
历史问答 content
↓ OpenAIEmbeddings.embedDocuments()
历史向量 + id/content/round/timestamp
↓ Milvus insert
conversations Collection
↓ 当前问题 → embedQuery()
查询向量
↓ Milvus COSINE Top-K search
相关历史文本 + score
↓ 组装 SystemMessage / HumanMessage
ChatOpenAI.invoke()
↓
模型回复 + 当前问题重新打包并向量化
↓
写回 Milvus,成为下一轮可召回的长期记忆
这个流程也是一种轻量的 RAG(Retrieval-Augmented Generation,检索增强生成):检索负责找证据,生成模型负责基于当前问题和证据组织回答。它不是模型微调,写入新记忆不会改变模型参数,只会改变下一次请求时提供的上下文。
3. 第一段核心代码:把历史对话写入 Milvus
3.1 连接客户端与定义 Collection Schema
insert-conversation.mjs 的第一项任务是定义"每条长期记忆长什么样"。id 使用 VarChar 主键,避免重复记录互相覆盖;vector 是检索真正比较的 FloatVector;content 保留可读文本;round 与 timestamp 让应用在召回后仍可显示来源与时间。
javascript
import {
MilvusClient,
DataType,
MetricType,
IndexType,
} from '@zilliz/milvus2-sdk-node';
const COLLECTION_NAME = 'conversations';
const VECTOR_DIM = 1024;
const client = new MilvusClient({ address: 'localhost:19530' });
async function ensureCollection() {
await client.connectPromise; // 等待 gRPC 连接真正建立
const { value: exists } = await client.hasCollection({
collection_name: COLLECTION_NAME,
});
if (!exists) {
await client.createCollection({
collection_name: COLLECTION_NAME,
fields: [
// 主键由应用生成;VarChar 必须指定最大字符长度
{ name: 'id', data_type: DataType.VarChar, max_length: 64, is_primary_key: true },
// dim 是 Milvus Schema 字段,不是 Embedding 配置字段
{ 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 },
],
});
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 });
}
原始演示直接调用 createCollection(),第一次运行很直观,但第二次运行时集合已经存在,初始化会抛出异常。hasCollection() 使初始化变成幂等操作:执行多次,目标状态仍是"集合、索引和加载状态准备完毕"。同样不应使用空的 catch 块吞掉建库异常,否则后面的插入或查询失败时很难定位真正原因。
字段设计的关键不只是"能存进去",更是"检索后能解释"。只存 vector 无法让模型理解历史内容;只存 content 又无法做语义搜索。因此每条记录同时拥有向量索引字段 与可回读的原始文本/元数据字段。
| 字段 | 数据类型 | 作用 | 设计原因 |
|---|---|---|---|
id |
VarChar |
唯一标识一段记忆 | 支持去重、追踪与更新 |
vector |
FloatVector |
语义相似度搜索 | 长度必须等于 VECTOR_DIM |
content |
VarChar |
命中后放入 Prompt | 让检索结果可读、可用 |
round |
Int64 |
标记记录轮次 | 便于调试、展示和排序 |
timestamp |
VarChar |
标记写入时间 | 用 ISO 字符串可避免时区歧义 |
3.2 对话文本如何变成可插入向量
演示数据把用户问题和助手回答合成一条 content。这是一个合理的最小粒度:检索到"用户喜欢篮球和电影"时,也同时带回了助手对该偏好的回应。更复杂的应用可按整轮、主题摘要或用户事实切块,但一条记忆不宜混入完全无关的话题,否则向量语义会被稀释。
写入多条文本时,embedDocuments() 比逐条调用 embedQuery() 更符合 API 语义,也能够批量请求。查询文本使用 embedQuery();在当前实现中两者都会请求同一 Embedding 模型,但区分二者可让"文档入库"和"用户查询"意图更加清晰。
javascript
async function insertConversations(conversations) {
// 批量生成向量:第 i 个 vector 对应第 i 条 content
const vectors = await embeddings.embedDocuments(
conversations.map((conversation) => conversation.content)
);
const data = conversations.map((conversation, index) => ({
...conversation,
vector: vectors[index], // 保持文本、元数据与向量一一对应
}));
await client.insert({
collection_name: COLLECTION_NAME,
data,
});
// Demo 中等待持久化完成,方便随后立刻进行稳定检索
await client.flushSync({ collection_names: [COLLECTION_NAME] });
}
const seedConversations = [
{
id: 'conv_001',
content: '用户:我叫赵六,是一名数据科学家\n助手:很高兴认识你,赵六!',
round: 1,
timestamp: new Date().toISOString(),
},
{
id: 'conv_002',
content: '用户:我最近在研究机器学习算法\n助手:你在研究哪些算法呢?',
round: 2,
timestamp: new Date().toISOString(),
},
];
这里的 Promise.all(conversations.map(...)) 也能并发逐条生成向量,适合几条演示数据;批量 embedDocuments() 对大量数据通常更节省网络往返。无论采用哪种方式,都必须保证数组位置没有错位,否则可能把 A 文本的向量写到 B 文本上,检索结果看似有数据,语义却会完全失真。
3.3 写入阶段最容易踩中的三处边界
- 维度字段的名字不同。 Milvus Schema 使用
dim,LangChain 的OpenAIEmbeddings使用dimensions;前者定义数据库字段,后者请求模型输出,两个值必须一致。 - 索引与查询指标必须一致。 集合以
COSINE创建索引,搜索时也要传MetricType.COSINE,否则结果的排序语义不可靠。 - 主键不能靠时间戳"碰运气"。
Date.now()加轮次适合演示,但分布式或高并发写入更适合 UUID、雪花 ID 等专门的唯一标识策略。
4. 第二段核心代码:查询向量、召回历史并增强模型输入
4.1 retrieveRelevantConversations():把自然语言问题变成 Top-K 记忆
检索函数的输入是用户问题 query,输出是 Milvus 命中的记录。它先调用 embedQuery(query) 得到查询向量,再执行 client.search()。其中 limit 即 Top-K 数量,output_fields 是希望随命中一起返回的字段;返回结果中的 score 来自向量相似度计算,content 才是后续构造上下文的材料。
javascript
import { MetricType } from '@zilliz/milvus2-sdk-node';
async function retrieveRelevantConversations(query, k = 2) {
try {
const queryVector = await embeddings.embedQuery(query);
const { results } = await client.search({
collection_name: COLLECTION_NAME,
vector: queryVector,
limit: k, // 只取语义最接近的前 k 条,避免 Prompt 被历史淹没
metric_type: MetricType.COSINE,
output_fields: ['id', 'content', 'round', 'timestamp'],
});
// Top-K 不等于"必然相关";阈值要结合真实数据集校准
const MIN_COSINE_SCORE = 0.55;
return results.filter((item) => item.score >= MIN_COSINE_SCORE);
} catch (error) {
console.error('检索对话时出错:', error.message);
return [];
}
}
原始检索代码会无条件返回 Top-2,这能清楚展示 API,但也意味着无论问题多么陌生,最不相关的两条记忆仍可能被塞进模型。上例增加了分数过滤,不过 0.55 只是示意值,不能机械照搬:不同 Embedding 模型、文本长度、切分方式和语料分布都会改变分数范围。正确做法是记录命中的 score,用真实问答集评估"相关/不相关"的分布后再设阈值。
score 的另一个常见误解是把它当作答案可信度。它只衡量"问题向量与记忆向量"的接近程度;即使分数很高,记忆本身也可能过期或包含错误信息。因此它适合作为召回过滤信号,而不是事实真伪证明。
4.2 把命中结果转成模型能使用的参考上下文
Milvus 返回的是对象数组,模型需要的是 Message 数组或文本提示。map() 负责把每条命中记录格式化,join() 用分隔线连接,保留 round 和 timestamp 能让模型或日志知道记忆来源。若没有命中,则不应伪造历史,而应只发送当前问题。
javascript
import { HumanMessage, SystemMessage } from '@langchain/core/messages';
function buildMessages(input, memories, recentMessages = []) {
const memoryText = memories
.map(
(memory, index) => `[相关历史 ${index + 1}]
轮次:${memory.round}
时间:${memory.timestamp}
内容:${memory.content}`
)
.join('\n\n---\n\n');
return [
new SystemMessage(
'你是严谨的助手。历史记忆仅作参考:只在确实相关时使用;' +
'若记忆不足以支持结论,请明确说明,不要把记忆中的内容改写成用户刚刚说过的话。'
),
// 检索记忆以"参考材料"形式提供,避免伪装成当前用户的最新表达
...(memoryText
? [new HumanMessage(`参考历史记忆:\n${memoryText}`)]
: []),
// 短期 History 保留最近问答的时间顺序,补足当前会话中的指代关系
...recentMessages,
new HumanMessage(`当前用户问题:${input}`),
];
}
原始示例把"相关历史对话"和"用户问题"合并到一条 HumanMessage 中,能够正常工作。加入 SystemMessage 后,模型多了一层稳定约束:检索记录只是参考,而不是必须引用的指令。尤其当长期记忆来自用户可编辑内容或外部文档时,这种"数据与指令分离"的提示词结构能降低无关文本干扰回答的风险。
4.3 生成后回写:让新对话成为下一次可召回的记忆
检索脚本在得到回复后做了两类保存:history.addMessage() 保存当前进程内的短期消息,client.insert() 把"本轮用户问题 + 助手回答"作为长期记忆写入 Milvus。前者让同一次会话保持连续,后者让未来会话可按语义找回本轮内容。
javascript
import { HumanMessage } from '@langchain/core/messages';
async function saveConversation({ history, input, response, round }) {
// 短期 Memory:服务当前会话,按时间保留原始 Message
await history.addMessage(new HumanMessage(input));
await history.addMessage(response);
// 长期 Memory:把一轮问答打包为可语义检索的文本块
const content = `用户:${input}\n助手:${response.content}`;
const [vector] = await embeddings.embedDocuments([content]);
await client.insert({
collection_name: COLLECTION_NAME,
data: [{
id: `conv_${Date.now()}_${round}`,
content,
vector,
round,
timestamp: new Date().toISOString(),
}],
});
// 教学演示等待本次写入完成,保证紧接着的检索可以观察到新记忆
await client.flushSync({ collection_names: [COLLECTION_NAME] });
}
注意当前短期 History 在原始示例中只负责保存,并没有直接参与 model.invoke();本轮模型上下文主要来自向量检索结果。若要形成真正的混合记忆,应把"最近短期消息"与"检索到的长期记忆"同时加入 Prompt:前者解决当前对话的连续性,后者补足被截断或跨会话的历史事实。
5. 把两段代码串成一个完整的混合 Memory 闭环
5.1 从用户问题到新记忆写回的完整实现
下面的函数刻意保持职责简单:初始化阶段只做一次 ensureCollection();每次聊天调用 chatWithMemory() 时,先查长期记忆,再将"系统规则 + 召回记忆 + 最近短期 History + 当前问题"交给模型,最后同步写入短期 History 和 Milvus。代码把 model.invoke() 放在写回之前,避免把尚未产生的回复误当成历史证据。
javascript
import { ChatOpenAI } from '@langchain/openai';
import { InMemoryChatMessageHistory } from '@langchain/core/chat_history';
const model = new ChatOpenAI({
modelName: process.env.MODEL_NAME,
apiKey: process.env.OPENAI_API_KEY,
temperature: 0,
configuration: { baseURL: process.env.OPENAI_BASE_URL },
});
const history = new InMemoryChatMessageHistory();
async function chatWithMemory(input, round) {
// 1. 将当前问题转成查询向量,从大量旧记录中只召回少数相关片段
const memories = await retrieveRelevantConversations(input, 2);
// 2. 检索结果不是答案;与最近短期 History 一起成为模型的参考上下文
const recentMessages = (await history.getMessages()).slice(-4);
const messages = buildMessages(input, memories, recentMessages);
const response = await model.invoke(messages);
// 3. 回答完成后,同时更新短期顺序记忆和长期向量记忆
await saveConversation({ history, input, response, round });
return response.content;
}
async function main() {
await ensureCollection(); // 建表、索引、加载只应在启动阶段处理
const questions = [
'我之前提到的机器学习项目进展如何?',
'我周末经常做什么?',
'我的职业是什么?',
];
for (const [index, input] of questions.entries()) {
const answer = await chatWithMemory(input, index + 1);
console.log(`用户:${input}\n助手:${answer}\n`);
}
}
main().catch((error) => {
console.error('长期记忆流程执行失败:', error);
});
完整调用的因果顺序可以再复盘一次:用户输入先变成查询向量,Milvus 返回带 content 和 score 的候选历史;应用只保留通过阈值的结果,并与最近问答一起格式化成参考上下文;聊天模型据此回答;最后再将这轮问答向量化写回。正是最后一步让系统从"只会查固定种子数据"变成"每次交互都会积累可检索经验"的长期记忆系统。示例中的 flushSync() 用于让教学演示立刻观察到新记录;高吞吐服务不宜每轮同步刷盘,应按一致性要求批量或异步处理。
6. 效果评估与工程取舍
6.1 影响召回质量的关键不是只有模型
Embedding 模型很重要,但实际效果往往先被数据组织方式决定。一整天对话塞入一条记录会让主题混杂;一条记录只剩几个词又会失去必要上下文。以"一轮完整问答"或"一个明确用户事实"为基本单元通常更容易检索。对于超长对话,可先按主题切块或摘要,再写入向量库。
| 问题 | 可能原因 | 对策 |
|---|---|---|
| 明明存过却检索不到 | 文本切块过粗、Embedding 不一致、分数阈值过高 | 统一模型与维度,优化切块,记录并分析 score |
| 总检索到无关内容 | k 太大、没有阈值、内容主题混杂 |
限制 Top-K,增加阈值,按话题拆分记忆 |
| 新写入内容暂时查不到 | 集合未加载、写入尚未持久化或一致性设置不同 | 启动时 loadCollection(),测试时使用 flushSync() 验证 |
| 模型把旧信息当作当前指令 | 直接拼接文本,缺少来源和边界 | 使用 SystemMessage 声明"历史仅作参考" |
6.2 长期记忆的边界与安全要求
向量检索会放大数据保留问题:用户身份、偏好和聊天内容一旦入库,必须具备按用户隔离、删除、更新和过期清理能力。实际集合应至少附加 user_id,并在检索时使用过滤表达式限制到当前用户;不能仅依赖相似度搜索。对姓名、联系方式、账号等敏感信息,还要遵守最小化保存原则,并提供用户可撤回的机制。
检索增强也不是事实校验。向量相似度只能告诉系统"哪段历史可能相关",不能证明它仍然有效。涉及价格、时间、权限或高风险决策时,应结合结构化数据源、时间字段和业务规则,而不是让模型仅依据旧对话做最终判断。
总结
向量长期记忆的核心是将"保存"与"使用"拆开:写入阶段用同一 Embedding 模型把完整对话片段、元数据和向量一起存入 Milvus;查询阶段把当前问题向量化,按 COSINE 搜索并取回可读的 content;生成阶段再把经过筛选的历史作为参考上下文交给聊天模型。VECTOR_DIM 与 dimensions 的一致性、集合索引和加载、Top-K 的分数过滤,以及短期 History 与长期检索的职责边界,决定了系统是否能稳定工作。将新问答再次回写后,应用便形成了"检索旧记忆---辅助当前回答---沉淀新记忆"的闭环,同时也必须通过用户隔离、可删除性与事实校验控制长期记忆的风险。