Cosine similarity of two vectors; 0 if either is empty, zero or they differ in length.
10pub fn cosine(a: &[f32], b: &[f32]) -> f32 { 11 if a.is_empty() || a.len() != b.len() { 12 return 0.0; 13 } 14 let (mut dot, mut na, mut nb) = (0.0f32, 0.0f32, 0.0f32); 15 for (x, y) in a.iter().zip(b) { 16 dot += x * y; 17 na += x * x; 18 nb += y * y; 19 } 20 if na == 0.0 || nb == 0.0 { 0.0 } else { dot / (na.sqrt() * nb.sqrt()) } 21}
Calling a memory to mind: search by meaning, then let a second opinion order the best few.
Whiskers has little to remember at first, and a small memory is all relevant, so up to
keep facts are simply returned. Past that, the message is embedded, the closest
candidates by cosine are taken, and the ranker (Jev, which sees the message and the
candidates together) orders them; the best keep are used. Either helper being down
degrades the search, never the conversation: no embedder means the most recent facts, no
ranker means the cosine order.
Below this cosine a fact is not even a candidate. Measured 2026-10-04 with embeddinggemma-300M and no prefixes: a fact that fits a question scores 0.58 to 0.67, unrelated ones 0.27 to 0.47.
41impl Recall { 42 pub fn new(embedder: Arc<dyn Embedder>, ranker: Arc<dyn Ranker>) -> Self { 43 Self { embedder, ranker, candidates: 8, keep: 4, min_cosine: 0.45 } 44 } 45 46 pub fn search(&self, memory: &SharedMemory, query: &str) -> Vec<Fact> { 47 let mut facts = memory.usable(); 48 debug!("recall: {} facts, query of {} chars, keep {}", facts.len(), query.len(), self.keep); 49 if facts.len() <= self.keep || query.trim().is_empty() { 50 debug!("recall: returning all {} facts without searching", facts.len()); 51 return facts; 52 } 53 let q = match self.embedder.embed(&[query.to_owned()]) { 54 Ok(mut v) if v.len() == 1 => v.remove(0), 55 Ok(v) => { 56 warn!("recall: embedder returned {} vectors for one text; falling back to the most recent facts", v.len()); 57 return most_recent(facts, self.keep); 58 } 59 Err(e) => { 60 warn!("recall: embedder failed ({}); falling back to the most recent facts", e.0); 61 return most_recent(facts, self.keep); 62 } 63 }; 64 let mut scored: Vec<(f32, Fact)> = facts 65 .drain(..) 66 .map(|f| (cosine(&q, &f.embedding), f)) 67 .filter(|(c, _)| *c >= self.min_cosine) 68 .collect(); 69 scored.sort_by(|a, b| b.0.total_cmp(&a.0)); 70 scored.truncate(self.candidates); 71 debug!("recall: {} candidates above cosine {}", scored.len(), self.min_cosine); 72 if scored.len() > self.keep { 73 let texts: Vec<String> = scored.iter().map(|(_, f)| f.text.clone()).collect(); 74 match self.ranker.rank(query, &texts) { 75 Ok(p) if p.len() == scored.len() => { 76 let mut paired: Vec<(f32, (f32, Fact))> = p.into_iter().zip(scored).collect(); 77 paired.sort_by(|a, b| b.0.total_cmp(&a.0)); 78 scored = paired.into_iter().map(|(_, sf)| sf).collect(); 79 trace!("recall: ranker ordered the candidates"); 80 } 81 Ok(p) => warn!("recall: ranker returned {} scores for {} candidates; keeping the cosine order", p.len(), scored.len()), 82 Err(e) => warn!("recall: ranker failed ({}); keeping the cosine order", e.0), 83 } 84 } 85 let kept: Vec<Fact> = scored.into_iter().take(self.keep).map(|(_, f)| f).collect(); 86 info!("recall: {} facts recalled", kept.len()); 87 kept 88 } 89} 90 91fn most_recent(mut facts: Vec<Fact>, n: usize) -> Vec<Fact> { 92 trace!("recall: taking the {n} most recent of {} facts", facts.len()); 93 facts.sort_by_key(|f| std::cmp::Reverse(f.learned_at_ms)); 94 facts.truncate(n); 95 facts 96}