1use anyhow::Result;
2use std::collections::HashMap;
3
4pub trait Embedder: Send + Sync {
10 fn embed(&self, text: &str) -> Result<Vec<f32>>;
12 fn embed_batch(&self, texts: &[String]) -> Result<Vec<Vec<f32>>>;
15 fn dimension(&self) -> usize;
17}
18
19pub 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
34pub 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
60pub const QUERY_INSTRUCTION_PREFIX: &str = "Represent this sentence for searching relevant passages: ";
67
68pub struct FastEmbedEmbedder {
73 model: Mutex<TextEmbedding>,
74}
75
76impl FastEmbedEmbedder {
77 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 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 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}