1use 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#[derive(Debug, Clone)]
29pub struct SemanticCacheConfig {
30 pub threshold: f32,
33 pub max_entries: usize,
36 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 pub fn new() -> Self {
55 Self::default()
56 }
57
58 pub fn with_threshold(mut self, threshold: f32) -> Self {
60 self.threshold = threshold;
61 self
62 }
63
64 pub fn with_max_entries(mut self, max_entries: usize) -> Self {
66 self.max_entries = max_entries.max(1);
67 self
68 }
69
70 pub fn with_ttl(mut self, ttl: Option<Duration>) -> Self {
72 self.ttl = ttl;
73 self
74 }
75}
76
77#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub enum CacheHitKind {
90 Lexical,
92 Semantic,
94}
95
96#[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 pub fn new(config: SemanticCacheConfig) -> Self {
112 Self {
113 config,
114 entries: Mutex::new(CacheInner::default()),
115 }
116 }
117
118 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 pub fn is_empty(&self) -> bool {
129 self.len() == 0
130 }
131
132 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 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 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 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 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 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 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
220pub 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 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 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 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 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 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 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 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 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 #[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 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 let _ = retriever.retrieve("banana", 3).await.unwrap();
551 assert_eq!(inner.calls.load(Ordering::SeqCst), 2);
552
553 let _ = retriever.retrieve("apple", 5).await.unwrap();
555 assert_eq!(inner.calls.load(Ordering::SeqCst), 3);
556
557 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 #[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}