Skip to main content

ninox_core/
embeddings.rs

1use anyhow::Result;
2use std::collections::HashMap;
3
4/// Turns text into a fixed-length vector for semantic similarity search.
5/// Kept as a trait (rather than calling `fastembed` directly from
6/// `brain.rs`) so every other test in the codebase can use a fake
7/// implementation with no network access, no ONNX runtime, and
8/// deterministic output.
9pub trait Embedder: Send + Sync {
10    /// Embed a single piece of text (e.g. a search query).
11    fn embed(&self, text: &str) -> Result<Vec<f32>>;
12    /// Embed many pieces of text in one batched call (e.g. during
13    /// `rebuild()`) — significantly faster than calling `embed` in a loop.
14    fn embed_batch(&self, texts: &[String]) -> Result<Vec<Vec<f32>>>;
15    /// The length of every vector this embedder produces.
16    fn dimension(&self) -> usize;
17}
18
19/// Cosine similarity between two equal-length vectors, in `[-1.0, 1.0]`.
20/// Returns `0.0` for a zero vector rather than dividing by zero / producing
21/// `NaN` — a zero vector carries no directional information, so "no
22/// similarity" is the correct answer.
23pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
24    let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
25    let norm_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
26    let norm_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
27    if norm_a == 0.0 || norm_b == 0.0 {
28        0.0
29    } else {
30        dot / (norm_a * norm_b)
31    }
32}
33
34/// Combine multiple independent rankings of the same ID space into one,
35/// using `score(id) = Σ 1 / (k + rank)` over every list containing `id`
36/// (rank is 1-based). This avoids comparing incomparable scales directly
37/// (e.g. FTS5 BM25 vs. cosine similarity) — only rank position matters.
38/// `k = 60` is the standard RRF constant. An id repeated within a single
39/// list only counts its first (best) rank in that list.
40pub fn reciprocal_rank_fusion(lists: &[Vec<String>], k: f64) -> Vec<(String, f64)> {
41    let mut scores: HashMap<String, f64> = HashMap::new();
42    for list in lists {
43        let mut seen_in_list: std::collections::HashSet<&str> = std::collections::HashSet::new();
44        for (idx, id) in list.iter().enumerate() {
45            if !seen_in_list.insert(id.as_str()) {
46                continue;
47            }
48            let rank = (idx + 1) as f64;
49            *scores.entry(id.clone()).or_insert(0.0) += 1.0 / (k + rank);
50        }
51    }
52    let mut scored: Vec<(String, f64)> = scores.into_iter().collect();
53    scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
54    scored
55}
56
57use fastembed::{EmbeddingModel, TextEmbedding, TextInitOptions};
58use std::{path::PathBuf, sync::Mutex};
59
60/// Instruction prefix Arctic Embed (and BGE-family models) expect on
61/// *query* text for asymmetric retrieval. Passage/entry text is embedded
62/// with no prefix. This lives here, not inside `FastEmbedEmbedder`, so the
63/// `Embedder` trait stays a plain "text in, vector out" abstraction —
64/// the model-specific convention is the caller's concern (see `brain.rs`'s
65/// hybrid query implementation).
66pub const QUERY_INSTRUCTION_PREFIX: &str = "Represent this sentence for searching relevant passages: ";
67
68/// `fastembed`-backed [`Embedder`] using `snowflake-arctic-embed-xs`
69/// (22M params, 384-dim) — see the design spec for why this model was
70/// chosen over `all-MiniLM-L6-v2` and over hand-rolling inference with
71/// `candle`.
72pub struct FastEmbedEmbedder {
73    model: Mutex<TextEmbedding>,
74}
75
76impl FastEmbedEmbedder {
77    /// Loads the model, downloading and caching it under
78    /// `{cache_dir}/ninox/fastembed` on first use. Every call after the
79    /// first (across process restarts) is fully offline.
80    pub fn try_new() -> Result<Self> {
81        let cache_dir = fastembed_cache_dir();
82        let model = TextEmbedding::try_new(
83            TextInitOptions::new(EmbeddingModel::SnowflakeArcticEmbedXS)
84                .with_cache_dir(cache_dir)
85                .with_show_download_progress(true),
86        )?;
87        Ok(Self { model: Mutex::new(model) })
88    }
89}
90
91fn fastembed_cache_dir() -> PathBuf {
92    dirs::cache_dir()
93        .unwrap_or_else(|| PathBuf::from("."))
94        .join("ninox")
95        .join("fastembed")
96}
97
98impl Embedder for FastEmbedEmbedder {
99    fn embed(&self, text: &str) -> Result<Vec<f32>> {
100        let mut model = self.model.lock().unwrap();
101        let mut out = model.embed(vec![text], None)?;
102        Ok(out.remove(0))
103    }
104
105    fn embed_batch(&self, texts: &[String]) -> Result<Vec<Vec<f32>>> {
106        let mut model = self.model.lock().unwrap();
107        model.embed(texts, None)
108    }
109
110    fn dimension(&self) -> usize {
111        384
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    #[test]
120    fn cosine_similarity_identical_vectors_is_one() {
121        let v = vec![1.0, 2.0, 3.0];
122        assert!((cosine_similarity(&v, &v) - 1.0).abs() < 1e-6);
123    }
124
125    #[test]
126    fn cosine_similarity_orthogonal_vectors_is_zero() {
127        let a = vec![1.0, 0.0];
128        let b = vec![0.0, 1.0];
129        assert!(cosine_similarity(&a, &b).abs() < 1e-6);
130    }
131
132    #[test]
133    fn cosine_similarity_opposite_vectors_is_negative_one() {
134        let a = vec![1.0, 0.0];
135        let b = vec![-1.0, 0.0];
136        assert!((cosine_similarity(&a, &b) + 1.0).abs() < 1e-6);
137    }
138
139    #[test]
140    fn cosine_similarity_zero_vector_is_zero_not_nan() {
141        let a = vec![0.0, 0.0];
142        let b = vec![1.0, 2.0];
143        assert_eq!(cosine_similarity(&a, &b), 0.0);
144    }
145
146    #[test]
147    fn rrf_single_list_preserves_order() {
148        let lists = vec![vec!["a".to_string(), "b".to_string(), "c".to_string()]];
149        let fused = reciprocal_rank_fusion(&lists, 60.0);
150        let ids: Vec<&str> = fused.iter().map(|(id, _)| id.as_str()).collect();
151        assert_eq!(ids, vec!["a", "b", "c"]);
152    }
153
154    #[test]
155    fn rrf_boosts_ids_appearing_in_both_lists() {
156        // "shared" is 2nd in list A and 3rd in list B; "a-only" is 1st in A
157        // only. Appearing in both lists should outrank a single 1st-place
158        // finish once contributions are summed.
159        let list_a = vec!["a-only".to_string(), "shared".to_string()];
160        let list_b = vec!["b-only-1".to_string(), "b-only-2".to_string(), "shared".to_string()];
161        let fused = reciprocal_rank_fusion(&[list_a, list_b], 60.0);
162        let top_id = &fused[0].0;
163        assert_eq!(top_id, "shared");
164    }
165
166    #[test]
167    fn rrf_empty_lists_returns_empty() {
168        let fused = reciprocal_rank_fusion(&[], 60.0);
169        assert!(fused.is_empty());
170    }
171
172    #[test]
173    fn rrf_deduplicates_ids_within_a_single_list() {
174        // Defensive: a caller should never pass duplicates within one list,
175        // but the scoring must not double-count if it happens.
176        let lists = vec![vec!["a".to_string(), "a".to_string()]];
177        let fused = reciprocal_rank_fusion(&lists, 60.0);
178        assert_eq!(fused.len(), 1);
179    }
180
181    #[test]
182    #[ignore = "downloads a real model and runs ONNX inference: run explicitly with `cargo test -p ninox-core --release -- --ignored fast_embed_embedder_produces_384_dim_vectors -- --nocapture`"]
183    fn fast_embed_embedder_produces_384_dim_vectors() {
184        let embedder = FastEmbedEmbedder::try_new().expect("model should load");
185        assert_eq!(embedder.dimension(), 384);
186
187        let vec = embedder.embed("hello world").expect("embed should succeed");
188        assert_eq!(vec.len(), 384);
189
190        let batch = embedder
191            .embed_batch(&["first".to_string(), "second".to_string()])
192            .expect("batch embed should succeed");
193        assert_eq!(batch.len(), 2);
194        assert_eq!(batch[0].len(), 384);
195    }
196
197    #[test]
198    #[ignore = "downloads a real model and runs ONNX inference: run explicitly with `cargo test -p ninox-core --release -- --ignored fast_embed_embedder_similar_text_scores_higher_than_unrelated -- --nocapture`"]
199    fn fast_embed_embedder_similar_text_scores_higher_than_unrelated() {
200        let embedder = FastEmbedEmbedder::try_new().expect("model should load");
201        let query = embedder
202            .embed(&format!("{QUERY_INSTRUCTION_PREFIX}auth failures"))
203            .unwrap();
204        let related = embedder.embed("401 debugging notes").unwrap();
205        let unrelated = embedder.embed("chocolate chip cookie recipe").unwrap();
206
207        let sim_related = cosine_similarity(&query, &related);
208        let sim_unrelated = cosine_similarity(&query, &unrelated);
209        assert!(
210            sim_related > sim_unrelated,
211            "expected related text to score higher: related={sim_related}, unrelated={sim_unrelated}"
212        );
213    }
214}