向量检索实现:SQLite + BM25 混合检索、0.7/0.3权重融合与 Embedding 降级链
第28章 向量检索实现:SQLite + BM25 混合检索、0.7/0.3 权重融合与 Embedding 降级链
"最好的向量数据库,是你不需要单独运维的那一个。" —— OpenClaw 架构决策记录
28.1 为什么选择 SQLite 而非独立向量数据库?
在向量检索领域,Pinecone、Weaviate、Qdrant、Milvus 等专用向量数据库如雨后春笋般涌现。OpenClaw 在架构选型时,明确选择了 SQLite + sqlite-vec 扩展,而非任何一个独立的向量数据库。
28.1.1 独立向量数据库的代价
| 代价 | 具体表现 |
|---|---|
| 运维复杂度 | 需要单独部署、配置、监控、备份向量数据库服务 |
| 网络依赖 | 每次检索需要网络请求,引入延迟和不可用风险 |
| 数据分离 | 文本数据在 Markdown 文件中,向量数据在远端服务,一致性难保证 |
| 成本 | 云托管向量数据库按索引大小和请求次数计费 |
| 认知负担 | 开发者需要学习专用 API、理解各数据库特有的一致性模型 |
28.1.2 SQLite 的优势
OpenClaw 的向量检索栈:
┌─────────────────────────────────┐
│ SQLite(单文件数据库) │
│ + sqlite-vec(向量扩展) │
│ + FTS5(全文检索扩展) │
└─────────────────────────────────┘
文件路径:~/.openclaw/memory/<agentId>.sqlite
选择 SQLite 的核心理由:
- 零运维:SQLite 是进程内数据库,无需服务器,随应用启动
- 文件可移植:整个向量索引是一个单文件,可以复制、备份、迁移
- 派生可重建:如前所述,SQLite 是从 Markdown 文件派生的,数据库文件损坏不丢失信息
- SQL 原生:可以用标准 SQL 查询、联合、过滤向量结果,无需学习专用查询语言
- 足够快:对于 Agent 个人记忆规模(通常数万至数百万条记录),SQLite 的性能完全满足需求
28.2 FTS5 虚表:关键词检索原理
28.2.1 FTS5 是什么
FTS5(Full-Text Search 5)是 SQLite 的内置全文检索扩展,通过**虚表(Virtual Table)**机制实现。虚表的外观和行为与普通表相同,但数据存储和检索逻辑完全自定义。
-- 创建 FTS5 虚表
CREATE VIRTUAL TABLE memory_fts USING fts5(
content, -- 要检索的文本内容
source_file, -- 来源文件路径
chunk_id, -- 对应 memory_embeddings 中的 ID
tokenize = 'porter unicode61' -- 分词器
);
28.2.2 BM25 排序算法
FTS5 默认使用 BM25(Best Matching 25) 算法对检索结果排序。BM25 是一种基于词频(TF)和逆文档频率(IDF)的概率检索模型:
$$ \text{BM25}(q, d) = \sum_{i=1}^{n} \text{IDF}(q_i) \cdot \frac{f(q_i, d) \cdot (k_1 + 1)}{f(q_i, d) + k_1 \cdot \left(1 - b + b \cdot \frac{|d|}{\text{avgdl}}\right)} $$
其中:
- $f(q_i, d)$:词 $q_i$ 在文档 $d$ 中的出现频率
- $|d|$:文档长度(词数)
- $\text{avgdl}$:语料库平均文档长度
- $k_1 = 1.2$:词频饱和参数(默认值)
- $b = 0.75$:长度归一化参数(默认值)
28.2.3 FTS5 检索示例
-- 基础关键词检索
SELECT content, chunk_id, rank
FROM memory_fts
WHERE content MATCH 'JWT 验证 RS256'
ORDER BY rank
LIMIT 24;
-- 短语检索
SELECT content, chunk_id, rank
FROM memory_fts
WHERE content MATCH '"JWT 验证"' -- 精确短语
ORDER BY rank;
-- 布尔检索
SELECT content, chunk_id, rank
FROM memory_fts
WHERE content MATCH 'auth AND (token OR session)'
ORDER BY rank;
28.3 向量存储格式与余弦相似度计算
28.3.1 向量存储表结构
CREATE TABLE memory_embeddings (
id INTEGER PRIMARY KEY,
content TEXT NOT NULL, -- 原始文本块
embedding FLOAT[768], -- 向量(维度取决于 Embedding 模型)
source_file TEXT, -- 来源文件(如 memory/2026-04-26.md)
chunk_index INTEGER, -- 在源文件中的块序号
created_at INTEGER, -- Unix 时间戳
model_name TEXT -- 生成向量的模型名称
);
FLOAT[768] 是 sqlite-vec 扩展引入的特殊列类型,支持高效的向量运算。
28.3.2 余弦相似度计算
向量相似度使用余弦相似度,完全在 SQL 内完成计算:
$$ \text{cosine_similarity}(\mathbf{u}, \mathbf{v}) = \frac{\mathbf{u} \cdot \mathbf{v}}{|\mathbf{u}| \cdot |\mathbf{v}|} $$
sqlite-vec 将此计算封装为 SQL 函数:
-- 向量相似度检索(查询向量为 :query_embedding)
SELECT
id,
content,
source_file,
vec_cosine_similarity(embedding, :query_embedding) AS vector_score
FROM memory_embeddings
ORDER BY vector_score DESC
LIMIT 24;
为什么选余弦相似度而非欧氏距离?
余弦相似度衡量的是方向相似性,而非绝对距离。对于语义嵌入向量而言,两段文本的语义相似度主要体现在向量方向上,与向量模长(可能因文本长度而异)无关。
28.4 Union 策略 vs Intersection 策略
28.4.1 两种策略的定义
在混合检索中,BM25 和向量检索各自返回一批候选结果。如何合并这两个结果集有两种策略:
Intersection(交集)策略:
BM25 Top-K ∩ Vector Top-K → 只保留同时出现在两个结果集中的文档
Union(并集)策略:
BM25 Top-K ∪ Vector Top-K → 合并两个结果集,统一排序
28.4.2 OpenClaw 选择 Union 策略的原因
OpenClaw 明确采用 Union 策略,理由如下:
| 考量 | Intersection | Union(OpenClaw 的选择) |
|---|---|---|
| 召回率 | 低(只保留两者都找到的) | 高(两个后端都有机会贡献结果) |
| 互补性 | 无法利用各后端的互补优势 | BM25 擅长精确词汇,Vector 擅长语义,互补 |
| 降级能力 | 任一后端失败则无结果 | 任一后端失败仍有另一后端的结果 |
| 适用场景 | 高精度需求(可接受低召回) | 通用记忆检索(需要高召回) |
对于 Agent 记忆检索场景,高召回率通常比高精度更重要——宁可多检索几条不完全相关的片段,也不要遗漏真正重要的信息。
28.5 candidateMultiplier:4 过度获取策略
28.5.1 为什么要过度获取候选?
在混合检索中,每个后端(BM25 和 Vector)各取 k × candidateMultiplier 条候选,而非直接取最终 k 条。默认 candidateMultiplier = 4。
如果最终目标是返回 6 条结果,每个后端先取 6 × 4 = 24 条候选。
28.5.2 过度获取的必要性
BM25 Top-24: [A, B, C, D, E, F, G, H, ...]
Vector Top-24: [A, C, E, X, Y, Z, ...]
Union 后去重: [A, B, C, D, E, F, G, H, X, Y, Z, ...]
按加权分数重排后 Top-6: [A, E, C, X, B, G]
如果不过度获取(每个后端只取 6 条):
BM25 Top-6: [A, B, C, D, E, F]
Vector Top-6: [A, C, E, X, Y, Z]
Union: [A, B, C, D, E, F, X, Y, Z](最多 12 条)
重排后 Top-6: [A, E, C, X, B, D]
过度获取确保了在重排时有足够的候选多样性。特别是当 BM25 和 Vector 的结果集重合度很高时,不过度获取可能导致最终候选池过小。
28.5.3 参数配置
vectorSearch:
finalK: 6 # 最终返回结果数
candidateMultiplier: 4 # 每个后端取 finalK × 4 = 24 条候选
# 实际每个后端各取 24 条,Union 后最多 48 条,重排后取 Top-6
28.6 权重融合:0.7 向量 + 0.3 BM25
28.6.1 融合公式
$$ \text{finalScore} = 0.7 \times vectorScore + 0.3 \times bm25Score $$
28.6.2 设计理由
选择 0.7:0.3 的权重比例,基于以下工程判断:
| 考量 | 说明 |
|---|---|
| 语义理解优先 | Agent 的查询往往是自然语言,语义匹配比精确词汇匹配更重要 |
| BM25 作为锚点 | BM25 善于匹配特定术语(如函数名、代码变量),权重 0.3 确保不被完全忽略 |
| 抗噪能力 | 向量相似度有时对语义相近但无关的内容过度打分,BM25 的词汇约束有助于校正 |
| 实验结果 | 根据 OpenClaw 开发团队在内部测试集上的消融实验,0.7/0.3 的检索质量指标最优 |
28.6.3 在代码中的实现
function hybridScore(vectorScore: number, bm25Score: number): number {
const VECTOR_WEIGHT = 0.7;
const BM25_WEIGHT = 0.3;
return VECTOR_WEIGHT * vectorScore + BM25_WEIGHT * bm25Score;
}
// 对所有候选进行重排
const reranked = candidates
.map(chunk => ({
...chunk,
finalScore: hybridScore(chunk.vectorScore, chunk.normalizedBm25Score)
}))
.sort((a, b) => b.finalScore - a.finalScore)
.slice(0, finalK);
28.7 BM25 得分规范化公式
28.7.1 为什么需要规范化?
BM25 返回的原始分数(在 SQLite FTS5 中,rank 列实际上是负数,绝对值越小表示相关性越高)与向量相似度分数(0 到 1 之间的浮点数)处于完全不同的数量级,无法直接加权融合。
需要将 BM25 分数规范化到 [0, 1] 区间。
28.7.2 规范化公式
OpenClaw 使用基于排名的规范化(Rank-based Normalization):
$$ \text{bm25_score}(r) = \frac{1}{1 + r} $$
其中 $r$ 是 BM25 检索结果中的排名(rank),从 0 开始:
| 排名 $r$ | 规范化 BM25 分数 |
|---|---|
| 0(第 1 名) | $\frac{1}{1+0} = 1.0$ |
| 1(第 2 名) | $\frac{1}{1+1} = 0.5$ |
| 2(第 3 名) | $\frac{1}{1+2} = 0.333$ |
| 3(第 4 名) | $\frac{1}{1+3} = 0.25$ |
| 7(第 8 名) | $\frac{1}{1+7} = 0.125$ |
| 23(第 24 名) | $\frac{1}{1+23} = 0.042$ |
这个公式确保:
- 排名第 1 的结果得到满分 1.0
- 分数随排名衰减,但衰减曲线平滑(调和级数)
- 无论 BM25 原始分数的数值范围如何,规范化分数始终在 [0, 1] 内
28.7.3 实现代码
function normalizeBm25Score(rank: number): number {
// rank 从 0 开始,rank=0 表示 BM25 排名第一
return 1 / (1 + rank);
}
// 对 BM25 结果按排名分配规范化分数
const bm25Results = await bm25Search(query, limit: 24);
const normalizedBm25 = bm25Results.map((result, index) => ({
...result,
normalizedBm25Score: normalizeBm25Score(index) // index 即为 rank
}));
28.8 Graceful Degradation:任意后端失败不影响另一个
28.8.1 设计原则
混合检索的每个后端(BM25 和 Vector)都独立地以 .catch(() => []) 包裹,确保任一后端抛出异常,另一个后端的结果仍然可用:
const [vectorResults, bm25Results] = await Promise.all([
searchByVector(queryEmbedding, limit: 24)
.catch((err) => {
logger.warn("Vector search failed, falling back to BM25 only", err);
return []; // 返回空数组,不影响 BM25 结果
}),
searchByBm25(query, limit: 24)
.catch((err) => {
logger.warn("BM25 search failed, falling back to vector only", err);
return []; // 返回空数组,不影响 Vector 结果
})
]);
// Union 合并
const combined = unionByChunkId(vectorResults, bm25Results);
28.8.2 降级场景
| 场景 | 结果 |
|---|---|
| 两个后端正常 | 完整混合检索结果 |
| Vector 后端失败(如 Embedding 不可用) | 仅 BM25 结果(纯关键词检索) |
| BM25 后端失败(FTS5 索引损坏) | 仅 Vector 结果(纯语义检索) |
| 两个后端都失败 | 空结果集(不抛出异常,任务继续) |
28.8.3 为什么用 Union 而非 Promise.all 直接失败
如果不使用 .catch(() => []) 的隔离机制:
// 危险写法:任一后端失败会导致整个检索失败
const [vectorResults, bm25Results] = await Promise.all([
searchByVector(queryEmbedding), // 如果失败,整个 Promise.all 都失败
searchByBm25(query)
]);
有了 Graceful Degradation,即使向量数据库出现问题(例如 Embedding 服务不可用),记忆检索依然能够工作,Agent 不会因为检索系统部分故障而完全失去记忆访问能力。
28.9 Embedding 四级降级链
28.9.1 降级链全图
查询文本
│
▼
[1] 本地 GGUF 模型(embeddinggemma-300M-Q8_0.gguf)
│ 失败/不可用
▼
[2] OpenAI text-embedding-3-small(1536 维)
│ 失败/不可用
▼
[3] Gemini gemini-embedding-001(768 维)
│ 失败/不可用
▼
[4] 纯 BM25(退出向量检索,仅关键词)
28.9.2 第一级:本地 GGUF 模型
embedding:
primary:
type: local_gguf
model_path: ~/.openclaw/models/embeddinggemma-300M-Q8_0.gguf
dimensions: 768
file_size: ~600MB
优势:
- 完全离线,无需网络
- 无 API 调用成本
- 低延迟(本地 CPU/GPU 推理)
- 数据隐私(文本不离开本地)
劣势:
- 需要 ~600MB 磁盘空间存储模型文件
- 在低性能设备上推理速度较慢
- 模型质量低于大型云端模型
28.9.3 第二级:OpenAI text-embedding-3-small
embedding:
fallback_1:
type: openai
model: text-embedding-3-small
dimensions: 1536
api_key: ${OPENAI_API_KEY}
1536 维的高维向量提供了优秀的语义区分度。成本较低(约 $0.02/1M tokens),适合作为第一个云端降级选项。
28.9.4 第三级:Gemini gemini-embedding-001
embedding:
fallback_2:
type: gemini
model: gemini-embedding-001
dimensions: 768
api_key: ${GEMINI_API_KEY}
作为第二个云端选项,维度为 768,与本地 GGUF 模型相同。适合在 OpenAI 服务不可用时使用。
28.9.5 第四级:纯 BM25
当所有 Embedding 方案均失败时,系统退出向量检索,回退到纯 BM25 关键词检索:
async function getEmbedding(text: string): Promise<Float32Array | null> {
try { return await localGgufEmbed(text); } catch {}
try { return await openaiEmbed(text); } catch {}
try { return await geminiEmbed(text); } catch {}
return null; // 返回 null,触发纯 BM25 模式
}
// 在检索流程中
const embedding = await getEmbedding(query);
if (embedding === null) {
// 降级到纯 BM25
return await searchByBm25(query, limit: finalK);
}
// 正常混合检索流程
return await hybridSearch(query, embedding);
28.9.6 维度不兼容问题
不同模型生成的向量维度不同(768 vs 1536),因此不能混用:
-- 索引时记录使用的模型
UPDATE memory_embeddings
SET model_name = 'text-embedding-3-small'
WHERE id = :id;
当降级到不同维度的模型时,需要重建向量索引(对文本数据重新 Embedding)。OpenClaw 在检测到模型变更时,会自动触发后台重建任务:
if (currentModel !== storedModel) {
logger.info("Embedding model changed, scheduling background re-embedding");
scheduleBackgroundReembedding(); // 异步重建,不阻塞当前检索
}
28.10 Chunking:文本分块的工程权衡
28.10.1 为什么需要分块?
Embedding 模型通常有输入 Token 上限(如 512 或 8192 tokens)。更重要的是,过长的文本块会导致:
- 语义稀释:一个 Embedding 向量需要表达过多内容,语义精度下降
- 检索粒度粗:返回大块文本,其中大部分与查询无关
- Token 浪费:注入 Context 时占用过多空间
28.10.2 OpenClaw 的 Chunking 参数
| 参数 | 默认值 | 说明 |
|---|---|---|
| 目标大小 | 400 tokens(~1600 字符) | 每个块的目标 Token 数 |
| Overlap | 80 tokens | 相邻块重叠的 Token 数 |
| 批处理上限 | 8000 tokens/批 | 单次 Embedding API 请求的最大 Token 量 |
| 并发数 | 4 | 并行 Embedding 请求数 |
28.10.3 400 tokens 目标大小的权衡
太小(< 100 tokens):
✓ 检索精度高
✗ 上下文不足,难以理解孤立片段
太大(> 1000 tokens):
✓ 上下文完整
✗ 语义稀释,检索精度低;注入时占用大量 Context
400 tokens(~1600 字符):
✓ 约等于一个完整的段落或函数定义
✓ 语义足够集中
✓ 注入 Context 时合理
28.10.4 80 tokens Overlap 的作用
Overlap 确保跨越块边界的信息不会丢失:
原始文本:[...段落A末尾...][...段落B开头...]
↑ 块1结束
↑ 块2开始(80 tokens 的重叠)
块1:[段落A大部分 + 段落B前80 tokens]
块2:[段落B前80 tokens + 段落B大部分]
↑ 重叠部分
如果没有 Overlap,恰好横跨两块的一段重要讨论可能在检索时被"切断",两块都只包含一半,都不会得到高相关性分数。
28.10.5 批处理与并发的工程细节
const BATCH_MAX_TOKENS = 8000;
const CONCURRENT_REQUESTS = 4;
// 将 chunks 按 Token 数分批
const batches = groupIntoBatches(chunks, BATCH_MAX_TOKENS);
// 并发处理,但限制并发数为 4(避免 API 限流)
const results = await pLimit(CONCURRENT_REQUESTS)(
batches.map(batch => () => embedBatch(batch))
);
限制并发数为 4 是对 API 速率限制(Rate Limit)的保护:大多数 Embedding API 对并发请求有限制,超过限制会导致 429 错误。
28.11 混合检索完整流程
用户查询 → 生成查询 Embedding(四级降级链)
│
┌───────┴───────┐
▼ ▼
BM25 检索(FTS5) 向量检索(sqlite-vec)
各取 24 条候选 各取 24 条候选
.catch(()=>[]) .catch(()=>[])
│ │
└───────┬───────┘
▼
Union 合并去重(按 chunk_id)
│
▼
BM25 得分规范化:bm25Score = 1/(1+rank)
│
▼
加权融合:finalScore = 0.7×vectorScore + 0.3×bm25Score
│
▼
按 finalScore 降序排列
│
▼
取 Top-6 结果
│
▼
注入 Context Window
28.12 本章小结
OpenClaw 的向量检索实现,是一个以实用主义为核心的工程设计:
- SQLite 替代独立向量数据库,实现零运维、文件可移植、SQL 原生
- FTS5 虚表 提供 BM25 关键词检索,与向量检索互补
- 余弦相似度 在 SQL 内完成,无需离开 SQLite 生态
- Union 策略 确保高召回率,两个后端互补而非互相约束
- candidateMultiplier:4 过度获取,保证重排时候选多样性
- 0.7/0.3 权重融合 平衡语义理解与词汇精确度
$$ \text{finalScore} = 0.7 \times vectorScore + 0.3 \times \frac{1}{1 + rank_{bm25}} $$
- Graceful Degradation 确保任一后端故障不影响整体服务
- 四级 Embedding 降级链 从本地到云端,确保在任何环境下都能生成向量
- 400/80 tokens 分块参数 平衡检索精度与上下文完整性
下一章:第29章 — Workspace 文件体系全解:AGENTS.md / SOUL.md / USER.md / HEARTBEAT.md 的作用与写法