use crate::retriever::{RetrieverError, RetrieverTrait};
use lc_embeddings::Embeddings;
use lc_vector_stores::{Document, SearchResult};
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
#[derive(Debug, Clone)]
pub struct SemanticCacheConfig {
pub threshold: f32,
pub max_entries: usize,
pub ttl: Option<Duration>,
}
impl Default for SemanticCacheConfig {
fn default() -> Self {
Self {
threshold: 0.95,
max_entries: 256,
ttl: None,
}
}
}
impl SemanticCacheConfig {
pub fn new() -> Self {
Self::default()
}
pub fn with_threshold(mut self, threshold: f32) -> Self {
self.threshold = threshold;
self
}
pub fn with_max_entries(mut self, max_entries: usize) -> Self {
self.max_entries = max_entries.max(1);
self
}
pub fn with_ttl(mut self, ttl: Option<Duration>) -> Self {
self.ttl = ttl;
self
}
}
#[derive(Debug, Clone)]
struct CacheEntry {
query: String,
query_vector: Vec<f32>,
k: usize,
results: Vec<SearchResult>,
inserted_at: Instant,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CacheHitKind {
Lexical,
Semantic,
}
#[derive(Debug)]
pub struct SemanticCacheCore {
config: SemanticCacheConfig,
entries: Mutex<CacheInner>,
}
#[derive(Debug, Default)]
struct CacheInner {
map: Vec<CacheEntry>,
order: VecDeque<String>,
}
impl SemanticCacheCore {
pub fn new(config: SemanticCacheConfig) -> Self {
Self {
config,
entries: Mutex::new(CacheInner::default()),
}
}
pub fn len(&self) -> usize {
self.entries
.lock()
.unwrap_or_else(|e| e.into_inner())
.map
.len()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn lookup(
&self,
query: &str,
query_vector: Option<&[f32]>,
k: usize,
now: Instant,
) -> Option<(Vec<SearchResult>, CacheHitKind)> {
let inner = self.entries.lock().unwrap_or_else(|e| e.into_inner());
for entry in &inner.map {
if entry.query == query && entry.k == k && !self.is_expired(&entry.inserted_at, now) {
return Some((entry.results.clone(), CacheHitKind::Lexical));
}
}
let query_vector = query_vector?;
for entry in &inner.map {
if entry.k != k || self.is_expired(&entry.inserted_at, now) {
continue;
}
if lc_embeddings::cosine_similarity(query_vector, &entry.query_vector).unwrap_or(0.0)
>= self.config.threshold
{
return Some((entry.results.clone(), CacheHitKind::Semantic));
}
}
None
}
pub fn insert(
&self,
query: &str,
query_vector: Vec<f32>,
k: usize,
results: Vec<SearchResult>,
now: Instant,
) {
let mut inner = self.entries.lock().unwrap_or_else(|e| e.into_inner());
if let Some(pos) = inner.map.iter().position(|e| e.query == query && e.k == k) {
inner.map.remove(pos);
if let Some(p) = inner.order.iter().position(|q| q == query) {
inner.order.remove(p);
}
}
while inner.map.len() >= self.config.max_entries {
if let Some(oldest) = inner.order.pop_front() {
if let Some(pos) = inner.map.iter().position(|e| e.query == oldest) {
inner.map.remove(pos);
}
} else {
break;
}
}
inner.order.push_back(query.to_string());
inner.map.push(CacheEntry {
query: query.to_string(),
query_vector,
k,
results,
inserted_at: now,
});
}
pub fn invalidate(&self) {
let mut inner = self.entries.lock().unwrap_or_else(|e| e.into_inner());
inner.map.clear();
inner.order.clear();
}
fn is_expired(&self, inserted_at: &Instant, now: Instant) -> bool {
match self.config.ttl {
Some(ttl) => now.duration_since(*inserted_at) > ttl,
None => false,
}
}
}
pub struct CachedRetriever {
inner: Arc<dyn RetrieverTrait>,
embeddings: Arc<dyn Embeddings>,
cache: Arc<SemanticCacheCore>,
}
impl std::fmt::Debug for CachedRetriever {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CachedRetriever")
.field("entries", &self.cache.len())
.finish()
}
}
impl CachedRetriever {
pub fn new(
inner: Arc<dyn RetrieverTrait>,
embeddings: Arc<dyn Embeddings>,
config: SemanticCacheConfig,
) -> Self {
Self {
inner,
embeddings,
cache: Arc::new(SemanticCacheCore::new(config)),
}
}
pub fn cache(&self) -> &Arc<SemanticCacheCore> {
&self.cache
}
async fn lookup_or_retrieve(
&self,
query: &str,
k: usize,
) -> Result<(Vec<SearchResult>, Option<CacheHitKind>), RetrieverError> {
let now = Instant::now();
if let Some((results, kind)) = self.cache.lookup(query, None, k, now) {
return Ok((results, Some(kind)));
}
let qvec = self
.embeddings
.embed_query(query)
.await
.map_err(|e| RetrieverError::EmbeddingError(e.to_string()))?;
if let Some((results, kind)) = self.cache.lookup(query, Some(&qvec), k, now) {
return Ok((results, Some(kind)));
}
let results = self.inner.retrieve_with_scores(query, k).await?;
self.cache
.insert(query, qvec, k, results.clone(), Instant::now());
Ok((results, None))
}
}
#[async_trait::async_trait]
impl RetrieverTrait for CachedRetriever {
async fn retrieve(&self, query: &str, k: usize) -> Result<Vec<Document>, RetrieverError> {
let (results, _) = self.lookup_or_retrieve(query, k).await?;
Ok(results.into_iter().map(|r| r.document).collect())
}
async fn retrieve_with_scores(
&self,
query: &str,
k: usize,
) -> Result<Vec<SearchResult>, RetrieverError> {
let (results, _) = self.lookup_or_retrieve(query, k).await?;
Ok(results)
}
async fn add_documents(&self, documents: Vec<Document>) -> Result<(), RetrieverError> {
self.cache.invalidate();
self.inner.add_documents(documents).await
}
}
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use std::sync::atomic::{AtomicUsize, Ordering};
fn result(content: &str, score: f32) -> SearchResult {
SearchResult {
document: Document::new(content),
score,
}
}
struct CountingRetriever {
calls: AtomicUsize,
results: Vec<SearchResult>,
}
impl CountingRetriever {
fn new(results: Vec<SearchResult>) -> Self {
Self {
calls: AtomicUsize::new(0),
results,
}
}
}
#[async_trait]
impl RetrieverTrait for CountingRetriever {
async fn retrieve(&self, _query: &str, _k: usize) -> Result<Vec<Document>, RetrieverError> {
self.calls.fetch_add(1, Ordering::SeqCst);
Ok(self.results.iter().map(|r| r.document.clone()).collect())
}
async fn retrieve_with_scores(
&self,
_query: &str,
_k: usize,
) -> Result<Vec<SearchResult>, RetrieverError> {
self.calls.fetch_add(1, Ordering::SeqCst);
Ok(self.results.clone())
}
async fn add_documents(&self, _documents: Vec<Document>) -> Result<(), RetrieverError> {
Ok(())
}
}
struct AxisEmbeddings;
#[async_trait]
impl Embeddings for AxisEmbeddings {
async fn embed_query(&self, text: &str) -> Result<Vec<f32>, lc_embeddings::EmbeddingError> {
self.embed_documents(&[text])
.await
.map(|mut v| v.pop().unwrap_or_default())
}
async fn embed_documents(
&self,
texts: &[&str],
) -> Result<Vec<Vec<f32>>, lc_embeddings::EmbeddingError> {
texts
.iter()
.map(|t| {
if t.starts_with('a') {
Ok(vec![1.0, 0.0])
} else if t.starts_with('b') {
Ok(vec![0.0, 1.0])
} else {
Err(lc_embeddings::EmbeddingError::EmptyInput)
}
})
.collect()
}
fn dimension(&self) -> usize {
2
}
fn model_name(&self) -> &str {
"axis"
}
}
#[test]
fn cosine_similarity_basics() {
use lc_embeddings::cosine_similarity;
let a = vec![1.0, 0.0];
assert!((cosine_similarity(&a, &a).unwrap() - 1.0).abs() < 1e-6);
assert!(cosine_similarity(&a, &[0.0, 1.0]).unwrap().abs() < 1e-6);
assert!((cosine_similarity(&a, &[-1.0, 0.0]).unwrap() + 1.0).abs() < 1e-6);
assert!(
cosine_similarity(&a, &[]).is_err(),
"length mismatch errors, never NaN"
);
}
#[test]
fn lexical_hit_requires_same_k() {
let core = SemanticCacheCore::new(SemanticCacheConfig::new());
let now = Instant::now();
core.insert("q", vec![1.0, 0.0], 5, vec![result("doc", 0.9)], now);
assert!(
core.lookup("q", None, 5, now).is_some(),
"exact (q, k) hits"
);
assert!(
core.lookup("q", None, 10, now).is_none(),
"different k must not be served from the k=5 result set"
);
}
#[test]
fn lexical_beats_semantic() {
let core = SemanticCacheCore::new(SemanticCacheConfig::new());
let now = Instant::now();
core.insert(
"a-query",
vec![1.0, 0.0],
5,
vec![result("from-a", 1.0)],
now,
);
core.insert(
"a-query ",
vec![1.0, 0.0],
5,
vec![result("from-a2", 0.9)],
now,
);
let (results, kind) = core.lookup("a-query", Some(&[1.0, 0.0]), 5, now).unwrap();
assert_eq!(kind, CacheHitKind::Lexical, "byte-identical wins");
assert_eq!(results[0].document.content, "from-a");
}
#[test]
fn semantic_hit_respects_threshold() {
let config = SemanticCacheConfig::new().with_threshold(0.9);
let core = SemanticCacheCore::new(config);
let now = Instant::now();
core.insert(
"query a",
vec![1.0, 0.0],
5,
vec![result("cached", 0.8)],
now,
);
let n: f32 = (0.8f32 * 0.8 + 0.6 * 0.6).sqrt();
let v = vec![0.8 / n, 0.6 / n];
assert!(core.lookup("query b", Some(&v), 5, now).is_none());
let (results, kind) = core.lookup("query c", Some(&[1.0, 0.0]), 5, now).unwrap();
assert_eq!(kind, CacheHitKind::Semantic);
assert_eq!(results[0].document.content, "cached");
}
#[test]
fn fifo_eviction_bounded() {
let config = SemanticCacheConfig::new().with_max_entries(2);
let core = SemanticCacheCore::new(config);
let now = Instant::now();
core.insert("q1", vec![1.0, 0.0], 5, vec![], now);
core.insert("q2", vec![1.0, 0.0], 5, vec![], now);
assert_eq!(core.len(), 2);
core.insert("q3", vec![1.0, 0.0], 5, vec![], now);
assert_eq!(core.len(), 2, "FIFO evicts the oldest");
assert!(core.lookup("q1", None, 5, now).is_none(), "q1 evicted");
assert!(core.lookup("q3", None, 5, now).is_some());
}
#[test]
fn ttl_expiry_is_a_miss() {
let config = SemanticCacheConfig::new().with_ttl(Some(Duration::from_millis(50)));
let core = SemanticCacheCore::new(config);
let now = Instant::now();
core.insert("q", vec![1.0, 0.0], 5, vec![result("doc", 1.0)], now);
assert!(core.lookup("q", None, 5, now).is_some());
let later = now + Duration::from_millis(51);
assert!(
core.lookup("q", None, 5, later).is_none(),
"expired entries are misses"
);
}
#[test]
fn insert_replaces_same_query_and_k() {
let core = SemanticCacheCore::new(SemanticCacheConfig::new());
let now = Instant::now();
core.insert("q", vec![1.0], 5, vec![result("old", 1.0)], now);
core.insert("q", vec![1.0], 5, vec![result("new", 1.0)], now);
assert_eq!(core.len(), 1, "replace, not duplicate");
let (results, _) = core.lookup("q", None, 5, now).unwrap();
assert_eq!(results[0].document.content, "new");
}
#[test]
fn invalidate_clears_all() {
let core = SemanticCacheCore::new(SemanticCacheConfig::new());
let now = Instant::now();
core.insert("q", vec![1.0], 5, vec![], now);
assert!(!core.is_empty());
core.invalidate();
assert!(core.is_empty());
assert!(core.lookup("q", None, 5, now).is_none());
}
#[tokio::test]
async fn cached_retriever_skips_inner_on_hits() {
let inner = Arc::new(CountingRetriever::new(vec![result("doc a", 0.9)]));
let retriever = CachedRetriever::new(
inner.clone(),
Arc::new(AxisEmbeddings),
SemanticCacheConfig::new(),
);
let first = retriever.retrieve("apple", 3).await.unwrap();
assert_eq!(first.len(), 1);
assert_eq!(inner.calls.load(Ordering::SeqCst), 1, "miss → inner call");
let second = retriever.retrieve("apple", 3).await.unwrap();
assert_eq!(second[0].content, "doc a");
assert_eq!(
inner.calls.load(Ordering::SeqCst),
1,
"lexical hit → no call"
);
let third = retriever.retrieve("avocado", 3).await.unwrap();
assert_eq!(third[0].content, "doc a");
assert_eq!(
inner.calls.load(Ordering::SeqCst),
1,
"semantic hit → no call"
);
let _ = retriever.retrieve("banana", 3).await.unwrap();
assert_eq!(inner.calls.load(Ordering::SeqCst), 2);
let _ = retriever.retrieve("apple", 5).await.unwrap();
assert_eq!(inner.calls.load(Ordering::SeqCst), 3);
retriever
.add_documents(vec![Document::new("new doc")])
.await
.unwrap();
assert!(
retriever.cache().is_empty(),
"corpus update invalidates cache"
);
let _ = retriever.retrieve("apple", 3).await.unwrap();
assert_eq!(inner.calls.load(Ordering::SeqCst), 4);
}
#[tokio::test]
async fn embedding_error_propagates() {
let inner = Arc::new(CountingRetriever::new(vec![]));
let retriever =
CachedRetriever::new(inner, Arc::new(AxisEmbeddings), SemanticCacheConfig::new());
let err = retriever.retrieve("zebra", 3).await;
assert!(err.is_err(), "embedding failure must not be swallowed");
}
}