Skip to main content

pocket_encoder/
lib.rs

1//! Bundled sentence-transformer embeddings, offline, with native Rust CPU inference.
2//!
3//! This runs all three attention layers of MiniLM. The 4-bit **storage** format is
4//! dequantized to F32 once at load; inference is F32, not an integer kernel.
5//! No model downloads, Python, ONNX shared library, cache or build script.
6//!
7//! ```no_run
8//! let encoder = pocket_encoder::Encoder::new()?;
9//! let vectors = encoder.embed(&["Send a document", "Transfer a file"])?;
10//! assert_eq!(vectors[0].len(), 384);
11//! # Ok::<(), pocket_encoder::Error>(())
12//! ```
13//!
14//! Enable the `search` feature for an exact cosine index with compact binary
15//! storage. Embedding-only users do not compile the index API.
16#[cfg(feature = "search")]
17mod index;
18#[cfg(feature = "search")]
19pub use index::{Index, Neighbor};
20
21#[cfg(not(all(target_arch = "aarch64", target_os = "linux")))]
22mod candle;
23#[cfg(any(all(target_arch = "aarch64", target_os = "linux"), test))]
24mod cpu;
25mod weights;
26#[cfg(not(all(target_arch = "aarch64", target_os = "linux")))]
27use candle::Model;
28#[cfg(all(target_arch = "aarch64", target_os = "linux"))]
29use cpu::Model;
30use serde::{Deserialize, Serialize};
31use std::sync::{Arc, OnceLock};
32use tokenizers::{Tokenizer, TruncationParams};
33
34/// Embedding coordinate-space identity. Persist this with indexed vectors.
35/// Exact equality, not just equal dimensions, is required before comparison.
36#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
37pub struct ModelInfo {
38    /// Identifies the model and preprocessing contract.
39    pub id: String,
40    /// SHA-256 of the bundled quantized weights.
41    pub revision: String,
42    /// Upstream model repository.
43    pub upstream_model: String,
44    /// Immutable upstream revision.
45    pub upstream_revision: String,
46    /// SHA-256 of the unchanged tokenizer JSON.
47    pub tokenizer_sha256: String,
48    /// Number of normalized F32 coordinates per input.
49    pub dimensions: usize,
50    /// Includes special tokens. Longer inputs are right-truncated.
51    pub max_tokens: usize,
52    /// Pooling and normalization contract.
53    pub pooling: String,
54}
55
56/// Metadata is available without loading weights or doing inference.
57pub fn model_info() -> ModelInfo {
58    let value: serde_json::Value = serde_json::from_str(include_str!("../assets/provenance.json"))
59        .expect("bundled provenance");
60    ModelInfo {
61        id: "pocket-encoder/minilm-l3-q4-g32-v1".into(),
62        revision: format!("sha256:{}", value["bundled_sha256"].as_str().unwrap()),
63        upstream_model: value["upstream_model"].as_str().unwrap().into(),
64        upstream_revision: value["upstream_revision"].as_str().unwrap().into(),
65        tokenizer_sha256: value["tokenizer_sha256"].as_str().unwrap().into(),
66        dimensions: 384,
67        max_tokens: 128,
68        pooling: "attention-masked mean; L2 normalized; no query prefix; right truncation".into(),
69    }
70}
71
72/// Validation or native inference failure. Input text is never included.
73#[derive(Debug, thiserror::Error)]
74pub enum Error {
75    /// Input bounds were exceeded.
76    #[error("{0}")]
77    Input(&'static str),
78    /// Tokenizer/model initialization or inference failed.
79    #[error("encoder failed: {0}")]
80    Inference(String),
81}
82
83struct Inner {
84    model: Model,
85    tokenizer: Tokenizer,
86}
87
88/// Cheap clones share immutable weights and tokenizer; CPU only.
89#[derive(Clone)]
90pub struct Encoder {
91    inner: Arc<Inner>,
92}
93
94impl Encoder {
95    /// Load compiled-in weights once per process. No filesystem or network I/O.
96    pub fn new() -> Result<Self, Error> {
97        static MODEL: OnceLock<Result<Arc<Inner>, String>> = OnceLock::new();
98        MODEL
99            .get_or_init(|| load_bundled().map(Arc::new).map_err(|e| e.to_string()))
100            .clone()
101            .map(|inner| Self { inner })
102            .map_err(Error::Inference)
103    }
104
105    /// Encode at most 4096 inputs, each at most 64 KiB, at most 4 MiB combined.
106    /// Inputs are independently right-truncated to 128 WordPiece tokens, including
107    /// special tokens. Use smaller chunks for long documents. Empty text is rejected.
108    /// Returns unit-length vectors in the same order; no input text is retained.
109    pub fn embed(&self, texts: &[impl AsRef<str>]) -> Result<Vec<Vec<f32>>, Error> {
110        if texts.len() > 4096 {
111            return Err(Error::Input("at most 4096 texts per call"));
112        }
113        let mut bytes = 0usize;
114        for text in texts {
115            let text = text.as_ref();
116            if text.trim().is_empty() {
117                return Err(Error::Input("text must not be empty"));
118            }
119            if text.len() > 65536 {
120                return Err(Error::Input("text exceeds 64 KiB"));
121            }
122            bytes = bytes.saturating_add(text.len());
123        }
124        if bytes > 4 * 1024 * 1024 {
125            return Err(Error::Input("texts exceed 4 MiB"));
126        }
127        texts.iter().map(|text| self.one(text.as_ref())).collect()
128    }
129
130    fn one(&self, text: &str) -> Result<Vec<f32>, Error> {
131        let encoded = self.inner.tokenizer.encode(text, true).map_err(inference)?;
132        normalize(self.inner.model.pooled(
133            encoded.get_ids(),
134            encoded.get_type_ids(),
135            encoded.get_attention_mask(),
136        )?)
137    }
138}
139
140fn inference(error: impl std::fmt::Display) -> Error {
141    Error::Inference(error.to_string())
142}
143
144fn normalize(pooled: Vec<f32>) -> Result<Vec<f32>, Error> {
145    let norm = pooled
146        .iter()
147        .map(|v| (*v as f64).powi(2))
148        .sum::<f64>()
149        .sqrt();
150    if !norm.is_finite() || norm <= 0.0 {
151        return Err(inference("non-finite or zero embedding"));
152    }
153    Ok(pooled
154        .into_iter()
155        .map(|v| (v as f64 / norm) as f32)
156        .collect())
157}
158
159fn tokenizer() -> Result<Tokenizer, Error> {
160    let mut tokenizer =
161        Tokenizer::from_bytes(include_bytes!("../assets/tokenizer.json")).map_err(inference)?;
162    tokenizer.with_padding(None);
163    tokenizer
164        .with_truncation(Some(TruncationParams {
165            max_length: 128,
166            ..Default::default()
167        }))
168        .map_err(inference)?;
169    Ok(tokenizer)
170}
171
172fn load_bundled() -> Result<Inner, Error> {
173    Ok(Inner {
174        model: Model::load()?,
175        tokenizer: tokenizer()?,
176    })
177}