1use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11use std::sync::Arc;
12
13use md5::{Digest, Md5};
14use serde::{Deserialize, Serialize};
15
16use super::bm25_index::CodeChunk;
17use super::embedding_quant::{self, QuantizedVector};
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct EmbeddingIndex {
21 pub version: u32,
22 pub dimensions: usize,
23 #[serde(default)]
26 pub model_id: Option<String>,
27 pub entries: Vec<EmbeddingEntry>,
28 pub file_hashes: HashMap<String, String>,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct EmbeddingEntry {
33 pub file_path: String,
34 pub symbol_name: String,
35 pub start_line: usize,
36 pub end_line: usize,
37 #[serde(default, skip_serializing_if = "Vec::is_empty")]
40 pub embedding: Vec<f32>,
41 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub quant: Option<QuantizedVector>,
44 pub content_hash: String,
45}
46
47impl EmbeddingEntry {
48 fn embedding_f32(&self) -> Vec<f32> {
51 match &self.quant {
52 Some(q) => q.dequantize(),
53 None => self.embedding.clone(),
54 }
55 }
56}
57
58const CURRENT_VERSION: u32 = 3;
60
61impl EmbeddingIndex {
62 pub fn new(dimensions: usize) -> Self {
63 Self {
64 version: CURRENT_VERSION,
65 dimensions,
66 model_id: None,
67 entries: Vec::new(),
68 file_hashes: HashMap::new(),
69 }
70 }
71
72 pub fn new_with_model(dimensions: usize, model_id: &str) -> Self {
74 Self {
75 version: CURRENT_VERSION,
76 dimensions,
77 model_id: Some(model_id.to_string()),
78 entries: Vec::new(),
79 file_hashes: HashMap::new(),
80 }
81 }
82
83 pub fn model_mismatch<'a>(&'a self, current_model: &'a str) -> Option<(&'a str, &'a str)> {
86 match &self.model_id {
87 Some(stored) if stored != current_model => Some((stored, current_model)),
88 _ => None,
89 }
90 }
91
92 pub fn dimension_mismatch(&self, engine_dimensions: usize) -> bool {
94 self.dimensions != engine_dimensions && !self.entries.is_empty()
95 }
96
97 pub fn memory_usage_bytes(&self) -> usize {
99 let entries_size: usize = self
100 .entries
101 .iter()
102 .map(|e| {
103 e.file_path.len()
104 + e.symbol_name.len()
105 + e.content_hash.len()
106 + e.quant
107 .as_ref()
108 .map_or(e.embedding.len() * 4, |q| q.code.len() + 4)
109 + 48
110 })
111 .sum();
112 let hashes_size: usize = self
113 .file_hashes
114 .iter()
115 .map(|(k, v)| k.len() + v.len() + 32)
116 .sum();
117 entries_size + hashes_size
118 }
119
120 pub fn unload(&mut self) {
122 let usage = self.memory_usage_bytes();
123 self.entries = Vec::new();
124 self.file_hashes = HashMap::new();
125 tracing::info!(
126 "[embeddings] unloaded index, freed ~{:.1}MB",
127 usage as f64 / 1_048_576.0
128 );
129 }
130
131 pub fn load_or_new(root: &Path, dimensions: usize) -> Self {
133 Self::load(root).unwrap_or_else(|| Self::new(dimensions))
134 }
135
136 pub fn files_needing_update(&self, chunks: &[CodeChunk]) -> Vec<String> {
138 let current_hashes = compute_file_hashes(chunks);
139
140 let mut needs_update = Vec::new();
141 for (file, hash) in ¤t_hashes {
142 match self.file_hashes.get(file) {
143 Some(old_hash) if old_hash == hash => {}
144 _ => needs_update.push(file.clone()),
145 }
146 }
147
148 for file in self.file_hashes.keys() {
149 if !current_hashes.contains_key(file) {
150 needs_update.push(file.clone());
151 }
152 }
153
154 needs_update
155 }
156
157 pub fn update(
160 &mut self,
161 chunks: &[CodeChunk],
162 new_embeddings: &[(usize, Vec<f32>)],
163 changed_files: &[String],
164 ) {
165 self.entries
166 .retain(|e| !changed_files.contains(&e.file_path));
167
168 for file in changed_files {
169 self.file_hashes.remove(file);
170 }
171
172 let current_hashes = compute_file_hashes(chunks);
173 for file in changed_files {
174 if let Some(hash) = current_hashes.get(file) {
175 self.file_hashes.insert(file.clone(), hash.clone());
176 }
177 }
178
179 for &(chunk_idx, ref embedding) in new_embeddings {
180 if let Some(chunk) = chunks.get(chunk_idx) {
181 let content_hash = hash_content(&chunk.content);
182 self.entries.push(EmbeddingEntry {
183 file_path: chunk.file_path.clone(),
184 symbol_name: chunk.symbol_name.clone(),
185 start_line: chunk.start_line,
186 end_line: chunk.end_line,
187 embedding: Vec::new(),
188 quant: Some(embedding_quant::quantize(embedding)),
189 content_hash,
190 });
191 }
192 }
193 }
194
195 fn migrate_legacy_entries(&mut self) -> bool {
198 let mut changed = false;
199 for e in &mut self.entries {
200 if e.quant.is_none() && !e.embedding.is_empty() {
201 e.quant = Some(embedding_quant::quantize(&e.embedding));
202 e.embedding = Vec::new();
203 changed = true;
204 }
205 }
206 changed
207 }
208
209 pub fn get_aligned_embeddings(&self, chunks: &[CodeChunk]) -> Option<Arc<[Vec<f32>]>> {
218 let mut map: HashMap<(&str, usize, usize), &EmbeddingEntry> =
219 HashMap::with_capacity(self.entries.len());
220 for e in &self.entries {
221 map.insert((e.file_path.as_str(), e.start_line, e.end_line), e);
222 }
223
224 let mut result = Vec::with_capacity(chunks.len());
225 for chunk in chunks {
226 let entry = map.get(&(chunk.file_path.as_str(), chunk.start_line, chunk.end_line))?;
227 result.push(entry.embedding_f32());
228 }
229 Some(Arc::from(result))
230 }
231
232 pub fn coverage(&self, total_chunks: usize) -> f64 {
233 if total_chunks == 0 {
234 return 0.0;
235 }
236 self.entries.len() as f64 / total_chunks as f64
237 }
238
239 pub fn save(&self, root: &Path) -> std::io::Result<()> {
240 let dir = index_dir(root);
241 std::fs::create_dir_all(&dir)?;
242 let data = serde_json::to_string(self).map_err(std::io::Error::other)?;
243 std::fs::write(dir.join("embeddings.json"), data)?;
244 Ok(())
245 }
246
247 pub fn load(root: &Path) -> Option<Self> {
248 let dir = index_dir(root);
249 let path = dir.join("embeddings.json");
250 let data = std::fs::read_to_string(&path)
251 .or_else(|_| {
252 let legacy_dir = legacy_embedding_dir(root);
253 if legacy_dir == dir {
254 return Err(std::io::Error::new(
255 std::io::ErrorKind::NotFound,
256 "same path",
257 ));
258 }
259 let legacy_path = legacy_dir.join("embeddings.json");
260 let content = std::fs::read_to_string(&legacy_path)?;
261 let _ = std::fs::create_dir_all(&dir);
262 let _ = std::fs::copy(&legacy_path, &path);
263 Ok(content)
264 })
265 .ok()?;
266 let mut idx: Self = serde_json::from_str(&data).ok()?;
267 match idx.version {
268 CURRENT_VERSION => Some(idx),
269 1 | 2 => {
270 tracing::info!(
271 "[embeddings] migrating index v{} → v{CURRENT_VERSION} (int8 quantization)",
272 idx.version
273 );
274 idx.version = CURRENT_VERSION;
275 let quantized = idx.migrate_legacy_entries();
276 if quantized {
278 let _ = idx.save(root);
279 }
280 Some(idx)
281 }
282 _ => None,
283 }
284 }
285}
286
287fn index_dir(root: &Path) -> PathBuf {
288 crate::core::index_namespace::vectors_dir(root)
289}
290
291fn legacy_embedding_dir(root: &Path) -> PathBuf {
292 let mut hasher = Md5::new();
293 hasher.update(root.to_string_lossy().as_bytes());
294 let hash = format!("{:x}", hasher.finalize());
295 crate::core::data_dir::lean_ctx_data_dir()
296 .unwrap_or_else(|_| PathBuf::from("."))
297 .join("vectors")
298 .join(hash)
299}
300
301fn hash_content(content: &str) -> String {
302 let mut hasher = Md5::new();
303 hasher.update(content.as_bytes());
304 format!("{:x}", hasher.finalize())
305}
306
307fn compute_file_hashes(chunks: &[CodeChunk]) -> HashMap<String, String> {
308 let mut by_file: HashMap<&str, Vec<&CodeChunk>> = HashMap::new();
309 for chunk in chunks {
310 by_file
311 .entry(chunk.file_path.as_str())
312 .or_default()
313 .push(chunk);
314 }
315
316 let mut out: HashMap<String, String> = HashMap::with_capacity(by_file.len());
317 for (file, mut file_chunks) in by_file {
318 file_chunks.sort_by(|a, b| {
319 (a.start_line, a.end_line, a.symbol_name.as_str()).cmp(&(
320 b.start_line,
321 b.end_line,
322 b.symbol_name.as_str(),
323 ))
324 });
325
326 let mut hasher = Md5::new();
327 hasher.update(file.as_bytes());
328 for c in file_chunks {
329 hasher.update(c.start_line.to_le_bytes());
330 hasher.update(c.end_line.to_le_bytes());
331 hasher.update(c.symbol_name.as_bytes());
332 hasher.update([kind_tag(&c.kind)]);
333 hasher.update(c.content.as_bytes());
334 }
335 out.insert(file.to_string(), format!("{:x}", hasher.finalize()));
336 }
337 out
338}
339
340fn kind_tag(kind: &super::bm25_index::ChunkKind) -> u8 {
341 use super::bm25_index::ChunkKind;
342 match kind {
343 ChunkKind::Function => 1,
344 ChunkKind::Struct => 2,
345 ChunkKind::Impl => 3,
346 ChunkKind::Module => 4,
347 ChunkKind::Class => 5,
348 ChunkKind::Method => 6,
349 ChunkKind::Other => 7,
350 ChunkKind::Issue => 8,
351 ChunkKind::PullRequest => 9,
352 ChunkKind::WikiPage => 10,
353 ChunkKind::DbSchema => 11,
354 ChunkKind::ApiEndpoint => 12,
355 ChunkKind::Ticket => 13,
356 ChunkKind::ExternalOther => 14,
357 }
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363 use crate::core::bm25_index::{ChunkKind, CodeChunk};
364
365 fn make_chunk(file: &str, name: &str, content: &str, start: usize, end: usize) -> CodeChunk {
366 CodeChunk {
367 file_path: file.to_string(),
368 symbol_name: name.to_string(),
369 kind: ChunkKind::Function,
370 start_line: start,
371 end_line: end,
372 content: content.to_string(),
373 tokens: vec![name.to_string()],
374 token_count: 1,
375 }
376 }
377
378 fn dummy_embedding(dim: usize) -> Vec<f32> {
379 vec![0.1; dim]
380 }
381
382 #[test]
383 fn new_index_is_empty() {
384 let idx = EmbeddingIndex::new(384);
385 assert!(idx.entries.is_empty());
386 assert!(idx.file_hashes.is_empty());
387 assert_eq!(idx.dimensions, 384);
388 }
389
390 #[test]
391 fn files_needing_update_all_new() {
392 let idx = EmbeddingIndex::new(384);
393 let chunks = vec![
394 make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3),
395 make_chunk("b.rs", "fn_b", "fn b() {}", 1, 3),
396 ];
397 let needs = idx.files_needing_update(&chunks);
398 assert_eq!(needs.len(), 2);
399 }
400
401 #[test]
402 fn files_needing_update_unchanged() {
403 let mut idx = EmbeddingIndex::new(384);
404 let chunks = vec![make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3)];
405
406 idx.update(&chunks, &[(0, dummy_embedding(384))], &["a.rs".to_string()]);
407
408 let needs = idx.files_needing_update(&chunks);
409 assert!(needs.is_empty(), "unchanged file should not need update");
410 }
411
412 #[test]
413 fn files_needing_update_changed_content() {
414 let mut idx = EmbeddingIndex::new(384);
415 let chunks_v1 = vec![make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3)];
416 idx.update(
417 &chunks_v1,
418 &[(0, dummy_embedding(384))],
419 &["a.rs".to_string()],
420 );
421
422 let chunks_v2 = vec![make_chunk("a.rs", "fn_a", "fn a() { modified }", 1, 3)];
423 let needs = idx.files_needing_update(&chunks_v2);
424 assert!(
425 needs.contains(&"a.rs".to_string()),
426 "changed file should need update"
427 );
428 }
429
430 #[test]
431 fn files_needing_update_detects_change_in_later_chunk() {
432 let mut idx = EmbeddingIndex::new(3);
433 let chunks_v1 = vec![
434 make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3),
435 make_chunk("a.rs", "fn_b", "fn b() {}", 10, 12),
436 ];
437 idx.update(
438 &chunks_v1,
439 &[(0, vec![0.1, 0.1, 0.1]), (1, vec![0.2, 0.2, 0.2])],
440 &["a.rs".to_string()],
441 );
442
443 let chunks_v2 = vec![
444 make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3),
445 make_chunk("a.rs", "fn_b", "fn b() { changed }", 10, 12),
446 ];
447 let needs = idx.files_needing_update(&chunks_v2);
448 assert!(
449 needs.contains(&"a.rs".to_string()),
450 "changing a later chunk should trigger re-embedding"
451 );
452 }
453
454 #[test]
455 fn files_needing_update_deleted_file() {
456 let mut idx = EmbeddingIndex::new(384);
457 let chunks = vec![
458 make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3),
459 make_chunk("b.rs", "fn_b", "fn b() {}", 1, 3),
460 ];
461 idx.update(
462 &chunks,
463 &[(0, dummy_embedding(384)), (1, dummy_embedding(384))],
464 &["a.rs".to_string(), "b.rs".to_string()],
465 );
466
467 let chunks_after = vec![make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3)];
468 let needs = idx.files_needing_update(&chunks_after);
469 assert!(
470 needs.contains(&"b.rs".to_string()),
471 "deleted file should trigger update"
472 );
473 }
474
475 #[test]
476 fn update_preserves_unchanged() {
477 let mut idx = EmbeddingIndex::new(384);
478 let chunks = vec![
479 make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3),
480 make_chunk("b.rs", "fn_b", "fn b() {}", 1, 3),
481 ];
482 idx.update(
483 &chunks,
484 &[(0, dummy_embedding(384)), (1, dummy_embedding(384))],
485 &["a.rs".to_string(), "b.rs".to_string()],
486 );
487 assert_eq!(idx.entries.len(), 2);
488
489 idx.update(&chunks, &[(0, vec![0.5; 384])], &["a.rs".to_string()]);
490 assert_eq!(idx.entries.len(), 2);
491
492 let b_entry = idx.entries.iter().find(|e| e.file_path == "b.rs").unwrap();
493 assert!(
494 (b_entry.embedding_f32()[0] - 0.1).abs() < 1e-6,
495 "b.rs embedding should be preserved"
496 );
497 }
498
499 #[test]
500 fn get_aligned_embeddings() {
501 let mut idx = EmbeddingIndex::new(2);
502 let chunks = vec![
503 make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3),
504 make_chunk("b.rs", "fn_b", "fn b() {}", 1, 3),
505 ];
506 idx.update(
507 &chunks,
508 &[(0, vec![1.0, 0.0]), (1, vec![0.0, 1.0])],
509 &["a.rs".to_string(), "b.rs".to_string()],
510 );
511
512 let aligned = idx.get_aligned_embeddings(&chunks).unwrap();
513 assert_eq!(aligned.len(), 2);
514 assert!((aligned[0][0] - 1.0).abs() < 1e-6);
515 assert!((aligned[1][1] - 1.0).abs() < 1e-6);
516 }
517
518 #[test]
519 fn get_aligned_embeddings_missing() {
520 let idx = EmbeddingIndex::new(384);
521 let chunks = vec![make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3)];
522 assert!(idx.get_aligned_embeddings(&chunks).is_none());
523 }
524
525 #[test]
526 fn coverage_calculation() {
527 let mut idx = EmbeddingIndex::new(384);
528 assert!((idx.coverage(10) - 0.0).abs() < 1e-6);
529
530 let chunks = vec![make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3)];
531 idx.update(&chunks, &[(0, dummy_embedding(384))], &["a.rs".to_string()]);
532 assert!((idx.coverage(2) - 0.5).abs() < 1e-6);
533 assert!((idx.coverage(1) - 1.0).abs() < 1e-6);
534 }
535
536 #[test]
537 fn save_and_load_roundtrip() {
538 let _lock = crate::core::data_dir::test_env_lock();
539 let data_dir = tempfile::tempdir().unwrap();
540 crate::test_env::set_var("LEAN_CTX_DATA_DIR", data_dir.path());
541
542 let project_dir = tempfile::tempdir().unwrap();
543
544 let mut idx = EmbeddingIndex::new(3);
545 let chunks = vec![make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3)];
546 idx.update(&chunks, &[(0, vec![1.0, 2.0, 3.0])], &["a.rs".to_string()]);
547 idx.save(project_dir.path()).unwrap();
548
549 let loaded = EmbeddingIndex::load(project_dir.path()).unwrap();
550 assert_eq!(loaded.dimensions, 3);
551 assert_eq!(loaded.entries.len(), 1);
552 let recon = loaded.entries[0].embedding_f32();
554 assert!((recon[0] - 1.0).abs() < 0.02);
555 assert!((recon[1] - 2.0).abs() < 0.02);
556 assert!((recon[2] - 3.0).abs() < 0.02);
557
558 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
559 }
560
561 #[test]
562 fn new_with_model_sets_model_id() {
563 let idx = EmbeddingIndex::new_with_model(768, "jina-code-v2");
564 assert_eq!(idx.model_id, Some("jina-code-v2".to_string()));
565 assert_eq!(idx.dimensions, 768);
566 }
567
568 #[test]
569 fn model_mismatch_detection() {
570 let idx = EmbeddingIndex::new_with_model(768, "all-MiniLM-L6-v2");
571 assert!(idx.model_mismatch("all-MiniLM-L6-v2").is_none());
572 assert!(idx.model_mismatch("jina-code-v2").is_some());
573
574 let (stored, current) = idx.model_mismatch("jina-code-v2").unwrap();
575 assert_eq!(stored, "all-MiniLM-L6-v2");
576 assert_eq!(current, "jina-code-v2");
577 }
578
579 #[test]
580 fn model_mismatch_none_when_no_model_id() {
581 let idx = EmbeddingIndex::new(384);
582 assert!(idx.model_mismatch("anything").is_none());
583 }
584
585 #[test]
586 fn dimension_mismatch_detection() {
587 let mut idx = EmbeddingIndex::new(384);
588 assert!(!idx.dimension_mismatch(384));
589 assert!(!idx.dimension_mismatch(768)); let chunks = vec![make_chunk("a.rs", "fn_a", "fn a() {}", 1, 3)];
592 idx.update(&chunks, &[(0, dummy_embedding(384))], &["a.rs".to_string()]);
593 assert!(!idx.dimension_mismatch(384));
594 assert!(idx.dimension_mismatch(768));
595 }
596
597 #[test]
598 fn v1_index_migration() {
599 let _lock = crate::core::data_dir::test_env_lock();
600 let data_dir = tempfile::tempdir().unwrap();
601 crate::test_env::set_var("LEAN_CTX_DATA_DIR", data_dir.path());
602 let project_dir = tempfile::tempdir().unwrap();
603
604 let v1_json = serde_json::json!({
605 "version": 1,
606 "dimensions": 384,
607 "entries": [],
608 "file_hashes": {}
609 });
610
611 let dir = crate::core::index_namespace::vectors_dir(project_dir.path());
612 std::fs::create_dir_all(&dir).unwrap();
613 std::fs::write(dir.join("embeddings.json"), v1_json.to_string()).unwrap();
614
615 let loaded = EmbeddingIndex::load(project_dir.path()).unwrap();
616 assert_eq!(loaded.version, CURRENT_VERSION);
617 assert_eq!(loaded.dimensions, 384);
618 assert!(loaded.model_id.is_none());
619
620 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
621 }
622
623 #[test]
624 fn v2_index_quantizes_on_migration() {
625 let _lock = crate::core::data_dir::test_env_lock();
626 let data_dir = tempfile::tempdir().unwrap();
627 crate::test_env::set_var("LEAN_CTX_DATA_DIR", data_dir.path());
628 let project_dir = tempfile::tempdir().unwrap();
629
630 let v2_json = serde_json::json!({
632 "version": 2,
633 "dimensions": 3,
634 "model_id": "all-MiniLM-L6-v2",
635 "entries": [{
636 "file_path": "a.rs",
637 "symbol_name": "fn_a",
638 "start_line": 1,
639 "end_line": 3,
640 "embedding": [1.0, 2.0, 3.0],
641 "content_hash": "abc"
642 }],
643 "file_hashes": {}
644 });
645
646 let dir = crate::core::index_namespace::vectors_dir(project_dir.path());
647 std::fs::create_dir_all(&dir).unwrap();
648 std::fs::write(dir.join("embeddings.json"), v2_json.to_string()).unwrap();
649
650 let loaded = EmbeddingIndex::load(project_dir.path()).unwrap();
651 assert_eq!(loaded.version, CURRENT_VERSION);
652 let entry = &loaded.entries[0];
654 assert!(
655 entry.embedding.is_empty(),
656 "f32 field cleared after migration"
657 );
658 assert!(entry.quant.is_some(), "entry is now quantized");
659 let recon = entry.embedding_f32();
660 assert!((recon[2] - 3.0).abs() < 0.02);
661
662 let reloaded = EmbeddingIndex::load(project_dir.path()).unwrap();
664 assert_eq!(reloaded.version, CURRENT_VERSION);
665 assert!(reloaded.entries[0].quant.is_some());
666
667 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
668 }
669}