Skip to main content

ipfrs_semantic/
search_pipeline.rs

1//! # Semantic Search Pipeline
2//!
3//! An end-to-end semantic search pipeline combining:
4//! - Vector similarity search (cosine similarity)
5//! - BM25 keyword matching
6//! - Result fusion (RRF, LinearCombination, CombSUM)
7//! - Re-ranking and metadata filtering
8//!
9//! ## Example
10//!
11//! ```rust
12//! use ipfrs_semantic::search_pipeline::{
13//!     SemanticSearchPipeline, SpPipelineConfig, SpSearchQuery,
14//!     SearchDocument, FusionMethod,
15//! };
16//! use std::collections::HashMap;
17//!
18//! let config = SpPipelineConfig::default();
19//! let mut pipeline = SemanticSearchPipeline::new(config);
20//!
21//! let doc = SearchDocument {
22//!     id: "doc1".to_string(),
23//!     content: "rust programming language systems".to_string(),
24//!     embedding: vec![0.1, 0.2, 0.3, 0.4],
25//!     metadata: HashMap::new(),
26//! };
27//! pipeline.add_document(doc);
28//!
29//! let query = SpSearchQuery {
30//!     text: "rust programming".to_string(),
31//!     embedding: Some(vec![0.1, 0.2, 0.3, 0.4]),
32//!     filters: HashMap::new(),
33//!     top_k: 10,
34//!     min_score: 0.0,
35//! };
36//!
37//! let result = pipeline.search(&query);
38//! assert!(!result.hits.is_empty());
39//! ```
40
41use std::collections::HashMap;
42use std::time::Instant;
43
44// ---------------------------------------------------------------------------
45// Public types
46// ---------------------------------------------------------------------------
47
48/// A document stored in the pipeline corpus.
49#[derive(Debug, Clone)]
50pub struct SearchDocument {
51    pub id: String,
52    pub content: String,
53    pub embedding: Vec<f64>,
54    pub metadata: HashMap<String, String>,
55}
56
57/// A search query issued against the pipeline.
58///
59/// Named `SpSearchQuery` to avoid collision with `multimodal_search::SearchQuery`.
60#[derive(Debug, Clone)]
61pub struct SpSearchQuery {
62    pub text: String,
63    pub embedding: Option<Vec<f64>>,
64    pub filters: HashMap<String, String>,
65    pub top_k: usize,
66    pub min_score: f64,
67}
68
69/// A single result hit returned by the pipeline.
70#[derive(Debug, Clone)]
71pub struct SearchHit {
72    pub doc_id: String,
73    pub score: f64,
74    pub vector_score: f64,
75    pub bm25_score: f64,
76    pub rank: usize,
77}
78
79/// The complete result of a pipeline search.
80#[derive(Debug, Clone)]
81pub struct SearchPipelineResult {
82    pub query_text: String,
83    pub hits: Vec<SearchHit>,
84    pub total_candidates: usize,
85    pub search_time_ms: u64,
86}
87
88/// Method used to fuse vector and BM25 ranked lists.
89#[derive(Debug, Clone)]
90pub enum FusionMethod {
91    /// Reciprocal Rank Fusion: score = Σ 1 / (k + rank).
92    /// `k` defaults to 60.0 and controls rank sensitivity.
93    ReciprocalRankFusion { k: f64 },
94    /// Weighted sum of min-max-normalised scores.
95    LinearCombination {
96        vector_weight: f64,
97        bm25_weight: f64,
98    },
99    /// Sum of normalised scores with equal weights (0.5 / 0.5).
100    CombSUM,
101}
102
103impl Default for FusionMethod {
104    fn default() -> Self {
105        FusionMethod::ReciprocalRankFusion { k: 60.0 }
106    }
107}
108
109/// Configuration for the [`SemanticSearchPipeline`].
110///
111/// Named `SpPipelineConfig` to avoid collision with `query_pipeline::PipelineConfig`.
112#[derive(Debug, Clone)]
113pub struct SpPipelineConfig {
114    pub fusion_method: FusionMethod,
115    /// Number of top-scoring candidates returned by vector search.
116    pub vector_candidates: usize,
117    /// Number of top-scoring candidates returned by BM25.
118    pub bm25_candidates: usize,
119    /// Re-rank this many fused candidates before applying `min_score` / `top_k`.
120    pub rerank_top_n: usize,
121}
122
123impl Default for SpPipelineConfig {
124    fn default() -> Self {
125        SpPipelineConfig {
126            fusion_method: FusionMethod::ReciprocalRankFusion { k: 60.0 },
127            vector_candidates: 100,
128            bm25_candidates: 100,
129            rerank_top_n: 20,
130        }
131    }
132}
133
134/// Runtime statistics for the pipeline.
135///
136/// Named `SpPipelineStats` to avoid collision with `embedding_pipeline::PipelineStats`.
137#[derive(Debug, Clone)]
138pub struct SpPipelineStats {
139    pub doc_count: usize,
140    pub vocabulary_size: usize,
141    pub avg_doc_length: f64,
142    pub total_searches: u64,
143    pub avg_hits_per_search: f64,
144}
145
146// ---------------------------------------------------------------------------
147// Internal BM25 per-document data
148// ---------------------------------------------------------------------------
149
150/// BM25 index data for a single document: token list and term-frequency map.
151#[derive(Debug, Clone)]
152struct DocBm25 {
153    tokens: Vec<String>,
154    tf: HashMap<String, f64>,
155}
156
157impl DocBm25 {
158    fn from_content(content: &str) -> Self {
159        let tokens: Vec<String> = tokenize(content);
160        let mut tf: HashMap<String, f64> = HashMap::new();
161        for t in &tokens {
162            *tf.entry(t.clone()).or_insert(0.0) += 1.0;
163        }
164        DocBm25 { tokens, tf }
165    }
166}
167
168// ---------------------------------------------------------------------------
169// Helpers
170// ---------------------------------------------------------------------------
171
172/// Lowercase whitespace tokeniser.
173fn tokenize(text: &str) -> Vec<String> {
174    text.split_whitespace().map(|w| w.to_lowercase()).collect()
175}
176
177/// Min-max normalise a scored list so that the maximum value becomes 1.0.
178/// Returns all zeros if the maximum is zero.
179fn normalise(scores: &[(String, f64)]) -> Vec<(String, f64)> {
180    let max = scores.iter().map(|(_, s)| *s).fold(0.0_f64, f64::max);
181    if max == 0.0 {
182        return scores.iter().map(|(id, _)| (id.clone(), 0.0)).collect();
183    }
184    scores.iter().map(|(id, s)| (id.clone(), s / max)).collect()
185}
186
187// ---------------------------------------------------------------------------
188// SemanticSearchPipeline
189// ---------------------------------------------------------------------------
190
191/// End-to-end semantic search pipeline.
192///
193/// Combines cosine-similarity vector search with BM25 keyword retrieval and
194/// fuses the two ranked lists via a configurable [`FusionMethod`].  Results
195/// are optionally filtered by document metadata and a minimum score threshold.
196#[derive(Debug)]
197pub struct SemanticSearchPipeline {
198    pub config: SpPipelineConfig,
199    /// Document corpus indexed by doc id.
200    pub documents: HashMap<String, SearchDocument>,
201    /// Pre-computed BM25 data (tokens, TF) indexed by doc id.
202    bm25_data: HashMap<String, DocBm25>,
203    /// IDF table: term → idf value.
204    pub idf: HashMap<String, f64>,
205    /// Document-frequency table: term → number of documents containing it.
206    df: HashMap<String, usize>,
207    /// Total number of documents (kept in sync with `documents.len()`).
208    pub total_docs: usize,
209    /// Cumulative search counter.
210    total_searches: u64,
211    /// Cumulative hit count (used for `avg_hits_per_search`).
212    total_hits: u64,
213}
214
215impl SemanticSearchPipeline {
216    // -----------------------------------------------------------------------
217    // Construction
218    // -----------------------------------------------------------------------
219
220    /// Create a new pipeline with the given configuration.
221    pub fn new(config: SpPipelineConfig) -> Self {
222        SemanticSearchPipeline {
223            config,
224            documents: HashMap::new(),
225            bm25_data: HashMap::new(),
226            idf: HashMap::new(),
227            df: HashMap::new(),
228            total_docs: 0,
229            total_searches: 0,
230            total_hits: 0,
231        }
232    }
233
234    // -----------------------------------------------------------------------
235    // Corpus management
236    // -----------------------------------------------------------------------
237
238    /// Add a document to the corpus and update IDF for all terms in its content.
239    pub fn add_document(&mut self, doc: SearchDocument) {
240        let bm25 = DocBm25::from_content(&doc.content);
241
242        // Update document-frequency counts for every unique term in the doc.
243        for term in bm25.tf.keys() {
244            *self.df.entry(term.clone()).or_insert(0) += 1;
245        }
246
247        let doc_id = doc.id.clone();
248        self.documents.insert(doc_id.clone(), doc);
249        self.bm25_data.insert(doc_id, bm25);
250        self.total_docs = self.documents.len();
251        self.recompute_idf();
252    }
253
254    /// Remove a document from the corpus by ID, updating the BM25 index and IDF.
255    /// Returns `true` if the document was present and was removed.
256    pub fn remove_document(&mut self, doc_id: &str) -> bool {
257        if let Some(bm25) = self.bm25_data.remove(doc_id) {
258            self.documents.remove(doc_id);
259            // Decrease DF for each term present in the removed document.
260            for term in bm25.tf.keys() {
261                if let Some(count) = self.df.get_mut(term.as_str()) {
262                    if *count <= 1 {
263                        self.df.remove(term.as_str());
264                    } else {
265                        *count -= 1;
266                    }
267                }
268            }
269            self.total_docs = self.documents.len();
270            self.recompute_idf();
271            true
272        } else {
273            false
274        }
275    }
276
277    /// Number of documents currently indexed.
278    pub fn doc_count(&self) -> usize {
279        self.documents.len()
280    }
281
282    /// Return runtime statistics for the pipeline.
283    pub fn stats(&self) -> SpPipelineStats {
284        let avg_doc_length = if self.bm25_data.is_empty() {
285            0.0
286        } else {
287            let total_tokens: usize = self.bm25_data.values().map(|b| b.tokens.len()).sum();
288            total_tokens as f64 / self.bm25_data.len() as f64
289        };
290
291        let avg_hits_per_search = if self.total_searches == 0 {
292            0.0
293        } else {
294            self.total_hits as f64 / self.total_searches as f64
295        };
296
297        SpPipelineStats {
298            doc_count: self.total_docs,
299            vocabulary_size: self.idf.len(),
300            avg_doc_length,
301            total_searches: self.total_searches,
302            avg_hits_per_search,
303        }
304    }
305
306    // -----------------------------------------------------------------------
307    // Full pipeline search
308    // -----------------------------------------------------------------------
309
310    /// Run the full pipeline for a query and return ranked, filtered results.
311    ///
312    /// Steps:
313    /// 1. Vector search (if `query.embedding` is `Some`)
314    /// 2. BM25 keyword search
315    /// 3. Fuse the two ranked lists
316    /// 4. Take top `rerank_top_n`, filter by `min_score` and metadata
317    /// 5. Limit to `top_k`, assign ranks 1..n, return
318    pub fn search(&mut self, query: &SpSearchQuery) -> SearchPipelineResult {
319        let start = Instant::now();
320
321        // 1. Vector search (optional).
322        let vector_results: Vec<(String, f64)> = query
323            .embedding
324            .as_deref()
325            .map(|emb| self.vector_search(emb, self.config.vector_candidates))
326            .unwrap_or_default();
327
328        // 2. BM25 keyword search.
329        let bm25_results = self.bm25_search(&query.text, self.config.bm25_candidates);
330
331        // 3. Count distinct candidates across both lists.
332        let mut candidate_ids: std::collections::HashSet<&str> = std::collections::HashSet::new();
333        for (id, _) in &vector_results {
334            candidate_ids.insert(id.as_str());
335        }
336        for (id, _) in &bm25_results {
337            candidate_ids.insert(id.as_str());
338        }
339        let total_candidates = candidate_ids.len();
340
341        // Build quick lookup maps for individual scores (populate SearchHit fields).
342        let vector_map: HashMap<&str, f64> = vector_results
343            .iter()
344            .map(|(id, s)| (id.as_str(), *s))
345            .collect();
346        let bm25_map: HashMap<&str, f64> = bm25_results
347            .iter()
348            .map(|(id, s)| (id.as_str(), *s))
349            .collect();
350
351        // 4. Fuse the two ranked lists.
352        let mut fused = self.fuse(&vector_results, &bm25_results);
353        fused.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
354
355        let rerank_top_n = self.config.rerank_top_n;
356        let min_score = query.min_score;
357        let top_k = query.top_k;
358
359        // 5. Apply rerank_top_n, min_score filter, metadata filter, top_k cap.
360        let mut hits: Vec<SearchHit> = fused
361            .into_iter()
362            .take(rerank_top_n)
363            .filter(|(_, score)| *score >= min_score)
364            .filter(|(id, _)| self.matches_filters(id, &query.filters))
365            .take(top_k)
366            .map(|(id, score)| {
367                let vs = vector_map.get(id.as_str()).copied().unwrap_or(0.0);
368                let bs = bm25_map.get(id.as_str()).copied().unwrap_or(0.0);
369                SearchHit {
370                    doc_id: id,
371                    score,
372                    vector_score: vs,
373                    bm25_score: bs,
374                    rank: 0, // assigned below
375                }
376            })
377            .collect();
378
379        // 6. Final sort and rank assignment (1-based).
380        hits.sort_by(|a, b| {
381            b.score
382                .partial_cmp(&a.score)
383                .unwrap_or(std::cmp::Ordering::Equal)
384        });
385        for (rank, hit) in hits.iter_mut().enumerate() {
386            hit.rank = rank + 1;
387        }
388
389        // Update internal counters.
390        self.total_searches += 1;
391        self.total_hits += hits.len() as u64;
392
393        let search_time_ms = start.elapsed().as_millis() as u64;
394
395        SearchPipelineResult {
396            query_text: query.text.clone(),
397            hits,
398            total_candidates,
399            search_time_ms,
400        }
401    }
402
403    // -----------------------------------------------------------------------
404    // Vector search
405    // -----------------------------------------------------------------------
406
407    /// Compute cosine similarity against every document and return the top-k
408    /// `(doc_id, similarity)` pairs, sorted descending.
409    pub fn vector_search(&self, embedding: &[f64], top_k: usize) -> Vec<(String, f64)> {
410        let mut scores: Vec<(String, f64)> = self
411            .documents
412            .iter()
413            .map(|(id, doc)| {
414                let sim = Self::cosine_similarity(embedding, &doc.embedding);
415                (id.clone(), sim)
416            })
417            .collect();
418
419        scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
420        scores.truncate(top_k);
421        scores
422    }
423
424    // -----------------------------------------------------------------------
425    // BM25 search
426    // -----------------------------------------------------------------------
427
428    /// Compute BM25 scores for `query_text` against every document.
429    ///
430    /// Parameters: k₁ = 1.5, b = 0.75 (standard Okapi BM25 defaults).
431    /// Returns the top-k `(doc_id, bm25_score)` pairs, sorted descending.
432    pub fn bm25_search(&self, query_text: &str, top_k: usize) -> Vec<(String, f64)> {
433        let query_tokens = tokenize(query_text);
434        if query_tokens.is_empty() || self.total_docs == 0 {
435            return Vec::new();
436        }
437
438        let avgdl = self.average_doc_length();
439        let k1 = 1.5_f64;
440        let b = 0.75_f64;
441
442        let mut scores: Vec<(String, f64)> = self
443            .bm25_data
444            .iter()
445            .filter_map(|(doc_id, bm25)| {
446                let dl = bm25.tokens.len() as f64;
447                let score: f64 = query_tokens
448                    .iter()
449                    .map(|term| {
450                        let idf = self.idf.get(term.as_str()).copied().unwrap_or(0.0);
451                        if idf <= 0.0 {
452                            return 0.0;
453                        }
454                        let tf = bm25.tf.get(term.as_str()).copied().unwrap_or(0.0);
455                        let denom = tf + k1 * (1.0 - b + b * dl / avgdl.max(1.0));
456                        idf * tf * (k1 + 1.0) / denom
457                    })
458                    .sum();
459                if score > 0.0 {
460                    Some((doc_id.clone(), score))
461                } else {
462                    None
463                }
464            })
465            .collect();
466
467        scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
468        scores.truncate(top_k);
469        scores
470    }
471
472    // -----------------------------------------------------------------------
473    // Fusion
474    // -----------------------------------------------------------------------
475
476    /// Fuse two ranked lists according to the configured [`FusionMethod`].
477    ///
478    /// Each input list is assumed to be sorted descending by score (rank 1 = best).
479    /// Returns an unsorted `(doc_id, fused_score)` map as a `Vec`.
480    pub fn fuse(
481        &self,
482        vector_results: &[(String, f64)],
483        bm25_results: &[(String, f64)],
484    ) -> Vec<(String, f64)> {
485        match &self.config.fusion_method {
486            FusionMethod::ReciprocalRankFusion { k } => {
487                self.rrf_fuse(vector_results, bm25_results, *k)
488            }
489            FusionMethod::LinearCombination {
490                vector_weight,
491                bm25_weight,
492            } => self.linear_fuse(vector_results, bm25_results, *vector_weight, *bm25_weight),
493            FusionMethod::CombSUM => self.linear_fuse(vector_results, bm25_results, 0.5, 0.5),
494        }
495    }
496
497    // -----------------------------------------------------------------------
498    // Cosine similarity (public static)
499    // -----------------------------------------------------------------------
500
501    /// Cosine similarity between two equal-length vectors.
502    ///
503    /// Returns 0.0 for empty inputs, mismatched lengths, or zero-norm vectors.
504    pub fn cosine_similarity(a: &[f64], b: &[f64]) -> f64 {
505        if a.is_empty() || a.len() != b.len() {
506            return 0.0;
507        }
508        let dot: f64 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
509        let norm_a: f64 = a.iter().map(|x| x * x).sum::<f64>().sqrt();
510        let norm_b: f64 = b.iter().map(|x| x * x).sum::<f64>().sqrt();
511        if norm_a == 0.0 || norm_b == 0.0 {
512            return 0.0;
513        }
514        dot / (norm_a * norm_b)
515    }
516
517    // -----------------------------------------------------------------------
518    // Private helpers
519    // -----------------------------------------------------------------------
520
521    /// Recompute the full IDF table from the current document-frequency table.
522    fn recompute_idf(&mut self) {
523        let n = self.total_docs as f64;
524        self.idf = self
525            .df
526            .iter()
527            .map(|(term, &df_count)| {
528                let df_f = df_count as f64;
529                // Okapi BM25 IDF with smoothing.
530                let idf = ((n - df_f + 0.5) / (df_f + 0.5) + 1.0).ln();
531                (term.clone(), idf)
532            })
533            .collect();
534    }
535
536    /// Average document length in tokens across the current corpus.
537    /// Returns 1.0 for an empty corpus to avoid division by zero.
538    fn average_doc_length(&self) -> f64 {
539        if self.bm25_data.is_empty() {
540            return 1.0;
541        }
542        let total: usize = self.bm25_data.values().map(|b| b.tokens.len()).sum();
543        total as f64 / self.bm25_data.len() as f64
544    }
545
546    /// True iff the document's metadata contains every key-value pair in `filters`.
547    fn matches_filters(&self, doc_id: &str, filters: &HashMap<String, String>) -> bool {
548        if filters.is_empty() {
549            return true;
550        }
551        self.documents
552            .get(doc_id)
553            .map(|doc| {
554                filters.iter().all(|(k, v)| {
555                    doc.metadata
556                        .get(k.as_str())
557                        .map(|mv| mv == v)
558                        .unwrap_or(false)
559                })
560            })
561            .unwrap_or(false)
562    }
563
564    /// Reciprocal Rank Fusion.
565    ///
566    /// For each document: fused_score = Σ_list 1 / (k + rank_in_list)
567    /// where rank is 1-based (best = 1).
568    fn rrf_fuse(
569        &self,
570        vector_results: &[(String, f64)],
571        bm25_results: &[(String, f64)],
572        k: f64,
573    ) -> Vec<(String, f64)> {
574        let mut scores: HashMap<String, f64> = HashMap::new();
575
576        for (rank, (doc_id, _)) in vector_results.iter().enumerate() {
577            // rank is 0-based; RRF uses 1-based, so add 1.
578            *scores.entry(doc_id.clone()).or_insert(0.0) += 1.0 / (k + rank as f64 + 1.0);
579        }
580        for (rank, (doc_id, _)) in bm25_results.iter().enumerate() {
581            *scores.entry(doc_id.clone()).or_insert(0.0) += 1.0 / (k + rank as f64 + 1.0);
582        }
583
584        scores.into_iter().collect()
585    }
586
587    /// Linear combination fusion with per-list normalised scores.
588    fn linear_fuse(
589        &self,
590        vector_results: &[(String, f64)],
591        bm25_results: &[(String, f64)],
592        vector_weight: f64,
593        bm25_weight: f64,
594    ) -> Vec<(String, f64)> {
595        let norm_vec = normalise(vector_results);
596        let norm_bm25 = normalise(bm25_results);
597
598        let mut scores: HashMap<String, f64> = HashMap::new();
599
600        for (id, s) in &norm_vec {
601            *scores.entry(id.clone()).or_insert(0.0) += vector_weight * s;
602        }
603        for (id, s) in &norm_bm25 {
604            *scores.entry(id.clone()).or_insert(0.0) += bm25_weight * s;
605        }
606
607        scores.into_iter().collect()
608    }
609}
610
611// ---------------------------------------------------------------------------
612// Tests
613// ---------------------------------------------------------------------------
614
615#[cfg(test)]
616mod tests {
617    use std::collections::HashMap;
618
619    use crate::search_pipeline::{
620        FusionMethod, SearchDocument, SemanticSearchPipeline, SpPipelineConfig, SpSearchQuery,
621    };
622
623    // -----------------------------------------------------------------------
624    // Test helpers
625    // -----------------------------------------------------------------------
626
627    fn make_doc(id: &str, content: &str, embedding: Vec<f64>) -> SearchDocument {
628        SearchDocument {
629            id: id.to_string(),
630            content: content.to_string(),
631            embedding,
632            metadata: HashMap::new(),
633        }
634    }
635
636    fn make_doc_meta(
637        id: &str,
638        content: &str,
639        embedding: Vec<f64>,
640        metadata: HashMap<String, String>,
641    ) -> SearchDocument {
642        SearchDocument {
643            id: id.to_string(),
644            content: content.to_string(),
645            embedding,
646            metadata,
647        }
648    }
649
650    fn default_pipeline() -> SemanticSearchPipeline {
651        SemanticSearchPipeline::new(SpPipelineConfig::default())
652    }
653
654    fn pipeline_with_docs() -> SemanticSearchPipeline {
655        let mut p = default_pipeline();
656        p.add_document(make_doc(
657            "d1",
658            "rust programming language",
659            vec![1.0, 0.0, 0.0],
660        ));
661        p.add_document(make_doc(
662            "d2",
663            "python data science machine learning",
664            vec![0.0, 1.0, 0.0],
665        ));
666        p.add_document(make_doc(
667            "d3",
668            "rust systems programming performance",
669            vec![0.9, 0.1, 0.0],
670        ));
671        p
672    }
673
674    fn simple_query(text: &str, embedding: Option<Vec<f64>>) -> SpSearchQuery {
675        SpSearchQuery {
676            text: text.to_string(),
677            embedding,
678            filters: HashMap::new(),
679            top_k: 10,
680            min_score: 0.0,
681        }
682    }
683
684    // -----------------------------------------------------------------------
685    // 1. Pipeline construction
686    // -----------------------------------------------------------------------
687
688    #[test]
689    fn test_new_empty_pipeline() {
690        let p = default_pipeline();
691        assert_eq!(p.doc_count(), 0);
692        assert_eq!(p.total_docs, 0);
693    }
694
695    #[test]
696    fn test_config_default_vector_candidates() {
697        let cfg = SpPipelineConfig::default();
698        assert_eq!(cfg.vector_candidates, 100);
699    }
700
701    #[test]
702    fn test_config_default_bm25_candidates() {
703        let cfg = SpPipelineConfig::default();
704        assert_eq!(cfg.bm25_candidates, 100);
705    }
706
707    #[test]
708    fn test_config_default_rerank_top_n() {
709        let cfg = SpPipelineConfig::default();
710        assert_eq!(cfg.rerank_top_n, 20);
711    }
712
713    // -----------------------------------------------------------------------
714    // 2. add_document / remove_document
715    // -----------------------------------------------------------------------
716
717    #[test]
718    fn test_add_single_document() {
719        let mut p = default_pipeline();
720        p.add_document(make_doc("x", "hello world", vec![1.0, 0.0]));
721        assert_eq!(p.doc_count(), 1);
722        assert_eq!(p.total_docs, 1);
723    }
724
725    #[test]
726    fn test_add_multiple_documents() {
727        let p = pipeline_with_docs();
728        assert_eq!(p.doc_count(), 3);
729        assert_eq!(p.total_docs, 3);
730    }
731
732    #[test]
733    fn test_remove_existing_document() {
734        let mut p = pipeline_with_docs();
735        let removed = p.remove_document("d1");
736        assert!(removed);
737        assert_eq!(p.doc_count(), 2);
738    }
739
740    #[test]
741    fn test_remove_missing_document() {
742        let mut p = pipeline_with_docs();
743        let removed = p.remove_document("nonexistent");
744        assert!(!removed);
745        assert_eq!(p.doc_count(), 3);
746    }
747
748    #[test]
749    fn test_remove_then_readd() {
750        let mut p = pipeline_with_docs();
751        p.remove_document("d1");
752        p.add_document(make_doc(
753            "d1",
754            "rust programming language",
755            vec![1.0, 0.0, 0.0],
756        ));
757        assert_eq!(p.doc_count(), 3);
758    }
759
760    #[test]
761    fn test_doc_count_matches_total_docs() {
762        let p = pipeline_with_docs();
763        assert_eq!(p.doc_count(), p.total_docs);
764    }
765
766    // -----------------------------------------------------------------------
767    // 3. IDF / vocabulary
768    // -----------------------------------------------------------------------
769
770    #[test]
771    fn test_idf_populated_after_add() {
772        let p = pipeline_with_docs();
773        assert!(!p.idf.is_empty());
774    }
775
776    #[test]
777    fn test_idf_rust_is_nonnegative() {
778        let p = pipeline_with_docs();
779        let idf_rust = p.idf.get("rust").copied().unwrap_or(0.0);
780        assert!(idf_rust >= 0.0);
781    }
782
783    #[test]
784    fn test_idf_decreases_as_df_increases() {
785        let mut p = default_pipeline();
786        p.add_document(make_doc("a", "rust programming", vec![1.0]));
787        let idf_before = p.idf.get("rust").copied().unwrap_or(0.0);
788        p.add_document(make_doc("b", "rust is great", vec![0.5]));
789        let idf_after = p.idf.get("rust").copied().unwrap_or(0.0);
790        // More docs containing "rust" → lower IDF.
791        assert!(idf_after <= idf_before);
792    }
793
794    #[test]
795    fn test_vocabulary_grows_on_new_terms() {
796        let mut p = default_pipeline();
797        p.add_document(make_doc("a", "hello", vec![1.0]));
798        let v1 = p.idf.len();
799        p.add_document(make_doc("b", "world unique_term_xyz", vec![0.5]));
800        let v2 = p.idf.len();
801        assert!(v2 > v1);
802    }
803
804    #[test]
805    fn test_idf_term_removed_when_only_doc_deleted() {
806        let mut p = default_pipeline();
807        p.add_document(make_doc("only", "unique_xyz_term_qwerty", vec![1.0]));
808        assert!(p.idf.contains_key("unique_xyz_term_qwerty"));
809        p.remove_document("only");
810        assert!(!p.idf.contains_key("unique_xyz_term_qwerty"));
811    }
812
813    // -----------------------------------------------------------------------
814    // 4. cosine_similarity
815    // -----------------------------------------------------------------------
816
817    #[test]
818    fn test_cosine_identical_vectors() {
819        let v = vec![1.0, 2.0, 3.0];
820        assert!((SemanticSearchPipeline::cosine_similarity(&v, &v) - 1.0).abs() < 1e-9);
821    }
822
823    #[test]
824    fn test_cosine_orthogonal_vectors() {
825        let a = vec![1.0, 0.0];
826        let b = vec![0.0, 1.0];
827        assert!(SemanticSearchPipeline::cosine_similarity(&a, &b).abs() < 1e-9);
828    }
829
830    #[test]
831    fn test_cosine_opposite_vectors() {
832        let a = vec![1.0, 0.0];
833        let b = vec![-1.0, 0.0];
834        assert!((SemanticSearchPipeline::cosine_similarity(&a, &b) + 1.0).abs() < 1e-9);
835    }
836
837    #[test]
838    fn test_cosine_zero_vector_returns_zero() {
839        let a = vec![0.0, 0.0];
840        let b = vec![1.0, 2.0];
841        assert_eq!(SemanticSearchPipeline::cosine_similarity(&a, &b), 0.0);
842    }
843
844    #[test]
845    fn test_cosine_empty_vectors() {
846        assert_eq!(SemanticSearchPipeline::cosine_similarity(&[], &[]), 0.0);
847    }
848
849    #[test]
850    fn test_cosine_mismatched_lengths() {
851        let a = vec![1.0, 2.0];
852        let b = vec![1.0];
853        assert_eq!(SemanticSearchPipeline::cosine_similarity(&a, &b), 0.0);
854    }
855
856    // -----------------------------------------------------------------------
857    // 5. vector_search
858    // -----------------------------------------------------------------------
859
860    #[test]
861    fn test_vector_search_returns_top_k() {
862        let p = pipeline_with_docs();
863        let results = p.vector_search(&[1.0, 0.0, 0.0], 2);
864        assert_eq!(results.len(), 2);
865    }
866
867    #[test]
868    fn test_vector_search_sorted_descending() {
869        let p = pipeline_with_docs();
870        let results = p.vector_search(&[1.0, 0.0, 0.0], 3);
871        for w in results.windows(2) {
872            assert!(w[0].1 >= w[1].1);
873        }
874    }
875
876    #[test]
877    fn test_vector_search_correct_top_result() {
878        let p = pipeline_with_docs();
879        // d1 = [1,0,0] — perfect cosine match with [1,0,0].
880        let results = p.vector_search(&[1.0, 0.0, 0.0], 1);
881        assert_eq!(results[0].0, "d1");
882        assert!((results[0].1 - 1.0).abs() < 1e-9);
883    }
884
885    #[test]
886    fn test_vector_search_empty_corpus() {
887        let p = default_pipeline();
888        let results = p.vector_search(&[1.0, 0.0], 5);
889        assert!(results.is_empty());
890    }
891
892    #[test]
893    fn test_vector_search_top_k_larger_than_corpus() {
894        let p = pipeline_with_docs();
895        let results = p.vector_search(&[1.0, 0.0, 0.0], 1000);
896        assert!(results.len() <= p.doc_count());
897    }
898
899    // -----------------------------------------------------------------------
900    // 6. bm25_search
901    // -----------------------------------------------------------------------
902
903    #[test]
904    fn test_bm25_search_returns_rust_docs() {
905        let p = pipeline_with_docs();
906        let results = p.bm25_search("rust", 5);
907        let ids: Vec<&str> = results.iter().map(|(id, _)| id.as_str()).collect();
908        assert!(ids.contains(&"d1") || ids.contains(&"d3"));
909    }
910
911    #[test]
912    fn test_bm25_empty_query_returns_empty() {
913        let p = pipeline_with_docs();
914        let results = p.bm25_search("", 5);
915        assert!(results.is_empty());
916    }
917
918    #[test]
919    fn test_bm25_unknown_term_returns_empty() {
920        let p = pipeline_with_docs();
921        let results = p.bm25_search("zzz_nonexistent_term", 5);
922        assert!(results.is_empty());
923    }
924
925    #[test]
926    fn test_bm25_scores_sorted_descending() {
927        let p = pipeline_with_docs();
928        let results = p.bm25_search("rust programming", 5);
929        for w in results.windows(2) {
930            assert!(w[0].1 >= w[1].1);
931        }
932    }
933
934    #[test]
935    fn test_bm25_top_k_respected() {
936        let p = pipeline_with_docs();
937        let results = p.bm25_search("rust", 1);
938        assert_eq!(results.len(), 1);
939    }
940
941    #[test]
942    fn test_bm25_higher_tf_scores_higher() {
943        let mut p = default_pipeline();
944        p.add_document(make_doc("doc_high", "rust rust language", vec![1.0]));
945        p.add_document(make_doc("doc_low", "rust language", vec![0.5]));
946        let results = p.bm25_search("rust", 2);
947        assert_eq!(results[0].0, "doc_high");
948    }
949
950    #[test]
951    fn test_bm25_score_positive_for_matching_terms() {
952        let p = pipeline_with_docs();
953        let results = p.bm25_search("python", 5);
954        let d2_score = results
955            .iter()
956            .find(|(id, _)| id == "d2")
957            .map(|(_, s)| *s)
958            .unwrap_or(0.0);
959        assert!(d2_score > 0.0);
960    }
961
962    // -----------------------------------------------------------------------
963    // 7. Fusion
964    // -----------------------------------------------------------------------
965
966    #[test]
967    fn test_rrf_fusion_contains_all_ids() {
968        let p = default_pipeline();
969        let vec_res = vec![("a".to_string(), 0.9), ("b".to_string(), 0.7)];
970        let bm25_res = vec![("b".to_string(), 5.0), ("c".to_string(), 3.0)];
971        let fused = p.fuse(&vec_res, &bm25_res);
972        let ids: Vec<&str> = fused.iter().map(|(id, _)| id.as_str()).collect();
973        assert!(ids.contains(&"a"));
974        assert!(ids.contains(&"b"));
975        assert!(ids.contains(&"c"));
976    }
977
978    #[test]
979    fn test_rrf_shared_doc_scores_higher_than_unique() {
980        let p = default_pipeline();
981        let vec_res = vec![("shared".to_string(), 0.9), ("only_vec".to_string(), 0.8)];
982        let bm25_res = vec![("shared".to_string(), 8.0), ("only_bm25".to_string(), 6.0)];
983        let fused = p.fuse(&vec_res, &bm25_res);
984        let shared_score = fused
985            .iter()
986            .find(|(id, _)| id == "shared")
987            .map(|(_, s)| *s)
988            .unwrap_or(0.0);
989        let only_vec_score = fused
990            .iter()
991            .find(|(id, _)| id == "only_vec")
992            .map(|(_, s)| *s)
993            .unwrap_or(0.0);
994        assert!(shared_score > only_vec_score);
995    }
996
997    #[test]
998    fn test_linear_fusion_zero_bm25_weight() {
999        let config = SpPipelineConfig {
1000            fusion_method: FusionMethod::LinearCombination {
1001                vector_weight: 1.0,
1002                bm25_weight: 0.0,
1003            },
1004            ..Default::default()
1005        };
1006        let p = SemanticSearchPipeline::new(config);
1007        let vec_res = vec![("a".to_string(), 1.0)];
1008        let bm25_res = vec![("b".to_string(), 10.0)];
1009        let fused = p.fuse(&vec_res, &bm25_res);
1010        let b_score = fused
1011            .iter()
1012            .find(|(id, _)| id == "b")
1013            .map(|(_, s)| *s)
1014            .unwrap_or(0.0);
1015        assert!((b_score).abs() < 1e-9);
1016    }
1017
1018    #[test]
1019    fn test_combsum_equal_weights_sums_to_one() {
1020        let config = SpPipelineConfig {
1021            fusion_method: FusionMethod::CombSUM,
1022            ..Default::default()
1023        };
1024        let p = SemanticSearchPipeline::new(config);
1025        let vec_res = vec![("a".to_string(), 1.0)];
1026        let bm25_res = vec![("a".to_string(), 2.0)];
1027        let fused = p.fuse(&vec_res, &bm25_res);
1028        let a_score = fused
1029            .iter()
1030            .find(|(id, _)| id == "a")
1031            .map(|(_, s)| *s)
1032            .unwrap_or(0.0);
1033        // After normalisation both lists yield 1.0; CombSUM = 0.5*1 + 0.5*1 = 1.0.
1034        assert!((a_score - 1.0).abs() < 1e-9);
1035    }
1036
1037    #[test]
1038    fn test_fusion_empty_inputs_returns_empty() {
1039        let p = default_pipeline();
1040        let fused = p.fuse(&[], &[]);
1041        assert!(fused.is_empty());
1042    }
1043
1044    #[test]
1045    fn test_fusion_single_list_passthrough() {
1046        let p = default_pipeline();
1047        let vec_res = vec![("a".to_string(), 1.0), ("b".to_string(), 0.5)];
1048        let fused = p.fuse(&vec_res, &[]);
1049        assert_eq!(fused.len(), 2);
1050    }
1051
1052    #[test]
1053    fn test_rrf_k_lower_gives_higher_score() {
1054        let p_low = SemanticSearchPipeline::new(SpPipelineConfig {
1055            fusion_method: FusionMethod::ReciprocalRankFusion { k: 1.0 },
1056            ..Default::default()
1057        });
1058        let p_high = SemanticSearchPipeline::new(SpPipelineConfig {
1059            fusion_method: FusionMethod::ReciprocalRankFusion { k: 1000.0 },
1060            ..Default::default()
1061        });
1062        let vec_res = vec![("a".to_string(), 1.0)];
1063        let bm25_res = vec![("a".to_string(), 1.0)];
1064        let score_low = p_low
1065            .fuse(&vec_res, &bm25_res)
1066            .first()
1067            .map(|(_, s)| *s)
1068            .unwrap_or(0.0);
1069        let score_high = p_high
1070            .fuse(&vec_res, &bm25_res)
1071            .first()
1072            .map(|(_, s)| *s)
1073            .unwrap_or(0.0);
1074        assert!(score_low > score_high);
1075    }
1076
1077    // -----------------------------------------------------------------------
1078    // 8. Full search pipeline
1079    // -----------------------------------------------------------------------
1080
1081    #[test]
1082    fn test_search_returns_hits() {
1083        let mut p = pipeline_with_docs();
1084        let q = simple_query("rust", Some(vec![1.0, 0.0, 0.0]));
1085        let result = p.search(&q);
1086        assert!(!result.hits.is_empty());
1087    }
1088
1089    #[test]
1090    fn test_search_hits_sorted_by_score_desc() {
1091        let mut p = pipeline_with_docs();
1092        let q = simple_query("rust programming", Some(vec![1.0, 0.0, 0.0]));
1093        let result = p.search(&q);
1094        for w in result.hits.windows(2) {
1095            assert!(w[0].score >= w[1].score);
1096        }
1097    }
1098
1099    #[test]
1100    fn test_search_rank_starts_at_one() {
1101        let mut p = pipeline_with_docs();
1102        let q = simple_query("rust", None);
1103        let result = p.search(&q);
1104        if !result.hits.is_empty() {
1105            assert_eq!(result.hits[0].rank, 1);
1106        }
1107    }
1108
1109    #[test]
1110    fn test_search_ranks_are_sequential() {
1111        let mut p = pipeline_with_docs();
1112        let q = simple_query("rust programming", Some(vec![1.0, 0.0, 0.0]));
1113        let result = p.search(&q);
1114        for (i, hit) in result.hits.iter().enumerate() {
1115            assert_eq!(hit.rank, i + 1);
1116        }
1117    }
1118
1119    #[test]
1120    fn test_search_respects_top_k() {
1121        let mut p = pipeline_with_docs();
1122        let q = SpSearchQuery {
1123            text: "rust programming language systems".to_string(),
1124            embedding: Some(vec![1.0, 0.0, 0.0]),
1125            filters: HashMap::new(),
1126            top_k: 1,
1127            min_score: 0.0,
1128        };
1129        let result = p.search(&q);
1130        assert!(result.hits.len() <= 1);
1131    }
1132
1133    #[test]
1134    fn test_search_min_score_filters_all() {
1135        let mut p = pipeline_with_docs();
1136        let q = SpSearchQuery {
1137            text: "rust".to_string(),
1138            embedding: Some(vec![1.0, 0.0, 0.0]),
1139            filters: HashMap::new(),
1140            top_k: 10,
1141            min_score: 9999.0,
1142        };
1143        let result = p.search(&q);
1144        assert!(result.hits.is_empty());
1145    }
1146
1147    #[test]
1148    fn test_search_total_candidates_positive() {
1149        let mut p = pipeline_with_docs();
1150        let q = simple_query("rust", Some(vec![1.0, 0.0, 0.0]));
1151        let result = p.search(&q);
1152        assert!(result.total_candidates > 0);
1153    }
1154
1155    #[test]
1156    fn test_search_query_text_in_result() {
1157        let mut p = pipeline_with_docs();
1158        let q = simple_query("hello world", None);
1159        let result = p.search(&q);
1160        assert_eq!(result.query_text, "hello world");
1161    }
1162
1163    #[test]
1164    fn test_search_vector_only_finds_similar_doc() {
1165        let mut p = pipeline_with_docs();
1166        let q = SpSearchQuery {
1167            text: String::new(),
1168            embedding: Some(vec![0.0, 1.0, 0.0]),
1169            filters: HashMap::new(),
1170            top_k: 5,
1171            min_score: 0.0,
1172        };
1173        let result = p.search(&q);
1174        // d2 has embedding [0,1,0] — should be the top hit.
1175        assert!(!result.hits.is_empty());
1176        assert_eq!(result.hits[0].doc_id, "d2");
1177    }
1178
1179    #[test]
1180    fn test_search_on_empty_corpus() {
1181        let mut p = default_pipeline();
1182        let q = simple_query("rust", Some(vec![1.0, 0.0]));
1183        let result = p.search(&q);
1184        assert!(result.hits.is_empty());
1185        assert_eq!(result.total_candidates, 0);
1186    }
1187
1188    #[test]
1189    fn test_hit_fields_all_populated() {
1190        let mut p = pipeline_with_docs();
1191        let q = simple_query("rust", Some(vec![1.0, 0.0, 0.0]));
1192        let result = p.search(&q);
1193        for hit in &result.hits {
1194            assert!(!hit.doc_id.is_empty());
1195            assert!(hit.score >= 0.0);
1196            assert!(hit.rank >= 1);
1197        }
1198    }
1199
1200    // -----------------------------------------------------------------------
1201    // 9. Metadata filtering
1202    // -----------------------------------------------------------------------
1203
1204    #[test]
1205    fn test_metadata_filter_exact_match() {
1206        let mut meta = HashMap::new();
1207        meta.insert("lang".to_string(), "rust".to_string());
1208        let mut p = default_pipeline();
1209        p.add_document(make_doc_meta(
1210            "d1",
1211            "systems programming",
1212            vec![1.0, 0.0],
1213            meta,
1214        ));
1215        p.add_document(make_doc("d2", "systems programming", vec![1.0, 0.0]));
1216
1217        let mut filters = HashMap::new();
1218        filters.insert("lang".to_string(), "rust".to_string());
1219        let q = SpSearchQuery {
1220            text: "systems programming".to_string(),
1221            embedding: Some(vec![1.0, 0.0]),
1222            filters,
1223            top_k: 10,
1224            min_score: 0.0,
1225        };
1226        let result = p.search(&q);
1227        assert!(result.hits.iter().all(|h| h.doc_id == "d1"));
1228    }
1229
1230    #[test]
1231    fn test_metadata_filter_no_match_returns_empty() {
1232        let mut p = pipeline_with_docs();
1233        let mut filters = HashMap::new();
1234        filters.insert("nonexistent".to_string(), "value".to_string());
1235        let q = SpSearchQuery {
1236            text: "rust".to_string(),
1237            embedding: Some(vec![1.0, 0.0, 0.0]),
1238            filters,
1239            top_k: 10,
1240            min_score: 0.0,
1241        };
1242        let result = p.search(&q);
1243        assert!(result.hits.is_empty());
1244    }
1245
1246    #[test]
1247    fn test_metadata_empty_filter_passes_all() {
1248        let mut p = pipeline_with_docs();
1249        let q = simple_query("rust", Some(vec![1.0, 0.0, 0.0]));
1250        let result = p.search(&q);
1251        assert!(!result.hits.is_empty());
1252    }
1253
1254    // -----------------------------------------------------------------------
1255    // 10. Stats
1256    // -----------------------------------------------------------------------
1257
1258    #[test]
1259    fn test_stats_initial_state() {
1260        let p = default_pipeline();
1261        let s = p.stats();
1262        assert_eq!(s.doc_count, 0);
1263        assert_eq!(s.total_searches, 0);
1264        assert_eq!(s.vocabulary_size, 0);
1265        assert_eq!(s.avg_doc_length, 0.0);
1266        assert_eq!(s.avg_hits_per_search, 0.0);
1267    }
1268
1269    #[test]
1270    fn test_stats_after_docs_added() {
1271        let p = pipeline_with_docs();
1272        let s = p.stats();
1273        assert_eq!(s.doc_count, 3);
1274        assert!(s.vocabulary_size > 0);
1275        assert!(s.avg_doc_length > 0.0);
1276    }
1277
1278    #[test]
1279    fn test_stats_total_searches_increments() {
1280        let mut p = pipeline_with_docs();
1281        let q = simple_query("rust", None);
1282        p.search(&q);
1283        p.search(&q);
1284        assert_eq!(p.stats().total_searches, 2);
1285    }
1286
1287    #[test]
1288    fn test_stats_avg_hits_computed_correctly() {
1289        let mut p = pipeline_with_docs();
1290        let q = simple_query("rust", None);
1291        let r = p.search(&q);
1292        let hits = r.hits.len() as f64;
1293        let s = p.stats();
1294        assert!((s.avg_hits_per_search - hits).abs() < 1e-9);
1295    }
1296}