Skip to main content

uni_xervo/provider/
candle.rs

1use crate::api::{ModelAliasSpec, ModelTask};
2use crate::error::{Result, RuntimeError};
3use crate::hf::HfModelRepo;
4use crate::traits::{
5    EmbedResult, EmbeddingModel, LoadedModelHandle, ModelProvider, ProviderCapabilities,
6    ProviderHealth,
7};
8use async_trait::async_trait;
9use candle_core::{DType, Device, Module, Tensor};
10use candle_nn::VarBuilder;
11use candle_transformers::models::bert::{BertModel, Config as BertConfig, DTYPE};
12use candle_transformers::models::gemma::{Config as GemmaConfig, Model as GemmaModel};
13use candle_transformers::models::jina_bert::{
14    BertModel as JinaBertModel, Config as JinaBertConfig,
15};
16use hf_hub::HFClient;
17use serde::Deserialize;
18use std::path::PathBuf;
19use std::sync::Arc;
20use tokenizers::{PaddingParams, PaddingStrategy, Tokenizer, TruncationParams};
21use tokio::sync::Mutex;
22
23#[derive(Deserialize, Debug)]
24struct BaseConfig {
25    architectures: Option<Vec<String>>,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq)]
29enum ModelArchitecture {
30    Bert,
31    JinaBert,
32    Gemma,
33}
34
35impl ModelArchitecture {
36    fn from_config(config: &BaseConfig) -> Result<Self> {
37        if let Some(archs) = &config.architectures
38            && let Some(arch) = archs.first()
39        {
40            return match arch.as_str() {
41                "BertModel" | "BertForMaskedLM" => Ok(Self::Bert),
42                "JinaBertModel" | "JinaBertForMaskedLM" => Ok(Self::JinaBert),
43                "GemmaModel" | "GemmaForCausalLM" => Ok(Self::Gemma),
44                _ => Err(RuntimeError::Config(format!(
45                    "Unsupported architecture: {}",
46                    arch
47                ))),
48            };
49        }
50        // Default to Bert if unspecified (legacy behavior)
51        Ok(Self::Bert)
52    }
53}
54
55/// Local embedding provider using the [Candle](https://github.com/huggingface/candle)
56/// ML framework.
57///
58/// Supports Bert, JinaBert, and Gemma architectures with lazy weight loading
59/// from HuggingFace Hub and mean-pooled, L2-normalized embeddings.
60#[derive(Default)]
61pub struct LocalCandleProvider;
62
63impl LocalCandleProvider {
64    pub fn new() -> Self {
65        Self
66    }
67}
68
69#[async_trait]
70impl ModelProvider for LocalCandleProvider {
71    fn provider_id(&self) -> &'static str {
72        "local/candle"
73    }
74
75    fn capabilities(&self) -> ProviderCapabilities {
76        ProviderCapabilities {
77            supported_tasks: vec![ModelTask::Embed],
78        }
79    }
80
81    async fn load(&self, spec: &ModelAliasSpec) -> Result<LoadedModelHandle> {
82        if spec.task != ModelTask::Embed {
83            return Err(RuntimeError::CapabilityMismatch(format!(
84                "Candle provider does not support task {:?}",
85                spec.task
86            )));
87        }
88
89        let model_type = CandleTextModel::from_name(&spec.model_id).ok_or_else(|| {
90            RuntimeError::Config(format!("Unsupported Candle model: {}", spec.model_id))
91        })?;
92
93        let cache_dir =
94            crate::cache::resolve_cache_dir("candle", model_type.model_id(), &spec.options);
95
96        tracing::info!(model = ?model_type, "Initializing Candle model");
97        let model = CandleEmbeddingModel::new(model_type, spec.revision.clone(), cache_dir);
98
99        let handle: Arc<dyn EmbeddingModel> = Arc::new(model);
100        Ok(Arc::new(handle) as LoadedModelHandle)
101    }
102
103    async fn health(&self) -> ProviderHealth {
104        ProviderHealth::Healthy
105    }
106
107    async fn warmup(&self) -> Result<()> {
108        tracing::info!("Warming up LocalCandleProvider");
109        // Pre-initialize the HF client to warm up network/cache.
110        let _ = HFClient::new().map_err(|e| RuntimeError::Load(e.to_string()))?;
111        Ok(())
112    }
113}
114
115/// Supported text embedding models.
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
117pub enum CandleTextModel {
118    /// all-MiniLM-L6-v2: 384 dims, fastest, English-optimized
119    #[default]
120    AllMiniLmL6V2,
121    /// BGE-small-en-v1.5: 384 dims, high quality English
122    BgeSmallEnV15,
123    /// BGE-base-en-v1.5: 768 dimensions, higher quality English
124    BgeBaseEnV15,
125}
126
127impl CandleTextModel {
128    pub fn model_id(&self) -> &'static str {
129        match self {
130            Self::AllMiniLmL6V2 => "sentence-transformers/all-MiniLM-L6-v2",
131            Self::BgeSmallEnV15 => "BAAI/bge-small-en-v1.5",
132            Self::BgeBaseEnV15 => "BAAI/bge-base-en-v1.5",
133        }
134    }
135
136    pub fn dimensions(&self) -> u32 {
137        match self {
138            Self::AllMiniLmL6V2 | Self::BgeSmallEnV15 => 384,
139            Self::BgeBaseEnV15 => 768,
140        }
141    }
142
143    pub fn name(&self) -> &'static str {
144        match self {
145            Self::AllMiniLmL6V2 => "all-MiniLM-L6-v2",
146            Self::BgeSmallEnV15 => "bge-small-en-v1.5",
147            Self::BgeBaseEnV15 => "bge-base-en-v1.5",
148        }
149    }
150
151    pub fn from_name(name: &str) -> Option<Self> {
152        match name.to_lowercase().as_str() {
153            "all-minilm-l6-v2" | "allminilml6v2" | "default" => Some(Self::AllMiniLmL6V2),
154            "bge-small-en-v1.5" | "bgesmallenv15" => Some(Self::BgeSmallEnV15),
155            "bge-base-en-v1.5" | "bgebaseenv15" => Some(Self::BgeBaseEnV15),
156            // Map known HF IDs to enum
157            "sentence-transformers/all-minilm-l6-v2" => Some(Self::AllMiniLmL6V2),
158            "baai/bge-small-en-v1.5" => Some(Self::BgeSmallEnV15),
159            "baai/bge-base-en-v1.5" => Some(Self::BgeBaseEnV15),
160            _ => None,
161        }
162    }
163}
164
165enum InnerModel {
166    Bert(BertModel),
167    JinaBert(JinaBertModel),
168    Gemma(GemmaModel),
169}
170
171struct LoadedModel {
172    model: InnerModel,
173    tokenizer: Tokenizer,
174    device: Device,
175}
176
177/// A lazily-loaded embedding model backed by Candle.
178///
179/// On first [`embed`](crate::traits::EmbeddingModel::embed) call (or explicit
180/// [`warmup`](crate::traits::EmbeddingModel::warmup)), the model weights and
181/// tokenizer are downloaded from HuggingFace Hub and loaded into memory.
182pub struct CandleEmbeddingModel {
183    model_type: CandleTextModel,
184    revision: Option<String>,
185    cache_dir: PathBuf,
186    state: Arc<Mutex<Option<LoadedModel>>>,
187}
188
189impl CandleEmbeddingModel {
190    pub fn new(model_type: CandleTextModel, revision: Option<String>, cache_dir: PathBuf) -> Self {
191        Self {
192            model_type,
193            revision,
194            cache_dir,
195            state: Arc::new(Mutex::new(None)),
196        }
197    }
198
199    async fn ensure_loaded(&self) -> Result<()> {
200        let mut state = self.state.lock().await;
201        if state.is_some() {
202            return Ok(());
203        }
204
205        tracing::info!(
206            model = self.model_type.name(),
207            "Loading Candle embedding model"
208        );
209
210        let api_repo = HfModelRepo::open(
211            &self.cache_dir,
212            self.model_type.model_id(),
213            self.revision.as_deref(),
214        )
215        .map_err(|e| RuntimeError::Load(e.to_string()))?;
216
217        let config_path = api_repo
218            .get("config.json")
219            .await
220            .map_err(|e| RuntimeError::Load(e.to_string()))?;
221
222        let config_contents =
223            std::fs::read_to_string(&config_path).map_err(|e| RuntimeError::Load(e.to_string()))?;
224
225        let base_config: BaseConfig = serde_json::from_str(&config_contents)
226            .map_err(|e| RuntimeError::Load(e.to_string()))?;
227
228        let arch = ModelArchitecture::from_config(&base_config)?;
229        tracing::info!(architecture = ?arch, "Detected model architecture");
230
231        let tokenizer_path = api_repo
232            .get("tokenizer.json")
233            .await
234            .map_err(|e| RuntimeError::Load(e.to_string()))?;
235        let weights_path = api_repo
236            .get("model.safetensors")
237            .await
238            .map_err(|e| RuntimeError::Load(e.to_string()))?;
239
240        let mut tokenizer = Tokenizer::from_file(&tokenizer_path)
241            .map_err(|e| RuntimeError::Load(format!("Failed to load tokenizer: {}", e)))?;
242
243        let padding = PaddingParams {
244            strategy: PaddingStrategy::BatchLongest,
245            ..Default::default()
246        };
247        tokenizer.with_padding(Some(padding));
248
249        // Gemma usually handles truncation differently or defaults are fine.
250        tokenizer
251            .with_truncation(Some(TruncationParams {
252                max_length: 512,
253                ..Default::default()
254            }))
255            .map_err(|e| RuntimeError::Load(format!("Failed to set truncation: {}", e)))?;
256
257        let device = Device::Cpu;
258        let vb = unsafe {
259            VarBuilder::from_mmaped_safetensors(&[weights_path], DTYPE, &device)
260                .map_err(|e| RuntimeError::Load(e.to_string()))?
261        };
262
263        let model = match arch {
264            ModelArchitecture::Bert => {
265                let config: BertConfig = serde_json::from_str(&config_contents)
266                    .map_err(|e| RuntimeError::Load(e.to_string()))?;
267                let model =
268                    BertModel::load(vb, &config).map_err(|e| RuntimeError::Load(e.to_string()))?;
269                InnerModel::Bert(model)
270            }
271            ModelArchitecture::JinaBert => {
272                let config: JinaBertConfig = serde_json::from_str(&config_contents)
273                    .map_err(|e| RuntimeError::Load(e.to_string()))?;
274                let model = JinaBertModel::new(vb, &config)
275                    .map_err(|e| RuntimeError::Load(e.to_string()))?;
276                InnerModel::JinaBert(model)
277            }
278            ModelArchitecture::Gemma => {
279                let config: GemmaConfig = serde_json::from_str(&config_contents)
280                    .map_err(|e| RuntimeError::Load(e.to_string()))?;
281                let model = GemmaModel::new(false, &config, vb)
282                    .map_err(|e| RuntimeError::Load(e.to_string()))?;
283                InnerModel::Gemma(model)
284            }
285        };
286
287        tracing::info!(
288            model = self.model_type.name(),
289            dimensions = self.model_type.dimensions(),
290            "Candle embedding model loaded"
291        );
292
293        *state = Some(LoadedModel {
294            model,
295            tokenizer,
296            device,
297        });
298
299        Ok(())
300    }
301}
302
303#[async_trait]
304impl EmbeddingModel for CandleEmbeddingModel {
305    async fn embed(&self, texts: &[&str]) -> Result<EmbedResult> {
306        self.ensure_loaded().await?;
307
308        let state_guard = self.state.lock().await;
309        let loaded = state_guard
310            .as_ref()
311            .ok_or_else(|| RuntimeError::Load("Model state missing".to_string()))?;
312
313        if texts.is_empty() {
314            return Ok(EmbedResult {
315                vectors: vec![],
316                usage: None,
317            });
318        }
319
320        let encodings = loaded
321            .tokenizer
322            .encode_batch(texts.to_vec(), true)
323            .map_err(|e| RuntimeError::InferenceError(format!("Tokenization failed: {}", e)))?;
324
325        let mut all_input_ids = Vec::new();
326        let mut all_attention_masks = Vec::new();
327        let mut all_token_type_ids = Vec::new();
328
329        for encoding in &encodings {
330            all_input_ids.push(
331                encoding
332                    .get_ids()
333                    .iter()
334                    .map(|&x| x as i64)
335                    .collect::<Vec<_>>(),
336            );
337            all_attention_masks.push(
338                encoding
339                    .get_attention_mask()
340                    .iter()
341                    .map(|&x| x as i64)
342                    .collect::<Vec<_>>(),
343            );
344            all_token_type_ids.push(
345                encoding
346                    .get_type_ids()
347                    .iter()
348                    .map(|&x| x as i64)
349                    .collect::<Vec<_>>(),
350            );
351        }
352
353        let batch_size = texts.len();
354        let seq_len = all_input_ids[0].len();
355
356        let input_ids_flat: Vec<i64> = all_input_ids.into_iter().flatten().collect();
357        let attention_mask_flat: Vec<i64> = all_attention_masks.into_iter().flatten().collect();
358        let token_type_ids_flat: Vec<i64> = all_token_type_ids.into_iter().flatten().collect();
359
360        let input_ids = Tensor::from_vec(input_ids_flat, (batch_size, seq_len), &loaded.device)
361            .map_err(|e| RuntimeError::InferenceError(e.to_string()))?;
362        let attention_mask =
363            Tensor::from_vec(attention_mask_flat, (batch_size, seq_len), &loaded.device)
364                .map_err(|e| RuntimeError::InferenceError(e.to_string()))?;
365        let token_type_ids =
366            Tensor::from_vec(token_type_ids_flat, (batch_size, seq_len), &loaded.device)
367                .map_err(|e| RuntimeError::InferenceError(e.to_string()))?;
368
369        let embeddings = match &loaded.model {
370            InnerModel::Bert(m) => m
371                .forward(&input_ids, &token_type_ids, Some(&attention_mask))
372                .map_err(|e| RuntimeError::InferenceError(e.to_string()))?,
373            InnerModel::JinaBert(m) => m
374                .forward(&input_ids)
375                .map_err(|e| RuntimeError::InferenceError(e.to_string()))?,
376            InnerModel::Gemma(_m) => {
377                // Gemma expects (input_ids, input_positions) usually.
378                // We construct simple positions 0..seq_len
379                // Note: This assumes simple batching without specialized attention masks for Gemma
380                // which might be suboptimal but functional for embedding.
381                let positions = (0..seq_len).map(|i| i as i64).collect::<Vec<_>>();
382                let _positions = Tensor::from_vec(positions, (seq_len,), &loaded.device)
383                    .map_err(|e| RuntimeError::InferenceError(e.to_string()))?
384                    .broadcast_as((batch_size, seq_len))
385                    .map_err(|e| RuntimeError::InferenceError(e.to_string()))?;
386
387                // Gemma forward returns logits? Or hidden states?
388                // Standard candle-transformers Gemma::forward returns logits.
389                // We usually want hidden states.
390                // If the model struct doesn't expose it, we are stuck for Gemma via this provider
391                // without copying the model code.
392                // For now, let's try calling it. If it returns logits (vocab size), we can't use it for embedding easily
393                // without knowing which layer to take (usually hidden states before head).
394                // However, "Embedding Gemma" might NOT have an LM head?
395                // If it's `GemmaForCausalLM`, it has a head.
396                // If we load it as `GemmaModel`, does it include head?
397                // `candle_transformers::models::gemma::Model` usually includes the head.
398                // We'll return an error for now for Gemma until we resolve this.
399                return Err(RuntimeError::InferenceError(
400                    "Gemma embedding not fully implemented (requires hidden state access)"
401                        .to_string(),
402                ));
403            }
404        };
405
406        // Mean pooling
407        let attention_mask_f32 = attention_mask
408            .to_dtype(DType::F32)
409            .map_err(|e| RuntimeError::InferenceError(e.to_string()))?;
410        let mask_expanded = attention_mask_f32
411            .unsqueeze(2)
412            .map_err(|e| RuntimeError::InferenceError(e.to_string()))?;
413        let mask_expanded = mask_expanded
414            .broadcast_as(embeddings.shape())
415            .map_err(|e| RuntimeError::InferenceError(e.to_string()))?;
416
417        let masked_embeddings = embeddings
418            .mul(&mask_expanded)
419            .map_err(|e| RuntimeError::InferenceError(e.to_string()))?;
420        let sum_embeddings = masked_embeddings
421            .sum(1)
422            .map_err(|e| RuntimeError::InferenceError(e.to_string()))?;
423
424        let mask_sum = attention_mask_f32
425            .sum(1)
426            .map_err(|e| RuntimeError::InferenceError(e.to_string()))?
427            .unsqueeze(1)
428            .map_err(|e| RuntimeError::InferenceError(e.to_string()))?;
429
430        let mask_sum = mask_sum
431            .broadcast_as(sum_embeddings.shape())
432            .map_err(|e| RuntimeError::InferenceError(e.to_string()))?;
433        let mask_sum = mask_sum
434            .clamp(1e-9, f64::MAX)
435            .map_err(|e| RuntimeError::InferenceError(e.to_string()))?;
436
437        let mean_embeddings = sum_embeddings
438            .div(&mask_sum)
439            .map_err(|e| RuntimeError::InferenceError(e.to_string()))?;
440
441        let norm = mean_embeddings
442            .sqr()
443            .map_err(|e| RuntimeError::InferenceError(e.to_string()))?
444            .sum_keepdim(1)
445            .map_err(|e| RuntimeError::InferenceError(e.to_string()))?
446            .sqrt()
447            .map_err(|e| RuntimeError::InferenceError(e.to_string()))?
448            .clamp(1e-12, f64::MAX)
449            .map_err(|e| RuntimeError::InferenceError(e.to_string()))?;
450
451        let normalized = mean_embeddings
452            .broadcast_div(&norm)
453            .map_err(|e| RuntimeError::InferenceError(e.to_string()))?;
454
455        let vectors: Vec<Vec<f32>> = normalized
456            .to_vec2()
457            .map_err(|e| RuntimeError::InferenceError(e.to_string()))?;
458
459        Ok(EmbedResult {
460            vectors,
461            usage: None,
462        })
463    }
464
465    fn dimensions(&self) -> u32 {
466        self.model_type.dimensions()
467    }
468
469    async fn warmup(&self) -> Result<()> {
470        self.ensure_loaded().await
471    }
472}
473
474impl crate::traits::ModelInfo for CandleEmbeddingModel {
475    fn model_id(&self) -> &str {
476        self.model_type.model_id()
477    }
478}