20 KiB
BM25 + TF-IDF:我如何为 Claude Code 构建语义路由引擎
开篇:为什么需要语义路由
如果你用过 Claude Code,你一定体会过它的"万能感"——几乎任何问题都能给出像样的回答。但随着深度使用,另一个问题慢慢浮现:什么都会,但什么都不够专业。
当你在处理一个复杂的 Kubernetes 网络故障时,你希望得到的不是一个"全知"AI 给出的笼统建议,而是一个真正懂 K8s 网络栈的专家,能直接帮你分析 iptables 规则、CNI 插件冲突和 Service CIDR 问题。
这就引出了一个系统设计问题:与其让一个 AI 做所有事,不如路由到 50 个专家。
这正是我在构建 Bookworm Smart Assistant 时的核心思路。Bookworm 是一套运行在 Claude Code 之上的智能路由系统,通过语义分析,自动将用户的自然语言请求路由到最合适的专家技能:
"React 页面加载慢" → performance-expert (不是 frontend-expert)
"API 安全漏洞" → security-expert (不是 backend-builder)
"帮我写个 PRD" → product-manager-expert
"从零搭建电商后台" → orchestrator (多技能编排)
注意第一个例子:用户说的是 React,但问题是性能,所以应该路由到性能专家而非前端专家。这种语义理解,正是路由引擎最难的部分。
为什么选择 BM25,而非向量匹配
在构建路由引擎时,我面临一个技术选型问题:用 embedding 向量匹配,还是 BM25?
搜索引擎领域有一个类比可以帮助理解:Google 最早用的就是 BM25 家族的算法(准确说是基于 TF-IDF 的改进变体)来匹配用户查询和网页文档。BM25 的优势在于:
- 可解释性强:每个关键词的贡献都可以量化,方便调试
- 无需 GPU/API:纯本地计算,零延迟
- 精确匹配优先:技术名词(如
Kubernetes、Playwright)精确命中权重高 - 易于微调:通过三层权重体系细粒度控制每个关键词的重要性
向量 embedding 的优势在于语义泛化("速度慢"能匹配 "performance"),但在这个场景下,我们有更好的解法:同义词展开。通过维护一个人工精心整理的 19 组同义词词典,可以在不引入向量模型的情况下,实现中英文混合输入的语义覆盖。
这篇文章将完整拆解这套路由引擎的技术实现,包括算法细节、工程踩坑和效果数据。所有代码片段均来自真实生产源码(scripts/route-analyzer.js、scripts/tfidf-engine.js)。
Part 1:BM25 算法原理与适配
BM25 经典公式
BM25(Best Match 25)是信息检索领域的标准排序算法,由 Robertson 等人于 1994 年提出。其核心公式为:
Score(q, d) = Σ IDF(t) × f(t,d) × (k1 + 1) / (f(t,d) + k1 × (1 - b + b × |d| / avgdl))
其中:
q:查询 (query)d:文档 (document),在我们的场景中是"技能"(skill)t:查询中的每个词项 (term)f(t, d):词 t 在文档 d 中的频率 (term frequency)|d|:文档长度(关键词数量)avgdl:语料库中文档的平均长度k1:词频饱和参数,控制词频对评分的影响上限b:长度归一化参数,控制文档长度对评分的影响程度
IDF(逆文档频率)的计算公式为:
IDF(t) = log((N - df(t) + 0.5) / (df(t) + 0.5) + 1)
其中 N 是技能总数,df(t) 是包含词 t 的技能数量。
参数调优:k1 和 b 的工程选择
在原始论文中,BM25 推荐的参数范围是 k1 ∈ [1.2, 2.0],b ∈ [0.75, 1.0]。我们最终选择了:
- k1 = 1.2(词频饱和较快,避免某个关键词独霸评分)
- b = 0.75(中等长度归一化,对关键词数量多的技能有轻微惩罚)
为什么这样选?在路由场景中,一个技能通常有 30-80 个关键词(文档长度差异不大),长度归一化的影响相对较小。b=0.75 是经典值,也是大多数搜索引擎的默认选择。
核心源码:BM25 评分实现
以下是 scripts/route-analyzer.js 中的 BM25 核心实现(第 104-152 行):
// === BM25 参数构建 (v4.9) ===
function buildBM25Params(index) {
const skills = index.skills || [];
const N = skills.length;
// 计算平均文档长度 (关键词数)
let totalDl = 0;
for (const skill of skills) {
totalDl += (skill.keywords || []).length;
}
const avgdl = N > 0 ? totalDl / N : 1;
// 构建倒排索引计算 IDF
const df = new Map(); // keyword → 出现在多少个技能中
for (const skill of skills) {
const seen = new Set();
for (const { keyword } of (skill.keywords || [])) {
const kw = keyword.toLowerCase();
if (!seen.has(kw)) {
seen.add(kw);
df.set(kw, (df.get(kw) || 0) + 1);
}
}
}
// 预计算 IDF: log((N - df + 0.5) / (df + 0.5) + 1)
const idf = new Map();
for (const [kw, docFreq] of df) {
idf.set(kw, Math.log((N - docFreq + 0.5) / (docFreq + 0.5) + 1));
}
return { N, avgdl, idf, df };
}
/**
* BM25 单项评分
*/
function computeBM25Score(tf, idf, dl, avgdl, k1 = 1.2, b = 0.75) {
const numerator = tf * (k1 + 1);
const denominator = tf + k1 * (1 - b + b * dl / avgdl);
return idf * numerator / denominator;
}
与经典 BM25 的差异:TF 的语义化
在标准 BM25 中,f(t, d) 是词在文档中的出现次数。但在我们的场景中,每个关键词被设计者赋予了人工权重(三层权重体系,详见 Part 2),这个权重本质上就是对该关键词"重要程度"的语义编码。
因此,我们将关键词的三层权重作为 BM25 中的 f(t, d)(即 TF 替代值),精确匹配时取原始权重,包含匹配(partial match)时打 0.6 折扣:
// 精确匹配
if (queryTokens.has(kwLower)) {
const bm25 = computeBM25Score(adjustedWeight, kwIDF, dl, avgdl);
totalScore += bm25;
continue;
}
// 包含匹配 (折扣 0.6)
for (const token of queryTokens) {
if (token.length >= 3 && kwLower.includes(token)) {
const bm25 = computeBM25Score(adjustedWeight * 0.6, kwIDF, dl, avgdl);
totalScore += bm25;
break;
}
}
Part 2:TF-IDF 三层关键词体系
为什么需要三层权重
在 50 个专家技能中,关键词的区分能力差异巨大:
Playwright只属于browser-automation-expert,出现即定位(高区分度)部署在 frontend、backend、devops、cloud-native 等 20+ 个技能中都有,几乎无区分能力(低区分度)
如果用统一权重处理所有关键词,低区分度关键词会造成大量噪声。三层权重体系正是为了解决这个问题。
三层权重设计
| 层级 | 权重值 | 含义 | 示例关键词 |
|---|---|---|---|
core |
1.0 | 核心定义词,技能的唯一标识 | Playwright(browser-automation) |
strong |
0.7 | 强相关词,高度倾向于该技能 | E2E测试(browser-automation) |
extended |
0.4 | 扩展词,有弱关联但不唯一 | 测试(多技能共享) |
TF-IDF 加权的计算过程
在索引编译阶段,scripts/tfidf-engine.js 对每个关键词叠加 IDF 权重,生成 tfidfWeight 字段(源码第 41-63 行):
/**
* 计算平滑 IDF 值
* 公式: log((N+1)/(df+1)) + 1
*/
function computeIDF(df, N) {
return Math.log((N + 1) / (df + 1)) + 1;
}
/**
* 为索引中的每个关键词附加 tfidfWeight 字段
* tfidfWeight = 原始 weight * IDF
*/
function applyTFIDFWeights(index) {
const skills = index.skills || [];
const N = skills.length;
const corpus = buildCorpus(skills);
for (const skill of skills) {
for (const kwEntry of (skill.keywords || [])) {
const kw = kwEntry.keyword.toLowerCase();
const df = corpus.has(kw) ? corpus.get(kw).size : 0;
const idf = computeIDF(df, N);
kwEntry.tfidfWeight = Math.round(kwEntry.weight * idf * 100) / 100;
}
}
}
注意这里使用的是平滑 IDF(加法平滑 log((N+1)/(df+1)) + 1),而不是标准 IDF(log(N/df))。平滑公式的好处是避免了 df=N 时 IDF 为 0 的问题,保证每个关键词至少有基础权重。
实际效果对比
以 N=50 个技能为例,不同区分度的关键词:
| 关键词 | df(出现技能数) | IDF | weight | tfidfWeight | 说明 |
|---|---|---|---|---|---|
playwright |
1 | 4.63 | 1.0 | 4.63 | 极高区分度 |
kubernetes |
1 | 4.63 | 1.0 | 4.63 | 极高区分度 |
react |
8 | 1.95 | 1.0 | 1.95 | 中等区分度 |
性能优化 |
15 | 1.22 | 0.7 | 0.85 | 中低区分度 |
部署 |
25 | 0.73 | 0.4 | 0.29 | 低区分度 |
可以看到,虽然 react 也是 core 关键词(weight=1.0),但因为它出现在 8 个技能中,TF-IDF 自动将其 tfidfWeight 压低到 1.95,而 playwright 的 tfidfWeight 高达 4.63。
运行时的 IDF 处理:避免双重应用
这里有一个关键的工程细节:tfidfWeight 已经在编译期将 IDF 因子融入权重中了。如果在运行时 BM25 评分时再乘以 IDF,就会双重应用 IDF,导致高区分度关键词被过度放大。
这正是 v5.5 架构评审发现的一个 P0 级 Bug(详见 Part 6 踩坑部分)。修复方式是在运行时检测关键词是否已有 tfidfWeight,有则将 IDF 设为 1:
// route-analyzer.js 第 223-224 行
// 修复: tfidfWeight 已含 IDF 因子,BM25 中不再重复乘以 IDF
const kwIDF = kwEntry.tfidfWeight ? 1 : (idfMap.get(kwLower) || 0);
Part 3:上下文感知融合
单维 BM25 的局限
纯 BM25 评分有一个问题:它只看当前这条消息,忽略了会话上下文。
假设用户刚完成了一个 React 组件的开发(用了 frontend-expert),现在说"帮我优化一下"。这句话本身没有任何技术关键词,BM25 会输出低置信度并回退到默认技能。但结合会话上下文,答案很可能仍是 frontend-expert。
四维融合公式
v5.0 引入了上下文感知融合,将四个维度的信号线性叠加:
/**
* 融合 BM25 + 上下文 + 项目类型 + 工作流模式
* 权重: BM25 0.6 + context 0.2 + project 0.1 + workflow 0.1
*/
function contextAwareScore(bm25Score, contextScore, projectBoost, workflowScore) {
const CTX_BASE = 5.0;
return bm25Score * 0.6
+ contextScore * CTX_BASE * 0.2
+ projectBoost * CTX_BASE * 0.1
+ workflowScore * CTX_BASE * 0.1;
}
四个维度的语义:
- BM25 基础分(0.6):当前消息与技能关键词的语义匹配度,权重最高
- 会话上下文(0.2):最近 10 次技能调用历史,衰减因子 0.85
- 项目类型(0.1):当前工作目录的技术栈类型(9 种项目类型检测)
- 工作流模式(0.1):基于历史操作序列的 n-gram 模式预测
会话滑动窗口设计
会话上下文由 scripts/context-tracker.js 维护,核心是一个最多保存 10 条记录的滑动窗口,每个历史技能调用带有衰减系数:
const MAX_WINDOW = 10;
const DECAY_FACTOR = 0.85;
function computeContextScore(candidateSkill, composableIndex) {
const state = loadState();
const recent = state.recentSkills;
if (recent.length === 0) return 0;
let score = 0;
for (let i = 0; i < recent.length; i++) {
const recentSkill = recent[recent.length - 1 - i];
const decay = Math.pow(DECAY_FACTOR, i);
// 同技能重复使用 +0.3
if (recentSkill === candidateSkill) {
score += 0.3 * decay;
continue;
}
// composable enhances 关系 +0.5
const comp = composableIndex[recentSkill] || {};
if (comp.enhances && comp.enhances.includes(candidateSkill)) {
score += 0.5 * decay;
}
}
return Math.min(1.0, Math.round(score * 100) / 100);
}
衰减系数 0.85 的含义:最近一次调用的权重是 1.0,上一次是 0.85,再上一次是 0.72(0.85²),以此类推。10 次前的调用权重已衰减至 0.85⁹ ≈ 0.23。
为什么从乘法改为线性加权
v5.5 架构评审发现了一个严重问题:原始版本使用乘法调制融合:
// 原始错误公式(已废弃)
return bm25Score * (1 + contextScore * 0.2) * (1 + projectBoost * 0.1) * ...
这个公式有一个致命缺陷:当 bm25Score = 0 时(BM25 无匹配),整个乘积仍然为 0,上下文信号完全失效。改为线性加权后,上下文信号可以独立贡献分数,实现真正的上下文延续。
Part 4:消歧规则引擎
"测试"一词的路由挑战
中文的"测试"是一个典型的多义词,在不同上下文中指向完全不同的技能:
| 输入示例 | 正确路由 | 错误路由(不消歧时) |
|---|---|---|
| "帮我写单元测试" | tester-expert |
正确 |
| "渗透测试报告怎么写" | security-expert |
tester-expert |
| "用户可用性测试方法" | ux-researcher |
tester-expert |
| "A/B 测试怎么设计" | data-analyst-expert |
tester-expert |
27 条消歧规则的设计
消歧规则引擎采用模式匹配 + 分数调整的方式处理这类问题。每条规则包含:
trigger:正则表达式,匹配时激活规则boost:被加分的目标技能penalty:被降分的竞争技能列表weight:加分强度(0.2-0.3)
以"测试污染"消歧为例(scripts/disambiguation-rules.json):
{
"id": "R19",
"note": "安全测试 → security-expert (tester 消歧)",
"trigger": "渗透测试|安全测试|漏洞测试|fuzz.*test|模糊测试",
"boost": "security-expert",
"penalty": ["tester-expert"],
"weight": 0.3
}
规则应用的核心逻辑:
function applyDisambiguation(results, queryText, index) {
if (results.length < 2) return results;
for (const rule of DISAMBIGUATION_RULES) {
if (!rule.trigger.test(queryLower)) continue;
const boosted = results.find(r => r.name === rule.boost && r.score > 0);
if (!boosted) continue;
// 基于原始分数计算 boost,取最大值而非累积相乘
if (!boosted._baseScore) boosted._baseScore = boosted.score;
const candidateScore = boosted._baseScore * (1 + rule.weight);
boosted.score = Math.max(boosted.score, candidateScore);
// 排名强制: 被惩罚技能不得超过 boosted
for (const r of results) {
if (rule.penalty.includes(r.name) && r.score > boosted.score) {
r.score = boosted.score * 0.95;
}
}
}
results.sort((a, b) => b.score - a.score);
return results;
}
规则外部化的设计决策
v5.5 将消歧规则从 JavaScript 代码中抽出,存放到独立的 disambiguation-rules.json 文件中。外部化后,规则的迭代速度明显加快——从 v5.5 到 v5.6,仅用一次 PR 就新增了 5 条规则(R23-R27)。
Part 5:自适应学习闭环
显式纠正 + 隐式反馈
学习信号来源有两类:
显式纠正:用户手动指出路由错误:
{
"ts": "2026-02-27T10:23:15Z",
"query": "React 组件加载慢",
"routedTo": "frontend-expert",
"correctedTo": "performance-expert",
"topConfidence": 0.72
}
隐式反馈:路由后 5 分钟内监测实际技能调用。如果用户路由到 A 后随即切换到 B,则以 0.5 倍权重记录为弱信号。
权重学习:指数衰减 5 天半衰期
对每条纠正记录的衰减公式:
decay = 0.5 ^ (age / (5 × 86400s))
delta = 0.1 × decay × implicitFactor
其中 implicitFactor 对隐式反馈为 0.5,手动纠正为 1.0。
安全约束:防止学习系统暴走
| 约束 | 实现 |
|---|---|
| 权重限幅 | [-0.5, +0.5],防止单一反馈主导 |
| 技能名白名单 | 只接受 skills-index.json 中存在的技能 |
| Holdout 验证集 | 70/30 分割,30% 不参与训练 |
| 权重快照 | 每次学习前自动备份,保留最近 20 个 |
Part 6:工程经验与踩坑
坑 1:IDF 双重应用(P0 级 Bug)
现象:高区分度关键词得分异常高。
根因:tfidf-engine.js 编译期已将 IDF 乘入 tfidfWeight,运行时 BM25 又乘了一次 IDF,导致权重被平方放大:
错误分数 = (weight × IDF) × IDF = weight × IDF²
修复:运行时检测 tfidfWeight 存在则 kwIDF=1。
坑 2:testing 同义词污染
现象:"A/B 测试"、"渗透测试"全部被路由到 tester-expert。
根因:同义词词典有泛化的 testing 组,把所有含"测试"的查询都展开到 tester 的核心关键词。
修复:拆分为 testing-unit(正统测试)和 testing-meta(测试类比),同时补充消歧规则 R19-R22。
坑 3:融合公式的零点问题
现象:会话中重复使用同一技能后输入模糊查询,上下文无效。
根因:乘法融合在 bm25Score=0 时整个结果为 0,上下文信号完全丢失。
修复:改为线性加权,引入 CTX_BASE=5.0 归一化。
坑 4:管道命令的退出码覆盖
现象:vitest run | tail -5 退出码始终是 tail 的 0,即使测试失败也判成功。
修复:解析命令输出内容,识别 12 种测试框架的汇总行格式。
Part 7:性能与效果
路由评分性能
| 阶段 | 耗时 |
|---|---|
| 索引加载(首次) | ~15ms |
| tokenize + 同义词展开 | < 1ms |
| BM25 评分(50 技能) | ~2ms |
| 上下文融合 | < 0.5ms |
| 消歧规则(27 条) | < 0.5ms |
| 总计(热缓存) | < 5ms |
10 维健康评分体系
| 维度 | 权重 | 当前值 |
|---|---|---|
| H1 配置一致性 | 13% | 100 |
| H2 行为基线(IQR+Z-score) | 13% | 100 |
| H3 磁盘健康 | 10% | 100 |
| H4 钩子完整性(SHA256+HMAC) | 13% | 100 |
| H5 技能索引同步 | 9% | 100 |
| H6 规则缓存新鲜度 | 9% | 100 |
| H7 路由准确率(455条反馈) | 13% | 100 |
| H8 学习收敛 | 10% | 90 |
| H9 路由合规率 | 10% | 100 |
| H10 Hook 有效性 | 9% | 100 |
| 综合健康分 | 99 / 100 |
总结与展望
这套基于 BM25 + TF-IDF + 上下文感知的语义路由引擎,经过从 v4.8 到 v5.6 的 8 个版本迭代,形成了一套完整的技术栈:
用户输入
→ tokenize (中文滑动窗口 + 英文单词 + 复合词优先)
→ 同义词展开 (19 组,覆盖中英文混合输入)
→ BM25 评分 (三层权重 × TF-IDF × 长度归一化)
→ 上下文融合 (BM25×0.6 + 会话×0.2 + 项目×0.1 + 工作流×0.1)
→ 消歧规则 (27 条正则,处理多义词边界)
→ 归一化置信度 (HIGH/MED/LOW 三档)
→ 学习闭环 (指数衰减权重,5天半衰期,Holdout 验证)
核心设计理念:可解释、可调试、可学习。
下一步计划
向量混合检索:对于长文本输入(>50 字),计划引入轻量 embedding,与 BM25 做 RRF(Reciprocal Rank Fusion)混合排序。
实时规则学习:基于路由反馈数据,自动识别高频误路由 pattern,辅助生成新的消歧规则候选。
多语言分词增强:计划引入 jieba 或 pkuseg 的 WASM 版本,作为滑动窗口的增强补充。
本文所有代码片段来自 Bookworm Smart Assistant v5.6 真实生产源码。 核心文件:
scripts/route-analyzer.js(589行)、scripts/tfidf-engine.js(124行)、scripts/disambiguation-rules.json(27条规则)。
知乎推荐话题:
BM25信息检索NLPClaude CodeAI编程搜索引擎算法