双引擎架构:文件 vs 向量数据库
上一期聊了记忆系统的"为什么"和"存什么"——四种精确的记忆类型,以及为什么需要双引擎。
这一期深入拆解两个引擎的内部实现:CCB 怎么把记忆存成文件,MAGMA 怎么把记忆变成向量,以及它们之间怎么协同。
一、CCB:把记忆存成 Markdown 文件
CCB(Claude Code Base)是记忆系统的"精确记忆"引擎。它的核心思路很简单:每个记忆就是一个 .md 文件。
简单不代表简陋。CCB 的设计里有很多精心考虑的细节。
1.1 文件结构
每个记忆文件由两部分组成:
YAML frontmatter——结构化元数据:
---
name: 测试规范
description: 集成测试必须使用真实数据库
type: feedback
source: ccb
---
Markdown 正文——非结构化内容:
集成测试必须使用真实数据库,不要 mock。
**Why:** 上个季度 mock 通过了但生产迁移失败。
**How to apply:** 所有涉及数据库的集成测试,使用真实数据库。
这种"frontmatter + 正文"的结构很聪明:frontmatter 方便程序解析和过滤,正文方便人类阅读和维护。同一个文件,机器和人都看得懂。
1.2 MEMORY.md 索引
CCB 目录下有一个特殊的 MEMORY.md 文件,是所有记忆的索引:
# Memory Index
## User Memories
- [用户角色](user_role.md) — 数据科学家,专注可观测性
- [偏好](user_preferences.md) — 喜欢简洁回答,用 pnpm
## Feedback Memories
- [测试规范](feedback_testing.md) — 集成测试用真实数据库
- [命名风格](feedback_naming.md) — 函数名用动词开头
这个索引是自动生成的,不是手动维护的。每次 Dream 整理记忆后,索引会跟着更新。
索引有两个硬约束:
- 200 行上限——超出就截断并警告
- 每条不超过 150 字符——防止某条索引太长
200 行上限是个有意思的设计。它防止记忆系统无限膨胀——如果一个项目积累了太多记忆,说明该整理了。硬约束比自觉靠谱。
1.3 CCB 读取器
CCB 读取器负责从记忆目录中读取和解析记忆:
// 读取记忆目录
async readMemoryDirectory(): Promise<CCBDirectory> {
const memoryFiles = await this.readAllMemoryFiles()
const index = await this.readMemoryIndex()
return {
memoryFiles,
index,
totalCount: memoryFiles.length,
lastUpdated: index.lastUpdated
}
}
// 按类型过滤
async readByType(type: MemoryType): Promise<Memory[]> {
return this.memoryFiles.filter(m => m.type === type)
}
// 全文搜索
async search(keyword: string): Promise<Memory[]> {
return this.memoryFiles.filter(m =>
m.name.includes(keyword) ||
m.description.includes(keyword) ||
m.content.includes(keyword)
)
}
读取器的逻辑很直接:读文件、解析 frontmatter、返回结构化数据。支持按类型过滤和关键词搜索。
1.4 CCB 整理器
CCB 整理器(CCBConsolidator)负责自动整理记忆:
// 增量整理
async consolidate() {
const lastConsolidatedAt = await this.getLastConsolidatedAt()
for (const memory of this.memoryFiles) {
const hasChanged = await this.hasMemoryChanged(memory, lastConsolidatedAt)
if (!hasChanged) {
continue // 跳过未变化的,这就是"增量"
}
// 合并重复记忆(基于内容哈希)
const duplicate = await this.findDuplicate(memory)
if (duplicate) {
await this.mergeMemories(duplicate, memory)
}
// 去重(保留最新版本)
await this.deduplicate()
}
// 更新索引
await this.updateIndex()
}
关键设计是增量整理——不是每次全量重写,而是只处理变化的部分。这跟 git commit 的思路一样:只记录增量,不重复处理没变的东西。
合并逻辑基于内容哈希:两个记忆如果内容几乎相同(哈希值接近),就合并成一个,保留更完整的那个版本。
二、MAGMA:把记忆变成向量
MAGMA 是记忆系统的"语义记忆"引擎。如果说 CCB 像文件夹——精确、结构化;那 MAGMA 像大脑——模糊、能联想。
2.1 向量存储
MAGMA 的核心存储是 LanceDB——一个开源的向量数据库。记忆被转换成高维向量存储在 LanceDB 中:
// 记忆向量化
async embedMemory(memory: Memory): Promise<Embedding> {
const text = `${memory.name}\n${memory.description}\n${memory.content}`
const embedding = await this.embeddingModel.embed(text)
return embedding // 比如 1536 维的浮点数向量
}
// 存储到 LanceDB
async storeEmbedding(id: string, embedding: Embedding, metadata: MemoryMetadata) {
await this.table.add([{
id,
vector: embedding,
metadata: {
type: metadata.type,
name: metadata.name,
description: metadata.description,
createdAt: metadata.createdAt
}
}])
}
把文本变成向量的过程叫嵌入(Embedding)。嵌入模型会把语义相近的文本映射到向量空间中相近的位置。"代码风格"和"命名规范"虽然是不同的词,但它们的向量会很接近。
2.2 混合检索
MAGMA 不是只用向量搜索,而是向量搜索 + 关键词搜索的混合方案:
// 混合检索
async search(query: string, options: SearchOptions): Promise<SearchResult[]> {
// 1. 向量搜索(语义匹配)
const embedding = await this.embeddingModel.embed(query)
const vectorResults = await this.table.search(embedding)
.limit(options.limit)
.execute()
// 2. 关键词搜索(精确匹配)
const keywordResults = await this.table.search(query)
.limit(options.limit)
.execute()
// 3. 合并结果,去重
return this.mergeAndDeduplicate(vectorResults, keywordResults)
}
为什么要混合?因为两种搜索各有盲区:
- 向量搜索擅长语义匹配,但可能漏掉精确关键词
- 关键词搜索擅长精确匹配,但找不到语义相关但表述不同的内容
混合使用,互相补充。
2.3 五层记忆架构
MAGMA 的记忆分为五层,从瞬时到持久:
| 层 | 名称 | 存储 | 特点 |
|---|---|---|---|
| L1 | 瞬时记忆 | 内存 | 当前会话,速度最快,容量最小 |
| L2 | 项目记忆 | LanceDB | 项目相关知识和上下文 |
| L3 | 用户记忆 | LanceDB | 用户偏好和习惯 |
| L4 | 反馈记忆 | LanceDB | 用户反馈和指导 |
| L5 | 参考记忆 | LanceDB + Obsidian | 外部资源引用,最持久 |
L1 是纯内存字典,纳秒级访问,但关机就没了。L2-L5 存储在向量数据库中,容量大但检索需要计算。
五层架构的设计思路是分层存储:频繁访问的放快速层,不常访问的放容量层。跟 CPU 的 L1/L2/L3 缓存一个道理。
2.4 Obsidian 集成
MAGMA 的一个独特设计是直接读取 Obsidian 笔记作为知识源:
// 读取 Obsidian 笔记
async readObsidianNotes(vaultPath: string): Promise<Note[]> {
const notes: Note[] = []
for (const filePath of await this.listMarkdownFiles(vaultPath)) {
const content = await readFile(filePath, 'utf-8')
const { frontmatter, body } = this.parseFrontmatter(content)
notes.push({
title: frontmatter.title || basename(filePath, '.md'),
content: body,
tags: frontmatter.tags || [],
links: this.extractWikiLinks(body), // [[wikilink]] 引用
path: filePath
})
}
return notes
}
Obsidian 笔记有两个独特价值:
- 知识图谱——Obsidian 的
[[wikilink]]语法天然形成了一个知识网络,MAGMA 可以自动提取这个网络作为图谱 - 用户生成的知识——很多人用 Obsidian 做笔记、写 wiki,MAGMA 直接读这些内容,不需要额外导入
Obsidian 支持三种读取模式:
- direct 模式:直接文件操作,快速、静默
- cli 模式:使用 Obsidian CLI,支持高级功能
- auto 模式:根据操作类型自动选择
三、知识图谱:从 Obsidian 自动提取
MAGMA 的知识图谱不是预先构建的,而是从 Obsidian 笔记中自动提取的。
3.1 图谱构建方式
Obsidian 笔记(WikiLinks)
│
▼ 自动提取
┌─────────────────────────┐
│ [[目标笔记]] → 边 │
│ 笔记标题 → 节点 │
│ 笔记标签 → 节点标签 │
└─────────────────────────┘
│
▼
GraphData { nodes[], edges[] }
提取逻辑非常简单:
// 从 Obsidian 笔记中提取图谱
function extractGraph(notes: Note[]): GraphData {
const nodes: GraphNode[] = []
const edges: GraphEdge[] = []
for (const note of notes) {
// 笔记标题 → 节点
nodes.push({
id: note.title,
label: note.title,
tags: note.tags
})
// [[wikilink]] → 边
for (const link of note.links) {
edges.push({
source: note.title,
target: link.target,
label: link.alias || link.target
})
}
}
return { nodes, edges }
}
不需要手动维护图谱,新增笔记时自动纳入。这比传统的知识图谱方案(手动标注、ETL 管道)轻量得多。
3.2 四种图谱查询类型
系统定义了四种图谱查询分类,在意图路由时决定激活哪些图谱:
| 类型 | 含义 | 激活场景 |
|---|---|---|
concept |
概念图谱 | 因果推理、混合问题 |
entity |
实体图谱 | 实体关系、混合问题 |
relationship |
关系图谱 | 因果推理、实体关系、混合问题 |
temporal |
时间图谱 | 时间线分析 |
这四种类型是查询时的分类维度,不是图谱本身的层数划分。底层图谱是一个统一的 WikiLinks 网络,根据查询意图的不同维度进行过滤和聚焦。
比如用户问"为什么这个 bug 会出现?"——这是因果推理,激活 concept 和 relationship 两个维度,在图谱中查找相关的概念定义和因果关系边。
四、跨系统协同
CCB 和 MAGMA 不是独立运作的,它们通过跨系统检索器协同工作。
4.1 并行检索
当需要双引擎检索时,两个引擎并行执行:
// 混合检索:并行执行
async retrieveBoth(strategy, query) {
const [ccbResult, magmaResult] = await Promise.all([
this.retrieveCCB(query, ccbOptions), // CCB 并行
this.retrieveMAGMA(query, magmaOptions), // MAGMA 并行
])
return this.mergeAndDeduplicate(ccbResult, magmaResult)
}
Promise.all 保证两个引擎并行执行,不会串行等待。哪个先返回先用哪个,最终合并结果。
4.2 结果融合
两个引擎返回的结果格式不同,需要统一:
// 统一结果格式
function unifyResults(ccbResults, magmaResults) {
return [
...ccbResults.map(r => ({
...r,
source: 'ccb',
baseScore: r.relevance
})),
...magmaResults.map(r => ({
...r,
source: 'magma',
baseScore: r.similarity
}))
]
}
CCB 的结果是相关性分数(0-1),MAGMA 的结果是余弦相似度(0-1)。统一后进入同一个排序管道。
4.3 去重
两个引擎可能返回重复的记忆(CCB 通过关键词找到的,MAGMA 通过语义找到的,可能是同一条)。
去重分两层:
精确去重——基于归一化键名 + 内容哈希:
function exactDuplicate(a, b) {
const keyA = normalize(a.name) // 转小写,去标点
const keyB = normalize(b.name)
if (keyA === keyB) return true
const hashA = simpleHash(a.content)
const hashB = simpleHash(b.content)
return hashA === hashB
}
语义去重——基于文本相似度:
function semanticDuplicate(a, b) {
const jaccard = jaccardSimilarity(a.content, b.content) // 词集合交集/并集
const lcs = lCSSimilarity(a.content, b.content) // 最长公共子序列
const score = jaccard * 0.4 + lcs * 0.6 // 混合权重
return score >= 0.85 // 阈值:0.85 以上视为重复
}
两层去重组合使用:精确去重快速过滤明显重复的,语义去重处理同义表达。
五、设计取舍
回顾双引擎的设计,有几个值得讨论的取舍:
1. 为什么不直接用向量数据库? 因为精确记忆(用户名、工具名、配置值)不适合向量化。"pnpm"这个词向量化后可能跟"npm"、"yarn"混在一起,但作为精确事实,CCB 的文件存储更可靠。
2. 为什么不全部用文件? 因为语义搜索是大文件无法做到的。"这个项目的架构"——你不可能在所有记忆文件里搜"架构"两个字然后拼出一个完整的答案。向量搜索能理解语义。
3. 为什么要增量整理? 全量整理的代价太高。一个项目可能有几百条记忆,每次 Dream 都全量重写,既浪费又容易出错。增量整理的可靠性更高。
4. 为什么 MEMORY.md 要有硬上限? 没有上限的索引文件会膨胀。200 行大约对应 50-80 条记忆,对大多数项目来说足够了。如果记忆超过这个量,说明该整理了。
这一期深入拆解了 CCB 和 MAGMA 的内部实现。下一期聊记忆系统最精巧的部分——意图路由。用户问了一个问题,系统怎么判断该去 CCB 找还是 MAGMA 找?怎么决定检索的深度和范围?
系列:
- 上一篇:AI 记忆系统:让 AI 拥有长期记忆
- 下一篇:意图驱动:AI 如何理解你要找什么