1use crate::config::EmbeddingConfig;
8use crate::error::MemoryError;
9use std::future::Future;
10use std::hash::{Hash, Hasher};
11use std::pin::Pin;
12
13pub type EmbedFuture<'a> = Pin<Box<dyn Future<Output = Result<Vec<f32>, MemoryError>> + Send + 'a>>;
15
16pub type EmbedBatchFuture<'a> =
18 Pin<Box<dyn Future<Output = Result<Vec<Vec<f32>>, MemoryError>> + Send + 'a>>;
19
20pub trait Embedder: Send + Sync {
24 fn embed<'a>(&'a self, text: &'a str) -> EmbedFuture<'a>;
26
27 fn embed_batch<'a>(&'a self, texts: Vec<String>) -> EmbedBatchFuture<'a>;
31
32 fn model_name(&self) -> &str;
34
35 fn dimensions(&self) -> usize;
37}
38
39pub struct OllamaEmbedder {
43 client: reqwest::Client,
44 base_url: String,
45 model: String,
46 dimensions: usize,
47 batch_size: usize,
48}
49
50impl OllamaEmbedder {
51 pub fn try_new(config: &EmbeddingConfig) -> Result<Self, MemoryError> {
56 let client = reqwest::Client::builder()
57 .timeout(std::time::Duration::from_secs(config.timeout_secs))
58 .build()
59 .map_err(|e| {
60 MemoryError::EmbedderUnavailable(format!("failed to build HTTP client: {e}"))
61 })?;
62
63 Ok(Self {
64 client,
65 base_url: config.ollama_url.trim_end_matches('/').to_string(),
66 model: config.model.clone(),
67 dimensions: config.dimensions,
68 batch_size: config.batch_size,
69 })
70 }
71
72 }
74
75impl Embedder for OllamaEmbedder {
76 fn embed<'a>(&'a self, text: &'a str) -> EmbedFuture<'a> {
77 Box::pin(async move {
78 let mut results = self.embed_batch(vec![text.to_string()]).await?;
79 results.pop().ok_or_else(|| {
80 MemoryError::Other("Ollama returned empty embeddings for single text".to_string())
81 })
82 })
83 }
84
85 fn embed_batch<'a>(&'a self, texts: Vec<String>) -> EmbedBatchFuture<'a> {
86 Box::pin(async move {
87 let mut all_embeddings = Vec::with_capacity(texts.len());
88
89 for batch in texts.chunks(self.batch_size) {
90 let input: Vec<&str> = batch.iter().map(|s| s.as_str()).collect();
91 let body = serde_json::json!({
92 "model": self.model,
93 "input": input
94 });
95
96 let url = format!("{}/api/embed", self.base_url);
97 let response = self
98 .client
99 .post(&url)
100 .json(&body)
101 .send()
102 .await
103 .map_err(|e| {
104 if e.is_connect() {
105 MemoryError::EmbedderUnavailable(format!(
106 "Ollama not running at {}",
107 self.base_url
108 ))
109 } else if e.is_timeout() {
110 MemoryError::EmbedderUnavailable(format!(
111 "Ollama embedding timed out: {}",
112 e
113 ))
114 } else {
115 MemoryError::EmbeddingRequest(e)
116 }
117 })?;
118
119 if response.status() == reqwest::StatusCode::NOT_FOUND {
120 return Err(MemoryError::EmbedderUnavailable(format!(
121 "Model '{}' not available in Ollama. Run: ollama pull {}",
122 self.model, self.model
123 )));
124 }
125
126 if !response.status().is_success() {
127 let status = response.status();
128 let body = response
129 .text()
130 .await
131 .map_err(|err| format!("failed to read Ollama error body: {err}"));
132 return Err(format_ollama_http_error(status, body));
133 }
134
135 let resp_body: serde_json::Value = response.json().await?;
136 let batch_embeddings = parse_embedding_response(&resp_body, self.dimensions)?;
137 all_embeddings.extend(batch_embeddings);
138 }
139
140 Ok(all_embeddings)
141 })
142 }
143
144 fn model_name(&self) -> &str {
145 &self.model
146 }
147
148 fn dimensions(&self) -> usize {
149 self.dimensions
150 }
151}
152
153#[doc(hidden)]
154pub fn format_ollama_http_error(
155 status: reqwest::StatusCode,
156 body: Result<String, String>,
157) -> MemoryError {
158 match body {
159 Ok(body) => MemoryError::Other(format!(
160 "Ollama returned HTTP {}: {}",
161 status,
162 &body[..body.len().min(500)]
163 )),
164 Err(err) => MemoryError::Other(format!("Ollama returned HTTP {status}; {err}")),
165 }
166}
167
168#[doc(hidden)]
172pub fn parse_embedding_response(
173 body: &serde_json::Value,
174 expected_dims: usize,
175) -> Result<Vec<Vec<f32>>, MemoryError> {
176 let embeddings = body["embeddings"].as_array().ok_or_else(|| {
177 MemoryError::Other("Ollama response missing 'embeddings' field".to_string())
178 })?;
179
180 let mut result = Vec::with_capacity(embeddings.len());
181 for embedding_val in embeddings {
182 let raw_array = embedding_val
183 .as_array()
184 .ok_or_else(|| MemoryError::Other("Embedding is not an array".to_string()))?;
185
186 let mut embedding = Vec::with_capacity(raw_array.len());
187 for (i, v) in raw_array.iter().enumerate() {
188 let val = v.as_f64().ok_or_else(|| {
189 MemoryError::Other(format!(
190 "Embedding dimension {} contains non-numeric value: {}",
191 i, v
192 ))
193 })?;
194 embedding.push(val as f32);
195 }
196
197 if embedding.len() != expected_dims {
198 return Err(MemoryError::DimensionMismatch {
199 expected: expected_dims,
200 actual: embedding.len(),
201 });
202 }
203
204 result.push(embedding);
205 }
206
207 Ok(result)
208}
209
210pub struct MockEmbedder {
217 dimensions: usize,
218}
219
220impl MockEmbedder {
221 pub fn new(dimensions: usize) -> Self {
223 Self { dimensions }
224 }
225}
226
227impl Embedder for MockEmbedder {
228 fn embed<'a>(&'a self, text: &'a str) -> EmbedFuture<'a> {
229 let embedding = deterministic_embedding(text, self.dimensions);
230 Box::pin(async move { Ok(embedding) })
231 }
232
233 fn embed_batch<'a>(&'a self, texts: Vec<String>) -> EmbedBatchFuture<'a> {
234 let embeddings: Vec<Vec<f32>> = texts
235 .iter()
236 .map(|t| deterministic_embedding(t, self.dimensions))
237 .collect();
238 Box::pin(async move { Ok(embeddings) })
239 }
240
241 fn model_name(&self) -> &str {
242 "mock-embedder"
243 }
244
245 fn dimensions(&self) -> usize {
246 self.dimensions
247 }
248}
249
250fn deterministic_embedding(text: &str, dimensions: usize) -> Vec<f32> {
252 let mut hasher = std::hash::DefaultHasher::new();
253 text.hash(&mut hasher);
254 let mut state = hasher.finish();
255 if state == 0 {
256 state = 1;
257 }
258
259 let mut values = Vec::with_capacity(dimensions);
260 for _ in 0..dimensions {
261 state ^= state << 13;
263 state ^= state >> 7;
264 state ^= state << 17;
265 let val = ((state as f64) / (u64::MAX as f64)) * 2.0 - 1.0;
266 values.push(val as f32);
267 }
268
269 let magnitude: f32 = values.iter().map(|v| v * v).sum::<f32>().sqrt();
271 if magnitude > 0.0 {
272 for v in &mut values {
273 *v /= magnitude;
274 }
275 }
276
277 values
278}
279
280#[cfg(feature = "candle-embedder")]
291pub struct CandleEmbedder {
292 model: candle_transformers::models::nomic_bert::NomicBertModel,
293 tokenizer: tokenizers::Tokenizer,
294 device: candle_core::Device,
295 model_id: String,
296 dimensions: usize,
297 max_seq_len: usize,
298}
299
300#[cfg(feature = "candle-embedder")]
301impl CandleEmbedder {
302 pub fn try_new(config: &EmbeddingConfig) -> Result<Self, MemoryError> {
304 Self::try_new_with_model(
305 "nomic-ai/nomic-embed-text-v1.5",
306 config,
307 )
308 }
309
310 pub fn try_new_with_model(model_id: &str, config: &EmbeddingConfig) -> Result<Self, MemoryError> {
315 let device = candle_core::Device::Cpu;
316 let dimensions = config.dimensions;
317 let max_seq_len = 8192; let (owner, name) = match model_id.split_once('/') {
323 Some((o, n)) => (o, n),
324 None => ("nomic-ai", model_id),
325 };
326
327 let api = hf_hub::HFClientSync::new().map_err(|e| {
328 MemoryError::EmbedderUnavailable(format!("failed to create HF Hub client: {e}"))
329 })?;
330 let repo = api.model(owner, name);
331
332 let config_path = download_hf_file(&repo, "config.json")?;
334 let tokenizer_path = download_hf_file(&repo, "tokenizer.json")?;
335
336 let weights_path = download_hf_file(&repo, "model.safetensors")
338 .or_else(|_| download_hf_file(&repo, "pytorch_model.bin"))?;
339
340 let tokenizer = tokenizers::Tokenizer::from_file(&tokenizer_path).map_err(|e| {
342 MemoryError::EmbedderUnavailable(format!("failed to load tokenizer from {}: {e}", tokenizer_path.display()))
343 })?;
344
345 let config_str = std::fs::read_to_string(&config_path).map_err(|e| {
347 MemoryError::EmbedderUnavailable(format!("failed to read config.json: {e}"))
348 })?;
349 let model_config: candle_transformers::models::nomic_bert::Config =
350 serde_json::from_str(&config_str).map_err(|e| {
351 MemoryError::EmbedderUnavailable(format!("failed to parse model config: {e}"))
352 })?;
353
354 if model_config.n_embd != dimensions {
356 return Err(MemoryError::DimensionMismatch {
357 expected: dimensions,
358 actual: model_config.n_embd,
359 });
360 }
361
362 let dtype = candle_core::DType::F32;
364 let weights_bytes = std::fs::read(&weights_path).map_err(|e| {
367 MemoryError::EmbedderUnavailable(format!("failed to read weights file {}: {e}", weights_path.display()))
368 })?;
369 let vb = candle_nn::VarBuilder::from_buffered_safetensors(weights_bytes, dtype, &device)
370 .map_err(|e| {
371 MemoryError::EmbedderUnavailable(format!("failed to load model weights: {e}"))
372 })?;
373
374 let model = candle_transformers::models::nomic_bert::NomicBertModel::load(vb, &model_config)
375 .map_err(|e| {
376 MemoryError::EmbedderUnavailable(format!("failed to build NomicBert model: {e}"))
377 })?;
378
379 Ok(Self {
380 model,
381 tokenizer,
382 device,
383 model_id: model_id.to_string(),
384 dimensions,
385 max_seq_len,
386 })
387 }
388
389 fn embed_batch_sync(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, MemoryError> {
391 use candle_core::Tensor;
392 use candle_transformers::models::nomic_bert::{mean_pooling, l2_normalize};
393
394 let mut all_embeddings = Vec::with_capacity(texts.len());
395
396 for text in texts {
398 let prefixed = format!("search_document: {text}");
408
409 let encoding = self.tokenizer.encode(prefixed.as_str(), true).map_err(|e| {
410 MemoryError::Other(format!("tokenizer error: {e}"))
411 })?;
412
413 let input_ids = encoding.get_ids();
414 let attention_mask = encoding.get_attention_mask();
415
416 let seq_len = input_ids.len().min(self.max_seq_len);
418 let input_ids = &input_ids[..seq_len];
419 let attention_mask = &attention_mask[..seq_len];
420
421 let input_ids_tensor = Tensor::new(input_ids, &self.device)?
422 .unsqueeze(0)?; let attention_mask_tensor = Tensor::new(attention_mask, &self.device)?
424 .unsqueeze(0)?; let token_type_ids = input_ids_tensor.zeros_like()?;
429 let hidden_states = self.model.forward(
430 &input_ids_tensor,
431 Some(&token_type_ids),
432 Some(&attention_mask_tensor),
433 )?;
434
435 let pooled = mean_pooling(&hidden_states, &attention_mask_tensor)?;
437 let normalized = l2_normalize(&pooled)?;
438
439 let embedding_vec = normalized.to_vec2::<f32>()?;
441 let embedding = embedding_vec.into_iter().next().ok_or_else(|| {
442 MemoryError::Other("model returned empty embedding".to_string())
443 })?;
444
445 if embedding.len() != self.dimensions {
446 return Err(MemoryError::DimensionMismatch {
447 expected: self.dimensions,
448 actual: embedding.len(),
449 });
450 }
451
452 all_embeddings.push(embedding);
453 }
454
455 Ok(all_embeddings)
456 }
457}
458
459#[cfg(feature = "candle-embedder")]
462fn download_hf_file(
463 repo: &hf_hub::HFRepositorySync<hf_hub::repository::RepoTypeModel>,
464 filename: &str,
465) -> Result<std::path::PathBuf, MemoryError> {
466 repo.download_file()
467 .filename(filename.to_string())
468 .send()
469 .map_err(|e| {
470 MemoryError::EmbedderUnavailable(format!("failed to download '{filename}': {e}"))
471 })
472}
473
474#[cfg(feature = "candle-embedder")]
475impl Embedder for CandleEmbedder {
476 fn embed<'a>(&'a self, text: &'a str) -> EmbedFuture<'a> {
477 let result = self.embed_batch_sync(&[text.to_string()]);
478 Box::pin(async move {
479 let mut results = result?;
480 results.pop().ok_or_else(|| {
481 MemoryError::Other("Candle embedder returned empty results".to_string())
482 })
483 })
484 }
485
486 fn embed_batch<'a>(&'a self, texts: Vec<String>) -> EmbedBatchFuture<'a> {
487 let result = self.embed_batch_sync(&texts);
488 Box::pin(async move { result })
489 }
490
491 fn model_name(&self) -> &str {
492 &self.model_id
493 }
494
495 fn dimensions(&self) -> usize {
496 self.dimensions
497 }
498}