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 Ok(Self::Bert)
52 }
53}
54
55#[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 let _ = HFClient::new().map_err(|e| RuntimeError::Load(e.to_string()))?;
111 Ok(())
112 }
113}
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
117pub enum CandleTextModel {
118 #[default]
120 AllMiniLmL6V2,
121 BgeSmallEnV15,
123 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 "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
177pub 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 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 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 return Err(RuntimeError::InferenceError(
400 "Gemma embedding not fully implemented (requires hidden state access)"
401 .to_string(),
402 ));
403 }
404 };
405
406 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}