Skip to main content

candle_transformers/models/
nomic_bert.rs

1//! # NomicBERT
2//!
3//! Implementation of the NomicBert architecture used by nomic-embed-text-v1.5.
4//!
5//! Key differences from standard BERT:
6//! - Rotary position embeddings (RoPE) instead of absolute position embeddings
7//! - SwiGLU activation in the feed-forward network
8//! - Fused QKV projection
9//! - No bias in attention and MLP projections (configurable)
10//!
11//! - [Model](https://huggingface.co/nomic-ai/nomic-embed-text-v1.5)
12//! - [Paper](https://arxiv.org/abs/2402.01613)
13
14use super::with_tracing::{layer_norm, linear, linear_no_bias, LayerNorm, Linear};
15use candle::{DType, Device, Result, Tensor, D};
16use candle_nn::{embedding, Embedding, Module, VarBuilder};
17use serde::Deserialize;
18
19// Matches nomic-ai/nomic-embed-text-v1.5 config.json field names.
20#[derive(Debug, Clone, PartialEq, Deserialize)]
21#[serde(default)]
22pub struct Config {
23    pub vocab_size: usize,
24    pub n_embd: usize,
25    pub n_head: usize,
26    pub n_layer: usize,
27    pub n_inner: usize,
28    pub n_positions: usize,
29    pub type_vocab_size: usize,
30    pub layer_norm_epsilon: f64,
31    pub rotary_emb_fraction: f64,
32    pub rotary_emb_base: f64,
33    pub rotary_emb_interleaved: bool,
34    pub qkv_proj_bias: bool,
35    pub mlp_fc1_bias: bool,
36    pub mlp_fc2_bias: bool,
37    pub activation_function: String,
38    pub prenorm: bool,
39    pub model_type: Option<String>,
40}
41
42impl Default for Config {
43    fn default() -> Self {
44        Self {
45            vocab_size: 30528,
46            n_embd: 768,
47            n_head: 12,
48            n_layer: 12,
49            n_inner: 3072,
50            n_positions: 8192,
51            type_vocab_size: 2,
52            layer_norm_epsilon: 1e-12,
53            rotary_emb_fraction: 1.0,
54            rotary_emb_base: 1000.0,
55            rotary_emb_interleaved: false,
56            qkv_proj_bias: false,
57            mlp_fc1_bias: false,
58            mlp_fc2_bias: false,
59            activation_function: "swiglu".to_string(),
60            prenorm: false,
61            model_type: Some("nomic_bert".to_string()),
62        }
63    }
64}
65
66impl Config {
67    fn head_dim(&self) -> usize {
68        self.n_embd / self.n_head
69    }
70
71    fn rotary_emb_dim(&self) -> usize {
72        (self.head_dim() as f64 * self.rotary_emb_fraction) as usize
73    }
74}
75
76// Precomputed cos/sin tables for rotary position embeddings.
77// Shared across all attention layers since they use identical frequencies.
78#[derive(Clone, Debug)]
79struct RotaryEmbedding {
80    cos: Tensor,
81    sin: Tensor,
82    interleaved: bool,
83}
84
85impl RotaryEmbedding {
86    fn new(
87        dim: usize,
88        max_seq_len: usize,
89        base: f64,
90        interleaved: bool,
91        device: &Device,
92    ) -> Result<Self> {
93        let half_dim = dim / 2;
94        let inv_freq: Vec<f32> = (0..half_dim)
95            .map(|i| 1f32 / (base as f32).powf(2.0 * i as f32 / dim as f32))
96            .collect();
97        let inv_freq = Tensor::new(inv_freq.as_slice(), device)?;
98        let positions = Tensor::arange(0u32, max_seq_len as u32, device)?
99            .to_dtype(DType::F32)?
100            .reshape((max_seq_len, 1))?;
101        let freqs = positions.matmul(&inv_freq.unsqueeze(0)?)?;
102        let cos = freqs.cos()?;
103        let sin = freqs.sin()?;
104        Ok(Self {
105            cos,
106            sin,
107            interleaved,
108        })
109    }
110
111    /// Apply rotary embeddings to x of shape (batch, n_heads, seq_len, head_dim).
112    /// Dispatches to interleaved (GPT-J) or non-interleaved (GPT-NeoX) style
113    /// based on the model config.
114    fn apply(&self, x: &Tensor) -> Result<Tensor> {
115        let cos = self.cos.to_dtype(x.dtype())?;
116        let sin = self.sin.to_dtype(x.dtype())?;
117        if self.interleaved {
118            candle_nn::rotary_emb::rope_i(x, &cos, &sin)
119        } else {
120            candle_nn::rotary_emb::rope(x, &cos, &sin)
121        }
122    }
123}
124
125// Word embeddings + optional token type embeddings.
126// No position embeddings since NomicBert uses rotary embeddings.
127#[derive(Clone, Debug)]
128struct NomicBertEmbeddings {
129    word_embeddings: Embedding,
130    token_type_embeddings: Option<Embedding>,
131    span: tracing::Span,
132}
133
134impl NomicBertEmbeddings {
135    fn new(vb: VarBuilder, config: &Config) -> Result<Self> {
136        let word_embeddings =
137            embedding(config.vocab_size, config.n_embd, vb.pp("word_embeddings"))?;
138        let token_type_embeddings = if config.type_vocab_size > 0 {
139            Some(embedding(
140                config.type_vocab_size,
141                config.n_embd,
142                vb.pp("token_type_embeddings"),
143            )?)
144        } else {
145            None
146        };
147        Ok(Self {
148            word_embeddings,
149            token_type_embeddings,
150            span: tracing::span!(tracing::Level::TRACE, "embeddings"),
151        })
152    }
153
154    fn forward(&self, input_ids: &Tensor, token_type_ids: Option<&Tensor>) -> Result<Tensor> {
155        let _enter = self.span.enter();
156        let embeddings = self.word_embeddings.forward(input_ids)?;
157        if let Some(tte) = &self.token_type_embeddings {
158            let tt_ids = match token_type_ids {
159                Some(ids) => ids.clone(),
160                None => {
161                    let (b, s) = input_ids.dims2()?;
162                    Tensor::zeros((b, s), DType::U32, input_ids.device())?
163                }
164            };
165            let tt_emb = tte.forward(&tt_ids)?;
166            embeddings + tt_emb
167        } else {
168            Ok(embeddings)
169        }
170    }
171}
172
173// Self-attention with fused QKV projection and rotary embeddings.
174#[derive(Clone, Debug)]
175struct NomicBertAttention {
176    wqkv: Linear,
177    out_proj: Linear,
178    num_heads: usize,
179    head_dim: usize,
180    n_embd: usize,
181    span: tracing::Span,
182}
183
184impl NomicBertAttention {
185    fn new(vb: VarBuilder, config: &Config) -> Result<Self> {
186        let wqkv = if config.qkv_proj_bias {
187            linear(config.n_embd, 3 * config.n_embd, vb.pp("Wqkv"))?
188        } else {
189            linear_no_bias(config.n_embd, 3 * config.n_embd, vb.pp("Wqkv"))?
190        };
191
192        let out_proj = if config.qkv_proj_bias {
193            linear(config.n_embd, config.n_embd, vb.pp("out_proj"))?
194        } else {
195            linear_no_bias(config.n_embd, config.n_embd, vb.pp("out_proj"))?
196        };
197
198        Ok(Self {
199            wqkv,
200            out_proj,
201            num_heads: config.n_head,
202            head_dim: config.head_dim(),
203            n_embd: config.n_embd,
204            span: tracing::span!(tracing::Level::TRACE, "attn"),
205        })
206    }
207
208    fn forward(
209        &self,
210        hidden_states: &Tensor,
211        attention_mask: &Tensor,
212        rotary_emb: &RotaryEmbedding,
213    ) -> Result<Tensor> {
214        let _enter = self.span.enter();
215        let (batch_size, seq_len, _) = hidden_states.dims3()?;
216
217        let qkv = self.wqkv.forward(hidden_states)?;
218        let q = qkv.narrow(D::Minus1, 0, self.n_embd)?;
219        let k = qkv.narrow(D::Minus1, self.n_embd, self.n_embd)?;
220        let v = qkv.narrow(D::Minus1, 2 * self.n_embd, self.n_embd)?;
221
222        // Reshape to (batch, seq_len, num_heads, head_dim) then transpose
223        // to (batch, num_heads, seq_len, head_dim) for attention + rope.
224        let q = q
225            .reshape((batch_size, seq_len, self.num_heads, self.head_dim))?
226            .transpose(1, 2)?
227            .contiguous()?;
228        let k = k
229            .reshape((batch_size, seq_len, self.num_heads, self.head_dim))?
230            .transpose(1, 2)?
231            .contiguous()?;
232        let v = v
233            .reshape((batch_size, seq_len, self.num_heads, self.head_dim))?
234            .transpose(1, 2)?;
235
236        let q = rotary_emb.apply(&q)?;
237        let k = rotary_emb.apply(&k)?;
238
239        let scale = (self.head_dim as f64).sqrt();
240        let attn_scores = (q.matmul(&k.t()?)? / scale)?;
241        let attn_scores = attn_scores.broadcast_add(attention_mask)?;
242        let attn_probs = candle_nn::ops::softmax_last_dim(&attn_scores)?;
243
244        let attn_output = attn_probs.matmul(&v.contiguous()?)?;
245        let attn_output = attn_output.transpose(1, 2)?.contiguous()?;
246        let attn_output = attn_output.flatten_from(D::Minus2)?;
247
248        self.out_proj.forward(&attn_output)
249    }
250}
251
252// SwiGLU feed-forward network.
253// Two parallel projections (fc11 for value, fc12 for gate with SiLU),
254// element-wise multiply, then project back.
255#[derive(Clone, Debug)]
256struct NomicBertSwiGLU {
257    fc11: Linear,
258    fc12: Linear,
259    fc2: Linear,
260    span: tracing::Span,
261}
262
263impl NomicBertSwiGLU {
264    fn new(vb: VarBuilder, config: &Config) -> Result<Self> {
265        let (fc11, fc12) = if config.mlp_fc1_bias {
266            (
267                linear(config.n_embd, config.n_inner, vb.pp("fc11"))?,
268                linear(config.n_embd, config.n_inner, vb.pp("fc12"))?,
269            )
270        } else {
271            (
272                linear_no_bias(config.n_embd, config.n_inner, vb.pp("fc11"))?,
273                linear_no_bias(config.n_embd, config.n_inner, vb.pp("fc12"))?,
274            )
275        };
276        let fc2 = if config.mlp_fc2_bias {
277            linear(config.n_inner, config.n_embd, vb.pp("fc2"))?
278        } else {
279            linear_no_bias(config.n_inner, config.n_embd, vb.pp("fc2"))?
280        };
281        Ok(Self {
282            fc11,
283            fc12,
284            fc2,
285            span: tracing::span!(tracing::Level::TRACE, "swiglu"),
286        })
287    }
288}
289
290impl Module for NomicBertSwiGLU {
291    fn forward(&self, xs: &Tensor) -> Result<Tensor> {
292        let _enter = self.span.enter();
293        let y = self.fc11.forward(xs)?;
294        let gate = self.fc12.forward(xs)?.silu()?;
295        self.fc2.forward(&(y * gate)?)
296    }
297}
298
299// Transformer block: attention → norm → MLP → norm (post-norm),
300// or norm → attention → norm → MLP (pre-norm).
301#[derive(Clone, Debug)]
302struct NomicBertBlock {
303    attn: NomicBertAttention,
304    mlp: NomicBertSwiGLU,
305    norm1: LayerNorm,
306    norm2: LayerNorm,
307    prenorm: bool,
308    span: tracing::Span,
309}
310
311impl NomicBertBlock {
312    fn new(vb: VarBuilder, config: &Config) -> Result<Self> {
313        let attn = NomicBertAttention::new(vb.pp("attn"), config)?;
314        let mlp = NomicBertSwiGLU::new(vb.pp("mlp"), config)?;
315        let norm1 = layer_norm(config.n_embd, config.layer_norm_epsilon, vb.pp("norm1"))?;
316        let norm2 = layer_norm(config.n_embd, config.layer_norm_epsilon, vb.pp("norm2"))?;
317        Ok(Self {
318            attn,
319            mlp,
320            norm1,
321            norm2,
322            prenorm: config.prenorm,
323            span: tracing::span!(tracing::Level::TRACE, "block"),
324        })
325    }
326
327    fn forward(
328        &self,
329        hidden_states: &Tensor,
330        attention_mask: &Tensor,
331        rotary_emb: &RotaryEmbedding,
332    ) -> Result<Tensor> {
333        let _enter = self.span.enter();
334        if self.prenorm {
335            let residual = hidden_states;
336            let hidden_states = self.norm1.forward(hidden_states)?;
337            let attn_out = self
338                .attn
339                .forward(&hidden_states, attention_mask, rotary_emb)?;
340            let hidden_states = (residual + attn_out)?;
341
342            let residual = hidden_states.clone();
343            let hidden_states = self.norm2.forward(&hidden_states)?;
344            let mlp_out = self.mlp.forward(&hidden_states)?;
345            residual + mlp_out
346        } else {
347            let attn_out = self
348                .attn
349                .forward(hidden_states, attention_mask, rotary_emb)?;
350            let hidden_states = self.norm1.forward(&(hidden_states + attn_out)?)?;
351            let mlp_out = self.mlp.forward(&hidden_states)?;
352            self.norm2.forward(&(hidden_states + mlp_out)?)
353        }
354    }
355}
356
357#[derive(Clone, Debug)]
358struct NomicBertEncoder {
359    layers: Vec<NomicBertBlock>,
360    rotary_emb: RotaryEmbedding,
361    span: tracing::Span,
362}
363
364impl NomicBertEncoder {
365    fn new(vb: VarBuilder, config: &Config) -> Result<Self> {
366        let layers = (0..config.n_layer)
367            .map(|i| NomicBertBlock::new(vb.pp(format!("layers.{i}")), config))
368            .collect::<Result<Vec<_>>>()?;
369        let rotary_emb = RotaryEmbedding::new(
370            config.rotary_emb_dim(),
371            config.n_positions,
372            config.rotary_emb_base,
373            config.rotary_emb_interleaved,
374            vb.device(),
375        )?;
376        Ok(Self {
377            layers,
378            rotary_emb,
379            span: tracing::span!(tracing::Level::TRACE, "encoder"),
380        })
381    }
382
383    fn forward(&self, hidden_states: &Tensor, attention_mask: &Tensor) -> Result<Tensor> {
384        let _enter = self.span.enter();
385        let mut xs = hidden_states.clone();
386        for layer in &self.layers {
387            xs = layer.forward(&xs, attention_mask, &self.rotary_emb)?;
388        }
389        Ok(xs)
390    }
391}
392
393/// Convert an attention mask from (batch, seq_len) with 1=attend/0=pad
394/// to (batch, 1, 1, seq_len) with 0=attend/-1e4=pad, suitable for
395/// adding to attention scores before softmax.
396fn get_extended_attention_mask(attention_mask: &Tensor, dtype: DType) -> Result<Tensor> {
397    let mask = attention_mask.unsqueeze(1)?.unsqueeze(1)?;
398    let on_true = mask.zeros_like()?.to_dtype(dtype)?;
399    let on_false = Tensor::new(-1e4f32, mask.device())?
400        .to_dtype(dtype)?
401        .broadcast_as(mask.shape())?;
402    mask.where_cond(&on_true, &on_false)
403}
404
405/// NomicBert base model. Returns the final hidden states (token embeddings)
406/// of shape (batch, seq_len, n_embd).
407///
408/// For text embeddings, apply [`mean_pooling`] and [`l2_normalize`] to the output.
409pub struct NomicBertModel {
410    embeddings: NomicBertEmbeddings,
411    emb_ln: LayerNorm,
412    encoder: NomicBertEncoder,
413    pub device: Device,
414    span: tracing::Span,
415}
416
417impl NomicBertModel {
418    pub fn load(vb: VarBuilder, config: &Config) -> Result<Self> {
419        let load_inner = |vb: VarBuilder| -> Result<Self> {
420            let embeddings = NomicBertEmbeddings::new(vb.pp("embeddings"), config)?;
421            let emb_ln = layer_norm(config.n_embd, config.layer_norm_epsilon, vb.pp("emb_ln"))?;
422            let encoder = NomicBertEncoder::new(vb.pp("encoder"), config)?;
423            Ok(Self {
424                embeddings,
425                emb_ln,
426                encoder,
427                device: vb.device().clone(),
428                span: tracing::span!(tracing::Level::TRACE, "nomic-bert"),
429            })
430        };
431
432        // Try without prefix, then with model_type prefix (e.g. "nomic_bert").
433        load_inner(vb.clone()).or_else(|err| {
434            if let Some(model_type) = &config.model_type {
435                load_inner(vb.pp(model_type)).map_err(|_| err)
436            } else {
437                Err(err)
438            }
439        })
440    }
441
442    pub fn forward(
443        &self,
444        input_ids: &Tensor,
445        token_type_ids: Option<&Tensor>,
446        attention_mask: Option<&Tensor>,
447    ) -> Result<Tensor> {
448        let _enter = self.span.enter();
449        let hidden_states = self.embeddings.forward(input_ids, token_type_ids)?;
450        let hidden_states = self.emb_ln.forward(&hidden_states)?;
451
452        let attention_mask = match attention_mask {
453            Some(mask) => mask.clone(),
454            None => input_ids.ones_like()?,
455        };
456        let extended_mask = get_extended_attention_mask(&attention_mask, hidden_states.dtype())?;
457
458        self.encoder.forward(&hidden_states, &extended_mask)
459    }
460}
461
462/// Mean-pool token embeddings using the attention mask.
463///
464/// Takes hidden states of shape (batch, seq_len, hidden_dim) and an attention
465/// mask of shape (batch, seq_len) with 1 for real tokens, 0 for padding.
466/// Returns pooled embeddings of shape (batch, hidden_dim).
467pub fn mean_pooling(hidden_states: &Tensor, attention_mask: &Tensor) -> Result<Tensor> {
468    let (batch, seq_len, hidden_dim) = hidden_states.dims3()?;
469    let mask = attention_mask.to_dtype(hidden_states.dtype())?;
470    let mask_expanded = mask
471        .unsqueeze(2)?
472        .broadcast_as((batch, seq_len, hidden_dim))?;
473    let sum_hidden = (hidden_states * &mask_expanded)?.sum(1)?;
474    let sum_mask = mask
475        .sum(1)?
476        .unsqueeze(1)?
477        .broadcast_as((batch, hidden_dim))?
478        .clamp(1e-9, f64::MAX)?;
479    sum_hidden / sum_mask
480}
481
482/// L2-normalize embeddings to unit length along the last dimension.
483pub fn l2_normalize(x: &Tensor) -> Result<Tensor> {
484    let norm = x.sqr()?.sum_keepdim(D::Minus1)?.sqrt()?;
485    x.broadcast_div(&norm)
486}