use std::path::PathBuf;
use anyhow::Context as _;
use candle_core::quantized::gguf_file;
use candle_core::{Device, Tensor};
use candle_nn::VarBuilder;
use candle_transformers::generation::LogitsProcessor;
use candle_transformers::models::bert::{BertModel, Config, DTYPE};
use candle_transformers::models::quantized_qwen2::ModelWeights as Qwen2;
use tokenizers::{PaddingParams, PaddingStrategy, Tokenizer};
pub(crate) const EMBEDDER_NAME: &str = "all-MiniLM-L6-v2";
pub(crate) const EMBEDDER_REPO: &str = "sentence-transformers/all-MiniLM-L6-v2";
pub(crate) const EMBEDDER_FILES: [&str; 3] = ["config.json", "tokenizer.json", "model.safetensors"];
pub(crate) const TEXTGEN_NAME: &str = "qwen2-0_5b-instruct";
pub(crate) const TEXTGEN_WEIGHTS_REPO: &str = "Qwen/Qwen2-0.5B-Instruct-GGUF";
pub(crate) const TEXTGEN_WEIGHTS_FILE: &str = "qwen2-0_5b-instruct-q4_0.gguf";
pub(crate) const TEXTGEN_TOKENIZER_REPO: &str = "Qwen/Qwen2-0.5B-Instruct";
pub(crate) const TEXTGEN_TOKENIZER_FILE: &str = "tokenizer.json";
const TEXTGEN_EOS: &str = "<|im_end|>";
pub struct Embedder {
model: BertModel,
tokenizer: Tokenizer,
device: Device,
}
impl Embedder {
pub fn load() -> anyhow::Result<Self> {
let dir = model_cache_dir(EMBEDDER_NAME);
ensure_files(EMBEDDER_REPO, &EMBEDDER_FILES, &dir)?;
Self::from_cache(&dir)
}
fn from_cache(dir: &std::path::Path) -> anyhow::Result<Self> {
let config_path = dir.join("config.json");
let tokenizer_path = dir.join("tokenizer.json");
let weights_path = dir.join("model.safetensors");
let device = Device::Cpu;
let config: Config = serde_json::from_str(&std::fs::read_to_string(config_path)?)?;
let tokenizer = Tokenizer::from_file(tokenizer_path).map_err(anyhow::Error::msg)?;
let vb = unsafe { VarBuilder::from_mmaped_safetensors(&[weights_path], DTYPE, &device)? };
let model = BertModel::load(vb, &config)?;
Ok(Self {
model,
tokenizer,
device,
})
}
pub fn embed(&self, texts: &[String]) -> anyhow::Result<Vec<Vec<f32>>> {
if texts.is_empty() {
return Ok(Vec::new());
}
let mut tokenizer = self.tokenizer.clone();
tokenizer.with_padding(Some(PaddingParams {
strategy: PaddingStrategy::BatchLongest,
..PaddingParams::default()
}));
let encodings = tokenizer
.encode_batch(texts.to_vec(), true)
.map_err(anyhow::Error::msg)?;
let mut ids = Vec::with_capacity(encodings.len());
let mut masks = Vec::with_capacity(encodings.len());
for encoding in &encodings {
ids.push(Tensor::new(encoding.get_ids(), &self.device)?);
masks.push(Tensor::new(encoding.get_attention_mask(), &self.device)?);
}
let token_ids = Tensor::stack(&ids, 0)?;
let token_type_ids = token_ids.zeros_like()?;
let attention_mask = Tensor::stack(&masks, 0)?;
let hidden = self
.model
.forward(&token_ids, &token_type_ids, Some(&attention_mask))?;
let mask = attention_mask.to_dtype(DTYPE)?.unsqueeze(2)?;
let summed = hidden.broadcast_mul(&mask)?.sum(1)?;
let counts = mask.sum(1)?;
let pooled = summed.broadcast_div(&counts)?;
let norms = pooled.sqr()?.sum_keepdim(1)?.sqrt()?;
let normalized = pooled.broadcast_div(&norms)?;
let rows = normalized.to_vec2::<f32>()?;
Ok(rows)
}
}
pub struct TextGen {
model: Qwen2,
tokenizer: Tokenizer,
device: Device,
eos: u32,
}
impl TextGen {
pub fn load() -> anyhow::Result<Self> {
let dir = model_cache_dir(TEXTGEN_NAME);
ensure_files(TEXTGEN_WEIGHTS_REPO, &[TEXTGEN_WEIGHTS_FILE], &dir)?;
ensure_files(TEXTGEN_TOKENIZER_REPO, &[TEXTGEN_TOKENIZER_FILE], &dir)?;
Self::from_cache(&dir)
}
fn from_cache(dir: &std::path::Path) -> anyhow::Result<Self> {
let weights_path = dir.join(TEXTGEN_WEIGHTS_FILE);
let tokenizer_path = dir.join(TEXTGEN_TOKENIZER_FILE);
let device = Device::Cpu;
let mut file = std::fs::File::open(&weights_path)
.with_context(|| format!("open {}", weights_path.display()))?;
let content =
gguf_file::Content::read(&mut file).map_err(|e| e.with_path(&weights_path))?;
let model = Qwen2::from_gguf(content, &mut file, &device)?;
let tokenizer = Tokenizer::from_file(tokenizer_path).map_err(anyhow::Error::msg)?;
let eos = tokenizer
.token_to_id(TEXTGEN_EOS)
.with_context(|| format!("tokenizer has no {TEXTGEN_EOS} token"))?;
Ok(Self {
model,
tokenizer,
device,
eos,
})
}
pub fn complete(
&mut self,
system: &str,
user: &str,
max_tokens: usize,
) -> anyhow::Result<String> {
let prompt = format!(
"<|im_start|>system\n{system}<|im_end|>\n<|im_start|>user\n{user}<|im_end|>\n<|im_start|>assistant\n"
);
let encoding = self
.tokenizer
.encode(prompt, true)
.map_err(anyhow::Error::msg)?;
self.model.clear_kv_cache();
let mut sampler = LogitsProcessor::new(0, None, None);
let prompt_ids = encoding.get_ids().to_vec();
let mut generated: Vec<u32> = Vec::new();
let mut index_pos = 0usize;
let input = Tensor::new(prompt_ids.as_slice(), &self.device)?.unsqueeze(0)?;
let logits = self.model.forward(&input, index_pos)?;
let logits = logits.squeeze(0)?;
let mut next = sampler.sample(&logits)?;
index_pos += prompt_ids.len();
for _ in 0..max_tokens {
if next == self.eos {
break;
}
generated.push(next);
let input = Tensor::new(&[next], &self.device)?.unsqueeze(0)?;
let logits = self.model.forward(&input, index_pos)?;
let logits = logits.squeeze(0)?;
next = sampler.sample(&logits)?;
index_pos += 1;
}
self.tokenizer
.decode(&generated, true)
.map_err(anyhow::Error::msg)
}
}
fn ensure_files(repo_id: &str, files: &[&str], dest: &std::path::Path) -> anyhow::Result<()> {
if files.iter().all(|file| dest.join(file).is_file()) {
return Ok(());
}
pull_files(repo_id, files, dest)
}
pub(crate) fn pull_files(
repo_id: &str,
files: &[&str],
dest: &std::path::Path,
) -> anyhow::Result<()> {
std::fs::create_dir_all(dest).with_context(|| format!("create {}", dest.display()))?;
let api = hf_hub::api::sync::Api::new()?;
let repo = api.model(repo_id.to_string());
for file in files {
let cached = repo
.get(file)
.with_context(|| format!("download {repo_id}/{file}"))?;
let target = dest.join(file);
std::fs::copy(&cached, &target).with_context(|| format!("write {}", target.display()))?;
}
Ok(())
}
pub(crate) fn model_cache_dir(name: &str) -> PathBuf {
dirs::cache_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("goosedump")
.join("models")
.join(name)
}