OpenClaw记忆检索
OpenClaw记忆检索
目录
- 为什么需要记忆检索
- FTS5 全文搜索
- sqlite-vec 向量搜索
- 完整搜索流程
- MMR 多样性重排
- Jaccard 相似度
- 整体技术对比
一、为什么需要记忆检索
用户问 Agent 一句话:“上周我们讨论的 API 鉴权方案是什么?”
AI 要去自己的”记忆库”(本地 Markdown 文件 + 历史对话)里找相关片段来回答。这个”找”的过程就是 记忆检索(Memory Search)。
问题:怎么衡量”相关”?
有两种思路:
| 思路 | 技术 | 特点 |
|---|---|---|
| 字面匹配:看词有没有出现 | FTS5(Full Text Search) | 快速精确,但换个说法就找不到 |
| 语义匹配:看意思像不像 | sqlite-vec | 理解同义词,但需要 Embedding 模型 |
OpenClaw 同时使用两者,融合后得到最优结果。
二、FTS5 全文搜索
2.1 是什么
FTS = Full-Text Search(全文搜索),FTS5 是 SQLite 内置的全文搜索引擎(第5版)。
它的核心是倒排索引(Inverted Index):
普通查询(慢,逐行扫描):
SELECT * FROM chunks WHERE text LIKE '%鉴权%';
FTS5 查询(快,走索引,毫秒级):
SELECT * FROM chunks_fts WHERE chunks_fts MATCH '"鉴权" AND "API"';
2.2 OpenClaw 建表方式
// src/memory/memory-schema.ts
CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(
text, -- 📌 只有这一列被全文索引
id UNINDEXED, -- 这些列不索引,只是附带存储
path UNINDEXED,
source UNINDEXED,
model UNINDEXED,
start_line UNINDEXED,
end_line UNINDEXED
);
2.3 查询语句构建
// src/memory/hybrid.ts
// 用户输入:"API 鉴权方案"
// 经过这个函数变成 FTS 查询语句
export function buildFtsQuery(raw: string): string | null {
const tokens = raw.match(/[\p{L}\p{N}_]+/gu)
?.map((t) => t.trim())
.filter(Boolean) ?? [];
// 结果:["API", "鉴权", "方案"]
const quoted = tokens.map((t) => `"${t}"`);
return quoted.join(" AND ");
// 最终查询:`"API" AND "鉴权" AND "方案"`
// 含义:三个词必须同时出现
}
2.4 BM25 评分转换
FTS5 使用 BM25 算法评分(Google 早期也用的标准算法,词频 × 稀有度)。返回值是负数,越小越相关,需要转换为[0,1]区间:
// src/memory/hybrid.ts
export function bm25RankToScore(rank: number): number {
const normalized = Math.max(0, rank); // 负数变0
return 1 / (1 + normalized); // 归一化到 (0,1]
}
// rank=0 → score=1.0(完美匹配)
// rank=1 → score=0.5
// rank=9 → score=0.1
2.5 FTS5 的局限
只能找”有这个词”的文档,找不到”语义相似”的内容。
例如:用户说”认证机制”,FTS5 找不到只写了 auth的片段。
三、sqlite-vec 向量搜索
3.1 sqlite-vec是什么
sqlite-vec 是一个 SQLite 扩展(.so / .dylib/ .dll动态库),给 SQLite 增加向量计算能力。
类比:SQLite 默认只会算加减乘除,sqlite-vec 给它装了一个”几何计算器”,让它能算向量距离。
3.2 向量(Embedding)是什么
Embedding 模型(如 OpenAI text-embedding-3-small)把一段文字变成一串数字:
"API 鉴权方案" → [0.12, -0.34, 0.89, 0.01, ...] // 1536 个数字
"OAuth2 认证" → [0.11, -0.31, 0.91, 0.02, ...] // ✅ 很相近!
"今天天气不错" → [0.88, 0.22, -0.54, 0.77, ...] // ❌ 差很远
语义越相近,向量越相近(在高维空间中的夹角越小)。
3.3 余弦相似度
衡量两个向量”方向”有多接近:
相似度 = cos(θ) = A·B / (|A| × |B|)
结果范围:[-1, 1]
1.0 = 完全相同方向(语义完全一致)
0.0 = 垂直(语义无关)
-1.0 = 相反方向(语义相反)
实际用余弦距离(= 1 - 相似度):距离越小,越相关。
3.4 OpenClaw 的 SQL 查询
-- 查找与用户问题最相近的前 N 个文本块
SELECT
c.id, c.path, c.start_line, c.end_line, c.text, c.source,
vec_distance_cosine(v.embedding, ?) AS dist -- ← sqlite-vec 提供的函数
FROM chunks_vec v -- ← sqlite-vec 虚拟表(加速 ANN 搜索)
JOIN chunks c ON c.id = v.id
WHERE c.model = ? -- 只找同一个 embedding 模型的结果
ORDER BY dist ASC -- 距离最小的排最前
LIMIT ?
vec_distance_cosine由 sqlite-vec 扩展提供。若无此扩展,则需把所有向量读出来在 JS 里逐个计算,性能极差。
3.5 降级策略
// src/memory/manager-search.ts
try {
db.loadExtension(extensionPath); // 加载 sqlite-vec.so(SIMD 加速)
vectorReady = true;
} catch {
// 降级:用 JS 实现的 cosineSimilarity 手动遍历(慢但可用)
}
3.6 支持的 Embedding Provider
| Provider | 模型示例 |
|---|---|
| OpenAI | text-embedding-3-small / text-embedding-3-large |
| Gemini | text-embedding-004 |
| Voyage | voyage-3 / voyage-3-lite |
| Mistral | mistral-embed |
| Local | node-llama-cpp(本地模型) |
四、完整搜索流程
用户输入:"上周讨论的 API 鉴权方案"
│
▼
┌────────────────────────────────────────┐
│ 并行执行两路搜索 │
│ │
│ 路① FTS5 关键词搜索 │
│ buildFtsQuery(...) │
│ → "\"API\" AND \"鉴权\" AND \"方案\"" │
│ SQL: SELECT ... FROM chunks_fts │
│ WHERE chunks_fts MATCH ? │
│ ORDER BY bm25(chunks_fts) │
│ │
│ 得到:[chunk_A(0.8), chunk_C(0.5)] │
│ │
│ 路② 向量语义搜索 │
│ embedding("上周讨论的 API 鉴权方案") │
│ → [0.12, -0.34, ...] (1536维) │
│ SQL: SELECT ..., │
│ vec_distance_cosine(v.embedding,?)│
│ FROM chunks_vec ORDER BY dist │
│ │
│ 得到:[chunk_B(0.92), chunk_A(0.85)] │
└──────────────────┬─────────────────────┘
│
▼
┌────────────────────────────────────────┐
│ 合并(Hybrid Merge) │
│ │
│ 按 id 合并两路结果: │
│ chunk_A: vectorScore=0.85, textScore=0.8 │
│ chunk_B: vectorScore=0.92, textScore=0 │
│ chunk_C: vectorScore=0, textScore=0.5 │
│ │
│ 加权合分: │
│ score = vectorWeight × v + textWeight × t │
│ chunk_A: 0.7×0.85 + 0.3×0.8 = 0.835 │
│ chunk_B: 0.7×0.92 + 0.3×0 = 0.644 │
│ chunk_C: 0.7×0 + 0.3×0.5 = 0.150 │
└──────────────────┬─────────────────────┘
│
▼ (可选)
┌────────────────────────────────────────┐
│ 时间衰减(Temporal Decay) │
│ 最近的文件加分,很久之前的减分 │
│ 用文件修改时间计算半衰期(halfLifeDays) │
└──────────────────┬─────────────────────┘
│
▼ (可选,默认关闭)
┌────────────────────────────────────────┐
│ MMR 重排(见下节) │
└──────────────────┬─────────────────────┘
│
▼
最终结果:[chunk_A, chunk_B, chunk_C]
注入到 AI 上下文中
加权合并的源码
// src/memory/hybrid.ts
const merged = Array.from(byId.values()).map((entry) => {
const score =
params.vectorWeight * entry.vectorScore +
params.textWeight * entry.textScore;
return { path, startLine, endLine, score, snippet, source };
});
五、MMR 多样性重排
5.1 问题:纯相关性排序的缺陷
假设记忆库里有 10 个文档都在讲”OAuth2 认证”,纯相关性排序会返回:
第1名:oauth2_guide.md(第 1-20 行)
第2名:oauth2_guide.md(第21-40 行) ← ⚠️ 和第1名几乎一样!
第3名:oauth2_guide.md(第41-60 行) ← ⚠️ 还是重复!
...
这就出现了冗余:10个结果说的是同一件事,浪费 Token,AI 也看不到其他相关内容。
5.2 MMR 的核心思想
MMR = Maximal Marginal Relevance(最大边际相关性),每次选下一个结果时,同时考虑:
- ✅ 相关性(跟查询有多像)
- ✅ 多样性(跟已选结果有多不同)
5.3 公式
MMR(d) = λ × Relevance(d, query) - (1 - λ) × max Similarity(d, selected)
λ = 0.7(默认):偏向相关性,同时引入30%多样性惩罚
// src/memory/mmr.ts
export function computeMMRScore(
relevance: number, // 跟查询的相似度(归一化到 [0,1])
maxSimilarity: number, // 跟已选结果中最相似的那个的相似度
lambda: number, // 默认 0.7
): number {
return lambda * relevance - (1 - lambda) * maxSimilarity;
}
5.4 迭代选择算法
// src/memory/mmr.ts
// 步骤1:预先对所有 snippet 分词,建 token 缓存
for (const item of items) {
tokenCache.set(item.id, tokenize(item.content));
// "API OAuth2 token" → Set{"api", "oauth2", "token"}
}
// 步骤2:分数归一化到 [0,1](与相似度量纲统一)
const normalizeScore = (score) => (score - minScore) / scoreRange;
// 步骤3:迭代贪心选择
while (remaining.size > 0) {
let bestItem = null, bestMMRScore = -Infinity;
for (const candidate of remaining) {
const relevance = normalizeScore(candidate.score);
const maxSim = maxSimilarityToSelected(candidate, selected, tokenCache);
const mmrScore = computeMMRScore(relevance, maxSim, lambda);
if (mmrScore > bestMMRScore) {
bestMMRScore = mmrScore;
bestItem = candidate;
}
}
selected.push(bestItem); // 选中!
remaining.delete(bestItem);
}
5.5 具体示例
假设搜索”API 鉴权”,得到3个候选:
| 候选 | score | 内容 |
|---|---|---|
| A | 0.9 | “OAuth2 bearer token 鉴权方式” |
| B | 0.8 | “OAuth2 access token 刷新机制”(与A很像) |
| C | 0.7 | “API Key 静态鉴权配置”(与A不同) |
无 MMR(纯相关性):
结果:A → B → C
问题:A 和 B 几乎说的同一件事,C 被压到末位
有 MMR(λ=0.7):
第1轮:selected=[], 直接选最高分 → 选 A
第2轮:selected=[A]
B 的 MMR = 0.7×0.8 - 0.3×jaccardSim(B,A)
= 0.56 - 0.3×0.6 = 0.38 ← A和B共同词多,被惩罚
C 的 MMR = 0.7×0.7 - 0.3×jaccardSim(C,A)
= 0.49 - 0.3×0.1 = 0.46 ← A和C差异大,惩罚小
→ 选 C(而非 B!)
第3轮:只剩 B → 选 B
最终结果:A → C → B
✅ A(OAuth2)和 C(API Key)提供互补视角,信息量更大
六、Jaccard 相似度
MMR 中用于衡量两段文本”内容重叠度”的算法:
// src/memory/mmr.ts
// 分词:只保留字母和数字
export function tokenize(text: string): Set<string> {
return new Set(text.toLowerCase().match(/[a-z0-9_]+/g) ?? []);
}
// "OAuth2 bearer token" → Set{"oauth2", "bearer", "token"}
// Jaccard = |交集| / |并集|
export function jaccardSimilarity(setA, setB): number {
let intersectionSize = 0;
for (const token of smaller) {
if (larger.has(token)) intersectionSize++;
}
const unionSize = setA.size + setB.size - intersectionSize;
return intersectionSize / unionSize;
}
举例:
A = {"oauth2", "bearer", "token"}
B = {"oauth2", "access", "token"}
交集 = {"oauth2", "token"} → size = 2
并集 = {"oauth2", "bearer", "token", "access"} → size = 4
Jaccard(A, B) = 2 / 4 = 0.5
为什么 MMR 用 Jaccard 而不是余弦相似度?
因为此时已经把所有 snippet 文本(最长 700 字符)都读到记忆中了,算 Jaccard 词袋相似度比重新调用 Embedding API 便宜得多,也足够准确。
七、整体技术对比
| 维度 | FTS5(关键词) | sqlite-vec(向量) | MMR(重排) |
|---|---|---|---|
| 本质 | 倒排索引,找词 | 近邻搜索,找意思 | 贪心选择,找多样性 |
| 擅长 | 精确词匹配、代码、专有名词 | 同义词、换说法、语义理解 | 去冗余、保多样 |
| 弱点 | 换个词就找不到 | 精确词匹配不如 FTS | 计算量 O(n²) |
| 打分算法 | BM25(词频 × 稀有度) | 余弦距离 | Jaccard 相似度 |
| 时间复杂度 | O(log n),极快 | O(n)(ANN 近似后更快) | O(n²) |
| 依赖 | SQLite 内置,无需额外安装 | sqlite-vec 扩展 + Embedding API | 已有结果列表即可 |
| OpenClaw 默认开启 | ✅ hybrid.enabled=true 时 | ✅ 有 Provider 时 | ❌ 需 opt-in |
八、三种搜索模式总结
OpenClaw 根据运行环境自动降级到最优模式:
有 Embedding Provider?
│
┌───┴───┐
Yes No
│ │
▼ ▼
hybrid.enabled? FTS-only 模式
│ │ (仅关键词搜索)
Yes No
│ │
▼ ▼
Hybrid 纯向量
模式 模式
| 模式 | 触发条件 | 能力 |
|---|---|---|
| Hybrid(混合) | 有 Provider + hybrid=true | 向量 + BM25 双路搜索,效果最好 |
| 向量 only | 有 Provider + hybrid=false | 纯语义,适合语义强的查询 |
| FTS only | 无 Provider | 纯关键词,无需 API,离线可用 |
评论
加载评论中...