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