Skip to main content

ipfrs_semantic/
dense_retriever.rs

1//! Dense Retriever — hybrid dense vector + BM25 sparse retrieval system.
2//!
3//! Combines exact cosine-similarity nearest-neighbour search over raw f64 embeddings
4//! with a BM25 inverted index for lexical scoring.  Results from both paths are
5//! independently min-max normalised and then fused via a configurable `hybrid_alpha`
6//! parameter before being returned as ranked [`RetrievalResult`] items.
7//!
8//! # Example
9//!
10//! ```rust
11//! use ipfrs_semantic::dense_retriever::{
12//!     DenseRetriever, Document, RetrievalQuery, RetrieverConfig,
13//! };
14//! use std::collections::HashMap;
15//!
16//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
17//! let config = RetrieverConfig::default();
18//! let mut retriever = DenseRetriever::new(config);
19//!
20//! let doc = Document {
21//!     id: "doc1".to_string(),
22//!     content: "Rust is a systems programming language".to_string(),
23//!     embedding: vec![0.1, 0.2, 0.3, 0.4],
24//!     metadata: HashMap::new(),
25//! };
26//! retriever.add_document(doc)?;
27//!
28//! let query = RetrievalQuery {
29//!     text: "systems programming".to_string(),
30//!     embedding: vec![0.1, 0.2, 0.3, 0.4],
31//!     top_k: 5,
32//!     hybrid_alpha: 0.7,
33//! };
34//! let results = retriever.hybrid_search(&mut query.clone());
35//! println!("hits: {}", results.len());
36//! # Ok(())
37//! # }
38//! ```
39
40use std::collections::HashMap;
41
42use thiserror::Error;
43
44// ---------------------------------------------------------------------------
45// Error type
46// ---------------------------------------------------------------------------
47
48/// Errors produced by [`DenseRetriever`].
49#[derive(Debug, Error, Clone, PartialEq)]
50pub enum RetrieverError {
51    /// The corpus has reached `config.max_documents`.
52    #[error("maximum document capacity ({0}) reached")]
53    MaxDocumentsReached(usize),
54
55    /// The supplied embedding has the wrong dimensionality.
56    #[error("embedding dimension mismatch: expected {expected}, got {got}")]
57    DimensionMismatch {
58        /// Expected dimension as configured.
59        expected: usize,
60        /// Actual dimension of the supplied embedding.
61        got: usize,
62    },
63
64    /// No document with the given id exists.
65    #[error("document not found: {0}")]
66    DocumentNotFound(String),
67}
68
69// ---------------------------------------------------------------------------
70// Public domain types
71// ---------------------------------------------------------------------------
72
73/// A document stored in the retriever.
74#[derive(Debug, Clone)]
75pub struct Document {
76    /// Unique identifier.
77    pub id: String,
78    /// Raw text content used for BM25 indexing.
79    pub content: String,
80    /// Dense embedding vector (length must equal `RetrieverConfig::embedding_dim`).
81    pub embedding: Vec<f64>,
82    /// Arbitrary key-value metadata.
83    pub metadata: HashMap<String, String>,
84}
85
86/// Query submitted to [`DenseRetriever::hybrid_search`].
87#[derive(Debug, Clone)]
88pub struct RetrievalQuery {
89    /// Free-text query for BM25 sparse path.
90    pub text: String,
91    /// Dense embedding for cosine-similarity search.
92    pub embedding: Vec<f64>,
93    /// Number of top results to return.
94    pub top_k: usize,
95    /// Interpolation weight: 1.0 = pure dense, 0.0 = pure sparse.
96    pub hybrid_alpha: f64,
97}
98
99/// A single result returned by [`DenseRetriever::hybrid_search`].
100#[derive(Debug, Clone)]
101pub struct RetrievalResult {
102    /// Document identifier.
103    pub doc_id: String,
104    /// Normalised dense (cosine) score ∈ [0, 1].
105    pub dense_score: f64,
106    /// Normalised sparse (BM25) score ∈ [0, 1].
107    pub sparse_score: f64,
108    /// `alpha * dense_score + (1 − alpha) * sparse_score`.
109    pub hybrid_score: f64,
110    /// 1-based rank position in the returned list.
111    pub rank: usize,
112}
113
114/// Runtime statistics snapshot.
115#[derive(Debug, Clone)]
116pub struct RetrieverStats {
117    /// Current number of indexed documents.
118    pub document_count: usize,
119    /// Total number of hybrid queries executed so far.
120    pub total_queries: u64,
121    /// Mean document token length.
122    pub avg_doc_length: f64,
123    /// Number of unique terms in the vocabulary.
124    pub vocabulary_size: usize,
125}
126
127// ---------------------------------------------------------------------------
128// Configuration
129// ---------------------------------------------------------------------------
130
131/// Configuration for [`DenseRetriever`].
132#[derive(Debug, Clone)]
133pub struct RetrieverConfig {
134    /// Expected dimensionality of all document / query embeddings.
135    pub embedding_dim: usize,
136    /// Upper bound on indexed documents.
137    pub max_documents: usize,
138    /// BM25 term-frequency saturation parameter k₁.
139    pub bm25_k1: f64,
140    /// BM25 document-length normalisation parameter b.
141    pub bm25_b: f64,
142}
143
144impl Default for RetrieverConfig {
145    fn default() -> Self {
146        Self {
147            embedding_dim: 128,
148            max_documents: 100_000,
149            bm25_k1: 1.2,
150            bm25_b: 0.75,
151        }
152    }
153}
154
155// ---------------------------------------------------------------------------
156// BM25 inverted index
157// ---------------------------------------------------------------------------
158
159/// Inverted BM25 index maintained alongside the document store.
160#[derive(Debug, Clone, Default)]
161pub struct BM25Index {
162    /// Token counts per document (parallel to the document vector).
163    pub doc_lengths: Vec<usize>,
164    /// Posting lists: term → [(doc_index, raw_tf)].
165    pub term_freq: HashMap<String, Vec<(usize, f64)>>,
166    /// Per-term document frequency (number of documents the term appears in).
167    pub doc_freq: HashMap<String, usize>,
168    /// Mean document length (re-computed after every mutation).
169    pub avg_doc_length: f64,
170}
171
172impl BM25Index {
173    /// Create an empty index.
174    pub fn new() -> Self {
175        Self::default()
176    }
177
178    /// Tokenise `text` into lower-cased alphabetic tokens.
179    pub fn tokenize(text: &str) -> Vec<String> {
180        text.split(|c: char| !c.is_alphanumeric())
181            .filter(|s| !s.is_empty())
182            .map(|s| s.to_lowercase())
183            .collect()
184    }
185
186    /// Recompute `avg_doc_length` from the current `doc_lengths` slice.
187    fn recompute_avg(&mut self) {
188        if self.doc_lengths.is_empty() {
189            self.avg_doc_length = 0.0;
190        } else {
191            let total: usize = self.doc_lengths.iter().sum();
192            self.avg_doc_length = total as f64 / self.doc_lengths.len() as f64;
193        }
194    }
195}
196
197// ---------------------------------------------------------------------------
198// Main retriever
199// ---------------------------------------------------------------------------
200
201/// Dense + sparse hybrid retriever.
202///
203/// See the [module documentation](self) for a full usage example.
204pub struct DenseRetriever {
205    /// Configuration (immutable after creation).
206    pub config: RetrieverConfig,
207    /// Ordered collection of indexed documents.
208    pub documents: Vec<Document>,
209    /// BM25 index mirroring `documents`.
210    pub bm25: BM25Index,
211    /// Monotonically increasing query counter.
212    pub total_queries: u64,
213}
214
215impl DenseRetriever {
216    // -----------------------------------------------------------------------
217    // Construction
218    // -----------------------------------------------------------------------
219
220    /// Create a new retriever with the supplied `config`.
221    pub fn new(config: RetrieverConfig) -> Self {
222        Self {
223            config,
224            documents: Vec::new(),
225            bm25: BM25Index::new(),
226            total_queries: 0,
227        }
228    }
229
230    // -----------------------------------------------------------------------
231    // Mutation helpers
232    // -----------------------------------------------------------------------
233
234    /// Add a document to the index.
235    ///
236    /// # Errors
237    ///
238    /// - [`RetrieverError::MaxDocumentsReached`] if the corpus is full.
239    /// - [`RetrieverError::DimensionMismatch`] if the embedding length differs from
240    ///   `config.embedding_dim`.
241    pub fn add_document(&mut self, doc: Document) -> Result<(), RetrieverError> {
242        if self.documents.len() >= self.config.max_documents {
243            return Err(RetrieverError::MaxDocumentsReached(
244                self.config.max_documents,
245            ));
246        }
247        if doc.embedding.len() != self.config.embedding_dim {
248            return Err(RetrieverError::DimensionMismatch {
249                expected: self.config.embedding_dim,
250                got: doc.embedding.len(),
251            });
252        }
253
254        let doc_idx = self.documents.len();
255        let tokens = BM25Index::tokenize(&doc.content);
256        let doc_len = tokens.len();
257
258        // Count per-document term frequencies.
259        let mut local_tf: HashMap<String, f64> = HashMap::new();
260        for token in &tokens {
261            *local_tf.entry(token.clone()).or_insert(0.0) += 1.0;
262        }
263
264        // Update posting lists and doc-freq.
265        for (term, tf) in local_tf {
266            let posts = self.bm25.term_freq.entry(term.clone()).or_default();
267            posts.push((doc_idx, tf));
268            *self.bm25.doc_freq.entry(term).or_insert(0) += 1;
269        }
270
271        self.bm25.doc_lengths.push(doc_len);
272        self.documents.push(doc);
273        self.bm25.recompute_avg();
274        Ok(())
275    }
276
277    /// Remove the document with the given `id`.
278    ///
279    /// Returns `true` if the document was found and removed, `false` otherwise.
280    /// The BM25 index is fully rebuilt after a successful removal.
281    pub fn remove_document(&mut self, id: &str) -> bool {
282        let pos = self.documents.iter().position(|d| d.id == id);
283        match pos {
284            None => false,
285            Some(idx) => {
286                self.documents.swap_remove(idx);
287                self.rebuild_bm25();
288                true
289            }
290        }
291    }
292
293    /// Rebuild the BM25 index from scratch using the current document set.
294    ///
295    /// Called automatically by [`remove_document`](Self::remove_document).
296    pub fn rebuild_bm25(&mut self) {
297        self.bm25 = BM25Index::new();
298
299        for (doc_idx, doc) in self.documents.iter().enumerate() {
300            let tokens = BM25Index::tokenize(&doc.content);
301            let doc_len = tokens.len();
302
303            let mut local_tf: HashMap<String, f64> = HashMap::new();
304            for token in &tokens {
305                *local_tf.entry(token.clone()).or_insert(0.0) += 1.0;
306            }
307
308            for (term, tf) in local_tf {
309                let posts = self.bm25.term_freq.entry(term.clone()).or_default();
310                posts.push((doc_idx, tf));
311                *self.bm25.doc_freq.entry(term).or_insert(0) += 1;
312            }
313
314            self.bm25.doc_lengths.push(doc_len);
315        }
316
317        self.bm25.recompute_avg();
318    }
319
320    // -----------------------------------------------------------------------
321    // Search primitives
322    // -----------------------------------------------------------------------
323
324    /// Return the top-`k` documents by cosine similarity to `query_embedding`.
325    ///
326    /// Result elements are `(doc_index, cosine_similarity)` sorted descending.
327    pub fn dense_search(&self, query_embedding: &[f64], k: usize) -> Vec<(usize, f64)> {
328        if self.documents.is_empty() || k == 0 {
329            return Vec::new();
330        }
331
332        let q_norm = l2_norm(query_embedding);
333
334        let mut scores: Vec<(usize, f64)> = self
335            .documents
336            .iter()
337            .enumerate()
338            .map(|(idx, doc)| {
339                let sim = cosine_sim_normed(query_embedding, &doc.embedding, q_norm);
340                (idx, sim)
341            })
342            .collect();
343
344        // Partial sort — O(n log k) via full sort for simplicity; corpus is bounded.
345        scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
346        scores.truncate(k);
347        scores
348    }
349
350    /// Compute the BM25 score for document `doc_idx` given a list of query tokens.
351    pub fn bm25_score(&self, doc_idx: usize, query_terms: &[String]) -> f64 {
352        let n = self.documents.len() as f64;
353        let dl = self.bm25.doc_lengths.get(doc_idx).copied().unwrap_or(0) as f64;
354        let avg_dl = self.bm25.avg_doc_length.max(1e-9);
355        let k1 = self.config.bm25_k1;
356        let b = self.config.bm25_b;
357
358        let mut score = 0.0_f64;
359
360        for term in query_terms {
361            let df = self.bm25.doc_freq.get(term).copied().unwrap_or(0) as f64;
362            if df == 0.0 {
363                continue;
364            }
365            // Retrieve term frequency for this document.
366            let tf = self
367                .bm25
368                .term_freq
369                .get(term)
370                .and_then(|posts| {
371                    posts
372                        .iter()
373                        .find(|(idx, _)| *idx == doc_idx)
374                        .map(|(_, tf)| *tf)
375                })
376                .unwrap_or(0.0);
377
378            if tf == 0.0 {
379                continue;
380            }
381
382            // Robertson-Spärck Jones IDF with additive smoothing.
383            let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
384            let tf_norm = tf * (k1 + 1.0) / (tf + k1 * (1.0 - b + b * dl / avg_dl));
385            score += idf * tf_norm;
386        }
387
388        score
389    }
390
391    /// Return the top-`k` documents by BM25 score for `query_text`.
392    ///
393    /// Result elements are `(doc_index, bm25_score)` sorted descending.
394    pub fn sparse_search(&self, query_text: &str, k: usize) -> Vec<(usize, f64)> {
395        if self.documents.is_empty() || k == 0 {
396            return Vec::new();
397        }
398
399        let terms = BM25Index::tokenize(query_text);
400        if terms.is_empty() {
401            return Vec::new();
402        }
403
404        let mut scores: Vec<(usize, f64)> = (0..self.documents.len())
405            .map(|idx| (idx, self.bm25_score(idx, &terms)))
406            .collect();
407
408        scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
409        scores.truncate(k);
410        scores
411    }
412
413    // -----------------------------------------------------------------------
414    // Hybrid search
415    // -----------------------------------------------------------------------
416
417    /// Execute a hybrid search combining dense and sparse retrieval.
418    ///
419    /// Both score lists are independently min-max normalised to [0, 1] before
420    /// fusion so that the magnitude of each raw signal does not dominate.
421    /// The final score is:
422    ///
423    /// ```text
424    /// hybrid_score = alpha * dense_norm + (1 - alpha) * sparse_norm
425    /// ```
426    ///
427    /// Documents that appear only in one list receive a score of 0.0 for the
428    /// other component.
429    pub fn hybrid_search(&mut self, query: &RetrievalQuery) -> Vec<RetrievalResult> {
430        self.total_queries += 1;
431
432        let alpha = query.hybrid_alpha.clamp(0.0, 1.0);
433        let k = query.top_k.max(1);
434
435        // Run both retrieval paths with generous candidate sets.
436        let candidate_k = (k * 4).max(k + 10).min(self.documents.len().max(1));
437
438        let dense_raw = self.dense_search(&query.embedding, candidate_k);
439        let sparse_raw = self.sparse_search(&query.text, candidate_k);
440
441        // Normalise each list independently.
442        let dense_norm = min_max_normalise(&dense_raw);
443        let sparse_norm = min_max_normalise(&sparse_raw);
444
445        // Merge by document index.
446        let mut merged: HashMap<usize, (f64, f64)> = HashMap::new();
447
448        for (doc_idx, score) in &dense_norm {
449            merged.entry(*doc_idx).or_insert((0.0, 0.0)).0 = *score;
450        }
451        for (doc_idx, score) in &sparse_norm {
452            merged.entry(*doc_idx).or_insert((0.0, 0.0)).1 = *score;
453        }
454
455        // Compute hybrid scores and sort.
456        let mut fused: Vec<(usize, f64, f64, f64)> = merged
457            .into_iter()
458            .map(|(idx, (d, s))| {
459                let h = alpha * d + (1.0 - alpha) * s;
460                (idx, d, s, h)
461            })
462            .collect();
463
464        fused.sort_by(|a, b| b.3.partial_cmp(&a.3).unwrap_or(std::cmp::Ordering::Equal));
465        fused.truncate(k);
466
467        fused
468            .into_iter()
469            .enumerate()
470            .filter_map(|(rank_idx, (doc_idx, d, s, h))| {
471                let doc_id = self.documents.get(doc_idx)?.id.clone();
472                Some(RetrievalResult {
473                    doc_id,
474                    dense_score: d,
475                    sparse_score: s,
476                    hybrid_score: h,
477                    rank: rank_idx + 1,
478                })
479            })
480            .collect()
481    }
482
483    // -----------------------------------------------------------------------
484    // Accessors
485    // -----------------------------------------------------------------------
486
487    /// Look up a document by its string identifier.
488    pub fn get_document(&self, id: &str) -> Option<&Document> {
489        self.documents.iter().find(|d| d.id == id)
490    }
491
492    /// Return the current number of indexed documents.
493    pub fn document_count(&self) -> usize {
494        self.documents.len()
495    }
496
497    /// Return a statistics snapshot.
498    pub fn retriever_stats(&self) -> RetrieverStats {
499        RetrieverStats {
500            document_count: self.documents.len(),
501            total_queries: self.total_queries,
502            avg_doc_length: self.bm25.avg_doc_length,
503            vocabulary_size: self.bm25.doc_freq.len(),
504        }
505    }
506}
507
508// ---------------------------------------------------------------------------
509// Internal utilities
510// ---------------------------------------------------------------------------
511
512/// Compute the L2 norm of a slice.
513fn l2_norm(v: &[f64]) -> f64 {
514    v.iter().map(|x| x * x).sum::<f64>().sqrt()
515}
516
517/// Cosine similarity when the query L2-norm is pre-computed.
518///
519/// Returns 0.0 if either vector is the zero vector.
520fn cosine_sim_normed(query: &[f64], doc: &[f64], q_norm: f64) -> f64 {
521    if q_norm < 1e-12 {
522        return 0.0;
523    }
524    let d_norm = l2_norm(doc);
525    if d_norm < 1e-12 {
526        return 0.0;
527    }
528    let dot: f64 = query.iter().zip(doc.iter()).map(|(q, d)| q * d).sum();
529    dot / (q_norm * d_norm)
530}
531
532/// Min-max normalise a score list to [0, 1].
533///
534/// If all scores are identical the result is a list of 1.0 values (to avoid
535/// division-by-zero and to preserve all candidates as equally relevant).
536fn min_max_normalise(scores: &[(usize, f64)]) -> Vec<(usize, f64)> {
537    if scores.is_empty() {
538        return Vec::new();
539    }
540    let min = scores.iter().map(|(_, s)| *s).fold(f64::INFINITY, f64::min);
541    let max = scores
542        .iter()
543        .map(|(_, s)| *s)
544        .fold(f64::NEG_INFINITY, f64::max);
545
546    let range = max - min;
547    scores
548        .iter()
549        .map(|(idx, s)| {
550            let norm = if range < 1e-12 {
551                1.0
552            } else {
553                (s - min) / range
554            };
555            (*idx, norm)
556        })
557        .collect()
558}
559
560// ---------------------------------------------------------------------------
561// Tests
562// ---------------------------------------------------------------------------
563
564#[cfg(test)]
565mod tests {
566    use std::collections::HashMap;
567
568    use crate::dense_retriever::{
569        min_max_normalise, BM25Index, DenseRetriever, Document, RetrievalQuery, RetrieverConfig,
570        RetrieverError,
571    };
572
573    // ------------------------------------------------------------------
574    // Helpers
575    // ------------------------------------------------------------------
576
577    fn make_config(dim: usize) -> RetrieverConfig {
578        RetrieverConfig {
579            embedding_dim: dim,
580            max_documents: 100,
581            bm25_k1: 1.2,
582            bm25_b: 0.75,
583        }
584    }
585
586    fn make_doc(id: &str, content: &str, emb: Vec<f64>) -> Document {
587        Document {
588            id: id.to_string(),
589            content: content.to_string(),
590            embedding: emb,
591            metadata: HashMap::new(),
592        }
593    }
594
595    fn unit_vec(dim: usize, fill: f64) -> Vec<f64> {
596        vec![fill; dim]
597    }
598
599    // ------------------------------------------------------------------
600    // 1. Construction
601    // ------------------------------------------------------------------
602
603    #[test]
604    fn test_new_retriever_is_empty() {
605        let r = DenseRetriever::new(make_config(4));
606        assert_eq!(r.document_count(), 0);
607        assert_eq!(r.total_queries, 0);
608    }
609
610    #[test]
611    fn test_default_config_values() {
612        let cfg = RetrieverConfig::default();
613        assert_eq!(cfg.embedding_dim, 128);
614        assert_eq!(cfg.max_documents, 100_000);
615        assert!((cfg.bm25_k1 - 1.2).abs() < 1e-9);
616        assert!((cfg.bm25_b - 0.75).abs() < 1e-9);
617    }
618
619    // ------------------------------------------------------------------
620    // 2. add_document
621    // ------------------------------------------------------------------
622
623    #[test]
624    fn test_add_single_document() {
625        let mut r = DenseRetriever::new(make_config(4));
626        let doc = make_doc("d1", "hello world", vec![0.1, 0.2, 0.3, 0.4]);
627        assert!(r.add_document(doc).is_ok());
628        assert_eq!(r.document_count(), 1);
629    }
630
631    #[test]
632    fn test_add_document_dimension_mismatch() {
633        let mut r = DenseRetriever::new(make_config(4));
634        let doc = make_doc("d1", "hello", vec![0.1, 0.2]); // wrong dim
635        let err = r
636            .add_document(doc)
637            .expect_err("test: add_document with wrong dimension should return error");
638        assert!(matches!(
639            err,
640            RetrieverError::DimensionMismatch {
641                expected: 4,
642                got: 2
643            }
644        ));
645    }
646
647    #[test]
648    fn test_add_document_capacity_limit() {
649        let mut cfg = make_config(2);
650        cfg.max_documents = 2;
651        let mut r = DenseRetriever::new(cfg);
652        r.add_document(make_doc("d1", "a", vec![1.0, 0.0]))
653            .expect("test: add_document should succeed");
654        r.add_document(make_doc("d2", "b", vec![0.0, 1.0]))
655            .expect("test: add_document should succeed");
656        let err = r
657            .add_document(make_doc("d3", "c", vec![0.5, 0.5]))
658            .expect_err("test: add_document beyond capacity should return error");
659        assert!(matches!(err, RetrieverError::MaxDocumentsReached(2)));
660    }
661
662    #[test]
663    fn test_bm25_index_updated_on_add() {
664        let mut r = DenseRetriever::new(make_config(2));
665        r.add_document(make_doc("d1", "rust is great", vec![1.0, 0.0]))
666            .expect("test: add_document should succeed");
667        assert!(r.bm25.doc_freq.contains_key("rust"));
668        assert!(r.bm25.doc_freq.contains_key("is"));
669        assert!(r.bm25.doc_freq.contains_key("great"));
670    }
671
672    #[test]
673    fn test_avg_doc_length_updated() {
674        let mut r = DenseRetriever::new(make_config(2));
675        r.add_document(make_doc("d1", "one two three", vec![1.0, 0.0]))
676            .expect("test: add_document should succeed");
677        r.add_document(make_doc("d2", "four five", vec![0.0, 1.0]))
678            .expect("test: add_document should succeed");
679        // avg_doc_length = (3 + 2) / 2 = 2.5
680        assert!((r.bm25.avg_doc_length - 2.5).abs() < 1e-9);
681    }
682
683    // ------------------------------------------------------------------
684    // 3. remove_document
685    // ------------------------------------------------------------------
686
687    #[test]
688    fn test_remove_existing_document() {
689        let mut r = DenseRetriever::new(make_config(2));
690        r.add_document(make_doc("d1", "hello world", vec![1.0, 0.0]))
691            .expect("test: add_document should succeed");
692        let removed = r.remove_document("d1");
693        assert!(removed);
694        assert_eq!(r.document_count(), 0);
695    }
696
697    #[test]
698    fn test_remove_nonexistent_returns_false() {
699        let mut r = DenseRetriever::new(make_config(2));
700        r.add_document(make_doc("d1", "hello", vec![1.0, 0.0]))
701            .expect("test: add_document should succeed");
702        assert!(!r.remove_document("does_not_exist"));
703    }
704
705    #[test]
706    fn test_bm25_rebuilt_after_remove() {
707        let mut r = DenseRetriever::new(make_config(2));
708        r.add_document(make_doc("d1", "alpha beta", vec![1.0, 0.0]))
709            .expect("test: add_document should succeed");
710        r.add_document(make_doc("d2", "alpha gamma", vec![0.0, 1.0]))
711            .expect("test: add_document should succeed");
712        r.remove_document("d1");
713        // "beta" should be gone; "alpha" still present in d2
714        assert!(!r.bm25.doc_freq.contains_key("beta"));
715        assert!(r.bm25.doc_freq.contains_key("alpha"));
716    }
717
718    // ------------------------------------------------------------------
719    // 4. BM25Index tokenizer
720    // ------------------------------------------------------------------
721
722    #[test]
723    fn test_tokenizer_splits_on_whitespace() {
724        let tokens = BM25Index::tokenize("hello world foo");
725        assert_eq!(tokens, vec!["hello", "world", "foo"]);
726    }
727
728    #[test]
729    fn test_tokenizer_splits_on_punctuation() {
730        let tokens = BM25Index::tokenize("hello, world! foo.");
731        assert_eq!(tokens, vec!["hello", "world", "foo"]);
732    }
733
734    #[test]
735    fn test_tokenizer_lowercases() {
736        let tokens = BM25Index::tokenize("Hello WORLD");
737        assert_eq!(tokens, vec!["hello", "world"]);
738    }
739
740    #[test]
741    fn test_tokenizer_empty_string() {
742        let tokens = BM25Index::tokenize("");
743        assert!(tokens.is_empty());
744    }
745
746    #[test]
747    fn test_tokenizer_only_punctuation() {
748        let tokens = BM25Index::tokenize("!!! ,,, ...");
749        assert!(tokens.is_empty());
750    }
751
752    // ------------------------------------------------------------------
753    // 5. dense_search
754    // ------------------------------------------------------------------
755
756    #[test]
757    fn test_dense_search_empty_index() {
758        let r = DenseRetriever::new(make_config(4));
759        let res = r.dense_search(&[1.0, 0.0, 0.0, 0.0], 5);
760        assert!(res.is_empty());
761    }
762
763    #[test]
764    fn test_dense_search_returns_at_most_k() {
765        let mut r = DenseRetriever::new(make_config(2));
766        for i in 0..10u32 {
767            r.add_document(make_doc(
768                &i.to_string(),
769                "doc",
770                vec![i as f64, (10 - i) as f64],
771            ))
772            .expect("test: add_document should succeed");
773        }
774        let res = r.dense_search(&[1.0, 0.0], 3);
775        assert_eq!(res.len(), 3);
776    }
777
778    #[test]
779    fn test_dense_search_highest_similarity_first() {
780        let mut r = DenseRetriever::new(make_config(2));
781        r.add_document(make_doc("d1", "a", vec![1.0, 0.0]))
782            .expect("test: add_document should succeed");
783        r.add_document(make_doc("d2", "b", vec![0.0, 1.0]))
784            .expect("test: add_document should succeed");
785
786        // Query aligned with d1 → d1 should rank first.
787        let res = r.dense_search(&[1.0, 0.0], 2);
788        assert_eq!(res[0].0, 0); // index 0 = d1
789    }
790
791    #[test]
792    fn test_dense_search_zero_query_vector() {
793        let mut r = DenseRetriever::new(make_config(2));
794        r.add_document(make_doc("d1", "x", vec![1.0, 0.0]))
795            .expect("test: add_document should succeed");
796        let res = r.dense_search(&[0.0, 0.0], 1);
797        // Zero vector → similarity = 0 for all docs; still returns entry
798        assert_eq!(res.len(), 1);
799        assert!((res[0].1).abs() < 1e-9);
800    }
801
802    // ------------------------------------------------------------------
803    // 6. bm25_score
804    // ------------------------------------------------------------------
805
806    #[test]
807    fn test_bm25_score_zero_for_missing_term() {
808        let mut r = DenseRetriever::new(make_config(2));
809        r.add_document(make_doc("d1", "apple banana", vec![1.0, 0.0]))
810            .expect("test: add_document should succeed");
811        let score = r.bm25_score(0, &["pear".to_string()]);
812        assert!((score).abs() < 1e-9);
813    }
814
815    #[test]
816    fn test_bm25_score_positive_for_matching_term() {
817        let mut r = DenseRetriever::new(make_config(2));
818        r.add_document(make_doc("d1", "rust programming language", vec![1.0, 0.0]))
819            .expect("test: add_document should succeed");
820        let score = r.bm25_score(0, &["rust".to_string()]);
821        assert!(score > 0.0);
822    }
823
824    #[test]
825    fn test_bm25_score_increases_with_tf() {
826        let mut r = DenseRetriever::new(make_config(2));
827        r.add_document(make_doc(
828            "d1",
829            "rust rust rust other words here",
830            vec![1.0, 0.0],
831        ))
832        .expect("test: add_document should succeed");
833        r.add_document(make_doc("d2", "rust other words here", vec![0.0, 1.0]))
834            .expect("test: add_document should succeed");
835        let s1 = r.bm25_score(0, &["rust".to_string()]);
836        let s2 = r.bm25_score(1, &["rust".to_string()]);
837        assert!(s1 > s2, "s1={s1} s2={s2}");
838    }
839
840    // ------------------------------------------------------------------
841    // 7. sparse_search
842    // ------------------------------------------------------------------
843
844    #[test]
845    fn test_sparse_search_empty_query() {
846        let mut r = DenseRetriever::new(make_config(2));
847        r.add_document(make_doc("d1", "hello world", vec![1.0, 0.0]))
848            .expect("test: add_document should succeed");
849        let res = r.sparse_search("", 5);
850        assert!(res.is_empty());
851    }
852
853    #[test]
854    fn test_sparse_search_returns_sorted_desc() {
855        let mut r = DenseRetriever::new(make_config(2));
856        r.add_document(make_doc("d1", "rust", vec![1.0, 0.0]))
857            .expect("test: add_document should succeed");
858        r.add_document(make_doc("d2", "rust rust programming", vec![0.0, 1.0]))
859            .expect("test: add_document should succeed");
860        let res = r.sparse_search("rust", 2);
861        assert!(!res.is_empty());
862        assert!(res[0].1 >= res[1].1);
863    }
864
865    #[test]
866    fn test_sparse_search_no_match_returns_all_zero() {
867        let mut r = DenseRetriever::new(make_config(2));
868        r.add_document(make_doc("d1", "apple banana", vec![1.0, 0.0]))
869            .expect("test: add_document should succeed");
870        let res = r.sparse_search("zephyr", 1);
871        // Document appears with score 0; the list is still non-empty but trivially zeroed.
872        if !res.is_empty() {
873            assert!((res[0].1).abs() < 1e-9);
874        }
875    }
876
877    // ------------------------------------------------------------------
878    // 8. hybrid_search
879    // ------------------------------------------------------------------
880
881    fn make_query(text: &str, emb: Vec<f64>, k: usize, alpha: f64) -> RetrievalQuery {
882        RetrievalQuery {
883            text: text.to_string(),
884            embedding: emb,
885            top_k: k,
886            hybrid_alpha: alpha,
887        }
888    }
889
890    #[test]
891    fn test_hybrid_search_increments_query_count() {
892        let mut r = DenseRetriever::new(make_config(2));
893        r.add_document(make_doc("d1", "hello", vec![1.0, 0.0]))
894            .expect("test: add_document should succeed");
895        let q = make_query("hello", vec![1.0, 0.0], 1, 0.5);
896        r.hybrid_search(&q);
897        r.hybrid_search(&q);
898        assert_eq!(r.total_queries, 2);
899    }
900
901    #[test]
902    fn test_hybrid_search_returns_at_most_top_k() {
903        let mut r = DenseRetriever::new(make_config(2));
904        for i in 0..10u32 {
905            r.add_document(make_doc(
906                &i.to_string(),
907                "hello world",
908                vec![i as f64 + 1.0, 1.0],
909            ))
910            .expect("test: add_document should succeed");
911        }
912        let q = make_query("hello world", vec![1.0, 0.5], 3, 0.5);
913        let res = r.hybrid_search(&q);
914        assert!(res.len() <= 3);
915    }
916
917    #[test]
918    fn test_hybrid_search_ranks_start_at_one() {
919        let mut r = DenseRetriever::new(make_config(2));
920        r.add_document(make_doc("d1", "hello", vec![1.0, 0.0]))
921            .expect("test: add_document should succeed");
922        r.add_document(make_doc("d2", "world", vec![0.0, 1.0]))
923            .expect("test: add_document should succeed");
924        let q = make_query("hello world", vec![0.8, 0.2], 2, 0.5);
925        let res = r.hybrid_search(&q);
926        assert_eq!(res[0].rank, 1);
927        if res.len() > 1 {
928            assert_eq!(res[1].rank, 2);
929        }
930    }
931
932    #[test]
933    fn test_hybrid_search_pure_dense_alpha_one() {
934        let mut r = DenseRetriever::new(make_config(2));
935        r.add_document(make_doc("d1", "irrelevant text abc", vec![1.0, 0.0]))
936            .expect("test: add_document should succeed");
937        r.add_document(make_doc("d2", "irrelevant text xyz", vec![0.0, 1.0]))
938            .expect("test: add_document should succeed");
939
940        // Query embedding aligned with d1; alpha=1.0 → pure dense
941        let q = make_query("unrelated", vec![1.0, 0.0], 2, 1.0);
942        let res = r.hybrid_search(&q);
943        // With alpha=1.0 sparse_score is irrelevant; top result must be d1.
944        assert_eq!(res[0].doc_id, "d1");
945        // And the sparse component of the hybrid score contributes 0.
946        // hybrid_score = 1.0 * dense_score + 0.0 * sparse_score
947        assert!((res[0].hybrid_score - res[0].dense_score).abs() < 1e-9);
948    }
949
950    #[test]
951    fn test_hybrid_search_pure_sparse_alpha_zero() {
952        let mut r = DenseRetriever::new(make_config(2));
953        r.add_document(make_doc("d1", "rust programming systems", vec![0.5, 0.5]))
954            .expect("test: add_document should succeed");
955        r.add_document(make_doc("d2", "python scripting", vec![0.5, 0.5]))
956            .expect("test: add_document should succeed");
957
958        // Both embeddings are identical so dense is a tie; sparse breaks the tie.
959        let q = make_query("rust", vec![0.5, 0.5], 2, 0.0);
960        let res = r.hybrid_search(&q);
961        assert_eq!(res[0].doc_id, "d1", "BM25 should prefer d1 for 'rust'");
962    }
963
964    #[test]
965    fn test_hybrid_score_formula() {
966        // hybrid_score = alpha * dense + (1 - alpha) * sparse
967        let alpha = 0.6_f64;
968        let dense = 0.8_f64;
969        let sparse = 0.5_f64;
970        let expected = alpha * dense + (1.0 - alpha) * sparse;
971        let computed = alpha * dense + (1.0 - alpha) * sparse;
972        assert!((expected - computed).abs() < 1e-12);
973    }
974
975    #[test]
976    fn test_hybrid_search_empty_index() {
977        let mut r = DenseRetriever::new(make_config(2));
978        let q = make_query("hello", vec![1.0, 0.0], 5, 0.5);
979        let res = r.hybrid_search(&q);
980        assert!(res.is_empty());
981    }
982
983    #[test]
984    fn test_hybrid_search_alpha_clamp_above_one() {
985        let mut r = DenseRetriever::new(make_config(2));
986        r.add_document(make_doc("d1", "foo", vec![1.0, 0.0]))
987            .expect("test: add_document should succeed");
988        let q = make_query("foo", vec![1.0, 0.0], 1, 2.5); // alpha > 1.0
989        let res = r.hybrid_search(&q);
990        assert_eq!(res.len(), 1);
991        // Clamped to 1.0 → hybrid_score == dense_score
992        assert!((res[0].hybrid_score - res[0].dense_score).abs() < 1e-9);
993    }
994
995    #[test]
996    fn test_hybrid_search_alpha_clamp_below_zero() {
997        let mut r = DenseRetriever::new(make_config(2));
998        r.add_document(make_doc("d1", "foo", vec![1.0, 0.0]))
999            .expect("test: add_document should succeed");
1000        let q = make_query("foo", vec![1.0, 0.0], 1, -0.5); // alpha < 0.0
1001        let res = r.hybrid_search(&q);
1002        assert_eq!(res.len(), 1);
1003        // Clamped to 0.0 → hybrid_score == sparse_score
1004        assert!((res[0].hybrid_score - res[0].sparse_score).abs() < 1e-9);
1005    }
1006
1007    // ------------------------------------------------------------------
1008    // 9. get_document / document_count
1009    // ------------------------------------------------------------------
1010
1011    #[test]
1012    fn test_get_document_found() {
1013        let mut r = DenseRetriever::new(make_config(2));
1014        r.add_document(make_doc("d1", "hello", vec![1.0, 0.0]))
1015            .expect("test: add_document should succeed");
1016        let doc = r.get_document("d1");
1017        assert!(doc.is_some());
1018        assert_eq!(
1019            doc.expect("test: get_document should return Some after insert")
1020                .id,
1021            "d1"
1022        );
1023    }
1024
1025    #[test]
1026    fn test_get_document_not_found() {
1027        let r = DenseRetriever::new(make_config(2));
1028        assert!(r.get_document("missing").is_none());
1029    }
1030
1031    #[test]
1032    fn test_document_count_after_operations() {
1033        let mut r = DenseRetriever::new(make_config(2));
1034        assert_eq!(r.document_count(), 0);
1035        r.add_document(make_doc("d1", "a", vec![1.0, 0.0]))
1036            .expect("test: add_document should succeed");
1037        assert_eq!(r.document_count(), 1);
1038        r.add_document(make_doc("d2", "b", vec![0.0, 1.0]))
1039            .expect("test: add_document should succeed");
1040        assert_eq!(r.document_count(), 2);
1041        r.remove_document("d1");
1042        assert_eq!(r.document_count(), 1);
1043    }
1044
1045    // ------------------------------------------------------------------
1046    // 10. retriever_stats
1047    // ------------------------------------------------------------------
1048
1049    #[test]
1050    fn test_stats_after_queries() {
1051        let mut r = DenseRetriever::new(make_config(2));
1052        r.add_document(make_doc("d1", "word1 word2", vec![1.0, 0.0]))
1053            .expect("test: add_document should succeed");
1054        let q = make_query("word1", vec![1.0, 0.0], 1, 0.5);
1055        r.hybrid_search(&q);
1056        let stats = r.retriever_stats();
1057        assert_eq!(stats.document_count, 1);
1058        assert_eq!(stats.total_queries, 1);
1059        assert!(stats.vocabulary_size >= 2);
1060    }
1061
1062    #[test]
1063    fn test_stats_vocabulary_size() {
1064        let mut r = DenseRetriever::new(make_config(2));
1065        r.add_document(make_doc("d1", "apple banana cherry", vec![1.0, 0.0]))
1066            .expect("test: add_document should succeed");
1067        r.add_document(make_doc("d2", "cherry date elderberry", vec![0.0, 1.0]))
1068            .expect("test: add_document should succeed");
1069        let stats = r.retriever_stats();
1070        // unique tokens: apple, banana, cherry, date, elderberry = 5
1071        assert_eq!(stats.vocabulary_size, 5);
1072    }
1073
1074    // ------------------------------------------------------------------
1075    // 11. min_max_normalise helper
1076    // ------------------------------------------------------------------
1077
1078    #[test]
1079    fn test_min_max_normalise_empty() {
1080        let out = min_max_normalise(&[]);
1081        assert!(out.is_empty());
1082    }
1083
1084    #[test]
1085    fn test_min_max_normalise_single_element() {
1086        let out = min_max_normalise(&[(0, 5.0)]);
1087        // Single element → range == 0 → normalised to 1.0
1088        assert!((out[0].1 - 1.0).abs() < 1e-9);
1089    }
1090
1091    #[test]
1092    fn test_min_max_normalise_range() {
1093        let scores = vec![(0, 0.0), (1, 5.0), (2, 10.0)];
1094        let out = min_max_normalise(&scores);
1095        assert!((out[0].1 - 0.0).abs() < 1e-9);
1096        assert!((out[1].1 - 0.5).abs() < 1e-9);
1097        assert!((out[2].1 - 1.0).abs() < 1e-9);
1098    }
1099
1100    #[test]
1101    fn test_min_max_normalise_all_equal() {
1102        let scores = vec![(0, 3.0), (1, 3.0), (2, 3.0)];
1103        let out = min_max_normalise(&scores);
1104        for (_, s) in &out {
1105            assert!((s - 1.0).abs() < 1e-9);
1106        }
1107    }
1108
1109    // ------------------------------------------------------------------
1110    // 12. rebuild_bm25 / BM25 consistency
1111    // ------------------------------------------------------------------
1112
1113    #[test]
1114    fn test_rebuild_bm25_idempotent() {
1115        let mut r = DenseRetriever::new(make_config(2));
1116        r.add_document(make_doc("d1", "foo bar", vec![1.0, 0.0]))
1117            .expect("test: add_document should succeed");
1118        r.add_document(make_doc("d2", "bar baz", vec![0.0, 1.0]))
1119            .expect("test: add_document should succeed");
1120        let avg_before = r.bm25.avg_doc_length;
1121        let vocab_before = r.bm25.doc_freq.len();
1122        r.rebuild_bm25();
1123        let avg_after = r.bm25.avg_doc_length;
1124        let vocab_after = r.bm25.doc_freq.len();
1125        assert!((avg_before - avg_after).abs() < 1e-9);
1126        assert_eq!(vocab_before, vocab_after);
1127    }
1128
1129    #[test]
1130    fn test_bm25_doc_freq_counts_documents_not_occurrences() {
1131        let mut r = DenseRetriever::new(make_config(2));
1132        // "rust" appears 3 times in d1 but doc_freq should be 1 for d1 alone.
1133        r.add_document(make_doc("d1", "rust rust rust", vec![1.0, 0.0]))
1134            .expect("test: add_document should succeed");
1135        assert_eq!(*r.bm25.doc_freq.get("rust").unwrap_or(&0), 1);
1136        r.add_document(make_doc("d2", "rust code", vec![0.0, 1.0]))
1137            .expect("test: add_document should succeed");
1138        assert_eq!(*r.bm25.doc_freq.get("rust").unwrap_or(&0), 2);
1139    }
1140
1141    // ------------------------------------------------------------------
1142    // 13. Error type
1143    // ------------------------------------------------------------------
1144
1145    #[test]
1146    fn test_error_display_max_documents_reached() {
1147        let err = RetrieverError::MaxDocumentsReached(50);
1148        let s = err.to_string();
1149        assert!(s.contains("50"));
1150    }
1151
1152    #[test]
1153    fn test_error_display_dimension_mismatch() {
1154        let err = RetrieverError::DimensionMismatch {
1155            expected: 128,
1156            got: 64,
1157        };
1158        let s = err.to_string();
1159        assert!(s.contains("128") && s.contains("64"));
1160    }
1161
1162    #[test]
1163    fn test_error_display_not_found() {
1164        let err = RetrieverError::DocumentNotFound("abc".to_string());
1165        assert!(err.to_string().contains("abc"));
1166    }
1167
1168    // ------------------------------------------------------------------
1169    // 14. Large-scale smoke test
1170    // ------------------------------------------------------------------
1171
1172    #[test]
1173    fn test_large_corpus_hybrid_search() {
1174        use std::collections::HashMap;
1175        let dim = 8_usize;
1176        let mut r = DenseRetriever::new(RetrieverConfig {
1177            embedding_dim: dim,
1178            max_documents: 500,
1179            bm25_k1: 1.2,
1180            bm25_b: 0.75,
1181        });
1182
1183        let words = ["alpha", "beta", "gamma", "delta", "epsilon"];
1184        for i in 0..200u32 {
1185            let word = words[(i as usize) % words.len()];
1186            let emb: Vec<f64> = (0..dim).map(|j| (i as f64 + j as f64) / 200.0).collect();
1187            r.add_document(Document {
1188                id: format!("d{i}"),
1189                content: format!("{word} document number {i}"),
1190                embedding: emb,
1191                metadata: HashMap::new(),
1192            })
1193            .expect("test: add_document in large corpus test should succeed");
1194        }
1195
1196        let q_emb: Vec<f64> = (0..dim).map(|j| j as f64 / 8.0).collect();
1197        let q = RetrievalQuery {
1198            text: "alpha document".to_string(),
1199            embedding: q_emb,
1200            top_k: 10,
1201            hybrid_alpha: 0.5,
1202        };
1203        let res = r.hybrid_search(&q);
1204        assert!(!res.is_empty());
1205        assert!(res.len() <= 10);
1206
1207        // Ranks must be strictly ascending and start at 1.
1208        for (i, hit) in res.iter().enumerate() {
1209            assert_eq!(hit.rank, i + 1);
1210        }
1211
1212        // Scores must be in descending order.
1213        for w in res.windows(2) {
1214            assert!(w[0].hybrid_score >= w[1].hybrid_score);
1215        }
1216    }
1217
1218    #[test]
1219    fn test_unit_embeddings_give_cosine_one() {
1220        let mut r = DenseRetriever::new(make_config(3));
1221        r.add_document(make_doc("d1", "x", unit_vec(3, 1.0)))
1222            .expect("test: add_document should succeed");
1223        // Cosine of two identical vectors = 1.0
1224        let res = r.dense_search(&unit_vec(3, 1.0), 1);
1225        assert!((res[0].1 - 1.0).abs() < 1e-6);
1226    }
1227}