Skip to main content

llm_kernel/embedding/
bgem3.rs

1//! BGE-M3 joint (dense + sparse) embedding.
2//!
3//! One forward pass yields both a dense vector and a learned-lexical sparse
4//! vector. That is what makes hybrid retrieval cheap here: no second model and
5//! no separate BM25 index — the sparse side already carries the exact-term
6//! signal (identifiers, statute numbers, rare jargon) that dense vectors blur.
7
8use std::path::PathBuf;
9use std::sync::Mutex;
10
11use fastembed::{Bgem3Embedding, Bgem3InitOptions, Bgem3Model, SparseEmbedding};
12
13use crate::embedding::sparse::SparseVector;
14use crate::error::{KernelError, Result};
15
16/// Dense width of BGE-M3.
17pub const BGEM3_DENSE_DIM: usize = 1024;
18
19/// Vocabulary size of BGE-M3's XLM-RoBERTa tokenizer — the dimension a
20/// pgvector `sparsevec` column must declare for these sparse vectors.
21pub const BGEM3_VOCAB_SIZE: usize = 250_002;
22
23/// Texts handed to the model per call by default. Bounded on purpose; see
24/// [`Bgem3Provider`]'s memory note.
25const DEFAULT_BATCH_CAP: usize = 32;
26
27/// One text's dense + sparse representation from a single BGE-M3 pass.
28#[derive(Debug, Clone)]
29pub struct JointEmbedding {
30    /// Dense vector, [`BGEM3_DENSE_DIM`] wide.
31    pub dense: Vec<f32>,
32    /// Learned-lexical sparse vector over the model vocabulary.
33    pub sparse: SparseVector,
34}
35
36/// Convert fastembed's sparse output into a [`SparseVector`], optionally
37/// pruning to the `top_k` highest-magnitude weights.
38fn sparse_from_fastembed(sp: SparseEmbedding, top_k: Option<usize>) -> Result<SparseVector> {
39    let indices = sp
40        .indices
41        .into_iter()
42        .map(|i| {
43            u32::try_from(i)
44                .map_err(|_| KernelError::Embedding(format!("sparse index {i} exceeds u32")))
45        })
46        .collect::<Result<Vec<u32>>>()?;
47    let sv = SparseVector::new(indices, sp.values).ok_or_else(|| {
48        KernelError::Embedding("BGE-M3 sparse indices/values length mismatch".into())
49    })?;
50    Ok(match top_k {
51        Some(k) => sv.prune_top_k(k),
52        None => sv,
53    })
54}
55
56/// BGE-M3 joint embedder — dense + sparse from one pass.
57///
58/// # Memory
59///
60/// fastembed's BGE-M3 graph always emits a third, ColBERT (multi-vector)
61/// output, and accumulates it for *every* text passed in a single call —
62/// roughly `seq_len × 1024 × 4` bytes each, about 2 MB for a 512-token chunk.
63/// Handing it a million chunks at once would therefore exhaust memory long
64/// before it finished. This provider slices its input into
65/// [`with_batch_cap`](Self::with_batch_cap)-sized runs and drops the ColBERT
66/// tensors as soon as each run is converted, so bulk indexing stays flat.
67///
68/// # Model
69///
70/// fastembed ships BGE-M3 as an int8-quantized ONNX export
71/// (`gpahal/bge-m3-onnx-int8`), not full precision.
72pub struct Bgem3Provider {
73    inner: Mutex<Bgem3Embedding>,
74    batch_cap: usize,
75    sparse_top_k: Option<usize>,
76}
77
78impl Bgem3Provider {
79    /// Load BGE-M3, downloading it on first use.
80    ///
81    /// `cache_dir` overrides the HuggingFace model cache directory.
82    pub fn new(cache_dir: Option<PathBuf>) -> Result<Self> {
83        Self::build(Bgem3InitOptions::new(Bgem3Model::BGEM3Q), cache_dir)
84    }
85
86    /// Load BGE-M3 with an explicit maximum sequence length. Longer inputs are
87    /// truncated; BGE-M3 itself supports up to 8192 tokens.
88    pub fn with_max_length(max_length: usize, cache_dir: Option<PathBuf>) -> Result<Self> {
89        Self::build(
90            Bgem3InitOptions::new(Bgem3Model::BGEM3Q).with_max_length(max_length),
91            cache_dir,
92        )
93    }
94
95    fn build(mut options: Bgem3InitOptions, cache_dir: Option<PathBuf>) -> Result<Self> {
96        if let Some(dir) = cache_dir {
97            options = options.with_cache_dir(dir);
98        }
99        let model = Bgem3Embedding::try_new(options).map_err(KernelError::embedding)?;
100        Ok(Self {
101            inner: Mutex::new(model),
102            batch_cap: DEFAULT_BATCH_CAP,
103            sparse_top_k: None,
104        })
105    }
106
107    /// Cap how many texts are sent to the model per call.
108    ///
109    /// This bounds peak memory: the discarded ColBERT output is accumulated per
110    /// call, so a larger cap trades memory for slightly fewer invocations. A
111    /// cap of 0 is treated as 1.
112    pub fn with_batch_cap(mut self, cap: usize) -> Self {
113        self.batch_cap = cap.max(1);
114        self
115    }
116
117    /// Keep only the `k` highest-magnitude weights of each sparse vector.
118    ///
119    /// Set this when the target store bounds non-zero elements — pgvector
120    /// refuses to build an HNSW index over a `sparsevec` beyond its cap. Off by
121    /// default, since the limit belongs to the backend, not the model.
122    pub fn with_sparse_top_k(mut self, k: usize) -> Self {
123        self.sparse_top_k = Some(k);
124        self
125    }
126
127    /// Dense vector width.
128    pub fn dense_dim(&self) -> usize {
129        BGEM3_DENSE_DIM
130    }
131
132    /// Vocabulary size — the `sparsevec(N)` dimension for the sparse side.
133    pub fn vocab_size(&self) -> usize {
134        BGEM3_VOCAB_SIZE
135    }
136
137    /// Embed `texts`, returning one dense + sparse pair each, in input order.
138    pub fn embed(&self, texts: &[&str]) -> Result<Vec<JointEmbedding>> {
139        let mut out = Vec::with_capacity(texts.len());
140        for run in texts.chunks(self.batch_cap) {
141            let batch = {
142                let mut model = self
143                    .inner
144                    .lock()
145                    .map_err(|e| KernelError::Embedding(format!("lock: {e}")))?;
146                model
147                    .embed(run, Some(self.batch_cap))
148                    .map_err(KernelError::embedding)?
149            };
150            if batch.dense.len() != batch.sparse.len() {
151                return Err(KernelError::Embedding(format!(
152                    "BGE-M3 returned {} dense and {} sparse vectors",
153                    batch.dense.len(),
154                    batch.sparse.len()
155                )));
156            }
157            // `batch.colbert` is never read and is freed with `batch` here.
158            for (dense, sp) in batch.dense.into_iter().zip(batch.sparse) {
159                out.push(JointEmbedding {
160                    dense,
161                    sparse: sparse_from_fastembed(sp, self.sparse_top_k)?,
162                });
163            }
164        }
165        Ok(out)
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    fn fe_sparse(indices: Vec<usize>, values: Vec<f32>) -> SparseEmbedding {
174        SparseEmbedding { indices, values }
175    }
176
177    #[test]
178    fn converts_fastembed_sparse() {
179        let sv = sparse_from_fastembed(fe_sparse(vec![7, 1], vec![0.25, 0.75]), None).unwrap();
180        // Sorted by index, zeros dropped by SparseVector::new.
181        assert_eq!(sv.indices(), &[1, 7]);
182        assert_eq!(sv.values(), &[0.75, 0.25]);
183    }
184
185    #[test]
186    fn prunes_to_top_k_when_requested() {
187        let sv =
188            sparse_from_fastembed(fe_sparse(vec![1, 2, 3], vec![0.1, 0.9, 0.5]), Some(2)).unwrap();
189        assert_eq!(sv.nnz(), 2);
190        assert_eq!(sv.indices(), &[2, 3]);
191    }
192
193    #[test]
194    fn rejects_length_mismatch() {
195        let err = sparse_from_fastembed(fe_sparse(vec![1, 2], vec![0.5]), None);
196        assert!(err.is_err());
197    }
198
199    #[test]
200    fn vocab_and_dense_dims_are_bgem3_shapes() {
201        assert_eq!(BGEM3_DENSE_DIM, 1024);
202        assert_eq!(BGEM3_VOCAB_SIZE, 250_002);
203    }
204}