750 lines
31 KiB
Markdown
750 lines
31 KiB
Markdown
|
|
---
|
|||
|
|
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 1:BM25 算法原理与适配
|
|||
|
|
|
|||
|
|
### BM25 经典公式
|
|||
|
|
|
|||
|
|
BM25(Best 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 2:TF-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:工程经验与踩坑
|
|||
|
|
|
|||
|
|
构建这套系统的过程中踩了不少坑,这些踩坑经历本身也是价值所在。
|
|||
|
|
|
|||
|
|
### 坑 1:IDF 双重应用(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`)。
|
|||
|
|
|
|||
|
|
### 坑 2:testing 同义词污染
|
|||
|
|
|
|||
|
|
**现象**:"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、上下文融合、消歧规则)的端到端延迟:
|
|||
|
|
|
|||
|
|
| 阶段 | 耗时 |
|
|||
|
|
|------|------|
|
|||
|
|
| 索引加载(首次) | ~15ms(Node.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 验证)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
核心设计理念:**可解释、可调试、可学习**。
|
|||
|
|
|
|||
|
|
- 每个路由决策都有完整的关键词命中记录,方便定位问题
|
|||
|
|
- 所有参数(k1、b、CTX_BASE、DECAY_FACTOR)都有明确的工程依据
|
|||
|
|
- 学习系统有多重安全约束,防止单一反馈破坏整体准确率
|
|||
|
|
|
|||
|
|
### 下一步计划
|
|||
|
|
|
|||
|
|
**向量混合检索**:对于长文本输入(>50 字),纯关键词匹配的召回率有限。计划引入轻量 embedding(如 sentence-transformers 或调用 Claude embeddings API),与 BM25 做 RRF(Reciprocal 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条规则)。
|
|||
|
|
> 如有技术问题,欢迎在评论区讨论。
|