sqz_engine/
minhash_lsh.rs1use std::collections::hash_map::DefaultHasher;
22use std::collections::{HashMap, HashSet};
23use std::hash::{Hash, Hasher};
24
25const NUM_HASHES: usize = 64;
27const NUM_BANDS: usize = 8;
29const ROWS_PER_BAND: usize = NUM_HASHES / NUM_BANDS;
31const SHINGLE_SIZE: usize = 3;
33
34#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct MinHashSignature {
37 pub values: Vec<u64>,
39}
40
41impl MinHashSignature {
42 pub fn jaccard_similarity(&self, other: &MinHashSignature) -> f64 {
46 if self.values.len() != other.values.len() || self.values.is_empty() {
47 return 0.0;
48 }
49 let matches = self
50 .values
51 .iter()
52 .zip(other.values.iter())
53 .filter(|(a, b)| a == b)
54 .count();
55 matches as f64 / self.values.len() as f64
56 }
57}
58
59pub struct MinHashLsh {
64 bands: Vec<HashMap<u64, HashSet<u64>>>,
66 signatures: HashMap<u64, MinHashSignature>,
68}
69
70impl Default for MinHashLsh {
71 fn default() -> Self {
72 Self::new()
73 }
74}
75
76impl MinHashLsh {
77 pub fn new() -> Self {
79 let bands = (0..NUM_BANDS).map(|_| HashMap::new()).collect();
80 Self {
81 bands,
82 signatures: HashMap::new(),
83 }
84 }
85
86 pub fn insert(&mut self, doc_id: u64, text: &str) {
91 let sig = Self::compute_signature(text);
92 self.insert_into_bands(doc_id, &sig);
93 self.signatures.insert(doc_id, sig);
94 }
95
96 pub fn query(&self, text: &str) -> Vec<u64> {
103 let sig = Self::compute_signature(text);
104 let mut candidates = HashSet::new();
105
106 for (band_idx, band_map) in self.bands.iter().enumerate() {
107 let band_hash = Self::hash_band(&sig, band_idx);
108 if let Some(doc_ids) = band_map.get(&band_hash) {
109 for &id in doc_ids {
110 candidates.insert(id);
111 }
112 }
113 }
114
115 let mut result: Vec<u64> = candidates.into_iter().collect();
116 result.sort_unstable();
117 result
118 }
119
120 pub fn compute_signature(text: &str) -> MinHashSignature {
122 let shingles = Self::shingle(text);
123 if shingles.is_empty() {
124 return MinHashSignature {
125 values: vec![u64::MAX; NUM_HASHES],
126 };
127 }
128
129 let mut min_hashes = vec![u64::MAX; NUM_HASHES];
130
131 for shingle in &shingles {
132 for i in 0..NUM_HASHES {
133 let h = Self::hash_with_seed(shingle, i as u64);
134 if h < min_hashes[i] {
135 min_hashes[i] = h;
136 }
137 }
138 }
139
140 MinHashSignature { values: min_hashes }
141 }
142
143 pub fn get_signature(&self, doc_id: u64) -> Option<&MinHashSignature> {
145 self.signatures.get(&doc_id)
146 }
147
148 pub fn len(&self) -> usize {
150 self.signatures.len()
151 }
152
153 pub fn is_empty(&self) -> bool {
155 self.signatures.is_empty()
156 }
157
158 fn shingle(text: &str) -> HashSet<&str> {
162 let starts = text.char_indices().map(|(i, _)| i);
163 let ends = text.char_indices().map(|(i, _)| i).chain([text.len()]).skip(SHINGLE_SIZE);
164 let mut set = HashSet::new();
165 for (start, end) in starts.zip(ends) {
166 set.insert(&text[start..end]);
167 }
168 if set.is_empty() && !text.is_empty() {
169 set.insert(text);
170 }
171 set
172 }
173
174 fn hash_with_seed(shingle: &str, seed: u64) -> u64 {
176 let mut hasher = DefaultHasher::new();
177 seed.hash(&mut hasher);
178 shingle.hash(&mut hasher);
179 hasher.finish()
180 }
181
182 fn hash_band(sig: &MinHashSignature, band_idx: usize) -> u64 {
184 let start = band_idx * ROWS_PER_BAND;
185 let end = start + ROWS_PER_BAND;
186 let mut hasher = DefaultHasher::new();
187 for &val in &sig.values[start..end] {
188 val.hash(&mut hasher);
189 }
190 hasher.finish()
191 }
192
193 fn insert_into_bands(&mut self, doc_id: u64, sig: &MinHashSignature) {
195 for band_idx in 0..NUM_BANDS {
196 let band_hash = Self::hash_band(sig, band_idx);
197 self.bands[band_idx]
198 .entry(band_hash)
199 .or_default()
200 .insert(doc_id);
201 }
202 }
203}
204
205#[cfg(test)]
208mod tests {
209 use super::*;
210
211 #[test]
212 fn test_identical_documents_are_candidates() {
213 let mut lsh = MinHashLsh::new();
214 lsh.insert(1, "the quick brown fox jumps over the lazy dog");
215 let candidates = lsh.query("the quick brown fox jumps over the lazy dog");
216 assert!(
217 candidates.contains(&1),
218 "identical document should be a candidate"
219 );
220 }
221
222 #[test]
223 fn test_similar_documents_are_candidates() {
224 let mut lsh = MinHashLsh::new();
225 lsh.insert(1, "the quick brown fox jumps over the lazy dog and more text here");
226 lsh.insert(2, "the quick brown fox leaps over the lazy dog and more text here");
227 let candidates = lsh.query("the quick brown fox jumps over the lazy dog and more text here");
229 assert!(
230 candidates.contains(&1),
231 "identical document should always be a candidate"
232 );
233 }
234
235 #[test]
236 fn test_empty_index_returns_no_candidates() {
237 let lsh = MinHashLsh::new();
238 let candidates = lsh.query("anything");
239 assert!(candidates.is_empty());
240 }
241
242 #[test]
243 fn test_empty_text_insert_and_query() {
244 let mut lsh = MinHashLsh::new();
245 lsh.insert(1, "");
246 let candidates = lsh.query("");
247 assert!(candidates.contains(&1));
249 }
250
251 #[test]
252 fn test_signature_jaccard_identical() {
253 let sig = MinHashLsh::compute_signature("hello world foo bar baz");
254 let similarity = sig.jaccard_similarity(&sig);
255 assert!(
256 (similarity - 1.0).abs() < f64::EPSILON,
257 "identical signatures should have Jaccard similarity 1.0"
258 );
259 }
260
261 #[test]
262 fn test_signature_jaccard_different() {
263 let sig_a = MinHashLsh::compute_signature(
264 "the quick brown fox jumps over the lazy dog and some extra padding text",
265 );
266 let sig_b = MinHashLsh::compute_signature(
267 "completely different content about quantum physics and mathematics research",
268 );
269 let similarity = sig_a.jaccard_similarity(&sig_b);
270 assert!(
271 similarity < 1.0,
272 "different texts should have Jaccard similarity < 1.0, got {similarity}"
273 );
274 }
275
276 #[test]
277 fn test_multiple_inserts_and_query() {
278 let mut lsh = MinHashLsh::new();
279 for i in 0..10 {
280 lsh.insert(i, &format!("document number {i} with some shared content here"));
281 }
282 assert_eq!(lsh.len(), 10);
283 assert!(!lsh.is_empty());
284
285 let candidates = lsh.query("document number 5 with some shared content here");
286 assert!(
287 candidates.contains(&5),
288 "exact match should be a candidate"
289 );
290 }
291
292 #[test]
293 fn test_get_signature() {
294 let mut lsh = MinHashLsh::new();
295 lsh.insert(42, "test document");
296 assert!(lsh.get_signature(42).is_some());
297 assert!(lsh.get_signature(99).is_none());
298 }
299
300 #[test]
301 fn test_shingles_hold_only_distinct_windows() {
302 let repeated = "ab".repeat(4096);
303 let shingles = MinHashLsh::shingle(&repeated);
304 assert_eq!(shingles.len(), 2);
305 assert!(shingles.capacity() < 64, "capacity {}", shingles.capacity());
306 for text in ["", "a", "日本", "abc", "héllo wörld", "日本語のテキスト、句読点"] {
307 let chars: Vec<char> = text.chars().collect();
308 let mut want: HashSet<String> =
309 chars.windows(SHINGLE_SIZE).map(|w| w.iter().collect()).collect();
310 if want.is_empty() && !text.is_empty() {
311 want.insert(text.to_owned());
312 }
313 let got: HashSet<String> =
314 MinHashLsh::shingle(text).iter().map(|s| s.to_string()).collect();
315 assert_eq!(got, want, "{text:?}");
316 }
317 }
318
319 use proptest::prelude::*;
322
323 proptest! {
324 #[test]
326 fn prop_identical_texts_same_signature(text in "[a-z ]{5,100}") {
327 let sig_a = MinHashLsh::compute_signature(&text);
328 let sig_b = MinHashLsh::compute_signature(&text);
329 prop_assert_eq!(sig_a, sig_b);
330 }
331
332 #[test]
334 fn prop_self_similarity_is_one(text in "[a-z ]{5,100}") {
335 let sig = MinHashLsh::compute_signature(&text);
336 let sim = sig.jaccard_similarity(&sig);
337 prop_assert!((sim - 1.0).abs() < f64::EPSILON);
338 }
339
340 #[test]
342 fn prop_jaccard_bounded(a in "[a-z ]{5,100}", b in "[a-z ]{5,100}") {
343 let sig_a = MinHashLsh::compute_signature(&a);
344 let sig_b = MinHashLsh::compute_signature(&b);
345 let sim = sig_a.jaccard_similarity(&sig_b);
346 prop_assert!(sim >= 0.0 && sim <= 1.0, "similarity out of bounds: {sim}");
347 }
348
349 #[test]
351 fn prop_self_query_returns_self(text in "[a-z ]{5,100}", id in 1u64..10000) {
352 let mut lsh = MinHashLsh::new();
353 lsh.insert(id, &text);
354 let candidates = lsh.query(&text);
355 prop_assert!(
356 candidates.contains(&id),
357 "document {id} should be a candidate for its own text"
358 );
359 }
360 }
361}