Skip to main content

uni_xervo/provider/
candle.rs

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