use std::path::{Path, PathBuf};
use async_trait::async_trait;
use candle_core::{Device, Tensor};
use candle_nn::VarBuilder;
use candle_transformers::models::bert::{BertModel, Config, DTYPE};
use newt_core::Embedder;
use tokenizers::Tokenizer;
pub const DEFAULT_EMBED_HF_REPO: &str = "BAAI/bge-small-en-v1.5";
const REQUIRED_FILES: &[&str] = &["config.json", "tokenizer.json", "model.safetensors"];
struct EmbedJob {
text: String,
reply: tokio::sync::oneshot::Sender<anyhow::Result<Vec<f32>>>,
}
#[derive(Debug)]
pub struct CandleEmbedder {
label: String,
tx: tokio::sync::mpsc::Sender<EmbedJob>,
}
impl CandleEmbedder {
pub fn new(
model_label: impl Into<String>,
model_dir: impl Into<PathBuf>,
) -> anyhow::Result<Self> {
let label = model_label.into();
let dir = model_dir.into();
if !dir.is_dir() {
anyhow::bail!(
"embedded embedder: model dir not found: {} — place a candle-clean standard-BERT \
model there ({}: config.json + tokenizer.json + model.safetensors). Nothing is \
auto-downloaded.",
dir.display(),
DEFAULT_EMBED_HF_REPO
);
}
for file in REQUIRED_FILES {
let p = dir.join(file);
if !p.exists() {
anyhow::bail!(
"embedded embedder: required file not found: {} — fetch a candle-clean \
standard-BERT model (e.g. {}) into {}. Nothing is auto-downloaded.",
p.display(),
DEFAULT_EMBED_HF_REPO,
dir.display()
);
}
}
let (tx, mut rx) = tokio::sync::mpsc::channel::<EmbedJob>(64);
let worker_dir = dir.clone();
let worker_label = label.clone();
std::thread::Builder::new()
.name("newt-candle-embedder".to_string())
.spawn(move || {
match load_model(&worker_dir) {
Ok((model, tokenizer, device)) => {
while let Some(job) = rx.blocking_recv() {
let res = embed_one(&model, &tokenizer, &device, &job.text);
let _ = job.reply.send(res);
}
}
Err(e) => {
let msg = format!("embedded embedder '{worker_label}' failed to load: {e}");
while let Some(job) = rx.blocking_recv() {
let _ = job.reply.send(Err(anyhow::anyhow!(msg.clone())));
}
}
}
})
.map_err(|e| anyhow::anyhow!("spawn embedded embedder worker thread: {e}"))?;
Ok(Self { label, tx })
}
}
#[async_trait]
impl Embedder for CandleEmbedder {
async fn embed(&self, text: &str) -> anyhow::Result<Vec<f32>> {
let (reply, reply_rx) = tokio::sync::oneshot::channel();
self.tx
.send(EmbedJob {
text: text.to_string(),
reply,
})
.await
.map_err(|_| {
anyhow::anyhow!("embedded embedder '{}' worker has stopped", self.label)
})?;
reply_rx
.await
.map_err(|_| anyhow::anyhow!("embedded embedder '{}' dropped a reply", self.label))?
}
}
fn load_model(dir: &Path) -> anyhow::Result<(BertModel, Tokenizer, Device)> {
let device = crate::embedded::engine::device()?;
let config_json = std::fs::read_to_string(dir.join("config.json"))
.map_err(|e| anyhow::anyhow!("read {}: {e}", dir.join("config.json").display()))?;
let config: Config = serde_json::from_str(&config_json)
.map_err(|e| anyhow::anyhow!("parse {}: {e}", dir.join("config.json").display()))?;
let tokenizer = Tokenizer::from_file(dir.join("tokenizer.json")).map_err(anyhow::Error::msg)?;
let vb = unsafe {
VarBuilder::from_mmaped_safetensors(&[dir.join("model.safetensors")], DTYPE, &device)?
};
let model = BertModel::load(vb, &config)?;
Ok((model, tokenizer, device))
}
fn embed_one(
model: &BertModel,
tokenizer: &Tokenizer,
device: &Device,
text: &str,
) -> anyhow::Result<Vec<f32>> {
let encoding = tokenizer
.encode(text, true)
.map_err(|e| anyhow::anyhow!("tokenize: {e}"))?;
let ids = encoding.get_ids().to_vec();
if ids.is_empty() {
anyhow::bail!("embedded embedder: text tokenized to zero tokens");
}
let input_ids = Tensor::new(ids.as_slice(), device)?.unsqueeze(0)?; let token_type_ids = input_ids.zeros_like()?; let hidden = model.forward(&input_ids, &token_type_ids, None)?; let (_b, n, _h) = hidden.dims3()?;
let pooled = (hidden.sum(1)? / (n as f64))?; let normalized = normalize_l2(&pooled)?;
let out: Vec<f32> = normalized.squeeze(0)?.to_vec1::<f32>()?;
Ok(out)
}
fn normalize_l2(v: &Tensor) -> candle_core::Result<Tensor> {
v.broadcast_div(&v.sqr()?.sum_keepdim(1)?.sqrt()?)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_rejects_a_missing_model_dir_without_downloading() {
let err =
CandleEmbedder::new("bge-small-en-v1.5", "/nonexistent/newt-bge-dir").unwrap_err();
let msg = err.to_string();
assert!(msg.contains("model dir not found"), "got: {msg}");
assert!(msg.contains("Nothing is auto-downloaded"), "got: {msg}");
assert!(msg.contains(DEFAULT_EMBED_HF_REPO), "got: {msg}");
}
#[test]
fn new_names_the_dir_and_default_repo_in_the_error() {
let err = CandleEmbedder::new("any-label", "/nonexistent/some-embed-model").unwrap_err();
let msg = err.to_string();
assert!(msg.contains("/nonexistent/some-embed-model"), "got: {msg}");
assert!(msg.contains(DEFAULT_EMBED_HF_REPO), "got: {msg}");
}
#[tokio::test]
#[ignore = "needs a real candle BERT model dir; set NEWT_EMBED_SMOKE_DIR"]
async fn smoke_embed_on_cpu() {
let dir = std::env::var("NEWT_EMBED_SMOKE_DIR").expect(
"set NEWT_EMBED_SMOKE_DIR to a candle standard-BERT model dir \
(config.json + tokenizer.json + model.safetensors)",
);
let embedder = CandleEmbedder::new("bge-small-en-v1.5", &dir).unwrap();
let v = embedder.embed("hello world").await.unwrap();
eprintln!("EMBED SMOKE: dim={}", v.len());
assert!(!v.is_empty(), "expected a non-empty embedding vector");
let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
assert!((norm - 1.0).abs() < 1e-3, "expected unit-norm, got {norm}");
}
}