1use crate::embed::{Embedder, EmbedderId};
13use crate::error::{Error, Result};
14use crate::index::dense::l2_normalize;
15use crate::metric::Metric;
16use crate::text;
17use graph_r::hash::xxh3_64_seeded as xxh3_64_with_seed;
18use std::future::Ready;
19
20const DEFAULT_NGRAM: u8 = 3;
22const DEFAULT_SEED: u64 = 0x6C69_6E6B_725F_6873; #[derive(Clone, Copy, Debug)]
27pub struct HashEmbedder {
28 dim: u32,
29 ngram: u8,
30 seed: u64,
31}
32
33impl HashEmbedder {
34 #[must_use]
36 pub fn new(dim: usize) -> Self {
37 Self::with_params(dim, DEFAULT_NGRAM, DEFAULT_SEED)
38 }
39
40 #[must_use]
43 pub fn with_params(dim: usize, ngram: u8, seed: u64) -> Self {
44 assert!(dim > 0, "embedding dimension must be positive");
45 Self {
46 dim: dim as u32,
47 ngram,
48 seed,
49 }
50 }
51
52 #[inline]
54 fn add_feature(&self, feature: &str, row: &mut [f32]) {
55 let h = xxh3_64_with_seed(feature.as_bytes(), self.seed);
56 let bucket = (h % u64::from(self.dim)) as usize;
57 let sign = if (h >> 63) & 1 == 1 { 1.0 } else { -1.0 };
59 row[bucket] += sign;
60 }
61
62 fn embed_into(&self, text_in: &str, row: &mut [f32]) {
64 row.fill(0.0);
65 for token in text::normalized_tokens(text_in) {
66 self.add_feature(&token, row);
67 text::for_each_char_ngram(&token, self.ngram as usize, |g| self.add_feature(g, row));
68 }
69 l2_normalize(row);
70 }
71}
72
73impl Embedder for HashEmbedder {
74 type EmbedFuture<'a> = Ready<Result<()>>;
75
76 fn dim(&self) -> usize {
77 self.dim as usize
78 }
79
80 fn metric(&self) -> Metric {
81 Metric::Cosine
82 }
83
84 fn identity(&self) -> EmbedderId {
85 let params = u64::from(self.ngram) ^ self.seed.rotate_left(8);
86 EmbedderId::derive("hash", self.dim as usize, params)
87 }
88
89 fn embed_batch<'a>(
90 &'a self,
91 texts: &'a [&'a str],
92 out: &'a mut [f32],
93 ) -> Self::EmbedFuture<'a> {
94 let dim = self.dim as usize;
95 if out.len() != texts.len() * dim {
96 return std::future::ready(Err(Error::embed(format!(
97 "output buffer is {} floats, expected {} ({} texts × {dim})",
98 out.len(),
99 texts.len() * dim,
100 texts.len()
101 ))));
102 }
103 for (i, t) in texts.iter().enumerate() {
104 self.embed_into(t, &mut out[i * dim..(i + 1) * dim]);
105 }
106 std::future::ready(Ok(()))
107 }
108}
109
110#[cfg(test)]
111mod tests {
112 use super::*;
113 use crate::embed::embed_one;
114 use crate::index::dense::dot;
115
116 #[tokio::test]
117 async fn embedding_is_unit_length() {
118 let e = HashEmbedder::new(64);
119 let v = embed_one(&e, "the quick brown fox").await.unwrap();
120 assert_eq!(v.len(), 64);
121 assert!((dot(&v, &v).sqrt() - 1.0).abs() < 1e-5);
122 }
123
124 #[tokio::test]
125 async fn embedding_is_deterministic() {
126 let e = HashEmbedder::new(128);
127 let a = embed_one(&e, "BigQuery row access policy").await.unwrap();
128 let b = embed_one(&e, "BigQuery row access policy").await.unwrap();
129 assert_eq!(a, b);
130 }
131
132 #[tokio::test]
133 async fn shared_vocabulary_scores_higher_than_disjoint() {
134 let e = HashEmbedder::new(256);
135 let q = embed_one(&e, "kubernetes autopilot cluster").await.unwrap();
136 let near = embed_one(&e, "autopilot kubernetes cluster setup")
137 .await
138 .unwrap();
139 let far = embed_one(&e, "banana smoothie recipe").await.unwrap();
140 assert!(dot(&q, &near) > dot(&q, &far));
141 }
142
143 #[tokio::test]
144 async fn batch_matches_single() {
145 let e = HashEmbedder::new(96);
146 let texts = ["alpha beta", "gamma delta"];
147 let mut out = vec![0.0f32; 2 * 96];
148 e.embed_batch(&texts, &mut out).await.unwrap();
149 let single0 = embed_one(&e, "alpha beta").await.unwrap();
150 assert_eq!(&out[..96], single0.as_slice());
151 }
152
153 #[tokio::test]
154 async fn wrong_buffer_size_errors() {
155 let e = HashEmbedder::new(32);
156 let texts = ["x"];
157 let mut out = vec![0.0f32; 16]; assert!(e.embed_batch(&texts, &mut out).await.is_err());
159 }
160
161 #[test]
162 fn identity_depends_on_params() {
163 assert_ne!(
164 HashEmbedder::new(64).identity(),
165 HashEmbedder::new(128).identity()
166 );
167 assert_ne!(
168 HashEmbedder::with_params(64, 3, 1).identity(),
169 HashEmbedder::with_params(64, 3, 2).identity()
170 );
171 }
172
173 #[tokio::test]
174 async fn empty_text_yields_zero_vector() {
175 let e = HashEmbedder::new(32);
176 let v = embed_one(&e, "").await.unwrap();
177 assert!(v.iter().all(|&x| x == 0.0));
178 }
179}