nusy_graph_query/
embedding.rs1#[derive(Debug, thiserror::Error)]
9pub enum EmbeddingError {
10 #[error("Embedding dimension mismatch: expected {expected}, got {actual}")]
11 DimensionMismatch { expected: usize, actual: usize },
12
13 #[error("Provider error: {0}")]
14 Provider(String),
15}
16
17pub type Result<T> = std::result::Result<T, EmbeddingError>;
18
19pub trait EmbeddingProvider {
24 fn embed_batch(&self, texts: &[String]) -> Result<Vec<Vec<f32>>>;
26
27 fn embed(&self, text: &str) -> Result<Vec<f32>> {
29 let results = self.embed_batch(&[text.to_string()])?;
30 results
31 .into_iter()
32 .next()
33 .ok_or_else(|| EmbeddingError::Provider("empty result".to_string()))
34 }
35
36 fn dim(&self) -> usize;
38}
39
40pub struct HashEmbeddingProvider {
46 dim: usize,
47}
48
49impl HashEmbeddingProvider {
50 pub fn new(dim: usize) -> Self {
52 Self { dim }
53 }
54}
55
56impl EmbeddingProvider for HashEmbeddingProvider {
57 fn embed_batch(&self, texts: &[String]) -> Result<Vec<Vec<f32>>> {
58 Ok(texts.iter().map(|t| hash_to_vector(t, self.dim)).collect())
59 }
60
61 fn dim(&self) -> usize {
62 self.dim
63 }
64}
65
66pub fn hash_to_vector(text: &str, dim: usize) -> Vec<f32> {
71 use sha2::{Digest, Sha256};
72 let mut vec = Vec::with_capacity(dim);
73
74 let mut seed = text.to_string();
77 while vec.len() < dim {
78 let mut hasher = Sha256::new();
79 hasher.update(seed.as_bytes());
80 let hash = hasher.finalize();
81 for chunk in hash.chunks(4) {
82 if vec.len() >= dim {
83 break;
84 }
85 let bytes: [u8; 4] = chunk.try_into().expect("4 bytes from sha256 chunk");
86 let val = (u32::from_le_bytes(bytes) as f64 / u32::MAX as f64 * 2.0 - 1.0) as f32;
88 vec.push(val);
89 }
90 seed = format!("{seed}+");
91 }
92
93 let norm: f32 = vec.iter().map(|x| x * x).sum::<f32>().sqrt();
95 if norm > 0.0 {
96 for v in &mut vec {
97 *v /= norm;
98 }
99 }
100
101 vec
102}
103
104pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
109 if a.len() != b.len() || a.is_empty() {
110 return 0.0;
111 }
112
113 let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
114 let norm_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
115 let norm_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
116
117 if norm_a == 0.0 || norm_b == 0.0 {
118 return 0.0;
119 }
120
121 dot / (norm_a * norm_b)
122}
123
124#[derive(Debug, Clone)]
128pub struct EmbeddedItem {
129 pub id: String,
130 pub vector: Vec<f32>,
131}
132
133#[derive(Debug, Clone)]
135pub struct SearchResult {
136 pub id: String,
137 pub score: f32,
138}
139
140pub fn semantic_search(
145 embeddings: &[EmbeddedItem],
146 query: &str,
147 provider: &dyn EmbeddingProvider,
148 top_k: usize,
149) -> Result<Vec<SearchResult>> {
150 let query_vec = provider.embed(query)?;
151
152 let mut results: Vec<SearchResult> = embeddings
153 .iter()
154 .map(|item| SearchResult {
155 id: item.id.clone(),
156 score: cosine_similarity(&query_vec, &item.vector),
157 })
158 .collect();
159
160 results.sort_by(|a, b| {
161 b.score
162 .partial_cmp(&a.score)
163 .unwrap_or(std::cmp::Ordering::Equal)
164 });
165 results.truncate(top_k);
166
167 Ok(results)
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173
174 fn provider(dim: usize) -> HashEmbeddingProvider {
175 HashEmbeddingProvider::new(dim)
176 }
177
178 #[test]
179 fn test_hash_embedding_deterministic() {
180 let p = provider(384);
181 let v1 = p.embed("hello world").unwrap();
182 let v2 = p.embed("hello world").unwrap();
183 assert_eq!(v1, v2);
184 assert_eq!(v1.len(), 384);
185 }
186
187 #[test]
188 fn test_hash_embedding_configurable_dim() {
189 let p384 = provider(384);
190 let p768 = provider(768);
191 assert_eq!(p384.embed("test").unwrap().len(), 384);
192 assert_eq!(p768.embed("test").unwrap().len(), 768);
193 }
194
195 #[test]
196 fn test_hash_embedding_unit_length() {
197 let p = provider(384);
198 let v = p.embed("test input").unwrap();
199 let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
200 assert!(
201 (norm - 1.0).abs() < 1e-5,
202 "Vector should be unit length, got norm={norm}"
203 );
204 }
205
206 #[test]
207 fn test_hash_embedding_different_inputs_differ() {
208 let p = provider(384);
209 let v1 = p.embed("arrow kanban").unwrap();
210 let v2 = p.embed("signal fusion").unwrap();
211 assert_ne!(v1, v2);
212 }
213
214 #[test]
215 fn test_cosine_similarity_identical() {
216 let v = vec![1.0, 0.0, 0.0];
217 assert!((cosine_similarity(&v, &v) - 1.0).abs() < 1e-6);
218 }
219
220 #[test]
221 fn test_cosine_similarity_orthogonal() {
222 let a = vec![1.0, 0.0, 0.0];
223 let b = vec![0.0, 1.0, 0.0];
224 assert!(cosine_similarity(&a, &b).abs() < 1e-6);
225 }
226
227 #[test]
228 fn test_cosine_similarity_opposite() {
229 let a = vec![1.0, 0.0];
230 let b = vec![-1.0, 0.0];
231 assert!((cosine_similarity(&a, &b) + 1.0).abs() < 1e-6);
232 }
233
234 #[test]
235 fn test_cosine_similarity_empty() {
236 assert_eq!(cosine_similarity(&[], &[]), 0.0);
237 }
238
239 #[test]
240 fn test_cosine_similarity_length_mismatch() {
241 assert_eq!(cosine_similarity(&[1.0], &[1.0, 2.0]), 0.0);
242 }
243
244 #[test]
245 fn test_embed_batch_consistency() {
246 let p = provider(384);
247 let texts = vec!["hello".to_string(), "world".to_string()];
248 let batch_result = p.embed_batch(&texts).unwrap();
249 let single_1 = p.embed("hello").unwrap();
250 let single_2 = p.embed("world").unwrap();
251 assert_eq!(batch_result[0], single_1);
252 assert_eq!(batch_result[1], single_2);
253 }
254
255 #[test]
256 fn test_semantic_search_ranked() {
257 let p = provider(384);
258 let items: Vec<EmbeddedItem> = ["arrow kanban", "signal fusion", "graph query"]
259 .iter()
260 .map(|text| EmbeddedItem {
261 id: text.to_string(),
262 vector: p.embed(text).unwrap(),
263 })
264 .collect();
265
266 let results = semantic_search(&items, "arrow", &p, 3).unwrap();
267 assert_eq!(results.len(), 3);
268 for w in results.windows(2) {
270 assert!(w[0].score >= w[1].score);
271 }
272 }
273
274 #[test]
275 fn test_semantic_search_top_k() {
276 let p = provider(384);
277 let items: Vec<EmbeddedItem> = (0..10)
278 .map(|i| EmbeddedItem {
279 id: format!("item-{i}"),
280 vector: p.embed(&format!("item {i}")).unwrap(),
281 })
282 .collect();
283
284 let results = semantic_search(&items, "test", &p, 3).unwrap();
285 assert_eq!(results.len(), 3);
286 }
287
288 #[test]
289 fn test_semantic_search_empty() {
290 let p = provider(384);
291 let results = semantic_search(&[], "test", &p, 10).unwrap();
292 assert!(results.is_empty());
293 }
294}