Skip to main content

model2vec_rs/
model.rs

1use anyhow::{Context, Result, anyhow};
2use half::{f16, slice::HalfFloatSliceExt};
3#[cfg(all(feature = "hf-hub", not(feature = "local-only")))]
4use hf_hub::api::sync::{Api, ApiBuilder, ApiRepo};
5use safetensors::{SafeTensors, tensor::Dtype};
6use serde_json::Value;
7use std::borrow::Cow;
8use std::cell::RefCell;
9use std::{
10    fs,
11    path::{Path, PathBuf},
12    sync::Arc,
13};
14use tokenizers::{
15    Tokenizer,
16    models::ModelWrapper,
17    parallelism::{MaybeParallelRefIterator, MaybeParallelRefMutIterator},
18};
19
20use crate::fast_unigram::FastUnigram;
21use crate::fast_wordpiece::FastWordPiece;
22
23thread_local! {
24    static TOKEN_IDS: RefCell<Vec<u32>> = const { RefCell::new(Vec::new()) };
25}
26
27/// Static embedding model for Model2Vec.
28///
29/// Clones share immutable model state instead of copying the tokenizer and embeddings.
30#[derive(Debug, Clone)]
31pub struct StaticModel {
32    inner: Arc<StaticModelInner>,
33}
34
35#[derive(Debug)]
36struct StaticModelInner {
37    tokenizer: LoadedTokenizer,
38    embeddings: Embeddings,
39    dim: usize,
40    /// Per-row scale applied after the token weight; set by `quantize_int8`.
41    row_scales: Option<Vec<f32>>,
42    weights: Option<Cow<'static, [f32]>>,
43    token_mapping: Option<Cow<'static, [usize]>>,
44    normalize: bool,
45}
46
47#[derive(Debug, Clone)]
48#[allow(clippy::large_enum_variant)]
49enum Tokenization {
50    /// Custom IDs-only WordPiece tokenizer, which needs no `Tokenizer`.
51    WordPiece(FastWordPiece),
52    /// Custom IDs-only Unigram model on top of the crate's pre-processing.
53    Unigram(Tokenizer, FastUnigram),
54    /// Pipelines neither custom tokenizer reproduces.
55    Crate(Tokenizer),
56}
57
58#[derive(Debug, Clone)]
59struct LoadedTokenizer {
60    tokenization: Tokenization,
61    median_token_length: usize,
62    unk_token_id: Option<usize>,
63}
64
65impl LoadedTokenizer {
66    fn from_tokenizer(mut tokenizer: Tokenizer) -> Result<Self> {
67        tokenizer
68            .with_truncation(None)
69            .map_err(|e| anyhow!("failed to disable truncation: {e}"))?;
70        tokenizer.with_padding(None);
71        let (median_token_length, unk_token_id) = compute_metadata(&tokenizer)?;
72        let tokenization = if let Some(ft) = FastWordPiece::from_tokenizer(&tokenizer) {
73            Tokenization::WordPiece(ft)
74        } else if let Some(fu) = FastUnigram::from_tokenizer(&tokenizer, unk_token_id) {
75            Tokenization::Unigram(tokenizer, fu)
76        } else {
77            Tokenization::Crate(tokenizer)
78        };
79        Ok(Self {
80            tokenization,
81            median_token_length,
82            unk_token_id,
83        })
84    }
85
86    /// Load from `tokenizer.json` bytes. WordPiece pipelines are read
87    /// directly from the JSON without building a [`Tokenizer`].
88    fn from_bytes(bytes: &[u8]) -> Result<Self> {
89        if let Some((ft, median_token_length)) = FastWordPiece::from_json(bytes) {
90            return Ok(Self {
91                unk_token_id: ft.unk_id().map(|id| id as usize),
92                tokenization: Tokenization::WordPiece(ft),
93                median_token_length,
94            });
95        }
96        let tokenizer = Tokenizer::from_bytes(bytes).map_err(|e| anyhow!("failed to load tokenizer: {e}"))?;
97        Self::from_tokenizer(tokenizer)
98    }
99}
100
101/// Median byte length of the vocabulary's tokens (1 for an empty vocabulary).
102pub(crate) fn median_token_length<'a>(tokens: impl Iterator<Item = &'a str>) -> usize {
103    let mut lens: Vec<usize> = tokens.map(str::len).collect();
104    lens.sort_unstable();
105    lens.get(lens.len() / 2).copied().unwrap_or(1)
106}
107
108/// Compute median token length and unk_token_id from tokenizer.
109fn compute_metadata(tokenizer: &Tokenizer) -> Result<(usize, Option<usize>)> {
110    let median_token_length = median_token_length(tokenizer.get_vocab(false).keys().map(String::as_str));
111
112    let lookup = |tok: &str| {
113        tokenizer
114            .token_to_id(tok)
115            .map(|id| id as usize)
116            .ok_or_else(|| anyhow!("unk_token '{tok}' not found in vocabulary"))
117    };
118    let unk_token_id = match tokenizer.get_model() {
119        ModelWrapper::BPE(model) => model.unk_token.as_deref().map(lookup).transpose()?,
120        ModelWrapper::WordPiece(model) => Some(lookup(&model.unk_token)?),
121        ModelWrapper::WordLevel(model) => Some(lookup(&model.unk_token)?),
122        // `tokenizers` keeps Unigram's `unk_id` private, so read it from the serialized model.
123        ModelWrapper::Unigram(model) => serde_json::to_value(model)
124            .context("failed to serialize unigram model")?
125            .get("unk_id")
126            .and_then(Value::as_u64)
127            .map(|id| id as usize),
128    };
129
130    Ok((median_token_length, unk_token_id))
131}
132
133/// Embedding table in its stored element type, row-major with `dim` columns.
134#[derive(Debug)]
135enum Embeddings {
136    F32(Cow<'static, [f32]>),
137    I8(Cow<'static, [i8]>),
138}
139
140impl Embeddings {
141    fn len(&self) -> usize {
142        match self {
143            Embeddings::F32(table) => table.len(),
144            Embeddings::I8(table) => table.len(),
145        }
146    }
147}
148
149#[derive(Debug, Clone)]
150struct ModelFiles {
151    tokenizer: PathBuf,
152    model: PathBuf,
153    config: PathBuf,
154}
155
156fn layout_candidates<T: Copy>(base: T, subfolder: T, parent: T) -> [(T, T, &'static str); 4] {
157    [
158        (base, base, "config.json"),
159        (base, base, "config_sentence_transformers.json"),
160        (base, subfolder, "config_sentence_transformers.json"),
161        (parent, base, "config_sentence_transformers.json"),
162    ]
163}
164
165fn decode_f32(raw: &[u8]) -> Vec<f32> {
166    raw.as_chunks::<4>().0.iter().map(|&b| f32::from_le_bytes(b)).collect()
167}
168
169fn decode_f16(raw: &[u8]) -> Vec<f32> {
170    let raw = raw.as_chunks::<2>().0;
171    let mut out = vec![0.0_f32; raw.len()];
172    let mut halves = [f16::ZERO; 1024];
173    for (src, dst) in raw.chunks(halves.len()).zip(out.chunks_mut(halves.len())) {
174        let halves = &mut halves[..src.len()];
175        for (h, &b) in halves.iter_mut().zip(src) {
176            *h = f16::from_le_bytes(b);
177        }
178        halves.convert_to_f32_slice(dst);
179    }
180    out
181}
182
183fn match_local_layout(config_base: &Path, model_base: &Path, config_file: &str) -> Option<ModelFiles> {
184    let config = config_base.join(config_file);
185    let tokenizer = model_base.join("tokenizer.json");
186    let model = model_base.join("model.safetensors");
187    (config.exists() && tokenizer.exists() && model.exists()).then_some(ModelFiles {
188        tokenizer,
189        model,
190        config,
191    })
192}
193
194fn decode_token_mapping(dtype: Dtype, raw: &[u8]) -> Result<Vec<usize>> {
195    let mapping = match dtype {
196        Dtype::I64 => raw
197            .as_chunks::<8>()
198            .0
199            .iter()
200            .map(|&b| i64::from_le_bytes(b) as usize)
201            .collect(),
202        Dtype::I32 => raw
203            .as_chunks::<4>()
204            .0
205            .iter()
206            .map(|&b| i32::from_le_bytes(b) as usize)
207            .collect(),
208        other => return Err(anyhow!("unsupported mapping dtype: {:?}", other)),
209    };
210
211    Ok(mapping)
212}
213
214#[cfg(all(feature = "hf-hub", not(feature = "local-only")))]
215fn is_not_found(e: &hf_hub::api::sync::ApiError) -> bool {
216    use hf_hub::api::sync::ApiError;
217
218    matches!(e, ApiError::RequestError(e) if matches!(e.as_ref(), ureq::Error::Status(404, _)))
219}
220
221#[cfg(all(feature = "hf-hub", not(feature = "local-only")))]
222fn match_hub_layout(
223    repo: &ApiRepo,
224    config_prefix: &str,
225    model_prefix: &str,
226    config_file: &str,
227) -> Result<Option<ModelFiles>> {
228    let fetch = |path: String| -> Result<Option<PathBuf>> {
229        match repo.get(&path) {
230            Ok(p) => Ok(Some(p)),
231            Err(e) if is_not_found(&e) => Ok(None),
232            Err(e) => Err(e.into()),
233        }
234    };
235    let Some(config) = fetch(format!("{config_prefix}{config_file}"))? else {
236        return Ok(None);
237    };
238    let Some(tokenizer) = fetch(format!("{model_prefix}tokenizer.json"))? else {
239        return Ok(None);
240    };
241    let Some(model) = fetch(format!("{model_prefix}model.safetensors"))? else {
242        return Ok(None);
243    };
244    Ok(Some(ModelFiles {
245        tokenizer,
246        model,
247        config,
248    }))
249}
250
251fn resolve_local_model_files(folder: &Path) -> Option<ModelFiles> {
252    let subfolder = folder.join("0_StaticEmbedding");
253    layout_candidates(Some(folder), Some(subfolder.as_path()), folder.parent())
254        .into_iter()
255        .find_map(|(config_base, model_base, config_file)| match_local_layout(config_base?, model_base?, config_file))
256}
257
258#[cfg(all(feature = "hf-hub", not(feature = "local-only")))]
259fn resolve_hub_model_files(repo: &ApiRepo, prefix: &str) -> Result<ModelFiles> {
260    let sub_prefix = format!("{prefix}0_StaticEmbedding/");
261    let trimmed = prefix.trim_end_matches('/');
262    let parent = match Path::new(trimmed).parent() {
263        Some(path) if !path.as_os_str().is_empty() => format!("{}/", path.display()),
264        _ => String::new(),
265    };
266
267    for (config_prefix, model_prefix, config_file) in layout_candidates(prefix, sub_prefix.as_str(), parent.as_str()) {
268        if let Some(files) = match_hub_layout(repo, config_prefix, model_prefix, config_file)? {
269            return Ok(files);
270        }
271    }
272    Err(anyhow!("no valid model layout found in '{prefix}'"))
273}
274
275impl StaticModel {
276    /// Load a Model2Vec model directly from in-memory bytes.
277    ///
278    /// This path is useful for runtimes that fetch model assets as bytes
279    /// rather than reading them from a local filesystem.
280    pub fn from_bytes<T, M, C>(
281        tokenizer_bytes: T,
282        model_bytes: M,
283        config_bytes: C,
284        normalize: Option<bool>,
285    ) -> Result<Self>
286    where
287        T: AsRef<[u8]>,
288        M: AsRef<[u8]>,
289        C: AsRef<[u8]>,
290    {
291        let tokenizer = LoadedTokenizer::from_bytes(tokenizer_bytes.as_ref())?;
292        Self::from_loaded_bytes(tokenizer, model_bytes.as_ref(), config_bytes.as_ref(), normalize)
293    }
294
295    fn from_loaded_bytes(
296        tokenizer: LoadedTokenizer,
297        model_bytes: &[u8],
298        config_bytes: &[u8],
299        normalize: Option<bool>,
300    ) -> Result<Self> {
301        // Read normalize default from config.json
302        let cfg: Value = serde_json::from_slice(config_bytes).context("failed to parse config.json")?;
303        let cfg_norm = cfg.get("normalize").and_then(Value::as_bool).unwrap_or(true);
304        let normalize = normalize.unwrap_or(cfg_norm);
305
306        // Load the safetensors
307        let safet = SafeTensors::deserialize(model_bytes).context("failed to parse safetensors")?;
308        let tensor = safet
309            .tensor("embeddings")
310            .or_else(|_| safet.tensor("0"))
311            .or_else(|_| safet.tensor("embedding.weight"))
312            .context("embeddings tensor not found")?;
313
314        let [rows, cols]: [usize; 2] = tensor.shape().try_into().context("embedding tensor is not 2-D")?;
315        let raw = tensor.data();
316        let embeddings = match tensor.dtype() {
317            Dtype::F32 => Embeddings::F32(Cow::Owned(decode_f32(raw))),
318            Dtype::F16 => Embeddings::F32(Cow::Owned(decode_f16(raw))),
319            Dtype::I8 => Embeddings::I8(Cow::Owned(raw.iter().map(|&b| b as i8).collect())),
320            other => return Err(anyhow!("unsupported tensor dtype: {other:?}")),
321        };
322
323        let weights = match safet.tensor("weights") {
324            Ok(t) => {
325                let raw = t.data();
326                let v: Vec<f32> = match t.dtype() {
327                    Dtype::F64 => raw
328                        .as_chunks::<8>()
329                        .0
330                        .iter()
331                        .map(|&b| f64::from_le_bytes(b) as f32)
332                        .collect(),
333                    Dtype::F32 => decode_f32(raw),
334                    Dtype::F16 => decode_f16(raw),
335                    other => return Err(anyhow!("unsupported weights dtype: {:?}", other)),
336                };
337                Some(v)
338            }
339            Err(_) => None,
340        };
341
342        let token_mapping = match safet.tensor("mapping") {
343            Ok(t) => Some(decode_token_mapping(t.dtype(), t.data())?),
344            Err(_) => None,
345        };
346
347        Self::from_storage(
348            tokenizer,
349            embeddings,
350            rows,
351            cols,
352            normalize,
353            weights.map(Cow::Owned),
354            token_mapping.map(Cow::Owned),
355        )
356    }
357
358    /// Load a Model2Vec model from a local folder or the HuggingFace Hub.
359    ///
360    /// # Arguments
361    /// * `repo_or_path` - HuggingFace repo ID or local path to the model folder.
362    /// * `token` - Optional HuggingFace token for authenticated downloads.
363    /// * `normalize` - Optional flag to normalize embeddings (default from the resolved config file).
364    /// * `subfolder` - Optional subfolder within the repo or path to look for model files.
365    pub fn from_pretrained<P: AsRef<Path>>(
366        repo_or_path: P,
367        token: Option<&str>,
368        normalize: Option<bool>,
369        subfolder: Option<&str>,
370    ) -> Result<Self> {
371        let files = resolve_model_files(repo_or_path, token, subfolder)?;
372        let tokenizer_bytes = fs::read(&files.tokenizer).context("failed to read tokenizer.json")?;
373        let model_bytes = fs::read(&files.model).context("failed to read model.safetensors")?;
374        let config_bytes = fs::read(&files.config).context("failed to read config.json")?;
375        Self::from_bytes(tokenizer_bytes, model_bytes, config_bytes, normalize)
376    }
377
378    /// Construct from owned data.
379    ///
380    /// # Arguments
381    /// * `tokenizer` - Pre-deserialized tokenizer
382    /// * `embeddings` - Owned f32 embedding data
383    /// * `rows` - Number of vocabulary entries
384    /// * `cols` - Embedding dimension
385    /// * `normalize` - Whether to L2-normalize output embeddings
386    /// * `weights` - Optional per-token weights for quantized models
387    /// * `token_mapping` - Optional token ID mapping for quantized models
388    pub fn from_owned(
389        tokenizer: Tokenizer,
390        embeddings: Vec<f32>,
391        rows: usize,
392        cols: usize,
393        normalize: bool,
394        weights: Option<Vec<f32>>,
395        token_mapping: Option<Vec<usize>>,
396    ) -> Result<Self> {
397        Self::from_storage(
398            LoadedTokenizer::from_tokenizer(tokenizer)?,
399            Embeddings::F32(Cow::Owned(embeddings)),
400            rows,
401            cols,
402            normalize,
403            weights.map(Cow::Owned),
404            token_mapping.map(Cow::Owned),
405        )
406    }
407
408    /// Construct from static slices (zero-copy for embedded binary data).
409    ///
410    /// # Arguments
411    /// * `tokenizer` - Pre-deserialized tokenizer
412    /// * `embeddings` - Static f32 embedding data (borrowed, no copy)
413    /// * `rows` - Number of vocabulary entries
414    /// * `cols` - Embedding dimension
415    /// * `normalize` - Whether to L2-normalize output embeddings
416    /// * `weights` - Optional static per-token weights for quantized models
417    /// * `token_mapping` - Optional static token ID mapping for quantized models
418    pub fn from_borrowed(
419        tokenizer: Tokenizer,
420        embeddings: &'static [f32],
421        rows: usize,
422        cols: usize,
423        normalize: bool,
424        weights: Option<&'static [f32]>,
425        token_mapping: Option<&'static [usize]>,
426    ) -> Result<Self> {
427        Self::from_storage(
428            LoadedTokenizer::from_tokenizer(tokenizer)?,
429            Embeddings::F32(Cow::Borrowed(embeddings)),
430            rows,
431            cols,
432            normalize,
433            weights.map(Cow::Borrowed),
434            token_mapping.map(Cow::Borrowed),
435        )
436    }
437
438    /// Construct from owned int8 data, as written by model2vec's
439    /// `quantize_to="int8"`. The embeddings stay int8 in memory.
440    ///
441    /// # Arguments
442    /// * `tokenizer` - Pre-deserialized tokenizer
443    /// * `embeddings` - Owned int8 embedding data
444    /// * `rows` - Number of vocabulary entries
445    /// * `cols` - Embedding dimension
446    /// * `normalize` - Whether to L2-normalize output embeddings
447    /// * `weights` - Optional per-token weights for quantized models
448    /// * `token_mapping` - Optional token ID mapping for quantized models
449    pub fn from_owned_i8(
450        tokenizer: Tokenizer,
451        embeddings: Vec<i8>,
452        rows: usize,
453        cols: usize,
454        normalize: bool,
455        weights: Option<Vec<f32>>,
456        token_mapping: Option<Vec<usize>>,
457    ) -> Result<Self> {
458        Self::from_storage(
459            LoadedTokenizer::from_tokenizer(tokenizer)?,
460            Embeddings::I8(Cow::Owned(embeddings)),
461            rows,
462            cols,
463            normalize,
464            weights.map(Cow::Owned),
465            token_mapping.map(Cow::Owned),
466        )
467    }
468
469    /// Construct from static int8 slices (zero-copy for embedded binary data),
470    /// as written by model2vec's `quantize_to="int8"`.
471    ///
472    /// # Arguments
473    /// * `tokenizer` - Pre-deserialized tokenizer
474    /// * `embeddings` - Static int8 embedding data (borrowed, no copy)
475    /// * `rows` - Number of vocabulary entries
476    /// * `cols` - Embedding dimension
477    /// * `normalize` - Whether to L2-normalize output embeddings
478    /// * `weights` - Optional static per-token weights for quantized models
479    /// * `token_mapping` - Optional static token ID mapping for quantized models
480    pub fn from_borrowed_i8(
481        tokenizer: Tokenizer,
482        embeddings: &'static [i8],
483        rows: usize,
484        cols: usize,
485        normalize: bool,
486        weights: Option<&'static [f32]>,
487        token_mapping: Option<&'static [usize]>,
488    ) -> Result<Self> {
489        Self::from_storage(
490            LoadedTokenizer::from_tokenizer(tokenizer)?,
491            Embeddings::I8(Cow::Borrowed(embeddings)),
492            rows,
493            cols,
494            normalize,
495            weights.map(Cow::Borrowed),
496            token_mapping.map(Cow::Borrowed),
497        )
498    }
499
500    fn from_storage(
501        tokenizer: LoadedTokenizer,
502        embeddings: Embeddings,
503        rows: usize,
504        cols: usize,
505        normalize: bool,
506        weights: Option<Cow<'static, [f32]>>,
507        token_mapping: Option<Cow<'static, [usize]>>,
508    ) -> Result<Self> {
509        if cols == 0 {
510            return Err(anyhow!("embedding dimension must be greater than 0"));
511        }
512        if rows.checked_mul(cols) != Some(embeddings.len()) {
513            return Err(anyhow!(
514                "embeddings length {} != rows {} * cols {}",
515                embeddings.len(),
516                rows,
517                cols
518            ));
519        }
520        Ok(Self {
521            inner: Arc::new(StaticModelInner {
522                tokenizer,
523                embeddings,
524                dim: cols,
525                row_scales: None,
526                weights,
527                token_mapping,
528                normalize,
529            }),
530        })
531    }
532
533    /// Quantize the embeddings to int8 with one scale per row, storing them in
534    /// a quarter of the memory. Outputs are close to, but not bit-identical
535    /// with, those of the f32 model. Models that are already int8 are
536    /// returned unchanged.
537    pub fn quantize_int8(self) -> StaticModel {
538        let Embeddings::F32(table) = &self.inner.embeddings else {
539            return self;
540        };
541        let (data, mut scales) = quantize_rows(table, self.inner.dim);
542        let embeddings = Embeddings::I8(Cow::Owned(data));
543        let mut inner = match Arc::try_unwrap(self.inner) {
544            Ok(mut inner) => {
545                inner.embeddings = embeddings;
546                inner
547            }
548            Err(shared) => StaticModelInner {
549                tokenizer: shared.tokenizer.clone(),
550                embeddings,
551                dim: shared.dim,
552                row_scales: None,
553                weights: shared.weights.clone(),
554                token_mapping: shared.token_mapping.clone(),
555                normalize: shared.normalize,
556            },
557        };
558        if inner.token_mapping.is_none()
559            && let Some(weights) = inner.weights.take()
560        {
561            for (row, scale) in scales.iter_mut().enumerate() {
562                *scale *= weights.get(row).copied().unwrap_or(1.0);
563            }
564        }
565        inner.row_scales = Some(scales);
566        StaticModel { inner: Arc::new(inner) }
567    }
568
569    /// Char-level truncation to max_tokens * median_token_length
570    fn truncate_str(s: &str, max_tokens: usize, median_len: usize) -> &str {
571        s.char_indices()
572            .nth(max_tokens.saturating_mul(median_len))
573            .map_or(s, |(byte_idx, _)| &s[..byte_idx])
574    }
575
576    /// Encode texts into embeddings.
577    ///
578    /// # Arguments
579    /// * `sentences` - the list of sentences to encode.
580    /// * `max_length` - max tokens per text.
581    /// * `batch_size` - number of texts per batch.
582    pub fn encode_with_args(
583        &self,
584        sentences: &[String],
585        max_length: Option<usize>,
586        batch_size: usize,
587    ) -> Vec<Vec<f32>> {
588        let mut embeddings = Vec::with_capacity(sentences.len());
589        for batch in sentences.chunks(batch_size) {
590            let pooled: Vec<Vec<f32>> = batch
591                .maybe_par_iter()
592                .map(|text| self.encode_one(text, max_length))
593                .collect();
594            embeddings.extend(pooled);
595        }
596        embeddings
597    }
598
599    /// Default encode: `max_length=512`, all sentences in one batch
600    pub fn encode(&self, sentences: &[String]) -> Vec<Vec<f32>> {
601        self.encode_with_args(sentences, Some(512), sentences.len().max(1))
602    }
603
604    /// Embedding dimension of the vectors this model produces.
605    pub fn dim(&self) -> usize {
606        self.inner.dim
607    }
608
609    /// Encode texts into one row-major buffer of `sentences.len() * dim` floats.
610    ///
611    /// # Arguments
612    /// * `sentences` - the list of sentences to encode.
613    /// * `max_length` - max tokens per text.
614    pub fn encode_flat_with_args(&self, sentences: &[String], max_length: Option<usize>) -> Vec<f32> {
615        let dim = self.inner.dim;
616        let mut out = vec![0.0_f32; sentences.len() * dim];
617        let mut jobs: Vec<(&str, &mut [f32])> = sentences.iter().map(String::as_str).zip(out.chunks_mut(dim)).collect();
618        jobs.maybe_par_iter_mut()
619            .for_each(|(text, row)| self.encode_into(text, max_length, row));
620        out
621    }
622
623    /// Default flat encode: `max_length=512`
624    pub fn encode_flat(&self, sentences: &[String]) -> Vec<f32> {
625        self.encode_flat_with_args(sentences, Some(512))
626    }
627
628    /// Encode a single sentence into a vector.
629    pub fn encode_single(&self, sentence: &str) -> Vec<f32> {
630        self.encode_one(sentence, Some(512))
631    }
632
633    /// Truncate, tokenize and pool one text.
634    /// Uses the custom WordPiece tokenizer or Unigram model when the model
635    /// matches that pipeline; other pipelines use the `tokenizers` crate.
636    fn encode_one(&self, text: &str, max_length: Option<usize>) -> Vec<f32> {
637        let mut out = vec![0.0_f32; self.inner.dim];
638        self.encode_into(text, max_length, &mut out);
639        out
640    }
641
642    /// Truncate, tokenize and pool one text into `out`, which must hold `dim` zeros.
643    fn encode_into(&self, text: &str, max_length: Option<usize>, out: &mut [f32]) {
644        let model = &self.inner;
645        let text = max_length.map_or(text, |max_tok| {
646            Self::truncate_str(text, max_tok, model.tokenizer.median_token_length)
647        });
648        match &model.tokenizer.tokenization {
649            Tokenization::WordPiece(ft) => TOKEN_IDS.with_borrow_mut(|ids| {
650                ids.clear();
651                ft.encode_ids_into(text, ids);
652                self.pool_ids(ids, max_length, out)
653            }),
654            Tokenization::Unigram(tokenizer, fu) => {
655                let ids = fu.encode_ids(tokenizer, text).expect("tokenization failed");
656                self.pool_ids(&ids, max_length, out)
657            }
658            Tokenization::Crate(tokenizer) => {
659                let encoding = tokenizer.encode_fast(text, false).expect("tokenization failed");
660                self.pool_ids(encoding.get_ids(), max_length, out)
661            }
662        }
663    }
664
665    /// Mean-pool the first `max_length` token IDs, skipping the unk token.
666    fn pool_ids(&self, ids: &[u32], max_length: Option<usize>, sum: &mut [f32]) {
667        let model = &self.inner;
668        let ids = &ids[..max_length.map_or(ids.len(), |max_tok| max_tok.min(ids.len()))];
669        let cnt = match &model.embeddings {
670            Embeddings::F32(table) => self.sum_rows(table, ids, sum),
671            Embeddings::I8(table) => self.sum_rows(table, ids, sum),
672        };
673        let denom = cnt.max(1) as f32;
674        if model.normalize {
675            let mut sq = 0.0_f32;
676            for x in sum.iter_mut() {
677                *x /= denom;
678                sq += *x * *x;
679            }
680            let norm = sq.sqrt().max(1e-12);
681            for x in sum.iter_mut() {
682                *x /= norm;
683            }
684        } else {
685            for x in sum.iter_mut() {
686                *x /= denom;
687            }
688        }
689    }
690
691    /// Weighted row sum, using a compile-time dimension when one matches.
692    fn sum_rows<T: Copy>(&self, table: &[T], ids: &[u32], out: &mut [f32]) -> usize
693    where
694        f32: From<T>,
695    {
696        match self.inner.dim {
697            64 => self.sum_rows_fixed::<T, 64>(table, ids, out),
698            128 => self.sum_rows_fixed::<T, 128>(table, ids, out),
699            256 => self.sum_rows_fixed::<T, 256>(table, ids, out),
700            384 => self.sum_rows_fixed::<T, 384>(table, ids, out),
701            512 => self.sum_rows_fixed::<T, 512>(table, ids, out),
702            768 => self.sum_rows_fixed::<T, 768>(table, ids, out),
703            1024 => self.sum_rows_fixed::<T, 1024>(table, ids, out),
704            _ => self.sum_rows_dyn(table, ids, out),
705        }
706    }
707
708    /// Weighted row sum for a compile-time embedding dimension.
709    fn sum_rows_fixed<T: Copy, const D: usize>(&self, table: &[T], ids: &[u32], out: &mut [f32]) -> usize
710    where
711        f32: From<T>,
712    {
713        let sum: &mut [f32; D] = out.try_into().expect("output has D columns");
714        self.sum_rows_blocked(table, ids, sum)
715    }
716
717    /// Weighted row sum for any embedding dimension.
718    fn sum_rows_dyn<T: Copy>(&self, table: &[T], ids: &[u32], sum: &mut [f32]) -> usize
719    where
720        f32: From<T>,
721    {
722        self.sum_rows_blocked(table, ids, sum)
723    }
724
725    /// Weighted row sum that adds `BLOCK` rows per pass over `sum`, in token
726    /// order, so the result matches adding one row at a time.
727    #[inline(always)]
728    fn sum_rows_blocked<T: Copy>(&self, table: &[T], ids: &[u32], sum: &mut [f32]) -> usize
729    where
730        f32: From<T>,
731    {
732        let mut pending: [(&[T], f32); BLOCK] = [(&[], 0.0); BLOCK];
733        let mut n = 0;
734        let cnt = self.for_each_row(table, ids, |row, scale| {
735            pending[n] = (row, scale);
736            n += 1;
737            if n == BLOCK {
738                add_rows(sum, &pending);
739                n = 0;
740            }
741        });
742        for &(row, scale) in &pending[..n] {
743            add_row(sum, row, scale);
744        }
745        cnt
746    }
747
748    /// Call `f` with the embedding row and scale (token weight times row
749    /// scale) of every non-unk token; returns the number of rows visited.
750    #[inline(always)]
751    fn for_each_row<'t, T>(&self, table: &'t [T], ids: &[u32], mut f: impl FnMut(&'t [T], f32)) -> usize {
752        let model = &self.inner;
753        let dim = model.dim;
754        let mut cnt = 0;
755        let tokens = ids
756            .iter()
757            .map(|&id| id as usize)
758            .filter(|&tok| Some(tok) != model.tokenizer.unk_token_id);
759        match (&model.token_mapping, &model.weights, &model.row_scales) {
760            (None, None, None) => {
761                for tok in tokens {
762                    f(&table[tok * dim..(tok + 1) * dim], 1.0);
763                    cnt += 1;
764                }
765            }
766            (None, None, Some(row_scales)) => {
767                for tok in tokens {
768                    f(&table[tok * dim..(tok + 1) * dim], row_scales[tok]);
769                    cnt += 1;
770                }
771            }
772            (mapping, weights, row_scales) => {
773                for tok in tokens {
774                    let row_idx = mapping.as_ref().and_then(|m| m.get(tok)).copied().unwrap_or(tok);
775                    let mut scale = weights.as_ref().and_then(|w| w.get(tok)).copied().unwrap_or(1.0);
776                    if let Some(row_scales) = row_scales {
777                        scale *= row_scales[row_idx];
778                    }
779                    f(&table[row_idx * dim..(row_idx + 1) * dim], scale);
780                    cnt += 1;
781                }
782            }
783        }
784        cnt
785    }
786}
787
788/// `sum += row * scale`.
789#[inline(always)]
790fn add_row<T: Copy>(sum: &mut [f32], row: &[T], scale: f32)
791where
792    f32: From<T>,
793{
794    for (s, &v) in sum.iter_mut().zip(row) {
795        *s += f32::from(v) * scale;
796    }
797}
798
799const BLOCK: usize = 8;
800
801/// `sum += r0 * s0 + r1 * s1 + ...` over BLOCK rows, added left to right.
802#[inline(always)]
803fn add_rows<T: Copy>(sum: &mut [f32], rows: &[(&[T], f32); BLOCK])
804where
805    f32: From<T>,
806{
807    let n = sum.len();
808    let [
809        (r0, s0),
810        (r1, s1),
811        (r2, s2),
812        (r3, s3),
813        (r4, s4),
814        (r5, s5),
815        (r6, s6),
816        (r7, s7),
817    ] = *rows;
818    let (r0, r1, r2, r3, r4, r5, r6, r7) = (
819        &r0[..n],
820        &r1[..n],
821        &r2[..n],
822        &r3[..n],
823        &r4[..n],
824        &r5[..n],
825        &r6[..n],
826        &r7[..n],
827    );
828    for j in 0..n {
829        sum[j] = sum[j]
830            + f32::from(r0[j]) * s0
831            + f32::from(r1[j]) * s1
832            + f32::from(r2[j]) * s2
833            + f32::from(r3[j]) * s3
834            + f32::from(r4[j]) * s4
835            + f32::from(r5[j]) * s5
836            + f32::from(r6[j]) * s6
837            + f32::from(r7[j]) * s7;
838    }
839}
840
841/// Symmetric per-row int8 quantization: each row is divided by
842/// `max|x| / 127`, rounded, and clamped to [-127, 127].
843fn quantize_rows(table: &[f32], dim: usize) -> (Vec<i8>, Vec<f32>) {
844    let mut data = Vec::with_capacity(table.len());
845    let mut scales = Vec::with_capacity(table.len() / dim.max(1));
846    for row in table.chunks_exact(dim.max(1)) {
847        let max = row.iter().fold(0.0_f32, |m, &v| m.max(v.abs()));
848        let scale = max / 127.0;
849        if scale > 0.0 {
850            data.extend(row.iter().map(|&v| (v / scale).round().clamp(-127.0, 127.0) as i8));
851        } else {
852            data.extend(std::iter::repeat_n(0, row.len()));
853        }
854        scales.push(scale);
855    }
856    (data, scales)
857}
858
859fn resolve_model_files<P: AsRef<Path>>(
860    repo_or_path: P,
861    token: Option<&str>,
862    subfolder: Option<&str>,
863) -> Result<ModelFiles> {
864    #[cfg(any(not(feature = "hf-hub"), feature = "local-only"))]
865    let _ = token;
866
867    let base = repo_or_path.as_ref();
868    if base.exists() {
869        let folder = subfolder.map(|s| base.join(s)).unwrap_or_else(|| base.to_path_buf());
870        return resolve_local_model_files(&folder).ok_or_else(|| {
871            anyhow!(
872                "no valid model layout found in {folder:?}. \
873                 Tried: model2vec (config.json), sentence-transformers \
874                 (config_sentence_transformers.json), and 0_StaticEmbedding subfolder."
875            )
876        });
877    }
878
879    #[cfg(all(feature = "hf-hub", not(feature = "local-only")))]
880    {
881        download_model_files(repo_or_path.as_ref().to_string_lossy().as_ref(), token, subfolder)
882    }
883    #[cfg(feature = "local-only")]
884    {
885        Err(anyhow!(
886            "remote model downloads are disabled by the `local-only` feature; pass a local model directory instead"
887        ))
888    }
889    #[cfg(all(not(feature = "hf-hub"), not(feature = "local-only")))]
890    {
891        Err(anyhow!(
892            "remote model downloads require the `hf-hub` feature; pass a local model directory instead"
893        ))
894    }
895}
896
897#[cfg(all(feature = "hf-hub", not(feature = "local-only")))]
898fn download_model_files(repo_id: &str, token: Option<&str>, subfolder: Option<&str>) -> Result<ModelFiles> {
899    let api = match token {
900        Some(token) => ApiBuilder::new().with_token(Some(token.to_owned())).build(),
901        None => Api::new(),
902    }
903    .context("hf-hub API init failed")?;
904    let repo = api.model(repo_id.to_owned());
905    let prefix = subfolder.map(|s| format!("{s}/")).unwrap_or_default();
906    resolve_hub_model_files(&repo, &prefix).with_context(|| format!("could not load '{repo_id}' from HuggingFace Hub"))
907}
908
909#[cfg(test)]
910mod tests {
911    use super::{Embeddings, StaticModel, Tokenization, decode_token_mapping};
912    use safetensors::tensor::Dtype;
913    use std::borrow::Cow;
914    use tokenizers::{
915        PaddingParams, PaddingStrategy, Tokenizer, TruncationParams, pre_tokenizers::whitespace::Whitespace,
916    };
917
918    #[test]
919    fn decode_token_mapping_supports_i32_and_i64() {
920        let i32_raw = [1i32, 2, 3]
921            .into_iter()
922            .flat_map(|value| value.to_le_bytes())
923            .collect::<Vec<_>>();
924        let i64_raw = [4i64, 5, 6]
925            .into_iter()
926            .flat_map(|value| value.to_le_bytes())
927            .collect::<Vec<_>>();
928
929        assert_eq!(decode_token_mapping(Dtype::I32, &i32_raw).unwrap(), vec![1, 2, 3]);
930        assert_eq!(decode_token_mapping(Dtype::I64, &i64_raw).unwrap(), vec![4, 5, 6]);
931    }
932
933    #[test]
934    fn decode_token_mapping_rejects_unsupported_dtype() {
935        let err = decode_token_mapping(Dtype::F32, &[0, 0, 0, 0]).unwrap_err();
936        assert!(err.to_string().contains("unsupported mapping dtype"));
937    }
938
939    #[test]
940    fn int8_models_keep_i8_storage() {
941        let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures");
942        let int8 = StaticModel::from_pretrained(format!("{dir}/test-model-int8"), None, None, None).unwrap();
943        assert!(matches!(&int8.inner.embeddings, Embeddings::I8(Cow::Owned(t)) if t.len() == 29528 * 2));
944        let f32_model = StaticModel::from_pretrained(format!("{dir}/test-model-float32"), None, None, None).unwrap();
945        assert!(matches!(&f32_model.inner.embeddings, Embeddings::F32(_)));
946    }
947
948    #[test]
949    fn quantize_int8_replaces_f32_storage() {
950        let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures");
951        let model = StaticModel::from_pretrained(format!("{dir}/test-model-vocab-quantized"), None, None, None)
952            .unwrap()
953            .quantize_int8();
954        let inner = &model.inner;
955        let rows = inner.embeddings.len() / inner.dim;
956        assert!(matches!(&inner.embeddings, Embeddings::I8(Cow::Owned(_))));
957        assert_eq!(inner.row_scales.as_ref().map(Vec::len), Some(rows));
958        assert_eq!(inner.weights.is_none(), inner.token_mapping.is_none());
959    }
960
961    #[test]
962    fn tokenizer_truncation_and_padding_are_ignored() {
963        let path = concat!(
964            env!("CARGO_MANIFEST_DIR"),
965            "/tests/fixtures/test-model-int8/tokenizer.json"
966        );
967        let mut plain = Tokenizer::from_file(path).unwrap();
968        plain.with_pre_tokenizer(Some(Whitespace {}));
969        let mut configured = plain.clone();
970        configured
971            .with_truncation(Some(TruncationParams {
972                max_length: 2,
973                ..Default::default()
974            }))
975            .unwrap();
976        configured.with_padding(Some(PaddingParams {
977            strategy: PaddingStrategy::Fixed(64),
978            ..Default::default()
979        }));
980
981        let rows = plain.get_vocab_size(false);
982        let embeddings: Vec<f32> = (0..rows * 2).map(|i| (i % 97) as f32).collect();
983        let load = |tok| StaticModel::from_owned(tok, embeddings.clone(), rows, 2, false, None, None).unwrap();
984        let (plain, configured) = (load(plain), load(configured));
985        assert!(matches!(
986            configured.inner.tokenizer.tokenization,
987            Tokenization::Crate(_)
988        ));
989
990        let text = "the quick brown fox jumps over the lazy dog";
991        assert_eq!(configured.encode_single(text), plain.encode_single(text));
992    }
993
994    #[test]
995    fn overflowing_shape_is_rejected() {
996        let path = concat!(
997            env!("CARGO_MANIFEST_DIR"),
998            "/tests/fixtures/test-model-int8/tokenizer.json"
999        );
1000        let tok = Tokenizer::from_file(path).unwrap();
1001        assert!(StaticModel::from_owned(tok, Vec::new(), usize::MAX / 2 + 1, 2, false, None, None).is_err());
1002    }
1003}