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