每次快照重建,都要给整个工具目录重新算一遍嵌入——可工具大多压根没变,重算就是白白花钱、白等延迟。 CachingEmbedder 是一个装饰器:它包在任意 Embedder 外面,按文本内容的哈希记住算过的向量, 只把缓存未命中的文本转发给内层 embedder。见过的文本,直接复用旧向量。
缓存的 key 是文本内容的 64-bit 哈希,用 FNV-1a 算法(hash_text:初值 0xcbf29ce484222325,每个字节异或后乘 0x100000001b3)。命中就直接复用缓存里的向量, 未命中才会真正去嵌入。embed 的实现分三段:先收集 miss、只嵌 miss、再按原序重组。
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()) }
缓存用的是 std::sync::Mutex(同步锁),而 embed 是 async 的。 实现把工作切成三个互不相交的临界区,绝不把锁跨 .await 持有:
锁住缓存,挑出去重的未命中文本,出了 {} 作用域立刻解锁。
inner.embed(&miss_texts).await 在这里发生——此时手里没有锁,再持锁把结果写回。
重新锁住缓存,按原始输入顺序拼出每个文本对应的向量。
为什么重要?std::sync::Mutex 的守卫不是 Send,跨 .await 持有它 会让 future 无法在多线程执行器间迁移,更糟的是持锁等待网络会把别的任务全堵死。把唯一的 .await 夹在两次解锁之间,正好避开这一切。
第三段重组里的 .expect("just inserted/hit") 永远不会触发 panic,靠的是一条 insert-only(只插入、从不淘汰)不变量:到这一步,每个 key 要么本来就命中、要么刚刚在第二段被插入, 而缓存从不删除、从不淘汰,所以此刻每个 key 必然存在。 源码里有注释明确写道:将来若加 LRU/TTL 淘汰策略,就必须在这一步重新处理 miss,否则这个 .expect 就会变成真正的 bug。
在装配层,CachingEmbedder 只被构造一次(细节见第 08 课),于是同一个缓存会跨多次 rebuild_snapshot 存活——没变的工具不会被重复嵌入。 有门控测试用 MockEmbedder.calls 断言这一点:第二次重建时内层调用次数不再增长,证明命中全部走了缓存。