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

CachingEmbedder 缓存

每次快照重建,都要给整个工具目录重新算一遍嵌入——可工具大多压根没变,重算就是白白花钱、白等延迟。 CachingEmbedder 是一个装饰器:它包在任意 Embedder 外面,按文本内容的哈希记住算过的向量, 只把缓存未命中的文本转发给内层 embedder。见过的文本,直接复用旧向量。

🔌 生活类比
背单词卡片:见过的词翻面直接念出答案,只有没见过的生词才去翻词典。 词典(内层 embedder)很贵又慢,卡片堆(缓存)又快又免费——所以能不翻就不翻。

怎么记:用内容哈希当 key

🔬 细节 / 代码对应

缓存的 key 是文本内容的 64-bit 哈希,用 FNV-1a 算法(hash_text:初值 0xcbf29ce484222325,每个字节异或后乘 0x100000001b3)。命中就直接复用缓存里的向量, 未命中才会真正去嵌入。embed 的实现分三段:先收集 miss、只嵌 miss、再按原序重组。

crates/retrieval/src/caching.rs · CachingEmbedder::embed
fn hash_text(text: &str) -> u64 {       // FNV-1a
    let mut h: u64 = 0xcbf29ce484222325;
    for b in text.as_bytes() { h ^= *b as u64; h = h.wrapping_mul(0x100000001b3); }
    h
}

async fn embed(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, EmbedError> {
    let hashes: Vec<u64> = texts.iter().map(|t| hash_text(t)).collect();

    // ① 持锁:收集去重的 cache-miss 文本,保留首次出现顺序
    let mut miss_texts = Vec::new();
    { let cache = self.cache.lock().unwrap();
      for (h, t) in hashes.iter().zip(texts) {
          if !cache.contains_key(h) && miss_seen.insert(*h) { miss_texts.push(t.clone()); } } }

    // ② 不持锁:全命中就跳过;否则只嵌 miss,再持锁写回
    if !miss_texts.is_empty() {
        let embedded = self.inner.embed(&miss_texts).await?;   // ← 此处不持锁
        let mut cache = self.cache.lock().unwrap();
        for (t, v) in miss_texts.iter().zip(embedded) { cache.insert(hash_text(t), Arc::from(/* v */)); }
    }

    // ③ 持锁:按原始输入顺序重组输出
    let cache = self.cache.lock().unwrap();
    Ok(hashes.iter().map(|h| cache.get(h).expect("just inserted/hit").to_vec()).collect())
}

关键纪律:绝不跨 .await 持锁

⚠️ 锁纪律

缓存用的是 std::sync::Mutex(同步锁),而 embedasync 的。 实现把工作切成三个互不相交的临界区绝不把锁跨 .await 持有

1

持锁 · 读 miss

锁住缓存,挑出去重的未命中文本,出了 {} 作用域立刻解锁

2

不持锁 · 只嵌 miss

inner.embed(&miss_texts).await 在这里发生——此时手里没有锁,再持锁把结果写回。

3

持锁 · 重组

重新锁住缓存,按原始输入顺序拼出每个文本对应的向量。

为什么重要?std::sync::Mutex 的守卫不是 Send,跨 .await 持有它 会让 future 无法在多线程执行器间迁移,更糟的是持锁等待网络会把别的任务全堵死。把唯一的 .await 夹在两次解锁之间,正好避开这一切。

为什么那个 .expect 永不 panic

🔬 细节 / insert-only 不变量

第三段重组里的 .expect("just inserted/hit") 永远不会触发 panic,靠的是一条 insert-only(只插入、从不淘汰)不变量:到这一步,每个 key 要么本来就命中、要么刚刚在第二段被插入, 而缓存从不删除、从不淘汰,所以此刻每个 key 必然存在。 源码里有注释明确写道:将来若加 LRU/TTL 淘汰策略,就必须在这一步重新处理 miss,否则这个 .expect 就会变成真正的 bug。

缓存跨多次重建存活

🔬 细节 / 跨 rebuild 持久

在装配层,CachingEmbedder 只被构造一次(细节见第 08 课),于是同一个缓存会跨多次 rebuild_snapshot 存活——没变的工具不会被重复嵌入。 有门控测试用 MockEmbedder.calls 断言这一点:第二次重建时内层调用次数不再增长,证明命中全部走了缓存。

✅ 关键要点
💡 设计亮点
装饰器模式把「省钱省延迟」这件事正交地叠加在任意 Embedder 之上: 内层是 OpenAI 还是本地服务、是 Mock 还是别的实现,统统无所谓;上层的 VectorStrategy完全无感—— 它拿到的还是一个普通 Embedder,缓存这层对它彻底透明。