1pub trait Embedder: Send + Sync {
5 fn dim(&self) -> usize;
7 fn embed(&self, text: &str) -> Vec<f32>;
10 fn try_embed(&self, text: &str) -> Result<Vec<f32>, String> {
13 Ok(self.embed(text))
14 }
15}
16
17#[derive(Debug, Clone)]
25pub struct HashEmbedder {
26 dim: usize,
27}
28
29impl Default for HashEmbedder {
30 fn default() -> Self {
31 Self { dim: 512 }
32 }
33}
34
35impl HashEmbedder {
36 pub fn new(dim: usize) -> Self {
38 Self { dim: dim.max(8) }
39 }
40}
41
42impl Embedder for HashEmbedder {
43 fn dim(&self) -> usize {
44 self.dim
45 }
46
47 fn embed(&self, text: &str) -> Vec<f32> {
48 let mut v = vec![0.0f32; self.dim];
49 let normalized: String = {
51 let mut out = String::with_capacity(text.len());
52 let mut last_ws = false;
53 for ch in text.chars() {
54 if ch.is_whitespace() {
55 if !last_ws && !out.is_empty() {
56 out.push(' ');
57 }
58 last_ws = true;
59 } else {
60 for lower in ch.to_lowercase() {
61 out.push(lower);
62 }
63 last_ws = false;
64 }
65 }
66 out
67 };
68 let chars: Vec<char> = normalized.chars().collect();
69 if chars.is_empty() {
70 return v;
71 }
72 let mut buf = String::with_capacity(8);
73 for n in 3..=5usize {
74 if chars.len() < n {
75 buf.clear();
77 buf.extend(chars.iter());
78 bump(&mut v, &buf, self.dim);
79 continue;
80 }
81 for w in chars.windows(n) {
82 buf.clear();
83 buf.extend(w.iter());
84 bump(&mut v, &buf, self.dim);
85 }
86 }
87 let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
89 if norm > 0.0 {
90 for x in &mut v {
91 *x /= norm;
92 }
93 }
94 v
95 }
96}
97
98fn bump(v: &mut [f32], gram: &str, dim: usize) {
99 let h = av_core::hash::fnv1a(gram.as_bytes());
100 #[allow(clippy::cast_possible_truncation)]
101 let idx = (h % dim as u64) as usize;
102 let sign = if (h >> 63) == 0 { 1.0 } else { -1.0 };
103 if let Some(slot) = v.get_mut(idx) {
104 *slot += sign;
105 }
106}
107
108pub fn cosine(a: &[f32], b: &[f32]) -> f32 {
110 if a.len() != b.len() {
111 return 0.0;
112 }
113 let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
114 let na: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
115 let nb: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
116 if na == 0.0 || nb == 0.0 {
117 return 0.0;
118 }
119 (dot / (na * nb)).clamp(-1.0, 1.0)
120}
121
122#[cfg(test)]
123mod tests {
124 #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
125
126 use super::*;
127
128 #[test]
129 fn deterministic() {
130 let e = HashEmbedder::default();
131 assert_eq!(e.embed("the same text"), e.embed("the same text"));
132 }
133
134 #[test]
135 fn normalized_output() {
136 let e = HashEmbedder::default();
137 let v = e.embed("some reasonably long reasoning step about databases");
138 let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
139 assert!((norm - 1.0).abs() < 1e-5, "norm = {norm}");
140 }
141
142 #[test]
143 fn identical_texts_cosine_one() {
144 let e = HashEmbedder::default();
145 let a = e.embed("check the database for pending orders");
146 let b = e.embed("check the database for pending orders");
147 assert!((cosine(&a, &b) - 1.0).abs() < 1e-6);
148 }
149
150 #[test]
151 fn whitespace_and_case_invariant() {
152 let e = HashEmbedder::default();
153 let a = e.embed("Check the Database\nfor pending Orders");
154 let b = e.embed("check the database for pending orders");
155 assert!(cosine(&a, &b) > 0.999, "{}", cosine(&a, &b));
156 }
157
158 #[test]
159 fn paraphrase_loops_are_close_progress_is_far() {
160 let e = HashEmbedder::default();
161 let p1 = e.embed("I should try checking the order database again for the pending records");
163 let p2 = e.embed("Let me try checking the order database again for pending records");
164 let q = e.embed("The API returned 502; switching to the backup endpoint and paging the on-call");
166 let sim_paraphrase = cosine(&p1, &p2);
167 let sim_progress = cosine(&p1, &q);
168 assert!(
169 sim_paraphrase > sim_progress + 0.2,
170 "paraphrase {sim_paraphrase} vs progress {sim_progress}: separation too weak"
171 );
172 }
173
174 #[test]
175 fn empty_and_unicode_do_not_panic() {
176 let e = HashEmbedder::default();
177 assert_eq!(e.embed("").iter().map(|x| x * x).sum::<f32>(), 0.0);
178 let _ = e.embed("日本語のテキスト 🎉 emoji مرحبا");
179 let _ = e.embed("ab"); }
181
182 #[test]
183 fn cosine_edge_cases() {
184 assert_eq!(cosine(&[], &[]), 0.0);
185 assert_eq!(cosine(&[1.0], &[1.0, 2.0]), 0.0); assert_eq!(cosine(&[0.0, 0.0], &[0.0, 0.0]), 0.0); }
188}