Skip to main content

nusy_graph_query/
cache.rs

1//! Embedding cache — content-hash invalidation with Parquet persistence.
2//!
3//! Caches embedding vectors keyed by (id, content_hash). When content changes,
4//! the hash changes and the cache entry is invalidated. Supports Parquet
5//! persistence for warm starts across sessions.
6
7use crate::embedding::{EmbeddedItem, EmbeddingError, EmbeddingProvider, Result};
8use std::collections::HashMap;
9use std::path::Path;
10
11/// An embedding cache that avoids re-computing embeddings for unchanged content.
12///
13/// Keys are `(id, content_hash)` pairs. When the content hash changes, the
14/// old embedding is invalidated and a new one is computed.
15pub struct EmbeddingCache {
16    /// Map from id → (content_hash, vector).
17    entries: HashMap<String, CacheEntry>,
18    /// Number of cache hits since creation.
19    hits: u64,
20    /// Number of cache misses since creation.
21    misses: u64,
22}
23
24#[derive(Debug, Clone)]
25struct CacheEntry {
26    content_hash: String,
27    vector: Vec<f32>,
28}
29
30impl EmbeddingCache {
31    /// Create an empty cache.
32    pub fn new() -> Self {
33        Self {
34            entries: HashMap::new(),
35            hits: 0,
36            misses: 0,
37        }
38    }
39
40    /// Number of cached entries.
41    pub fn len(&self) -> usize {
42        self.entries.len()
43    }
44
45    /// Whether the cache is empty.
46    pub fn is_empty(&self) -> bool {
47        self.entries.is_empty()
48    }
49
50    /// Cache hit count.
51    pub fn hits(&self) -> u64 {
52        self.hits
53    }
54
55    /// Cache miss count.
56    pub fn misses(&self) -> u64 {
57        self.misses
58    }
59
60    /// Look up an embedding by ID and content hash.
61    ///
62    /// Returns `Some(vector)` if the cache has a valid entry (matching hash).
63    /// Returns `None` if the entry is missing or the hash has changed.
64    pub fn get(&mut self, id: &str, content_hash: &str) -> Option<&Vec<f32>> {
65        if let Some(entry) = self.entries.get(id)
66            && entry.content_hash == content_hash
67        {
68            self.hits += 1;
69            return Some(&entry.vector);
70        }
71        self.misses += 1;
72        None
73    }
74
75    /// Insert or update a cache entry.
76    pub fn insert(&mut self, id: String, content_hash: String, vector: Vec<f32>) {
77        self.entries.insert(
78            id,
79            CacheEntry {
80                content_hash,
81                vector,
82            },
83        );
84    }
85
86    /// Embed items using the cache, only computing embeddings for cache misses.
87    ///
88    /// `items` is a list of `(id, content_hash, text)` tuples. Items with
89    /// unchanged content hashes use cached vectors. Changed items are embedded
90    /// in batch and cached.
91    pub fn embed_cached(
92        &mut self,
93        items: &[(String, String, String)],
94        provider: &dyn EmbeddingProvider,
95    ) -> Result<Vec<EmbeddedItem>> {
96        let mut results = Vec::with_capacity(items.len());
97        let mut to_embed: Vec<(usize, String, String, String)> = Vec::new();
98
99        for (idx, (id, hash, text)) in items.iter().enumerate() {
100            if let Some(vec) = self.get(id, hash) {
101                results.push((
102                    idx,
103                    EmbeddedItem {
104                        id: id.clone(),
105                        vector: vec.clone(),
106                    },
107                ));
108            } else {
109                to_embed.push((idx, id.clone(), hash.clone(), text.clone()));
110            }
111        }
112
113        if !to_embed.is_empty() {
114            let texts: Vec<String> = to_embed.iter().map(|(_, _, _, t)| t.clone()).collect();
115            let vectors = provider.embed_batch(&texts)?;
116
117            for (vec, (idx, id, hash, _)) in vectors.into_iter().zip(to_embed) {
118                if vec.len() != provider.dim() {
119                    return Err(EmbeddingError::DimensionMismatch {
120                        expected: provider.dim(),
121                        actual: vec.len(),
122                    });
123                }
124                self.insert(id.clone(), hash, vec.clone());
125                results.push((idx, EmbeddedItem { id, vector: vec }));
126            }
127        }
128
129        // Sort by original index to maintain input order
130        results.sort_by_key(|(idx, _)| *idx);
131        Ok(results.into_iter().map(|(_, item)| item).collect())
132    }
133
134    /// Save the cache to a Parquet file.
135    pub fn save(&self, path: &Path) -> std::result::Result<(), Box<dyn std::error::Error>> {
136        use arrow::array::{FixedSizeListArray, Float32Array, RecordBatch, StringArray};
137        use arrow::buffer::{BooleanBuffer, NullBuffer};
138        use arrow::datatypes::{DataType, Field, Schema};
139        use parquet::arrow::ArrowWriter;
140        use std::sync::Arc;
141
142        if self.entries.is_empty() {
143            return Ok(());
144        }
145
146        // Determine embedding dimension from first entry
147        let dim = self
148            .entries
149            .values()
150            .next()
151            .map(|e| e.vector.len())
152            .unwrap_or(0);
153        if dim == 0 {
154            return Ok(());
155        }
156
157        let n = self.entries.len();
158        let mut ids = Vec::with_capacity(n);
159        let mut hashes = Vec::with_capacity(n);
160        let mut flat_vectors = Vec::with_capacity(n * dim);
161        let mut validity = Vec::with_capacity(n);
162
163        for (id, entry) in &self.entries {
164            ids.push(id.as_str());
165            hashes.push(entry.content_hash.as_str());
166            flat_vectors.extend_from_slice(&entry.vector);
167            validity.push(true);
168        }
169
170        let schema = Arc::new(Schema::new(vec![
171            Field::new("id", DataType::Utf8, false),
172            Field::new("content_hash", DataType::Utf8, false),
173            Field::new(
174                "vector",
175                DataType::FixedSizeList(
176                    Arc::new(Field::new("item", DataType::Float32, false)),
177                    dim as i32,
178                ),
179                false,
180            ),
181        ]));
182
183        let embedding_array = FixedSizeListArray::try_new(
184            Arc::new(Field::new("item", DataType::Float32, false)),
185            dim as i32,
186            Arc::new(Float32Array::from(flat_vectors)),
187            Some(NullBuffer::new(BooleanBuffer::from(validity))),
188        )?;
189
190        let batch = RecordBatch::try_new(
191            schema,
192            vec![
193                Arc::new(StringArray::from(ids)),
194                Arc::new(StringArray::from(hashes)),
195                Arc::new(embedding_array),
196            ],
197        )?;
198
199        let file = std::fs::File::create(path)?;
200        let mut writer = ArrowWriter::try_new(file, batch.schema(), None)?;
201        writer.write(&batch)?;
202        writer.close()?;
203
204        Ok(())
205    }
206
207    /// Load a cache from a Parquet file.
208    pub fn load(path: &Path) -> std::result::Result<Self, Box<dyn std::error::Error>> {
209        use arrow::array::{Array, FixedSizeListArray, Float32Array, StringArray};
210        use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
211
212        if !path.exists() {
213            return Ok(Self::new());
214        }
215
216        let file = std::fs::File::open(path)?;
217        let builder = ParquetRecordBatchReaderBuilder::try_new(file)?;
218        let reader = builder.build()?;
219
220        let mut cache = Self::new();
221
222        for batch in reader {
223            let batch = batch?;
224            let ids = batch
225                .column(0)
226                .as_any()
227                .downcast_ref::<StringArray>()
228                .ok_or("id column not StringArray")?;
229            let hashes = batch
230                .column(1)
231                .as_any()
232                .downcast_ref::<StringArray>()
233                .ok_or("hash column not StringArray")?;
234            let vectors = batch
235                .column(2)
236                .as_any()
237                .downcast_ref::<FixedSizeListArray>()
238                .ok_or("vector column not FixedSizeListArray")?;
239
240            for i in 0..batch.num_rows() {
241                if vectors.is_null(i) {
242                    continue;
243                }
244                let values = vectors.value(i);
245                let float_arr = values
246                    .as_any()
247                    .downcast_ref::<Float32Array>()
248                    .ok_or("vector values not Float32Array")?;
249                let vec: Vec<f32> = (0..float_arr.len()).map(|j| float_arr.value(j)).collect();
250
251                cache.insert(ids.value(i).to_string(), hashes.value(i).to_string(), vec);
252            }
253        }
254
255        Ok(cache)
256    }
257}
258
259impl Default for EmbeddingCache {
260    fn default() -> Self {
261        Self::new()
262    }
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268    use crate::embedding::HashEmbeddingProvider;
269
270    #[test]
271    fn test_cache_hit() {
272        let mut cache = EmbeddingCache::new();
273        cache.insert("item-1".to_string(), "hash-a".to_string(), vec![1.0, 2.0]);
274
275        assert!(cache.get("item-1", "hash-a").is_some());
276        assert_eq!(cache.hits(), 1);
277    }
278
279    #[test]
280    fn test_cache_miss_wrong_hash() {
281        let mut cache = EmbeddingCache::new();
282        cache.insert("item-1".to_string(), "hash-a".to_string(), vec![1.0, 2.0]);
283
284        assert!(cache.get("item-1", "hash-b").is_none());
285        assert_eq!(cache.misses(), 1);
286    }
287
288    #[test]
289    fn test_cache_miss_not_found() {
290        let mut cache = EmbeddingCache::new();
291        assert!(cache.get("nonexistent", "hash").is_none());
292        assert_eq!(cache.misses(), 1);
293    }
294
295    #[test]
296    fn test_embed_cached() {
297        let p = HashEmbeddingProvider::new(64);
298        let mut cache = EmbeddingCache::new();
299
300        let items = vec![
301            ("A".to_string(), "h1".to_string(), "hello".to_string()),
302            ("B".to_string(), "h2".to_string(), "world".to_string()),
303        ];
304
305        // First call — all misses
306        let result = cache.embed_cached(&items, &p).unwrap();
307        assert_eq!(result.len(), 2);
308        assert_eq!(cache.misses(), 2);
309        assert_eq!(cache.hits(), 0);
310        assert_eq!(cache.len(), 2);
311
312        // Second call — all hits
313        let result2 = cache.embed_cached(&items, &p).unwrap();
314        assert_eq!(result2.len(), 2);
315        assert_eq!(cache.hits(), 2);
316        assert_eq!(result[0].vector, result2[0].vector);
317    }
318
319    #[test]
320    fn test_embed_cached_invalidation() {
321        let p = HashEmbeddingProvider::new(64);
322        let mut cache = EmbeddingCache::new();
323
324        let items1 = vec![("A".to_string(), "h1".to_string(), "hello".to_string())];
325        let r1 = cache.embed_cached(&items1, &p).unwrap();
326
327        // Change content hash → cache miss
328        let items2 = vec![("A".to_string(), "h2".to_string(), "goodbye".to_string())];
329        let r2 = cache.embed_cached(&items2, &p).unwrap();
330
331        assert_ne!(r1[0].vector, r2[0].vector);
332        assert_eq!(cache.len(), 1); // still one entry (updated)
333    }
334
335    #[test]
336    fn test_cache_save_load_round_trip() {
337        let p = HashEmbeddingProvider::new(64);
338        let mut cache = EmbeddingCache::new();
339
340        let items = vec![
341            ("A".to_string(), "h1".to_string(), "hello".to_string()),
342            ("B".to_string(), "h2".to_string(), "world".to_string()),
343        ];
344        cache.embed_cached(&items, &p).unwrap();
345
346        let dir = tempfile::tempdir().unwrap();
347        let path = dir.path().join("cache.parquet");
348
349        cache.save(&path).unwrap();
350        assert!(path.exists());
351
352        let loaded = EmbeddingCache::load(&path).unwrap();
353        assert_eq!(loaded.len(), 2);
354
355        // Verify vectors survived round-trip
356        let mut loaded = loaded;
357        assert!(loaded.get("A", "h1").is_some());
358        assert!(loaded.get("B", "h2").is_some());
359    }
360
361    #[test]
362    fn test_cache_load_nonexistent() {
363        let path = Path::new("/tmp/nonexistent-cache-12345.parquet");
364        let cache = EmbeddingCache::load(path).unwrap();
365        assert!(cache.is_empty());
366    }
367
368    #[test]
369    fn test_cache_empty_save() {
370        let cache = EmbeddingCache::new();
371        let dir = tempfile::tempdir().unwrap();
372        let path = dir.path().join("empty.parquet");
373
374        // Empty cache save should succeed without creating a file
375        cache.save(&path).unwrap();
376        assert!(!path.exists());
377    }
378
379    #[test]
380    fn test_embed_cached_preserves_order() {
381        let p = HashEmbeddingProvider::new(64);
382        let mut cache = EmbeddingCache::new();
383
384        // Pre-cache "B" but not "A"
385        cache.insert("B".to_string(), "h2".to_string(), p.embed("world").unwrap());
386
387        let items = vec![
388            ("A".to_string(), "h1".to_string(), "hello".to_string()),
389            ("B".to_string(), "h2".to_string(), "world".to_string()),
390            ("C".to_string(), "h3".to_string(), "test".to_string()),
391        ];
392
393        let result = cache.embed_cached(&items, &p).unwrap();
394        assert_eq!(result.len(), 3);
395        assert_eq!(result[0].id, "A"); // order preserved
396        assert_eq!(result[1].id, "B");
397        assert_eq!(result[2].id, "C");
398    }
399}