1pub mod hnsw;
13mod rrf;
14
15pub use hnsw::{HnswConfig, HnswIndex, HnswResult};
16pub use rrf::{RrfConfig, reciprocal_rank_fusion, weighted_rrf};
17
18use crate::embedding::{Embedder, cosine_similarity};
19use crate::error::Result;
20use crate::storage::{SqliteStorage, Storage};
21
22pub const DEFAULT_SIMILARITY_THRESHOLD: f32 = 0.3;
24
25pub const DEFAULT_TOP_K: usize = 10;
27
28#[derive(Debug, Clone)]
30pub struct SearchResult {
31 pub chunk_id: i64,
33 pub buffer_id: i64,
35 pub index: usize,
37 pub score: f64,
39 pub semantic_score: Option<f32>,
41 pub bm25_score: Option<f64>,
43 pub content_preview: Option<String>,
45}
46
47#[derive(Debug, Clone)]
49pub struct SearchConfig {
50 pub top_k: usize,
52 pub similarity_threshold: f32,
54 pub rrf_k: u32,
56 pub use_semantic: bool,
58 pub use_bm25: bool,
60}
61
62impl Default for SearchConfig {
63 fn default() -> Self {
64 Self {
65 top_k: DEFAULT_TOP_K,
66 similarity_threshold: DEFAULT_SIMILARITY_THRESHOLD,
67 rrf_k: 60,
68 use_semantic: true,
69 use_bm25: true,
70 }
71 }
72}
73
74pub const DEFAULT_PREVIEW_LEN: usize = 150;
76
77impl SearchResult {
78 fn from_chunk_id(
82 storage: &SqliteStorage,
83 chunk_id: i64,
84 score: f64,
85 semantic_score: Option<f32>,
86 bm25_score: Option<f64>,
87 ) -> Option<Self> {
88 storage
89 .get_chunk(chunk_id)
90 .ok()
91 .flatten()
92 .map(|chunk| Self {
93 chunk_id,
94 buffer_id: chunk.buffer_id,
95 index: chunk.index,
96 score,
97 semantic_score,
98 bm25_score,
99 content_preview: None,
100 })
101 }
102}
103
104pub fn populate_previews(
116 storage: &SqliteStorage,
117 results: &mut [SearchResult],
118 preview_len: usize,
119) -> Result<()> {
120 if results.is_empty() {
121 return Ok(());
122 }
123
124 let ids: Vec<i64> = results.iter().map(|r| r.chunk_id).collect();
126 let chunk_map = storage.get_chunks_by_ids(&ids)?;
127
128 for result in results.iter_mut() {
129 if let Some(chunk) = chunk_map.get(&result.chunk_id) {
130 let content = &chunk.content;
131 let preview = if content.len() <= preview_len {
132 content.clone()
133 } else {
134 let end = crate::io::find_char_boundary(content, preview_len);
136 let mut preview = content[..end].to_string();
137 if end < content.len() {
138 preview.push_str("...");
139 }
140 preview
141 };
142 result.content_preview = Some(preview);
143 }
144 }
145 Ok(())
146}
147
148impl SearchConfig {
149 #[must_use]
151 pub fn new() -> Self {
152 Self::default()
153 }
154
155 #[must_use]
157 pub const fn with_top_k(mut self, top_k: usize) -> Self {
158 self.top_k = top_k;
159 self
160 }
161
162 #[must_use]
164 pub const fn with_threshold(mut self, threshold: f32) -> Self {
165 self.similarity_threshold = threshold;
166 self
167 }
168
169 #[must_use]
171 pub const fn with_rrf_k(mut self, k: u32) -> Self {
172 self.rrf_k = k;
173 self
174 }
175
176 #[must_use]
178 pub const fn with_semantic(mut self, enabled: bool) -> Self {
179 self.use_semantic = enabled;
180 self
181 }
182
183 #[must_use]
185 pub const fn with_bm25(mut self, enabled: bool) -> Self {
186 self.use_bm25 = enabled;
187 self
188 }
189}
190
191pub fn hybrid_search(
204 storage: &SqliteStorage,
205 embedder: &dyn Embedder,
206 query: &str,
207 config: &SearchConfig,
208) -> Result<Vec<SearchResult>> {
209 hybrid_search_scoped(storage, embedder, query, config, None)
210}
211
212pub fn hybrid_search_in_buffer(
226 storage: &SqliteStorage,
227 embedder: &dyn Embedder,
228 query: &str,
229 config: &SearchConfig,
230 buffer_id: i64,
231) -> Result<Vec<SearchResult>> {
232 hybrid_search_scoped(storage, embedder, query, config, Some(buffer_id))
233}
234
235fn hybrid_search_scoped(
236 storage: &SqliteStorage,
237 embedder: &dyn Embedder,
238 query: &str,
239 config: &SearchConfig,
240 buffer_id: Option<i64>,
241) -> Result<Vec<SearchResult>> {
242 let mut semantic_results: Vec<(i64, f32)> = Vec::new();
243 let mut bm25_results: Vec<(i64, f64)> = Vec::new();
244
245 if config.use_semantic {
247 semantic_results = semantic_search(storage, embedder, query, config, buffer_id)?;
248 }
249
250 if config.use_bm25 {
252 bm25_results = storage.search_fts_in_buffer(query, config.top_k * 2, buffer_id)?;
253 }
254
255 if !config.use_semantic {
257 return Ok(bm25_results
258 .into_iter()
259 .take(config.top_k)
260 .filter_map(|(chunk_id, score)| {
261 SearchResult::from_chunk_id(storage, chunk_id, score, None, Some(score))
262 })
263 .collect());
264 }
265
266 if !config.use_bm25 {
267 return Ok(semantic_results
268 .into_iter()
269 .take(config.top_k)
270 .filter_map(|(chunk_id, score)| {
271 SearchResult::from_chunk_id(storage, chunk_id, f64::from(score), Some(score), None)
272 })
273 .collect());
274 }
275
276 let rrf_config = RrfConfig::new(config.rrf_k);
278
279 let semantic_ranked: Vec<i64> = semantic_results.iter().map(|(id, _)| *id).collect();
281 let bm25_ranked: Vec<i64> = bm25_results.iter().map(|(id, _)| *id).collect();
282
283 let fused = reciprocal_rank_fusion(&[&semantic_ranked, &bm25_ranked], &rrf_config);
284
285 let semantic_map: std::collections::HashMap<i64, f32> = semantic_results.into_iter().collect();
287 let bm25_map: std::collections::HashMap<i64, f64> = bm25_results.into_iter().collect();
288
289 let results: Vec<SearchResult> = fused
290 .into_iter()
291 .take(config.top_k)
292 .filter_map(|(chunk_id, rrf_score)| {
293 SearchResult::from_chunk_id(
294 storage,
295 chunk_id,
296 rrf_score,
297 semantic_map.get(&chunk_id).copied(),
298 bm25_map.get(&chunk_id).copied(),
299 )
300 })
301 .collect();
302
303 Ok(results)
304}
305
306fn semantic_search(
310 storage: &SqliteStorage,
311 embedder: &dyn Embedder,
312 query: &str,
313 config: &SearchConfig,
314 buffer_id: Option<i64>,
315) -> Result<Vec<(i64, f32)>> {
316 use rayon::prelude::*;
317
318 let query_embedding = embedder.embed(query)?;
320
321 let all_embeddings = match buffer_id {
323 Some(buffer_id) => storage.get_embeddings_in_buffer(buffer_id)?,
324 None => storage.get_all_embeddings()?,
325 };
326
327 if all_embeddings.is_empty() {
328 return Ok(Vec::new());
329 }
330
331 let mut similarities: Vec<(i64, f32)> = all_embeddings
333 .par_iter()
334 .map(|(chunk_id, embedding)| {
335 let sim = cosine_similarity(&query_embedding, embedding);
336 (*chunk_id, sim)
337 })
338 .filter(|(_, sim)| *sim >= config.similarity_threshold)
339 .collect();
340
341 similarities.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
343
344 similarities.truncate(config.top_k * 2);
346
347 Ok(similarities)
348}
349
350pub fn search_semantic(
364 storage: &SqliteStorage,
365 embedder: &dyn Embedder,
366 query: &str,
367 top_k: usize,
368 threshold: f32,
369) -> Result<Vec<SearchResult>> {
370 let config = SearchConfig::new()
371 .with_top_k(top_k)
372 .with_threshold(threshold)
373 .with_semantic(true)
374 .with_bm25(false);
375
376 hybrid_search(storage, embedder, query, &config)
377}
378
379pub fn search_bm25(
391 storage: &SqliteStorage,
392 query: &str,
393 top_k: usize,
394) -> Result<Vec<SearchResult>> {
395 let results = storage.search_fts(query, top_k)?;
396
397 Ok(results
398 .into_iter()
399 .filter_map(|(chunk_id, score)| {
400 SearchResult::from_chunk_id(storage, chunk_id, score, None, Some(score))
401 })
402 .collect())
403}
404
405pub fn embed_buffer_chunks(
421 storage: &mut SqliteStorage,
422 embedder: &dyn Embedder,
423 buffer_id: i64,
424) -> Result<usize> {
425 let chunks = storage.get_chunks(buffer_id)?;
426
427 if chunks.is_empty() {
428 return Ok(0);
429 }
430
431 let texts: Vec<&str> = chunks.iter().map(|c| c.content.as_str()).collect();
433
434 let embeddings = embedder.embed_batch(&texts)?;
436
437 let batch: Vec<(i64, Vec<f32>)> = chunks
439 .iter()
440 .zip(embeddings)
441 .filter_map(|(chunk, embedding)| chunk.id.map(|id| (id, embedding)))
442 .collect();
443
444 let count = batch.len();
445
446 storage.store_embeddings_batch(&batch, Some(embedder.model_name()))?;
448
449 Ok(count)
450}
451
452pub fn buffer_fully_embedded(storage: &SqliteStorage, buffer_id: i64) -> Result<bool> {
461 storage.all_chunks_have_embeddings(buffer_id)
462}
463
464pub fn check_model_mismatch(
472 storage: &SqliteStorage,
473 buffer_id: i64,
474 current_model: &str,
475) -> Result<Option<String>> {
476 let models = storage.get_embedding_models(buffer_id)?;
477
478 if models.is_empty() {
480 return Ok(None);
481 }
482
483 for model in models {
485 if model != current_model {
486 return Ok(Some(model));
487 }
488 }
489
490 Ok(None)
491}
492
493#[derive(Debug, Clone)]
495pub struct EmbeddingModelInfo {
496 pub models: Vec<(Option<String>, i64)>,
498 pub total_embeddings: i64,
500 pub has_mixed_models: bool,
502}
503
504pub fn get_embedding_model_info(
510 storage: &SqliteStorage,
511 buffer_id: i64,
512) -> Result<EmbeddingModelInfo> {
513 let models = storage.get_embedding_model_counts(buffer_id)?;
514 let total_embeddings: i64 = models.iter().map(|(_, count)| count).sum();
515 let distinct_models: std::collections::HashSet<_> =
516 models.iter().map(|(name, _)| name.as_deref()).collect();
517 let has_mixed_models = distinct_models.len() > 1;
518
519 Ok(EmbeddingModelInfo {
520 models,
521 total_embeddings,
522 has_mixed_models,
523 })
524}
525
526#[derive(Debug, Clone)]
528pub struct IncrementalEmbedResult {
529 pub embedded_count: usize,
531 pub skipped_count: usize,
533 pub replaced_count: usize,
535 pub total_chunks: usize,
537 pub model_name: String,
539}
540
541impl IncrementalEmbedResult {
542 #[must_use]
544 pub const fn had_changes(&self) -> bool {
545 self.embedded_count > 0 || self.replaced_count > 0
546 }
547
548 #[must_use]
550 #[allow(clippy::cast_precision_loss)] pub fn completion_percentage(&self) -> f64 {
552 if self.total_chunks == 0 {
553 100.0
554 } else {
555 let completed = self.embedded_count + self.skipped_count + self.replaced_count;
556 (completed as f64 / self.total_chunks as f64) * 100.0
557 }
558 }
559}
560
561pub fn embed_buffer_chunks_incremental(
585 storage: &mut SqliteStorage,
586 embedder: &dyn Embedder,
587 buffer_id: i64,
588 force_reembed: bool,
589) -> Result<IncrementalEmbedResult> {
590 let current_model = embedder.model_name();
591 let stats = storage.get_embedding_stats(buffer_id)?;
592 let total_chunks = stats.total_chunks;
593
594 let model_to_check = if force_reembed {
596 Some(current_model)
597 } else {
598 None
599 };
600
601 let chunk_ids_to_embed = storage.get_chunks_needing_embedding(buffer_id, model_to_check)?;
602
603 if chunk_ids_to_embed.is_empty() {
604 return Ok(IncrementalEmbedResult {
605 embedded_count: 0,
606 skipped_count: total_chunks,
607 replaced_count: 0,
608 total_chunks,
609 model_name: current_model.to_string(),
610 });
611 }
612
613 let all_chunks = storage.get_chunks(buffer_id)?;
615 let chunks_to_embed: Vec<_> = all_chunks
616 .iter()
617 .filter(|c| c.id.is_some_and(|id| chunk_ids_to_embed.contains(&id)))
618 .collect();
619
620 let mut replaced_count = 0;
622 for chunk in &chunks_to_embed {
623 if let Some(id) = chunk.id
624 && storage.has_embedding(id)?
625 {
626 replaced_count += 1;
627 }
628 }
629
630 let texts: Vec<&str> = chunks_to_embed.iter().map(|c| c.content.as_str()).collect();
632 let embeddings = embedder.embed_batch(&texts)?;
633
634 let batch: Vec<(i64, Vec<f32>)> = chunks_to_embed
636 .iter()
637 .zip(embeddings)
638 .filter_map(|(chunk, embedding)| chunk.id.map(|id| (id, embedding)))
639 .collect();
640
641 let embedded_count = batch.len();
642 storage.store_embeddings_batch(&batch, Some(current_model))?;
643
644 let new_embeddings = embedded_count - replaced_count;
645 let skipped_count = total_chunks - embedded_count;
646
647 Ok(IncrementalEmbedResult {
648 embedded_count: new_embeddings,
649 skipped_count,
650 replaced_count,
651 total_chunks,
652 model_name: current_model.to_string(),
653 })
654}
655
656#[cfg(test)]
657mod tests {
658 use super::*;
659 use crate::core::{Buffer, Chunk};
660 use crate::embedding::{DEFAULT_DIMENSIONS, Embedder, FallbackEmbedder};
661 use crate::storage::Storage;
662
663 fn setup_storage() -> SqliteStorage {
664 let mut storage = SqliteStorage::in_memory().unwrap();
665 storage.init().unwrap();
666 storage
667 }
668
669 fn setup_storage_with_chunks() -> SqliteStorage {
670 let mut storage = setup_storage();
671
672 let buffer = Buffer::from_named(
674 "test.txt".to_string(),
675 "Test content for searching".to_string(),
676 );
677 let buffer_id = storage.add_buffer(&buffer).unwrap();
678
679 let chunks = vec![
681 Chunk::new(
682 buffer_id,
683 "The quick brown fox jumps over the lazy dog".to_string(),
684 0..44,
685 0,
686 ),
687 Chunk::new(
688 buffer_id,
689 "Machine learning is a subset of artificial intelligence".to_string(),
690 44..100,
691 1,
692 ),
693 Chunk::new(
694 buffer_id,
695 "Rust is a systems programming language".to_string(),
696 100..139,
697 2,
698 ),
699 ];
700
701 storage.add_chunks(buffer_id, &chunks).unwrap();
702
703 storage
704 }
705
706 fn add_single_chunk_buffer(storage: &mut SqliteStorage, content: &str) -> (i64, i64) {
707 let buffer_id = storage
708 .add_buffer(&Buffer::from_content(content.to_string()))
709 .unwrap();
710 storage
711 .add_chunks(
712 buffer_id,
713 &[Chunk::new(
714 buffer_id,
715 content.to_string(),
716 0..content.len(),
717 0,
718 )],
719 )
720 .unwrap();
721 let chunk_id = storage.get_chunks(buffer_id).unwrap()[0].id.unwrap();
722 (buffer_id, chunk_id)
723 }
724
725 #[test]
726 fn test_search_config_default() {
727 let config = SearchConfig::default();
728 assert_eq!(config.top_k, DEFAULT_TOP_K);
729 assert!((config.similarity_threshold - DEFAULT_SIMILARITY_THRESHOLD).abs() < f32::EPSILON);
730 assert_eq!(config.rrf_k, 60);
731 assert!(config.use_semantic);
732 assert!(config.use_bm25);
733 }
734
735 #[test]
736 fn test_search_config_builder() {
737 let config = SearchConfig::new()
738 .with_top_k(20)
739 .with_threshold(0.5)
740 .with_rrf_k(30)
741 .with_semantic(false)
742 .with_bm25(true);
743
744 assert_eq!(config.top_k, 20);
745 assert!((config.similarity_threshold - 0.5).abs() < f32::EPSILON);
746 assert_eq!(config.rrf_k, 30);
747 assert!(!config.use_semantic);
748 assert!(config.use_bm25);
749 }
750
751 #[test]
752 fn test_search_bm25() {
753 let storage = setup_storage_with_chunks();
754
755 let results = search_bm25(&storage, "fox", 10).unwrap();
757 assert!(!results.is_empty());
758 assert!(results[0].bm25_score.is_some());
759 assert!(results[0].semantic_score.is_none());
760 }
761
762 #[test]
763 fn test_search_bm25_no_results() {
764 let storage = setup_storage_with_chunks();
765
766 let results = search_bm25(&storage, "xyz123nonexistent", 10).unwrap();
768 assert!(results.is_empty());
769 }
770
771 #[test]
772 fn test_embed_buffer_chunks() {
773 let mut storage = setup_storage_with_chunks();
774 let embedder = FallbackEmbedder::new(DEFAULT_DIMENSIONS);
775
776 let count = embed_buffer_chunks(&mut storage, &embedder, 1).unwrap();
778 assert_eq!(count, 3); }
780
781 #[test]
782 fn test_embed_buffer_chunks_empty() {
783 let mut storage = setup_storage();
784 let embedder = FallbackEmbedder::new(DEFAULT_DIMENSIONS);
785
786 let buffer = Buffer::from_named("empty.txt".to_string(), String::new());
788 let buffer_id = storage.add_buffer(&buffer).unwrap();
789
790 let count = embed_buffer_chunks(&mut storage, &embedder, buffer_id).unwrap();
791 assert_eq!(count, 0);
792 }
793
794 #[test]
795 fn test_buffer_fully_embedded_empty() {
796 let mut storage = setup_storage();
797
798 let buffer = Buffer::from_named("empty.txt".to_string(), String::new());
800 let buffer_id = storage.add_buffer(&buffer).unwrap();
801
802 let result = buffer_fully_embedded(&storage, buffer_id).unwrap();
804 assert!(result);
805 }
806
807 #[test]
808 fn test_buffer_fully_embedded_with_embeddings() {
809 let mut storage = setup_storage_with_chunks();
810 let embedder = FallbackEmbedder::new(DEFAULT_DIMENSIONS);
811
812 let result = buffer_fully_embedded(&storage, 1).unwrap();
814 assert!(!result);
815
816 embed_buffer_chunks(&mut storage, &embedder, 1).unwrap();
818
819 let result = buffer_fully_embedded(&storage, 1).unwrap();
821 assert!(result);
822 }
823
824 #[test]
825 fn test_hybrid_search_bm25_only() {
826 let storage = setup_storage_with_chunks();
827 let embedder = FallbackEmbedder::new(DEFAULT_DIMENSIONS);
828
829 let config = SearchConfig::new().with_semantic(false).with_bm25(true);
830
831 let results = hybrid_search(&storage, &embedder, "programming", &config).unwrap();
832 assert!(!results.is_empty());
834 assert!(results[0].bm25_score.is_some());
835 assert!(results[0].semantic_score.is_none());
836 }
837
838 #[test]
839 fn test_buffer_filter_applies_before_ranking() {
840 let mut storage = setup_storage();
841 let (target_buffer, target_chunk) = add_single_chunk_buffer(&mut storage, "needle");
842 let (_, distractor_chunk) =
843 add_single_chunk_buffer(&mut storage, "needle needle needle needle");
844 let embedder = FallbackEmbedder::new(2);
845 let query_embedding = embedder.embed("needle").unwrap();
846 storage
849 .store_embedding(
850 target_chunk,
851 &[-query_embedding[1], query_embedding[0]],
852 Some("query-test"),
853 )
854 .unwrap();
855 storage
856 .store_embedding(distractor_chunk, &query_embedding, Some("query-test"))
857 .unwrap();
858
859 let bm25_config = SearchConfig::new()
860 .with_top_k(1)
861 .with_semantic(false)
862 .with_bm25(true);
863 let bm25_results =
864 hybrid_search_in_buffer(&storage, &embedder, "needle", &bm25_config, target_buffer)
865 .unwrap();
866 assert_eq!(bm25_results[0].chunk_id, target_chunk);
867
868 let semantic_config = SearchConfig::new()
869 .with_top_k(1)
870 .with_threshold(-1.0)
871 .with_semantic(true)
872 .with_bm25(false);
873 let semantic_results = hybrid_search_in_buffer(
874 &storage,
875 &embedder,
876 "needle",
877 &semantic_config,
878 target_buffer,
879 )
880 .unwrap();
881 assert_eq!(semantic_results[0].chunk_id, target_chunk);
882 }
883
884 #[test]
885 fn test_hybrid_search_semantic_only() {
886 let mut storage = setup_storage_with_chunks();
887 let embedder = FallbackEmbedder::new(DEFAULT_DIMENSIONS);
888
889 embed_buffer_chunks(&mut storage, &embedder, 1).unwrap();
891
892 let config = SearchConfig::new()
893 .with_semantic(true)
894 .with_bm25(false)
895 .with_threshold(0.0); let results = hybrid_search(&storage, &embedder, "programming language", &config).unwrap();
898 assert!(!results.is_empty());
899 assert!(results[0].semantic_score.is_some());
900 assert!(results[0].bm25_score.is_none());
901 }
902
903 #[test]
904 fn test_hybrid_search_both() {
905 let mut storage = setup_storage_with_chunks();
906 let embedder = FallbackEmbedder::new(DEFAULT_DIMENSIONS);
907
908 embed_buffer_chunks(&mut storage, &embedder, 1).unwrap();
910
911 let config = SearchConfig::new()
912 .with_semantic(true)
913 .with_bm25(true)
914 .with_threshold(0.0); let results = hybrid_search(&storage, &embedder, "programming", &config).unwrap();
917 assert!(!results.is_empty());
918 }
919
920 #[test]
921 fn test_search_semantic() {
922 let mut storage = setup_storage_with_chunks();
923 let embedder = FallbackEmbedder::new(DEFAULT_DIMENSIONS);
924
925 embed_buffer_chunks(&mut storage, &embedder, 1).unwrap();
927
928 let results = search_semantic(&storage, &embedder, "test query", 10, 0.0).unwrap();
929 for result in &results {
931 assert!(result.semantic_score.is_some());
932 assert!(result.bm25_score.is_none());
933 }
934 }
935
936 #[test]
937 fn test_search_semantic_empty_embeddings() {
938 let storage = setup_storage_with_chunks();
939 let embedder = FallbackEmbedder::new(DEFAULT_DIMENSIONS);
940
941 let results = search_semantic(&storage, &embedder, "test query", 10, 0.5).unwrap();
943 assert!(results.is_empty());
944 }
945
946 #[test]
947 fn test_incremental_embed_new_chunks() {
948 let mut storage = setup_storage_with_chunks();
949 let embedder = FallbackEmbedder::new(DEFAULT_DIMENSIONS);
950
951 let result = embed_buffer_chunks_incremental(&mut storage, &embedder, 1, false).unwrap();
953 assert_eq!(result.embedded_count, 3);
954 assert_eq!(result.skipped_count, 0);
955 assert_eq!(result.replaced_count, 0);
956 assert_eq!(result.total_chunks, 3);
957 assert!(result.had_changes());
958
959 let result2 = embed_buffer_chunks_incremental(&mut storage, &embedder, 1, false).unwrap();
961 assert_eq!(result2.embedded_count, 0);
962 assert_eq!(result2.skipped_count, 3);
963 assert_eq!(result2.replaced_count, 0);
964 assert!(!result2.had_changes());
965 }
966
967 #[test]
968 fn test_incremental_embed_force_reembed() {
969 let mut storage = setup_storage_with_chunks();
970 let embedder = FallbackEmbedder::new(DEFAULT_DIMENSIONS);
971
972 embed_buffer_chunks_incremental(&mut storage, &embedder, 1, false).unwrap();
974
975 let result = embed_buffer_chunks_incremental(&mut storage, &embedder, 1, true).unwrap();
977 assert_eq!(result.skipped_count, 3);
980 assert!(!result.had_changes());
981 }
982
983 #[test]
984 fn test_parallel_semantic_search() {
985 let mut storage = setup_storage_with_chunks();
986 let embedder = FallbackEmbedder::new(DEFAULT_DIMENSIONS);
987
988 embed_buffer_chunks(&mut storage, &embedder, 1).unwrap();
990
991 let config = SearchConfig::new().with_top_k(10).with_threshold(0.0);
993
994 let results = semantic_search(&storage, &embedder, "test query", &config, None).unwrap();
995
996 assert!(!results.is_empty());
998
999 for window in results.windows(2) {
1001 assert!(
1002 window[0].1 >= window[1].1,
1003 "Results should be sorted by descending similarity: {} >= {}",
1004 window[0].1,
1005 window[1].1,
1006 );
1007 }
1008
1009 let strict_config = SearchConfig::new().with_top_k(10).with_threshold(0.99);
1011 let strict_results =
1012 semantic_search(&storage, &embedder, "test query", &strict_config, None).unwrap();
1013
1014 assert!(
1016 strict_results.len() <= results.len(),
1017 "Strict threshold should not return more results than lenient threshold",
1018 );
1019 }
1020
1021 #[test]
1022 fn test_incremental_embed_result_completion() {
1023 let result = IncrementalEmbedResult {
1024 embedded_count: 2,
1025 skipped_count: 3,
1026 replaced_count: 0,
1027 total_chunks: 5,
1028 model_name: "test".to_string(),
1029 };
1030 assert!(result.had_changes());
1031 assert!((result.completion_percentage() - 100.0).abs() < f64::EPSILON);
1032 }
1033
1034 #[test]
1035 fn test_completion_percentage_zero_chunks() {
1036 let result = IncrementalEmbedResult {
1037 embedded_count: 0,
1038 skipped_count: 0,
1039 replaced_count: 0,
1040 total_chunks: 0,
1041 model_name: "test".to_string(),
1042 };
1043 assert!((result.completion_percentage() - 100.0).abs() < f64::EPSILON);
1045 assert!(!result.had_changes());
1046 }
1047
1048 #[test]
1049 fn test_completion_percentage_partial() {
1050 let result = IncrementalEmbedResult {
1051 embedded_count: 1,
1052 skipped_count: 1,
1053 replaced_count: 0,
1054 total_chunks: 4,
1055 model_name: "test".to_string(),
1056 };
1057 assert!((result.completion_percentage() - 50.0).abs() < f64::EPSILON);
1058 assert!(result.had_changes());
1059 }
1060
1061 #[test]
1062 fn test_had_changes_replaced_only() {
1063 let result = IncrementalEmbedResult {
1064 embedded_count: 0,
1065 skipped_count: 2,
1066 replaced_count: 1,
1067 total_chunks: 3,
1068 model_name: "test".to_string(),
1069 };
1070 assert!(result.had_changes());
1071 }
1072
1073 #[test]
1074 fn test_check_model_mismatch_no_embeddings() {
1075 let mut storage = setup_storage();
1076 let buffer = crate::core::Buffer::from_named("x.txt".to_string(), "hi".to_string());
1077 let buffer_id = storage.add_buffer(&buffer).unwrap();
1078
1079 let result = check_model_mismatch(&storage, buffer_id, "model-a").unwrap();
1081 assert!(result.is_none());
1082 }
1083
1084 #[test]
1085 fn test_check_model_mismatch_same_model() {
1086 let mut storage = setup_storage_with_chunks();
1087 let embedder = FallbackEmbedder::new(DEFAULT_DIMENSIONS);
1088
1089 embed_buffer_chunks(&mut storage, &embedder, 1).unwrap();
1090
1091 let result = check_model_mismatch(&storage, 1, embedder.model_name()).unwrap();
1093 assert!(result.is_none());
1094 }
1095
1096 #[test]
1097 fn test_check_model_mismatch_different_model() {
1098 let mut storage = setup_storage_with_chunks();
1099 let embedder = FallbackEmbedder::new(DEFAULT_DIMENSIONS);
1100
1101 embed_buffer_chunks(&mut storage, &embedder, 1).unwrap();
1102
1103 let result = check_model_mismatch(&storage, 1, "some-other-model").unwrap();
1105 assert!(result.is_some());
1106 assert_eq!(result.unwrap(), embedder.model_name());
1107 }
1108
1109 #[test]
1110 fn test_get_embedding_model_info_no_embeddings() {
1111 let mut storage = setup_storage();
1112 let buffer = crate::core::Buffer::from_named("x.txt".to_string(), "hi".to_string());
1113 let buffer_id = storage.add_buffer(&buffer).unwrap();
1114
1115 let info = get_embedding_model_info(&storage, buffer_id).unwrap();
1116 assert_eq!(info.total_embeddings, 0);
1117 assert!(info.models.is_empty());
1118 assert!(!info.has_mixed_models);
1119 }
1120
1121 #[test]
1122 fn test_get_embedding_model_info_single_model() {
1123 let mut storage = setup_storage_with_chunks();
1124 let embedder = FallbackEmbedder::new(DEFAULT_DIMENSIONS);
1125
1126 embed_buffer_chunks(&mut storage, &embedder, 1).unwrap();
1127
1128 let info = get_embedding_model_info(&storage, 1).unwrap();
1129 assert_eq!(info.total_embeddings, 3);
1130 assert!(!info.has_mixed_models);
1131 }
1132
1133 #[test]
1134 fn test_populate_previews_short_content() {
1135 let storage = setup_storage_with_chunks();
1136
1137 let results_raw = search_bm25(&storage, "fox", 1).unwrap();
1139 assert!(!results_raw.is_empty());
1140
1141 let mut results = results_raw;
1142 populate_previews(&storage, &mut results, 200).unwrap();
1143
1144 let preview = results[0].content_preview.as_ref().unwrap();
1146 assert!(!preview.ends_with("..."));
1147 assert!(!preview.is_empty());
1148 }
1149
1150 #[test]
1151 fn test_populate_previews_truncates_long_content() {
1152 let mut storage = setup_storage();
1153 let buffer = crate::core::Buffer::from_named("long.txt".to_string(), String::new());
1154 let buffer_id = storage.add_buffer(&buffer).unwrap();
1155
1156 let long_content = "word ".repeat(100); let chunk =
1159 crate::core::Chunk::new(buffer_id, long_content.clone(), 0..long_content.len(), 0);
1160 storage.add_chunks(buffer_id, &[chunk]).unwrap();
1161
1162 let chunks = storage.get_chunks(buffer_id).unwrap();
1163 let chunk_id = chunks[0].id.unwrap();
1164
1165 let mut results = vec![SearchResult {
1167 chunk_id,
1168 buffer_id,
1169 index: 0,
1170 score: 1.0,
1171 semantic_score: None,
1172 bm25_score: None,
1173 content_preview: None,
1174 }];
1175
1176 populate_previews(&storage, &mut results, 20).unwrap();
1177
1178 let preview = results[0].content_preview.as_ref().unwrap();
1179 assert!(
1180 preview.ends_with("..."),
1181 "Expected ellipsis, got: {preview}"
1182 );
1183 assert!(preview.len() <= 23);
1185 }
1186
1187 #[test]
1188 fn test_populate_previews_utf8_boundary() {
1189 let mut storage = setup_storage();
1190 let buffer = crate::core::Buffer::from_named("utf8.txt".to_string(), String::new());
1191 let buffer_id = storage.add_buffer(&buffer).unwrap();
1192
1193 let content = "hello \u{65E5}\u{672C}\u{8A9E}"; let chunk = crate::core::Chunk::new(buffer_id, content.to_string(), 0..content.len(), 0);
1197 storage.add_chunks(buffer_id, &[chunk]).unwrap();
1198
1199 let chunks = storage.get_chunks(buffer_id).unwrap();
1200 let chunk_id = chunks[0].id.unwrap();
1201
1202 let mut results = vec![SearchResult {
1203 chunk_id,
1204 buffer_id,
1205 index: 0,
1206 score: 1.0,
1207 semantic_score: None,
1208 bm25_score: None,
1209 content_preview: None,
1210 }];
1211
1212 populate_previews(&storage, &mut results, 7).unwrap();
1215
1216 let preview = results[0].content_preview.as_ref().unwrap();
1217 assert!(preview.ends_with("..."), "Expected ellipsis in: {preview}");
1219 let body = preview.trim_end_matches("...");
1222 assert_eq!(
1223 body, "hello ",
1224 "Expected truncation at char boundary, got: {body:?}"
1225 );
1226 }
1227}