1use anyhow::{Context, Result, anyhow};
2use half::f16;
3#[cfg(all(feature = "hf-hub", not(feature = "local-only")))]
4use hf_hub::api::sync::{Api, ApiBuilder, ApiRepo};
5use ndarray::{Array2, ArrayView2, CowArray, Ix2};
6use safetensors::{SafeTensors, tensor::Dtype};
7use serde_json::Value;
8use std::borrow::Cow;
9use std::{
10 fs,
11 path::{Path, PathBuf},
12 sync::Arc,
13};
14use tokenizers::{Tokenizer, models::ModelWrapper};
15
16#[derive(Debug, Clone)]
20pub struct StaticModel {
21 inner: Arc<StaticModelInner>,
22}
23
24#[derive(Debug)]
25struct StaticModelInner {
26 tokenizer: Tokenizer,
27 embeddings: CowArray<'static, f32, Ix2>,
28 weights: Option<Cow<'static, [f32]>>,
29 token_mapping: Option<Cow<'static, [usize]>>,
30 normalize: bool,
31 median_token_length: usize,
32 unk_token_id: Option<usize>,
33}
34
35#[derive(Debug, Clone)]
36struct ModelFiles {
37 tokenizer: PathBuf,
38 model: PathBuf,
39 config: PathBuf,
40}
41
42fn layout_candidates<T: Copy>(base: T, subfolder: T, parent: T) -> [(T, T, &'static str); 4] {
43 [
44 (base, base, "config.json"),
45 (base, base, "config_sentence_transformers.json"),
46 (base, subfolder, "config_sentence_transformers.json"),
47 (parent, base, "config_sentence_transformers.json"),
48 ]
49}
50
51fn decode_f32(raw: &[u8]) -> Vec<f32> {
52 raw.as_chunks::<4>().0.iter().map(|&b| f32::from_le_bytes(b)).collect()
53}
54
55fn decode_f16(raw: &[u8]) -> Vec<f32> {
56 raw.as_chunks::<2>()
57 .0
58 .iter()
59 .map(|&b| f16::from_le_bytes(b).to_f32())
60 .collect()
61}
62
63fn match_local_layout(config_base: &Path, model_base: &Path, config_file: &str) -> Option<ModelFiles> {
64 let config = config_base.join(config_file);
65 let tokenizer = model_base.join("tokenizer.json");
66 let model = model_base.join("model.safetensors");
67 (config.exists() && tokenizer.exists() && model.exists()).then_some(ModelFiles {
68 tokenizer,
69 model,
70 config,
71 })
72}
73
74fn decode_token_mapping(dtype: Dtype, raw: &[u8]) -> Result<Vec<usize>> {
75 let mapping = match dtype {
76 Dtype::I64 => raw
77 .as_chunks::<8>()
78 .0
79 .iter()
80 .map(|&b| i64::from_le_bytes(b) as usize)
81 .collect(),
82 Dtype::I32 => raw
83 .as_chunks::<4>()
84 .0
85 .iter()
86 .map(|&b| i32::from_le_bytes(b) as usize)
87 .collect(),
88 other => return Err(anyhow!("unsupported mapping dtype: {:?}", other)),
89 };
90
91 Ok(mapping)
92}
93
94#[cfg(all(feature = "hf-hub", not(feature = "local-only")))]
95fn is_not_found(e: &hf_hub::api::sync::ApiError) -> bool {
96 use hf_hub::api::sync::ApiError;
97
98 matches!(e, ApiError::RequestError(e) if matches!(e.as_ref(), ureq::Error::Status(404, _)))
99}
100
101#[cfg(all(feature = "hf-hub", not(feature = "local-only")))]
102fn match_hub_layout(
103 repo: &ApiRepo,
104 config_prefix: &str,
105 model_prefix: &str,
106 config_file: &str,
107) -> Result<Option<ModelFiles>> {
108 let fetch = |path: String| -> Result<Option<PathBuf>> {
109 match repo.get(&path) {
110 Ok(p) => Ok(Some(p)),
111 Err(e) if is_not_found(&e) => Ok(None),
112 Err(e) => Err(e.into()),
113 }
114 };
115 let Some(config) = fetch(format!("{config_prefix}{config_file}"))? else {
116 return Ok(None);
117 };
118 let Some(tokenizer) = fetch(format!("{model_prefix}tokenizer.json"))? else {
119 return Ok(None);
120 };
121 let Some(model) = fetch(format!("{model_prefix}model.safetensors"))? else {
122 return Ok(None);
123 };
124 Ok(Some(ModelFiles {
125 tokenizer,
126 model,
127 config,
128 }))
129}
130
131fn resolve_local_model_files(folder: &Path) -> Option<ModelFiles> {
132 let subfolder = folder.join("0_StaticEmbedding");
133 layout_candidates(Some(folder), Some(subfolder.as_path()), folder.parent())
134 .into_iter()
135 .find_map(|(config_base, model_base, config_file)| match_local_layout(config_base?, model_base?, config_file))
136}
137
138#[cfg(all(feature = "hf-hub", not(feature = "local-only")))]
139fn resolve_hub_model_files(repo: &ApiRepo, prefix: &str) -> Result<ModelFiles> {
140 let sub_prefix = format!("{prefix}0_StaticEmbedding/");
141 let trimmed = prefix.trim_end_matches('/');
142 let parent = match Path::new(trimmed).parent() {
143 Some(path) if !path.as_os_str().is_empty() => format!("{}/", path.display()),
144 _ => String::new(),
145 };
146
147 for (config_prefix, model_prefix, config_file) in layout_candidates(prefix, sub_prefix.as_str(), parent.as_str()) {
148 if let Some(files) = match_hub_layout(repo, config_prefix, model_prefix, config_file)? {
149 return Ok(files);
150 }
151 }
152 Err(anyhow!("no valid model layout found in '{prefix}'"))
153}
154
155impl StaticModel {
156 pub fn from_bytes<T, M, C>(
161 tokenizer_bytes: T,
162 model_bytes: M,
163 config_bytes: C,
164 normalize: Option<bool>,
165 ) -> Result<Self>
166 where
167 T: AsRef<[u8]>,
168 M: AsRef<[u8]>,
169 C: AsRef<[u8]>,
170 {
171 let tokenizer = Tokenizer::from_bytes(tokenizer_bytes).map_err(|e| anyhow!("failed to load tokenizer: {e}"))?;
172
173 let cfg: Value = serde_json::from_slice(config_bytes.as_ref()).context("failed to parse config.json")?;
175 let cfg_norm = cfg.get("normalize").and_then(Value::as_bool).unwrap_or(true);
176 let normalize = normalize.unwrap_or(cfg_norm);
177
178 let safet = SafeTensors::deserialize(model_bytes.as_ref()).context("failed to parse safetensors")?;
180 let tensor = safet
181 .tensor("embeddings")
182 .or_else(|_| safet.tensor("0"))
183 .or_else(|_| safet.tensor("embedding.weight"))
184 .context("embeddings tensor not found")?;
185
186 let [rows, cols]: [usize; 2] = tensor.shape().try_into().context("embedding tensor is not 2-D")?;
187 let raw = tensor.data();
188 let floats: Vec<f32> = match tensor.dtype() {
189 Dtype::F32 => decode_f32(raw),
190 Dtype::F16 => decode_f16(raw),
191 Dtype::I8 => raw.iter().map(|&b| f32::from(b as i8)).collect(),
192 other => return Err(anyhow!("unsupported tensor dtype: {other:?}")),
193 };
194
195 let weights = match safet.tensor("weights") {
196 Ok(t) => {
197 let raw = t.data();
198 let v: Vec<f32> = match t.dtype() {
199 Dtype::F64 => raw
200 .as_chunks::<8>()
201 .0
202 .iter()
203 .map(|&b| f64::from_le_bytes(b) as f32)
204 .collect(),
205 Dtype::F32 => decode_f32(raw),
206 Dtype::F16 => decode_f16(raw),
207 other => return Err(anyhow!("unsupported weights dtype: {:?}", other)),
208 };
209 Some(v)
210 }
211 Err(_) => None,
212 };
213
214 let token_mapping = match safet.tensor("mapping") {
215 Ok(t) => Some(decode_token_mapping(t.dtype(), t.data())?),
216 Err(_) => None,
217 };
218
219 Self::from_owned(tokenizer, floats, rows, cols, normalize, weights, token_mapping)
220 }
221
222 pub fn from_pretrained<P: AsRef<Path>>(
230 repo_or_path: P,
231 token: Option<&str>,
232 normalize: Option<bool>,
233 subfolder: Option<&str>,
234 ) -> Result<Self> {
235 let files = resolve_model_files(repo_or_path, token, subfolder)?;
236 let tokenizer_bytes = fs::read(&files.tokenizer).context("failed to read tokenizer.json")?;
237 let model_bytes = fs::read(&files.model).context("failed to read model.safetensors")?;
238 let config_bytes = fs::read(&files.config).context("failed to read config.json")?;
239 Self::from_bytes(tokenizer_bytes, model_bytes, config_bytes, normalize)
240 }
241
242 pub fn from_owned(
253 tokenizer: Tokenizer,
254 embeddings: Vec<f32>,
255 rows: usize,
256 cols: usize,
257 normalize: bool,
258 weights: Option<Vec<f32>>,
259 token_mapping: Option<Vec<usize>>,
260 ) -> Result<Self> {
261 Self::from_storage(
262 tokenizer,
263 Cow::Owned(embeddings),
264 rows,
265 cols,
266 normalize,
267 weights.map(Cow::Owned),
268 token_mapping.map(Cow::Owned),
269 )
270 }
271
272 pub fn from_borrowed(
283 tokenizer: Tokenizer,
284 embeddings: &'static [f32],
285 rows: usize,
286 cols: usize,
287 normalize: bool,
288 weights: Option<&'static [f32]>,
289 token_mapping: Option<&'static [usize]>,
290 ) -> Result<Self> {
291 Self::from_storage(
292 tokenizer,
293 Cow::Borrowed(embeddings),
294 rows,
295 cols,
296 normalize,
297 weights.map(Cow::Borrowed),
298 token_mapping.map(Cow::Borrowed),
299 )
300 }
301
302 fn from_storage(
303 tokenizer: Tokenizer,
304 embeddings: Cow<'static, [f32]>,
305 rows: usize,
306 cols: usize,
307 normalize: bool,
308 weights: Option<Cow<'static, [f32]>>,
309 token_mapping: Option<Cow<'static, [usize]>>,
310 ) -> Result<Self> {
311 if embeddings.len() != rows * cols {
312 return Err(anyhow!(
313 "embeddings length {} != rows {} * cols {}",
314 embeddings.len(),
315 rows,
316 cols
317 ));
318 }
319 let (median_token_length, unk_token_id) = Self::compute_metadata(&tokenizer)?;
320 let embeddings = match embeddings {
321 Cow::Owned(data) => {
322 CowArray::from(Array2::from_shape_vec((rows, cols), data).context("failed to build embeddings array")?)
323 }
324 Cow::Borrowed(data) => {
325 CowArray::from(ArrayView2::from_shape((rows, cols), data).context("failed to build embeddings view")?)
326 }
327 };
328 Ok(Self {
329 inner: Arc::new(StaticModelInner {
330 tokenizer,
331 embeddings,
332 weights,
333 token_mapping,
334 normalize,
335 median_token_length,
336 unk_token_id,
337 }),
338 })
339 }
340
341 fn compute_metadata(tokenizer: &Tokenizer) -> Result<(usize, Option<usize>)> {
343 let mut lens: Vec<usize> = tokenizer.get_vocab(false).keys().map(|tk| tk.len()).collect();
344 lens.sort_unstable();
345 let median_token_length = lens.get(lens.len() / 2).copied().unwrap_or(1);
346
347 let lookup = |tok: &str| {
348 tokenizer
349 .token_to_id(tok)
350 .map(|id| id as usize)
351 .ok_or_else(|| anyhow!("unk_token '{tok}' not found in vocabulary"))
352 };
353 let unk_token_id = match tokenizer.get_model() {
354 ModelWrapper::BPE(model) => model.unk_token.as_deref().map(lookup).transpose()?,
355 ModelWrapper::WordPiece(model) => Some(lookup(&model.unk_token)?),
356 ModelWrapper::WordLevel(model) => Some(lookup(&model.unk_token)?),
357 ModelWrapper::Unigram(model) => serde_json::to_value(model)
359 .context("failed to serialize unigram model")?
360 .get("unk_id")
361 .and_then(Value::as_u64)
362 .map(|id| id as usize),
363 };
364
365 Ok((median_token_length, unk_token_id))
366 }
367
368 fn truncate_str(s: &str, max_tokens: usize, median_len: usize) -> &str {
370 s.char_indices()
371 .nth(max_tokens.saturating_mul(median_len))
372 .map_or(s, |(byte_idx, _)| &s[..byte_idx])
373 }
374
375 pub fn encode_with_args(
382 &self,
383 sentences: &[String],
384 max_length: Option<usize>,
385 batch_size: usize,
386 ) -> Vec<Vec<f32>> {
387 let model = &self.inner;
388 let mut embeddings = Vec::with_capacity(sentences.len());
389 for batch in sentences.chunks(batch_size) {
390 let truncated: Vec<&str> = batch
391 .iter()
392 .map(|text| {
393 max_length
394 .map(|max_tok| Self::truncate_str(text, max_tok, model.median_token_length))
395 .unwrap_or(text.as_str())
396 })
397 .collect();
398 let encodings = model
399 .tokenizer
400 .encode_batch_fast::<String>(truncated.into_iter().map(Into::into).collect(), false)
401 .expect("tokenization failed");
402 for encoding in encodings {
403 let mut token_ids = encoding.get_ids().to_vec();
404 if let Some(unk_id) = model.unk_token_id {
405 token_ids.retain(|&id| id as usize != unk_id);
406 }
407 if let Some(max_tok) = max_length {
408 token_ids.truncate(max_tok);
409 }
410 embeddings.push(self.pool_ids(token_ids));
411 }
412 }
413 embeddings
414 }
415
416 pub fn encode(&self, sentences: &[String]) -> Vec<Vec<f32>> {
418 self.encode_with_args(sentences, Some(512), 1024)
419 }
420
421 pub fn encode_single(&self, sentence: &str) -> Vec<f32> {
423 self.encode(&[sentence.to_string()])
424 .into_iter()
425 .next()
426 .unwrap_or_default()
427 }
428
429 fn pool_ids(&self, ids: Vec<u32>) -> Vec<f32> {
431 let model = &self.inner;
432 let dim = model.embeddings.ncols();
433 let mut sum = vec![0.0_f32; dim];
434 let mut cnt = 0usize;
435 for &id in &ids {
436 let tok = id as usize;
437 let row_idx = model
438 .token_mapping
439 .as_ref()
440 .and_then(|m| m.get(tok))
441 .copied()
442 .unwrap_or(tok);
443 let scale = model.weights.as_ref().and_then(|w| w.get(tok)).copied().unwrap_or(1.0);
444 let row = model.embeddings.row(row_idx);
445 for (s, &v) in sum.iter_mut().zip(row.iter()) {
446 *s += v * scale;
447 }
448 cnt += 1;
449 }
450 let denom = cnt.max(1) as f32;
451 for x in &mut sum {
452 *x /= denom;
453 }
454 if model.normalize {
455 let norm = sum.iter().map(|&v| v * v).sum::<f32>().sqrt().max(1e-12);
456 for x in &mut sum {
457 *x /= norm;
458 }
459 }
460 sum
461 }
462}
463
464fn resolve_model_files<P: AsRef<Path>>(
465 repo_or_path: P,
466 token: Option<&str>,
467 subfolder: Option<&str>,
468) -> Result<ModelFiles> {
469 #[cfg(any(not(feature = "hf-hub"), feature = "local-only"))]
470 let _ = token;
471
472 let base = repo_or_path.as_ref();
473 if base.exists() {
474 let folder = subfolder.map(|s| base.join(s)).unwrap_or_else(|| base.to_path_buf());
475 return resolve_local_model_files(&folder).ok_or_else(|| {
476 anyhow!(
477 "no valid model layout found in {folder:?}. \
478 Tried: model2vec (config.json), sentence-transformers \
479 (config_sentence_transformers.json), and 0_StaticEmbedding subfolder."
480 )
481 });
482 }
483
484 #[cfg(all(feature = "hf-hub", not(feature = "local-only")))]
485 {
486 download_model_files(repo_or_path.as_ref().to_string_lossy().as_ref(), token, subfolder)
487 }
488 #[cfg(feature = "local-only")]
489 {
490 Err(anyhow!(
491 "remote model downloads are disabled by the `local-only` feature; pass a local model directory instead"
492 ))
493 }
494 #[cfg(all(not(feature = "hf-hub"), not(feature = "local-only")))]
495 {
496 Err(anyhow!(
497 "remote model downloads require the `hf-hub` feature; pass a local model directory instead"
498 ))
499 }
500}
501
502#[cfg(all(feature = "hf-hub", not(feature = "local-only")))]
503fn download_model_files(repo_id: &str, token: Option<&str>, subfolder: Option<&str>) -> Result<ModelFiles> {
504 let api = match token {
505 Some(token) => ApiBuilder::new().with_token(Some(token.to_owned())).build(),
506 None => Api::new(),
507 }
508 .context("hf-hub API init failed")?;
509 let repo = api.model(repo_id.to_owned());
510 let prefix = subfolder.map(|s| format!("{s}/")).unwrap_or_default();
511 resolve_hub_model_files(&repo, &prefix).with_context(|| format!("could not load '{repo_id}' from HuggingFace Hub"))
512}
513
514#[cfg(test)]
515mod tests {
516 use super::decode_token_mapping;
517 use safetensors::tensor::Dtype;
518
519 #[test]
520 fn decode_token_mapping_supports_i32_and_i64() {
521 let i32_raw = [1i32, 2, 3]
522 .into_iter()
523 .flat_map(|value| value.to_le_bytes())
524 .collect::<Vec<_>>();
525 let i64_raw = [4i64, 5, 6]
526 .into_iter()
527 .flat_map(|value| value.to_le_bytes())
528 .collect::<Vec<_>>();
529
530 assert_eq!(decode_token_mapping(Dtype::I32, &i32_raw).unwrap(), vec![1, 2, 3]);
531 assert_eq!(decode_token_mapping(Dtype::I64, &i64_raw).unwrap(), vec![4, 5, 6]);
532 }
533
534 #[test]
535 fn decode_token_mapping_rejects_unsupported_dtype() {
536 let err = decode_token_mapping(Dtype::F32, &[0, 0, 0, 0]).unwrap_err();
537 assert!(err.to_string().contains("unsupported mapping dtype"));
538 }
539}