use crate::util::UnwrapPoison;
use anyhow::{Context, Result, anyhow};
use candle_core::quantized::{QMatMul, gguf_file};
use candle_core::{DType, Device, Tensor};
use candle_nn::{Embedding, Module};
use futures_util::StreamExt;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
use std::sync::{OnceLock, RwLock};
use std::time::Duration;
use tokenizers::Tokenizer;
use tracing::{debug, info, warn};
const MAX_SEQ_LEN: usize = 8192;
const ROPE_FREQ_BASE: f32 = 1_000_000.0;
const MODEL_DOWNLOAD_TIMEOUT: Duration = Duration::from_mins(10);
const TOKENIZER_DOWNLOAD_TIMEOUT: Duration = Duration::from_mins(2);
const DEFAULT_PAD_ID: u32 = 128_001;
const MODEL_URL: &str = "https://huggingface.co/jinaai/jina-embeddings-v5-text-nano-retrieval/resolve/main/v5-nano-retrieval-Q4_K_M.gguf";
const MODEL_SHA256: &str = "f50822244ba0c7a348c5455b99bb8a0afd182511e8a816888c5dc65d972e51d5";
const TOKENIZER_URL: &str = "https://huggingface.co/jinaai/jina-embeddings-v5-text-nano-retrieval/resolve/main/tokenizer.json";
const TOKENIZER_SHA256: &str = "98d4a1d32152d6cedf85b5e88f3b205106dca1fe72aaab34e0ac13c238421069";
const STATE_UNINIT: u8 = 0;
const STATE_LOADING: u8 = 1;
const STATE_READY: u8 = 2;
static GLOBAL_EMBEDDER: OnceLock<RwLock<Option<Embedder>>> = OnceLock::new();
static STATE: AtomicU8 = AtomicU8::new(STATE_UNINIT);
static DOWNLOAD_SPAWNED: AtomicBool = AtomicBool::new(false);
#[must_use]
pub fn global_embedder() -> &'static RwLock<Option<Embedder>> {
GLOBAL_EMBEDDER.get_or_init(|| RwLock::new(None))
}
fn ensure_embedder() -> bool {
if STATE.load(Ordering::Acquire) == STATE_READY {
return true;
}
if STATE.load(Ordering::Acquire) != STATE_UNINIT {
return false;
}
if STATE
.compare_exchange(
STATE_UNINIT,
STATE_LOADING,
Ordering::AcqRel,
Ordering::Acquire,
)
.is_err()
{
return false;
}
let Some(models_dir) = models_dir() else {
STATE.store(STATE_UNINIT, Ordering::Release);
return false;
};
let model_path = models_dir.join("v5-nano-retrieval-Q4_K_M.gguf");
let tokenizer_path = models_dir.join("embed_tokenizer.json");
std::fs::create_dir_all(&models_dir).ok();
let cache_loaded = if model_path.exists() && tokenizer_path.exists() {
match Embedder::load(&model_path, &tokenizer_path) {
Ok(emb) => {
*global_embedder().write().unwrap_poison() = Some(emb);
STATE.store(STATE_READY, Ordering::Release);
true
}
Err(e) => {
warn!(reason = %e, "Failed to load cached embedding model");
false
}
}
} else {
false
};
if cache_loaded {
return true;
}
if !DOWNLOAD_SPAWNED.swap(true, Ordering::AcqRel) {
if tokio::runtime::Handle::try_current().is_ok() {
tokio::spawn(download_retry_loop());
} else {
DOWNLOAD_SPAWNED.store(false, Ordering::Release);
STATE.store(STATE_UNINIT, Ordering::Release);
}
}
false
}
#[must_use]
pub fn embed(text: &str, is_query: bool) -> Option<Vec<f32>> {
if !ensure_embedder() {
return None;
}
let guard = global_embedder().read().unwrap_poison();
let emb = guard.as_ref()?;
let v = if is_query {
emb.embed_queries(&[text]).ok()?
} else {
emb.embed_documents(&[text]).ok()?
};
v.into_iter().next()
}
async fn download_retry_loop() {
let models_dir =
models_dir().expect("CONFIG storage_root must be set before download_retry_loop runs");
std::fs::create_dir_all(&models_dir).ok();
let model_dest = models_dir.join("v5-nano-retrieval-Q4_K_M.gguf");
let tokenizer_dest = models_dir.join("embed_tokenizer.json");
let client = reqwest::Client::builder()
.timeout(MODEL_DOWNLOAD_TIMEOUT)
.connect_timeout(Duration::from_secs(30))
.build()
.expect("Failed to build reqwest::Client for model download");
let mut delay = Duration::from_mins(1);
let max_delay = Duration::from_mins(30);
let mut model_has = model_dest.exists() && tokenizer_dest.exists();
loop {
if model_has {
if let Ok(emb) = Embedder::load(&model_dest, &tokenizer_dest) {
info!("Embedding model loaded successfully (from previously downloaded files)");
*global_embedder().write().unwrap_poison() = Some(emb);
STATE.store(STATE_READY, Ordering::Release);
return;
}
warn!("Failed to load embedding model from cached files, retrying with backoff");
}
let (model_result, tokenizer_result) = tokio::join!(
maybe_download(
&client,
MODEL_URL,
&model_dest,
MODEL_DOWNLOAD_TIMEOUT,
Some(MODEL_SHA256)
),
maybe_download(
&client,
TOKENIZER_URL,
&tokenizer_dest,
TOKENIZER_DOWNLOAD_TIMEOUT,
Some(TOKENIZER_SHA256)
),
);
if let (Err(e_model), Err(e_tokenizer)) = (&model_result, &tokenizer_result) {
warn!(
model_error = %e_model,
tokenizer_error = %e_tokenizer,
retry_after_secs = delay.as_secs(),
"Failed to download embedding model files, retrying"
);
} else if let Err(e) = &model_result {
warn!(error = %e, retry_after_secs = delay.as_secs(), "Failed to download embedding model, retrying");
} else if let Err(e) = &tokenizer_result {
warn!(error = %e, retry_after_secs = delay.as_secs(), "Failed to download tokenizer, retrying");
}
let model_ok = model_result.is_ok();
let tokenizer_ok = tokenizer_result.is_ok();
if model_ok && tokenizer_ok {
match Embedder::load(&model_dest, &tokenizer_dest) {
Ok(emb) => {
info!("Embedding model loaded successfully after download");
*global_embedder().write().unwrap_poison() = Some(emb);
STATE.store(STATE_READY, Ordering::Release);
return;
}
Err(e) => {
warn!(reason = %e, "Failed to load model after download, retrying with backoff (files preserved)");
}
}
} else {
if !model_ok {
let _ = std::fs::remove_file(model_dest.with_extension("tmp"));
}
if !tokenizer_ok {
let _ = std::fs::remove_file(tokenizer_dest.with_extension("tmp"));
}
}
model_has = model_dest.exists() && tokenizer_dest.exists();
tokio::time::sleep(delay).await;
delay = (delay * 2).min(max_delay);
}
}
async fn maybe_download(
client: &reqwest::Client,
url: &str,
dest: &Path,
_timeout: Duration,
expected_sha256: Option<&str>,
) -> Result<()> {
if dest.exists() {
return Ok(());
}
download_file(client, url, dest, expected_sha256).await
}
async fn download_file(
client: &reqwest::Client,
url: &str,
dest: &Path,
expected_sha256: Option<&str>,
) -> Result<()> {
use sha2::{Digest, Sha256};
let response = client
.get(url)
.send()
.await
.context("Failed to send download request")?;
let status = response.status();
if !status.is_success() {
anyhow::bail!("HTTP {status} from {url}");
}
let total_size = response.content_length();
let tmp_path = dest.with_extension("tmp");
let mut file = tokio::fs::File::create(&tmp_path)
.await
.context("Failed to create temp file")?;
let mut downloaded: u64 = 0;
let mut hasher = expected_sha256.map(|_| Sha256::new());
let mut stream = response.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk.context("Download stream error")?;
let len = chunk.len() as u64;
downloaded += len;
if let Some(ref mut h) = hasher {
h.update(&chunk);
}
tokio::io::AsyncWriteExt::write_all(&mut file, &chunk)
.await
.context("Failed to write download chunk")?;
}
if let Some(expected) = total_size
&& downloaded != expected
{
let _ = tokio::fs::remove_file(&tmp_path).await;
anyhow::bail!("Download size mismatch: expected {expected} bytes, got {downloaded} bytes");
}
if let Some(expected_hex) = expected_sha256
&& let Some(hasher) = hasher
{
let actual_hash = format!("{:x}", hasher.finalize());
if actual_hash != expected_hex {
let _ = tokio::fs::remove_file(&tmp_path).await;
anyhow::bail!("SHA256 mismatch: expected {expected_hex}, got {actual_hash}");
}
}
tokio::fs::rename(&tmp_path, dest)
.await
.context("Failed to rename temp file to final path")?;
info!(path = %dest.display(), size = downloaded, "Downloaded model file");
Ok(())
}
fn models_dir() -> Option<PathBuf> {
crate::config::CONFIG
.try_storage_root()
.map(|root| root.join("models"))
}
fn get_meta_u32(metadata: &HashMap<String, gguf_file::Value>, key: &str) -> Result<u32> {
metadata
.get(key)
.ok_or_else(|| anyhow!("Missing metadata key '{key}'"))?
.to_u32()
.map_err(|e| anyhow!("Failed to read metadata '{key}': {e}"))
}
fn get_meta_f32(metadata: &HashMap<String, gguf_file::Value>, key: &str) -> Result<f32> {
metadata
.get(key)
.ok_or_else(|| anyhow!("Missing metadata key '{key}'"))?
.to_f32()
.map_err(|e| anyhow!("Failed to read metadata '{key}': {e}"))
}
#[derive(Debug)]
struct Layer {
attn_q: QMatMul,
attn_k: QMatMul,
attn_v: QMatMul,
attn_o: QMatMul,
attn_norm: Tensor,
ffn_gate: QMatMul,
ffn_up: QMatMul,
ffn_down: QMatMul,
ffn_norm: Tensor,
}
impl Layer {
#[allow(clippy::many_single_char_names)]
fn forward(
&self,
x: &Tensor,
mask: &Tensor,
cos: &Tensor,
sin: &Tensor,
n_head: usize,
head_dim: usize,
) -> Result<Tensor> {
let residual = x;
let h = candle_nn::ops::rms_norm(x, &self.attn_norm, 1e-5)?;
let q = self.attn_q.forward(&h)?;
let k = self.attn_k.forward(&h)?;
let v = self.attn_v.forward(&h)?;
let h = Layer::apply_attention(&q, &k, &v, mask, cos, sin, n_head, head_dim)?;
let h = self.attn_o.forward(&h)?;
let h = (h + residual)?;
let residual = &h;
let h = candle_nn::ops::rms_norm(&h, &self.ffn_norm, 1e-5)?;
let gate = self.ffn_gate.forward(&h)?;
let up = self.ffn_up.forward(&h)?;
let h = self
.ffn_down
.forward(&(candle_nn::ops::silu(&gate)? * up)?)?;
let h = (h + residual)?;
Ok(h)
}
#[allow(clippy::too_many_arguments)]
fn apply_attention(
q: &Tensor,
k: &Tensor,
v: &Tensor,
mask: &Tensor,
cos: &Tensor,
sin: &Tensor,
n_head: usize,
head_dim: usize,
) -> Result<Tensor> {
let (b_sz, seq_len, n_embd) = q.shape().dims3()?;
let q = q
.reshape((b_sz, seq_len, n_head, head_dim))?
.transpose(1, 2)?;
let k = k
.reshape((b_sz, seq_len, n_head, head_dim))?
.transpose(1, 2)?;
let v = v
.reshape((b_sz, seq_len, n_head, head_dim))?
.transpose(1, 2)?
.contiguous()?;
let q = Self::apply_rotary_emb(&q, cos, sin)?;
let k = Self::apply_rotary_emb(&k, cos, sin)?;
#[allow(clippy::cast_precision_loss)]
let scale = 1.0_f64 / (head_dim as f64).sqrt();
let att = q.matmul(&k.t()?)?;
let att = (att * scale)?;
let mask = mask.broadcast_as(att.shape())?;
let att = (att + mask)?;
let att = candle_nn::ops::softmax_last_dim(&att)?;
let y = att.matmul(&v)?;
let y = y.transpose(1, 2)?.reshape((b_sz, seq_len, n_embd))?;
Ok(y)
}
fn apply_rotary_emb(x: &Tensor, cos: &Tensor, sin: &Tensor) -> Result<Tensor> {
let (_b_sz, _n_head, seq_len, head_dim) = x.shape().dims4()?;
let cos = cos.narrow(0, 0, seq_len)?;
let sin = sin.narrow(0, 0, seq_len)?;
let cos = cos.reshape((1, 1, seq_len, head_dim / 2))?;
let sin = sin.reshape((1, 1, seq_len, head_dim / 2))?;
let x_f32 = x.to_dtype(DType::F32)?;
let chunks = x_f32.chunk(2, 3)?;
let x1 = &chunks[0];
let x2 = &chunks[1];
let y1 = (x1.broadcast_mul(&cos)? - x2.broadcast_mul(&sin)?)?;
let y2 = (x1.broadcast_mul(&sin)? + x2.broadcast_mul(&cos)?)?;
let result = Tensor::cat(&[&y1, &y2], 3)?;
Ok(result.to_dtype(x.dtype())?)
}
}
pub struct Embedder {
tokenizer: Tokenizer,
tok_embeddings: Embedding,
layers: Vec<Layer>,
output_norm: Tensor,
cos: Tensor,
sin: Tensor,
head_dim: usize,
n_head: usize,
pad_id: u32,
}
impl Embedder {
#[allow(clippy::too_many_lines)]
pub fn load(model_path: &Path, tokenizer_path: &Path) -> Result<Self> {
let tokenizer = Tokenizer::from_file(tokenizer_path).map_err(|e| {
anyhow!(
"Failed to load tokenizer from {}: {e}",
tokenizer_path.display()
)
})?;
let pad_id = tokenizer
.token_to_id("<|end_of_text|>")
.or_else(|| tokenizer.token_to_id("<|pad|>"))
.or_else(|| tokenizer.token_to_id("[PAD]"))
.map_or(DEFAULT_PAD_ID, |id| id);
debug!(pad_id, "Discovered pad token ID from tokenizer");
let device = Device::Cpu;
let mut file = std::fs::File::open(model_path)
.map_err(|e| anyhow!("Failed to open model file {}: {e}", model_path.display()))?;
let content = gguf_file::Content::read(&mut file)
.map_err(|e| anyhow!("Failed to read GGUF file: {e}"))?;
let hidden_size = get_meta_u32(&content.metadata, "eurobert.embedding_length")? as usize;
let n_head = get_meta_u32(&content.metadata, "eurobert.attention.head_count")? as usize;
let head_dim = get_meta_u32(&content.metadata, "eurobert.attention.value_length")? as usize;
let rope_freq_base =
get_meta_f32(&content.metadata, "eurobert.rope.freq_base").unwrap_or(ROPE_FREQ_BASE);
let n_layers = content
.tensor_infos
.keys()
.filter_map(|name| {
let name = name.as_str();
if name.starts_with("blk.") && name.ends_with(".attn_q.weight") {
name.trim_start_matches("blk.")
.split('.')
.next()?
.parse::<usize>()
.ok()
} else {
None
}
})
.max()
.map(|max| max + 1)
.context("No layer tensors found in GGUF file")?;
info!(
hidden_size,
n_head, head_dim, n_layers, rope_freq_base, "Loading EuroBERT embedding model"
);
let tok_embd_qt = content
.tensor(&mut file, "token_embd.weight", &device)
.context("Failed to load token_embd.weight")?;
let tok_embd_f32 = tok_embd_qt
.dequantize(&device)
.context("Failed to dequantize token_embd.weight")?;
let tok_embeddings = Embedding::new(tok_embd_f32, hidden_size);
let output_norm_qt = content
.tensor(&mut file, "output_norm.weight", &device)
.context("Failed to load output_norm.weight")?;
let output_norm = output_norm_qt
.dequantize(&device)
.context("Failed to dequantize output_norm.weight")?;
let mut layers = Vec::with_capacity(n_layers);
for i in 0..n_layers {
let prefix = format!("blk.{i}");
let attn_q = QMatMul::from_qtensor(
content
.tensor(&mut file, &format!("{prefix}.attn_q.weight"), &device)
.with_context(|| format!("Failed to load {prefix}.attn_q.weight"))?,
)
.context("Failed to create QMatMul for attn_q")?;
let attn_k = QMatMul::from_qtensor(
content
.tensor(&mut file, &format!("{prefix}.attn_k.weight"), &device)
.with_context(|| format!("Failed to load {prefix}.attn_k.weight"))?,
)
.context("Failed to create QMatMul for attn_k")?;
let attn_v = QMatMul::from_qtensor(
content
.tensor(&mut file, &format!("{prefix}.attn_v.weight"), &device)
.with_context(|| format!("Failed to load {prefix}.attn_v.weight"))?,
)
.context("Failed to create QMatMul for attn_v")?;
let attn_o = QMatMul::from_qtensor(
content
.tensor(&mut file, &format!("{prefix}.attn_output.weight"), &device)
.with_context(|| format!("Failed to load {prefix}.attn_output.weight"))?,
)
.context("Failed to create QMatMul for attn_o")?;
let attn_norm = content
.tensor(&mut file, &format!("{prefix}.attn_norm.weight"), &device)
.with_context(|| format!("Failed to load {prefix}.attn_norm.weight"))?
.dequantize(&device)
.context("Failed to dequantize attn_norm")?;
let ffn_gate = QMatMul::from_qtensor(
content
.tensor(&mut file, &format!("{prefix}.ffn_gate.weight"), &device)
.with_context(|| format!("Failed to load {prefix}.ffn_gate.weight"))?,
)
.context("Failed to create QMatMul for ffn_gate")?;
let ffn_up = QMatMul::from_qtensor(
content
.tensor(&mut file, &format!("{prefix}.ffn_up.weight"), &device)
.with_context(|| format!("Failed to load {prefix}.ffn_up.weight"))?,
)
.context("Failed to create QMatMul for ffn_up")?;
let ffn_down = QMatMul::from_qtensor(
content
.tensor(&mut file, &format!("{prefix}.ffn_down.weight"), &device)
.with_context(|| format!("Failed to load {prefix}.ffn_down.weight"))?,
)
.context("Failed to create QMatMul for ffn_down")?;
let ffn_norm = content
.tensor(&mut file, &format!("{prefix}.ffn_norm.weight"), &device)
.with_context(|| format!("Failed to load {prefix}.ffn_norm.weight"))?
.dequantize(&device)
.context("Failed to dequantize ffn_norm")?;
layers.push(Layer {
attn_q,
attn_k,
attn_v,
attn_o,
attn_norm,
ffn_gate,
ffn_up,
ffn_down,
ffn_norm,
});
}
let (cos, sin) = precompute_freqs_cis(head_dim, rope_freq_base, &device)?;
let emb = Self {
tokenizer,
tok_embeddings,
layers,
output_norm,
cos,
sin,
head_dim,
n_head,
pad_id,
};
let v = emb.embed_documents(&["."])?;
anyhow::ensure!(
!v.is_empty() && !v[0].is_empty(),
"Embedder warm-up produced empty output"
);
anyhow::ensure!(
v[0].len() == hidden_size,
"Embedder warm-up produced wrong dimension: expected {hidden_size}, got {}",
v[0].len()
);
info!("Embedder initialized successfully (hidden_size={hidden_size}, layers={n_layers})");
Ok(emb)
}
pub fn embed_queries(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
self.embed_prefixed("Query: ", texts)
}
pub fn embed_documents(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
self.embed_prefixed("Document: ", texts)
}
fn embed_prefixed(&self, prefix: &str, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
let encodings: Vec<_> = texts
.iter()
.map(|t| {
let input = format!("{prefix}{t}");
self.tokenizer.encode(input, true)
})
.collect::<std::result::Result<Vec<_>, _>>()
.map_err(|e| anyhow!("Tokenization error: {e}"))?;
if encodings.is_empty() {
anyhow::bail!("Empty input");
}
let max_len = encodings
.iter()
.map(|e| e.len().min(MAX_SEQ_LEN))
.max()
.context("Empty encoding")?;
let batch_size = encodings.len();
let mut input_ids_vec = vec![i64::from(self.pad_id); batch_size * max_len];
let mut attention_mask_vec = vec![0i64; batch_size * max_len];
for (row, enc) in encodings.iter().enumerate() {
let ids = enc.get_ids();
let mask = enc.get_attention_mask();
let len = ids.len().min(MAX_SEQ_LEN);
for col in 0..len {
input_ids_vec[row * max_len + col] = i64::from(ids[col]);
attention_mask_vec[row * max_len + col] = i64::from(mask[col]);
}
}
let input_ids = Tensor::from_vec(input_ids_vec, (batch_size, max_len), &Device::Cpu)?;
let attention_mask =
Tensor::from_vec(attention_mask_vec, (batch_size, max_len), &Device::Cpu)?;
let embeddings = self.forward(&input_ids, &attention_mask)?;
let result = last_token_pool_and_l2_normalize(&embeddings, &attention_mask)?;
Ok(result)
}
fn forward(&self, input_ids: &Tensor, attention_mask: &Tensor) -> Result<Tensor> {
let (_batch_size, _seq_len) = input_ids.shape().dims2()?;
let mask = build_attn_mask(attention_mask, &Device::Cpu)?;
let mut h = self.tok_embeddings.forward(input_ids)?;
for layer in &self.layers {
h = layer.forward(&h, &mask, &self.cos, &self.sin, self.n_head, self.head_dim)?;
}
h = candle_nn::ops::rms_norm(&h, &self.output_norm, 1e-5)?;
Ok(h)
}
}
#[allow(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_lossless
)]
fn precompute_freqs_cis(
head_dim: usize,
freq_base: f32,
device: &Device,
) -> Result<(Tensor, Tensor)> {
#[allow(clippy::cast_precision_loss, clippy::cast_lossless)]
let theta: Vec<f32> = (0..head_dim)
.step_by(2)
.map(|i| 1.0_f32 / freq_base.powf(i as f32 / head_dim as f32))
.collect();
let theta = Tensor::from_vec(theta, (head_dim / 2,), device)?;
#[allow(clippy::cast_possible_truncation)]
let positions = Tensor::arange(0u32, MAX_SEQ_LEN as u32, device)?
.to_dtype(DType::F32)?
.reshape((MAX_SEQ_LEN, 1))?;
let idx_theta = positions.matmul(&theta.reshape((1, theta.elem_count()))?)?;
let cos = idx_theta.cos()?;
let sin = idx_theta.sin()?;
Ok((cos, sin))
}
fn build_attn_mask(attention_mask: &Tensor, device: &Device) -> Result<Tensor> {
let (batch_size, seq_len) = attention_mask.shape().dims2()?;
let mask_f32 = attention_mask.to_dtype(DType::F32)?;
let mask_a = mask_f32.reshape((batch_size, 1, 1, seq_len))?;
let mask_b = mask_f32.reshape((batch_size, 1, seq_len, 1))?;
let pairwise = mask_a.broadcast_mul(&mask_b)?;
let large_neg = Tensor::new(-1e10_f32, device)?.broadcast_as(pairwise.shape())?;
let zero = Tensor::new(0.0_f32, device)?.broadcast_as(pairwise.shape())?;
let mask_cond = pairwise.eq(&zero)?;
let mask = mask_cond.where_cond(&large_neg, &zero)?;
Ok(mask)
}
fn last_token_pool_and_l2_normalize(
embeddings: &Tensor,
attention_mask: &Tensor,
) -> Result<Vec<Vec<f32>>> {
let (batch_size, _seq_len, hidden_size) = embeddings.shape().dims3()?;
let mut results = Vec::with_capacity(batch_size);
let seq_lengths: Vec<i64> = attention_mask.sum(1)?.to_vec1()?;
for (i, &seq_len) in seq_lengths.iter().enumerate().take(batch_size) {
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let last_pos = (seq_len - 1).max(0) as usize;
let token_emb = embeddings.narrow(0, i, 1)?.narrow(1, last_pos, 1)?;
let token_emb = token_emb.reshape(hidden_size)?;
let norm = token_emb
.sqr()?
.sum_all()?
.sqrt()?
.to_scalar::<f32>()?
.max(1e-12);
let normalized = token_emb.broadcast_div(&Tensor::new(norm, token_emb.device())?)?;
let vec: Vec<f32> = normalized.to_vec1()?;
results.push(vec);
}
Ok(results)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::vector::cosine_similarity;
fn init_test_config() -> std::path::PathBuf {
use std::sync::OnceLock;
static CONFIG_INIT: OnceLock<tempfile::TempDir> = OnceLock::new();
let tmp = CONFIG_INIT
.get_or_init(|| tempfile::TempDir::new().expect("failed to create test temp dir"));
let root = tmp.path().to_path_buf();
let _ = crate::config::CONFIG.try_set_storage_root(root.clone());
root
}
fn test_embedder() -> Option<Embedder> {
if std::env::var("MAHBOT_SKIP_EMBEDDER_TESTS").is_ok() {
return None;
}
let mut candidates = Vec::new();
if let Some(root) = crate::config::CONFIG.try_storage_root() {
candidates.push(root.join("models"));
}
if let Some(home) = std::env::var("HOME").ok().filter(|h| !h.is_empty()) {
let real = std::path::PathBuf::from(&home)
.join(".mahbot")
.join("models");
if !candidates.contains(&real) {
candidates.push(real);
}
}
for models_dir in &candidates {
let model_path = models_dir.join("v5-nano-retrieval-Q4_K_M.gguf");
let tokenizer_path = models_dir.join("embed_tokenizer.json");
if model_path.exists() && tokenizer_path.exists() {
match Embedder::load(&model_path, &tokenizer_path) {
Ok(emb) => return Some(emb),
Err(e) => {
eprintln!("WARNING: Failed to load test embedder: {e}");
return None;
}
}
}
}
let last_candidate = candidates.last().map(|p| p.display().to_string());
eprintln!(
"WARNING: Model files not found. Looked in: {}. \
Set MAHBOT_SKIP_EMBEDDER_TESTS=1 to suppress this warning.",
last_candidate.as_deref().unwrap_or("<none>")
);
None
}
fn reset_global_state() {
*global_embedder().write().unwrap_poison() = None;
STATE.store(STATE_UNINIT, Ordering::Release);
DOWNLOAD_SPAWNED.store(false, Ordering::Release);
}
#[test]
fn test_embedder_graceful_degradation() {
let _root = init_test_config();
reset_global_state();
let result = embed("test", false);
assert!(
result.is_none(),
"embed() should return None when model not available"
);
let guard = global_embedder().read().unwrap_poison();
assert!(guard.is_none(), "global embedder should remain None");
}
#[test]
fn test_embedder_init() {
let emb = match test_embedder() {
Some(e) => e,
None => return, };
let v = emb.embed_documents(&["hello world"]).unwrap();
assert_eq!(v.len(), 1);
assert_eq!(v[0].len(), 768);
let norm: f32 = v[0].iter().map(|x| x * x).sum::<f32>().sqrt();
assert!(
(norm - 1.0).abs() < 1e-5,
"expected unit vector, got norm={norm}"
);
}
#[test]
fn test_embed_documents() {
let emb = match test_embedder() {
Some(e) => e,
None => return,
};
let docs = &["first document", "second document about something"];
let v = emb.embed_documents(docs).unwrap();
assert_eq!(v.len(), 2);
for vec in &v {
assert_eq!(vec.len(), 768);
let norm: f32 = vec.iter().map(|x| x * x).sum::<f32>().sqrt();
assert!(
(norm - 1.0).abs() < 1e-5,
"expected unit vector, got norm={norm}"
);
}
}
#[test]
fn test_embed_queries() {
let emb = match test_embedder() {
Some(e) => e,
None => return,
};
let v = emb.embed_queries(&["what is rust?"]).unwrap();
assert_eq!(v.len(), 1);
assert_eq!(v[0].len(), 768);
}
#[test]
fn test_similar_embeddings_are_similar() {
let emb = match test_embedder() {
Some(e) => e,
None => return,
};
let v = emb
.embed_documents(&[
"rust programming language",
"the rust programming language",
"python programming language",
])
.unwrap();
let sim_01 = cosine_similarity(&v[0], &v[1]);
let sim_02 = cosine_similarity(&v[0], &v[2]);
assert!(
sim_01 > sim_02,
"rust/rust ({sim_01}) should be more similar than rust/python ({sim_02})"
);
}
#[test]
fn test_empty_input_fails() {
let emb = match test_embedder() {
Some(e) => e,
None => return,
};
let result = emb.embed_documents(&[]);
assert!(result.is_err(), "empty input should produce an error");
}
}