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 | 直接读取 |
这里选择存储介质时,判断依据是访问模式,而不是单纯看数据量。
CloudFlare KV 负责热数据。最近 N 轮对话、当前情绪状态、亲密度数值等内容体量不大,却几乎每次请求都要读取,很适合放在 KV 中。KV 是全球分布的键值存储,读取延迟在个位数毫秒,但它只能按照 key 精确读取,不支持复杂查询。
CloudFlare D1 负责结构化数据。用户画像中的姓名、生日和职业,对话摘要中的时间段索引,键值形式的偏好标签,以及用于关键词检索的记忆条目,都需要条件查询能力。D1 是边缘 SQLite 数据库,支持完整的 SQL,WHERE、LIKE、ORDER BY、GROUP BY 都可以使用,但它无法计算语义相似度。
CloudFlare Vectorize 负责语义数据。事件记忆、情感片段和长文本记忆更关心“意思是否相近”,适合放入向量数据库。这一层的具体实现已经在第四篇文章中讲过。它的优势是理解语义,短板则是精确匹配和条件过滤。
这里还有一个很重要的架构选择:事件记忆要同时写入 D1 和 Vectorize。同一条数据保存两份,Vectorize 用来做语义检索,D1 用来做关键词检索。混合架构因此会增加存储冗余,但换来的是更完整的检索能力。
3. 混合检索流程
收到用户消息后,系统不会只选择一条检索路径,而是先判断这条消息需要哪些记忆,再并行执行相应的检索任务。等各通道返回结果后,系统会把它们合并成一份可供模型使用的记忆列表。
第一步:查询理解
查询理解是整个流程的入口。LLM 会分析用户消息并生成一份查询计划,这份计划决定语义检索、结构化查询、时间范围查询和关键词检索中,哪些通道需要被激活。
下面几条消息可以帮助我们理解这个判断过程:
| 用户消息 | 激活的通道 | 原因 |
|---|---|---|
| "我的生日是几号" | 结构化查询 | 精确事实,直接查 D1 画像表 |
| "上周我们聊了什么" | 时间范围查询 | 涉及时间表述,查 D1 摘要表 |
| "我之前说过喜欢《三体》" | 关键词 + 语义 | 专有名词用关键词匹配,上下文用语义检索 |
| "你还记得我们第一次吵架吗" | 语义检索 | 纯语义问题,无精确事实或时间范围 |
| "我上个月跟你说过我换工作了" | 结构化 + 时间 + 语义 | "换工作"是事实变更,"上个月"是时间范围,还需要语义补充 |
实际使用时,大部分查询会同时激活多个通道。这并不意味着延迟会按通道数量累加,因为各通道可以并行执行,总延迟取决于其中最慢的一个,通常在 50-150ms 内。
查询理解本身需要调用一次 LLM,大约增加 100-300ms 延迟。如果省略这一步,直接让每条消息都查询所有通道,就会产生大量没有必要的数据库查询和向量检索。这里需要权衡延迟和资源消耗:当用户规模增大后,查询理解节省下来的后端资源,通常会超过它自身带来的延迟成本。
第二步:多通道并行检索
查询计划确定后,所有被激活的通道同时开始执行,彼此之间不需要等待。
第三步:结果融合
当各通道都返回结果后,系统会统一去重、加权和排序,把来源不同的记忆合并到同一份列表中。具体的融合规则会在第五节展开。
第四步:截断输出
融合后的结果按照分数排序,只取 Top-K 作为最终输出,通常保留 5-8 条。截断并不是为了少查数据,而是为了保护模型的上下文:输入太多关联较弱的记忆,会稀释真正重要的信息,反而降低回复质量。
4. 四条检索通道
四条检索通道分别解决不同类型的问题。它们没有谁能够替代谁,组合起来才构成完整的记忆检索能力。
| 通道 | 数据源 | 匹配方式 | 延迟 | 擅长场景 | 弱点 |
|---|---|---|---|---|---|
| 语义检索 | Vectorize | 向量余弦相似度 | 50-100ms | 模糊回忆、情感关联、开放性问题 | 专有名词、精确数值 |
| 结构化查询 | D1 画像表 | SQL 精确匹配 | 10-30ms | 姓名、生日、职业等确定性事实 | 无法处理模糊描述 |
| 时间范围查询 | D1 摘要表 | SQL 范围过滤 | 10-30ms | "上周"、"去年夏天"等时间表述 | 依赖摘要质量 |
| 关键词检索 | D1 记忆表 | SQL LIKE / FTS | 15-40ms | 书名、地名、人名等专有名词 | 无法理解同义词 |
语义检索可以看作兜底通道。用户消息通常都会带有一些语义信息,因此它几乎每次都会被激活;另外三个通道则负责精准补充,在特定场景中给出语义检索无法提供的确定结果。
结构化查询的置信度天然更高。用户问“我的生日是几号”时,从画像表查到的 birthday: "03-14" 是一个明确事实,不存在相似度高低的问题。因此在结果融合阶段,结构化查询的结果应该获得更高权重。
时间范围查询依赖对话摘要,而不是直接返回原始消息。假设用户一周内产生了 200 条对话,把这些原始内容全部放进模型上下文显然不可行。系统需要在后台异步生成对话摘要,例如每 N 轮对话压缩成一段摘要。之后再遇到“上周”或“去年夏天”一类问题时,查询的是带时间范围的摘要,而不是完整的原始对话。
关键词检索与语义检索经常会返回互补的信息。用户提到《三体》时,关键词检索可以准确命中包含“三体”二字的记忆;语义检索则可能找到“用户喜欢刘慈欣的科幻作品”这类没有出现书名、但含义相关的内容。把两类结果放在一起,才能补全这段上下文。
5. 结果融合
四条通道返回的结果不能直接拼接。系统还需要处理重复内容、不同来源的分数尺度,以及记忆的新旧程度,最后再决定哪些内容进入模型上下文。
去重
同一条记忆可能同时被语义检索和关键词检索召回。例如,一条包含“三体”的记忆既会因为字面匹配出现在关键词结果中,也可能因为与“科幻小说”语义相近而被向量检索命中。如果直接把两条结果都交给 LLM,相同信息就会重复占用上下文窗口。
去重时,可以计算任意两条结果的文本相似度。相似度超过 85% 时,将它们视为同一条记忆,只保留分数更高的结果。这里不需要再次执行向量计算,因为我们要找的是“几乎相同的文本”,而不是“意思相近的文本”。字符级 Jaccard 系数或编辑距离已经足够。
来源加权
不同通道的原始分数不能直接比较。语义检索返回的是 0-1 之间的余弦相似度,结构化查询表达的则是命中或未命中。为了把这些结果放到同一尺度中排序,需要为每种来源分配权重:
| 来源 | 权重 | 理由 |
|---|---|---|
| 结构化查询 | 1.2x | 精确事实,可信度最高 |
| 语义检索 | 1.0x | 基准权重 |
| 时间范围查询 | 0.9x | 摘要有信息损失 |
| 关键词检索 | 0.85x | 能匹配字面,但不理解语义 |
最终分数的计算方式是 原始分数 × 来源权重。结构化查询获得最高权重,是因为它返回的是确定事实,出错概率通常低于其他通道。
时效性加成
越接近当前时间的记忆,通常越可能与这轮对话相关。系统可以为 24 小时内产生的记忆额外增加 10%,为 7 天内的记忆增加 5%,更早的记忆不做加成。
完成去重、来源加权和时效性加成后,所有记忆就进入了同一个分数体系。系统按最终分数降序排列,再取 Top-K 输出。
6. 记忆写入
检索解决的是如何读取记忆,而系统最终能查到什么,取决于每轮对话结束后写入了哪些内容。混合记忆架构的写入过程分为同步和异步两个部分。
同步阶段只更新 KV 中的短期上下文。系统把最近 N 轮对话写入 KV,并设置 24 小时过期时间。下一轮对话会立即读取这份上下文,因此这一步需要在当前处理链路内完成。
长期记忆的提取和落库放在后台异步执行。系统把任务交给消息队列,再由 LLM 从对话中提取结构化信息,写入 D1 和 Vectorize。这样既能完成记忆沉淀,也不会阻塞用户发送下一条消息。把同步与异步两部分合在一起看,写入过程一共包含以下四项操作。
事实提取写入 D1 画像表。 LLM 识别对话中的结构化事实,例如把“我下周生日”转换为 birthday 字段更新,再以键值对形式写入用户画像表。这些内容会被结构化查询通道使用。
事件提取同时写入 Vectorize 和 D1。 LLM 识别值得长期保留的事件与情感,例如“用户今天被裁员了,心情很低落”。这段内容向量化后写入 Vectorize,供语义检索使用;原文还会写入 D1 的 memory_entries 表,供关键词检索使用。这是整个架构中唯一的双写点。同一条数据保存两份,是获得混合检索能力必须承担的存储代价。
摘要写入 D1 摘要表。 每 N 轮对话,例如每 20 轮,LLM 会把这一段对话压缩成摘要,写入 D1 摘要表,并记录对应的时间范围。之后的时间范围查询会从这些摘要中寻找结果。
上下文更新写入 KV。 系统把最新对话追加到短期上下文缓存中,供下一轮对话直接读取。
事实提取与事件提取是两个独立的 LLM 调用,它们的目标和写入方式完全不同:
| 维度 | 事实提取 | 事件提取 |
|---|---|---|
| 输出格式 | 键值对:{ field: "birthday", value: "03-14" } | 自然语言:"用户今天被裁员,心情低落" |
| 存储方式 | 覆盖写入(同一个 field 只保留最新值) | 追加写入(每条事件独立存储) |
| 写入目标 | D1 画像表 | D1 记忆表 + Vectorize |
| 调用频率 | 有新事实时才写入 | 每轮对话都可能产生 |
覆盖写入与追加写入的区别会直接影响记忆的含义。用户说“我换工作了,现在在字节跳动”时,事实提取会覆盖 job 字段中的旧值,确保画像表反映当前状态;事件提取则会追加一条“用户从上一家公司离职,入职字节跳动”的新记忆,用来保留完整的历史轨迹。
7. 总结
单一向量检索擅长理解语义,却无法独立处理精确事实、时间范围和专有名词。混合检索架构补上了这些能力:KV 保存需要高频读取的热数据,D1 保存可按条件查询的结构化数据,Vectorize 保存需要语义检索的文本。六类记忆因此可以按照各自的访问模式,进入合适的存储层。
读取记忆时,LLM 先生成查询计划,再并行激活语义、结构化、时间范围和关键词通道。返回结果依次经过 85% 阈值去重、来源加权、时效性加成与 Top-K 截断。其中,语义检索负责兜底,结构化查询拥有最高置信度,时间范围查询依赖后台摘要,关键词检索则与语义检索相互补充。
写入记忆时,短期上下文同步进入 KV,事实、事件与摘要由后台异步提取。事件记忆会同时写入 D1 和 Vectorize,这是整个架构唯一的双写点。它增加了存储冗余,却让同一条记忆既能按字面检索,也能按语义召回,从而补齐混合记忆系统的检索能力。