双引擎架构:文件 vs 向量数据库

双引擎架构:文件 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 笔记有两个独特价值:

  1. 知识图谱——Obsidian 的 [[wikilink]] 语法天然形成了一个知识网络,MAGMA 可以自动提取这个网络作为图谱
  2. 用户生成的知识——很多人用 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 会出现?"——这是因果推理,激活 conceptrelationship 两个维度,在图谱中查找相关的概念定义和因果关系边。

四、跨系统协同

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 找?怎么决定检索的深度和范围?

系列