Skip to main content

ferrox_models/
bert_gguf_loader.rs

1//! BERT GGUF → [`BertEncoder`].
2//!
3//! Tensor names and requiredness follow llama.cpp
4//! `llama_model_bert::load_arch_tensors` (`src/models/bert.cpp`), and
5//! hparam keys follow its `load_arch_hparams` plus the shared
6//! `LLM_KV_*` set.
7//!
8//! # `bert.cpp` upstream is five architectures; this is one of them
9//!
10//! `nomic-bert`, `nomic-bert-moe`, `jina-bert-v2`, `jina-bert-v3` and
11//! `neo-bert` all build their graph from the same file, each switching
12//! on `model.arch` for RoPE, a gated FFN, expert layers or a second
13//! attention norm. [`crate::bert_encoder`] implements only the plain
14//! `bert` shape, so every one of those differences is checked for here
15//! and **refused by name**: a checkpoint that carries `ffn_gate` or
16//! `attn_q_norm` is not silently run without it.
17//!
18//! The last line of defence is
19//! [`crate::loader::assert_every_tensor_consumed`], which the load ends
20//! with: for `bge-small-en-v1.5-q8_0.gguf` this graph reads all 197
21//! tensors, so any weight a variant adds and this module has no home
22//! for stops the load instead of being ignored.
23
24use ferrox_gguf::{ShardedGguf, TensorSource};
25
26use crate::bert_encoder::{BertEncoder, BertHparams, BertLayer};
27use crate::loader::{
28    assert_every_tensor_consumed, load_f32_vec, load_f32_vec_optional, load_weight_matrix,
29    LoadError,
30};
31use crate::pooling::PoolingType;
32
33/// The architecture string this loader implements.
34pub const BERT_ARCH: &str = "bert";
35
36/// llama.cpp's `tokenizer_model == "bert"` defaults, applied when the
37/// GGUF carries no explicit id (`llama-vocab.cpp`).
38const DEFAULT_CLS_ID: u32 = 101;
39const DEFAULT_SEP_ID: u32 = 102;
40
41fn meta_u64(file: &impl TensorSource, key: &str) -> Result<u64, LoadError> {
42    file.metadata_u64(key)
43        .ok_or_else(|| LoadError::MissingHparam(key.to_string()))
44}
45
46fn refuse(what: &str) -> LoadError {
47    LoadError::UnsupportedFeature(BERT_ARCH.to_string(), what.to_string())
48}
49
50/// Refuses if `name` exists, naming the upstream variant that carries it.
51fn reject_tensor(file: &ShardedGguf, name: &str, why: &str) -> Result<(), LoadError> {
52    if file.find_tensor(name).is_some() {
53        return Err(refuse(&format!("checkpoint carries '{name}': {why}")));
54    }
55    Ok(())
56}
57
58/// The whole architecture policy of this loader, in one place so it can
59/// be tested without a GGUF: `bert` and nothing else.
60pub fn check_arch(arch: &str) -> Result<(), LoadError> {
61    if arch == BERT_ARCH {
62        Ok(())
63    } else {
64        Err(LoadError::UnsupportedArchitecture(arch.to_string()))
65    }
66}
67
68/// Reads and checks `bert.*` hparams. Fails closed on anything the
69/// graph in [`crate::bert_encoder`] does not implement.
70pub fn read_bert_hparams(file: &impl TensorSource) -> Result<BertHparams, LoadError> {
71    let arch = file
72        .metadata_str("general.architecture")
73        .ok_or_else(|| LoadError::MissingHparam("general.architecture".into()))?
74        .to_string();
75    check_arch(&arch)?;
76    let p = |suffix: &str| format!("{arch}.{suffix}");
77
78    let n_layer = meta_u64(file, &p("block_count"))? as usize;
79    let n_embd = meta_u64(file, &p("embedding_length"))? as usize;
80    let n_ff = meta_u64(file, &p("feed_forward_length"))? as usize;
81    let n_head = meta_u64(file, &p("attention.head_count"))? as usize;
82    let n_head_kv = file
83        .metadata_u64(&p("attention.head_count_kv"))
84        .unwrap_or(n_head as u64) as usize;
85    let n_ctx_train = meta_u64(file, &p("context_length"))? as usize;
86
87    // `LLM_KV_ATTENTION_LAYERNORM_EPS` is read with `get_key(..., true)`
88    // upstream, i.e. required: there is no sane default for a norm this
89    // small (this checkpoint's is 1e-12, a thousand times tighter than
90    // any RMSNorm eps in the rest of this codebase).
91    let layer_norm_eps = file
92        .metadata_f32(&p("attention.layer_norm_epsilon"))
93        .ok_or_else(|| LoadError::MissingHparam(p("attention.layer_norm_epsilon")))?;
94
95    // `n_token_types` is required by upstream's own loader, which
96    // throws "model needs to define token type count".
97    let n_token_types = meta_u64(file, "tokenizer.ggml.token_type_count")? as usize;
98    if n_token_types == 0 {
99        return Err(refuse("tokenizer.ggml.token_type_count is 0"));
100    }
101
102    // An encoder is bidirectional by construction. If a checkpoint ever
103    // says otherwise, this graph is the wrong one for it.
104    if file.metadata_bool(&p("attention.causal")).unwrap_or(false) {
105        return Err(refuse(
106            "bert.attention.causal is true, but this graph applies no mask — \
107             a causal BERT would need a decoder path",
108        ));
109    }
110
111    if n_head == 0 || n_head_kv == 0 || !n_head.is_multiple_of(n_head_kv) {
112        return Err(refuse(&format!(
113            "head_count {n_head} is not a multiple of head_count_kv {n_head_kv}"
114        )));
115    }
116    if !n_embd.is_multiple_of(n_head) {
117        return Err(refuse(&format!(
118            "embedding_length {n_embd} is not divisible by head_count {n_head}"
119        )));
120    }
121    if file.metadata_u64(&p("expert_count")).unwrap_or(0) != 0
122        || file.metadata_u64(&p("moe_every_n_layers")).unwrap_or(0) != 0
123    {
124        return Err(refuse(
125            "expert layers (nomic-bert-moe's moe_every_n_layers) are not implemented",
126        ));
127    }
128
129    // Upstream defaults `hparams.pooling_type` to NONE and reads the key
130    // as optional, so an absent key means "return every row", not
131    // "guess CLS".
132    let pooling = PoolingType::from_gguf(file, &arch)
133        .map_err(|e| refuse(&e.to_string()))?
134        .unwrap_or(PoolingType::None);
135
136    let cls_id = file
137        .metadata_u64("tokenizer.ggml.bos_token_id")
138        .unwrap_or(u64::from(DEFAULT_CLS_ID)) as u32;
139    let sep_id = file
140        .metadata_u64("tokenizer.ggml.seperator_token_id")
141        .unwrap_or(u64::from(DEFAULT_SEP_ID)) as u32;
142
143    Ok(BertHparams {
144        arch,
145        n_layer,
146        n_embd,
147        n_ff,
148        n_head,
149        n_head_kv,
150        n_ctx_train,
151        n_token_types,
152        layer_norm_eps,
153        pooling,
154        cls_id,
155        sep_id,
156    })
157}
158
159/// Loads a `bert` GGUF into a runnable encoder.
160pub fn load_bert_encoder_from_path(
161    path: impl AsRef<std::path::Path>,
162) -> Result<BertEncoder, LoadError> {
163    load_bert_encoder(&ShardedGguf::open(path.as_ref())?)
164}
165
166/// Same, from an already-open file — so a caller that also needs the
167/// tokenizer out of it ([`crate::embedding_model`]) mmaps it once.
168pub fn load_bert_encoder(file: &ShardedGguf) -> Result<BertEncoder, LoadError> {
169    let hp = read_bert_hparams(file)?;
170
171    let tok_embd = load_weight_matrix(file, "token_embd.weight")?;
172    let pos_embd = load_weight_matrix(file, "position_embd.weight")?;
173    if pos_embd.rows() != hp.n_ctx_train {
174        return Err(refuse(&format!(
175            "position_embd.weight has {} rows but {}.context_length says {} — the learned \
176             position table and the advertised context disagree",
177            pos_embd.rows(),
178            hp.arch,
179            hp.n_ctx_train
180        )));
181    }
182    if pos_embd.cols() != hp.n_embd || tok_embd.cols() != hp.n_embd {
183        return Err(refuse(&format!(
184            "embedding tables are {} / {} wide but embedding_length is {}",
185            tok_embd.cols(),
186            pos_embd.cols(),
187            hp.n_embd
188        )));
189    }
190
191    // The WHOLE table, not row 0. Upstream views `type_embd` at offset
192    // 0 and adds it everywhere, because `llama_batch` carries no
193    // segment ids; ferrox's encoder takes them as a parameter, so a
194    // cross-encoder pair can put its document half on row 1 the way the
195    // checkpoint was trained. Loading row 0 alone is what made
196    // `/v1/rerank` rank the relevant document last (see
197    // `bert_encoder`'s module docs). The tensor is
198    // `TENSOR_NOT_REQUIRED` upstream, so its absence is not an error —
199    // it means no segment embedding is added at all, and a reranker
200    // checkpoint in that state is refused by `EmbeddingModel`.
201    let type_embd = match file.find_tensor("token_types.weight") {
202        Some(_) => {
203            let table = load_weight_matrix(file, "token_types.weight")?;
204            if table.rows() != hp.n_token_types || table.cols() != hp.n_embd {
205                return Err(refuse(&format!(
206                    "token_types.weight is {}x{}, expected {}x{}",
207                    table.rows(),
208                    table.cols(),
209                    hp.n_token_types,
210                    hp.n_embd
211                )));
212            }
213            Some((0..table.rows()).map(|r| table.dequant_row(r)).collect())
214        }
215        None => None,
216    };
217
218    let tok_norm_w = load_f32_vec(file, "token_embd_norm.weight")?;
219    let tok_norm_b = load_f32_vec(file, "token_embd_norm.bias")?;
220
221    let mut layers = Vec::with_capacity(hp.n_layer);
222    for l in 0..hp.n_layer {
223        let b = format!("blk.{l}");
224        reject_tensor(
225            file,
226            &format!("{b}.attn_qkv.weight"),
227            "a fused QKV projection; this graph reads separate attn_q/attn_k/attn_v",
228        )?;
229        reject_tensor(
230            file,
231            &format!("{b}.attn_q_norm.weight"),
232            "per-projection QK normalization (jina-bert-v3 / neo-bert), not implemented",
233        )?;
234        reject_tensor(
235            file,
236            &format!("{b}.attn_k_norm.weight"),
237            "per-projection QK normalization (jina-bert-v3 / neo-bert), not implemented",
238        )?;
239        reject_tensor(
240            file,
241            &format!("{b}.attn_norm_2.weight"),
242            "jina-bert-v2's second attention norm, not implemented",
243        )?;
244        reject_tensor(
245            file,
246            &format!("{b}.ffn_gate.weight"),
247            "a gated FFN (nomic-bert / jina-bert-v2 GEGLU); this graph runs a plain GELU MLP",
248        )?;
249        reject_tensor(
250            file,
251            &format!("{b}.ffn_up_exps.weight"),
252            "MoE expert tensors (nomic-bert-moe), not implemented",
253        )?;
254
255        layers.push(BertLayer {
256            wq: load_weight_matrix(file, &format!("{b}.attn_q.weight"))?,
257            bq: load_f32_vec_optional(file, &format!("{b}.attn_q.bias"))?,
258            wk: load_weight_matrix(file, &format!("{b}.attn_k.weight"))?,
259            bk: load_f32_vec_optional(file, &format!("{b}.attn_k.bias"))?,
260            wv: load_weight_matrix(file, &format!("{b}.attn_v.weight"))?,
261            bv: load_f32_vec_optional(file, &format!("{b}.attn_v.bias"))?,
262            wo: load_weight_matrix(file, &format!("{b}.attn_output.weight"))?,
263            bo: load_f32_vec_optional(file, &format!("{b}.attn_output.bias"))?,
264            attn_out_norm_w: load_f32_vec(file, &format!("{b}.attn_output_norm.weight"))?,
265            attn_out_norm_b: load_f32_vec(file, &format!("{b}.attn_output_norm.bias"))?,
266            ffn_up: load_weight_matrix(file, &format!("{b}.ffn_up.weight"))?,
267            ffn_up_b: load_f32_vec_optional(file, &format!("{b}.ffn_up.bias"))?,
268            ffn_down: load_weight_matrix(file, &format!("{b}.ffn_down.weight"))?,
269            ffn_down_b: load_f32_vec_optional(file, &format!("{b}.ffn_down.bias"))?,
270            layer_out_norm_w: load_f32_vec(file, &format!("{b}.layer_output_norm.weight"))?,
271            layer_out_norm_b: load_f32_vec(file, &format!("{b}.layer_output_norm.bias"))?,
272        });
273    }
274
275    let kv_dim = hp.n_head_kv * hp.head_dim();
276    for (l, layer) in layers.iter().enumerate() {
277        for (name, m, rows) in [
278            ("attn_q", &layer.wq, hp.n_embd),
279            ("attn_k", &layer.wk, kv_dim),
280            ("attn_v", &layer.wv, kv_dim),
281            ("attn_output", &layer.wo, hp.n_embd),
282            ("ffn_up", &layer.ffn_up, hp.n_ff),
283            ("ffn_down", &layer.ffn_down, hp.n_embd),
284        ] {
285            if m.rows() != rows {
286                return Err(refuse(&format!(
287                    "blk.{l}.{name}.weight has {} output rows, expected {rows}",
288                    m.rows()
289                )));
290            }
291        }
292    }
293
294    assert_every_tensor_consumed(file)?;
295
296    Ok(BertEncoder {
297        hp,
298        tok_embd,
299        type_embd,
300        pos_embd,
301        tok_norm_w,
302        tok_norm_b,
303        layers,
304    })
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310
311    /// Only `bert`. The other eleven encoder rows in the catalog share
312    /// `bert.cpp` upstream, and each of them differs from this graph in
313    /// a way that would load clean and embed wrong.
314    #[test]
315    fn a_non_bert_architecture_is_refused_by_name() {
316        for arch in [
317            "nomic-bert",
318            "nomic-bert-moe",
319            "jina-bert-v2",
320            "jina-bert-v3",
321            "neo-bert",
322            "modern-bert",
323            "llama",
324        ] {
325            let err = check_arch(arch).unwrap_err();
326            assert!(
327                matches!(&err, LoadError::UnsupportedArchitecture(a) if a == arch),
328                "{arch} was not refused: {err}"
329            );
330        }
331        assert!(check_arch(BERT_ARCH).is_ok());
332    }
333}