use crate::catalog::{ModelCatalog, ModelFetcher, ModelSource, ModelSpec};
use crate::{EmbeddingError, Embeddings};
use async_trait::async_trait;
use candle_core::quantized::{GgmlDType, QTensor};
use candle_core::{DType, Device, Tensor};
use candle_nn::VarBuilder;
use candle_transformers::models::bert::{BertModel, Config};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
const MAX_BATCH: usize = 16;
pub struct CandleEmbeddings {
model: Arc<BertModel>,
tokenizer: tokenizers::Tokenizer,
dim: usize,
model_name: String,
max_len: usize,
}
impl std::fmt::Debug for CandleEmbeddings {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CandleEmbeddings")
.field("model", &self.model_name)
.field("dim", &self.dim)
.finish()
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum Quantization {
#[default]
None,
F16,
Int8,
Fp8,
}
pub struct CandleEmbeddingsBuilder {
config: PathBuf,
tokenizer: PathBuf,
weights: PathBuf,
model_name: String,
quantization: Quantization,
}
impl CandleEmbeddingsBuilder {
pub fn from_dir(dir: impl AsRef<Path>) -> Self {
let dir = dir.as_ref();
let model_name = dir
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("candle-bert")
.to_string();
Self {
config: dir.join("config.json"),
tokenizer: dir.join("tokenizer.json"),
weights: dir.join("model.safetensors"),
model_name,
quantization: Quantization::None,
}
}
pub fn from_hf_hub(repo_id: &str) -> Result<Self, EmbeddingError> {
let api = hf_hub::api::sync::Api::new().map_err(|e| {
EmbeddingError::Config(format!("failed to initialize hf-hub client: {e}"))
})?;
let repo = api.model(repo_id.to_string());
let config = repo.get("config.json").map_err(hf_err)?;
let tokenizer = repo.get("tokenizer.json").map_err(hf_err)?;
let weights = repo.get("model.safetensors").map_err(hf_err)?;
Ok(Self {
config,
tokenizer,
weights,
model_name: repo_id.to_string(),
quantization: Quantization::None,
})
}
pub fn with_quantization(mut self, quant: Quantization) -> Self {
self.quantization = quant;
self
}
pub fn build(&self) -> Result<CandleEmbeddings, EmbeddingError> {
CandleEmbeddings::load(
&self.config,
&self.tokenizer,
&self.weights,
self.model_name.clone(),
self.quantization,
)
}
}
impl CandleEmbeddings {
pub fn from_dir(model_dir: impl AsRef<Path>) -> Result<Self, EmbeddingError> {
CandleEmbeddingsBuilder::from_dir(model_dir).build()
}
pub fn from_dir_with(
model_dir: impl AsRef<Path>,
quant: Quantization,
) -> Result<Self, EmbeddingError> {
CandleEmbeddingsBuilder::from_dir(model_dir)
.with_quantization(quant)
.build()
}
pub fn from_hf_hub(repo_id: &str) -> Result<Self, EmbeddingError> {
CandleEmbeddingsBuilder::from_hf_hub(repo_id)?.build()
}
pub fn from_hf_hub_with(repo_id: &str, quant: Quantization) -> Result<Self, EmbeddingError> {
CandleEmbeddingsBuilder::from_hf_hub(repo_id)?
.with_quantization(quant)
.build()
}
pub fn from_catalog(catalog: &ModelCatalog, name: &str) -> Result<Self, EmbeddingError> {
Self::from_catalog_with(catalog, name, Quantization::None)
}
pub fn from_catalog_with(
catalog: &ModelCatalog,
name: &str,
quant: Quantization,
) -> Result<Self, EmbeddingError> {
let entry = catalog.resolve_ready(name)?;
let config_path = entry.config_path().ok_or_else(|| {
EmbeddingError::Config(format!(
"model '{}' is registered with the {:?} layout; the candle backend needs \
config.json + model.safetensors + tokenizer.json",
entry.name, entry.layout
))
})?;
if entry.verify_checksum()? == Some(false) {
return Err(EmbeddingError::Config(format!(
"model '{}' at '{}' does not match the checksum recorded in the catalog \
(re-register it after replacing the weights)",
entry.name,
entry.dir.display()
)));
}
Self::load(
&config_path,
&entry.tokenizer_path(),
&entry.model_path(),
entry.name.clone(),
quant,
)
}
fn load(
config_path: &Path,
tokenizer_path: &Path,
weights_path: &Path,
model_name: String,
quant: Quantization,
) -> Result<Self, EmbeddingError> {
let config: Config =
serde_json::from_reader(std::fs::File::open(config_path).map_err(|e| {
EmbeddingError::Config(format!("failed to open {}: {e}", config_path.display()))
})?)
.map_err(|e| {
EmbeddingError::Config(format!("failed to parse {}: {e}", config_path.display()))
})?;
let tokenizer = tokenizers::Tokenizer::from_file(tokenizer_path).map_err(|e| {
EmbeddingError::Config(format!(
"failed to load tokenizer {}: {e}",
tokenizer_path.display()
))
})?;
let device = Device::Cpu;
let tensors = candle_core::safetensors::load(weights_path, &device).map_err(|e| {
EmbeddingError::Config(format!(
"failed to load weights {}: {e}",
weights_path.display()
))
})?;
let model = match quant {
Quantization::None => {
let vb = VarBuilder::from_tensors(tensors, DType::F32, &device);
BertModel::load(vb, &config)
}
Quantization::F16 => {
let tensors = quantize_f16(tensors)?;
let vb = VarBuilder::from_tensors(tensors, DType::F32, &device);
BertModel::load(vb, &config)
}
Quantization::Int8 => {
let tensors = quantize_int8(tensors, &device)?;
let vb = VarBuilder::from_tensors(tensors, DType::F32, &device);
BertModel::load(vb, &config)
}
Quantization::Fp8 => {
let tensors = quantize_fp8(tensors)?;
let vb = VarBuilder::from_tensors(tensors, DType::F32, &device);
BertModel::load(vb, &config)
}
}
.map_err(|e| EmbeddingError::Config(format!("failed to build BERT model: {e}")))?;
Ok(Self {
dim: config.hidden_size,
model: Arc::new(model),
tokenizer,
model_name,
max_len: config.max_position_embeddings,
})
}
}
fn hf_err(e: hf_hub::api::sync::ApiError) -> EmbeddingError {
EmbeddingError::Config(format!("hf-hub download failed: {e}"))
}
pub struct HfHubFetcher {
base_dir: PathBuf,
}
impl HfHubFetcher {
pub fn new(base_dir: impl Into<PathBuf>) -> Self {
Self {
base_dir: base_dir.into(),
}
}
}
impl ModelFetcher for HfHubFetcher {
fn fetch(&self, spec: &ModelSpec) -> Result<PathBuf, EmbeddingError> {
let repo_id = match &spec.source {
ModelSource::HuggingFace { repo_id } => repo_id.clone(),
ModelSource::Local => {
return Err(EmbeddingError::Config(format!(
"HfHubFetcher needs a HuggingFace source, but '{}' is declared as local",
spec.name
)));
}
};
let api = hf_hub::api::sync::Api::new().map_err(|e| {
EmbeddingError::Config(format!("failed to initialize hf-hub client: {e}"))
})?;
let repo = api.model(repo_id);
let dir = self.base_dir.join(&spec.name);
std::fs::create_dir_all(&dir).map_err(|e| {
EmbeddingError::Config(format!(
"failed to create model directory '{}': {e}",
dir.display()
))
})?;
for file in spec.layout.required_files() {
let downloaded = repo.get(*file).map_err(hf_err)?;
let target = dir.join(file);
std::fs::copy(&downloaded, &target).map_err(|e| {
EmbeddingError::Config(format!(
"failed to copy '{}' to '{}': {e}",
downloaded.display(),
target.display()
))
})?;
}
Ok(dir)
}
}
fn tensor_err(e: candle_core::Error) -> EmbeddingError {
EmbeddingError::ApiError(format!("candle tensor error: {e}"))
}
fn quantize_f16(
tensors: HashMap<String, Tensor>,
) -> Result<HashMap<String, Tensor>, EmbeddingError> {
let mut out = HashMap::with_capacity(tensors.len());
for (name, t) in tensors {
let restored = t.to_dtype(DType::F16).and_then(|h| h.to_dtype(DType::F32));
let r = match restored {
Ok(r) => r,
Err(e) => {
let msg = format!("failed to FP16-cast weight `{}`: {e}", name);
return Err(EmbeddingError::Config(msg));
}
};
out.insert(name, r);
}
Ok(out)
}
fn quantize_int8(
tensors: HashMap<String, Tensor>,
device: &Device,
) -> Result<HashMap<String, Tensor>, EmbeddingError> {
let mut out = HashMap::with_capacity(tensors.len());
for (name, t) in tensors {
let restored = QTensor::quantize(&t, GgmlDType::Q8_0)
.and_then(|q| q.dequantize(device))
.unwrap_or(t);
out.insert(name, restored);
}
Ok(out)
}
fn quantize_fp8(
tensors: HashMap<String, Tensor>,
) -> Result<HashMap<String, Tensor>, EmbeddingError> {
let mut out = HashMap::with_capacity(tensors.len());
for (name, t) in tensors {
let restored = t.to_dtype(DType::F8E4M3).and_then(|h| h.to_dtype(DType::F32));
let r = match restored {
Ok(r) => r,
Err(e) => {
let msg = format!("failed to FP8-cast weight `{}`: {e}", name);
return Err(EmbeddingError::Config(msg));
}
};
out.insert(name, r);
}
Ok(out)
}
pub(crate) fn mean_pool_rows_flat(
hidden: &[f32],
batch: usize,
seq_len: usize,
hidden_size: usize,
masks: &[i64],
) -> Result<Vec<Vec<f32>>, EmbeddingError> {
let expected = batch * seq_len * hidden_size;
if hidden.len() < expected {
return Err(EmbeddingError::ParseError(format!(
"hidden tensor too short: need {expected} floats, got {}",
hidden.len()
)));
}
let mask_len = batch * seq_len;
if masks.len() < mask_len {
return Err(EmbeddingError::ParseError(format!(
"attention mask too short: need {mask_len} entries, got {}",
masks.len()
)));
}
let mut out = vec![vec![0.0f32; hidden_size]; batch];
for (b, out_row) in out.iter_mut().enumerate() {
let mask = &masks[b * seq_len..(b + 1) * seq_len];
let mut count = 0usize;
for (s, m) in mask.iter().enumerate() {
if *m == 0 {
continue;
}
count += 1;
let base = (b * seq_len + s) * hidden_size;
for d in 0..hidden_size {
out_row[d] += hidden[base + d];
}
}
if count > 0 {
for v in out_row {
*v /= count as f32;
}
}
}
Ok(out)
}
pub(crate) fn mean_pool_rows(
hidden: &[f32],
batch: usize,
seq_len: usize,
hidden_size: usize,
masks: &[Vec<i64>],
) -> Result<Vec<Vec<f32>>, EmbeddingError> {
let mut flat: Vec<i64> = Vec::with_capacity(batch * seq_len);
for b in 0..batch {
let row = masks
.get(b)
.ok_or_else(|| EmbeddingError::ParseError("missing attention mask row".to_string()))?;
if row.len() < seq_len {
return Err(EmbeddingError::ParseError(format!(
"attention mask row {b} too short: need {seq_len} entries, got {}",
row.len()
)));
}
flat.extend_from_slice(&row[..seq_len]);
}
mean_pool_rows_flat(hidden, batch, seq_len, hidden_size, &flat)
}
#[async_trait]
impl Embeddings for CandleEmbeddings {
async fn embed_query(&self, text: &str) -> Result<Vec<f32>, EmbeddingError> {
if text.trim().is_empty() {
return Err(EmbeddingError::EmptyInput);
}
self.embed_documents(&[text])
.await
.map(|mut v| v.pop().unwrap_or_default())
}
async fn embed_documents(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>, EmbeddingError> {
if texts.is_empty() {
return Ok(Vec::new());
}
let mut all: Vec<Vec<f32>> = Vec::with_capacity(texts.len());
for chunk in texts.chunks(MAX_BATCH) {
let model = self.model.clone();
let tokenizer = self.tokenizer.clone();
let dim = self.dim;
let max_len = self.max_len;
let chunk: Vec<String> = chunk.iter().map(|s| s.to_string()).collect();
let blocking = BlockingEmbedder {
model,
tokenizer,
dim,
max_len,
};
let rows = tokio::task::spawn_blocking(move || blocking.embed(chunk))
.await
.map_err(|e| EmbeddingError::ApiError(format!("Task execution failed: {e}")))??;
all.extend(rows);
}
Ok(all)
}
fn dimension(&self) -> usize {
self.dim
}
fn model_name(&self) -> &str {
&self.model_name
}
}
struct BlockingEmbedder {
model: Arc<BertModel>,
tokenizer: tokenizers::Tokenizer,
dim: usize,
max_len: usize,
}
impl BlockingEmbedder {
fn embed(&self, texts: Vec<String>) -> Result<Vec<Vec<f32>>, EmbeddingError> {
if texts.is_empty() {
return Ok(Vec::new());
}
if texts.iter().any(|t| t.trim().is_empty()) {
return Err(EmbeddingError::EmptyInput);
}
let encodings = self
.tokenizer
.encode_batch(texts, true)
.map_err(|e| EmbeddingError::ApiError(format!("tokenizer failed: {e}")))?;
let batch = encodings.len();
let max_len = encodings
.iter()
.map(|e| e.get_ids().len())
.max()
.unwrap_or(0)
.min(self.max_len.max(1));
let pad_id = self.tokenizer.token_to_id("[PAD]").unwrap_or(0) as i64;
let mut input_ids = vec![pad_id; batch * max_len];
let mut attention = vec![0i64; batch * max_len];
let token_types = vec![0i64; batch * max_len];
for (b, enc) in encodings.iter().enumerate() {
let row = b * max_len;
for (i, id) in enc.get_ids().iter().take(max_len).enumerate() {
input_ids[row + i] = *id as i64;
}
for (i, m) in enc.get_attention_mask().iter().take(max_len).enumerate() {
attention[row + i] = *m as i64;
}
}
let device = Device::Cpu;
let input_ids = Tensor::new(&input_ids[..], &device)
.and_then(|t| t.reshape((batch, max_len)))
.map_err(tensor_err)?;
let token_type_ids = Tensor::new(&token_types[..], &device)
.and_then(|t| t.reshape((batch, max_len)))
.map_err(tensor_err)?;
let attention_mask = Tensor::new(&attention[..], &device)
.and_then(|t| t.reshape((batch, max_len)))
.map_err(tensor_err)?;
let hidden = self
.model
.forward(&input_ids, &token_type_ids, Some(&attention_mask))
.map_err(|e| EmbeddingError::ApiError(format!("candle forward failed: {e}")))?;
let hidden_data = hidden
.flatten_all()
.map_err(tensor_err)?
.to_dtype(DType::F32)
.map_err(tensor_err)?
.to_vec1::<f32>()
.map_err(tensor_err)?;
let mut rows = mean_pool_rows_flat(&hidden_data, batch, max_len, self.dim, &attention)?;
for row in &mut rows {
crate::l2_normalize(row);
}
Ok(rows)
}
}
#[cfg(test)]
mod tests {
use super::*;
use candle_transformers::models::bert::{HiddenAct, PositionEmbeddingType};
const TINY_HIDDEN: usize = 16;
const TINY_INTER: usize = 32;
const TINY_VOCAB: usize = 40;
fn tiny_bert_config() -> Config {
Config {
vocab_size: TINY_VOCAB,
hidden_size: TINY_HIDDEN,
num_hidden_layers: 1,
num_attention_heads: 2,
intermediate_size: TINY_INTER,
hidden_act: HiddenAct::Gelu,
hidden_dropout_prob: 0.0,
max_position_embeddings: 16,
type_vocab_size: 2,
initializer_range: 0.02,
layer_norm_eps: 1e-12,
pad_token_id: 0,
position_embedding_type: PositionEmbeddingType::Absolute,
use_cache: false,
classifier_dropout: None,
model_type: Some("bert".to_string()),
}
}
fn tiny_bert_weights(device: &Device) -> HashMap<String, Tensor> {
let h = TINY_HIDDEN;
let mut w = HashMap::new();
fn add(
w: &mut HashMap<String, Tensor>,
path: &str,
shape: (usize, usize),
device: &Device,
) {
w.insert(
path.to_string(),
Tensor::randn(0f32, 0.01f32, shape, device).unwrap(),
);
}
fn add1(w: &mut HashMap<String, Tensor>, path: &str, n: usize, device: &Device) {
w.insert(
path.to_string(),
Tensor::randn(0f32, 0.01f32, (n,), device).unwrap(),
);
}
add(
&mut w,
"embeddings.word_embeddings.weight",
(TINY_VOCAB, h),
device,
);
add(
&mut w,
"embeddings.position_embeddings.weight",
(16, h),
device,
);
add(
&mut w,
"embeddings.token_type_embeddings.weight",
(2, h),
device,
);
add1(&mut w, "embeddings.LayerNorm.weight", h, device);
add1(&mut w, "embeddings.LayerNorm.bias", h, device);
add(
&mut w,
"encoder.layer.0.attention.self.query.weight",
(h, h),
device,
);
add1(
&mut w,
"encoder.layer.0.attention.self.query.bias",
h,
device,
);
add(
&mut w,
"encoder.layer.0.attention.self.key.weight",
(h, h),
device,
);
add1(&mut w, "encoder.layer.0.attention.self.key.bias", h, device);
add(
&mut w,
"encoder.layer.0.attention.self.value.weight",
(h, h),
device,
);
add1(
&mut w,
"encoder.layer.0.attention.self.value.bias",
h,
device,
);
add(
&mut w,
"encoder.layer.0.attention.output.dense.weight",
(h, h),
device,
);
add1(
&mut w,
"encoder.layer.0.attention.output.dense.bias",
h,
device,
);
add1(
&mut w,
"encoder.layer.0.attention.output.LayerNorm.weight",
h,
device,
);
add1(
&mut w,
"encoder.layer.0.attention.output.LayerNorm.bias",
h,
device,
);
add(
&mut w,
"encoder.layer.0.intermediate.dense.weight",
(TINY_INTER, h),
device,
);
add1(
&mut w,
"encoder.layer.0.intermediate.dense.bias",
TINY_INTER,
device,
);
add(
&mut w,
"encoder.layer.0.output.dense.weight",
(h, TINY_INTER),
device,
);
add1(&mut w, "encoder.layer.0.output.dense.bias", h, device);
add1(&mut w, "encoder.layer.0.output.LayerNorm.weight", h, device);
add1(&mut w, "encoder.layer.0.output.LayerNorm.bias", h, device);
w
}
#[test]
fn mean_pool_weights_by_mask() {
let hidden = vec![
1.0, 1.0, 3.0, 3.0, 5.0, 7.0, 100.0, 100.0, ];
let masks = vec![vec![1, 1], vec![1, 0]];
let rows = mean_pool_rows(&hidden, 2, 2, 2, &masks).unwrap();
assert_eq!(rows[0], vec![2.0, 2.0]);
assert_eq!(rows[1], vec![5.0, 7.0], "pad position excluded");
}
#[test]
fn mean_pool_all_masked_row_is_zero() {
let hidden = vec![1.0, 2.0];
let rows = mean_pool_rows(&hidden, 1, 1, 2, &[vec![0]]).unwrap();
assert_eq!(rows[0], vec![0.0, 0.0]);
assert!(rows[0].iter().all(|v| v.is_finite()));
}
#[test]
fn mean_pool_short_tensor_errors() {
let err = mean_pool_rows(&[1.0, 2.0], 1, 2, 2, &[vec![1, 1]]).unwrap_err();
assert!(matches!(err, EmbeddingError::ParseError(_)));
}
#[test]
fn quantization_default_is_none() {
assert_eq!(Quantization::default(), Quantization::None);
let q = CandleEmbeddingsBuilder::from_dir(std::env::temp_dir())
.with_quantization(Quantization::F16);
assert_eq!(q.quantization, Quantization::F16);
}
#[test]
fn model_forward_on_cpu_returns_f32() {
let device = Device::Cpu;
let weights = tiny_bert_weights(&device);
let vb = VarBuilder::from_tensors(weights, DType::F32, &device);
let config = tiny_bert_config();
let model = BertModel::load(vb, &config).unwrap();
let ids = Tensor::new(&[[1i64, 2i64, 3i64]], &device).unwrap();
let types = Tensor::new(&[[0i64, 0i64, 0i64]], &device).unwrap();
let mask = Tensor::new(&[[1i64, 1i64, 1i64]], &device).unwrap();
let hidden = model.forward(&ids, &types, Some(&mask)).unwrap();
assert_eq!(hidden.dtype(), DType::F32);
assert_eq!(hidden.shape().dims(), &[1usize, 3usize, 16usize]);
let out = hidden.flatten_all().unwrap().to_vec1::<f32>().unwrap();
assert_eq!(out.len(), 48);
assert!(out.iter().all(|v| v.is_finite()), "all outputs finite");
}
#[test]
fn f16_quantize_roundtrip_keeps_shape_and_values() {
let device = Device::Cpu;
let src = Tensor::randn(0f32, 0.1f32, (768, 384), &device).unwrap();
let dq = src
.to_dtype(DType::F16)
.unwrap()
.to_dtype(DType::F32)
.unwrap();
assert_eq!(dq.shape(), src.shape());
let a = src.flatten_all().unwrap().to_vec1::<f32>().unwrap();
let b = dq.flatten_all().unwrap().to_vec1::<f32>().unwrap();
let max_err = a
.iter()
.zip(&b)
.map(|(x, y)| (x - y).abs())
.fold(0.0f32, f32::max);
assert!(max_err < 1e-2, "FP16 round-trip max error {max_err}");
}
#[test]
fn q8_quantize_roundtrip_keeps_shape_and_values() {
let device = Device::Cpu;
let src = Tensor::randn(0f32, 1f32, (768usize, 384usize), &device).unwrap();
let q = QTensor::quantize(&src, GgmlDType::Q8_0).unwrap();
let dq = q.dequantize(&device).unwrap();
assert_eq!(dq.shape(), src.shape());
let a = src.flatten_all().unwrap().to_vec1::<f32>().unwrap();
let b = dq.flatten_all().unwrap().to_vec1::<f32>().unwrap();
assert_eq!(a.len(), b.len());
let max_err = a
.iter()
.zip(&b)
.map(|(x, y)| (x - y).abs())
.fold(0.0f32, f32::max);
assert!(max_err < 5e-2, "Q8_0 round-trip max error {max_err}");
}
#[test]
fn fp8_quantize_roundtrip_keeps_shape_and_values() {
let device = Device::Cpu;
let src = Tensor::randn(0f32, 1f32, (768usize, 384usize), &device).unwrap();
let dq = src.to_dtype(DType::F8E4M3).unwrap().to_dtype(DType::F32).unwrap();
assert_eq!(dq.shape(), src.shape());
let a = src.flatten_all().unwrap().to_vec1::<f32>().unwrap();
let b = dq.flatten_all().unwrap().to_vec1::<f32>().unwrap();
assert_eq!(a.len(), b.len());
let max_err = a
.iter()
.zip(&b)
.map(|(x, y)| (x - y).abs())
.fold(0.0f32, f32::max);
assert!(b.iter().all(|v| v.is_finite()), "no NaN/Inf (SatFinite)");
assert!(max_err < 0.5, "FP8 round-trip max error {max_err}");
}
#[test]
fn mean_pool_rows_flat_matches_rowwise() {
let hidden = vec![
1.0, 1.0, 3.0, 3.0, 5.0, 7.0, 100.0, 100.0, ];
let row_masks = vec![vec![1, 1], vec![1, 0]];
let flat_masks: Vec<i64> = row_masks.iter().flatten().copied().collect();
let by_rows = mean_pool_rows(&hidden, 2, 2, 2, &row_masks).unwrap();
let by_flat = mean_pool_rows_flat(&hidden, 2, 2, 2, &flat_masks).unwrap();
assert_eq!(by_rows, by_flat);
assert_eq!(by_flat[0], vec![2.0, 2.0]);
assert_eq!(by_flat[1], vec![5.0, 7.0], "pad position excluded");
}
#[test]
fn quantization_knob_fails_fast_on_missing_dir() {
let dir = std::env::temp_dir().join("lc-candle-quant-nonexistent");
for quant in [Quantization::F16, Quantization::Int8, Quantization::Fp8] {
let err = CandleEmbeddings::from_dir_with(&dir, quant).unwrap_err();
assert!(matches!(err, EmbeddingError::Config(_)));
}
}
#[tokio::test]
#[ignore = "downloads a real BERT model from HuggingFace (~120MB) per mode"]
async fn quantized_modes_match_f32_within_tolerance() {
let text = "Rust is a systems programming language.";
let f32v = CandleEmbeddings::from_hf_hub("BAAI/bge-small-en-v1.5")
.unwrap()
.embed_query(text)
.await
.unwrap();
let f16 = CandleEmbeddings::from_hf_hub_with("BAAI/bge-small-en-v1.5", Quantization::F16)
.unwrap()
.embed_query(text)
.await
.unwrap();
let i8 = CandleEmbeddings::from_hf_hub_with("BAAI/bge-small-en-v1.5", Quantization::Int8)
.unwrap()
.embed_query(text)
.await
.unwrap();
for (name, v) in [("f16", &f16), ("int8", &i8)] {
let dot: f32 = f32v.iter().zip(v.iter()).map(|(a, b)| a * b).sum();
assert!(dot > 0.999, "{name} vs F32 cosine similarity {dot}");
}
}
#[test]
fn config_json_parses() {
let json = r#"{
"vocab_size": 30522, "hidden_size": 384, "num_hidden_layers": 6,
"num_attention_heads": 12, "intermediate_size": 1536,
"hidden_act": "gelu", "hidden_dropout_prob": 0.1,
"max_position_embeddings": 512, "type_vocab_size": 2,
"initializer_range": 0.02, "layer_norm_eps": 1e-12,
"pad_token_id": 0, "classifier_dropout": null, "model_type": "bert"
}"#;
let config: Config = serde_json::from_str(json).unwrap();
assert_eq!(config.hidden_size, 384);
assert_eq!(config.num_hidden_layers, 6);
}
#[test]
fn missing_files_fail_fast() {
let dir = std::env::temp_dir().join("lc-candle-nonexistent-dir");
let err = CandleEmbeddings::from_dir(&dir).unwrap_err();
assert!(matches!(err, EmbeddingError::Config(_)));
}
#[tokio::test]
#[ignore = "downloads a real BERT model from HuggingFace (~120MB)"]
async fn real_model_end_to_end() {
let embedder =
CandleEmbeddings::from_hf_hub("BAAI/bge-small-en-v1.5").expect("model loads");
assert_eq!(embedder.dimension(), 384);
let vec = embedder
.embed_query("Rust is a systems programming language.")
.await
.unwrap();
assert_eq!(vec.len(), 384);
let norm: f32 = vec.iter().map(|x| x * x).sum::<f32>().sqrt();
assert!((norm - 1.0).abs() < 1e-3, "L2-normalized, got {norm}");
let again = embedder
.embed_query("Rust is a systems programming language.")
.await
.unwrap();
assert_eq!(vec, again);
let other = embedder.embed_query("banana pancake recipe").await.unwrap();
let dot: f32 = vec.iter().zip(other.iter()).map(|(a, b)| a * b).sum();
assert!(dot < 0.99, "distinct texts should not be near-identical");
let batch = embedder
.embed_documents(&["first doc", "second doc", "third one"])
.await
.unwrap();
assert_eq!(batch.len(), 3);
assert!(batch.iter().all(|v| v.len() == 384));
}
}