要做向量检索,第一步是把文本变成向量。mcpgw 用一个与厂商无关的 Embedder trait 把「怎么变」这件事抽象掉,真实的 HTTP 实现放在独立的 embedder crate。 于是 retrieval 这个检索内核不引入任何 HTTP 依赖,可以在无网络下编译和测试。
trait 只有两个方法:embed 把一批文本各转成一个向量(顺序一一对应、 all-or-nothing——要么整批成功,要么报错),dim 返回期望维度用于体检。 错误类型 EmbedError 也是与 provider 无关的,所以检索内核完全不知道背后是谁、用不用 HTTP。
// provider 无关,于是 retrieval 不需要任何 HTTP 依赖 pub enum EmbedError { Provider(String), // 厂商/网络/解码等失败 Dimension { expected: usize, got: usize }, // 维度不符 } pub trait Embedder: Send + Sync { // 一批文本 → 各一个向量,顺序对应;要么全成功要么 Err async fn embed(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, EmbedError>; fn dim(&self) -> usize; // 期望维度,用于校验 }
OpenAiEmbedder(crates/embedder/src/lib.rs)对接任何 OpenAI 兼容的 /embeddings 端点(OpenAI 本体,或 Ollama / LM Studio / vLLM 等同形状的本地服务)。它的 embed 做这几件事:
async fn embed(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, EmbedError> { if texts.is_empty() { return Ok(Vec::new()); } // 空输入短路 let resp = self.client .post(&format!("{}/embeddings", self.base_url)) .bearer_auth(&self.api_key) // Bearer 密钥 .json(&json!({ "model": self.model, "input": texts })) .send().await?; if !resp.status().is_success() { let snippet: String = body.chars().take(500).collect(); // ≤500 字符,绝不含 Authorization return Err(EmbedError::Provider(format!("HTTP {code} …: {snippet}"))); } let mut data = parsed.data; data.sort_by_key(|d| d.index); // 按 index 还原输入顺序 // …校验 data.len()==texts.len()、index 连续、可选 dim 一致… }
MockEmbedder(在 retrieval 的 testkit feature 下)让测试不依赖网络: 它把每个 token 用 FNV 哈希分桶,在对应桶 +1,生成确定性的伪向量—— 共享 token 越多的文本,余弦相似度越高,所以语义检索行为可被稳定验证。它还暴露 calls(embed 被调用次数)和 seen(见过哪些文本),供缓存断言使用(第 06 课会用到); 另有 MockEmbedder::failing 专门让 embed 必报错,用来驱动降级测试。