Markdown 记忆系统设计
最后更新:2026-06-07
总判断
March 的新记忆系统采用 Markdown 文件作为事实源。索引只是从 Markdown 派生出来的缓存,可以随时删除重建。
记忆文件只放在全局位置,不放进项目目录。记忆默认属于同一个全局池,不按项目做硬隔离。跨项目召回是有价值的:一个项目里踩过的坑,可能正好能帮另一个项目避开同类问题。
被动召回不注入记忆原文,只在对话消息后附加少量记忆线索。AI 如果需要原文,必须通过主动 memory 工具打开。
系统结构
Obsidian Vault / global memory root
↓
March Memories/**/*.md
↓
parse frontmatter: id / name / description / tags / status
↓
derived semantic vector index + metadata cache
↓
recall hint: id + score + name + short_description
↓
memory_open(id) reads Markdown body when neededProfiles 与 Memories
Profiles 是每轮固定注入的长期身份/偏好,不属于按需召回记忆。
~/.march/memory/profiles/
├─ agent.md # Agent Profile: March 如何工作、表达和协作
└─ user.md # User Profile: 用户偏好、长期目标和稳定事实March 启动时会自动创建缺失的 profile 文件,并在 context 中注入为 [agent_profile] 和 [user_profile]。
Markdown Memories 是事件型、经验型或项目型记忆,通过 memory_search / memory_open 按需召回。不要把可检索的历史事件塞进 Profiles;也不要把稳定身份偏好拆成普通 recall hint。
Markdown 文件是真相:用户可以直接用 Obsidian 打开、编辑、移动或删除。March 不依赖 Obsidian API,只读写文件系统。
索引是缓存:semantic vector index 服务 March 内部被动召回,SQLite metadata cache 服务扫描加速;索引错了就重建,不能让索引反过来覆盖 Markdown。
存储位置
记忆库优先通过配置指向用户的 Obsidian vault 子目录:
<ObsidianVault>/March Memories/配置项:
{
"memoryRoot": "/path/to/ObsidianVault/March Memories"
}也可以用环境变量指定:
MARCH_MEMORY_ROOT=/path/to/ObsidianVault/March Memories如果没有配置,March 默认使用自己的用户状态目录:
<MarchUserState>/March Memories/March 只索引这个子目录,不默认索引整个 Obsidian vault。这样可以避免把用户的普通笔记、日记或私人材料误纳入 AI 记忆召回。
项目目录不保存记忆文件。当前项目名称、仓库名或 project id 只作为排序信号使用,不作为召回过滤条件。
如果一条记忆和某个项目强相关,可以用 tag 表达:
tags:
- project/march-cli
- context
- cache这些 tags 只影响排序加分。March 不因为记忆没有匹配当前项目 tag 就排除它。
目录结构
记忆文件按时间轴分桶,避免单个文件夹里文件过多。
推荐结构:
<ObsidianVault>/March Memories/
└─ 2026/
└─ 05/
└─ week2/
├─ 2026-05-14-writing-style.md
├─ 2026-05-14-context-cache-ordering.md
└─ 2026-05-14-prefix-cache.md月内按 7 天分桶:01-07 -> week1,08-14 -> week2,15-21 -> week3,22-28 -> week4,29-31 -> week5。
文件名用于人工浏览,不作为稳定引用。稳定引用只依赖 frontmatter 里的 id。
推荐文件名:
YYYY-MM-DD-slug.mdMarkdown 格式
每条记忆是一个 Markdown 文件。
---
id: mem_01hx_context_cache
name: Context cache ordering
description: 高频变化层不能放在大块稳定上下文前面,否则会污染 prefix cache
tags:
- march/context
- cache
- architecture
status: active
created_at: 2026-05-14T10:30:00.000Z
updated_at: 2026-05-14T10:30:00.000Z
---
# Context cache ordering
这里是记忆原文。默认不进入被动召回。核心字段:
id 稳定引用,memory_open(id) 使用
name 人类可读短名
description 被动召回展示的短描述,不参与索引
tags 召回和人工整理用的标签
status active / deprecated
body 记忆原文,默认不参与被动召回严格规则:
没有 id 的文件不进入索引
没有 description 的文件不参与被动召回
status != active 的文件默认不参与搜索和召回这样做是为了减少误召回。普通 Obsidian 笔记可以存在,但只有被明确整理成 memory 格式的文件才会进入 March 记忆系统。
索引源
March 内部被动召回索引把文档级 metadata、正文分块和稀疏词项分开处理:
Memory Markdown
├─ metadata: name + description + tags
│ ↓
│ document-level dense signal
│
├─ body chunks: body paragraphs only
│ ↓
│ top chunk dense signal
│
└─ BM25 document terms: weighted metadata + body
↓
sparse exact-match signalid、status、path 是元数据,不参与语义匹配。description 仍然是给 AI 和用户看的自然语言摘要,不应该为了搜索效果写成关键词堆砌文本。
body 会按段落切成有限长度 chunk。正文 chunk 只包含正文,不重复塞入 name / description / tags,避免标题或标签命中时把多个 chunk 都伪装成相关正文。被动召回仍只返回 hint,不直接注入正文;如果 AI 需要细节,再调用 memory_open(id) 读取原文。
被动召回索引
被动召回使用本地 hybrid recall index。索引不写回 Markdown;当 memory 文件的 path、mtime 或 size 变化时,派生索引会按当前 active memory 重新构建。
普通 SQLite metadata 表只保存扫描缓存和同步所需字段:
CREATE TABLE memory_index (
id TEXT PRIMARY KEY,
path TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT NOT NULL,
tags_json TEXT NOT NULL,
status TEXT NOT NULL,
mtime_ms INTEGER NOT NULL,
size INTEGER NOT NULL
);dense 语义信号分两类:
metadata document = name + description + tags
body chunk = body section onlysparse 信号使用文档级 BM25:
BM25 document terms = metadata × 2 + bodymetadata 在 BM25 中加权重复,用于增强标题、摘要和 tag 这种精确信号;但它不会被复制进每个正文 chunk。
被动召回匹配
March 内部被动召回统一查询 hybrid index。user 和 assistant 只是触发时机,不对应不同匹配算法。
打分来源:
query
├─ dense metadata score
│ query embedding ↔ metadata embedding
│
├─ dense body score
│ query embedding ↔ body chunk embeddings
│ top chunks weighted: 0.65 / 0.25 / 0.10
│
└─ sparse BM25 score
document-level BM25, normalized to 0..1
↓
final score = available dense score × 0.7 + available BM25 score × 0.3body dense score 取最高几个正文 chunk 的加权结果;chunk 不足时按已有权重重新归一。dense 文档分数取 body dense 与 metadata dense 的较高值。最终 hybrid 分数只按存在的信号归一:dense-only 或 BM25-only 的候选不会因为缺少另一路信号而被固定权重压低。
默认阈值是 0.5,可用 MARCH_MEMORY_RECALL_MIN_SCORE 覆盖。
查询策略:
用户消息 / assistant output
↓
compute dense metadata/body scores + BM25 document scores
↓
hybrid rank active memories
↓
filter status = active
↓
apply recall dedup rules
↓
return top hints with score=0.xx这个算法的边界是:被动召回只返回轻量 hint,不注入 memory 原文;AI 如果需要原文,必须主动调用 memory_open。
被动召回
被动召回结果以轻量 hint 形式进入上下文:用户 recall 在 Agent Run 开始时注入;assistant recall 在有工具的中间轮结束后以隐藏 steer message 注入,最终 Agent Run 结束时只记录。
用户消息触发:
输入:user message
搜索:semantic vector index
数量:最多 3 条
输出:id / score / name / short_description
位置:Agent Run 开始时的隐藏 recall message
去重:rolling suppression window,默认最近 10 个 Agent Runsassistant 输出触发:
输入:assistant model output
搜索:semantic vector index
注入:最多 2 条过阈值 memory
UI:无论是否过阈值,轻量显示最多 3 条候选;不展示 description
位置:有工具调用/工具结果的中间 assistant 轮结束后,作为隐藏 steer message 进入后续 Model Call;Agent Run 最终结束后的结果只记录,不再注入模型
去重:当前 Agent Run 内去重同一 Agent Run 内还维护 seen memory ids,避免用户消息召回和 assistant 输出召回重复提示同一条记忆。
上下文展示形态:
[user]
我们继续讨论 memory 召回。
[recall]
- mem_01hx_context_cache | score=0.62 | Context cache ordering | 高频变化层不能放在大块稳定上下文前面
- mem_01hx_recall_dedup | score=0.57 | Passive recall dedup | 用户召回按最近 Agent Runs 做滚动抑制
[assistant]
这里的 user/assistant 只是触发时机,匹配方式统一走向量检索...
[recall]
- mem_01hx_run_seen | score=0.59 | Agent Run seen set | 同一 Agent Run 内 user/assistant recall 不重复主动回忆工具
工具集保持小而清楚。
memory_search(query, limit?)
memory_open(id)
memory_save(id?, name, description, body, tags?)memory_search 用于 AI 主动回忆,背后使用 ripgrep 搜索 March Memories/ 里的 Markdown 文件全文。它返回匹配文件、行号和上下文片段。工具描述里必须明确告诉 AI:这是 ripgrep 文本搜索,不是 March 内部被动语义召回。
memory_open 用 id 打开 Markdown 原文。被动召回只给 id、name、description;AI 想要原文必须显式调用这个工具。
memory_save 用于新建或更新 Markdown 记忆文件。它负责写 frontmatter、更新时间戳,并触发索引刷新。
因为被动召回只依赖 tags,新建 memory 时 tags 必填,且至少包含 1 个有效 tag。更新已有 memory 时,未传字段保持原值;传入 tags 时整体替换旧 tags。AI 可以通过 memory_save({ id, tags: [...] }) 给已有记忆补充或修正 tags。
March 对 tags 做轻量规范化:
trim 空白
去重
英文统一小写
空格转 -
允许中文
允许 / - _
空字符串无效第一版不提供单独的 tag 增删工具。memory_save 覆盖 tags 的语义更简单,也避免工具数量过多。以后如果频繁需要增量改 tag,再考虑增加 memory_tag(id, add?, remove?)。
暂不提供 memory_list。记忆系统应该以召回和搜索为主,而不是让 AI 浏览全部记忆。
暂不提供 memory_archive。废弃通过 frontmatter 表达:
status: deprecated搜索和被动召回默认排除 deprecated 记忆。
索引同步
索引同步的原则是:Markdown 文件永远是事实源,metadata cache 和 semantic vector index 都可以重建。
Markdown files
↓
scan / watch
↓
metadata cache + semantic vector index
↓
recall hintAI 工具层的 memory_search 使用 ripgrep 直接搜索 Markdown 文件,不依赖被动召回索引。即使 semantic index 暂时过期,主动 ripgrep 搜索也能看到文件系统上的当前内容。
不能只依赖文件 watcher。Obsidian、同步盘、git pull、系统休眠都可能让 watcher 漏事件。所以同步要用三层机制:
启动时全量轻扫描
运行时 watcher 增量更新
搜索前节流 dirty check全量轻扫描
启动时扫描:
scan March Memories/**/*.md
↓
for each file:
stat path
if path known and mtime/size unchanged:
skip parsing
else:
parse frontmatter
update index
for indexed paths not seen:
remove from index绝大多数文件只需要 stat,不需要读正文,所以即使记忆库变大,启动成本也可控。
运行时 watcher
watcher 负责快速响应外部修改:
file changed → 重新 parse 该文件 frontmatter
file created → 加入 index
file deleted → 从 index 删除
file renamed → 旧 path 删除 + 新 path 创建移动文件时,如果新文件 frontmatter 里还是同一个 id,March 更新该 id 的 path。这样用户在 Obsidian 里移动文件不会破坏 memory_open(id)。
搜索前 dirty check
每次 search 或 recall 前,March 保证索引不是明显过期的。
可以用节流策略:
if lastScanAge < 5s:
use current index
else:
run lightweight stat scan这让用户刚在 Obsidian 里改完文件时,March 很快能看到变化;同时避免每次模型调用都全量读文件。
索引记录
索引条目至少包含:
MemoryIndexEntry {
id: string
path: string
name: string
description: string
tags: string[]
status: "active" | "deprecated"
createdAt?: string
updatedAt?: string
mtimeMs: number
size: number
contentHash?: string
}mtimeMs + size 用来快速判断文件是否可能变了。contentHash 可选,但建议在文件被读取时计算,用来确认内容是否真的变化。
外部修改规则
外部更新:
mtime/size/hash changed → 重新 parse frontmatter → 更新 index外部删除:
indexed path 不存在 → 删除 index entry
memory_open(id) → not found外部移动:
旧 path 不存在,新 path 出现,id 相同 → 更新 path外部改 id:
旧 id 删除,新 id 创建重复 id:
同一个 id 出现在多个文件 → 标记 conflict
搜索默认排除冲突项
memory_open(id) 返回冲突列表,让用户修正缺 id:
不进入 index缺 description:
可以进入 diagnostics
不参与 recall hintObsidian 兼容性
March 只要求标准 Markdown + YAML frontmatter。用户可以在 Obsidian 里直接:
阅读记忆
编辑 description 和 tags
移动文件
删除文件
使用 Obsidian 搜索和反链March 不依赖 Obsidian 插件,也不要求 Obsidian 正在运行。
为了避免污染用户普通笔记,March 默认只扫描 March Memories/ 子目录。后续如果要支持 whole-vault,应作为显式高级选项,而不是默认行为。
与现有 memory 系统的关系
现有 memory 系统可以参考这些概念:
glossary keywords → tags / aliases / triggers
search_documents → derived index
access_log → recall/open 日志
system views → diagnostics / index health不继续沿用这些作为核心真相:
nodes / edges
SQLite memories 表
独立 [memory] 上下文层
自动注入记忆原文新系统的核心边界是:
Markdown 是真相
index 是缓存
recall hint 只给线索
memory_open 才读原文