Skip to main content

model2vec_rs/
model.rs

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/// Static embedding model for Model2Vec.
17///
18/// Clones share immutable model state instead of copying the tokenizer and embeddings.
19#[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    /// Load a Model2Vec model directly from in-memory bytes.
157    ///
158    /// This path is useful for runtimes that fetch model assets as bytes
159    /// rather than reading them from a local filesystem.
160    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        // Read normalize default from config.json
174        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        // Load the safetensors
179        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    /// Load a Model2Vec model from a local folder or the HuggingFace Hub.
223    ///
224    /// # Arguments
225    /// * `repo_or_path` - HuggingFace repo ID or local path to the model folder.
226    /// * `token` - Optional HuggingFace token for authenticated downloads.
227    /// * `normalize` - Optional flag to normalize embeddings (default from the resolved config file).
228    /// * `subfolder` - Optional subfolder within the repo or path to look for model files.
229    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    /// Construct from owned data.
243    ///
244    /// # Arguments
245    /// * `tokenizer` - Pre-deserialized tokenizer
246    /// * `embeddings` - Owned f32 embedding data
247    /// * `rows` - Number of vocabulary entries
248    /// * `cols` - Embedding dimension
249    /// * `normalize` - Whether to L2-normalize output embeddings
250    /// * `weights` - Optional per-token weights for quantized models
251    /// * `token_mapping` - Optional token ID mapping for quantized models
252    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    /// Construct from static slices (zero-copy for embedded binary data).
273    ///
274    /// # Arguments
275    /// * `tokenizer` - Pre-deserialized tokenizer
276    /// * `embeddings` - Static f32 embedding data (borrowed, no copy)
277    /// * `rows` - Number of vocabulary entries
278    /// * `cols` - Embedding dimension
279    /// * `normalize` - Whether to L2-normalize output embeddings
280    /// * `weights` - Optional static per-token weights for quantized models
281    /// * `token_mapping` - Optional static token ID mapping for quantized models
282    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    /// Compute median token length and unk_token_id from tokenizer.
342    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            // `tokenizers` keeps Unigram's `unk_id` private, so read it from the serialized model.
358            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    /// Char-level truncation to max_tokens * median_token_length
369    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    /// Encode texts into embeddings.
376    ///
377    /// # Arguments
378    /// * `sentences` - the list of sentences to encode.
379    /// * `max_length` - max tokens per text.
380    /// * `batch_size` - number of texts per batch.
381    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    /// Default encode: `max_length=512`, `batch_size=1024`
417    pub fn encode(&self, sentences: &[String]) -> Vec<Vec<f32>> {
418        self.encode_with_args(sentences, Some(512), 1024)
419    }
420
421    /// Encode a single sentence into a vector.
422    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    /// Mean-pool a token-ID list into a single vector.
430    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}