VectorStrategy 在云端嵌入之上做暴力余弦检索——工具目录很小,线性扫一遍就足够快, 不需要近似最近邻索引。而当嵌入服务不可用时,它会透明回落到内置的 BM25,对上层完全不露痕迹。
VectorStrategy 持有四样东西:
每个工具被嵌入的文本是 tool_text = "{qualified_name}\n{description}"(限定名 + 换行 + 描述)。
self.bm25 = Bm25Strategy::new(); self.bm25.index(catalog).await; —— 无论嵌入成不成功,兜底索引先就位。
对目录里每个工具算 tool_text,再一次性 embedder.embed(&texts).await。
Ok(vecs) 且 vecs.len() == tools.len():把每个向量 normalize 后存入 vectors,degraded = false。
Ok(vecs) 但数量对不上:这违反 embedder 的 all-or-nothing / 顺序对应契约, 强行 zip 会让向量与工具错位。于是清空 vectors、degraded = true, 宁可降级也不建错位索引。
嵌入直接报错,同样清空向量、degraded = true,退回 BM25。
当 degraded 或 vectors 为空时,search 直接走 bm25.search(query, top_k);否则先嵌入查询(这一次嵌入失败也回落 BM25), 对每个工具算 dot(归一化 query, 归一化向量) 即余弦相似度, 按 score 降序、并列时按 qualified_name 升序做稳定排序,最后截断到 top_k。
normalize 做 L2 归一化时,对零向量原样返回(不做除法),所以绝不会除以 0, 余弦打分里也就永远不会冒出 NaN。
// 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」会在两个时机各自独立发生,别混为一谈: