use anyhow::{Context, Result};
use fastembed::{
EmbeddingModel, InitOptionsUserDefined, Pooling, TextEmbedding, TextInitOptions,
TokenizerFiles, UserDefinedEmbeddingModel,
};
use std::path::{Path, PathBuf};
use std::str::FromStr;
const GRANITE_REPO: &str = "onnx-community/granite-embedding-small-english-r2-ONNX";
const GRANITE_ONNX: &str = "onnx/model_quantized.onnx";
const GRANITE_WEIGHTS: &str = "onnx/model_quantized.onnx_data";
pub const GRANITE_NAME: &str = "granite-embedding-small-english-r2-int8";
const GRANITE_DIM: usize = 384;
pub const THREADS_ENV: &str = "SEMLITH_EMBED_THREADS";
#[derive(Debug, Clone, PartialEq, Default)]
pub enum Model {
Builtin(EmbeddingModel),
#[default]
Granite,
}
impl std::fmt::Display for Model {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Model::Granite => f.write_str(GRANITE_NAME),
Model::Builtin(m) => write!(f, "{m}"),
}
}
}
impl FromStr for Model {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if s == GRANITE_NAME {
return Ok(Model::Granite);
}
EmbeddingModel::from_str(s).map(Model::Builtin)
}
}
impl Model {
pub fn dim(&self) -> Result<usize> {
match self {
Model::Granite => Ok(GRANITE_DIM),
Model::Builtin(m) => Ok(TextEmbedding::get_model_info(m)
.map_err(|e| anyhow::anyhow!("{e}"))?
.dim),
}
}
pub fn query_text(&self, query: &str) -> String {
match self {
Model::Builtin(m) => {
let name = m.to_string();
if name.starts_with("BGE") && name.contains("EN") {
format!("Represent this sentence for searching relevant passages: {query}")
} else {
query.to_string()
}
}
Model::Granite => query.to_string(),
}
}
pub fn load(
&self,
cache_dir: PathBuf,
max_length: usize,
quiet: bool,
) -> Result<TextEmbedding> {
match self {
Model::Builtin(m) => {
let opts = TextInitOptions::new(m.clone())
.with_show_download_progress(!quiet)
.with_max_length(max_length)
.with_intra_threads(embed_threads())
.with_cache_dir(cache_dir);
TextEmbedding::try_new(opts).map_err(|e| anyhow::anyhow!("{e}"))
}
Model::Granite => load_granite(cache_dir, max_length, quiet),
}
}
}
fn load_granite(cache_dir: PathBuf, max_length: usize, quiet: bool) -> Result<TextEmbedding> {
let repo = hf_hub::api::sync::ApiBuilder::new()
.with_cache_dir(cache_dir)
.with_progress(!quiet)
.build()
.context("building the Hugging Face client")?
.model(GRANITE_REPO.to_string());
let fetch = |name: &str| -> Result<Vec<u8>> {
let path = repo
.get(name)
.with_context(|| format!("fetching {name} from {GRANITE_REPO}"))?;
std::fs::read(&path).with_context(|| format!("reading {}", path.display()))
};
let tokenizer_files = TokenizerFiles {
tokenizer_file: fetch("tokenizer.json")?,
config_file: fetch("config.json")?,
special_tokens_map_file: fetch("special_tokens_map.json")?,
tokenizer_config_file: fetch("tokenizer_config.json")?,
};
let model = UserDefinedEmbeddingModel::new(fetch(GRANITE_ONNX)?, tokenizer_files)
.with_pooling(Pooling::Cls)
.with_external_initializer(
Path::new(GRANITE_WEIGHTS)
.file_name()
.expect("weights constant has a file name")
.to_string_lossy()
.into_owned(),
fetch(GRANITE_WEIGHTS)?,
);
let opts = InitOptionsUserDefined::new()
.with_max_length(max_length)
.with_intra_threads(embed_threads());
TextEmbedding::try_new_from_user_defined(model, opts)
.map_err(|e| anyhow::anyhow!("loading {GRANITE_NAME}: {e}"))
}
pub fn embed_threads() -> usize {
if let Ok(raw) = std::env::var(THREADS_ENV)
&& let Ok(n) = raw.parse::<usize>()
&& n > 0
{
return n;
}
performance_cores().unwrap_or_else(|| {
std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(1)
})
}
#[cfg(target_os = "macos")]
fn performance_cores() -> Option<usize> {
let mut out: i32 = 0;
let mut len = std::mem::size_of::<i32>();
let name = c"hw.perflevel0.logicalcpu";
let rc = unsafe {
libc::sysctlbyname(
name.as_ptr(),
&mut out as *mut i32 as *mut libc::c_void,
&mut len,
std::ptr::null_mut(),
0,
)
};
(rc == 0 && out > 0).then_some(out as usize)
}
#[cfg(not(target_os = "macos"))]
fn performance_cores() -> Option<usize> {
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn granite_round_trips_through_its_stored_name() {
let parsed: Model = GRANITE_NAME.parse().unwrap();
assert_eq!(parsed, Model::Granite);
assert_eq!(parsed.to_string(), GRANITE_NAME);
assert_eq!(parsed.dim().unwrap(), 384);
}
#[test]
fn stores_written_by_0_1_0_still_parse() {
let parsed: Model = "BGESmallENV15".parse().unwrap();
assert_eq!(parsed, Model::Builtin(EmbeddingModel::BGESmallENV15));
assert_eq!(parsed.to_string(), "BGESmallENV15");
assert_eq!(parsed.dim().unwrap(), 384);
}
#[test]
fn nonsense_model_names_are_rejected() {
assert!("not-a-model".parse::<Model>().is_err());
}
#[test]
fn only_bge_english_gets_the_instruction_prefix() {
let bge = Model::Builtin(EmbeddingModel::BGESmallENV15);
assert!(bge.query_text("hi").starts_with("Represent this sentence"));
assert!(bge.query_text("hi").ends_with("hi"));
assert_eq!(Model::Granite.query_text("hi"), "hi");
}
#[test]
fn thread_count_honours_the_override() {
unsafe { std::env::set_var(THREADS_ENV, "3") };
assert_eq!(embed_threads(), 3);
unsafe { std::env::remove_var(THREADS_ENV) };
assert!(embed_threads() >= 1);
}
}