做 Agent 记忆系统时,最容易得到的答案是:把历史对话切块,做 Embedding,存进向量数据库,查询时取 Top K。这个方案能跑,但它只解决了“哪些内容和问题看起来相似”,没有解决“这件事为什么发生”“它前后还有什么”“这条结论来自哪段原话”。
GauzMem 的出发点因此不是再封装一次 Vector Search,而是把记忆做成一条可以回到原始证据的链路,再让语义、关系和时间分别承担自己擅长的召回职责。
向量检索只能回答“像不像”
假设用户曾经说:“发布推迟到周五,因为支付集成还需要一次审查。”几天后,他问:“为什么发布日期变了?”向量检索大概率能找到这条 Fact,但仅靠相似度,系统仍然不知道它和哪段原始对话对应,也不知道上一轮讨论了什么、下一轮最终确认了什么。
这类缺口通常分成三种:
- 证据缺口:Fact 是模型提炼的,必须能回到原始 Chunk,避免把派生内容当作原话。
- 关系缺口:相似不等于因果、支持、矛盾或条件,很多“为什么”问题需要沿关系继续找。
- 时间缺口:对话的前后轮次往往比另一条高相似文本更重要,顺序本身就是信息。
所以我把向量检索放在召回入口,但没有让它成为整个记忆系统。
先把记忆拆成一条证据链
Source → Chunk → Fact → Entity / Relation → Topic
↑ ↑
原始证据 可检索的原子记忆
Source 表示一段对话、一个文档或一次上传;Chunk 保存原始文本、轮次和创建时间;Fact 是从 Chunk 中提取的原子结论。Entity、Relation 和 Topic 都是基于 Fact 继续构建的检索投影。
| 对象 | 保存什么 | 为什么需要 |
|---|---|---|
| Source | 来源与导入边界 | 区分对话、文档和文件 |
| Chunk | 原文、turn、created_at | 承担证据与时序真相 |
| Fact | 原子化结论与 chunk_id | 提供稳定的检索和扩展种子 |
| Entity / Relation | 事实之间的结构化联系 | 支持多跳关系召回 |
| Topic | 一组相关事实的高层摘要 | 补充跨片段的长期主题 |
这里最关键的字段不是某个漂亮的摘要,而是 Fact.chunk_id。它让任何派生事实都能回到生成它的 Chunk。没有这条边,系统只能告诉 Agent“我记得有这件事”;有了它,系统才能继续回答“我为什么记得、原话在哪里、当时前后发生了什么”。
三路召回不是三个数据库并排查询
第一路:Qdrant 找到语义种子
查询先被转换成 Embedding,再从 conversation、Fact 和 Topic 三类向量中召回候选。Qdrant 在这里解决的是候选发现:把搜索空间快速收敛到一批相关内容,并且保留 seed Fact IDs,供后续两条路径继续工作。
语义结果本身也会进入最终合并,但它更重要的作用,是给关系扩展和时序扩展提供可靠起点。三路召回因此不是三次互不相干的搜索,而是先找到种子,再从不同维度恢复上下文。
第二路:Neo4j 补回关系
GauzMem 把 Fact 之间的 CAUSE、TEMPORAL、SUPPORT、ELABORATE、CONTRADICT、ANALOGY、CONDITIONAL 和 PARALLEL 等关系投影到 Neo4j。拿到 seed Fact IDs 后,系统可以沿这些边做一到三跳扩展,记录完整 reasoning path,并优先保留更短的路径。
这条路径回答的是结构问题:什么导致了这件事,哪条事实支持它,后面又发生了什么。向量相似度可以发现“支付审查”和“发布日期变化”相关;图关系才能明确它们之间是因果还是仅仅同时出现。
第三路:MySQL 补回时间
时序扩展最容易被误解成“读取 Fact metadata 里的时间”。GauzMem 实际上不依赖把时间复制进 Fact metadata,而是从 Fact 回到 Chunk:
seed Fact IDs
↓ JOIN facts.chunk_id = mem_chunks.chunk_id
seed Chunk.turn / Chunk.created_at
↓ calculate window + direction
neighbor Chunks in MySQL
↓ load facts by chunk_id
temporal context
turn 模式下,如果 seed Fact 来自第 12 轮,hop_distance=1 且 direction=both,系统会查询第 11 到 13 轮的 Chunk,再取回这些 Chunk 对应的 Facts。forward 只向后扩,backward 只向前扩。
time 模式使用 mem_chunks.created_at 计算窗口。当前实现把一跳映射为五分钟,再按方向扩展。无论是哪种模式,时间和顺序都属于原始证据 Chunk,而不是某个可能漂移的派生向量 payload。
为什么 MySQL 是真相源,另外两个是投影
三个数据库同时存在,不代表它们拥有同等权威。GauzMem 把 MySQL 当作 Source、Chunk、Fact、Topic、turn、created_at 和 provenance 的持久化真相源;Qdrant 和 Neo4j 是为特定查询优化的投影。
| 存储 | 主要职责 | 丢失后怎样处理 |
|---|---|---|
| MySQL | 原始证据、派生记录、时间、顺序与追溯关系 | 需要备份与恢复 |
| Qdrant | conversation、Fact、Topic 的语义索引 | 可从真相源重新嵌入构建 |
| Neo4j | Fact、Entity、Relation 的图关系投影 | 可从事实和关系记录重建 |
这种 authority / projection 分离让系统更容易解释一致性问题:检索索引可以异步更新,也可以重建;真正不能丢的是证据链。它还避免把 Qdrant payload 或 Neo4j 节点误当成所有业务状态的唯一来源。
Recall Bundle 才是面向 Agent 的产品边界
如果 API 直接返回三份列表,调用方仍然需要理解 Qdrant、Neo4j 和 MySQL 的内部结构。GauzMem 在最后一步把三路结果合并、去重,并保留 source、path、time、chunk_id、hop level 等追溯信息,形成 Recall Bundle。
对 Agent 来说,一条可用记忆至少应该回答四个问题:
- 内容是什么:Fact 或原始 Chunk 表达了什么。
- 为什么被召回:来自语义命中、图关系还是时序邻接。
- 从哪里来:对应哪个 Source 和 Chunk。
- 处在什么上下文:它的关系路径、轮次和时间窗口是什么。
这也是我把系统定位成 memory service,而不是 vector database wrapper 的原因。数据库是实现细节,Recall Bundle 才是 Agent 真正消费的上下文合同。
短期记忆与长期记忆不应该互相等待
一段对话进入 GauzMem 后,原始 Chunk 会先成为可用的短期记忆,后台再继续抽取 Fact、Entity、Relation 和 Topic,并更新向量与图谱投影。这个顺序的重点不是“用了异步任务”,而是先保住证据,再生产解释:即使后面的编译暂时失败,刚刚发生的对话仍然存在,也仍然可以重新加工。
这让写入延迟和记忆质量可以分开优化:短期路径优先保证“刚说过的话不会忘”,长期路径再负责把原始对话编译成更稳定的知识结构。项目已经实测的结果包括短期记忆可用时间小于 1 秒、向量召回小于 100 ms、图召回小于 500 ms,以及 SOTA 长期记忆表现。
三路召回不是从零并行,而是先找到记忆锚点
把三路召回理解成“三个数据库各查一次”仍然不够准确。图谱不知道应该从哪里开始遍历,时间也不知道应该围绕哪一刻展开。语义召回的真正价值不是直接给出最终答案,而是先把查询收敛成一小组 seed Facts,为后两条路径确定记忆锚点。
有了锚点,图谱扩展才能回答“这条事实沿什么关系连到另一条事实”,时序扩展才能回答“这条事实前后发生了什么”。因此,先语义、再扩展不是数据库编排技巧,而是一条认识论约束:每一段新增上下文,都必须能够说明自己从哪条记忆出发,以及为什么被带回来。
这套设计仍然有明确边界
- 图扩展限制在一到三跳,防止关系数量失控;更多跳数不天然等于更好的上下文。
- turn 不是全局时钟,只在会话域内有意义;当一个项目承载多条独立会话时,时序查询必须同时限定 session 边界。
- Qdrant 和 Neo4j 可以重建,不代表可以忽略同步失败;投影状态仍然需要健康检查与可观测性。
- Recall Bundle 能提供证据,不保证上层模型一定做出正确推理;生成答案仍然需要自己的评测。
这些边界反而让系统更可靠。一个记忆服务不需要假装自己解决了所有智能问题,它只需要清楚地说明:什么是事实,什么是投影,召回为什么发生,证据能否回去。
GauzMem 已经开源。对我而言,它首先是一份可运行的架构判断:Agent 的记忆不应止于保存和命中,而应该能够沿证据、关系和时间重新组织上下文。
View GauzMem on GitHub →