ferrox_models/
embedding_model.rs1use 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
22const 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
46pub 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
95pub struct EmbeddingModel {
98 encoder: Box<dyn TextEncoder + Send + Sync>,
99 tokenizer: GgufWordPieceTokenizer,
100 arch: String,
101 name: String,
102}
103
104impl EmbeddingModel {
105 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 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 pub fn token_ids(&self, text: &str) -> Vec<u32> {
167 self.encoder.wrap_special(&self.tokenizer.encode(text))
168 }
169
170 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 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 #[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 #[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 #[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 ®istry {
254 assert!(is_embedding_arch(arch), "{arch} is not routed to this path");
255 }
256 for arch in ["llama", "qwen3", "gemma3", "deepseek2"] {
260 assert!(!is_embedding_arch(arch), "{arch} was routed to this path");
261 }
262 }
263}