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 Ok(Self::Bert)
54 }
55}
56
57#[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 let _ = Api::new().map_err(|e| RuntimeError::Load(e.to_string()))?;
113 Ok(())
114 }
115}
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
119pub enum CandleTextModel {
120 #[default]
122 AllMiniLmL6V2,
123 BgeSmallEnV15,
125 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 "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
179pub 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 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 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 return Err(RuntimeError::InferenceError(
409 "Gemma embedding not fully implemented (requires hidden state access)"
410 .to_string(),
411 ));
412 }
413 };
414
415 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}