前言
本文基于 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 结束对话时,系统自动:
- 读取上一轮会话的最近 N 条消息(默认 15 条)
- 调用 LLM 生成描述性文件名 slug(如 "api-design")
- 写入
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 | 模型 | 特点 |
|---|---|---|
| local | embeddinggemma-300m (GGUF) | 零成本,无网络依赖,通过 node-llama-cpp 运行 |
| openai | text-embedding-3-small | 最稳定,8192 token 上限 |
| gemini | text-embedding-004 | Google 生态 |
| voyage | voyage-3 | 专注嵌入质量 |
| mistral | mistral-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 种触发同步的方式,覆盖不同场景:
| 触发方式 | 触发时机 | 适用场景 |
|---|---|---|
| watch | chokidar 监听到文件增/删/改 | 用户编辑 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));
}
时间来源优先级:
- 文件名日期
memory/2026-01-15.md→ 直接解析 - 常青文件
MEMORY.md、memory/conventions.md→ 不衰减 - 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 API | OpenAI/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 = false,temporalDecay.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 代理模式 | 多后端容错 |
| 时间衰减 | 指数衰减 + 常青文件豁免 | 需要时效性的知识检索 |
| 多语言 FTS | 7 语言停用词 + 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 文件给人读写,向量索引给机器检索,混合搜索兼顾语义和精确匹配,三层降级保证在任何环境下都能用。整体设计在工程质量、成本控制、可用性之间取得了很好的平衡。