Skip to main content

lc_rag/
semantic_cache.rs

1// lc-rag/src/semantic_cache.rs
2//! Semantic cache for retrieval results (0.21.0 S4.2).
3//!
4//! A semantic cache answers repeated (or paraphrased) queries from cached
5//! retrieval results instead of re-running embedding + retrieval:
6//! - **lexical hit**: the query text is byte-identical to a cached query —
7//!   always preferred (product codes, IDs: lexical is more reliable);
8//! - **semantic hit**: the query embedding's cosine similarity against a
9//!   cached query's embedding reaches the threshold.
10//!
11//! The cache is a [`RetrieverTrait`] decorator ([`CachedRetriever`]); it is
12//! off-by-default (wrap your retriever explicitly) and eviction/TTL keep the
13//! footprint bounded. `k` is part of the cached result set: a hit requires the
14//! same `k` (a different `k` recomputes rather than slicing padded results).
15//!
16//! The inner bookkeeping ([`SemanticCacheCore`]) is a pure, embedder-free
17//! unit so the threshold/FIFO/TTL/exact-priority semantics are testable with
18//! synthetic vectors.
19
20use crate::retriever::{RetrieverError, RetrieverTrait};
21use lc_embeddings::Embeddings;
22use lc_vector_stores::{Document, SearchResult};
23use std::collections::VecDeque;
24use std::sync::{Arc, Mutex};
25use std::time::{Duration, Instant};
26
27/// Semantic cache configuration.
28#[derive(Debug, Clone)]
29pub struct SemanticCacheConfig {
30    /// Minimum cosine similarity for a semantic hit. Conservative default:
31    /// a too-low threshold serves semantically-nearbut-different queries.
32    pub threshold: f32,
33    /// Max entries; the oldest are evicted FIFO (same policy as lc-agents'
34    /// `MemoryCache`).
35    pub max_entries: usize,
36    /// Optional TTL: entries older than this are treated as misses (corpus
37    /// updates invalidate results; pair with `invalidate()` for explicit
38    /// invalidation).
39    pub ttl: Option<Duration>,
40}
41
42impl Default for SemanticCacheConfig {
43    fn default() -> Self {
44        Self {
45            threshold: 0.95,
46            max_entries: 256,
47            ttl: None,
48        }
49    }
50}
51
52impl SemanticCacheConfig {
53    /// Creates a config with defaults.
54    pub fn new() -> Self {
55        Self::default()
56    }
57
58    /// Sets the semantic-hit threshold.
59    pub fn with_threshold(mut self, threshold: f32) -> Self {
60        self.threshold = threshold;
61        self
62    }
63
64    /// Sets the max entry count (FIFO eviction).
65    pub fn with_max_entries(mut self, max_entries: usize) -> Self {
66        self.max_entries = max_entries.max(1);
67        self
68    }
69
70    /// Sets an optional TTL.
71    pub fn with_ttl(mut self, ttl: Option<Duration>) -> Self {
72        self.ttl = ttl;
73        self
74    }
75}
76
77/// One cache entry: query text + its embedding + the result set for `k`.
78#[derive(Debug, Clone)]
79struct CacheEntry {
80    query: String,
81    query_vector: Vec<f32>,
82    k: usize,
83    results: Vec<SearchResult>,
84    inserted_at: Instant,
85}
86
87/// Hit kind returned by [`SemanticCacheCore::lookup`].
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub enum CacheHitKind {
90    /// Byte-identical query text.
91    Lexical,
92    /// Similarity reached the threshold.
93    Semantic,
94}
95
96/// Pure cache bookkeeping (no embedder, no retriever — fully unit-testable).
97#[derive(Debug)]
98pub struct SemanticCacheCore {
99    config: SemanticCacheConfig,
100    entries: Mutex<CacheInner>,
101}
102
103#[derive(Debug, Default)]
104struct CacheInner {
105    map: Vec<CacheEntry>,
106    order: VecDeque<String>,
107}
108
109impl SemanticCacheCore {
110    /// Creates a core with the given config.
111    pub fn new(config: SemanticCacheConfig) -> Self {
112        Self {
113            config,
114            entries: Mutex::new(CacheInner::default()),
115        }
116    }
117
118    /// Number of live entries.
119    pub fn len(&self) -> usize {
120        self.entries
121            .lock()
122            .unwrap_or_else(|e| e.into_inner())
123            .map
124            .len()
125    }
126
127    /// Whether the cache is empty.
128    pub fn is_empty(&self) -> bool {
129        self.len() == 0
130    }
131
132    /// Looks up `query` (optionally with its embedding for semantic matching)
133    /// for result sets of size `k`. Returns the cached results and the hit kind.
134    ///
135    /// `query_vector` should be the freshly embedded query; pass `None` to
136    /// restrict to lexical hits only.
137    pub fn lookup(
138        &self,
139        query: &str,
140        query_vector: Option<&[f32]>,
141        k: usize,
142        now: Instant,
143    ) -> Option<(Vec<SearchResult>, CacheHitKind)> {
144        let inner = self.entries.lock().unwrap_or_else(|e| e.into_inner());
145
146        // 1. Lexical exact match wins.
147        for entry in &inner.map {
148            if entry.query == query && entry.k == k && !self.is_expired(&entry.inserted_at, now) {
149                return Some((entry.results.clone(), CacheHitKind::Lexical));
150            }
151        }
152
153        // 2. Semantic match above threshold.
154        let query_vector = query_vector?;
155        for entry in &inner.map {
156            if entry.k != k || self.is_expired(&entry.inserted_at, now) {
157                continue;
158            }
159            if lc_embeddings::cosine_similarity(query_vector, &entry.query_vector).unwrap_or(0.0)
160                >= self.config.threshold
161            {
162                return Some((entry.results.clone(), CacheHitKind::Semantic));
163            }
164        }
165        None
166    }
167
168    /// Inserts a result set for `(query, k)`.
169    pub fn insert(
170        &self,
171        query: &str,
172        query_vector: Vec<f32>,
173        k: usize,
174        results: Vec<SearchResult>,
175        now: Instant,
176    ) {
177        let mut inner = self.entries.lock().unwrap_or_else(|e| e.into_inner());
178        // Replace existing entry for the same (query, k).
179        if let Some(pos) = inner.map.iter().position(|e| e.query == query && e.k == k) {
180            inner.map.remove(pos);
181            if let Some(p) = inner.order.iter().position(|q| q == query) {
182                inner.order.remove(p);
183            }
184        }
185        // FIFO eviction.
186        while inner.map.len() >= self.config.max_entries {
187            if let Some(oldest) = inner.order.pop_front() {
188                if let Some(pos) = inner.map.iter().position(|e| e.query == oldest) {
189                    inner.map.remove(pos);
190                }
191            } else {
192                break;
193            }
194        }
195        inner.order.push_back(query.to_string());
196        inner.map.push(CacheEntry {
197            query: query.to_string(),
198            query_vector,
199            k,
200            results,
201            inserted_at: now,
202        });
203    }
204
205    /// Clears all entries (corpus update invalidation).
206    pub fn invalidate(&self) {
207        let mut inner = self.entries.lock().unwrap_or_else(|e| e.into_inner());
208        inner.map.clear();
209        inner.order.clear();
210    }
211
212    fn is_expired(&self, inserted_at: &Instant, now: Instant) -> bool {
213        match self.config.ttl {
214            Some(ttl) => now.duration_since(*inserted_at) > ttl,
215            None => false,
216        }
217    }
218}
219
220/// A [`RetrieverTrait`] decorator backed by a [`SemanticCacheCore`].
221///
222/// Query flow: embed once → lexical hit → semantic hit → miss (call inner,
223/// insert). On a hit no retrieval call (and only the embedding call) happens;
224/// the embedding itself could be skipped only for lexical hits — which skip
225/// embedding too. Failures of the inner retriever propagate (never cached).
226pub struct CachedRetriever {
227    inner: Arc<dyn RetrieverTrait>,
228    embeddings: Arc<dyn Embeddings>,
229    cache: Arc<SemanticCacheCore>,
230}
231
232impl std::fmt::Debug for CachedRetriever {
233    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
234        f.debug_struct("CachedRetriever")
235            .field("entries", &self.cache.len())
236            .finish()
237    }
238}
239
240impl CachedRetriever {
241    /// Wraps `inner` with a semantic cache using `config`.
242    pub fn new(
243        inner: Arc<dyn RetrieverTrait>,
244        embeddings: Arc<dyn Embeddings>,
245        config: SemanticCacheConfig,
246    ) -> Self {
247        Self {
248            inner,
249            embeddings,
250            cache: Arc::new(SemanticCacheCore::new(config)),
251        }
252    }
253
254    /// The underlying cache core (for inspection / invalidation).
255    pub fn cache(&self) -> &Arc<SemanticCacheCore> {
256        &self.cache
257    }
258
259    async fn lookup_or_retrieve(
260        &self,
261        query: &str,
262        k: usize,
263    ) -> Result<(Vec<SearchResult>, Option<CacheHitKind>), RetrieverError> {
264        let now = Instant::now();
265        // Lexical hits need no embedding at all.
266        if let Some((results, kind)) = self.cache.lookup(query, None, k, now) {
267            return Ok((results, Some(kind)));
268        }
269        let qvec = self
270            .embeddings
271            .embed_query(query)
272            .await
273            .map_err(|e| RetrieverError::EmbeddingError(e.to_string()))?;
274        if let Some((results, kind)) = self.cache.lookup(query, Some(&qvec), k, now) {
275            return Ok((results, Some(kind)));
276        }
277        let results = self.inner.retrieve_with_scores(query, k).await?;
278        self.cache
279            .insert(query, qvec, k, results.clone(), Instant::now());
280        Ok((results, None))
281    }
282}
283
284#[async_trait::async_trait]
285impl RetrieverTrait for CachedRetriever {
286    async fn retrieve(&self, query: &str, k: usize) -> Result<Vec<Document>, RetrieverError> {
287        let (results, _) = self.lookup_or_retrieve(query, k).await?;
288        Ok(results.into_iter().map(|r| r.document).collect())
289    }
290
291    async fn retrieve_with_scores(
292        &self,
293        query: &str,
294        k: usize,
295    ) -> Result<Vec<SearchResult>, RetrieverError> {
296        let (results, _) = self.lookup_or_retrieve(query, k).await?;
297        Ok(results)
298    }
299
300    async fn add_documents(&self, documents: Vec<Document>) -> Result<(), RetrieverError> {
301        // Corpus changed: previously cached results are potentially stale.
302        self.cache.invalidate();
303        self.inner.add_documents(documents).await
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310    use async_trait::async_trait;
311    use std::sync::atomic::{AtomicUsize, Ordering};
312
313    fn result(content: &str, score: f32) -> SearchResult {
314        SearchResult {
315            document: Document::new(content),
316            score,
317        }
318    }
319
320    /// Inner retriever counting calls and returning fixed results.
321    struct CountingRetriever {
322        calls: AtomicUsize,
323        results: Vec<SearchResult>,
324    }
325
326    impl CountingRetriever {
327        fn new(results: Vec<SearchResult>) -> Self {
328            Self {
329                calls: AtomicUsize::new(0),
330                results,
331            }
332        }
333    }
334
335    #[async_trait]
336    impl RetrieverTrait for CountingRetriever {
337        async fn retrieve(&self, _query: &str, _k: usize) -> Result<Vec<Document>, RetrieverError> {
338            self.calls.fetch_add(1, Ordering::SeqCst);
339            Ok(self.results.iter().map(|r| r.document.clone()).collect())
340        }
341        async fn retrieve_with_scores(
342            &self,
343            _query: &str,
344            _k: usize,
345        ) -> Result<Vec<SearchResult>, RetrieverError> {
346            self.calls.fetch_add(1, Ordering::SeqCst);
347            Ok(self.results.clone())
348        }
349        async fn add_documents(&self, _documents: Vec<Document>) -> Result<(), RetrieverError> {
350            Ok(())
351        }
352    }
353
354    /// Identity embedding: unit vector on the axis named by the text ("a"/"b").
355    struct AxisEmbeddings;
356
357    #[async_trait]
358    impl Embeddings for AxisEmbeddings {
359        async fn embed_query(&self, text: &str) -> Result<Vec<f32>, lc_embeddings::EmbeddingError> {
360            self.embed_documents(&[text])
361                .await
362                .map(|mut v| v.pop().unwrap_or_default())
363        }
364        async fn embed_documents(
365            &self,
366            texts: &[&str],
367        ) -> Result<Vec<Vec<f32>>, lc_embeddings::EmbeddingError> {
368            texts
369                .iter()
370                .map(|t| {
371                    if t.starts_with('a') {
372                        Ok(vec![1.0, 0.0])
373                    } else if t.starts_with('b') {
374                        Ok(vec![0.0, 1.0])
375                    } else {
376                        Err(lc_embeddings::EmbeddingError::EmptyInput)
377                    }
378                })
379                .collect()
380        }
381        fn dimension(&self) -> usize {
382            2
383        }
384        fn model_name(&self) -> &str {
385            "axis"
386        }
387    }
388
389    #[test]
390    fn cosine_similarity_basics() {
391        use lc_embeddings::cosine_similarity;
392        let a = vec![1.0, 0.0];
393        assert!((cosine_similarity(&a, &a).unwrap() - 1.0).abs() < 1e-6);
394        assert!(cosine_similarity(&a, &[0.0, 1.0]).unwrap().abs() < 1e-6);
395        assert!((cosine_similarity(&a, &[-1.0, 0.0]).unwrap() + 1.0).abs() < 1e-6);
396        assert!(
397            cosine_similarity(&a, &[]).is_err(),
398            "length mismatch errors, never NaN"
399        );
400    }
401
402    #[test]
403    fn lexical_hit_requires_same_k() {
404        let core = SemanticCacheCore::new(SemanticCacheConfig::new());
405        let now = Instant::now();
406        core.insert("q", vec![1.0, 0.0], 5, vec![result("doc", 0.9)], now);
407
408        assert!(
409            core.lookup("q", None, 5, now).is_some(),
410            "exact (q, k) hits"
411        );
412        assert!(
413            core.lookup("q", None, 10, now).is_none(),
414            "different k must not be served from the k=5 result set"
415        );
416    }
417
418    #[test]
419    fn lexical_beats_semantic() {
420        let core = SemanticCacheCore::new(SemanticCacheConfig::new());
421        let now = Instant::now();
422        core.insert(
423            "a-query",
424            vec![1.0, 0.0],
425            5,
426            vec![result("from-a", 1.0)],
427            now,
428        );
429        core.insert(
430            "a-query ",
431            vec![1.0, 0.0],
432            5,
433            vec![result("from-a2", 0.9)],
434            now,
435        );
436
437        let (results, kind) = core.lookup("a-query", Some(&[1.0, 0.0]), 5, now).unwrap();
438        assert_eq!(kind, CacheHitKind::Lexical, "byte-identical wins");
439        assert_eq!(results[0].document.content, "from-a");
440    }
441
442    #[test]
443    fn semantic_hit_respects_threshold() {
444        let config = SemanticCacheConfig::new().with_threshold(0.9);
445        let core = SemanticCacheCore::new(config);
446        let now = Instant::now();
447        core.insert(
448            "query a",
449            vec![1.0, 0.0],
450            5,
451            vec![result("cached", 0.8)],
452            now,
453        );
454
455        // Similarity 0.8 < 0.9 → miss.
456        let n: f32 = (0.8f32 * 0.8 + 0.6 * 0.6).sqrt();
457        let v = vec![0.8 / n, 0.6 / n];
458        assert!(core.lookup("query b", Some(&v), 5, now).is_none());
459
460        // Similarity 1.0 ≥ 0.9 → hit.
461        let (results, kind) = core.lookup("query c", Some(&[1.0, 0.0]), 5, now).unwrap();
462        assert_eq!(kind, CacheHitKind::Semantic);
463        assert_eq!(results[0].document.content, "cached");
464    }
465
466    #[test]
467    fn fifo_eviction_bounded() {
468        let config = SemanticCacheConfig::new().with_max_entries(2);
469        let core = SemanticCacheCore::new(config);
470        let now = Instant::now();
471        core.insert("q1", vec![1.0, 0.0], 5, vec![], now);
472        core.insert("q2", vec![1.0, 0.0], 5, vec![], now);
473        assert_eq!(core.len(), 2);
474        core.insert("q3", vec![1.0, 0.0], 5, vec![], now);
475        assert_eq!(core.len(), 2, "FIFO evicts the oldest");
476        assert!(core.lookup("q1", None, 5, now).is_none(), "q1 evicted");
477        assert!(core.lookup("q3", None, 5, now).is_some());
478    }
479
480    #[test]
481    fn ttl_expiry_is_a_miss() {
482        let config = SemanticCacheConfig::new().with_ttl(Some(Duration::from_millis(50)));
483        let core = SemanticCacheCore::new(config);
484        let now = Instant::now();
485        core.insert("q", vec![1.0, 0.0], 5, vec![result("doc", 1.0)], now);
486
487        assert!(core.lookup("q", None, 5, now).is_some());
488        let later = now + Duration::from_millis(51);
489        assert!(
490            core.lookup("q", None, 5, later).is_none(),
491            "expired entries are misses"
492        );
493    }
494
495    #[test]
496    fn insert_replaces_same_query_and_k() {
497        let core = SemanticCacheCore::new(SemanticCacheConfig::new());
498        let now = Instant::now();
499        core.insert("q", vec![1.0], 5, vec![result("old", 1.0)], now);
500        core.insert("q", vec![1.0], 5, vec![result("new", 1.0)], now);
501        assert_eq!(core.len(), 1, "replace, not duplicate");
502        let (results, _) = core.lookup("q", None, 5, now).unwrap();
503        assert_eq!(results[0].document.content, "new");
504    }
505
506    #[test]
507    fn invalidate_clears_all() {
508        let core = SemanticCacheCore::new(SemanticCacheConfig::new());
509        let now = Instant::now();
510        core.insert("q", vec![1.0], 5, vec![], now);
511        assert!(!core.is_empty());
512        core.invalidate();
513        assert!(core.is_empty());
514        assert!(core.lookup("q", None, 5, now).is_none());
515    }
516
517    /// Decorator: identical query → 1 retrieval call; paraphrase above
518    /// threshold → still cached (semantic hit); add_documents invalidates.
519    #[tokio::test]
520    async fn cached_retriever_skips_inner_on_hits() {
521        let inner = Arc::new(CountingRetriever::new(vec![result("doc a", 0.9)]));
522        let retriever = CachedRetriever::new(
523            inner.clone(),
524            Arc::new(AxisEmbeddings),
525            SemanticCacheConfig::new(),
526        );
527
528        let first = retriever.retrieve("apple", 3).await.unwrap();
529        assert_eq!(first.len(), 1);
530        assert_eq!(inner.calls.load(Ordering::SeqCst), 1, "miss → inner call");
531
532        let second = retriever.retrieve("apple", 3).await.unwrap();
533        assert_eq!(second[0].content, "doc a");
534        assert_eq!(
535            inner.calls.load(Ordering::SeqCst),
536            1,
537            "lexical hit → no call"
538        );
539
540        // "avocado" embeds to the same axis (starts with 'a') → semantic hit.
541        let third = retriever.retrieve("avocado", 3).await.unwrap();
542        assert_eq!(third[0].content, "doc a");
543        assert_eq!(
544            inner.calls.load(Ordering::SeqCst),
545            1,
546            "semantic hit → no call"
547        );
548
549        // Different axis → miss → second inner call.
550        let _ = retriever.retrieve("banana", 3).await.unwrap();
551        assert_eq!(inner.calls.load(Ordering::SeqCst), 2);
552
553        // k is part of the cache identity: k change → miss → third call.
554        let _ = retriever.retrieve("apple", 5).await.unwrap();
555        assert_eq!(inner.calls.load(Ordering::SeqCst), 3);
556
557        // add_documents invalidates: "apple" k=3 needs a fresh inner call.
558        retriever
559            .add_documents(vec![Document::new("new doc")])
560            .await
561            .unwrap();
562        assert!(
563            retriever.cache().is_empty(),
564            "corpus update invalidates cache"
565        );
566        let _ = retriever.retrieve("apple", 3).await.unwrap();
567        assert_eq!(inner.calls.load(Ordering::SeqCst), 4);
568    }
569
570    /// Unknown text (embeddings error) surfaces the error rather than caching.
571    #[tokio::test]
572    async fn embedding_error_propagates() {
573        let inner = Arc::new(CountingRetriever::new(vec![]));
574        let retriever =
575            CachedRetriever::new(inner, Arc::new(AxisEmbeddings), SemanticCacheConfig::new());
576        let err = retriever.retrieve("zebra", 3).await;
577        assert!(err.is_err(), "embedding failure must not be swallowed");
578    }
579}