#![allow(unsafe_code)]
use std::sync::Mutex;
use candle_core::{Device, Tensor};
use candle_nn::VarBuilder;
use candle_transformers::models::bert::{BertModel, Config, DTYPE, HiddenAct};
use hf_hub::{HFClientSync, HFError, split_id};
use ijima_core::embeddings::{DEFAULT_EMBEDDING_DIM, Embedder, Embedding};
use ijima_core::{IjimaError, Result};
use tokenizers::{PaddingParams, PaddingStrategy, Tokenizer};
pub const DEFAULT_MODEL: &str = "sentence-transformers/all-MiniLM-L6-v2";
pub struct CandleEmbedder {
model: BertModel,
tokenizer: Mutex<Tokenizer>,
device: Device,
model_id: String,
}
impl CandleEmbedder {
pub fn from_hub() -> Result<Self> {
Self::from_hub_model(DEFAULT_MODEL, "main")
}
pub fn from_env() -> Result<Self> {
let model =
std::env::var("IJIMA_EMBED_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.to_string());
let revision = std::env::var("IJIMA_EMBED_REVISION").unwrap_or_else(|_| "main".to_string());
Self::from_hub_model(&model, &revision)
}
pub fn from_hub_model(model_id: &str, revision: &str) -> Result<Self> {
Self::load(model_id, revision, Device::Cpu)
}
fn load(model_id: &str, revision: &str, device: Device) -> Result<Self> {
let (owner, model_name) = split_id(model_id);
let client = HFClientSync::new().map_err(hub_err)?;
let repo = client.model(owner, model_name);
let rev = Some(revision.to_string());
let config_filename = repo
.download_file()
.filename("config.json")
.maybe_revision(rev.clone())
.send()
.map_err(hub_err)?;
let tokenizer_filename = repo
.download_file()
.filename("tokenizer.json")
.maybe_revision(rev.clone())
.send()
.map_err(hub_err)?;
let weights_filename = repo
.download_file()
.filename("model.safetensors")
.maybe_revision(rev)
.send()
.map_err(hub_err)?;
let config_str = std::fs::read_to_string(&config_filename).map_err(io_err)?;
let mut config: Config =
serde_json::from_str(&config_str).map_err(|e| IjimaError::Store {
detail: format!("config parse: {e}"),
})?;
config.hidden_act = HiddenAct::GeluApproximate;
let mut tokenizer =
Tokenizer::from_file(&tokenizer_filename).map_err(|e| IjimaError::Store {
detail: format!("tokenizer load: {e}"),
})?;
tokenizer.with_padding(Some(PaddingParams {
strategy: PaddingStrategy::BatchLongest,
..Default::default()
}));
let vb = unsafe {
VarBuilder::from_mmaped_safetensors(&[weights_filename], DTYPE, &device)
.map_err(candle_err)?
};
let model = BertModel::load(vb, &config).map_err(candle_err)?;
Ok(Self {
model,
tokenizer: Mutex::new(tokenizer),
device,
model_id: format!("{model_id}@{revision}"),
})
}
}
impl Embedder for CandleEmbedder {
fn dim(&self) -> usize {
DEFAULT_EMBEDDING_DIM
}
fn model_id(&self) -> &str {
&self.model_id
}
fn embed(&self, text: &str) -> Result<Embedding> {
let encoding = {
let tok = self.tokenizer.lock().expect("tokenizer poisoned");
tok.encode(text, true).map_err(|e| IjimaError::Store {
detail: format!("tokenize: {e}"),
})?
};
let input_ids = encoding.get_ids().to_vec();
let attention_mask = encoding.get_attention_mask().to_vec();
let token_type_ids = vec![0u32; input_ids.len()];
let token_ids = Tensor::new(input_ids.as_slice(), &self.device)
.map_err(candle_err)?
.unsqueeze(0)
.map_err(candle_err)?;
let attention_mask = Tensor::new(attention_mask.as_slice(), &self.device)
.map_err(candle_err)?
.unsqueeze(0)
.map_err(candle_err)?;
let token_type_ids = Tensor::new(token_type_ids.as_slice(), &self.device)
.map_err(candle_err)?
.unsqueeze(0)
.map_err(candle_err)?;
let embeddings = self
.model
.forward(&token_ids, &token_type_ids, Some(&attention_mask))
.map_err(candle_err)?;
let mask = attention_mask
.to_dtype(DTYPE)
.map_err(candle_err)?
.unsqueeze(2)
.map_err(candle_err)?;
let sum_mask = mask.sum(1).map_err(candle_err)?;
let masked_sum = (embeddings.broadcast_mul(&mask))
.map_err(candle_err)?
.sum(1)
.map_err(candle_err)?;
let pooled = masked_sum.broadcast_div(&sum_mask).map_err(candle_err)?;
let norm = pooled
.sqr()
.map_err(candle_err)?
.sum_all()
.map_err(candle_err)?;
let norm = norm.sqrt().map_err(candle_err)?;
let normalized = pooled.broadcast_div(&norm).map_err(candle_err)?;
let vec = normalized
.squeeze(0)
.map_err(candle_err)?
.to_vec1::<f32>()
.map_err(candle_err)?;
Ok(Embedding(vec))
}
}
fn candle_err(e: candle_core::Error) -> IjimaError {
IjimaError::Store {
detail: format!("candle: {e}"),
}
}
fn hub_err(e: HFError) -> IjimaError {
IjimaError::Store {
detail: format!("hf-hub: {e}"),
}
}
fn io_err(e: std::io::Error) -> IjimaError {
IjimaError::Store {
detail: format!("io: {e}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reports_default_dim() {
let dim = DEFAULT_EMBEDDING_DIM;
assert_eq!(dim, 384);
}
#[tokio::test]
#[ignore]
async fn embeds_real_sentences() {
let embedder = CandleEmbedder::from_hub().expect("load model");
let a = embedder.embed("A dog plays in the park").expect("embed a");
let b = embedder
.embed("A puppy runs in the garden")
.expect("embed b");
let c = embedder
.embed("Rust compiles WebAssembly")
.expect("embed c");
assert_eq!(a.dim(), 384);
assert_eq!(b.dim(), 384);
assert_eq!(c.dim(), 384);
let sim_ab = cosine(&a, &b);
let sim_ac = cosine(&a, &c);
assert!(
sim_ab > sim_ac,
"sim(a,b)={sim_ab:.3} should exceed sim(a,c)={sim_ac:.3}"
);
let mag = a.0.iter().map(|v| v * v).sum::<f32>().sqrt();
assert!((mag - 1.0).abs() < 1e-3, "magnitude {mag} != 1.0");
}
fn cosine(a: &Embedding, b: &Embedding) -> f32 {
a.0.iter().zip(&b.0).map(|(x, y)| x * y).sum::<f32>()
}
}