1use crate::embedding::{EmbeddedItem, EmbeddingError, EmbeddingProvider, Result};
8use std::collections::HashMap;
9use std::path::Path;
10
11pub struct EmbeddingCache {
16 entries: HashMap<String, CacheEntry>,
18 hits: u64,
20 misses: u64,
22}
23
24#[derive(Debug, Clone)]
25struct CacheEntry {
26 content_hash: String,
27 vector: Vec<f32>,
28}
29
30impl EmbeddingCache {
31 pub fn new() -> Self {
33 Self {
34 entries: HashMap::new(),
35 hits: 0,
36 misses: 0,
37 }
38 }
39
40 pub fn len(&self) -> usize {
42 self.entries.len()
43 }
44
45 pub fn is_empty(&self) -> bool {
47 self.entries.is_empty()
48 }
49
50 pub fn hits(&self) -> u64 {
52 self.hits
53 }
54
55 pub fn misses(&self) -> u64 {
57 self.misses
58 }
59
60 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 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 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 results.sort_by_key(|(idx, _)| *idx);
131 Ok(results.into_iter().map(|(_, item)| item).collect())
132 }
133
134 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 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 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 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 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 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); }
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 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 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 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"); assert_eq!(result[1].id, "B");
397 assert_eq!(result[2].id, "C");
398 }
399}