use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash as _, Hasher as _};
use crate::backend::BoxFuture;
use crate::error::{SearchError, SearchResult};
use crate::text::tokenize;
pub trait Embedder: Send + Sync + 'static {
fn dimensions(&self) -> usize;
fn embed<'a>(&'a self, texts: &'a [String]) -> BoxFuture<'a, SearchResult<Vec<Vec<f32>>>>;
}
#[derive(Debug, Clone, Copy, Default)]
pub struct NoEmbedder;
impl Embedder for NoEmbedder {
fn dimensions(&self) -> usize {
0
}
fn embed<'a>(&'a self, texts: &'a [String]) -> BoxFuture<'a, SearchResult<Vec<Vec<f32>>>> {
Box::pin(async move {
if texts.is_empty() {
return Ok(Vec::new());
}
Err(SearchError::EmbedderUnavailable)
})
}
}
#[derive(Debug, Clone, Copy)]
pub struct HashingEmbedder {
dimensions: usize,
}
impl HashingEmbedder {
#[must_use]
#[allow(
clippy::expect_used,
reason = "construction-time argument validation; no request path reaches this"
)]
pub const fn new(dimensions: usize) -> Self {
Self::try_new(dimensions).expect(
"HashingEmbedder requires at least one dimension; 0 would make every text identical",
)
}
#[must_use]
pub const fn try_new(dimensions: usize) -> Option<Self> {
if dimensions == 0 {
None
} else {
Some(Self { dimensions })
}
}
fn embed_one(self, text: &str) -> Vec<f32> {
let mut vector = vec![0.0_f32; self.dimensions];
for token in tokenize(text) {
let mut hasher = DefaultHasher::new();
token.hash(&mut hasher);
let hash = hasher.finish();
let bucket =
usize::try_from(hash.checked_rem(self.dimensions as u64).unwrap_or_default())
.unwrap_or(0);
let sign = if (hash >> 63) & 1 == 1 { -1.0 } else { 1.0 };
if let Some(slot) = vector.get_mut(bucket) {
*slot += sign;
}
}
normalize(&mut vector);
vector
}
}
impl Embedder for HashingEmbedder {
fn dimensions(&self) -> usize {
self.dimensions
}
fn embed<'a>(&'a self, texts: &'a [String]) -> BoxFuture<'a, SearchResult<Vec<Vec<f32>>>> {
Box::pin(async move { Ok(texts.iter().map(|t| self.embed_one(t)).collect()) })
}
}
pub fn normalize(vector: &mut [f32]) {
let norm = vector.iter().map(|v| v * v).sum::<f32>().sqrt();
if norm > f32::EPSILON {
for value in vector.iter_mut() {
*value /= norm;
}
}
}
#[must_use]
pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
if a.len() != b.len() || a.is_empty() {
return 0.0;
}
let mut dot = 0.0_f32;
let mut norm_a = 0.0_f32;
let mut norm_b = 0.0_f32;
for (x, y) in a.iter().zip(b.iter()) {
dot += x * y;
norm_a += x * x;
norm_b += y * y;
}
let denominator = norm_a.sqrt() * norm_b.sqrt();
if denominator <= f32::EPSILON {
return 0.0;
}
(dot / denominator).clamp(-1.0, 1.0)
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn the_hashing_embedder_produces_unit_vectors_of_the_right_width() {
let embedder = HashingEmbedder::new(16);
let vectors = embedder
.embed(&["autumn rust".to_owned()])
.await
.expect("embed");
assert_eq!(vectors.len(), 1);
assert_eq!(vectors[0].len(), 16);
let norm: f32 = vectors[0].iter().map(|v| v * v).sum::<f32>().sqrt();
assert!((norm - 1.0).abs() < 1e-5);
}
#[tokio::test]
async fn empty_text_yields_a_zero_vector_rather_than_a_nan() {
let embedder = HashingEmbedder::new(8);
let vectors = embedder.embed(&[String::new()]).await.expect("embed");
assert!(vectors[0].iter().all(|v| v.abs() < f32::EPSILON));
assert!(cosine_similarity(&vectors[0], &vectors[0]).abs() < f32::EPSILON);
}
#[test]
fn normalize_leaves_a_zero_vector_alone() {
let mut zero = vec![0.0_f32; 4];
normalize(&mut zero);
assert!(zero.iter().all(|v| v.abs() < f32::EPSILON));
}
#[test]
fn cosine_similarity_is_bounded_and_total() {
assert!(cosine_similarity(&[], &[]).abs() < f32::EPSILON);
assert!(cosine_similarity(&[1.0], &[1.0, 2.0]).abs() < f32::EPSILON);
assert!(cosine_similarity(&[3.0, 4.0], &[3.0, 4.0]) <= 1.0);
}
#[test]
fn try_new_rejects_zero_dimensions() {
assert!(HashingEmbedder::try_new(0).is_none());
}
}