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