📘 mcpgw 图解教程 · 目录 第二部分 · 检索深入 · 向量检索 07 / 15
第二部分 · 检索深入 · 向量检索

VectorStrategy 余弦 + 降级

VectorStrategy 在云端嵌入之上做暴力余弦检索——工具目录很小,线性扫一遍就足够快, 不需要近似最近邻索引。而当嵌入服务不可用时,它会透明回落到内置的 BM25,对上层完全不露痕迹。

数据结构

🔬 细节 / 代码对应

VectorStrategy 持有四样东西:

每个工具被嵌入的文本是 tool_text = "{qualified_name}\n{description}"(限定名 + 换行 + 描述)。

index:先建兜底,再尝试嵌入

1

总是先建 BM25

self.bm25 = Bm25Strategy::new(); self.bm25.index(catalog).await; —— 无论嵌入成不成功,兜底索引先就位

2

收集 tool_text 并批量嵌入

对目录里每个工具算 tool_text,再一次性 embedder.embed(&texts).await

3

数量匹配 → 存归一化向量

Ok(vecs)vecs.len() == tools.len():把每个向量 normalize 后存入 vectorsdegraded = false

4

数量不符 → 降级

Ok(vecs) 但数量对不上:这违反 embedder 的 all-or-nothing / 顺序对应契约, 强行 zip 会让向量与工具错位。于是清空 vectorsdegraded = true, 宁可降级也不建错位索引。

5

Err → 降级

嵌入直接报错,同样清空向量、degraded = true,退回 BM25。

search:余弦打分,单次失败也兜底

degraded 或 vectors 空?
是 → 直接 bm25.search
嵌入 query
单次失败 → 回落 BM25
余弦打分
dot(归一化 query, 归一化向量)
排序 + 截断
score 降序, qualified_name 升序 → top_k

degradedvectors 为空时,search 直接走 bm25.search(query, top_k);否则先嵌入查询(这一次嵌入失败也回落 BM25), 对每个工具算 dot(归一化 query, 归一化向量)余弦相似度, 按 score 降序、并列时按 qualified_name 升序稳定排序,最后截断到 top_k

零范数不会算出 NaN

🔬 细节 / 代码对应

normalize 做 L2 归一化时,对零向量原样返回(不做除法),所以绝不会除以 0, 余弦打分里也就永远不会冒出 NaN

crates/retrieval/src/vector.rs · normalize
// L2 归一化(原地);零向量原样返回——它和任何向量的余弦都是 0
fn normalize(mut v: Vec<f32>) -> Vec<f32> {
    let norm = v.iter().map(|x| x * x).sum::<f32>().sqrt();
    if norm > 0.0 {              // ← 仅当范数 > 0 才除,零向量跳过
        for x in &mut v { *x /= norm; }
    }
    v
}
⚠️ 双重降级的两条路径

「降级到 BM25」会在两个时机各自独立发生,别混为一谈:

✅ 关键要点
💡 设计亮点
VectorStrategy 把「语义检索」与「字面兜底」缝进同一个策略里, 对上层而言它就只是一个普通的 RetrievalStrategy——调用方既不用判断嵌入服务是否健在,也不用自己拼装兜底逻辑。