20.3 混合搜索(BM25 + 向量)
生成模型:Claude Opus 4.6 (anthropic/claude-opus-4-6) Token 消耗:输入 ~330k tokens,输出 ~7k tokens(本节)
向量搜索擅长捕捉语义相关性——"TypeScript 配置" 和 "TS 项目设置" 的向量距离很近,即使它们没有共享关键词。但向量搜索也有盲区:当用户搜索精确的函数名 calculateTotal 时,传统的关键词匹配反而更精准。OpenClaw 的解决方案是混合搜索(Hybrid Search)——将 BM25 全文搜索与向量搜索的结果融合,兼顾精确匹配和语义理解。
20.3.1 BM25 全文搜索原理
什么是 BM25?
衍生解释:BM25(Best Matching 25)是信息检索领域最经典的排名算法之一,由 Stephen Robertson 等人在 1994 年提出。它是 TF-IDF(词频-逆文档频率)的改进版,核心思想是:一个词在某文档中出现的频率越高(TF),在整个文档集中出现的频率越低(IDF),这个词对该文档的重要性就越大。BM25 在 TF-IDF 基础上增加了两个改进:(1) 词频饱和——词频增长到一定程度后,贡献不再线性增加;(2) 文档长度归一化——长文档不会因为词多而占优势。公式为:
BM25(q,d)=∑t∈qIDF(t)⋅f(t,d)+k1⋅(1−b+b⋅avgdl∣d∣)f(t,d)⋅(k1+1)
其中 $f(t, d)$ 是词 $t$ 在文档 $d$ 中的频率,$|d|$ 是文档长度,$\text{avgdl}$ 是平均文档长度,$k_1$ 和 $b$ 是可调参数。
OpenClaw 不需要自己实现 BM25——SQLite 的 FTS5 引擎内置了 BM25 排名函数。OpenClaw 要做的是:(1) 将文本块插入 FTS5 虚拟表,(2) 构造合适的 MATCH 查询,(3) 将 BM25 排名转换为可与向量分数融合的归一化分数。
FTS 查询构造
用户的自然语言查询需要转换为 FTS5 能理解的 MATCH 表达式。buildFtsQuery 负责这个转换:
// src/memory/hybrid.ts
export function buildFtsQuery(raw: string): string | null {
const tokens = raw
.match(/[A-Za-z0-9_]+/g) // 提取所有字母数字词
?.map(t => t.trim())
.filter(Boolean) ?? [];
if (tokens.length === 0) return null; // 没有有效词,放弃搜索
const quoted = tokens.map(t => `"${t.replaceAll('"', '')}"`);
return quoted.join(" AND "); // 所有词必须同时出现
}转换示例:
TypeScript 配置
"TypeScript" AND "配置"
— 注:中文被过滤,只剩 "TypeScript"
calculate total price
"calculate" AND "total" AND "price"
三个词必须全部出现
API_KEY setup
"API_KEY" AND "setup"
下划线视为词的一部分
你好世界
null
全是中文,无有效 token
注意一个重要限制:buildFtsQuery 使用 [A-Za-z0-9_]+ 正则提取 token,这意味着纯中文查询会被过滤为空,回退到纯向量搜索。这是一个合理的权衡——FTS5 默认的分词器对中文支持有限,而向量搜索天然支持多语言。
关键词搜索实现
searchKeyword 执行实际的 FTS5 查询:
几个要点:
bm25()函数:FTS5 内置的排名函数,返回一个负数(越小越相关)。SQLite 文档中称其为 "rank",值域为 $(-\infty, 0]$。ORDER BY rank ASC:因为 rank 越小越好,所以升序排列。model过滤:确保只搜索当前嵌入模型对应的 chunks,避免跨模型数据混淆。
BM25 分数归一化
FTS5 的 bm25() 返回的是负数 rank,需要转换为 $[0, 1]$ 区间的分数才能与向量搜索的余弦相似度融合:
等等——Math.max(0, rank) 对负数 rank 会返回 0,而 1 / (1 + 0) = 1?实际上,SQLite FTS5 的 bm25() 返回值在 OpenClaw 的使用场景中会被 ORDER BY rank ASC 处理后传入,且 FTS5 的 rank 值经过 bm25() 聚合函数后在大多数实现中是非负的(具体取决于 SQLite 版本和配置)。转换函数使用倒数映射 $s = \frac{1}{1 + r}$:
0
1.0
最相关
1
0.5
4
0.2
9
0.1
999
≈ 0.001
几乎无关(或异常值)
这个映射是单调递减的,且自然地将分数压缩到 $(0, 1]$ 区间,与余弦相似度的值域 $[-1, 1]$(实际中通常为 $[0, 1]$)兼容。
20.3.2 混合检索实现
搜索入口
MemoryIndexManager.search() 是混合搜索的入口方法。它编排了三个阶段:关键词搜索、向量搜索、结果融合。
关键设计决策:
候选倍数(candidateMultiplier):如果用户只需要 10 条结果,系统会向两个搜索通道分别请求
10 × multiplier条候选(默认 multiplier = 3,即 30 条)。更多候选意味着融合后的 Top-K 更准确,代价是搜索量略大。错误隔离:两个搜索通道分别用
.catch(() => [])包裹——关键词搜索失败不影响向量搜索,反之亦然。零向量检测:如果嵌入 API 返回全零向量(通常意味着嵌入失败),则跳过向量搜索,只使用关键词结果。
用时序图表示完整搜索流程:
20.3.3 分数融合策略:加权线性组合
融合算法
mergeHybridResults 是混合搜索的核心——将两个来源的结果按 ID 合并,计算加权分数:
融合公式非常直观:
scorefinal=wv×svector+wt×stext
其中默认 $w_v = 0.7$,$w_t = 0.3$。
三种命中情况
一个文本块在融合时可能处于三种状态:
双重命中
0.85
0.72
$0.7 \times 0.85 + 0.3 \times 0.72 = 0.811$
仅向量命中
0.85
0
$0.7 \times 0.85 = 0.595$
仅关键词命中
0
0.72
$0.3 \times 0.72 = 0.216$
双重命中的 chunk 获得最高分数——这正是混合搜索的价值所在。一个既在语义上相关、又包含精确关键词的结果,几乎肯定是用户想要的。
权重的含义
默认的 7:3 权重偏向向量搜索,反映了一个设计判断:对于 AI Agent 的记忆检索场景,语义理解比精确匹配更重要。用户搜索 "之前讨论的数据库方案" 时,向量搜索能找到包含 "PostgreSQL vs MongoDB 对比" 的记忆块,而关键词搜索可能一无所获(因为没有共享词汇)。
但关键词搜索的 30% 权重也不可忽视——它在以下场景中起到关键补充:
精确名称搜索:函数名、变量名、项目名
错误信息搜索:搜索特定的报错文本
版本号搜索:搜索 "v2.3.1" 这类精确字符串
中英混合查询:英文关键词通过 BM25 精确匹配,中文语义通过向量补充
配置可调
权重和其他混合搜索参数都可以通过配置覆盖:
用户可以在 settings.json 中调整:
设置 vectorWeight: 0, textWeight: 1 可以完全禁用向量搜索,只用 BM25;反过来设 enabled: false 则完全禁用混合搜索,只用向量。
20.3.4 从理论到实践:一次完整搜索
让我们跟踪一次完整的混合搜索,理解数据如何在各层流动。
场景:用户问 Agent "我们之前决定用什么数据库?"
步骤 1:Agent 调用 memory_search 工具,传入查询 "之前决定用什么数据库"
步骤 2:search() 方法启动两个并行通道
关键词通道:
buildFtsQuery("之前决定用什么数据库")→ 所有中文被过滤 → 返回null→ 关键词搜索跳过向量通道:嵌入 API 将查询转为 1536 维向量 → sqlite-vec 执行余弦距离搜索 → 返回 Top-30 候选
步骤 3:由于关键词结果为空,融合退化为纯向量搜索
步骤 4:过滤 score >= minScore,返回 Top-10 给 Agent
现在换一个场景:用户搜索 "PostgreSQL migration plan"
关键词通道:
buildFtsQuery(...)→"PostgreSQL" AND "migration" AND "plan"→ BM25 返回包含这三个词的 chunks向量通道:向量搜索返回语义相关的 chunks(可能包含 "数据库迁移方案"、"从 MongoDB 切换"等)
融合:同时命中的 chunks 得到最高分,如某个 chunk 标题是 "PostgreSQL migration plan — 2026.01.10"
这个例子很好地展示了混合搜索的互补性:关键词搜索精准锁定包含 "PostgreSQL" 的记忆,向量搜索补充了语义相关但措辞不同的结果。
本节小结
BM25 全文搜索通过 SQLite FTS5 实现,
buildFtsQuery将用户输入转换为 AND 连接的引号词查询,bm25RankToScore将排名转换为 $(0, 1]$ 区间的归一化分数。混合搜索入口
search()同时启动关键词和向量两个通道,各自独立容错(.catch(() => [])),通过候选倍数(默认 3x)确保融合质量。加权线性组合是融合策略的核心:$\text{score} = 0.7 \times s_{\text{vec}} + 0.3 \times s_{\text{text}}$。双重命中的 chunk 获得最高分数,确保既语义相关又包含精确关键词的结果排在最前。
中文查询的特殊处理:由于 FTS5 分词器对中文支持有限,纯中文查询自然降级为纯向量搜索,这是一个合理的设计权衡。
所有参数可配置:权重比例、候选倍数、是否启用混合搜索,都可以通过
settings.json调整,适应不同使用场景。
Last updated