Skip to main content

ferrox_models/
embedding_model.rs

1//! One GGUF path in, one embedding vector out.
2//!
3//! Binds a tokenizer to a [`TextEncoder`] and owns the two steps
4//! between them that neither half should own alone: adding the model's
5//! own special tokens (`[CLS] … [SEP]`) around the tokenizer's pieces,
6//! and pooling the hidden states the way the checkpoint's
7//! `pooling_type` says.
8//!
9//! This is the type `/v1/embeddings` and the CLI both hold. It exists
10//! so neither of them has to know that `bert` is an encoder, that
11//! WordPiece does not add its own specials, or that CLS pooling means
12//! row zero.
13
14use thiserror::Error;
15
16use crate::bert_gguf_loader::{load_bert_encoder, BERT_ARCH};
17use crate::encoder::{EncodeError, TextEncoder};
18use crate::loader::LoadError;
19use crate::pooling::{l2_normalize, PoolingType};
20use crate::tokenizer::{GgufWordPieceTokenizer, TokenizerLoadError};
21
22/// Encoder architectures upstream builds from `bert.cpp` and the other
23/// embedding rows in the capability catalog, with what each one needs
24/// that this crate does not have. Used to refuse *by name* instead of
25/// with a generic "unsupported".
26const NOT_YET: &[(&str, &str)] = &[
27    ("nomic-bert", "RoPE on Q/K and a gated FFN"),
28    ("nomic-bert-moe", "RoPE, a gated FFN and MoE expert layers"),
29    ("jina-bert-v2", "GEGLU and a second attention norm"),
30    ("jina-bert-v3", "RoPE and per-projection QK norm"),
31    ("neo-bert", "per-projection QK norm"),
32    (
33        "modern-bert",
34        "its own graph (local/global alternating attention)",
35    ),
36    ("eurobert", "its own graph"),
37    ("t5encoder", "the T5 encoder stack"),
38    ("llama-embed", "a decoder embedding path, not an encoder"),
39    (
40        "gemma-embedding",
41        "a decoder embedding path, not an encoder",
42    ),
43    ("pangu-embedded", "a decoder embedding path, not an encoder"),
44];
45
46/// True when `general.architecture` names an encoder / embedding model
47/// rather than something with an output head.
48///
49/// This is the question a *server* asks before it decides which loader
50/// a checkpoint path goes to: an encoder can never reach the decoder
51/// path, so routing it there produces a refusal about a missing tensor
52/// instead of "this is an embedding model". The answer comes from the
53/// capability registry's own [`crate::capability::ArchScope`] and not
54/// from a second list beside [`NOT_YET`], because two lists of the same
55/// architectures is the copy this repo has already paid for seven times
56/// — a row added to the registry is covered here the moment it lands.
57///
58/// `true` does not mean ferrox can serve it. It means
59/// [`EmbeddingModel::from_gguf_path`] is the loader that will either
60/// build it or refuse it *by name*.
61pub fn is_embedding_arch(arch: &str) -> bool {
62    crate::capability::resolve_profile(arch).is_some_and(|p| {
63        matches!(
64            p.scope,
65            crate::capability::ArchScope::DeferredEncoderEmbedding
66        )
67    })
68}
69
70#[derive(Debug, Error)]
71pub enum EmbedError {
72    #[error(transparent)]
73    Load(#[from] LoadError),
74    #[error(transparent)]
75    Tokenizer(#[from] TokenizerLoadError),
76    #[error(transparent)]
77    Encode(#[from] EncodeError),
78    #[error(
79        "architecture {arch:?} is an embedding model ferrox cannot serve yet: it needs {needs}. \
80         Only {BERT_ARCH:?} is implemented"
81    )]
82    NotYetImplemented { arch: String, needs: &'static str },
83    #[error(
84        "architecture {0:?} is not an embedding model this build knows. \
85         Only {BERT_ARCH:?} is implemented"
86    )]
87    NotAnEmbeddingModel(String),
88    #[error(
89        "{arch:?} carries tokenizer.ggml.model = {model:?}, but this embedding path only has \
90         WordPiece (\"bert\")"
91    )]
92    UnsupportedTokenizer { arch: String, model: String },
93}
94
95/// A loaded embedding model: tokenizer + encoder + the checkpoint's own
96/// pooling rule.
97pub struct EmbeddingModel {
98    encoder: Box<dyn TextEncoder + Send + Sync>,
99    tokenizer: GgufWordPieceTokenizer,
100    arch: String,
101    name: String,
102}
103
104impl EmbeddingModel {
105    /// Opens `path` and builds whichever embedding stack its
106    /// `general.architecture` names, or refuses naming what is missing.
107    pub fn from_gguf_path(path: impl AsRef<std::path::Path>) -> Result<Self, EmbedError> {
108        let file = ferrox_gguf::ShardedGguf::open(path.as_ref()).map_err(LoadError::from)?;
109        let arch = ferrox_gguf::TensorSource::metadata_str(&file, "general.architecture")
110            .ok_or_else(|| LoadError::MissingHparam("general.architecture".into()))?
111            .to_string();
112        if arch != BERT_ARCH {
113            return Err(match NOT_YET.iter().find(|(a, _)| *a == arch) {
114                Some((_, needs)) => EmbedError::NotYetImplemented { arch, needs },
115                None => EmbedError::NotAnEmbeddingModel(arch),
116            });
117        }
118        let tok_model = ferrox_gguf::TensorSource::metadata_str(&file, "tokenizer.ggml.model")
119            .unwrap_or_default()
120            .to_string();
121        if tok_model != "bert" {
122            return Err(EmbedError::UnsupportedTokenizer {
123                arch,
124                model: tok_model,
125            });
126        }
127        let name = ferrox_gguf::TensorSource::metadata_str(&file, "general.name")
128            .map(str::to_string)
129            .unwrap_or_else(|| arch.clone());
130        let tokenizer = GgufWordPieceTokenizer::from_gguf(&file)?;
131        let encoder = load_bert_encoder(&file)?;
132        Ok(Self {
133            encoder: Box::new(encoder),
134            tokenizer,
135            arch,
136            name,
137        })
138    }
139
140    pub fn architecture(&self) -> &str {
141        &self.arch
142    }
143
144    /// The checkpoint's `general.name`, or its architecture when the
145    /// file carries none. What `/v1/embeddings` reports as `model`.
146    pub fn name(&self) -> &str {
147        &self.name
148    }
149
150    pub fn n_embd(&self) -> usize {
151        self.encoder.n_embd()
152    }
153
154    pub fn n_ctx_train(&self) -> usize {
155        self.encoder.n_ctx_train()
156    }
157
158    pub fn pooling_type(&self) -> PoolingType {
159        self.encoder.pooling_type()
160    }
161
162    /// The exact ids the encoder will see for `text`: the tokenizer's
163    /// pieces wrapped in the model's own special tokens. Public because
164    /// `/v1/embeddings` has to report `usage.prompt_tokens`, and that
165    /// number is this length — llama.cpp counts the specials too.
166    pub fn token_ids(&self, text: &str) -> Vec<u32> {
167        self.encoder.wrap_special(&self.tokenizer.encode(text))
168    }
169
170    /// Pooled embedding for `text`. `normalize` applies L2 normalization,
171    /// which is what an OpenAI-compatible `/v1/embeddings` response is
172    /// expected to carry and what llama.cpp's server does by default;
173    /// the raw pooled vector is what the graph produced.
174    pub fn embed(&self, text: &str, normalize: bool) -> Result<Vec<f32>, EmbedError> {
175        let ids = self.token_ids(text);
176        let mut v = self.encoder.embed_tokens(&ids)?;
177        if normalize {
178            l2_normalize(&mut v);
179        }
180        Ok(v)
181    }
182
183    /// Un-pooled `n_tokens × n_embd` hidden states, for a caller that
184    /// wants to pool differently (or not at all).
185    pub fn hidden_states(&self, text: &str) -> Result<Vec<f32>, EmbedError> {
186        Ok(self.encoder.encode_tokens(&self.token_ids(text))?)
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    /// Every deferred embedding architecture must produce a refusal
195    /// that names it and names what it needs — not a generic error.
196    #[test]
197    fn every_deferred_embedding_arch_is_named_in_its_own_refusal() {
198        for (arch, needs) in NOT_YET {
199            let err = EmbedError::NotYetImplemented {
200                arch: (*arch).to_string(),
201                needs,
202            };
203            let msg = err.to_string();
204            assert!(msg.contains(arch), "{msg} does not name {arch}");
205            assert!(msg.contains(needs), "{msg} does not say what is missing");
206        }
207    }
208
209    /// The catalog rows this module claims to cover must actually be
210    /// the encoder/embedding rows the capability registry defers, so a
211    /// new row added there cannot silently fall through to the generic
212    /// "not an embedding model" arm.
213    #[test]
214    fn the_deferred_list_is_a_subset_of_the_capability_registry() {
215        for (arch, _) in NOT_YET {
216            assert!(
217                crate::capability::resolve_profile(arch).is_some(),
218                "{arch} is not in the capability registry"
219            );
220        }
221    }
222
223    /// [`is_embedding_arch`] is what a server routes on, so it has to
224    /// name *exactly* the architectures this module can answer for:
225    /// `bert`, which loads, plus every row in [`NOT_YET`], which
226    /// refuses by name. A registry row scoped
227    /// `DeferredEncoderEmbedding` that is in neither would be routed
228    /// here and hit the generic `NotAnEmbeddingModel` arm, which says
229    /// the opposite of the truth about it.
230    #[test]
231    fn is_embedding_arch_covers_the_registry_rows_and_nothing_else() {
232        let mut registry: Vec<&str> = crate::capability::architecture_catalog()
233            .iter()
234            .filter(|p| {
235                matches!(
236                    p.scope,
237                    crate::capability::ArchScope::DeferredEncoderEmbedding
238                )
239            })
240            .map(|p| p.gguf_name)
241            .collect();
242        registry.sort_unstable();
243        let mut known: Vec<&str> = NOT_YET
244            .iter()
245            .map(|(a, _)| *a)
246            .chain(std::iter::once(BERT_ARCH))
247            .collect();
248        known.sort_unstable();
249        assert_eq!(
250            registry, known,
251            "the registry's encoder/embedding rows and this module's own list disagree"
252        );
253        for arch in &registry {
254            assert!(is_embedding_arch(arch), "{arch} is not routed to this path");
255        }
256        // A decoder must NOT be routed here, or `FERROX_MODEL_PATH`
257        // pointing at a llama GGUF would be told it is an embedding
258        // model.
259        for arch in ["llama", "qwen3", "gemma3", "deepseek2"] {
260            assert!(!is_embedding_arch(arch), "{arch} was routed to this path");
261        }
262    }
263}