Skip to main content

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 &registry {
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}