Skip to main content

rlm_rs/search/
mod.rs

1//! Hybrid search with semantic and lexical retrieval.
2//!
3//! Combines vector similarity search with FTS5 BM25 using Reciprocal Rank Fusion (RRF).
4//!
5//! ## Features
6//!
7//! - **Semantic Search**: Vector similarity using embeddings
8//! - **BM25 Search**: Full-text search using `SQLite` `FTS5`
9//! - **Hybrid Search**: Combines both using Reciprocal Rank Fusion
10//! - **HNSW Index**: Optional scalable approximate nearest neighbor search (requires `usearch-hnsw` feature)
11
12pub 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
22/// Default similarity threshold for semantic search.
23pub const DEFAULT_SIMILARITY_THRESHOLD: f32 = 0.3;
24
25/// Default number of results to return.
26pub const DEFAULT_TOP_K: usize = 10;
27
28/// Search result with chunk ID and combined score.
29#[derive(Debug, Clone)]
30pub struct SearchResult {
31    /// Chunk ID.
32    pub chunk_id: i64,
33    /// Buffer ID this chunk belongs to.
34    pub buffer_id: i64,
35    /// Sequential index within the buffer (0-based, for temporal ordering).
36    pub index: usize,
37    /// Combined RRF score (higher is better).
38    pub score: f64,
39    /// Semantic similarity score (if available).
40    pub semantic_score: Option<f32>,
41    /// BM25 score (if available).
42    pub bm25_score: Option<f64>,
43    /// Content preview (first N characters, if requested).
44    pub content_preview: Option<String>,
45}
46
47/// Configuration for hybrid search.
48#[derive(Debug, Clone)]
49pub struct SearchConfig {
50    /// Maximum number of results to return.
51    pub top_k: usize,
52    /// Minimum similarity threshold for semantic results.
53    pub similarity_threshold: f32,
54    /// RRF k parameter (default 60).
55    pub rrf_k: u32,
56    /// Whether to include semantic search.
57    pub use_semantic: bool,
58    /// Whether to include BM25 search.
59    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
74/// Default preview length in characters.
75pub const DEFAULT_PREVIEW_LEN: usize = 150;
76
77impl SearchResult {
78    /// Creates a new search result, looking up chunk metadata from storage.
79    ///
80    /// Returns `None` if the chunk cannot be found.
81    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
104/// Populates content previews for search results.
105///
106/// # Arguments
107///
108/// * `storage` - The storage backend.
109/// * `results` - Search results to populate.
110/// * `preview_len` - Maximum preview length in bytes.
111///
112/// # Errors
113///
114/// Returns an error if chunk retrieval fails.
115pub 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    // Batch-fetch all chunks in a single query instead of N individual lookups.
125    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                // Find a valid UTF-8 boundary
135                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    /// Creates a new search config with default values.
150    #[must_use]
151    pub fn new() -> Self {
152        Self::default()
153    }
154
155    /// Sets the top-k limit.
156    #[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    /// Sets the similarity threshold.
163    #[must_use]
164    pub const fn with_threshold(mut self, threshold: f32) -> Self {
165        self.similarity_threshold = threshold;
166        self
167    }
168
169    /// Sets the RRF k parameter.
170    #[must_use]
171    pub const fn with_rrf_k(mut self, k: u32) -> Self {
172        self.rrf_k = k;
173        self
174    }
175
176    /// Enables or disables semantic search.
177    #[must_use]
178    pub const fn with_semantic(mut self, enabled: bool) -> Self {
179        self.use_semantic = enabled;
180        self
181    }
182
183    /// Enables or disables BM25 search.
184    #[must_use]
185    pub const fn with_bm25(mut self, enabled: bool) -> Self {
186        self.use_bm25 = enabled;
187        self
188    }
189}
190
191/// Performs hybrid search combining semantic and BM25 results.
192///
193/// # Arguments
194///
195/// * `storage` - The storage backend.
196/// * `embedder` - The embedding generator.
197/// * `query` - The search query text.
198/// * `config` - Search configuration.
199///
200/// # Errors
201///
202/// Returns an error if search operations fail.
203pub 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
212/// Performs hybrid search restricted to a single buffer before ranking.
213///
214/// # Arguments
215///
216/// * `storage` - The storage backend.
217/// * `embedder` - The embedding generator.
218/// * `query` - The search query text.
219/// * `config` - Search configuration.
220/// * `buffer_id` - Buffer ID to restrict before semantic and BM25 ranking.
221///
222/// # Errors
223///
224/// Returns an error if search operations fail.
225pub 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    // Semantic search
246    if config.use_semantic {
247        semantic_results = semantic_search(storage, embedder, query, config, buffer_id)?;
248    }
249
250    // BM25 search
251    if config.use_bm25 {
252        bm25_results = storage.search_fts_in_buffer(query, config.top_k * 2, buffer_id)?;
253    }
254
255    // If only one type of search is enabled, return those results directly
256    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    // Combine using RRF
277    let rrf_config = RrfConfig::new(config.rrf_k);
278
279    // Convert to ranked lists (already sorted by score descending)
280    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    // Build result with original scores
286    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
306/// Performs semantic similarity search.
307///
308/// Uses cosine similarity between query embedding and stored chunk embeddings.
309fn 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    // Generate query embedding
319    let query_embedding = embedder.embed(query)?;
320
321    // Get only the embeddings that can participate in this search.
322    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    // Calculate similarities in parallel (rayon data parallelism)
332    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    // Sort by similarity descending
342    similarities.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
343
344    // Limit results
345    similarities.truncate(config.top_k * 2);
346
347    Ok(similarities)
348}
349
350/// Performs semantic-only search.
351///
352/// # Arguments
353///
354/// * `storage` - The storage backend.
355/// * `embedder` - The embedding generator.
356/// * `query` - The search query text.
357/// * `top_k` - Maximum number of results.
358/// * `threshold` - Minimum similarity threshold.
359///
360/// # Errors
361///
362/// Returns an error if search fails.
363pub 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
379/// Performs BM25-only search.
380///
381/// # Arguments
382///
383/// * `storage` - The storage backend.
384/// * `query` - The search query text.
385/// * `top_k` - Maximum number of results.
386///
387/// # Errors
388///
389/// Returns an error if search fails.
390pub 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
405/// Generates and stores embeddings for all chunks in a buffer.
406///
407/// # Arguments
408///
409/// * `storage` - The storage backend (mutable for storing embeddings).
410/// * `embedder` - The embedding generator.
411/// * `buffer_id` - The buffer ID to process.
412///
413/// # Returns
414///
415/// The number of chunks embedded.
416///
417/// # Errors
418///
419/// Returns an error if embedding generation or storage fails.
420pub 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    // Collect chunk texts for batch embedding
432    let texts: Vec<&str> = chunks.iter().map(|c| c.content.as_str()).collect();
433
434    // Generate embeddings in batch
435    let embeddings = embedder.embed_batch(&texts)?;
436
437    // Prepare batch for storage
438    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    // Store embeddings with model name for version tracking
447    storage.store_embeddings_batch(&batch, Some(embedder.model_name()))?;
448
449    Ok(count)
450}
451
452/// Checks if a buffer has all chunks embedded.
453///
454/// Uses a single SQL query (`NOT EXISTS`) instead of per-chunk lookups, reducing database
455/// round-trips from O(n) to O(1) for a buffer with n chunks.
456///
457/// # Errors
458///
459/// Returns an error if the check fails.
460pub fn buffer_fully_embedded(storage: &SqliteStorage, buffer_id: i64) -> Result<bool> {
461    storage.all_chunks_have_embeddings(buffer_id)
462}
463
464/// Checks if existing embeddings were created with a different model.
465///
466/// Returns `Some(existing_model)` if there's a model mismatch, `None` otherwise.
467///
468/// # Errors
469///
470/// Returns an error if the query fails.
471pub 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 no embeddings exist, no mismatch
479    if models.is_empty() {
480        return Ok(None);
481    }
482
483    // Check if any existing model differs from the current one
484    for model in models {
485        if model != current_model {
486            return Ok(Some(model));
487        }
488    }
489
490    Ok(None)
491}
492
493/// Information about embedding model versions for a buffer.
494#[derive(Debug, Clone)]
495pub struct EmbeddingModelInfo {
496    /// Model names and their embedding counts.
497    pub models: Vec<(Option<String>, i64)>,
498    /// Total number of embeddings.
499    pub total_embeddings: i64,
500    /// Whether there are mixed model versions.
501    pub has_mixed_models: bool,
502}
503
504/// Gets embedding model information for a buffer.
505///
506/// # Errors
507///
508/// Returns an error if the query fails.
509pub 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/// Result of an incremental embedding operation.
527#[derive(Debug, Clone)]
528pub struct IncrementalEmbedResult {
529    /// Number of new embeddings created.
530    pub embedded_count: usize,
531    /// Number of chunks that were skipped (already embedded with correct model).
532    pub skipped_count: usize,
533    /// Number of embeddings that were replaced (different model).
534    pub replaced_count: usize,
535    /// Total chunks in the buffer.
536    pub total_chunks: usize,
537    /// Model name used for embedding.
538    pub model_name: String,
539}
540
541impl IncrementalEmbedResult {
542    /// Returns true if any embeddings were created or updated.
543    #[must_use]
544    pub const fn had_changes(&self) -> bool {
545        self.embedded_count > 0 || self.replaced_count > 0
546    }
547
548    /// Returns the percentage of chunks now embedded.
549    #[must_use]
550    #[allow(clippy::cast_precision_loss)] // Acceptable for percentage calculation
551    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
561/// Incrementally embeds chunks in a buffer.
562///
563/// Only embeds chunks that:
564/// - Have no embedding, OR
565/// - Have an embedding from a different model (if `force_reembed` is true)
566///
567/// This is more efficient than `embed_buffer_chunks` for large buffers
568/// where only a few chunks have changed.
569///
570/// # Arguments
571///
572/// * `storage` - The storage backend.
573/// * `embedder` - The embedder to use.
574/// * `buffer_id` - The buffer to embed.
575/// * `force_reembed` - If true, re-embeds chunks with different models.
576///
577/// # Returns
578///
579/// An `IncrementalEmbedResult` with statistics about what was done.
580///
581/// # Errors
582///
583/// Returns an error if embedding generation or storage fails.
584pub 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    // Determine which chunks need embedding
595    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    // Load the chunks we need to embed
614    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    // Count how many are replacements (had embeddings before)
621    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    // Generate embeddings
631    let texts: Vec<&str> = chunks_to_embed.iter().map(|c| c.content.as_str()).collect();
632    let embeddings = embedder.embed_batch(&texts)?;
633
634    // Store embeddings
635    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        // Create a buffer
673        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        // Create chunks with different content
680        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        // Search for "fox" - should find the first chunk
756        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        // Search for something not in the content
767        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        // Embed chunks for buffer 1
777        let count = embed_buffer_chunks(&mut storage, &embedder, 1).unwrap();
778        assert_eq!(count, 3); // We created 3 chunks
779    }
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        // Create buffer with no chunks
787        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        // Create buffer with no chunks
799        let buffer = Buffer::from_named("empty.txt".to_string(), String::new());
800        let buffer_id = storage.add_buffer(&buffer).unwrap();
801
802        // Empty buffer should be "fully embedded"
803        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        // Before embedding
813        let result = buffer_fully_embedded(&storage, 1).unwrap();
814        assert!(!result);
815
816        // Embed all chunks
817        embed_buffer_chunks(&mut storage, &embedder, 1).unwrap();
818
819        // After embedding
820        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        // Should find "Rust is a systems programming language"
833        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        // The target embedding is orthogonal to the query while the distractor
847        // matches, so this fails if buffer filtering happens after ranking.
848        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        // First embed the chunks
890        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); // Low threshold for fallback embedder
896
897        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        // First embed the chunks
909        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); // Low threshold for fallback embedder
915
916        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        // First embed the chunks
926        embed_buffer_chunks(&mut storage, &embedder, 1).unwrap();
927
928        let results = search_semantic(&storage, &embedder, "test query", 10, 0.0).unwrap();
929        // Should return results with semantic scores only
930        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        // Don't embed chunks - search should return empty
942        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        // First incremental embed - should embed all 3 chunks
952        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        // Second incremental embed - should skip all (already embedded)
960        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        // First embed normally
973        embed_buffer_chunks_incremental(&mut storage, &embedder, 1, false).unwrap();
974
975        // Force re-embed - should replace all 3
976        let result = embed_buffer_chunks_incremental(&mut storage, &embedder, 1, true).unwrap();
977        // All chunks already have correct model, so no changes needed even with force
978        // (force only affects different-model embeddings)
979        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 all chunks so semantic_search has data to work with
989        embed_buffer_chunks(&mut storage, &embedder, 1).unwrap();
990
991        // Use a zero threshold to capture all results from the fallback embedder
992        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        // Should return results (we embedded 3 chunks)
997        assert!(!results.is_empty());
998
999        // Verify results are sorted by descending similarity
1000        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        // Verify threshold filtering: with a very high threshold, results should be excluded
1010        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        // Strict threshold should return fewer (or no) results
1015        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        // Zero-chunk buffer is considered 100% complete
1044        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        // No embeddings → no mismatch
1080        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        // Same model → no mismatch
1092        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        // Ask with a different model name → returns the existing model
1104        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        // Get a result with a known chunk_id
1138        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        // Content is shorter than preview_len → no ellipsis
1145        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        // Create a chunk with long content
1157        let long_content = "word ".repeat(100); // 500 chars
1158        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        // Build a synthetic SearchResult pointing at this chunk
1166        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        // Should be no longer than preview_len + "..."
1184        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        // Content where a naive byte truncation would split a multi-byte char
1194        // '日' is 3 bytes; place it so it straddles the preview boundary
1195        let content = "hello \u{65E5}\u{672C}\u{8A9E}"; // "hello 日本語"
1196        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        // preview_len=7 bytes falls inside '日' (which occupies bytes 6-8),
1213        // so the implementation must back up to the nearest valid UTF-8 boundary.
1214        populate_previews(&storage, &mut results, 7).unwrap();
1215
1216        let preview = results[0].content_preview.as_ref().unwrap();
1217        // Must end with ellipsis because the content is longer than preview_len
1218        assert!(preview.ends_with("..."), "Expected ellipsis in: {preview}");
1219        // The prefix before "..." must be exactly "hello " — truncated at the
1220        // char boundary before '日', not mid-way through its bytes.
1221        let body = preview.trim_end_matches("...");
1222        assert_eq!(
1223            body, "hello ",
1224            "Expected truncation at char boundary, got: {body:?}"
1225        );
1226    }
1227}