bookworm-smart-assistant/docs/blog-02-bm25-routing.md

750 lines
31 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
theme: github
highlight: atom-one-dark
---
# 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 的优势在于:
1. **可解释性强**:每个关键词的贡献都可以量化,方便调试
2. **无需 GPU/API**:纯本地计算,零延迟
3. **精确匹配优先**:技术名词(如 `Kubernetes`、`Playwright`)精确命中权重高
4. **易于微调**:通过三层权重体系细粒度控制每个关键词的重要性
向量 embedding 的优势在于语义泛化("速度慢"能匹配 "performance"),但在这个场景下,我们有更好的解法:**同义词展开**。通过维护一个人工精心整理的 19 组同义词词典,可以在不引入向量模型的情况下,实现中英文混合输入的语义覆盖。
这篇文章将完整拆解这套路由引擎的技术实现,包括算法细节、工程踩坑和效果数据。所有代码片段均来自真实生产源码(`scripts/route-analyzer.js`、`scripts/tfidf-engine.js`)。
---
## Part 1BM25 算法原理与适配
### BM25 经典公式
BM25Best Match 25是信息检索领域的标准排序算法由 Robertson 等人于 1994 年提出。其核心公式为:
$$
\text{Score}(q, d) = \sum_{t \in q} \text{IDF}(t) \cdot \frac{f(t, d) \cdot (k_1 + 1)}{f(t, d) + k_1 \cdot \left(1 - b + b \cdot \frac{|d|}{\text{avgdl}}\right)}
$$
其中:
- $q$:查询 (query)
- $d$:文档 (document),在我们的场景中是"技能"(skill)
- $t$:查询中的每个词项 (term)
- $f(t, d)$:词 $t$ 在文档 $d$ 中的频率 (term frequency)
- $|d|$:文档长度(关键词数量)
- $\text{avgdl}$:语料库中文档的平均长度
- $k_1$:词频饱和参数,控制词频对评分的影响上限
- $b$:长度归一化参数,控制文档长度对评分的影响程度
IDF逆文档频率的计算公式为
$$
\text{IDF}(t) = \log\left(\frac{N - \text{df}(t) + 0.5}{\text{df}(t) + 0.5} + 1\right)
$$
其中 $N$ 是技能总数,$\text{df}(t)$ 是包含词 $t$ 的技能数量。
### 参数调优k1 和 b 的工程选择
在原始论文中BM25 推荐的参数范围是 $k_1 \in [1.2, 2.0]$$b \in [0.75, 1.0]$。我们最终选择了:
- $k_1 = 1.2$(词频饱和较快,避免某个关键词独霸评分)
- $b = 0.75$(中等长度归一化,对关键词数量多的技能有轻微惩罚)
为什么这样选?在路由场景中,一个技能通常有 30-80 个关键词(文档长度差异不大),长度归一化的影响相对较小。$b=0.75$ 是经典值,也是大多数搜索引擎的默认选择。
### 核心源码BM25 评分实现
以下是 `scripts/route-analyzer.js` 中的 BM25 核心实现(第 104-152 行):
```javascript
// === 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 单项评分
* @param {number} tf - 词频 (匹配权重)
* @param {number} idf - 逆文档频率
* @param {number} dl - 文档长度 (技能关键词数)
* @param {number} avgdl - 平均文档长度
* @param {number} k1 - 词频饱和参数 (默认 1.2)
* @param {number} b - 长度归一化参数 (默认 0.75)
* @returns {number} 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 折扣:
```javascript
// 精确匹配
if (queryTokens.has(kwLower)) {
const bm25 = computeBM25Score(adjustedWeight, kwIDF, dl, avgdl);
totalScore += bm25;
matchedKeywords.push({ keyword: kwEntry.keyword, weight: bm25, matchType: 'exact' });
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;
matchedKeywords.push({ keyword: kwEntry.keyword, weight: bm25, matchType: 'partial' });
break;
}
}
```
---
## Part 2TF-IDF 三层关键词体系
### 为什么需要三层权重
在 50 个专家技能中,关键词的区分能力差异巨大:
- `Playwright` 只属于 `browser-automation-expert`,出现即定位(高区分度)
- `部署` 在 frontend、backend、devops、cloud-native 等 20+ 个技能中都有,几乎无区分能力(低区分度)
如果用统一权重处理所有关键词,低区分度关键词会造成大量噪声。三层权重体系正是为了解决这个问题。
### 三层权重设计
每个关键词在编译时被标记为三个层级之一:
| 层级 | 权重值 | 含义 | 示例关键词 |
|------|--------|------|-----------|
| `core` | 1.0 | 核心定义词,技能的唯一标识 | `Playwright`browser-automation、`Kubernetes`cloud-native|
| `strong` | 0.7 | 强相关词,高度倾向于该技能 | `E2E测试`browser-automation、`Helm`cloud-native|
| `extended` | 0.4 | 扩展词,有弱关联但不唯一 | `测试`(多技能共享)、`部署`(多技能共享)|
这三层权重由技能作者在 `SKILL.md` 中人工标注,再由 `generate-skill-index.js` 编译到 `skills-index.json`,形成 50 技能 × 2,393 个加权关键词的索引。
### TF-IDF 加权的计算过程
在索引编译阶段,`scripts/tfidf-engine.js` 对每个关键词叠加 IDF 权重,生成 `tfidfWeight` 字段(源码第 41-63 行):
```javascript
/**
* 计算平滑 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);
// TF 简化为 1 (布尔频率: 关键词在技能中出现即为 1)
kwEntry.tfidfWeight = Math.round(kwEntry.weight * idf * 100) / 100;
}
}
}
```
注意这里使用的是**平滑 IDF**(加法平滑 $\log\frac{N+1}{df+1}+1$),而不是标准 IDF$\log\frac{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 个技能中frontend、performance、reviewer 等TF-IDF 自动将其 `tfidfWeight` 压低到 1.95,而 `playwright``tfidfWeight` 高达 4.63。当用户输入 "用 Playwright 写 E2E 测试" 时,`browser-automation-expert` 会以绝对优势领先。
### 运行时的 IDF 处理:避免双重应用
这里有一个关键的工程细节:`tfidfWeight` 已经在**编译期**将 IDF 因子融入权重中了。如果在运行时 BM25 评分时再乘以 IDF就会**双重应用 IDF**,导致高区分度关键词被过度放大。
这正是 v5.5 架构评审发现的一个 P0 级 Bug详见 Part 6 踩坑部分)。修复方式是在运行时评分时检测关键词是否已有 `tfidfWeight`,有则将 IDF 设为 1即跳过 IDF 因子):
```javascript
// route-analyzer.js 第 223-224 行
// 修复: tfidfWeight 已含 IDF 因子 (tfidf-engine 编译期计算)BM25 中不再重复乘以 IDF
const kwIDF = kwEntry.tfidfWeight ? 1 : (idfMap.get(kwLower) || 0);
```
---
## Part 3上下文感知融合
### 单维 BM25 的局限
纯 BM25 评分有一个问题:它只看当前这条消息,忽略了会话上下文。
假设用户刚完成了一个 React 组件的开发(用了 `frontend-expert`),现在说"帮我优化一下"。这句话本身没有任何技术关键词BM25 会输出低置信度并回退到默认技能。但结合会话上下文,答案很可能仍是 `frontend-expert`
### 四维融合公式
v5.0 引入了上下文感知融合,将四个维度的信号线性叠加(`route-analyzer.js` 第 264-272 行):
```javascript
/**
* 融合 BM25 + 上下文 + 项目类型 + 工作流模式
* 权重: BM25 0.6 + context 0.2 + project 0.1 + workflow 0.1
*/
function contextAwareScore(bm25Score, contextScore, projectBoost, workflowScore) {
// 修复: 线性加权融合,上下文信号独立于 BM25 分数
// 上下文信号使用固定基准值缩放,确保对排名有实质影响
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 条记录的滑动窗口,每个历史技能调用带有衰减系数(第 71-108 行):
```javascript
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);
const comp = composableIndex[recentSkill] || {};
// 同技能重复使用 +0.3
if (recentSkill === candidateSkill) {
score += 0.3 * decay;
continue;
}
// composable enhances 关系 +0.5
if (comp.enhances && comp.enhances.includes(candidateSkill)) {
score += 0.5 * decay;
}
// composable requires 关系 +0.4
if (comp.requires && comp.requires.includes(candidateSkill)) {
score += 0.4 * decay;
}
}
return Math.min(1.0, Math.round(score * 100) / 100);
}
```
衰减系数 0.85 的含义:最近一次调用的权重是 1.0,上一次是 0.85,再上一次是 0.72$0.85^2$以此类推。10 次前的调用权重已衰减至 $0.85^9 \approx 0.23$,对当前路由的影响很微弱。
### 为什么从乘法改为线性加权
v5.5 架构评审发现了一个严重问题:原始版本使用乘法调制融合:
```javascript
// 原始错误公式(已废弃)
return bm25Score * (1 + contextScore * 0.2) * (1 + projectBoost * 0.1) * ...
```
这个公式有一个致命缺陷:当 `bm25Score = 0`BM25 无匹配),整个乘积仍然为 0上下文信号完全失效。这意味着对于模糊查询上下文延续机制形同虚设。
改为线性加权后,上下文信号可以独立贡献分数,哪怕 BM25 为 0会话历史也能将分数推到正值实现真正的上下文延续。
同时引入了 `CTX_BASE = 5.0` 这个归一化基准值,将 0-1 范围的上下文分数放大到与 BM25 分数同一量级,确保上下文信号对排名有实质性影响。
---
## Part 4消歧规则引擎
### "测试"一词的路由挑战
中文的"测试"是一个典型的多义词,在不同上下文中指向完全不同的技能:
| 输入示例 | 正确路由 | 错误路由(不消歧时) |
|----------|----------|---------------------|
| "帮我写单元测试" | `tester-expert` | 正确 |
| "渗透测试报告怎么写" | `security-expert` | `tester-expert` |
| "用户可用性测试方法" | `ux-researcher` | `tester-expert` |
| "A/B 测试怎么设计" | `data-analyst-expert` | `tester-expert` |
| "增长实验和 A/B 测试" | `growth-hacker` | `tester-expert` |
"测试"这个词在 `tester-expert` 中是 core 关键词TF-IDF 权重很高,但它同时也出现在其他多个技能的 extended 关键词中。没有消歧机制时,"测试"几乎成了 `tester-expert` 的专属路由触发词。
### 27 条消歧规则的设计
消歧规则引擎采用**模式匹配 + 分数调整**的方式处理这类问题。每条规则包含:
- `trigger`:正则表达式,匹配时激活规则
- `boost`:被加分的目标技能
- `penalty`:被降分的竞争技能列表
- `weight`加分强度0.2-0.3
以"测试污染"消歧为例(`scripts/disambiguation-rules.json`R19-R22
```json
{
"id": "R19",
"note": "安全测试 → security-expert (tester 消歧)",
"trigger": "渗透测试|安全测试|漏洞测试|fuzz.*test|模糊测试",
"boost": "security-expert",
"penalty": ["tester-expert"],
"weight": 0.3
},
{
"id": "R21",
"note": "A/B测试 → data-analyst-expert (tester 消歧)",
"trigger": "a\\/b.*测试|ab.*测试|增长.*测试|实验设计.*转化|对照.*实验",
"boost": "data-analyst-expert",
"penalty": ["tester-expert"],
"weight": 0.25
}
```
规则应用的核心逻辑(`route-analyzer.js` 第 376-407 行):
```javascript
function applyDisambiguation(results, queryText, index) {
if (results.length < 2) return results;
const queryLower = queryText.toLowerCase();
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; // boosted skill 无匹配则跳过
// 修复: 基于原始分数计算 boost取最大值而非累积相乘防止多规则叠加虚高
if (!boosted._baseScore) boosted._baseScore = boosted.score;
const candidateScore = boosted._baseScore * (1 + rule.weight);
boosted.score = Math.max(boosted.score, candidateScore);
boosted.disambiguated = true;
// 排名强制: 被惩罚技能的分数不得超过 boosted skill
for (const r of results) {
if (rule.penalty.includes(r.name) && r.score > boosted.score) {
r.score = boosted.score * 0.95; // 压到 boosted 之下
r.penalizedBy = rule.boost;
}
}
}
results.sort((a, b) => b.score - a.score);
return results;
}
```
### 规则外部化的设计决策
v5.5 架构评审提出将消歧规则从 JavaScript 代码中抽出,存放到独立的 `disambiguation-rules.json` 文件中P4 修复项)。
这个决策的核心权衡:
| 维度 | 硬编码在 JS | 外部化 JSON |
|------|------------|-------------|
| 修改规则 | 需要改代码 + 测试 | 直接编辑 JSON |
| 热更新 | 不支持 | 支持(重启前加载) |
| 类型安全 | 编译时检查 | 运行时校验 |
| 可读性 | 混杂在业务逻辑中 | 清晰的数据结构 |
| 规则数量 | v5.4 是 18 条硬编码 | v5.5 外部化后扩展到 22 条v5.6 增至 27 条 |
外部化后,规则的迭代速度明显加快——从 v5.5 到 v5.6,仅用一次 PR 就新增了 5 条规则R23-R27
### 19 组同义词展开
消歧规则解决的是"同词不同义"问题,同义词扩展解决的是"不同词同义"问题。
`scripts/synonyms.json` 维护了 19 组同义词,覆盖中英文混合输入场景(`synonym-expander.js` 加载此文件):
```json
{
"id": "performance",
"words": ["性能", "performance", "优化", "optimization", "调优", "tuning", "加速"]
},
{
"id": "state-management",
"words": ["状态管理", "state management", "pinia", "vuex", "redux", "zustand", "recoil", "jotai"]
},
{
"id": "llm",
"words": ["大语言模型", "llm", "large language model", "rag", "向量数据库", "embedding", "fine-tuning", "微调"]
}
```
同义词扩展在 tokenize 阶段完成,扩展后的 token 集合被送入 BM25 评分(`route-analyzer.js` 第 96-101 行):
```javascript
// v4.9: 同义词展开
try {
const { expandSynonyms } = require('./synonym-expander.js');
return expandSynonyms(tokens);
} catch {
return tokens;
}
```
`state-management` 同义词组为例:用户输入 "pinia 状态"tokenize 后得到 `["pinia", "状态"]`。经过同义词扩展,集合变为 `["pinia", "状态", "状态管理", "state management", "vuex", "redux", "zustand", ...]`,从而精准命中 `frontend-expert` 中的状态管理相关关键词。
---
## Part 5自适应学习闭环
### 问题:路由准确率如何保持 100%
455 条反馈数据0 误路由100% 准确率——这不是玄学,而是一套完整的学习闭环。
### 显式纠正 + 隐式反馈
学习信号来源有两类:
**显式纠正**:用户手动指出路由错误,通过命令行工具记录:
```bash
node scripts/route-feedback.js --correct "React 组件加载慢" performance-expert
```
这会在 `debug/route-feedback.jsonl` 中追加一条记录,同时从路由日志中回查原始路由目标:
```json
{
"ts": "2026-02-27T10:23:15Z",
"query": "React 组件加载慢",
"routedTo": "frontend-expert",
"correctedTo": "performance-expert",
"topConfidence": 0.72,
"queryTokens": ["react", "组件", "加载", "慢", "performance", "optimization"]
}
```
**隐式反馈**`scripts/implicit-feedback.js` 在路由后 5 分钟内监测实际技能调用。如果用户路由到 A 技能后,随即又切换到 B 技能,则推断路由可能有误,以 0.5 倍权重记录为弱信号(`type: "implicit"`)。
### 权重学习:指数衰减 5 天半衰期
学习核心在 `learnWeights()` 函数(`route-feedback.js` 第 320-427 行)。对每条纠正记录:
$$
\text{decay} = 0.5^{\frac{\text{age}}{5 \times 86400 \text{ s}}}
$$
$$
\delta = 0.1 \times \text{decay} \times \text{implicitFactor}
$$
其中 `implicitFactor` 对隐式反馈为 0.5,手动纠正为 1.0。
```javascript
const DECAY_HALF_LIFE = 5 * 86400000; // 5 天半衰期
for (const fb of feedback) {
if (fb.routedTo === fb.correctedTo) continue; // 非纠正
const age = now - new Date(fb.ts).getTime();
const decay = Math.pow(0.5, age / DECAY_HALF_LIFE);
const implicitFactor = fb.type === 'implicit' ? 0.5 : 1.0;
const delta = 0.1 * decay * implicitFactor;
// 降低被错误路由到的技能中匹配的关键词权重
const wrongKw = skillKeywords[fb.routedTo];
for (const token of queryTokens) {
if (wrongKw.has(token)) {
deltas[fb.routedTo][token] = (deltas[fb.routedTo][token] || 0) - delta;
}
}
// 提升正确技能中匹配的关键词权重
const rightKw = skillKeywords[fb.correctedTo];
for (const token of queryTokens) {
if (rightKw.has(token)) {
deltas[fb.correctedTo][token] = (deltas[fb.correctedTo][token] || 0) + delta;
}
}
}
```
### 安全约束:防止学习系统暴走
学习系统有四重安全约束:
**1. 权重限幅 [-0.5, +0.5]**:单个关键词的权重调整不超过 0.5,防止单一反馈主导评分:
```javascript
deltas[skill][kw] = Math.max(-0.5, Math.min(0.5, Math.round(deltas[skill][kw] * 100) / 100));
```
**2. 技能名白名单校验**:只接受 `skills-index.json` 中存在的技能名,防止学习虚构技能:
```javascript
function loadSkillWhitelist() {
const index = loadIndex();
return new Set(index.skills.map(s => s.name));
}
// 记录纠正时校验
if (whitelist && !whitelist.has(correctSkill)) {
console.error(`Invalid skill name: "${correctSkill}"`);
process.exit(1);
}
```
**3. Holdout 验证集70/30 分割)**30% 的反馈数据作为 holdout 集不参与训练,用于评估学习效果是否真实有效:
```javascript
const HOLDOUT_RATIO = 0.3;
// 使用确定性哈希进行分割,保证可复现
function simpleHash(str) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0;
}
return Math.abs(hash);
}
const bucket = simpleHash(fb.query) % 100;
// bucket < 30 → holdout否则 → 训练集
```
**4. 权重快照**:每次学习前自动备份当前权重文件到 `debug/weights-history/`,保留最近 20 个快照,支持回滚:
```javascript
function snapshotWeights() {
const ts = new Date().toISOString().replace(/[:.]/g, '-');
const dest = path.join(WEIGHTS_HISTORY_DIR, `route-weights-${ts}.json`);
fs.copyFileSync(WEIGHTS_FILE, dest);
// 清理旧快照,保留最新 MAX_WEIGHT_SNAPSHOTS 个
}
```
---
## Part 6工程经验与踩坑
构建这套系统的过程中踩了不少坑,这些踩坑经历本身也是价值所在。
### 坑 1IDF 双重应用P0 级 Bug
**现象**:高区分度关键词(如 `playwright`)得分异常高,测试时发现 `browser-automation-expert` 对许多无关查询也排到 top1。
**根因**`tfidf-engine.js` 在编译期已将 IDF 乘入 `tfidfWeight`,但 `route-analyzer.js` 在运行时 BM25 评分时又再次乘以 IDF导致高区分度词的权重被平方放大
$$
\text{错误分数} = (weight \times IDF) \times IDF = weight \times IDF^2
$$
**修复**:运行时检测 `kwEntry.tfidfWeight` 是否存在,存在则 kwIDF=1 跳过(`route-analyzer.js` 第 223-224 行)。
**教训**:编译期和运行期对同一字段的处理逻辑必须明确分工,建议在字段命名上区分(`tfidfWeight` vs `rawWeight`)。
### 坑 2testing 同义词污染
**现象**"A/B 测试" 被路由到 `tester-expert`"渗透测试" 也被路由到 `tester-expert`,完全错误。
**根因**:最初的同义词词典中有一个泛化的 `testing` 组,包含 `["单元测试", "a/b测试", "渗透测试", "可用性测试", ...]`。这导致所有含"测试"的查询,在同义词展开后都命中了 `tester-expert` 的核心关键词。
**修复**:拆分为两个精细化同义词组:
- `testing-unit`仅含单元测试、集成测试、E2E 测试等正统测试类型
- `testing-meta`A/B 测试、渗透测试、可用性测试等"测试类比"场景
同时在消歧规则中补充 R19-R22 四条规则处理剩余边界情况。
### 坑 3融合公式的零点问题
**现象**:用户在会话中重复使用同一技能,再输入模糊查询(如"帮我继续"),系统仍然回退到默认技能,上下文无效。
**根因**:乘法融合公式 `bm25Score * (1 + ctxScore * 0.2) * ...``bm25Score = 0` 时整个结果为 0上下文信号完全丢失。
**修复**:改为线性加权,引入 `CTX_BASE = 5.0` 将上下文分数归一化到与 BM25 同量级,并允许在 BM25 为 0 时仍输出正值:
```javascript
// 修复后route-analyzer.js 第 267-271 行)
return bm25Score * 0.6
+ contextScore * CTX_BASE * 0.2
+ projectBoost * CTX_BASE * 0.1
+ workflowScore * CTX_BASE * 0.1;
```
### 坑 4管道命令的退出码覆盖问题
这是 v5.6 新增的一个系统级踩坑,与路由引擎无关,但值得记录。
**现象**:在 build-outcome-tracker 钩子中,通过检测命令退出码判断构建是否成功。但 `vitest run | tail -5` 这类管道命令,退出码始终是 `tail` 的退出码0即使测试失败也显示为成功。
**修复**:改为解析命令输出内容,识别 12 种主流测试框架的汇总行格式(如 `3 failed, 9 passed`、`FAILED: 3 tests`),从文本中直接提取成功/失败状态,完全绕过退出码。
---
## Part 7性能与效果
### 1,371 个测试用例全绿
整套系统有 46 个测试文件,覆盖路由引擎、消歧规则、同义词展开、学习闭环、健康评分等全部核心模块。测试框架使用 Vitest通过 `pnpm test` 一键运行。
关键测试场景:
- 56 个路由准确率用例(覆盖所有消歧边界)
- 27 条消歧规则各自的 trigger 验证
- 同义词展开的覆盖率测试
- 学习权重的限幅边界测试
- Holdout 验证集的独立性验证
### 路由评分性能
在 50 技能 × 2,393 关键词的规模下,单次路由评分(含 BM25、上下文融合、消歧规则的端到端延迟
| 阶段 | 耗时 |
|------|------|
| 索引加载(首次) | ~15msNode.js JSON.parse |
| tokenize + 同义词展开 | < 1ms |
| BM25 评分50 技能 | ~2ms |
| 上下文融合 | < 0.5ms |
| 消歧规则27 | < 0.5ms |
| **总计(冷启动)** | **~20ms** |
| **总计(热缓存)** | **< 5ms** |
对于运行在 Claude Code 钩子hook中的路由系统5-20ms 的延迟完全可接受不会影响交互体验
### 10 维健康评分体系
路由引擎的健康状态通过 `scripts/health-check.js` 持续监测评分权重设计如下
| 维度 | 权重 | 当前值 |
|------|------|--------|
| 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** |
H8学习收敛之所以是 90 而非 100是因为系统检测到学习权重已完全收敛所有 delta 接近 0理论上是正常状态但健康检查将"零变化"也视为可能的数据缺失信号stale 检测)。
---
## 总结与展望
这套基于 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 验证)
```
核心设计理念**可解释可调试可学习**。
- 每个路由决策都有完整的关键词命中记录方便定位问题
- 所有参数k1bCTX_BASEDECAY_FACTOR都有明确的工程依据
- 学习系统有多重安全约束防止单一反馈破坏整体准确率
### 下一步计划
**向量混合检索**对于长文本输入>50 字),纯关键词匹配的召回率有限。计划引入轻量 embedding如 sentence-transformers 或调用 Claude embeddings API与 BM25 做 RRFReciprocal Rank Fusion混合排序。
**实时规则学习**:目前消歧规则完全由人工维护。计划基于路由反馈数据,自动识别高频误路由 pattern辅助生成新的消歧规则候选人工确认后写入 `disambiguation-rules.json`
**多语言分词增强**当前中文分词使用滑动窗口2-4 字符片段),在一些歧义场景下准确率不足。计划引入 jieba 或 pkuseg 的 WASM 版本,作为滑动窗口的增强补充。
---
> 本文所有代码片段来自 Bookworm Smart Assistant v5.6 真实生产源码。
> 核心文件:`scripts/route-analyzer.js`589行、`scripts/tfidf-engine.js`124行、`scripts/disambiguation-rules.json`27条规则
> 如有技术问题,欢迎在评论区讨论。