AI Agent 记忆系统设计与实现深度解析

37 阅读16分钟

前言

本文基于 OpenClaw 开源项目的 src/memory 模块,完整拆解了一个生产级 AI Agent 记忆系统的设计与实现。

OpenClaw 是一个本地优先的个人 AI 助手,支持 WhatsApp、Telegram、Slack 等多种消息通道。它的记忆模块让 Agent 能够跨会话记住用户的偏好、讨论过的方案、做出的决策——而不是每次对话都从零开始。

该模块的核心思路是:Markdown 文件给人读写,向量索引给机器检索。人类用编辑器维护记忆文件,系统自动构建向量 + 全文索引,Agent 通过混合搜索(向量语义 + BM25 关键词)在记忆中找到相关内容。整套系统从文件监听、分块、嵌入、存储、搜索、结果优化到多层降级容错,形成了完整的闭环。

阅读引导

章节关注点适合谁
1. 为什么需要记忆系统问题定义所有人
2. 架构总览全局视角,理解各模块关系所有人
3. 记忆的生成写入来源、分块策略、嵌入 Provider、数据库设计想自己实现的工程师
4. 记忆的同步文件监听、增量/全量同步、安全重建关心数据一致性的工程师
5. 记忆的检索混合搜索、向量/关键词双路径、结果优化关心搜索质量的工程师
6. 降级与容错Provider Fallback、后端主备、FTS-only 降级关心可用性的工程师
7. 成本控制嵌入缓存、Batch API、本地模型关心 API 费用的工程师
8. 核心设计决策5 个关键 Why 的权衡分析做架构选型的技术负责人
9. 参考价值总结10 个可复用模块 + 完整文件清单想借鉴落地的工程师

如果时间有限,建议至少阅读第 1、2、8 章,可以在 15 分钟内掌握核心设计思路。


目录


1. 为什么需要记忆系统

AI Agent 的核心痛点是无状态——每次对话开始,Agent 对用户的历史一无所知。

记忆系统解决的问题:

问题没有记忆时有记忆时
"我们之前讨论的方案是什么?"Agent 无法回答从记忆中检索到具体方案
"用我习惯的代码风格"Agent 不知道偏好从 MEMORY.md 读取偏好配置
"上周的 bug 修了吗?"需要用户重新描述自动关联历史会话内容

仅存储不够,还需要高效检索。用户用的词往往和文档里的词不同("高并发" vs "令牌桶限流"),这就需要语义级别的检索能力。


2. 架构总览

整个记忆系统由两层组成——文件层(给人用)和索引层(给机器用)。

flowchart TD
    subgraph WRITE["✏️ 写入方"]
        direction LR
        H["人类编辑器<br/>手动编辑 .md"]
        K["session-memory Hook<br/>/new 时自动生成 .md"]
        S["会话 Transcript<br/>.jsonl 自动记录"]
    end

    subgraph FILES["📁 Markdown 文件 — Source of Truth"]
        F["MEMORY.md · memory.md · memory/*.md · session *.jsonl"]
    end

    subgraph SYNC["⚙️ MemoryIndexManager.sync()"]
        PIPELINE["文件监听 → hash 对比 → chunkMarkdown → embedBatch → 写入 SQLite"]
    end

    PROVIDER["🔌 Embedding Provider<br/>local · OpenAI · Gemini<br/>Voyage · Mistral"]

    subgraph DB["🗄️ SQLite 数据库 — 搜索索引"]
        direction LR
        C["chunks<br/>文本 + 向量"]
        V["chunks_vec<br/>sqlite-vec"]
        FTS["chunks_fts<br/>FTS5"]
        EC["embedding_cache<br/>嵌入缓存"]
    end

    subgraph READ["🔍 读取方 — Agent Tool"]
        direction LR
        MS["memory_search<br/>语义召回 → 查向量 + FTS"]
        MG["memory_get<br/>精确获取 → 读 Markdown"]
    end

    H --> F
    K --> F
    S --> F
    F -- "chokidar 监听 / search 触发 / 定时" --> PIPELINE
    PIPELINE <-- "embedQuery / embedBatch" --> PROVIDER
    PIPELINE --> DB
    DB -- "向量 + BM25 混合搜索" --> MS
    F -. "fs.readFile 直接读文件" .-> MG

    style WRITE fill:#F9F9F9,stroke:#E9EBED,stroke-dasharray:5
    style FILES fill:#B3E1DB,stroke:#113366,color:#113366
    style SYNC fill:#E9EBED,stroke:#113366,color:#113366
    style PROVIDER fill:#EE4D2D,stroke:none,color:#FFFFFF
    style DB fill:#B3CEF3,stroke:#113366,color:#113366
    style READ fill:#F9F9F9,stroke:#E9EBED,stroke-dasharray:5
    style MS fill:#EE4D2D,stroke:none,color:#FFFFFF
    style MG fill:#113366,stroke:none,color:#FFFFFF
    style H fill:#113366,stroke:none,color:#FFFFFF
    style K fill:#113366,stroke:none,color:#FFFFFF
    style S fill:#113366,stroke:none,color:#FFFFFF

核心设计理念:Markdown 文件是 source of truth(丢了索引可以重建,丢了文件不行)。向量索引是搜索加速结构,职责类似数据库的 B+ 树索引。

两个 Agent Tool 的分工体现了这一点:

  • memory_search 查的是 SQLite 向量索引——"找到"相关内容
  • memory_get 读的是 Markdown 原始文件——"读全"完整上下文

3. 记忆的生成(写入)

3.1 记忆文件的来源

记忆有两种写入来源:

来源一:人类手动编辑

用户用任何编辑器直接维护 Markdown 文件:

workspace/
├── MEMORY.md              # 核心知识(技术栈、偏好、规范)
├── memory.md              # 同上,备选文件名
└── memory/
    ├── conventions.md     # 项目规范
    └── architecture.md    # 架构决策

这些文件是常青内容(evergreen),不随时间衰减。

来源二:session-memory Hook 自动生成

当用户执行 /new/reset 结束对话时,系统自动:

  1. 读取上一轮会话的最近 N 条消息(默认 15 条)
  2. 调用 LLM 生成描述性文件名 slug(如 "api-design")
  3. 写入 memory/YYYY-MM-DD-slug.md
// session-memory hook 核心逻辑
const filename = `${dateStr}-${slug}.md`;  // 如 2026-02-25-api-design.md
const memoryFilePath = path.join(memoryDir, filename);
await fs.writeFile(memoryFilePath, entry, "utf-8");

生成的文件格式:

# Session: 2026-02-25 14:30:00 UTC

- **Session Key**: agent:main:main
- **Session ID**: abc123def456
- **Source**: telegram

## Conversation Summary

User: 我们需要设计一个 API 限流方案...
Assistant: 推荐使用令牌桶算法...

这些带日期的文件会随时间衰减——30 天后搜索权重减半。

来源三:会话 Transcript(JSONL)

除了 Markdown 记忆文件,系统还支持索引原始会话记录(.jsonl 文件)。会话文件经过特殊处理:

// session-files.ts: 将 JSONL 转为可索引的纯文本
export async function buildSessionEntry(absPath: string): Promise<SessionFileEntry | null> {
  const lines = raw.split("\n");
  const collected: string[] = [];
  const lineMap: number[] = [];  // 记录每行对应的原始 JSONL 行号

  for (const line of lines) {
    const record = JSON.parse(line);
    if (record.type === "message") {
      const text = extractSessionText(message.content);
      const safe = redactSensitiveText(text, { mode: "tools" });  // 脱敏处理
      collected.push(`${label}: ${safe}`);
      lineMap.push(jsonlIdx + 1);  // 保留行号映射,支持精确引用
    }
  }
  return { path, content: collected.join("\n"), lineMap, hash, ... };
}

关键设计:

  • 敏感信息脱敏:入索引前自动 redact
  • 行号映射:chunk 的 startLine/endLine 映射回原始 JSONL 行号,而非展平后的文本行号

3.2 索引构建流程

当文件变化时,indexFile() 执行以下流程:

读取文件内容
  → chunkMarkdown() 分块
  → enforceEmbeddingMaxInputTokens() 确保不超限
  → embedChunksInBatches() 或 embedChunksWithBatch() 获取向量
  → 写入 chunks 表(文本 + 向量 JSON)
  → 写入 chunks_vec 表(sqlite-vec 二进制向量)
  → 写入 chunks_fts 表(FTS5 全文索引)
  → 更新 files 表(文件元信息)

核心代码:

// manager-embedding-ops.ts: indexFile
protected async indexFile(entry, options) {
  const content = options.content ?? await fs.readFile(entry.absPath, "utf-8");

  // Step 1: 分块
  const chunks = enforceEmbeddingMaxInputTokens(
    this.provider,
    chunkMarkdown(content, this.settings.chunking).filter(c => c.text.trim().length > 0),
    EMBEDDING_BATCH_MAX_TOKENS,
  );

  // Step 2: 获取嵌入向量(支持缓存 + 批量 API)
  const embeddings = this.batch.enabled
    ? await this.embedChunksWithBatch(chunks, entry, options.source)
    : await this.embedChunksInBatches(chunks);

  // Step 3: 写入三张表
  for (let i = 0; i < chunks.length; i++) {
    const chunk = chunks[i];
    const embedding = embeddings[i];
    const id = hashText(`${source}:${path}:${startLine}:${endLine}:${hash}:${model}`);

    // 写 chunks 主表
    db.prepare(`INSERT INTO chunks ...`).run(id, path, source, ...);

    // 写 sqlite-vec 向量表
    if (vectorReady && embedding.length > 0) {
      db.prepare(`INSERT INTO chunks_vec (id, embedding) VALUES (?, ?)`)
        .run(id, Buffer.from(new Float32Array(embedding).buffer));
    }

    // 写 FTS5 全文索引表
    if (fts.available) {
      db.prepare(`INSERT INTO chunks_fts (text, id, path, ...) VALUES (?, ?, ?, ...)`)
        .run(chunk.text, id, path, ...);
    }
  }
}

3.3 文本分块策略

分块是检索质量的关键。这个实现采用行级滑动窗口 + 重叠策略:

// internal.ts: chunkMarkdown
export function chunkMarkdown(content: string, chunking: { tokens: number; overlap: number }) {
  const maxChars = Math.max(32, chunking.tokens * 4);   // token → 字符估算(1 token ≈ 4 chars)
  const overlapChars = Math.max(0, chunking.overlap * 4);

  // 按行扫描,累积字符数达到 maxChars 时 flush 一个 chunk
  for (const line of lines) {
    if (currentChars + lineSize > maxChars && current.length > 0) {
      flush();         // 产出一个 chunk
      carryOverlap();  // 保留尾部 overlapChars 作为下一个 chunk 的开头
    }
    current.push({ line, lineNo });
    currentChars += lineSize;
  }
}

设计特点:

特性说明
token 估算tokens × 4 转字符数,粗略但高效,避免引入 tokenizer 依赖
滑动窗口重叠chunk 之间有 overlap,防止语义在边界处被截断
超长行切割单行超过 maxChars 时自动分段
行号追踪每个 chunk 记录 startLine/endLine,支持精确引用回原文
模型限制裁剪enforceEmbeddingMaxInputTokens() 确保每个 chunk 不超过模型的 token 上限

3.4 向量嵌入与多 Provider 支持

嵌入层采用工厂模式,支持 5 种 Provider:

// embeddings.ts: createEmbeddingProvider
export async function createEmbeddingProvider(options): Promise<EmbeddingProviderResult> {
  if (requestedProvider === "auto") {
    // 自动模式:本地 → openai → gemini → voyage → mistral
    if (canAutoSelectLocal(options)) {
      return createProvider("local");   // node-llama-cpp 本地模型
    }
    for (const provider of ["openai", "gemini", "voyage", "mistral"]) {
      try { return await createProvider(provider); } catch { continue; }
    }
    // 全部失败 → 返回 null provider,进入 FTS-only 模式
    return { provider: null, providerUnavailableReason: reason };
  }
  // 指定模式:primary → fallback → FTS-only
}
Provider模型特点
localembeddinggemma-300m (GGUF)零成本,无网络依赖,通过 node-llama-cpp 运行
openaitext-embedding-3-small最稳定,8192 token 上限
geminitext-embedding-004Google 生态
voyagevoyage-3专注嵌入质量
mistralmistral-embed多语言支持好

统一的 EmbeddingProvider 接口:

export type EmbeddingProvider = {
  id: string;
  model: string;
  maxInputTokens?: number;
  embedQuery: (text: string) => Promise<number[]>;     // 单条查询
  embedBatch: (texts: string[]) => Promise<number[][]>; // 批量索引
};

所有远程 Provider 都通过统一的 createRemoteEmbeddingProvider() 构建,共享同一套 HTTP 请求、重试、超时逻辑:

// embeddings-remote-provider.ts
export function createRemoteEmbeddingProvider(params) {
  const url = `${client.baseUrl}/embeddings`;
  const embed = async (input: string[]) => {
    return await fetchRemoteEmbeddingVectors({ url, headers, body: { model, input } });
  };
  return { id, model, embedQuery: (text) => embed([text])[0], embedBatch: embed };
}

3.5 数据库 Schema 设计

存储层使用 Node.js 内置的 node:sqlite + sqlite-vec 扩展 + FTS5:

-- 元信息:记录当前索引的 model/provider/配置,用于判断是否需要全量重建
CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);

-- 文件记录:通过 hash 判断文件是否变化
CREATE TABLE files (
  path TEXT PRIMARY KEY,
  source TEXT NOT NULL DEFAULT 'memory',  -- 'memory' | 'sessions'
  hash TEXT NOT NULL,
  mtime INTEGER NOT NULL,
  size INTEGER NOT NULL
);

-- 文本块:核心数据表
CREATE TABLE chunks (
  id TEXT PRIMARY KEY,       -- hash(source:path:startLine:endLine:hash:model)
  path TEXT NOT NULL,
  source TEXT NOT NULL DEFAULT 'memory',
  start_line INTEGER NOT NULL,
  end_line INTEGER NOT NULL,
  hash TEXT NOT NULL,         -- 内容 hash,用于缓存命中
  model TEXT NOT NULL,        -- 嵌入模型名,切换模型时触发重建
  text TEXT NOT NULL,
  embedding TEXT NOT NULL,    -- JSON 格式的向量
  updated_at INTEGER NOT NULL
);

-- sqlite-vec 向量索引:原生向量近邻搜索
CREATE VIRTUAL TABLE chunks_vec USING vec0(
  id TEXT PRIMARY KEY,
  embedding FLOAT[1536]       -- 维度随模型变化
);

-- FTS5 全文索引:BM25 关键词搜索
CREATE VIRTUAL TABLE chunks_fts USING fts5(
  text,
  id UNINDEXED, path UNINDEXED, source UNINDEXED,
  model UNINDEXED, start_line UNINDEXED, end_line UNINDEXED
);

-- 嵌入缓存:避免重复 API 调用
CREATE TABLE embedding_cache (
  provider TEXT NOT NULL,
  model TEXT NOT NULL,
  provider_key TEXT NOT NULL,  -- 标识 API endpoint + headers
  hash TEXT NOT NULL,          -- 文本内容 hash
  embedding TEXT NOT NULL,
  dims INTEGER,
  updated_at INTEGER NOT NULL,
  PRIMARY KEY (provider, model, provider_key, hash)
);

设计要点:

  • chunks.model 字段:切换嵌入模型时,旧向量和新向量不可比较,通过 model 字段过滤
  • embedding_cache 四元组主键:同一文本在不同 provider/model 下产出不同向量,需要分别缓存
  • source 字段:区分 memory 文件和 session 文件,支持按来源过滤

4. 记忆的同步(更新)

4.1 同步触发机制

系统有 5 种触发同步的方式,覆盖不同场景:

触发方式触发时机适用场景
watchchokidar 监听到文件增/删/改用户编辑 MEMORY.md 后自动更新
search搜索时发现 dirty 标记搜索前保证索引是最新的
session-start新会话开始时预热索引
session-delta会话文件增长超过阈值长对话中增量索引新内容
interval定时器(可配置分钟数)兜底,防止遗漏

文件监听的实现:

// manager-sync-ops.ts
protected ensureWatcher() {
  this.watcher = chokidar.watch([
    path.join(workspaceDir, "MEMORY.md"),
    path.join(workspaceDir, "memory.md"),
    path.join(workspaceDir, "memory", "**", "*.md"),
    // + 配置的额外路径
  ], {
    ignoreInitial: true,
    ignored: (p) => shouldIgnoreMemoryWatchPath(p),  // 忽略 .git, node_modules 等
    awaitWriteFinish: { stabilityThreshold: debounceMs, pollInterval: 100 },
  });

  const markDirty = () => { this.dirty = true; this.scheduleWatchSync(); };
  this.watcher.on("add", markDirty);
  this.watcher.on("change", markDirty);
  this.watcher.on("unlink", markDirty);
}

会话增量同步使用字节/消息数双阈值

// 当会话文件增长超过 deltaBytes 或 deltaMessages 时触发
const bytesHit = delta.pendingBytes >= thresholds.deltaBytes;
const messagesHit = delta.pendingMessages >= thresholds.deltaMessages;
if (bytesHit || messagesHit) {
  this.sessionsDirty = true;
  void this.sync({ reason: "session-delta" });
}

4.2 增量同步与全量重建

增量同步(常规路径):只处理变化的文件

// 对比文件 hash,未变化则跳过
const record = db.prepare(`SELECT hash FROM files WHERE path = ? AND source = ?`)
  .get(entry.path, "memory");
if (!needsFullReindex && record?.hash === entry.hash) {
  return; // 跳过
}
await this.indexFile(entry, { source: "memory" });

全量重建触发条件:

const needsFullReindex =
  params?.force ||                                    // 用户手动强制
  !meta ||                                            // 首次运行
  meta.model !== this.provider.model ||               // 切换了嵌入模型
  meta.provider !== this.provider.id ||               // 切换了 provider
  meta.providerKey !== this.providerKey ||            // API endpoint 变了
  this.metaSourcesDiffer(meta, configuredSources) ||  // sources 配置变了
  meta.chunkTokens !== this.settings.chunking.tokens || // 分块参数变了
  meta.chunkOverlap !== this.settings.chunking.overlap;

4.3 安全重建索引

全量重建使用临时数据库 + 原子交换,确保在线查询不受影响:

// manager-sync-ops.ts: runSafeReindex
async runSafeReindex(params) {
  const tempDbPath = `${dbPath}.tmp-${randomUUID()}`;
  const tempDb = this.openDatabaseAtPath(tempDbPath);

  // 1. 切换到临时 DB
  this.db = tempDb;
  this.ensureSchema();

  // 2. 从旧 DB 复制嵌入缓存(避免重复 API 调用)
  this.seedEmbeddingCache(originalDb);

  // 3. 在临时 DB 中完成全量索引
  await this.syncMemoryFiles({ needsFullReindex: true });
  await this.syncSessionFiles({ needsFullReindex: true });

  // 4. 关闭两个 DB,原子交换文件
  this.db.close();
  originalDb.close();
  await this.swapIndexFiles(dbPath, tempDbPath);  // rename 原子操作

  // 5. 重新打开正式 DB
  this.db = this.openDatabaseAtPath(dbPath);
}

失败时自动回滚:

catch (err) {
  this.db.close();
  await this.removeIndexFiles(tempDbPath);  // 清理临时文件
  restoreOriginalState();                   // 恢复旧 DB
  throw err;
}

5. 记忆的检索(读取)

5.1 两种 Agent Tool

系统为 AI Agent 提供两个互补的工具:

memory_search — 语义召回(查索引)

// Agent Tool 定义
{
  name: "memory_search",
  description: "Mandatory recall step: semantically search MEMORY.md + memory/*.md ...",
  execute: async (params) => {
    const { manager } = await getMemorySearchManager({ cfg, agentId });
    const results = await manager.search(query, { maxResults, minScore, sessionKey });
    // 返回: [{ path, startLine, endLine, score, snippet, source, citation }]
  }
}

何时使用:Agent 收到用户问题的第一步,语义级别的模糊搜索,返回最相关的 chunk 片段。

memory_get — 精确获取(读文件)

// Agent Tool 定义
{
  name: "memory_get",
  description: "Safe snippet read from MEMORY.md or memory/*.md with optional from/lines; "
    + "use after memory_search to pull only the needed lines ...",
  execute: async (params) => {
    const { manager } = await getMemorySearchManager({ cfg, agentId });
    const result = await manager.readFile({ relPath, from, lines });
    // 返回: { text: "文件原始内容", path: "memory/xxx.md" }
  }
}

何时使用memory_search 找到线索后,需要更多上下文时,按路径+行号读取 Markdown 原文。

典型使用链路

用户: "之前讨论的 API 限流方案是什么?"

Step 1: Agent 调用 memory_search("API 限流方案")
  → 返回: [{ path: "memory/2026-01-10.md", startLine: 15, endLine: 28,
             score: 0.87, snippet: "对接口设置令牌桶限流..." }]

Step 2: Agent 需要更多上下文,调用 memory_get
  → readFile({ relPath: "memory/2026-01-10.md", from: 10, lines: 30 })
  → 返回完整的 Markdown 原文

Step 3: Agent 基于完整上下文回答用户

5.2 混合搜索引擎

搜索的核心是向量 + 关键词的混合检索

// manager.ts: search()
async search(query, opts?) {
  // 无 Provider → 纯 FTS 关键词搜索
  if (!this.provider) {
    const keywords = extractKeywords(cleaned);  // 多语言停用词过滤 + 关键词提取
    return searchKeyword(keywords, candidates);
  }

  // 有 Provider → 混合搜索
  const keywordResults = await this.searchKeyword(cleaned, candidates);
  const queryVec = await this.embedQueryWithTimeout(cleaned);  // 1 次 Embedding API 调用
  const vectorResults = await this.searchVector(queryVec, candidates);

  // 加权融合 + 时间衰减 + MMR 重排序
  return this.mergeHybridResults({
    vector: vectorResults,
    keyword: keywordResults,
    vectorWeight: hybrid.vectorWeight,
    textWeight: hybrid.textWeight,
    mmr: hybrid.mmr,
    temporalDecay: hybrid.temporalDecay,
  });
}

5.3 向量搜索路径

向量搜索有两种实现,自动根据环境选择:

// manager-search.ts: searchVector
export async function searchVector(params) {
  // 路径 1: sqlite-vec 可用 → 原生向量索引(推荐)
  if (await params.ensureVectorReady(queryVec.length)) {
    return db.prepare(
      `SELECT c.*, vec_distance_cosine(v.embedding, ?) AS dist
       FROM chunks_vec v JOIN chunks c ON c.id = v.id
       WHERE c.model = ?
       ORDER BY dist ASC LIMIT ?`
    ).all(vectorToBlob(queryVec), providerModel, limit);
    // score = 1 - dist(距离越小,相似度越高)
  }

  // 路径 2: sqlite-vec 不可用 → 全量暴力余弦计算(降级)
  const candidates = listChunks({ db, providerModel });
  return candidates
    .map(chunk => ({ chunk, score: cosineSimilarity(queryVec, chunk.embedding) }))
    .toSorted((a, b) => b.score - a.score)
    .slice(0, limit);
}

向量转为二进制 blob 以供 sqlite-vec 使用:

const vectorToBlob = (embedding: number[]): Buffer =>
  Buffer.from(new Float32Array(embedding).buffer);

5.4 关键词搜索路径

基于 SQLite FTS5 的 BM25 全文搜索:

// manager-search.ts: searchKeyword
export async function searchKeyword(params) {
  const ftsQuery = buildFtsQuery(query);  // "限流 方案" → '"限流" AND "方案"'

  return db.prepare(
    `SELECT id, path, text, bm25(chunks_fts) AS rank
     FROM chunks_fts
     WHERE chunks_fts MATCH ?
     ORDER BY rank ASC LIMIT ?`
  ).all(ftsQuery, limit);
  // score = 1 / (1 + rank)  — BM25 rank 归一化到 [0, 1]
}

FTS-only 模式下,查询会经过多语言关键词提取,支持中/英/日/韩/西/葡/阿 7 种语言的停用词过滤:

// query-expansion.ts
export function extractKeywords(query: string): string[] {
  const tokens = tokenize(query);  // 支持 CJK 字符级分词
  return tokens.filter(t =>
    !STOP_WORDS_EN.has(t) && !STOP_WORDS_ZH.has(t) && !STOP_WORDS_JA.has(t) &&
    !STOP_WORDS_KO.has(t) && !STOP_WORDS_ES.has(t) && !STOP_WORDS_PT.has(t) &&
    !STOP_WORDS_AR.has(t) && isValidKeyword(t)
  );
}
// "之前讨论的那个方案" → ["讨论", "方案"](过滤掉 "之前", "的", "那个")

5.5 结果融合与优化

搜索结果经过三层处理管线:

第一层:加权融合

// hybrid.ts
const score = vectorWeight * vectorScore + textWeight * textScore;

向量搜索和关键词搜索的结果按 ID 合并,同时出现在两个结果集的 chunk 会获得双重加分。

第二层:时间衰减

// temporal-decay.ts
// 指数衰减:score = score × e^(-λ × age)
// halfLifeDays=30 时:30 天前 → 权重 0.5,60 天前 → 0.25
export function calculateTemporalDecayMultiplier({ ageInDays, halfLifeDays }) {
  const lambda = Math.LN2 / halfLifeDays;
  return Math.exp(-lambda * Math.max(0, ageInDays));
}

时间来源优先级:

  1. 文件名日期 memory/2026-01-15.md → 直接解析
  2. 常青文件 MEMORY.mdmemory/conventions.md不衰减
  3. fallback 到文件 mtime

第三层:MMR 多样性重排序

// mmr.ts
// MMR = λ × relevance - (1-λ) × max_similarity_to_selected
export function computeMMRScore(relevance, maxSimilarity, lambda) {
  return lambda * relevance - (1 - lambda) * maxSimilarity;
}

使用 Jaccard 相似度(token 集合的交/并)衡量结果间的相似程度,迭代选择既相关又多样的结果。避免 top-K 被高度相似的内容霸占。


6. 降级与容错

6.1 Provider 三层 Fallback

auto 模式:local → openai → gemini → voyage → mistral → FTS-only
指定模式:primary → fallback → FTS-only

运行时如果 primary provider 出现嵌入错误,还能自动切换:

// manager-sync-ops.ts
catch (err) {
  const reason = err.message;
  // 嵌入相关错误 → 尝试激活 fallback provider
  if (this.shouldFallbackOnError(reason)) {
    const activated = await this.activateFallbackProvider(reason);
    if (activated) {
      await this.runSafeReindex({ reason: "fallback", force: true });
      return;
    }
  }
  throw err;
}

6.2 后端主备切换

FallbackMemoryManager 实现了透明的主备切换:

// search-manager.ts
class FallbackMemoryManager implements MemorySearchManager {
  async search(query, opts?) {
    if (!this.primaryFailed) {
      try {
        return await this.deps.primary.search(query, opts);  // 先走 QMD
      } catch (err) {
        this.primaryFailed = true;
        await this.deps.primary.close?.();
        this.evictCacheEntry();  // 从缓存移除,下次可重试
      }
    }
    const fallback = await this.ensureFallback();  // 懒创建 builtin 后端
    return await fallback.search(query, opts);
  }
}

6.3 FTS-only 降级模式

当所有 Embedding Provider 都不可用时(没有 API key、没有本地模型),系统不会直接报错,而是退化为纯关键词搜索:

// manager.ts: search() 中的 FTS-only 分支
if (!this.provider) {
  // 没有向量能力,只用 FTS
  const keywords = extractKeywords(cleaned);
  const resultSets = await Promise.all(
    searchTerms.map(term => this.searchKeyword(term, candidates))
  );
  // 合并去重,按分数排序
  return merged;
}

这意味着:零配置也能使用记忆搜索,只是精度从"语义级"降到"关键词级"。


7. 成本控制

优化措施实现方式节省
嵌入缓存embedding_cache 表,按 provider+model+hash 去重相同内容不重复调 API
增量同步文件 hash 对比,只处理变化的文件未修改文件零成本
Batch APIOpenAI/Gemini/Voyage 批量嵌入接口比逐条调用便宜约 50%
本地嵌入node-llama-cpp + embeddinggemma-300m完全零成本
FTS-only 降级无 API key 时纯 FTS零成本可用
缓存跨重建迁移全量重建时从旧 DB seed 缓存到新 DB重建不重复调 API
缓存 LRU 淘汰超过 maxEntries 时删除最旧的条目控制缓存大小

嵌入缓存的写入和重建迁移:

// 写入缓存
private upsertEmbeddingCache(entries: Array<{ hash: string; embedding: number[] }>) {
  for (const entry of entries) {
    stmt.run(provider.id, provider.model, providerKey, entry.hash,
             JSON.stringify(entry.embedding), entry.embedding.length, Date.now());
  }
}

// 重建时迁移缓存
private seedEmbeddingCache(sourceDb: DatabaseSync) {
  const rows = sourceDb.prepare(
    `SELECT * FROM embedding_cache`
  ).all();
  for (const row of rows) {
    insert.run(row.provider, row.model, row.provider_key, row.hash,
               row.embedding, row.dims, row.updated_at);
  }
}

API 调用还有重试 + 指数退避机制:

// manager-embedding-ops.ts
protected async embedBatchWithRetry(texts: string[]) {
  let attempt = 0;
  let delayMs = 500;  // 起始 500ms
  while (true) {
    try {
      return await this.withTimeout(this.provider.embedBatch(texts), timeoutMs, ...);
    } catch (err) {
      if (!isRetryableError(err) || attempt >= 3) throw err;
      // 429/rate-limit/5xx → 指数退避重试
      await sleep(Math.min(8000, delayMs * (1 + Math.random() * 0.2)));
      delayMs *= 2;
      attempt += 1;
    }
  }
}

8. 核心设计决策与权衡

决策 1: Markdown 文件 + 向量索引(而非纯向量数据库)

选择:记忆以 Markdown 文件为 source of truth,向量索引是可重建的搜索加速结构。

Why

  • 人类可以直接用编辑器读写 Markdown,向量数据库做不到
  • Markdown 可以 git diff、code review、版本回溯
  • 索引丢失不丢数据,从文件重建即可
  • 文件复制即迁移,不依赖特定数据库

权衡:需要额外的同步机制保持文件和索引一致。

决策 2: SQLite 而非专用向量数据库

选择:SQLite + sqlite-vec + FTS5,单文件数据库。

Why

  • 零部署成本,嵌入在应用进程中
  • 单文件便于备份和迁移
  • sqlite-vec 在万级 chunk 规模下性能足够
  • 与 FTS5 共享同一个 SQLite 实例,减少复杂度

权衡:不适合多用户并发写入,不适合百万级向量。

决策 3: 混合搜索(而非纯向量搜索)

选择:向量搜索 + BM25 关键词搜索,加权融合。

Why

  • 向量擅长语义匹配("高并发" ≈ "QPS 限制")
  • 关键词擅长精确匹配(函数名、配置项、ID)
  • 两者互补,单独使用都有盲区

权衡:融合权重需要调优,线性加权不一定是最优融合方式。

决策 4: Jaccard MMR 而非向量 MMR

选择:MMR 重排序使用 Jaccard 相似度(token 集合交/并),而非向量余弦。

Why

  • 不需要额外的嵌入计算
  • 基于已有 snippet 文本即可完成
  • 性能更轻量

权衡:语义区分度不如向量余弦。"自然语言处理" 和 "NLP" 的 Jaccard = 0,但语义相同。

决策 5: 默认关闭 MMR 和时间衰减

选择mmr.enabled = falsetemporalDecay.enabled = false

Why

  • 大多数场景下基础的混合搜索已经够好
  • MMR 和时间衰减引入了额外的复杂度和性能开销
  • opt-in 让用户按需启用

9. 参考价值总结

如果你要在自己的项目中实现类似的记忆系统,以下是可以直接借鉴的设计:

模块可复用的设计适用场景
统一接口MemorySearchManager 接口抽象任何需要多后端的搜索系统
混合搜索向量 + BM25 加权融合RAG 系统、知识库检索
分块策略行级滑动窗口 + overlap + 行号追踪任何需要文档分块的场景
嵌入缓存按 provider+model+hash 缓存嵌入减少 Embedding API 成本
增量同步文件 hash 对比 + 文件监听实时性要求不高的索引系统
安全重建临时 DB → 原子交换 → 失败回滚任何在线索引重建场景
三层降级auto provider → fallback → FTS-only需要高可用的 AI 应用
主备切换FallbackManager 代理模式多后端容错
时间衰减指数衰减 + 常青文件豁免需要时效性的知识检索
多语言 FTS7 语言停用词 + CJK 字符级分词面向国际化的搜索系统

文件清单

src/memory/
├── index.ts                    # 模块公共导出
├── types.ts                    # 核心类型定义
├── manager.ts                  # MemoryIndexManager(主入口)
├── manager-sync-ops.ts         # 同步操作基类(文件监听、增量/全量同步)
├── manager-embedding-ops.ts    # 嵌入操作(分块、批量嵌入、缓存)
├── manager-search.ts           # 搜索实现(向量搜索、关键词搜索)
├── search-manager.ts           # 后端选择 + FallbackManager
├── backend-config.ts           # 后端配置解析
├── memory-schema.ts            # SQLite Schema 定义
├── internal.ts                 # 文件扫描、分块、hash、余弦相似度
├── session-files.ts            # 会话 JSONL 解析与索引
├── hybrid.ts                   # 混合搜索融合
├── temporal-decay.ts           # 时间衰减
├── mmr.ts                      # MMR 多样性重排序
├── query-expansion.ts          # 多语言关键词提取(FTS-only 模式)
├── embeddings.ts               # Embedding Provider 工厂
├── embeddings-openai.ts        # OpenAI Provider
├── embeddings-gemini.ts        # Gemini Provider
├── embeddings-voyage.ts        # Voyage Provider
├── embeddings-mistral.ts       # Mistral Provider
├── embeddings-remote-provider.ts # 通用远程 Provider
├── embedding-chunk-limits.ts   # 分块大小限制
├── embedding-input-limits.ts   # UTF-8 字节限制
├── embedding-model-limits.ts   # 模型 token 限制
├── sqlite.ts                   # Node SQLite 加载
├── sqlite-vec.ts               # sqlite-vec 扩展加载
├── node-llama.ts               # node-llama-cpp 本地嵌入
├── batch-runner.ts             # 批量 API 执行器
├── batch-openai.ts             # OpenAI Batch API
├── batch-gemini.ts             # Gemini Batch API
├── batch-voyage.ts             # Voyage Batch API
└── fs-utils.ts                 # 文件工具函数

总结一句话:这是一个为本地优先的 AI Agent 设计的记忆系统——Markdown 文件给人读写,向量索引给机器检索,混合搜索兼顾语义和精确匹配,三层降级保证在任何环境下都能用。整体设计在工程质量、成本控制、可用性之间取得了很好的平衡。