OpenClaw Memory 系统深度解析:从文件到向量的完整实现

引言

在 AI 助手领域,记忆系统是实现长期对话和知识积累的关键。OpenClaw 作为一个开源的自托管 AI 助手平台,实现了一套完整的 Memory 系统,将简单的文件存储与强大的向量检索相结合。本文将深入剖析 OpenClaw Memory 系统的完整技术实现,从文件系统设计到向量检索,从工具接口到混合排序算法。

本文涵盖的内容

  1. 基于文件系统的 Memory 和 Message 存储设计
  2. 文件监听机制与向量/全文数据库的增删改查联动
  3. Memory 工具设计与 LLM Prompt 集成
  4. 关键词搜索的 BM25 算法实现
  5. Embedding 模型的自动选择与配置
  6. 混合检索的排序算法与优化策略

让我们从最基础的文件系统设计开始。


第一部分:基于文件系统的存储架构

1.1 设计理念

OpenClaw 的 Memory 系统采用了一个优雅的设计原则:文件是数据的唯一真实来源(Single Source of Truth)

markdown 复制代码
文件系统(Markdown)
        ↓
    主数据源
        ↓
索引是派生的(可重建)

这种设计带来了几个关键优势:

  1. 人类可读可编辑:Memory 数据存储为纯文本 Markdown 格式
  2. 版本控制友好:可以使用 Git 管理知识库
  3. 无锁定风险:不依赖专有数据库格式
  4. 灾难恢复简单:索引损坏时可以完全重建

1.2 文件系统结构

工作区组织

perl 复制代码
~/.openclaw/agents/<agentId>/
├── workspace/
│   ├── MEMORY.md                    # 持久知识库(主文件)
│   ├── memory.md                    # 备选主文件
│   └── memory/                      # 记忆目录
│       ├── 2024-03-15.md           # 每日日志
│       ├── 2024-03-16.md
│       ├── projects/               # 子目录支持
│       │   └── openclaw.md
│       └── config/
│           └── discord-setup.md
└── sessions/                        # 会话历史
    ├── session_abc123.jsonl        # JSONL 格式
    ├── session_def456.jsonl
    └── ...

文件类型详解

MEMORY.md:持久知识库

用途:存储长期、结构化的知识

推荐格式

markdown 复制代码
# 项目信息

OpenClaw 是一个自托管的 AI 助手平台。

## 配置

### Discord
- Token: abc123
- Channel ID: 456789

### Slack
- Token: xyz789
- Workspace: myworkspace

## 开发指南

### 环境设置
...

特点

  • 手动编辑和维护
  • 结构化组织
  • 长期保存

memory/YYYY-MM-DD.md:每日日志

用途:存储时间敏感的上下文和事件

格式示例

markdown 复制代码
# 2024-03-15

- 10:30 - 用户配置了 Discord 渠道
- 11:00 - 讨论了 Memory 系统的实现细节
- 14:00 - 解决了向量搜索的性能问题

## 决策

决定使用 SQLite FTS5 进行全文搜索。

## 待办

- [ ] 实现 MMR 去重算法
- [ ] 添加时间衰减功能

特点

  • 自动追加(pre-compaction flush)
  • 时间序列
  • 事件驱动

sessions/*.jsonl:对话历史

用途:存储完整的对话记录

格式示例

jsonl 复制代码
{"type":"meta","data":{"agentId":"default","createdAt":1234567890}}
{"type":"message","role":"user","content":"Hello"}
{"type":"message","role":"assistant","content":"Hi there!"}
{"type":"message","role":"user","content":"What's 2+2?"}
{"type":"message","role":"assistant","content":[{"type":"thinking","thinking":"..."},{"type":"text","text":"2+2 equals 4"}]}
{"type":"toolUse","name":"read","params":{"path":"file.txt"},"toolCallId":"call_123"}
{"type":"toolResult","toolCallId":"call_123","result":"File contents..."}

特点

  • JSONL 格式(每行一个 JSON)
  • 包含工具调用和结果
  • 支持增量索引

1.3 文件写入机制

OpenClaw 采用 标准工具 + 自动刷新 的混合策略。

策略 1:使用标准 write 工具

Agent 使用标准的 write 工具写入 Memory 文件:

typescript 复制代码
// LLM 调用
{
  "tool": "write",
  "input": {
    "path": "MEMORY.md",
    "content": "## Discord 配置\n\nToken: abc123\n"
  }
}

特点

  • 与其他文件写入一致
  • 受工作区权限控制
  • 支持完整覆盖

策略 2:Pre-compaction Memory Flush(自动)

最重要的自动写入机制

erlang 复制代码
正常对话...
    ↓
上下文接近 token 限制(80%)
    ↓
[OpenClaw 暂停]
    ↓
发送特殊提示:
  "Before we compact context, write important facts to memory/2024-03-15.md"
    ↓
Agent 自动调用 write 工具
    ↓
写入完成
    ↓
执行上下文压缩
    ↓
恢复对话(记忆已保存)

配置

json 复制代码
{
  "agents": {
    "defaults": {
      "compaction": {
        "memoryFlush": {
          "enabled": true,
          "targetPath": "memory/YYYY-MM-DD.md",
          "prompt": "Write any lasting notes to memory/YYYY-MM-DD.md"
        }
      }
    }
  }
}

时机

  • Token 使用率达到阈值(默认 80%)
  • 消息数量超过限制
  • 手动触发压缩

为什么重要

  • 防止长对话中信息丢失
  • 自动化知识积累
  • 无需用户干预

策略 3:用户明确要求

arduino 复制代码
用户:"记住:我的 Discord token 是 abc123"
    ↓
LLM 理解意图
    ↓
调用 write 工具保存
    ↓
回复:"已记住,保存到 MEMORY.md"

第二部分:文件监听与数据库同步机制

2.1 文件监听架构

OpenClaw 使用 Chokidar 实现实时文件监听。

监听器配置

typescript 复制代码
const watcher = chokidar.watch([
  'MEMORY.md',
  'memory.md',
  'memory/**/*.md',
  ...extraPaths
], {
  ignoreInitial: false,
  ignored: shouldIgnoreMemoryWatchPath,
  awaitWriteFinish: {
    stabilityThreshold: 1500,  // 防抖 1.5 秒
    pollInterval: 100
  }
})

监听的事件

typescript 复制代码
watcher.on('add', markDirty)       // 新文件
watcher.on('change', markDirty)    // 修改文件
watcher.on('unlink', markDirty)    // 删除文件

防抖机制

ini 复制代码
文件变化事件
    ↓
标记 dirty = true
    ↓
等待 stabilityThreshold(1500ms)
    ↓
确保文件写入完成
    ↓
触发同步

为什么需要防抖?

  1. 避免写入过程中的部分读取
  2. 批量处理多个文件变化
  3. 减少重复索引

2.2 数据库架构设计

OpenClaw 使用 SQLite + 三表架构

sql 复制代码
-- 1. 文件元数据表
CREATE TABLE files (
  path TEXT PRIMARY KEY,
  source TEXT NOT NULL,        -- 'memory' 或 'sessions'
  hash TEXT NOT NULL,          -- 内容 SHA256
  mtime INTEGER NOT NULL,      -- 修改时间
  size INTEGER NOT NULL        -- 文件大小
);

-- 2. 分块主表
CREATE TABLE chunks (
  id TEXT PRIMARY KEY,         -- 唯一 ID
  path TEXT NOT NULL,          -- 文件路径
  source TEXT NOT NULL,        -- 数据源
  start_line INTEGER NOT NULL, -- 起始行号(1-indexed)
  end_line INTEGER NOT NULL,   -- 结束行号
  hash TEXT NOT NULL,          -- 块哈希
  model TEXT NOT NULL,         -- Embedding 模型
  text TEXT NOT NULL,          -- 块文本
  embedding TEXT NOT NULL,     -- JSON 序列化的向量
  updated_at INTEGER NOT NULL  -- 更新时间戳
);

-- 3. 向量虚拟表(sqlite-vec 扩展)
CREATE VIRTUAL TABLE chunks_vec USING vec0(
  id TEXT PRIMARY KEY,
  embedding FLOAT[1536]        -- 向量维度由模型决定
);

-- 4. 全文搜索虚拟表(FTS5)
CREATE VIRTUAL TABLE chunks_fts USING fts5(
  text,                        -- 索引的文本
  id UNINDEXED,               -- 元数据(不索引)
  path UNINDEXED,
  source UNINDEXED,
  model UNINDEXED,
  start_line UNINDEXED,
  end_line UNINDEXED
);

-- 5. 嵌入缓存表
CREATE TABLE embedding_cache (
  provider TEXT,
  model TEXT,
  provider_key TEXT,
  hash TEXT,                   -- 块哈希
  embedding TEXT,              -- 缓存的向量
  dims INTEGER,
  updated_at INTEGER,
  PRIMARY KEY (provider, model, provider_key, hash)
);

索引设计

sql 复制代码
CREATE INDEX idx_chunks_path ON chunks(path);
CREATE INDEX idx_chunks_source ON chunks(source);

2.3 同步流程:增删改查联动

增量同步流程

sql 复制代码
文件变化触发
    ↓
┌─────────────────────────────────────────────────────┐
│ 1. 扫描文件系统                                      │
│    - 列出所有 .md 文件                               │
│    - 计算文件哈希(SHA256)                          │
│    - 读取修改时间和大小                              │
└─────────────────────────────────────────────────────┘
    ↓
┌─────────────────────────────────────────────────────┐
│ 2. 对比数据库                                        │
│    - 查询 files 表                                   │
│    - 找出新增文件(不在 files 表)                   │
│    - 找出修改文件(哈希值变化)                       │
│    - 找出删除文件(在 files 表但不在文件系统)       │
└─────────────────────────────────────────────────────┘
    ↓
┌─────────────────────────────────────────────────────┐
│ 3. 处理新增和修改                                    │
│    For each changed file:                           │
│      ├─ 读取文件内容                                 │
│      ├─ 分块(400 token,80 token 重叠)            │
│      ├─ 检查嵌入缓存                                 │
│      ├─ 批量生成 embedding(未缓存的块)            │
│      ├─ L2 归一化向量                                │
│      └─ 写入三个表:                                 │
│         ├─ chunks(主表)                            │
│         ├─ chunks_vec(向量)                        │
│         └─ chunks_fts(全文索引)                    │
└─────────────────────────────────────────────────────┘
    ↓
┌─────────────────────────────────────────────────────┐
│ 4. 处理删除                                          │
│    For each deleted file:                           │
│      ├─ DELETE FROM chunks_vec WHERE id IN (...)    │
│      ├─ DELETE FROM chunks WHERE path = ?           │
│      ├─ DELETE FROM chunks_fts WHERE path = ?       │
│      └─ DELETE FROM files WHERE path = ?            │
└─────────────────────────────────────────────────────┘
    ↓
┌─────────────────────────────────────────────────────┐
│ 5. 更新文件元数据                                    │
│    INSERT OR REPLACE INTO files                     │
│    VALUES (path, source, hash, mtime, size)         │
└─────────────────────────────────────────────────────┘

分块算法

核心代码

typescript 复制代码
function chunkMarkdown(
  content: string,
  tokens: number = 400,
  overlap: number = 80
): Chunk[] {
  const maxChars = Math.max(32, tokens * 4)      // ~1600 字符
  const overlapChars = overlap * 4                // ~320 字符

  const lines = content.split('\n')
  const chunks: Chunk[] = []
  let currentChunk = ''
  let startLine = 1

  for (let i = 0; i < lines.length; i++) {
    currentChunk += lines[i] + '\n'

    if (currentChunk.length >= maxChars) {
      chunks.push({
        text: currentChunk,
        startLine: startLine,
        endLine: i + 1,
        hash: sha256(currentChunk)
      })

      // 建立重叠
      const overlap = currentChunk.slice(-overlapChars)
      currentChunk = overlap
      startLine = i + 1 - countLines(overlap)
    }
  }

  if (currentChunk.trim()) {
    chunks.push({
      text: currentChunk,
      startLine: startLine,
      endLine: lines.length
    })
  }

  return chunks
}

可视化

scss 复制代码
原文件(1000 行)
┌────────────────────────┐
│ Lines 1-100            │  Chunk 1 (400 tokens)
│ Lines 80-180           │  ← 重叠 80 tokens
│                        │  Chunk 2 (400 tokens)
│ Lines 160-260          │  ← 重叠 80 tokens
│                        │  Chunk 3 (400 tokens)
│ ...                    │
│ Lines 920-1000         │  Chunk 11 (400 tokens)
└────────────────────────┘

为什么重叠?

  • 避免语义边界被切断
  • 提高检索召回率
  • 保留上下文连贯性

批量嵌入优化

批处理流程

typescript 复制代码
// 1. 收集未缓存的块
const uncachedChunks = chunks.filter(
  chunk => !cache.has(chunk.hash)
)

// 2. 分批(100 个/批)
const batches = splitIntoBatches(uncachedChunks, 100)

// 3. 并发执行(2 批并发)
for (const batch of batches) {
  const embeddings = await provider.embedBatch(
    batch.map(c => c.text)
  )

  // 4. 归一化和缓存
  embeddings.forEach((vec, i) => {
    const normalized = normalizeL2(vec)
    cache.set(batch[i].hash, normalized)
  })
}

性能对比

方式 100 个块的时间 性能比
逐个调用 ~20 秒 1x
Batch API ~2 秒 10x

删除时的级联清理

完整删除流程

sql 复制代码
文件删除:rm MEMORY.md
    ↓
Chokidar 触发 unlink 事件
    ↓
等待防抖(1500ms)
    ↓
同步执行:
    ↓
检测已删除文件:
  activePaths = {扫描文件系统}
  dbPaths = {查询 files 表}
  deletedPaths = dbPaths - activePaths
    ↓
级联删除(对每个已删除文件):
  ┌─────────────────────────────────────┐
  │ 1. DELETE FROM chunks_vec           │
  │    WHERE id IN (                    │
  │      SELECT id FROM chunks          │
  │      WHERE path = 'MEMORY.md'       │
  │    )                                │
  └─────────────────────────────────────┘
  ┌─────────────────────────────────────┐
  │ 2. DELETE FROM chunks               │
  │    WHERE path = 'MEMORY.md'         │
  └─────────────────────────────────────┘
  ┌─────────────────────────────────────┐
  │ 3. DELETE FROM chunks_fts           │
  │    WHERE path = 'MEMORY.md'         │
  └─────────────────────────────────────┘
  ┌─────────────────────────────────────┐
  │ 4. DELETE FROM files                │
  │    WHERE path = 'MEMORY.md'         │
  └─────────────────────────────────────┘

删除顺序很重要

  1. 先删向量(依赖 chunks.id
  2. 再删主块
  3. 然后删 FTS 索引
  4. 最后删元数据

容错设计

  • 向量和 FTS 删除使用 try/catch(允许失败)
  • 主块和元数据删除必须成功
  • 兼容 FTS-only 模式(无向量扩展)

第三部分:Memory 工具设计与 Prompt 集成

3.1 工具架构

OpenClaw 提供 2 个 Memory 工具(以 Tool 形式注册):

typescript 复制代码
const MEMORY_TOOLS = [
  "memory_search",  // 语义搜索
  "memory_get",     // 精确读取
  // 注意:没有 memory_write(使用标准 write 工具)
]

工具定义

typescript 复制代码
{
  name: "memory_search",
  description: "Mandatory recall step: semantically search MEMORY.md + memory/*.md (and optional session transcripts) before answering questions about prior work, decisions, dates, people, preferences, or todos; returns top snippets with path + lines.",

  schema: {
    type: "object",
    properties: {
      query: {
        type: "string",
        description: "Search query"
      },
      maxResults: {
        type: "number",
        description: "Maximum results to return (default 6)"
      },
      minScore: {
        type: "number",
        description: "Minimum similarity score (default 0.35)"
      }
    },
    required: ["query"]
  },

  async execute(params, ctx) {
    const memoryManager = getMemoryManager(ctx);

    // 执行混合搜索
    const results = await memoryManager.search(params.query, {
      maxResults: params.maxResults || 6,
      minScore: params.minScore || 0.35,
      sessionKey: ctx.sessionKey
    });

    return results.map(r => ({
      snippet: truncate(r.text, 700),
      score: r.score,
      path: r.path,
      lines: `${r.startLine}-${r.endLine}`,
      source: r.source
    }));
  }
}

返回示例

json 复制代码
[
  {
    "snippet": "## Discord 配置\n\nToken: abc123\nChannel ID: 456789",
    "score": 0.85,
    "path": "MEMORY.md",
    "lines": "10-15",
    "source": "memory"
  },
  {
    "snippet": "- 10:30 - 用户配置了 Discord 渠道",
    "score": 0.72,
    "path": "memory/2024-03-15.md",
    "lines": "5-6",
    "source": "memory"
  }
]

3.3 memory_get 工具

工具定义

typescript 复制代码
{
  name: "memory_get",
  description: "Safe snippet read from MEMORY.md or memory/*.md by path + line range",

  schema: {
    type: "object",
    properties: {
      path: {
        type: "string",
        description: "Relative path like 'MEMORY.md' or 'memory/2024-03-15.md'"
      },
      from: {
        type: "number",
        description: "Start line number (1-indexed)"
      },
      lines: {
        type: "number",
        description: "Number of lines to read (default 10)"
      }
    },
    required: ["path", "from"]
  },

  async execute(params, ctx) {
    const memoryManager = getMemoryManager(ctx);

    return await memoryManager.readFile({
      relPath: params.path,
      from: params.from,
      lines: params.lines || 10
    });
  }
}

使用模式

ini 复制代码
步骤 1:memory_search 找到位置
  → 返回:path="MEMORY.md", startLine=10, endLine=15

步骤 2:memory_get 读取完整内容
  → 输入:path="MEMORY.md", from=10, lines=5
  → 返回:完整的 5 行文本

3.4 系统 Prompt 设计

Memory Recall 指导

markdown 复制代码
## Memory Recall

Before answering anything about prior work, decisions, dates, people,
preferences, or todos: run memory_search on MEMORY.md + memory/*.md;
then use memory_get to pull only the needed lines. If low confidence
after search, say you checked.

## When to write memory

- Decisions, preferences, and durable facts go to MEMORY.md.
- Day-to-day notes and running context go to memory/YYYY-MM-DD.md.
- If someone says "remember this," write it down (do not keep it in RAM).
- This area is still evolving. It helps to remind the model to store
  memories; it will know what to do.
- If you want something to stick, ask the bot to write it.

关键设计点

  1. "Mandatory recall step":强烈建议但不强制
  2. 明确场景:什么时候需要查询 memory
  3. 两步流程:search → get(节省 token)
  4. 低信心处理:搜索无结果时告知用户
  5. 写入指导:何时写、写什么、写哪里

3.5 LLM 决策流程

完整流程

swift 复制代码
用户消息:
  "我之前配置的 Discord token 是什么?"
    ↓
┌────────────────────────────────────────────────┐
│ LLM 分析                                        │
│  - 涉及历史信息?是                             │
│  - 系统提示说"Mandatory recall step"            │
│  - 需要调用 memory_search                       │
└────────────────────────────────────────────────┘
    ↓
┌────────────────────────────────────────────────┐
│ 工具调用 1:memory_search                       │
│  {                                             │
│    "tool": "memory_search",                    │
│    "input": {                                  │
│      "query": "Discord token 配置"             │
│    }                                           │
│  }                                             │
└────────────────────────────────────────────────┘
    ↓
┌────────────────────────────────────────────────┐
│ 返回结果                                        │
│  [                                             │
│    {                                           │
│      "snippet": "Token: abc...",               │
│      "score": 0.85,                            │
│      "path": "MEMORY.md",                      │
│      "lines": "10-15"                          │
│    }                                           │
│  ]                                             │
└────────────────────────────────────────────────┘
    ↓
┌────────────────────────────────────────────────┐
│ LLM 判断:需要完整内容                          │
└────────────────────────────────────────────────┘
    ↓
┌────────────────────────────────────────────────┐
│ 工具调用 2:memory_get                          │
│  {                                             │
│    "tool": "memory_get",                       │
│    "input": {                                  │
│      "path": "MEMORY.md",                      │
│      "from": 10,                               │
│      "lines": 5                                │
│    }                                           │
│  }                                             │
└────────────────────────────────────────────────┘
    ↓
┌────────────────────────────────────────────────┐
│ 返回完整内容                                    │
│  {                                             │
│    "text": "## Discord 配置\n\n               │
│             Token: abc123\n                    │
│             Channel ID: 456789\n               │
│             Guild ID: 987654",                 │
│    "path": "MEMORY.md"                         │
│  }                                             │
└────────────────────────────────────────────────┘
    ↓
┌────────────────────────────────────────────────┐
│ LLM 最终回答                                    │
│  "你的 Discord token 是 abc123"                │
└────────────────────────────────────────────────┘

为什么是 Tool 而不是预查询?

  1. 按需查询:LLM 判断是否需要,避免浪费
  2. 动态参数:LLM 可调整 query、maxResults、minScore
  3. 多次查询:一轮对话中可多次调用
  4. 标准化:复用工具基础设施

第四部分:关键词搜索的 BM25 算法

4.1 全文搜索引擎:SQLite FTS5

虚拟表定义

sql 复制代码
CREATE VIRTUAL TABLE chunks_fts USING fts5(
  text,              -- 索引的文本(可搜索)
  id UNINDEXED,      -- 元数据(不索引)
  path UNINDEXED,
  source UNINDEXED,
  model UNINDEXED,
  start_line UNINDEXED,
  end_line UNINDEXED
);

索引特点

  • 只有 text 列被完全索引
  • 其他列存储但不索引(节省空间)
  • 自动分词和倒排索引
  • 内置 BM25 评分

4.2 BM25 算法详解

**BM25(Best Matching 25)**是信息检索中的经典算法。

核心公式

scss 复制代码
BM25(D, Q) = Σ IDF(qi) × (f(qi, D) × (k1 + 1)) / (f(qi, D) + k1 × (1 - b + b × |D| / avgdl))

其中:
- D:文档
- Q:查询(包含多个词 qi)
- f(qi, D):词 qi 在文档 D 中的频率
- |D|:文档长度
- avgdl:平均文档长度
- k1 ≈ 1.2(词频饱和参数)
- b ≈ 0.75(文档长度归一化参数)

IDF(qi) = log((N - df(qi) + 0.5) / (df(qi) + 0.5))
- N:总文档数
- df(qi):包含词 qi 的文档数

直观理解

1. 词频(TF)

less 复制代码
文档 A:"token token token config"
文档 B:"token config"

查询:"token"

词频:
  A: f("token") = 3
  B: f("token") = 1

TF 分数(简化):
  A: 3 × (1.2 + 1) / (3 + 1.2) ≈ 1.57
  B: 1 × (1.2 + 1) / (1 + 1.2) ≈ 1.0

→ 文档 A 更相关(但有饱和效应)

2. 逆文档频率(IDF)

scss 复制代码
总文档数:1000

查询:"token" 和 "abc123"

df("token") = 500(常见词)
df("abc123") = 2(罕见词)

IDF("token") = log((1000 - 500 + 0.5) / (500 + 0.5)) ≈ 0
IDF("abc123") = log((1000 - 2 + 0.5) / (2 + 0.5)) ≈ 6.2

→ "abc123" 权重更高(罕见词更重要)

3. 文档长度归一化

less 复制代码
文档 A:1000 词(长文档)
文档 B:100 词(短文档)
平均文档长度:500 词

查询词出现次数相同时:
  A: 受惩罚(长度 > 平均)
  B: 受奖励(长度 < 平均)

→ 避免长文档天然占优

4.3 查询构建

提取 tokens

typescript 复制代码
function buildFtsQuery(raw: string): string | null {
  // 1. 提取 Unicode 字母、数字、下划线
  const tokens = raw
    .match(/[\p{L}\p{N}_]+/gu)
    ?.map(t => t.trim())
    .filter(Boolean) ?? [];

  if (tokens.length === 0) {
    return null;
  }

  // 2. 每个 token 加引号,用 AND 连接
  const quoted = tokens.map(t => `"${t.replaceAll('"', "")}"`);
  return quoted.join(" AND ");
}

查询示例

arduino 复制代码
原始查询:
  "find the API key error"

提取 tokens:
  ["find", "the", "API", "key", "error"]

FTS 查询:
  "find" AND "the" AND "API" AND "key" AND "error"

含义:
  搜索同时包含所有这些词的文档

4.4 执行流程

SQL 查询

sql 复制代码
SELECT
  id,
  path,
  source,
  start_line,
  end_line,
  text,
  bm25(chunks_fts) AS rank         -- ← BM25 评分
FROM chunks_fts
WHERE chunks_fts MATCH ?           -- ← FTS5 MATCH 操作符
  AND model = ?
  AND source IN (?, ?)
ORDER BY rank ASC                  -- ← rank 越小越相关
LIMIT ?

分数转换

typescript 复制代码
function bm25RankToScore(rank: number): number {
  const normalized = Number.isFinite(rank)
    ? Math.max(0, rank)
    : 999;
  return 1 / (1 + normalized);
}

转换表

BM25 rank Score 含义
0 1.0 完美匹配
1 0.5 良好匹配
9 0.1 弱匹配
99 0.01 几乎不相关

4.5 停用词过滤

typescript 复制代码
const STOP_WORDS = new Set([
  "a", "an", "and", "are", "as", "at", "be", "by",
  "for", "from", "has", "he", "in", "is", "it",
  "of", "on", "that", "the", "to", "was", "will"
]);

function extractKeywords(text: string): string[] {
  const tokens = text.toLowerCase().split(/\s+/);
  return tokens.filter(token => !STOP_WORDS.has(token));
}

例子

arduino 复制代码
查询:"find the API key in the config"
  ↓ 移除停用词
关键词:["find", "API", "key", "config"]

第五部分:Embedding 模型与自动配置

5.1 支持的 Embedding 提供商

OpenClaw 支持 5 个提供商

提供商 默认模型 维度 类型 API 密钥
openai text-embedding-3-small 1536 远程 OPENAI_API_KEY
gemini gemini-embedding-001 动态 远程 GOOGLE_API_KEY
voyage voyage-4-large 动态 远程 VOYAGE_API_KEY
mistral mistral-embed 动态 远程 MISTRAL_API_KEY
local embedding-gemma-300m 768 本地 无需

5.2 自动选择机制

provider: "auto" 的逻辑(默认):

markdown 复制代码
1. 检查本地模型文件
   ↓ 如果存在且可用
   使用:embedding-gemma-300m(768维,离线)

2. 否则,按顺序尝试远程提供商
   ↓
   OpenAI → Gemini → Voyage → Mistral
   (使用第一个有 API 密钥的)

3. 如果所有远程提供商都没有 API 密钥
   ↓
   降级到 FTS-only 模式(仅关键词搜索)

代码实现

typescript 复制代码
async function createEmbeddingProvider(
  request: "auto" | ProviderName,
  config: Config
): Promise<EmbeddingProvider | null> {

  if (request === "auto") {
    // 1. 尝试本地
    const local = await tryLocal(config);
    if (local) return local;

    // 2. 遍历远程提供商
    for (const provider of ["openai", "gemini", "voyage", "mistral"]) {
      const apiKey = getApiKey(provider, config);
      if (apiKey) {
        return createRemoteProvider(provider, apiKey, config);
      }
    }

    // 3. 都失败,返回 null(FTS-only)
    return null;
  }

  // 指定提供商
  return createProvider(request, config);
}

5.3 零配置使用

完全不配置

json 复制代码
{
  // 空配置或默认配置
}

行为

  1. 检查环境变量:OPENAI_API_KEYGOOGLE_API_KEY
  2. 使用第一个找到的 API 密钥
  3. 如果没有任何 API 密钥,降级到 FTS-only
  4. Memory 功能仍然可用(仅关键词搜索)

环境变量配置

bash 复制代码
# ~/.bashrc 或 ~/.zshrc
export OPENAI_API_KEY="sk-..."
export GOOGLE_API_KEY="..."

配置文件配置

json 复制代码
{
  "models": {
    "providers": {
      "openai": {
        "apiKey": "sk-..."
      },
      "google": {
        "apiKey": "..."
      }
    }
  }
}

本地模型配置

json 复制代码
{
  "agents": {
    "defaults": {
      "memorySearch": {
        "provider": "local",
        "local": {
          "modelPath": "~/.cache/embedding-gemma-300m.gguf"
        }
      }
    }
  }
}

5.4 Fallback 机制

json 复制代码
{
  "memorySearch": {
    "provider": "openai",
    "fallback": "local"
  }
}

行为

  1. 优先使用 OpenAI
  2. 如果失败(无 API 密钥、配额用尽等),自动切换到本地模型
  3. 如果本地模型也失败,降级到 FTS-only

5.5 向量归一化

L2 归一化(所有向量):

typescript 复制代码
function sanitizeAndNormalizeEmbedding(vec: number[]): number[] {
  // 1. 清理 NaN 和 Inf
  const sanitized = vec.map(x =>
    Number.isFinite(x) ? x : 0
  );

  // 2. L2 归一化(转为单位向量)
  const norm = Math.sqrt(
    sanitized.reduce((sum, x) => sum + x * x, 0)
  );

  return sanitized.map(x => x / norm);
}

为什么归一化?

  • 余弦相似度计算更高效
  • 数值稳定性更好
  • 向量长度统一为 1

第六部分:混合检索与排序算法

6.1 混合检索架构

核心思想:结合向量搜索的语义理解和关键词搜索的精确匹配。

css 复制代码
用户查询
  ↓
并行执行:
  ├─ 向量搜索(语义)
  │  ├─ 生成查询向量
  │  ├─ 计算余弦相似度
  │  └─ 返回 top N × 4
  │
  └─ 关键词搜索(BM25)
     ├─ 提取关键词
     ├─ 构建 FTS 查询
     └─ 返回 top N × 4
  ↓
合并结果(RRF)
  ↓
可选:MMR 去重
  ↓
可选:时间衰减
  ↓
过滤(minScore)+ 限制(maxResults)
  ↓
返回最终结果

6.2 倒数排名融合(RRF)

算法

typescript 复制代码
function mergeHybridResults(
  vectorResults: VectorResult[],
  ftsResults: FtsResult[],
  vectorWeight: number = 0.7,
  textWeight: number = 0.3
): HybridResult[] {

  // 1. 建立 ID 到分数的映射
  const scoreMap = new Map<string, {
    vector?: number;
    text?: number;
  }>();

  vectorResults.forEach(r => {
    scoreMap.set(r.id, { vector: r.score });
  });

  ftsResults.forEach(r => {
    const existing = scoreMap.get(r.id) || {};
    scoreMap.set(r.id, { ...existing, text: r.score });
  });

  // 2. 计算加权分数
  const merged = Array.from(scoreMap.entries()).map(([id, scores]) => {
    const vectorScore = scores.vector || 0;
    const textScore = scores.text || 0;
    const finalScore = vectorScore * vectorWeight + textScore * textWeight;

    return {
      id,
      score: finalScore,
      vectorScore,
      textScore
    };
  });

  // 3. 按分数排序
  merged.sort((a, b) => b.score - a.score);

  return merged;
}

权重配置(默认):

json 复制代码
{
  "vectorWeight": 0.7,    // 向量 70%
  "textWeight": 0.3       // 关键词 30%
}

为什么 7:3?

  • 向量搜索擅长语义理解(同义词、相关概念)
  • 关键词搜索擅长精确匹配(术语、代码、命令)
  • 7:3 平衡两者优势

例子

ini 复制代码
查询:"Discord bot 配置"

向量搜索结果:
  Chunk A: "Discord 机器人设置指南"
    vectorScore = 0.88(语义相似)

  Chunk B: "discord-bot.config.json"
    vectorScore = 0.65(语义较远)

关键词搜索结果:
  Chunk A: "Discord 机器人设置指南"
    textScore = 0.45(部分匹配)

  Chunk B: "discord-bot.config.json"
    textScore = 0.92(精确匹配 "discord" 和 "bot")

混合分数:
  Chunk A: 0.88 × 0.7 + 0.45 × 0.3 = 0.616 + 0.135 = 0.751
  Chunk B: 0.65 × 0.7 + 0.92 × 0.3 = 0.455 + 0.276 = 0.731

排序:A > B(平衡语义和精确)

6.3 候选乘数

配置

json 复制代码
{
  "maxResults": 6,
  "candidateMultiplier": 4
}

逻辑

ini 复制代码
向量搜索:返回 6 × 4 = 24 个候选
关键词搜索:返回 6 × 4 = 24 个候选
合并排序:取并集(可能有重叠)
最终返回:top 6

为什么需要?

  • 避免过早截断
  • 提供更多候选供合并算法选择
  • 提升最终结果质量

6.4 MMR 去重算法

最大边际相关性(Maximal Marginal Relevance)

typescript 复制代码
function maximalMarginalRelevance(
  results: Result[],
  lambda: number = 0.7,
  maxResults: number
): Result[] {
  const selected: Result[] = [];
  const remaining = [...results];

  while (remaining.length > 0 && selected.length < maxResults) {
    // 计算每个候选的 MMR 分数
    const scores = remaining.map(candidate => {
      const relevance = candidate.score;

      // 计算与已选结果的最大相似度
      const maxSimilarity = selected.length === 0
        ? 0
        : Math.max(
            ...selected.map(s =>
              cosineSimilarity(candidate.embedding, s.embedding)
            )
          );

      // MMR = lambda × 相关性 - (1 - lambda) × 冗余度
      return lambda * relevance - (1 - lambda) * maxSimilarity;
    });

    // 选择最高 MMR 分数的结果
    const bestIndex = scores.indexOf(Math.max(...scores));
    selected.push(remaining[bestIndex]);
    remaining.splice(bestIndex, 1);
  }

  return selected;
}

配置

json 复制代码
{
  "mmr": {
    "enabled": true,
    "lambda": 0.7    // 0.7 = 70% 相关性,30% 多样性
  }
}

例子

ini 复制代码
候选结果:
  A: "Discord token 是 abc123"(score=0.9)
  B: "Discord token 配置方法"(score=0.88)
  C: "Slack token 配置"(score=0.75)

不启用 MMR:
  返回:A, B(内容重复)

启用 MMR:
  第一轮:
    A: MMR = 0.7 × 0.9 - 0.3 × 0 = 0.63
    B: MMR = 0.7 × 0.88 - 0.3 × 0 = 0.616
    C: MMR = 0.7 × 0.75 - 0.3 × 0 = 0.525
    选择:A

  第二轮:
    B 与 A 高度相似(similarity = 0.9)
    B: MMR = 0.7 × 0.88 - 0.3 × 0.9 = 0.616 - 0.27 = 0.346

    C 与 A 不相似(similarity = 0.2)
    C: MMR = 0.7 × 0.75 - 0.3 × 0.2 = 0.525 - 0.06 = 0.465
    选择:C

  返回:A, C(内容多样)

6.5 时间衰减

算法

typescript 复制代码
function applyTemporalDecay(
  results: Result[],
  halfLifeDays: number = 30
): Result[] {
  const now = Date.now();

  return results.map(r => {
    const ageInDays = (now - r.timestamp) / (1000 * 60 * 60 * 24);
    const decay = Math.pow(2, -ageInDays / halfLifeDays);

    return {
      ...r,
      score: r.score * decay
    };
  });
}

配置

json 复制代码
{
  "temporalDecay": {
    "enabled": true,
    "halfLifeDays": 30    // 30 天半衰期
  }
}

衰减曲线

ini 复制代码
分数衰减(halfLifeDays=30):

1.0 ┤●
    │  ●
0.5 ┤    ●
    │      ●
0.25┤        ●
    │          ●
0   └────────────────
    0   30  60  90
       天数

例子:
  今天:decay = 1.0(不衰减)
  30 天前:decay = 0.5(分数减半)
  60 天前:decay = 0.25(分数减至 1/4)

应用场景

  • 项目文档(新版本优先)
  • 会议记录(近期会议更相关)
  • 日志和笔记

6.6 完整混合检索流程

ini 复制代码
用户查询:"Discord token 配置"
    ↓
┌──────────────────────────────────────────────────┐
│ 1. 生成查询向量                                   │
│    queryVec = embed("Discord token 配置")        │
│    → [0.023, -0.145, ..., 0.234](1536 维)      │
└──────────────────────────────────────────────────┘
    ↓
┌──────────────────────────────────────────────────┐
│ 2. 并行搜索                                       │
│                                                   │
│  向量搜索:                                       │
│    SQL: SELECT *, vec_distance_cosine(...) AS dist│
│         FROM chunks_vec                          │
│         ORDER BY dist ASC LIMIT 24               │
│    结果:                                         │
│      Chunk A: dist=0.12 → score=0.88            │
│      Chunk B: dist=0.18 → score=0.82            │
│      ...                                         │
│                                                   │
│  关键词搜索:                                     │
│    FTS: "Discord" AND "token" AND "配置"         │
│    SQL: SELECT *, bm25(...) AS rank              │
│         FROM chunks_fts                          │
│         WHERE MATCH '...' ORDER BY rank LIMIT 24 │
│    结果:                                         │
│      Chunk A: rank=-2.34 → score=0.30           │
│      Chunk C: rank=-1.89 → score=0.35           │
│      ...                                         │
└──────────────────────────────────────────────────┘
    ↓
┌──────────────────────────────────────────────────┐
│ 3. RRF 合并                                       │
│    Chunk A:                                      │
│      finalScore = 0.88×0.7 + 0.30×0.3 = 0.706   │
│    Chunk B:                                      │
│      finalScore = 0.82×0.7 + 0×0.3 = 0.574      │
│    Chunk C:                                      │
│      finalScore = 0×0.7 + 0.35×0.3 = 0.105      │
│    排序:A > B > C                                │
└──────────────────────────────────────────────────┘
    ↓
┌──────────────────────────────────────────────────┐
│ 4. MMR 去重(可选)                               │
│    检测 A 和 B 内容重复(similarity=0.9)        │
│    降低 B 的分数或跳过                            │
└──────────────────────────────────────────────────┘
    ↓
┌──────────────────────────────────────────────────┐
│ 5. 时间衰减(可选)                               │
│    Chunk A: 15 天前 → decay=0.7                  │
│    Chunk B: 60 天前 → decay=0.25                 │
│    调整分数                                       │
└──────────────────────────────────────────────────┘
    ↓
┌──────────────────────────────────────────────────┐
│ 6. 过滤和限制                                     │
│    - 过滤:score < 0.35                          │
│    - 限制:maxResults = 6                        │
│    最终返回 top 6 结果                            │
└──────────────────────────────────────────────────┘

第七部分:性能优化与最佳实践

7.1 性能对比

向量搜索 vs 关键词搜索 vs 混合搜索

维度 向量搜索 关键词搜索 混合搜索
语义理解 ✅ 强 ❌ 弱 ✅ 强
精确匹配 ⚠️ 中 ✅ 强 ✅ 强
查询速度 ⚠️ 中(50-100ms) ✅ 快(10-20ms) ⚠️ 中(60-120ms)
离线使用 ⚠️ 需本地模型 ✅ 完全离线 ⚠️ 需本地模型
配置需求 ⚠️ API 或模型 ✅ 零配置 ⚠️ API 或模型
召回率 ⚠️ 中 ⚠️ 中
整体质量 ⚠️ 好 ⚠️ 中 最佳

7.2 优化策略

策略 1:嵌入缓存

typescript 复制代码
// 配置
{
  "cache": {
    "enabled": true,
    "maxEntries": 100000
  }
}

效果

  • 相同块不重复嵌入
  • 80% 缓存命中率 → 减少 80% API 调用
  • 重新索引时速度提升 5x

策略 2:批量嵌入

typescript 复制代码
// 配置
{
  "embedding": {
    "batchSize": 100,
    "batchConcurrency": 2
  }
}

效果

  • 批处理 vs 单个:性能提升 10x
  • 100 个块:2 秒 vs 20 秒

策略 3:增量同步

typescript 复制代码
// 配置
{
  "sync": {
    "mode": "incremental",  // 默认
    "sessions": {
      "deltaBytes": 50000,
      "deltaMessages": 25
    }
  }
}

效果

  • 只索引变化的文件
  • 避免全量重建
  • 大型知识库友好

策略 4:调整权重

typescript 复制代码
// 更依赖语义
{
  "hybrid": {
    "vectorWeight": 0.9,
    "textWeight": 0.1
  }
}

// 更依赖精确匹配
{
  "hybrid": {
    "vectorWeight": 0.5,
    "textWeight": 0.5
  }
}

7.3 最佳实践配置

json 复制代码
{
  "agents": {
    "defaults": {
      "memorySearch": {
        "enabled": true,
        "provider": "auto",
        "fallback": "local",

        "sources": ["memory", "sessions"],

        "chunking": {
          "tokens": 400,
          "overlap": 80
        },

        "sync": {
          "watch": true,
          "watchDebounceMs": 1500,
          "onSessionStart": true,
          "onSearch": true,
          "sessions": {
            "deltaBytes": 100000,
            "deltaMessages": 50
          }
        },

        "query": {
          "maxResults": 6,
          "minScore": 0.35,
          "hybrid": {
            "enabled": true,
            "vectorWeight": 0.7,
            "textWeight": 0.3,
            "candidateMultiplier": 4,
            "mmr": {
              "enabled": true,
              "lambda": 0.7
            },
            "temporalDecay": {
              "enabled": true,
              "halfLifeDays": 30
            }
          }
        },

        "cache": {
          "enabled": true,
          "maxEntries": 100000
        }
      }
    }
  }
}

结论

OpenClaw 的 Memory 系统展现了从简单到复杂的完整技术栈:

核心设计原则

  1. 文件优先:Markdown 文件作为唯一真实来源
  2. 零配置:开箱即用,优雅降级
  3. 混合检索:结合语义和精确匹配
  4. 实时同步:文件变化自动索引
  5. 工具驱动:LLM 主动决策查询时机

技术亮点

  1. 智能分块:400 token + 80 token 重叠
  2. BM25 算法:经典的信息检索评分
  3. 向量归一化:L2 归一化保证数值稳定
  4. RRF 融合:加权合并向量和关键词结果
  5. MMR 去重:避免返回重复内容
  6. 时间衰减:优先返回近期信息

适用场景

  • ✅ 个人知识库管理
  • ✅ 长期对话上下文
  • ✅ 项目文档检索
  • ✅ 会议记录查询
  • ✅ 代码库知识积累

未来方向

  1. 多模态支持:图片、PDF 的向量化
  2. 图结构索引:知识图谱增强
  3. 动态 chunking:语义边界分块
  4. 联邦搜索:跨多个 Agent 的记忆查询

OpenClaw 的 Memory 系统证明了一个理念:简单的文件系统 + 强大的向量检索 = 高效的知识管理


参考资源


本文基于 OpenClaw 版本 2026.3.1 撰写。项目持续演进中,部分实现细节可能有所变化。

关键文件参考

  • src/memory/manager.ts - 核心管理器
  • src/memory/manager-sync-ops.ts - 同步操作
  • src/memory/manager-search.ts - 搜索实现
  • src/memory/embeddings.ts - Embedding 提供商
  • src/memory/hybrid.ts - 混合检索
  • src/agents/tools/memory-tool.ts - Memory 工具
  • src/agents/system-prompt.ts - 系统提示
相关推荐
程序猿阿越3 小时前
Kafka4源码(二)创建Topic
java·后端·源码阅读
悟空码字3 小时前
Spring Boot 整合 MongoDB 最佳实践:CRUD、分页、事务、索引全覆盖
java·spring boot·后端
开心就好20253 小时前
iOS App 安全加固流程记录,代码、资源与安装包保护
后端·ios
省长3 小时前
Sa-Token v1.45.0 发布 🚀,正式支持 Spring Boot 4、新增 Jackson3/Snack4 插件适配
java·后端·开源
开心就好20253 小时前
iOS App 性能测试工具怎么选?使用克魔助手(Keymob)结合 Instruments 完成
后端·ios
神奇小汤圆4 小时前
牛客网Java面试题总结(金三银四最新版)
后端
Cache技术分享4 小时前
346. Java IO API - 操作文件和目录
前端·后端
sTone873754 小时前
web后端开发概念: VO 和 PO
java·后端·架构