引言
在 AI 助手领域,记忆系统是实现长期对话和知识积累的关键。OpenClaw 作为一个开源的自托管 AI 助手平台,实现了一套完整的 Memory 系统,将简单的文件存储与强大的向量检索相结合。本文将深入剖析 OpenClaw Memory 系统的完整技术实现,从文件系统设计到向量检索,从工具接口到混合排序算法。
本文涵盖的内容:
- 基于文件系统的 Memory 和 Message 存储设计
- 文件监听机制与向量/全文数据库的增删改查联动
- Memory 工具设计与 LLM Prompt 集成
- 关键词搜索的 BM25 算法实现
- Embedding 模型的自动选择与配置
- 混合检索的排序算法与优化策略
让我们从最基础的文件系统设计开始。
第一部分:基于文件系统的存储架构
1.1 设计理念
OpenClaw 的 Memory 系统采用了一个优雅的设计原则:文件是数据的唯一真实来源(Single Source of Truth)。
markdown
文件系统(Markdown)
↓
主数据源
↓
索引是派生的(可重建)
这种设计带来了几个关键优势:
- 人类可读可编辑:Memory 数据存储为纯文本 Markdown 格式
- 版本控制友好:可以使用 Git 管理知识库
- 无锁定风险:不依赖专有数据库格式
- 灾难恢复简单:索引损坏时可以完全重建
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)
↓
确保文件写入完成
↓
触发同步
为什么需要防抖?
- 避免写入过程中的部分读取
- 批量处理多个文件变化
- 减少重复索引
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' │
└─────────────────────────────────────┘
删除顺序很重要:
- 先删向量(依赖 chunks.id)
- 再删主块
- 然后删 FTS 索引
- 最后删元数据
容错设计:
- 向量和 FTS 删除使用
try/catch(允许失败) - 主块和元数据删除必须成功
- 兼容 FTS-only 模式(无向量扩展)
第三部分:Memory 工具设计与 Prompt 集成
3.1 工具架构
OpenClaw 提供 2 个 Memory 工具(以 Tool 形式注册):
typescript
const MEMORY_TOOLS = [
"memory_search", // 语义搜索
"memory_get", // 精确读取
// 注意:没有 memory_write(使用标准 write 工具)
]
3.2 memory_search 工具
工具定义:
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.
关键设计点:
- "Mandatory recall step":强烈建议但不强制
- 明确场景:什么时候需要查询 memory
- 两步流程:search → get(节省 token)
- 低信心处理:搜索无结果时告知用户
- 写入指导:何时写、写什么、写哪里
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 而不是预查询?
- 按需查询:LLM 判断是否需要,避免浪费
- 动态参数:LLM 可调整 query、maxResults、minScore
- 多次查询:一轮对话中可多次调用
- 标准化:复用工具基础设施
第四部分:关键词搜索的 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
{
// 空配置或默认配置
}
行为:
- 检查环境变量:
OPENAI_API_KEY、GOOGLE_API_KEY等 - 使用第一个找到的 API 密钥
- 如果没有任何 API 密钥,降级到 FTS-only
- 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"
}
}
行为:
- 优先使用 OpenAI
- 如果失败(无 API 密钥、配额用尽等),自动切换到本地模型
- 如果本地模型也失败,降级到 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 系统展现了从简单到复杂的完整技术栈:
核心设计原则
- 文件优先:Markdown 文件作为唯一真实来源
- 零配置:开箱即用,优雅降级
- 混合检索:结合语义和精确匹配
- 实时同步:文件变化自动索引
- 工具驱动:LLM 主动决策查询时机
技术亮点
- 智能分块:400 token + 80 token 重叠
- BM25 算法:经典的信息检索评分
- 向量归一化:L2 归一化保证数值稳定
- RRF 融合:加权合并向量和关键词结果
- MMR 去重:避免返回重复内容
- 时间衰减:优先返回近期信息
适用场景
- ✅ 个人知识库管理
- ✅ 长期对话上下文
- ✅ 项目文档检索
- ✅ 会议记录查询
- ✅ 代码库知识积累
未来方向
- 多模态支持:图片、PDF 的向量化
- 图结构索引:知识图谱增强
- 动态 chunking:语义边界分块
- 联邦搜索:跨多个 Agent 的记忆查询
OpenClaw 的 Memory 系统证明了一个理念:简单的文件系统 + 强大的向量检索 = 高效的知识管理。
参考资源
- OpenClaw 官方文档 :docs.openclaw.ai
- 项目仓库 :github.com/openclaw/op...
- BM25 算法论文:Robertson & Zaragoza (2009)
- SQLite FTS5 文档 :www.sqlite.org/fts5.html
- 向量检索综述:Johnson et al., "Billion-scale similarity search"
本文基于 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- 系统提示