160 lines
5.5 KiB
Rust
160 lines
5.5 KiB
Rust
//! Deterministic text embedding for semantic intent matching.
|
|
//!
|
|
//! No ML model dependency: utterances are embedded with the classic
|
|
//! **feature-hashing** (hashing-vectorizer) technique. Each n-gram feature is
|
|
//! hashed into a fixed-width vector; a second sign-hash decides whether the
|
|
//! feature adds or subtracts, which keeps the expected dot-product unbiased
|
|
//! under collisions. The vector is L2-normalised so that cosine similarity is
|
|
//! a clean `1 - distance`.
|
|
//!
|
|
//! Features used per utterance:
|
|
//! - **word unigrams** — whole tokens after lowercasing/trimming punctuation.
|
|
//! - **character trigrams** — sliding 3-grams over each padded token, which
|
|
//! gives partial-overlap credit ("kitchen" ~ "kitchens") and robustness to
|
|
//! small lexical variation.
|
|
//!
|
|
//! This is intentionally *lexical-semantic*: paraphrases that share tokens
|
|
//! ("turn on the light" vs "turn on the kitchen light") land close together,
|
|
//! while unrelated utterances ("play jazz music") land far apart. It is a real,
|
|
//! reproducible similarity signal — not a hash that ignores meaning.
|
|
//!
|
|
//! The output dimension matches [`EMBEDDING_DIM`] and is consumed directly by
|
|
//! the exact in-memory cosine k-NN in `crate::semantic_recognizer`.
|
|
|
|
/// Dimensionality of the hashed embedding space.
|
|
///
|
|
/// 256 buckets keeps collisions low for the small intent vocabularies HOMECORE
|
|
/// deals with while staying cheap to index in HNSW.
|
|
pub const EMBEDDING_DIM: usize = 256;
|
|
|
|
// FNV-1a 64 constants — small, fast, well-distributed for feature hashing.
|
|
const FNV_OFFSET_BASIS_64: u64 = 0xcbf2_9ce4_8422_2325;
|
|
const FNV_PRIME_64: u64 = 0x0000_0100_0000_01b3;
|
|
|
|
#[inline]
|
|
fn fnv1a64(seed: u64, bytes: &[u8]) -> u64 {
|
|
let mut hash = seed;
|
|
for &b in bytes {
|
|
hash ^= u64::from(b);
|
|
hash = hash.wrapping_mul(FNV_PRIME_64);
|
|
}
|
|
hash
|
|
}
|
|
|
|
/// Accumulate one hashed feature into `acc` with signed weight.
|
|
#[inline]
|
|
fn add_feature(acc: &mut [f32], feature: &[u8], weight: f32) {
|
|
let h = fnv1a64(FNV_OFFSET_BASIS_64, feature);
|
|
let bucket = (h % EMBEDDING_DIM as u64) as usize;
|
|
// Independent sign hash (different seed) → unbiased under collisions.
|
|
let sign = if fnv1a64(0x100, feature) & 1 == 0 { 1.0 } else { -1.0 };
|
|
acc[bucket] += sign * weight;
|
|
}
|
|
|
|
/// Normalise text: lowercase, keep alphanumerics, split on everything else.
|
|
fn tokenize(text: &str) -> Vec<String> {
|
|
text.to_lowercase()
|
|
.split(|c: char| !c.is_alphanumeric())
|
|
.filter(|s| !s.is_empty())
|
|
.map(|s| s.to_owned())
|
|
.collect()
|
|
}
|
|
|
|
/// Embed an utterance into a deterministic, L2-normalised vector.
|
|
///
|
|
/// Returns a zero vector only for input with no alphanumeric content.
|
|
pub fn embed(text: &str) -> Vec<f32> {
|
|
let mut acc = vec![0.0_f32; EMBEDDING_DIM];
|
|
let tokens = tokenize(text);
|
|
|
|
for tok in &tokens {
|
|
// Word unigram — weighted higher than sub-word features.
|
|
add_feature(&mut acc, format!("w:{tok}").as_bytes(), 1.5);
|
|
|
|
// Character trigrams over a padded token so prefixes/suffixes count.
|
|
let padded: Vec<char> = format!("^{tok}$").chars().collect();
|
|
if padded.len() >= 3 {
|
|
for window in padded.windows(3) {
|
|
let gram: String = window.iter().collect();
|
|
add_feature(&mut acc, format!("c:{gram}").as_bytes(), 1.0);
|
|
}
|
|
}
|
|
}
|
|
|
|
l2_normalise(&mut acc);
|
|
acc
|
|
}
|
|
|
|
/// L2-normalise in place; no-op for the zero vector.
|
|
fn l2_normalise(v: &mut [f32]) {
|
|
let norm = v.iter().map(|x| x * x).sum::<f32>().sqrt();
|
|
if norm > 1e-12 {
|
|
for x in v.iter_mut() {
|
|
*x /= norm;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Cosine similarity of two equal-length vectors (dot product of unit vectors).
|
|
///
|
|
/// Exposed for tests and for callers that want similarity without round-tripping
|
|
/// through the HNSW index.
|
|
pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
|
|
debug_assert_eq!(a.len(), b.len());
|
|
a.iter().zip(b).map(|(x, y)| x * y).sum()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn embedding_has_correct_dim() {
|
|
assert_eq!(embed("turn on the light").len(), EMBEDDING_DIM);
|
|
}
|
|
|
|
#[test]
|
|
fn embedding_is_deterministic() {
|
|
assert_eq!(embed("turn on the light"), embed("turn on the light"));
|
|
}
|
|
|
|
#[test]
|
|
fn embedding_is_unit_norm() {
|
|
let v = embed("turn on the kitchen light");
|
|
let norm_sq: f32 = v.iter().map(|x| x * x).sum();
|
|
assert!((norm_sq - 1.0).abs() < 1e-4, "norm^2 = {norm_sq}");
|
|
}
|
|
|
|
#[test]
|
|
fn empty_input_is_zero_vector() {
|
|
let v = embed("!!! ???");
|
|
assert!(v.iter().all(|x| *x == 0.0));
|
|
}
|
|
|
|
#[test]
|
|
fn paraphrase_is_more_similar_than_unrelated() {
|
|
let exemplar = embed("turn on the light");
|
|
let paraphrase = embed("turn on the kitchen light");
|
|
let unrelated = embed("play some jazz music");
|
|
|
|
let sim_para = cosine_similarity(&exemplar, ¶phrase);
|
|
let sim_unrel = cosine_similarity(&exemplar, &unrelated);
|
|
|
|
assert!(
|
|
sim_para > sim_unrel,
|
|
"paraphrase ({sim_para:.3}) must beat unrelated ({sim_unrel:.3})"
|
|
);
|
|
// Real, non-trivial separation.
|
|
assert!(sim_para > 0.5, "paraphrase similarity too low: {sim_para:.3}");
|
|
assert!(sim_unrel < 0.3, "unrelated similarity too high: {sim_unrel:.3}");
|
|
}
|
|
|
|
#[test]
|
|
fn identical_text_is_similarity_one() {
|
|
let a = embed("lock the front door");
|
|
let b = embed("lock the front door");
|
|
let sim = cosine_similarity(&a, &b);
|
|
assert!((sim - 1.0).abs() < 1e-4, "sim = {sim}");
|
|
}
|
|
}
|