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, read_bert_hparams, BERT_ARCH};
17use crate::encoder::{EncodeError, PairSequence, TextEncoder};
18use crate::loader::LoadError;
19use crate::pooling::{l2_normalize, pool, PoolingType};
20use crate::rank_head::{load_rank_head, RankHead};
21use crate::tokenizer::{GgufWordPieceTokenizer, SpecialTokens, TokenizerLoadError};
22
23/// Encoder architectures upstream builds from `bert.cpp` and the other
24/// embedding rows in the capability catalog, with what each one needs
25/// that this crate does not have. Used to refuse *by name* instead of
26/// with a generic "unsupported".
27const NOT_YET: &[(&str, &str)] = &[
28 ("nomic-bert", "RoPE on Q/K and a gated FFN"),
29 ("nomic-bert-moe", "RoPE, a gated FFN and MoE expert layers"),
30 ("jina-bert-v2", "GEGLU and a second attention norm"),
31 ("jina-bert-v3", "RoPE and per-projection QK norm"),
32 ("neo-bert", "per-projection QK norm"),
33 (
34 "modern-bert",
35 "its own graph (local/global alternating attention)",
36 ),
37 ("eurobert", "its own graph"),
38 ("t5encoder", "the T5 encoder stack"),
39 ("llama-embed", "a decoder embedding path, not an encoder"),
40 (
41 "gemma-embedding",
42 "a decoder embedding path, not an encoder",
43 ),
44 ("pangu-embedded", "a decoder embedding path, not an encoder"),
45];
46
47/// True when `general.architecture` names an encoder / embedding model
48/// rather than something with an output head.
49///
50/// This is the question a *server* asks before it decides which loader
51/// a checkpoint path goes to: an encoder can never reach the decoder
52/// path, so routing it there produces a refusal about a missing tensor
53/// instead of "this is an embedding model". The answer comes from the
54/// capability registry's own [`crate::capability::ArchScope`] and not
55/// from a second list beside [`NOT_YET`], because two lists of the same
56/// architectures is the copy this repo has already paid for seven times
57/// — a row added to the registry is covered here the moment it lands.
58///
59/// `true` does not mean ferrox can serve it. It means
60/// [`EmbeddingModel::from_gguf_path`] is the loader that will either
61/// build it or refuse it *by name*.
62pub fn is_embedding_arch(arch: &str) -> bool {
63 crate::capability::resolve_profile(arch).is_some_and(|p| {
64 matches!(
65 p.scope,
66 crate::capability::ArchScope::DeferredEncoderEmbedding
67 )
68 })
69}
70
71#[derive(Debug, Error)]
72pub enum EmbedError {
73 #[error(transparent)]
74 Load(#[from] LoadError),
75 #[error(transparent)]
76 Tokenizer(#[from] TokenizerLoadError),
77 #[error(transparent)]
78 Encode(#[from] EncodeError),
79 #[error(
80 "architecture {arch:?} is an embedding model ferrox cannot serve yet: it needs {needs}. \
81 Only {BERT_ARCH:?} is implemented"
82 )]
83 NotYetImplemented { arch: String, needs: &'static str },
84 #[error(
85 "architecture {0:?} is not an embedding model this build knows. \
86 Only {BERT_ARCH:?} is implemented"
87 )]
88 NotAnEmbeddingModel(String),
89 #[error(
90 "{arch:?} carries tokenizer.ggml.model = {model:?}, but this embedding path only has \
91 WordPiece (\"bert\")"
92 )]
93 UnsupportedTokenizer { arch: String, model: String },
94 #[error(
95 "the embedding model {name:?} ({arch}) carries no reranker classification head: the \
96 checkpoint has no cls / cls.output tensors, so it has no relevance score to \
97 report. It can only produce embeddings"
98 )]
99 NoRankHead { name: String, arch: String },
100 #[error(
101 "the encoder for {arch:?} has no two-segment (query, document) input form, which a \
102 cross-encoder rerank needs. Concatenating the two texts would score fluently and \
103 wrongly, so this refuses instead"
104 )]
105 NoPairInput { arch: String },
106 #[error(
107 "the reranker checkpoint {name:?} ({arch}) carries a classification head but only \
108 {rows} token-type row(s): there is no \"Sentence B\" embedding to put the document \
109 half of a pair on. Scoring both halves as Sentence A is what this cross-encoder was \
110 NOT trained on, and it reorders the results rather than merely shifting them, so \
111 this refuses at load instead of serving a plausible wrong ranking"
112 )]
113 NoSegmentB {
114 name: String,
115 arch: String,
116 rows: usize,
117 },
118}
119
120/// The one condition under which a checkpoint that HAS a classification
121/// head still cannot serve `/v1/rerank`: its token-type table has no
122/// "Sentence B" row, so a pair would put both halves on segment 0.
123///
124/// A function rather than an `if` inside the loader so the arm is
125/// testable without a GGUF carrying that shape. A refusal whose
126/// condition cannot be shown to fire reads as coverage and is not: this
127/// repo has shipped one keyed on a GGUF spelling nothing writes.
128///
129/// It is checked at LOAD, and only here, because this is the only place
130/// the head and the encoder are both in hand — the BERT loader does not
131/// know whether a head was found, and asking once per request would put
132/// the answer in two places. A checkpoint in this state cannot answer
133/// the one route it exists for, so it does not load.
134fn refuse_unpairable_reranker(
135 has_rank_head: bool,
136 n_segments: usize,
137 name: &str,
138 arch: &str,
139) -> Option<EmbedError> {
140 if has_rank_head && n_segments < 2 {
141 return Some(EmbedError::NoSegmentB {
142 name: name.to_string(),
143 arch: arch.to_string(),
144 rows: n_segments,
145 });
146 }
147 None
148}
149
150/// A loaded embedding model: tokenizer + encoder + the checkpoint's own
151/// pooling rule.
152pub struct EmbeddingModel {
153 encoder: Box<dyn TextEncoder + Send + Sync>,
154 tokenizer: GgufWordPieceTokenizer,
155 /// The reranker classification head, when the checkpoint carries
156 /// one. `None` for a plain embedding model, and that is what makes
157 /// `/v1/rerank` refuse rather than substitute a cosine similarity.
158 rank_head: Option<RankHead>,
159 arch: String,
160 name: String,
161}
162
163impl EmbeddingModel {
164 /// Opens `path` and builds whichever embedding stack its
165 /// `general.architecture` names, or refuses naming what is missing.
166 pub fn from_gguf_path(path: impl AsRef<std::path::Path>) -> Result<Self, EmbedError> {
167 let file = ferrox_gguf::ShardedGguf::open(path.as_ref()).map_err(LoadError::from)?;
168 let arch = ferrox_gguf::TensorSource::metadata_str(&file, "general.architecture")
169 .ok_or_else(|| LoadError::MissingHparam("general.architecture".into()))?
170 .to_string();
171 if arch != BERT_ARCH {
172 return Err(match NOT_YET.iter().find(|(a, _)| *a == arch) {
173 Some((_, needs)) => EmbedError::NotYetImplemented { arch, needs },
174 None => EmbedError::NotAnEmbeddingModel(arch),
175 });
176 }
177 let tok_model = ferrox_gguf::TensorSource::metadata_str(&file, "tokenizer.ggml.model")
178 .unwrap_or_default()
179 .to_string();
180 if tok_model != "bert" {
181 return Err(EmbedError::UnsupportedTokenizer {
182 arch,
183 model: tok_model,
184 });
185 }
186 let name = ferrox_gguf::TensorSource::metadata_str(&file, "general.name")
187 .map(str::to_string)
188 .unwrap_or_else(|| arch.clone());
189 let tokenizer = GgufWordPieceTokenizer::from_gguf(&file)?;
190
191 // ORDER IS LOAD-BEARING. `load_rank_head` MUST run before
192 // `load_bert_encoder`, which ends in
193 // `assert_every_tensor_consumed`: `cls.weight`, `cls.output.*`
194 // and `cls.norm.weight` are read by nothing else in this crate,
195 // so with the two lines swapped every reranker checkpoint dies
196 // with an `UnconsumedTensors` refusal listing tensors ferrox
197 // does in fact read. `read_bert_hparams` touches metadata only,
198 // so asking for the geometry twice costs nothing.
199 let hp = read_bert_hparams(&file)?;
200 let rank_head = load_rank_head(&file, &hp.arch, hp.n_embd, hp.layer_norm_eps)?;
201 let encoder = load_bert_encoder(&file)?;
202
203 if let Some(refusal) =
204 refuse_unpairable_reranker(rank_head.is_some(), encoder.n_segments(), &name, &arch)
205 {
206 return Err(refusal);
207 }
208
209 Ok(Self {
210 encoder: Box::new(encoder),
211 tokenizer,
212 rank_head,
213 arch,
214 name,
215 })
216 }
217
218 pub fn architecture(&self) -> &str {
219 &self.arch
220 }
221
222 /// The checkpoint's `general.name`, or its architecture when the
223 /// file carries none. What `/v1/embeddings` reports as `model`.
224 pub fn name(&self) -> &str {
225 &self.name
226 }
227
228 pub fn n_embd(&self) -> usize {
229 self.encoder.n_embd()
230 }
231
232 pub fn n_ctx_train(&self) -> usize {
233 self.encoder.n_ctx_train()
234 }
235
236 pub fn pooling_type(&self) -> PoolingType {
237 self.encoder.pooling_type()
238 }
239
240 /// The exact ids the encoder will see for `text`: the tokenizer's
241 /// pieces wrapped in the model's own special tokens. Public because
242 /// `/v1/embeddings` has to report `usage.prompt_tokens`, and that
243 /// number is this length — llama.cpp counts the specials too.
244 ///
245 /// `SpecialTokens::Parse`, as llama.cpp's `/v1/embeddings` does
246 /// (`tools/server/server-context.cpp`, `handle_embeddings_impl`:
247 /// `tokenize_input_prompts(..., /* add_special */ true,
248 /// /* parse_special */ true)`).
249 pub fn token_ids(&self, text: &str) -> Vec<u32> {
250 self.encoder
251 .wrap_special(&self.tokenizer.encode(text, SpecialTokens::Parse))
252 }
253
254 /// Text for `ids`, through this checkpoint's own vocabulary.
255 ///
256 /// The counterpart to [`Self::token_ids`], so `/v1/detokenize`
257 /// answers for an encoder rather than refusing. An embedding
258 /// model's whole contract is the vector it returns for a string,
259 /// and when that vector is surprising the first question is what
260 /// tokens it actually saw. Without this the only way to ask was to
261 /// load the checkpoint in a second tool.
262 ///
263 /// Not `wrap_special`'s inverse: it decodes exactly the ids given,
264 /// including specials if the caller passes them, because a caller
265 /// checking a tokenization wants to see what it sent.
266 pub fn decode_tokens(&self, ids: &[u32]) -> String {
267 self.tokenizer.decode(ids)
268 }
269
270 /// Pooled embedding for `text`. `normalize` applies L2 normalization,
271 /// which is what an OpenAI-compatible `/v1/embeddings` response is
272 /// expected to carry and what llama.cpp's server does by default;
273 /// the raw pooled vector is what the graph produced.
274 pub fn embed(&self, text: &str, normalize: bool) -> Result<Vec<f32>, EmbedError> {
275 let ids = self.token_ids(text);
276 let mut v = self.encoder.embed_tokens(&ids)?;
277 if normalize {
278 l2_normalize(&mut v);
279 }
280 Ok(v)
281 }
282
283 /// Un-pooled `n_tokens × n_embd` hidden states, for a caller that
284 /// wants to pool differently (or not at all).
285 pub fn hidden_states(&self, text: &str) -> Result<Vec<f32>, EmbedError> {
286 Ok(self.encoder.encode_tokens(&self.token_ids(text))?)
287 }
288
289 /// The checkpoint's reranker classification head, or `None` for a
290 /// plain embedding model. What `/v1/rerank` checks before it
291 /// promises a caller a relevance score.
292 pub fn rank_head(&self) -> Option<&RankHead> {
293 self.rank_head.as_ref()
294 }
295
296 /// The exact input [`Self::rerank_score`] will see for one
297 /// `(query, document)` pair: `[CLS] query [SEP] document [SEP]`,
298 /// **with** the segment id of every position.
299 ///
300 /// Separate from the scoring call for the same reason
301 /// [`Self::token_ids`] is separate from [`Self::embed`] — a route
302 /// has to report `usage.prompt_tokens`, and that number is
303 /// `tokens.len()`.
304 ///
305 /// `SpecialTokens::AsText` for both halves, as llama.cpp's
306 /// `format_prompt_rerank` does (`tools/server/server-common.cpp`:
307 /// `tokenize_input_subprompt(vocab, mctx, query, false, false)` and
308 /// the same for `doc`). A document that mentions `[SEP]` must not be
309 /// able to end the query half early.
310 pub fn rerank_input(&self, query: &str, document: &str) -> Result<PairSequence, EmbedError> {
311 self.encoder
312 .wrap_special_pair(
313 &self.tokenizer.encode(query, SpecialTokens::AsText),
314 &self.tokenizer.encode(document, SpecialTokens::AsText),
315 )
316 .ok_or_else(|| EmbedError::NoPairInput {
317 arch: self.arch.clone(),
318 })
319 }
320
321 /// The head's relevance score for a pair sequence built by
322 /// [`Self::rerank_input`].
323 ///
324 /// This is upstream's RANK path in full: encode, take the **CLS**
325 /// row, run the classification head, report output 0
326 /// (`send_rerank`'s `embd[0]`). The CLS row is taken here regardless
327 /// of what `{arch}.pooling_type` says, because the head was trained
328 /// on that position — `pooling_type = RANK` is the checkpoint
329 /// *declaring* this path, not naming a pooling rule, which is why
330 /// [`crate::pooling::pool`] still refuses RANK and must keep
331 /// refusing it.
332 ///
333 /// No L2 normalization and no sigmoid: upstream reports the raw
334 /// logit, so a score is comparable only against other scores from
335 /// the same head, and this must not quietly squash it into `0..1`.
336 pub fn rerank_score(&self, pair: &PairSequence) -> Result<f32, EmbedError> {
337 let head = self
338 .rank_head
339 .as_ref()
340 .ok_or_else(|| EmbedError::NoRankHead {
341 name: self.name.clone(),
342 arch: self.arch.clone(),
343 })?;
344 let hidden = self.pair_hidden_states(pair)?;
345 let cls = pool(&hidden, self.encoder.n_embd(), PoolingType::Cls)
346 .map_err(|e| EmbedError::Encode(EncodeError::Pooling(e)))?;
347 Ok(head.score(&cls))
348 }
349
350 /// Un-pooled `n_tokens × n_embd` hidden states for a pair built by
351 /// [`Self::rerank_input`] — [`Self::hidden_states`]'s counterpart
352 /// for the cross-encoder input, and the one graph call
353 /// [`Self::rerank_score`] itself makes.
354 ///
355 /// Public for the same reason [`Self::hidden_states`] is: when a
356 /// relevance score is surprising, the first questions are what
357 /// tokens the model saw and what came out before the head, and
358 /// without this the only way to ask was to load the checkpoint a
359 /// second time — which for a reranker does not even work, because
360 /// [`crate::load_bert_encoder_from_path`] alone leaves `cls.*`
361 /// unconsumed and refuses.
362 ///
363 /// The pair's own `segments` are honoured, so passing a
364 /// [`PairSequence`] whose segments are all zero reproduces the
365 /// segment-blind graph exactly, without a second copy of it to
366 /// drift.
367 pub fn pair_hidden_states(&self, pair: &PairSequence) -> Result<Vec<f32>, EmbedError> {
368 Ok(self.encoder.encode(&pair.tokens, Some(&pair.segments))?)
369 }
370}
371
372#[cfg(test)]
373mod tests {
374 use super::*;
375
376 /// Every deferred embedding architecture must produce a refusal
377 /// that names it and names what it needs — not a generic error.
378 #[test]
379 fn every_deferred_embedding_arch_is_named_in_its_own_refusal() {
380 for (arch, needs) in NOT_YET {
381 let err = EmbedError::NotYetImplemented {
382 arch: (*arch).to_string(),
383 needs,
384 };
385 let msg = err.to_string();
386 assert!(msg.contains(arch), "{msg} does not name {arch}");
387 assert!(msg.contains(needs), "{msg} does not say what is missing");
388 }
389 }
390
391 /// The catalog rows this module claims to cover must actually be
392 /// the encoder/embedding rows the capability registry defers, so a
393 /// new row added there cannot silently fall through to the generic
394 /// "not an embedding model" arm.
395 #[test]
396 fn the_deferred_list_is_a_subset_of_the_capability_registry() {
397 for (arch, _) in NOT_YET {
398 assert!(
399 crate::capability::resolve_profile(arch).is_some(),
400 "{arch} is not in the capability registry"
401 );
402 }
403 }
404
405 /// [`is_embedding_arch`] is what a server routes on, so it has to
406 /// name *exactly* the architectures this module can answer for:
407 /// `bert`, which loads, plus every row in [`NOT_YET`], which
408 /// refuses by name. A registry row scoped
409 /// `DeferredEncoderEmbedding` that is in neither would be routed
410 /// here and hit the generic `NotAnEmbeddingModel` arm, which says
411 /// the opposite of the truth about it.
412 #[test]
413 fn is_embedding_arch_covers_the_registry_rows_and_nothing_else() {
414 let mut registry: Vec<&str> = crate::capability::architecture_catalog()
415 .iter()
416 .filter(|p| {
417 matches!(
418 p.scope,
419 crate::capability::ArchScope::DeferredEncoderEmbedding
420 )
421 })
422 .map(|p| p.gguf_name)
423 .collect();
424 registry.sort_unstable();
425 let mut known: Vec<&str> = NOT_YET
426 .iter()
427 .map(|(a, _)| *a)
428 .chain(std::iter::once(BERT_ARCH))
429 .collect();
430 known.sort_unstable();
431 assert_eq!(
432 registry, known,
433 "the registry's encoder/embedding rows and this module's own list disagree"
434 );
435 for arch in ®istry {
436 assert!(is_embedding_arch(arch), "{arch} is not routed to this path");
437 }
438 // A decoder must NOT be routed here, or `FERROX_MODEL_PATH`
439 // pointing at a llama GGUF would be told it is an embedding
440 // model.
441 for arch in ["llama", "qwen3", "gemma3", "deepseek2"] {
442 assert!(!is_embedding_arch(arch), "{arch} was routed to this path");
443 }
444 }
445
446 /// A reranker that cannot express "Sentence B" is refused at load,
447 /// and a plain embedding model in the same state is NOT — an
448 /// embedding pass is all segment 0 and has nothing to say about a
449 /// second row.
450 ///
451 /// The point of the test is that the refusing arm is REACHABLE.
452 /// Written as a condition inside the loader it could only be
453 /// exercised by a checkpoint nobody publishes, which is how a gate
454 /// comes to read as coverage while never firing.
455 #[test]
456 fn only_a_reranker_needs_a_second_token_type_row_and_it_is_refused_without_one() {
457 assert!(refuse_unpairable_reranker(true, 2, "r", "bert").is_none());
458 assert!(refuse_unpairable_reranker(false, 1, "e", "bert").is_none());
459 assert!(refuse_unpairable_reranker(false, 0, "e", "bert").is_none());
460
461 let err = refuse_unpairable_reranker(true, 1, "some-reranker", "bert")
462 .expect("a head with one segment row must refuse");
463 let msg = err.to_string();
464 for fact in ["some-reranker", "bert", "1 token-type row"] {
465 assert!(msg.contains(fact), "{msg} does not carry {fact}");
466 }
467 assert!(matches!(err, EmbedError::NoSegmentB { rows: 1, .. }));
468 }
469}