use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use hf_hub::{Repo, RepoType, api::sync::ApiBuilder};
use ort::environment::Environment;
use ort::session::Session;
use ort::session::builder::GraphOptimizationLevel;
use tokenizers::Tokenizer;
use super::inference;
use crate::embedding_profiles::{EmbeddingRole, ModelProfile};
use crate::errors::{Error, with_prefix};
use tokenizers::TruncationParams;
pub const EMBEDDING_DIMS: usize = 384;
pub const MAX_EMBEDDING_TOKENS: usize = 512;
pub const EMBED_MODEL_ID: &str = "BAAI/bge-small-en-v1.5";
pub const EMBED_MODEL_REVISION: &str = "5c38ec7c405ec4b44b94cc5a9bb96e735b38267a";
pub(crate) fn resolve_hf_cache_dir(hf_home: Option<&str>, home: &Path) -> PathBuf {
match hf_home {
Some(hf_home) if !hf_home.is_empty() => Path::new(hf_home).join("hub"),
_ => home.join(".cache").join("huggingface").join("hub"),
}
}
fn ort_environment() -> Result<Environment, Error> {
static ENVIRONMENT: OnceLock<Result<Environment, String>> = OnceLock::new();
let environment = ENVIRONMENT.get_or_init(|| {
ort::init()
.with_telemetry(false)
.build()
.map_err(|e| format!("[{:?}] {}", e.code(), e))
});
match environment {
Ok(environment) => Ok(environment.clone()),
Err(message) => Err(Error::Onnx {
message: format!("ort environment: {message}"),
}),
}
}
pub struct EmbeddingEngine {
_environment: Environment,
pub(super) session: Session,
pub(super) tokenizer: Tokenizer,
count_tokenizer: Tokenizer,
pub(super) requires_token_type_ids: bool,
profile: &'static ModelProfile,
}
impl EmbeddingEngine {
pub fn new(model_id: &str) -> Result<Self, Error> {
use crate::embedding_profiles::profile_for;
let profile = profile_for(model_id)?;
let environment = ort_environment()?;
let home = crate::config::home_dir();
let cache_dir = resolve_hf_cache_dir(std::env::var("HF_HOME").ok().as_deref(), &home);
let air_gapped_hint = format!(
"If running in an air-gapped environment, pre-fetch the model before going offline:\n huggingface-cli download {} --revision {} --cache-dir {}",
profile.model_id,
profile.revision,
cache_dir.display()
);
let api = ApiBuilder::from_cache(hf_hub::Cache::new(cache_dir))
.build()
.map_err(|e| {
Error::Config(format!(
"Failed to initialise the HuggingFace Hub client: {e}.\n\n{air_gapped_hint}"
))
})?;
let repo = api.repo(Repo::with_revision(
profile.model_id.to_string(),
RepoType::Model,
profile.revision.to_string(),
));
let wrap_download_err = |e: hf_hub::api::sync::ApiError| {
Error::Config(format!(
"Failed to download embedding model '{}': {}.\n\n{air_gapped_hint}",
profile.model_id, e
))
};
let model_path = repo
.get(profile.onnx_path)
.or_else(|_| repo.get("onnx/model.onnx"))
.or_else(|_| repo.get("model.onnx"))
.map_err(wrap_download_err)?;
let tokenizer_path = repo.get("tokenizer.json").map_err(wrap_download_err)?;
let mut tokenizer = Tokenizer::from_file(tokenizer_path).map_err(Error::from)?;
tokenizer
.with_padding(None)
.with_truncation(Some(TruncationParams {
max_length: 512,
..Default::default()
}))
.map_err(Error::from)?;
let session = Session::builder(&environment)
.map_err(Error::from)?
.with_optimization_level(GraphOptimizationLevel::Level1)
.map_err(|e| Error::from(ort::error::Error::from(e)))?
.commit_from_file(&model_path)
.map_err(Error::from)?;
let requires_token_type_ids = session
.inputs()
.iter()
.any(|input| input.name() == "token_type_ids");
let mut count_tokenizer = tokenizer.clone();
count_tokenizer.with_truncation(None).map_err(Error::from)?;
Ok(EmbeddingEngine {
_environment: environment,
session,
tokenizer,
count_tokenizer,
requires_token_type_ids,
profile,
})
}
pub fn token_count(&self, role: EmbeddingRole, text: &str) -> Result<usize, Error> {
let input = self.prefix_input(role, text);
self.count_tokens(&input)
}
pub fn embed_passage(&mut self, text: &str) -> Result<Vec<f32>, Error> {
self.embed_role(text, EmbeddingRole::Passage)
}
pub fn embed_query(&mut self, text: &str) -> Result<Vec<f32>, Error> {
self.embed_role(text, EmbeddingRole::Query)
}
pub(crate) fn prefix_input(&self, role: EmbeddingRole, text: &str) -> String {
let prefix = role.prefix(self.profile);
if prefix.is_empty() {
text.to_string()
} else {
format!("{prefix}{text}")
}
}
fn embed_role(&mut self, text: &str, role: EmbeddingRole) -> Result<Vec<f32>, Error> {
if text.is_empty() {
return Ok(vec![0.0f32; EMBEDDING_DIMS]);
}
let input = self.prefix_input(role, text);
let token_count = self.count_tokens(&input)?;
if token_count > MAX_EMBEDDING_TOKENS {
return Err(Error::ContentTooLong {
token_count,
max_tokens: MAX_EMBEDDING_TOKENS,
});
}
inference::encode_and_infer(self, &input)
}
fn count_tokens(&self, input: &str) -> Result<usize, Error> {
let encoding = self
.count_tokenizer
.encode(input, true)
.map_err(|e| with_prefix("count tokenizer:")(Error::from(e)))?;
Ok(encoding.get_ids().len())
}
}