Skip to main content

link_r/embed/
hash.rs

1// Author: support@lituus.io
2// Copyright: lituus-io, all rights reserved
3//
4//! The deterministic hash embedder — the zero-dependency, offline fallback.
5//!
6//! Signed feature hashing of word tokens and their character n-grams into a
7//! fixed-dimensional vector, L2-normalized. It is a dense proxy for *lexical*
8//! overlap (not semantics), so it pairs with BM25 to make tests/benches/fuzz
9//! hermetic and byte-reproducible. Production semantic search uses the `onnx`
10//! embedder; see the crate docs.
11
12use 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
20/// Default character n-gram size for subword robustness.
21const DEFAULT_NGRAM: u8 = 3;
22/// Default hashing seed (fixed → deterministic across runs/platforms).
23const DEFAULT_SEED: u64 = 0x6C69_6E6B_725F_6873; // "linkr_hs"
24
25/// A deterministic feature-hashing embedder.
26#[derive(Clone, Copy, Debug)]
27pub struct HashEmbedder {
28    dim: u32,
29    ngram: u8,
30    seed: u64,
31}
32
33impl HashEmbedder {
34    /// Create a `dim`-dimensional hash embedder with default n-gram and seed.
35    #[must_use]
36    pub fn new(dim: usize) -> Self {
37        Self::with_params(dim, DEFAULT_NGRAM, DEFAULT_SEED)
38    }
39
40    /// Create with explicit parameters. All three are folded into the
41    /// [`EmbedderId`], so changing any of them invalidates an old index.
42    #[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    /// Hash a feature string into a `(bucket, sign)` and accumulate it into `row`.
53    #[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        // Use a high bit for the sign so it is independent of the bucket bits.
58        let sign = if (h >> 63) & 1 == 1 { 1.0 } else { -1.0 };
59        row[bucket] += sign;
60    }
61
62    /// Embed one text into `row` (length == dim).
63    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]; // wrong
158        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}