#[cfg(target_endian = "big")]
compile_error!("fathomdb-embedder default path requires a little-endian target");
use std::path::Path;
use candle_core::{DType, Device, Tensor};
use candle_nn::VarBuilder;
use candle_transformers::models::bert::{BertModel, Config as BertConfig};
use fathomdb_embedder_api::{Embedder, EmbedderError, EmbedderIdentity, Vector};
use tokenizers::{Tokenizer, TruncationParams};
use crate::loader::{load_pinned_default_embedder, EmbedderLoadError, LoadedWeights, HF_REVISION};
pub const DEFAULT_EMBEDDER_NAME: &str = "fathomdb-bge-small-en-v1.5";
pub const DEFAULT_EMBEDDER_DIM: u32 = 384;
const MAX_SEQUENCE_TOKENS: usize = 512;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Pooling {
Mean,
Cls,
}
fn l2_normalize(pooled: &Tensor) -> candle_core::Result<Tensor> {
let norm = pooled.sqr()?.sum_keepdim(1)?.sqrt()?;
let norm = norm.clamp(1e-12_f32, f32::INFINITY)?;
pooled.broadcast_div(&norm)
}
fn mean_pool(hidden: &Tensor, attn_mask_u32: &Tensor) -> candle_core::Result<Tensor> {
let mask_f = attn_mask_u32.to_dtype(DType::F32)?.unsqueeze(2)?; let mask_f = mask_f.broadcast_as(hidden.shape())?; let summed = (hidden * &mask_f)?.sum(1)?; let counts = mask_f.sum(1)?.clamp(1e-9_f32, f32::INFINITY)?; summed / counts
}
fn cls_pool(hidden: &Tensor) -> candle_core::Result<Tensor> {
hidden.narrow(1, 0, 1)?.squeeze(1)
}
pub struct CandleBgeEmbedder {
identity: EmbedderIdentity,
tokenizer: Tokenizer,
model: BertModel,
device: Device,
pooling: Pooling,
}
use crate::device::{parse_device_request, DeviceRequest};
#[allow(clippy::print_stderr)] fn resolve_device() -> Device {
match parse_device_request(&std::env::var("FATHOMDB_EMBED_DEVICE").unwrap_or_default()) {
DeviceRequest::Cpu => Device::Cpu,
DeviceRequest::Cuda(_idx) => {
#[cfg(feature = "embed-cuda")]
match Device::new_cuda(_idx) {
Ok(d) => return d,
Err(e) => eprintln!(
"fathomdb-embedder: FATHOMDB_EMBED_DEVICE=cuda:{_idx} but CUDA init failed ({e}); using CPU"
),
}
#[cfg(not(feature = "embed-cuda"))]
eprintln!(
"fathomdb-embedder: FATHOMDB_EMBED_DEVICE=cuda requested but this build lacks the `embed-cuda` feature; using CPU"
);
Device::Cpu
}
DeviceRequest::Metal => {
#[cfg(feature = "embed-metal")]
match Device::new_metal(0) {
Ok(d) => return d,
Err(e) => eprintln!(
"fathomdb-embedder: FATHOMDB_EMBED_DEVICE=metal but Metal init failed ({e}); using CPU"
),
}
#[cfg(not(feature = "embed-metal"))]
eprintln!(
"fathomdb-embedder: FATHOMDB_EMBED_DEVICE=metal requested but this build lacks the `embed-metal` feature; using CPU"
);
Device::Cpu
}
DeviceRequest::Unknown(req) => {
eprintln!(
"fathomdb-embedder: FATHOMDB_EMBED_DEVICE={req} not recognized (expected cpu|cuda|cuda:N|metal); using CPU"
);
Device::Cpu
}
}
}
impl CandleBgeEmbedder {
pub fn new() -> Result<Self, EmbedderLoadError> {
let weights = load_pinned_default_embedder()?;
Self::new_from_weights(weights)
}
pub fn new_from_weights(weights: LoadedWeights) -> Result<Self, EmbedderLoadError> {
let config_bytes = std::fs::read(&weights.config_json_path).map_err(|source| {
EmbedderLoadError::CacheIoError { path: weights.config_json_path.clone(), source }
})?;
let config: BertConfig =
serde_json::from_slice(&config_bytes).map_err(|e| EmbedderLoadError::CacheIoError {
path: weights.config_json_path.clone(),
source: std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()),
})?;
if config.hidden_size != DEFAULT_EMBEDDER_DIM as usize {
return Err(EmbedderLoadError::DimensionMismatch {
expected: DEFAULT_EMBEDDER_DIM,
actual: config.hidden_size as u32,
});
}
let mut tokenizer = Tokenizer::from_file(&weights.tokenizer_json_path)
.map_err(|e| EmbedderLoadError::TokenizerLoad { source: e })?;
tokenizer
.with_truncation(Some(TruncationParams {
max_length: MAX_SEQUENCE_TOKENS,
..Default::default()
}))
.map_err(|e| EmbedderLoadError::TokenizerLoad { source: e })?;
let device = resolve_device();
let vb = unsafe {
VarBuilder::from_mmaped_safetensors(
&[weights.model_safetensors_path.as_path() as &Path],
DType::F32,
&device,
)
}
.map_err(|source| EmbedderLoadError::ModelDeserialize { source })?;
let model = BertModel::load(vb, &config)
.map_err(|source| EmbedderLoadError::ModelDeserialize { source })?;
let identity =
EmbedderIdentity::new(DEFAULT_EMBEDDER_NAME, HF_REVISION, DEFAULT_EMBEDDER_DIM);
Ok(Self { identity, tokenizer, model, device, pooling: Pooling::Mean })
}
pub fn with_pooling(mut self, pooling: Pooling) -> Self {
self.pooling = pooling;
self
}
#[must_use]
pub fn device_label(&self) -> String {
match self.device.location() {
candle_core::DeviceLocation::Cpu => "cpu".to_string(),
candle_core::DeviceLocation::Cuda { gpu_id } => format!("cuda:{gpu_id}"),
candle_core::DeviceLocation::Metal { gpu_id } => format!("metal:{gpu_id}"),
}
}
}
impl Embedder for CandleBgeEmbedder {
fn identity(&self) -> EmbedderIdentity {
self.identity.clone()
}
fn embed(&self, input: &str) -> Result<Vector, EmbedderError> {
let encoding = self
.tokenizer
.encode(input, true)
.map_err(|e| EmbedderError::Failed { message: format!("tokenize: {e}") })?;
let ids: Vec<u32> = encoding.get_ids().to_vec();
let attn: Vec<u32> = encoding.get_attention_mask().to_vec();
let len = ids.len();
let embed_impl = || -> candle_core::Result<Vec<f32>> {
let input_ids = Tensor::from_vec(ids, (1, len), &self.device)?;
let attn_mask_u32 = Tensor::from_vec(attn, (1, len), &self.device)?;
let token_type_ids = input_ids.zeros_like()?;
let hidden = self.model.forward(&input_ids, &token_type_ids, Some(&attn_mask_u32))?;
let pooled = match self.pooling {
Pooling::Mean => mean_pool(&hidden, &attn_mask_u32)?,
Pooling::Cls => cls_pool(&hidden)?,
};
let normed = l2_normalize(&pooled)?;
let v: Vec<f32> = normed.squeeze(0)?.to_vec1::<f32>()?;
Ok(v)
};
embed_impl().map_err(|e| EmbedderError::Failed { message: format!("forward: {e}") })
}
fn embed_batch(&self, inputs: &[&str]) -> Result<Vec<Vector>, EmbedderError> {
if inputs.is_empty() {
return Ok(Vec::new());
}
let mut encodings = Vec::with_capacity(inputs.len());
for input in inputs {
let enc = self
.tokenizer
.encode(*input, true)
.map_err(|e| EmbedderError::Failed { message: format!("tokenize: {e}") })?;
encodings.push(enc);
}
let batch = inputs.len();
let max_len = encodings.iter().map(|e| e.get_ids().len()).max().unwrap_or(0).max(1);
let mut ids = vec![0u32; batch * max_len];
let mut attn = vec![0u32; batch * max_len];
for (row, enc) in encodings.iter().enumerate() {
let base = row * max_len;
for (col, (&id, &mask)) in
enc.get_ids().iter().zip(enc.get_attention_mask()).enumerate()
{
ids[base + col] = id;
attn[base + col] = mask;
}
}
let embed_impl = || -> candle_core::Result<Vec<Vec<f32>>> {
let input_ids = Tensor::from_vec(ids, (batch, max_len), &self.device)?;
let attn_mask_u32 = Tensor::from_vec(attn, (batch, max_len), &self.device)?;
let token_type_ids = input_ids.zeros_like()?;
let hidden = self.model.forward(&input_ids, &token_type_ids, Some(&attn_mask_u32))?;
let pooled = match self.pooling {
Pooling::Mean => mean_pool(&hidden, &attn_mask_u32)?,
Pooling::Cls => cls_pool(&hidden)?,
};
let normed = l2_normalize(&pooled)?; normed.to_vec2::<f32>() };
embed_impl().map_err(|e| EmbedderError::Failed { message: format!("batch forward: {e}") })
}
}
impl CandleBgeEmbedder {
pub fn embed_dual_for_test(&self, input: &str) -> Result<(Vector, Vector), EmbedderError> {
let encoding = self
.tokenizer
.encode(input, true)
.map_err(|e| EmbedderError::Failed { message: format!("tokenize: {e}") })?;
let ids: Vec<u32> = encoding.get_ids().to_vec();
let attn: Vec<u32> = encoding.get_attention_mask().to_vec();
let len = ids.len();
let dual = || -> candle_core::Result<(Vec<f32>, Vec<f32>)> {
let input_ids = Tensor::from_vec(ids, (1, len), &self.device)?;
let attn_mask_u32 = Tensor::from_vec(attn, (1, len), &self.device)?;
let token_type_ids = input_ids.zeros_like()?;
let hidden = self.model.forward(&input_ids, &token_type_ids, Some(&attn_mask_u32))?;
let mean = l2_normalize(&mean_pool(&hidden, &attn_mask_u32)?)?.squeeze(0)?.to_vec1()?;
let cls = l2_normalize(&cls_pool(&hidden)?)?.squeeze(0)?.to_vec1()?;
Ok((mean, cls))
};
dual().map_err(|e| EmbedderError::Failed { message: format!("forward: {e}") })
}
}