混合记忆架构设计
要点
- 在第四篇文章中,我们讲了向量化与语义检索的完整流程
- 在设计混合检索之前,我们先把「记忆」分个类
- 用户的一条消息进来后,系统不是只走一条检索路径,而是同时发起多条检索,最后将结果融合
- 四条通道各有所长,覆盖不同类型的查询需求
- 四条通道的结果汇总后,需要解决三个问题:去重、排序、截断
内容
1. 纯向量检索的局限
在第四篇文章中,我们讲了向量化与语义检索的完整流程。那套方案在大多数场景下工作得不错,但如果你真的用它上线一个 AI 伴侣产品,很快会遇到几类搞不定的问题。
场景一:精确事实查询
用户说「我的生日是几月几号?」。向量检索可能会召回「用户生日当天去了 Blue Note 爵士酒吧庆祝」——这条语义上和「生日」相关,但它回答不了「几月几号」这个精确问题。真正的答案应该从结构化的用户画像表中直接查出来:birthday: "03-14"。
场景二:时间范围查询
用户说「上周我们聊了什么?」。向量检索只关心语义相似度,它没有「上周」的概念。你可以用元数据过滤(我们在第四篇提过),但如果「上周」涉及的对话有 200 条,向量检索返回的 Top-5 远远不够覆盖。
场景三:全文关键词查询
用户说「我之前提到过一本叫《三体》的书」。「三体」是一个专有名词,Embedding 模型在处理专有名词时经常「翻车」——它可能把「三体」映射到「物理学」或「科幻」的方向,而不是精确匹配这两个字。传统的关键词搜索反而能精准命中。
这三类场景有一个共同特征:它们需要的不是「意思相近」,而是「精确匹配」或「结构化查询」。单靠向量检索无法覆盖。
这就引出了本篇的核心主题:混合检索架构——将向量检索、关键词检索和结构化查询组合在一起,让记忆层能应对所有类型的查询。
2. 记忆的分类与存储策略
在设计混合检索之前,我们先把「记忆」分个类。不同类型的记忆,适合存储在不同的介质中,也需要用不同的方式检索。
| 记忆类型 | 示例 | 存储介质 | 检索方式 |
|---|---|---|---|
| 事实(Fact) | 生日 03-14、喜欢巧克力蛋糕 | D1 关系型数据库 | 结构化查询 |
| 事件(Event) | 去 Blue Note 听爵士、第一次吵架 | Vectorize + D1 | 语义 + 关键词 |
| 偏好(Preference) | 讨厌被忽视、喜欢被夸聪明 | D1 + Vectorize | 结构化 + 语义 |
| 对话摘要(Summary) | "上周主要聊了工作压力和追剧" | D1 关系型数据库 | 时间范围查询 |
| 情绪快照(Mood) | 昨天 23:00 心情指数 35/100 | CloudFlare KV | 键值查询 |
| 短期上下文(Context) | 最近 10 轮对话 | CloudFlare KV | 直接读取 |
Drawing canvas 三种存储介质的选型依据是访问模式而非数据量:
CloudFlare KV(热数据层):适合"每次请求都要读"的数据。最近 N 轮对话、当前情绪状态、亲密度数值这类高频读取的小体量数据放在这里。KV 是全球分布的键值存储,读取延迟在个位数毫秒,但不支持复杂查询——只能按 key 精确读取。
CloudFlare D1(结构化数据层):适合需要"条件查询"的数据。用户画像(姓名、生日、职业)、对话摘要(按时间段索引)、偏好标签(键值对形式)、记忆条目(供关键词检索)放在这里。D1 是边缘 SQLite 数据库,支持完整的 SQL——WHERE、LIKE、ORDER BY、GROUP BY 都能用,但不支持语义相似度计算。
CloudFlare Vectorize(语义数据层):适合需要"意思相近"检索的数据。事件记忆、情感片段、长文本记忆放在这里。这一层我们在第四篇已经详细讲过。它的优势是语义理解,劣势是无法精确匹配和条件过滤。
一个关键的架构决策是:事件记忆同时写入 D1 和 Vectorize。这意味着同一条数据存两份——Vectorize 用于语义检索,D1 用于关键词检索。这是混合架构的核心代价:用存储冗余换取检索能力的完整覆盖。
3. 混合检索管线
用户的一条消息进来后,系统不是只走一条检索路径,而是同时发起多条检索,最后将结果融合。整个管线分为四步:
Drawing canvas 第一步:查询理解(Query Understanding)
这是管线的入口,也是最关键的一步。它由 LLM 分析用户消息,输出一个"查询计划"——决定哪些检索通道需要被激活。
几个典型的分析结果:
| 用户消息 | 激活的通道 | 原因 |
|---|---|---|
| "我的生日是几号" | 结构化查询 | 精确事实,直接查 D1 画像表 |
| "上周我们聊了什么" | 时间范围查询 | 涉及时间表述,查 D1 摘要表 |
| "我之前说过喜欢《三体》" | 关键词 + 语义 | 专有名词用关键词匹配,上下文用语义检索 |
| "你还记得我们第一次吵架吗" | 语义检索 | 纯语义问题,无精确事实或时间范围 |
| "我上个月跟你说过我换工作了" | 结构化 + 时间 + 语义 | "换工作"是事实变更,"上个月"是时间范围,还需要语义补充 |
大部分查询会同时激活多个通道。这不浪费——并行执行的总延迟等于最慢的那个通道,通常在 50-150ms 内。
查询理解本身需要一次 LLM 调用(100-300ms),这是管线中的延迟成本。但如果跳过这一步直接激活所有通道,每次请求都会产生不必要的数据库查询和向量检索,对后端资源是更大的浪费。这是一个延迟 vs 资源的权衡——在用户量大时,查询理解带来的资源节省远超它自身的延迟成本。
第二步:多通道并行检索
查询计划确定后,被激活的通道同时执行,互不等待。
第三步:结果融合
所有通道的结果汇总后,需要去重、加权、排序,合并为一个统一的记忆列表。下一节详细讲解。
第四步:截断输出
融合后的结果按分数排序,取 Top-K(通常 5-8 条)作为最终输出。截断是必要的——LLM 的上下文窗口有限,塞入太多记忆反而会稀释重要信息,导致回复质量下降。
4. 四条检索通道的特征对比
四条通道各有所长,覆盖不同类型的查询需求:
| 通道 | 数据源 | 匹配方式 | 延迟 | 擅长场景 | 弱点 |
|---|---|---|---|---|---|
| 语义检索 | Vectorize | 向量余弦相似度 | 50-100ms | 模糊回忆、情感关联、开放性问题 | 专有名词、精确数值 |
| 结构化查询 | D1 画像表 | SQL 精确匹配 | 10-30ms | 姓名、生日、职业等确定性事实 | 无法处理模糊描述 |
| 时间范围查询 | D1 摘要表 | SQL 范围过滤 | 10-30ms | "上周"、"去年夏天"等时间表述 | 依赖摘要质量 |
| 关键词检索 | D1 记忆表 | SQL LIKE / FTS | 15-40ms | 书名、地名、人名等专有名词 | 无法理解同义词 |
几个架构层面的观察:
语义检索是"兜底通道"。它几乎在每次查询中都被激活,因为用户的大部分消息或多或少包含语义信息。其他三个通道是"精准补充"——在特定场景下提供语义检索无法给出的精确结果。
结构化查询的置信度天然最高。当用户问"我的生日是几号",从画像表查出的 birthday: "03-14" 是 100% 准确的事实,不存在"相似度"的概念。因此在结果融合时,结构化查询的结果权重应该高于其他通道。
时间范围查询依赖"摘要"而非原始对话。如果直接检索"上周的所有对话",可能返回 200 条原始消息,远超 LLM 的上下文容量。所以系统需要在后台异步生成对话摘要(每 N 轮对话压缩为一段摘要),时间范围查询检索的是这些摘要,而不是原始对话。
关键词检索和语义检索经常互补。用户说"我之前提到过《三体》",关键词检索能精确命中包含"三体"二字的记忆条目,语义检索能召回"用户喜欢刘慈欣的科幻作品"这类没有"三体"字面但语义相关的记忆。两者组合才能给出完整的上下文。
5. 结果融合策略
四条通道的结果汇总后,需要解决三个问题:去重、排序、截断。
Drawing canvas 去重
同一条记忆可能同时被语义检索和关键词检索召回。比如一条包含"三体"的记忆,语义上与"科幻小说"相似(被语义检索命中),关键词上也匹配"三体"(被关键词检索命中)。如果不去重,同一条信息会在 LLM 的上下文中出现两次,浪费宝贵的上下文窗口。
去重策略是:计算任意两条结果的文本相似度,如果超过 85%,视为同一条记忆,保留分数更高的那条。这里的"文本相似度"不需要再走向量计算——用简单的字符级 Jaccard 系数或编辑距离就够了,因为目的是检测"几乎相同的文本"而非"语义相近"。
来源加权
不同通道返回的分数含义不同:语义检索返回的是余弦相似度(0-1),结构化查询返回的是"命中/未命中"。为了将它们放在同一个尺度上比较,需要给每个来源分配一个权重系数:
| 来源 | 权重 | 理由 |
|---|---|---|
| 结构化查询 | 1.2x | 精确事实,可信度最高 |
| 语义检索 | 1.0x | 基准权重 |
| 时间范围查询 | 0.9x | 摘要有信息损失 |
| 关键词检索 | 0.85x | 能匹配字面,但不理解语义 |
最终分数 = 原始分数 × 来源权重。精确事实查询的权重最高,因为它返回的结果几乎不会出错。
时效性加成
越近的记忆越可能与当前对话相关。一条昨天产生的记忆,其价值通常高于一条三个月前的记忆。时效性加成规则很简单:24 小时内的记忆额外加 10%,7 天内加 5%,更早的不加成。
这三步之后,所有记忆片段被统一到同一个分数维度上,按最终分数降序排列,取 Top-K 输出。
6. 记忆写入:双写架构
检索是"读"的一面,还有"写"的一面。每轮对话结束后,系统需要将新记忆写入三层存储。写入路径的设计直接决定了检索时能找到什么。
Drawing canvas 写入分为两个阶段:
同步写入(不阻塞用户回复):只更新 KV 中的短期上下文。将最近 N 轮对话写入 KV,设置 24 小时过期。这步必须同步执行,因为下一轮对话立即需要读取最新的上下文。
异步写入(在后台完成):由 LLM 从对话中提取结构化信息,写入 D1 和 Vectorize。这步通过消息队列异步执行,不阻塞用户的下一条消息。异步写入包含四个操作:
事实提取 → D1 画像表。LLM 识别对话中的结构化事实("我下周生日"→ birthday 字段更新),以键值对形式写入用户画像表。这些数据供结构化查询通道使用。
事件提取 → Vectorize + D1(双写)。LLM 识别值得长期记住的事件和情感("用户今天被裁员了,心情很低落"),将其向量化后写入 Vectorize(供语义检索),同时以原文形式写入 D1 的 memory_entries 表(供关键词检索)。这是整个架构中唯一的双写点——同一条数据存两份,是混合检索能力的必要代价。
摘要生成 → D1 摘要表。每 N 轮对话(如 20 轮),LLM 将这段对话压缩为一段摘要,写入 D1 摘要表并标记时间范围。这些摘要供时间范围查询通道使用。
上下文更新 → KV。将最新的对话追加到短期上下文缓存。
事实提取 vs 事件提取是两个独立的 LLM 调用,目标完全不同:
| 维度 | 事实提取 | 事件提取 |
|---|---|---|
| 输出格式 | 键值对:{ field: "birthday", value: "03-14" } | 自然语言:"用户今天被裁员,心情低落" |
| 存储方式 | 覆盖写入(同一个 field 只保留最新值) | 追加写入(每条事件独立存储) |
| 写入目标 | D1 画像表 | D1 记忆表 + Vectorize |
| 调用频率 | 有新事实时才写入 | 每轮对话都可能产生 |
覆盖写入 vs 追加写入的区别很重要。用户说"我换工作了,现在在字节跳动",事实提取会覆盖 job 字段的旧值;而事件提取会追加一条新记忆"用户从上一家公司离职,入职字节跳动"。前者保证画像表始终是最新状态,后者保留完整的历史轨迹。
7. 总结
这篇文章将 AI 伴侣的记忆系统从"单一向量检索"升级为了完整的混合检索架构。
关键要点:
- 纯向量检索无法覆盖三类场景:精确事实查询、时间范围查询、专有名词匹配。混合检索架构通过组合多种检索方式解决这个问题
- 记忆分为 6 种类型,存储介质的选择依据是访问模式——KV 存高频热数据、D1 存需要条件查询的结构化数据、Vectorize 存需要语义检索的文本
- 混合检索管线四步走:查询理解(LLM 分析意图)→ 四通道并行检索 → 结果融合(去重 + 加权 + 时效性)→ Top-K 截断
- 四条通道各有所长:语义检索是兜底通道,结构化查询置信度最高,时间查询依赖摘要,关键词与语义互补
- 事件记忆的双写(Vectorize + D1)是混合架构的核心代价,用存储冗余换取检索能力的完整覆盖
- 写入分为同步(KV 上下文)和异步(LLM 提取 → D1/Vectorize)两个阶段,异步写入不阻塞用户体验