use crate::error::AiError;
use async_trait::async_trait;
use std::collections::HashMap;
use std::sync::RwLock;
#[derive(Debug, Clone)]
pub struct EmbeddingError {
pub message: String,
pub model: Option<String>,
}
impl EmbeddingError {
pub fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
model: None,
}
}
pub fn with_model(message: impl Into<String>, model: impl Into<String>) -> Self {
Self {
message: message.into(),
model: Some(model.into()),
}
}
}
impl std::fmt::Display for EmbeddingError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "EmbeddingError: {}", self.message)?;
if let Some(ref model) = self.model {
write!(f, " (model: {})", model)?;
}
Ok(())
}
}
impl std::error::Error for EmbeddingError {}
#[async_trait]
pub trait EmbeddingModel: Send + Sync {
async fn embed(&self, text: &str) -> Result<Vec<f32>, AiError>;
async fn embed_batch(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, AiError>;
fn dimension(&self) -> usize;
fn model_name(&self) -> &str;
}
pub struct EmbeddingRecord {
pub id: String,
pub text: String,
pub vector: Vec<f32>,
pub metadata: Option<std::collections::HashMap<String, serde_json::Value>>,
}
impl EmbeddingRecord {
pub fn new(id: impl Into<String>, text: impl Into<String>, vector: Vec<f32>) -> Self {
Self {
id: id.into(),
text: text.into(),
vector,
metadata: None,
}
}
pub fn with_metadata(
id: impl Into<String>,
text: impl Into<String>,
vector: Vec<f32>,
metadata: std::collections::HashMap<String, serde_json::Value>,
) -> Self {
Self {
id: id.into(),
text: text.into(),
vector,
metadata: Some(metadata),
}
}
}
pub struct EmbeddingBatch {
pub records: Vec<EmbeddingRecord>,
pub batch_size: usize,
}
impl EmbeddingBatch {
pub fn new(records: Vec<EmbeddingRecord>) -> Self {
Self {
records,
batch_size: 32,
}
}
pub fn with_batch_size(mut self, size: usize) -> Self {
self.batch_size = size;
self
}
pub fn batch_chunks(&self) -> Vec<&[EmbeddingRecord]> {
self.records.chunks(self.batch_size).collect()
}
}
pub struct SimpleEmbeddingModel {
name: String,
dimension: usize,
vocabulary: RwLock<HashMap<String, usize>>,
}
impl SimpleEmbeddingModel {
pub fn new(name: impl Into<String>, dimension: usize) -> Self {
Self {
name: name.into(),
dimension,
vocabulary: RwLock::new(HashMap::new()),
}
}
pub fn vocabulary_size(&self) -> usize {
self.vocabulary.read().map(|v| v.len()).unwrap_or(0)
}
fn register_token(&self, token: &str) -> usize {
let mut vocab = self.vocabulary.write().unwrap();
if let Some(&idx) = vocab.get(token) {
return idx;
}
let idx = fnv1a(token) % self.dimension.max(1);
vocab.insert(token.to_string(), idx);
idx
}
fn tokenize(text: &str) -> Vec<String> {
text.split(|c: char| !c.is_alphanumeric())
.filter(|s| !s.is_empty())
.map(|s| s.to_lowercase())
.collect()
}
fn embed_text(&self, text: &str) -> Vec<f32> {
let mut vec = vec![0.0f32; self.dimension];
if self.dimension == 0 {
return vec;
}
let tokens = Self::tokenize(text);
if tokens.is_empty() {
return vec;
}
for token in &tokens {
let idx = self.register_token(token);
vec[idx] += 1.0;
}
let norm: f32 = vec.iter().map(|v| v * v).sum::<f32>().sqrt();
if norm > 0.0 {
for v in vec.iter_mut() {
*v /= norm;
}
}
vec
}
}
fn fnv1a(s: &str) -> usize {
let mut hash: u64 = 0xcbf29ce484222325;
for byte in s.as_bytes() {
hash ^= *byte as u64;
hash = hash.wrapping_mul(0x100000001b3);
}
hash as usize
}
#[async_trait]
impl EmbeddingModel for SimpleEmbeddingModel {
async fn embed(&self, text: &str) -> Result<Vec<f32>, AiError> {
Ok(self.embed_text(text))
}
async fn embed_batch(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, AiError> {
Ok(texts.iter().map(|t| self.embed_text(t)).collect())
}
fn dimension(&self) -> usize {
self.dimension
}
fn model_name(&self) -> &str {
&self.name
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_embed_simple_text() {
let model = SimpleEmbeddingModel::new("test-model", 16);
let v = model.embed("hello world").await.unwrap();
assert_eq!(v.len(), 16);
let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
assert!((norm - 1.0).abs() < 1e-5 || norm.abs() < 1e-5);
}
#[tokio::test]
async fn test_embed_empty_text() {
let model = SimpleEmbeddingModel::new("test-model", 8);
let v = model.embed("").await.unwrap();
assert!(v.iter().all(|x| *x == 0.0));
}
#[tokio::test]
async fn test_embed_deterministic() {
let model = SimpleEmbeddingModel::new("test-model", 32);
let v1 = model.embed("rust programming language").await.unwrap();
let v2 = model.embed("rust programming language").await.unwrap();
assert_eq!(v1, v2);
}
#[tokio::test]
async fn test_embed_similar_texts_closer_than_different() {
let model = SimpleEmbeddingModel::new("test-model", 64);
let v1 = model.embed("the quick brown fox jumps").await.unwrap();
let v2 = model.embed("the quick brown fox").await.unwrap();
let v3 = model
.embed("completely different words here")
.await
.unwrap();
let sim_close = cosine(&v1, &v2);
let sim_far = cosine(&v1, &v3);
assert!(
sim_close >= sim_far,
"similar texts should be at least as close"
);
}
#[tokio::test]
async fn test_embed_batch() {
let model = SimpleEmbeddingModel::new("test-model", 16);
let texts = vec!["hello".to_string(), "world".to_string()];
let vecs = model.embed_batch(&texts).await.unwrap();
assert_eq!(vecs.len(), 2);
assert_eq!(vecs[0].len(), 16);
assert_eq!(vecs[1].len(), 16);
}
#[test]
fn test_dimension_and_name() {
let model = SimpleEmbeddingModel::new("my-model", 128);
assert_eq!(model.dimension(), 128);
assert_eq!(model.model_name(), "my-model");
}
fn cosine(a: &[f32], b: &[f32]) -> f32 {
let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
let na: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
let nb: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
if na == 0.0 || nb == 0.0 {
return 0.0;
}
dot / (na * nb)
}
}