1use crate::util::UnwrapPoison;
31use anyhow::{Context, Result, anyhow};
32use candle_core::quantized::{QMatMul, gguf_file};
33use candle_core::{DType, Device, Tensor};
34use candle_nn::{Embedding, Module};
35use futures_util::StreamExt;
36use std::collections::HashMap;
37use std::path::{Path, PathBuf};
38use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
39use std::sync::{OnceLock, RwLock};
40use std::time::Duration;
41use tokenizers::Tokenizer;
42use tracing::{debug, info, warn};
43
44const MAX_SEQ_LEN: usize = 8192;
48
49const ROPE_FREQ_BASE: f32 = 1_000_000.0;
51
52const MODEL_DOWNLOAD_TIMEOUT: Duration = Duration::from_mins(10);
54
55const DEFAULT_PAD_ID: u32 = 128_001;
57
58const MODEL_URL: &str = "https://huggingface.co/jinaai/jina-embeddings-v5-text-nano-retrieval/resolve/main/v5-nano-retrieval-Q4_K_M.gguf";
62
63const MODEL_SHA256: &str = "f50822244ba0c7a348c5455b99bb8a0afd182511e8a816888c5dc65d972e51d5";
65
66const TOKENIZER_URL: &str = "https://huggingface.co/jinaai/jina-embeddings-v5-text-nano-retrieval/resolve/main/tokenizer.json";
68
69const TOKENIZER_SHA256: &str = "98d4a1d32152d6cedf85b5e88f3b205106dca1fe72aaab34e0ac13c238421069";
71
72const STATE_UNINIT: u8 = 0;
80const STATE_LOADING: u8 = 1;
81const STATE_READY: u8 = 2;
82
83static GLOBAL_EMBEDDER: OnceLock<RwLock<Option<Embedder>>> = OnceLock::new();
85
86static STATE: AtomicU8 = AtomicU8::new(STATE_UNINIT);
88
89static DOWNLOAD_SPAWNED: AtomicBool = AtomicBool::new(false);
91
92#[must_use]
94pub fn global_embedder() -> &'static RwLock<Option<Embedder>> {
95 GLOBAL_EMBEDDER.get_or_init(|| RwLock::new(None))
96}
97
98fn ensure_embedder() -> bool {
103 if STATE.load(Ordering::Acquire) == STATE_READY {
105 return true;
106 }
107
108 if STATE.load(Ordering::Acquire) != STATE_UNINIT {
111 return false;
112 }
113
114 if STATE
116 .compare_exchange(
117 STATE_UNINIT,
118 STATE_LOADING,
119 Ordering::AcqRel,
120 Ordering::Acquire,
121 )
122 .is_err()
123 {
124 return false;
125 }
126
127 let Some(models_dir) = models_dir() else {
129 STATE.store(STATE_UNINIT, Ordering::Release);
131 return false;
132 };
133 let model_path = models_dir.join("v5-nano-retrieval-Q4_K_M.gguf");
134 let tokenizer_path = models_dir.join("embed_tokenizer.json");
135
136 std::fs::create_dir_all(&models_dir).ok();
137
138 let cache_loaded = if model_path.exists() && tokenizer_path.exists() {
139 match Embedder::load(&model_path, &tokenizer_path) {
140 Ok(emb) => {
141 *global_embedder().write().unwrap_poison() = Some(emb);
142 STATE.store(STATE_READY, Ordering::Release);
143 true
144 }
145 Err(e) => {
146 warn!(reason = %e, "Failed to load cached embedding model");
147 false
153 }
154 }
155 } else {
156 false
157 };
158
159 if cache_loaded {
160 return true;
161 }
162
163 if !DOWNLOAD_SPAWNED.swap(true, Ordering::AcqRel) {
165 if tokio::runtime::Handle::try_current().is_ok() {
166 tokio::spawn(download_retry_loop());
167 } else {
168 DOWNLOAD_SPAWNED.store(false, Ordering::Release);
177 STATE.store(STATE_UNINIT, Ordering::Release);
178 }
179 }
180
181 false
182}
183
184#[must_use]
198pub fn embed(text: &str, is_query: bool) -> Option<Vec<f32>> {
199 if !ensure_embedder() {
200 return None;
201 }
202
203 let guard = global_embedder().read().unwrap_poison();
204 let emb = guard.as_ref()?;
205 let v = if is_query {
206 emb.embed_queries(&[text]).ok()?
207 } else {
208 emb.embed_documents(&[text]).ok()?
209 };
210 v.into_iter().next()
211}
212
213async fn download_retry_loop() {
220 let models_dir =
221 models_dir().expect("CONFIG storage_root must be set before download_retry_loop runs");
222 std::fs::create_dir_all(&models_dir).ok();
223
224 let model_dest = models_dir.join("v5-nano-retrieval-Q4_K_M.gguf");
225 let tokenizer_dest = models_dir.join("embed_tokenizer.json");
226
227 let client = reqwest::Client::builder()
229 .timeout(MODEL_DOWNLOAD_TIMEOUT)
230 .connect_timeout(Duration::from_secs(30))
231 .build()
232 .expect("Failed to build reqwest::Client for model download");
233
234 let mut delay = Duration::from_mins(1);
235 let max_delay = Duration::from_mins(30); let mut model_has = model_dest.exists() && tokenizer_dest.exists();
240
241 loop {
242 if model_has {
243 if let Ok(emb) = Embedder::load(&model_dest, &tokenizer_dest) {
245 info!("Embedding model loaded successfully (from previously downloaded files)");
246 *global_embedder().write().unwrap_poison() = Some(emb);
247 STATE.store(STATE_READY, Ordering::Release);
248 return;
249 }
250 warn!("Failed to load embedding model from cached files, retrying with backoff");
253 }
254
255 let (model_result, tokenizer_result) = tokio::join!(
257 maybe_download(&client, MODEL_URL, &model_dest, Some(MODEL_SHA256)),
258 maybe_download(
259 &client,
260 TOKENIZER_URL,
261 &tokenizer_dest,
262 Some(TOKENIZER_SHA256)
263 ),
264 );
265
266 if let (Err(e_model), Err(e_tokenizer)) = (&model_result, &tokenizer_result) {
267 warn!(
268 model_error = %e_model,
269 tokenizer_error = %e_tokenizer,
270 retry_after_secs = delay.as_secs(),
271 "Failed to download embedding model files, retrying"
272 );
273 } else if let Err(e) = &model_result {
274 warn!(error = %e, retry_after_secs = delay.as_secs(), "Failed to download embedding model, retrying");
275 } else if let Err(e) = &tokenizer_result {
276 warn!(error = %e, retry_after_secs = delay.as_secs(), "Failed to download tokenizer, retrying");
277 }
278
279 let model_ok = model_result.is_ok();
280 let tokenizer_ok = tokenizer_result.is_ok();
281
282 if model_ok && tokenizer_ok {
283 match Embedder::load(&model_dest, &tokenizer_dest) {
285 Ok(emb) => {
286 info!("Embedding model loaded successfully after download");
287 *global_embedder().write().unwrap_poison() = Some(emb);
288 STATE.store(STATE_READY, Ordering::Release);
289 return;
290 }
291 Err(e) => {
292 warn!(reason = %e, "Failed to load model after download, retrying with backoff (files preserved)");
293 }
296 }
297 } else {
298 if !model_ok {
303 let _ = std::fs::remove_file(model_dest.with_extension("tmp"));
305 }
306 if !tokenizer_ok {
307 let _ = std::fs::remove_file(tokenizer_dest.with_extension("tmp"));
308 }
309 }
310
311 model_has = model_dest.exists() && tokenizer_dest.exists();
313
314 tokio::time::sleep(delay).await;
316 delay = (delay * 2).min(max_delay);
317 }
318}
319
320async fn maybe_download(
323 client: &reqwest::Client,
324 url: &str,
325 dest: &Path,
326 expected_sha256: Option<&str>,
327) -> Result<()> {
328 if dest.exists() {
330 return Ok(());
331 }
332 download_file(client, url, dest, expected_sha256).await
333}
334
335async fn download_file(
337 client: &reqwest::Client,
338 url: &str,
339 dest: &Path,
340 expected_sha256: Option<&str>,
341) -> Result<()> {
342 use sha2::{Digest, Sha256};
343
344 let response = client
345 .get(url)
346 .send()
347 .await
348 .context("Failed to send download request")?;
349
350 let status = response.status();
351 if !status.is_success() {
352 anyhow::bail!("HTTP {status} from {url}");
353 }
354
355 let total_size = response.content_length();
356
357 let tmp_path = dest.with_extension("tmp");
359 let mut file = tokio::fs::File::create(&tmp_path)
360 .await
361 .context("Failed to create temp file")?;
362
363 let mut downloaded: u64 = 0;
364 let mut hasher = expected_sha256.map(|_| Sha256::new());
365 let mut stream = response.bytes_stream();
366 while let Some(chunk) = stream.next().await {
367 let chunk = chunk.context("Download stream error")?;
368 let len = chunk.len() as u64;
369 downloaded += len;
370 if let Some(ref mut h) = hasher {
371 h.update(&chunk);
372 }
373 tokio::io::AsyncWriteExt::write_all(&mut file, &chunk)
374 .await
375 .context("Failed to write download chunk")?;
376 }
377
378 if let Some(expected) = total_size
380 && downloaded != expected
381 {
382 let _ = tokio::fs::remove_file(&tmp_path).await;
383 anyhow::bail!("Download size mismatch: expected {expected} bytes, got {downloaded} bytes");
384 }
385
386 if let Some(expected_hex) = expected_sha256
388 && let Some(hasher) = hasher
389 {
390 let actual_hash = format!("{:x}", hasher.finalize());
391 if actual_hash != expected_hex {
392 let _ = tokio::fs::remove_file(&tmp_path).await;
393 anyhow::bail!("SHA256 mismatch: expected {expected_hex}, got {actual_hash}");
394 }
395 }
396
397 tokio::fs::rename(&tmp_path, dest)
399 .await
400 .context("Failed to rename temp file to final path")?;
401
402 info!(path = %dest.display(), size = downloaded, "Downloaded model file");
403 Ok(())
404}
405
406fn models_dir() -> Option<PathBuf> {
411 crate::config::CONFIG
412 .try_storage_root()
413 .map(|root| root.join("models"))
414}
415
416fn get_meta_u32(metadata: &HashMap<String, gguf_file::Value>, key: &str) -> Result<u32> {
420 metadata
421 .get(key)
422 .ok_or_else(|| anyhow!("Missing metadata key '{key}'"))?
423 .to_u32()
424 .map_err(|e| anyhow!("Failed to read metadata '{key}': {e}"))
425}
426
427fn get_meta_f32(metadata: &HashMap<String, gguf_file::Value>, key: &str) -> Result<f32> {
429 metadata
430 .get(key)
431 .ok_or_else(|| anyhow!("Missing metadata key '{key}'"))?
432 .to_f32()
433 .map_err(|e| anyhow!("Failed to read metadata '{key}': {e}"))
434}
435
436#[derive(Debug)]
440struct Layer {
441 attn_q: QMatMul,
443 attn_k: QMatMul,
445 attn_v: QMatMul,
447 attn_o: QMatMul,
449 attn_norm: Tensor,
451 ffn_gate: QMatMul,
453 ffn_up: QMatMul,
455 ffn_down: QMatMul,
457 ffn_norm: Tensor,
459}
460
461impl Layer {
462 #[allow(clippy::many_single_char_names)]
464 fn forward(
465 &self,
466 x: &Tensor,
467 mask: &Tensor,
468 cos: &Tensor,
469 sin: &Tensor,
470 n_head: usize,
471 head_dim: usize,
472 ) -> Result<Tensor> {
473 let residual = x;
475 let h = candle_nn::ops::rms_norm(x, &self.attn_norm, 1e-5)?;
476
477 let q = self.attn_q.forward(&h)?;
479 let k = self.attn_k.forward(&h)?;
480 let v = self.attn_v.forward(&h)?;
481
482 let h = Layer::apply_attention(&q, &k, &v, mask, cos, sin, n_head, head_dim)?;
484 let h = self.attn_o.forward(&h)?;
485 let h = (h + residual)?;
486
487 let residual = &h;
489 let h = candle_nn::ops::rms_norm(&h, &self.ffn_norm, 1e-5)?;
490
491 let gate = self.ffn_gate.forward(&h)?;
492 let up = self.ffn_up.forward(&h)?;
493 let h = self
494 .ffn_down
495 .forward(&(candle_nn::ops::silu(&gate)? * up)?)?;
496 let h = (h + residual)?;
497
498 Ok(h)
499 }
500
501 #[allow(clippy::too_many_arguments)]
503 fn apply_attention(
504 q: &Tensor,
505 k: &Tensor,
506 v: &Tensor,
507 mask: &Tensor,
508 cos: &Tensor,
509 sin: &Tensor,
510 n_head: usize,
511 head_dim: usize,
512 ) -> Result<Tensor> {
513 let (b_sz, seq_len, n_embd) = q.shape().dims3()?;
514
515 let q = q
517 .reshape((b_sz, seq_len, n_head, head_dim))?
518 .transpose(1, 2)?;
519 let k = k
520 .reshape((b_sz, seq_len, n_head, head_dim))?
521 .transpose(1, 2)?;
522 let v = v
523 .reshape((b_sz, seq_len, n_head, head_dim))?
524 .transpose(1, 2)?
525 .contiguous()?;
526
527 let q = Self::apply_rotary_emb(&q, cos, sin)?;
529 let k = Self::apply_rotary_emb(&k, cos, sin)?;
530
531 #[allow(clippy::cast_precision_loss)]
533 let scale = 1.0_f64 / (head_dim as f64).sqrt();
534 let att = q.matmul(&k.t()?)?;
535 let att = (att * scale)?;
536 let mask = mask.broadcast_as(att.shape())?;
537 let att = (att + mask)?;
538 let att = candle_nn::ops::softmax_last_dim(&att)?;
539 let y = att.matmul(&v)?;
540
541 let y = y.transpose(1, 2)?.reshape((b_sz, seq_len, n_embd))?;
543 Ok(y)
544 }
545
546 fn apply_rotary_emb(x: &Tensor, cos: &Tensor, sin: &Tensor) -> Result<Tensor> {
548 let (_b_sz, _n_head, seq_len, head_dim) = x.shape().dims4()?;
549 let cos = cos.narrow(0, 0, seq_len)?;
550 let sin = sin.narrow(0, 0, seq_len)?;
551
552 let cos = cos.reshape((1, 1, seq_len, head_dim / 2))?;
554 let sin = sin.reshape((1, 1, seq_len, head_dim / 2))?;
555
556 let x_f32 = x.to_dtype(DType::F32)?;
557 let chunks = x_f32.chunk(2, 3)?;
559 let x1 = &chunks[0];
560 let x2 = &chunks[1];
561 let y1 = (x1.broadcast_mul(&cos)? - x2.broadcast_mul(&sin)?)?;
562 let y2 = (x1.broadcast_mul(&sin)? + x2.broadcast_mul(&cos)?)?;
563 let result = Tensor::cat(&[&y1, &y2], 3)?;
564 Ok(result.to_dtype(x.dtype())?)
566 }
567}
568
569pub struct Embedder {
573 tokenizer: Tokenizer,
574 tok_embeddings: Embedding,
575 layers: Vec<Layer>,
576 output_norm: Tensor,
577 cos: Tensor,
578 sin: Tensor,
579 head_dim: usize,
580 n_head: usize,
581 pad_id: u32,
582}
583
584impl Embedder {
585 #[allow(clippy::too_many_lines)]
591 pub fn load(model_path: &Path, tokenizer_path: &Path) -> Result<Self> {
592 let tokenizer = Tokenizer::from_file(tokenizer_path).map_err(|e| {
593 anyhow!(
594 "Failed to load tokenizer from {}: {e}",
595 tokenizer_path.display()
596 )
597 })?;
598
599 let pad_id = tokenizer
602 .token_to_id("<|end_of_text|>")
603 .or_else(|| tokenizer.token_to_id("<|pad|>"))
604 .or_else(|| tokenizer.token_to_id("[PAD]"))
605 .map_or(DEFAULT_PAD_ID, |id| id);
606
607 debug!(pad_id, "Discovered pad token ID from tokenizer");
608
609 let device = Device::Cpu;
610
611 let mut file = std::fs::File::open(model_path)
613 .map_err(|e| anyhow!("Failed to open model file {}: {e}", model_path.display()))?;
614 let content = gguf_file::Content::read(&mut file)
615 .map_err(|e| anyhow!("Failed to read GGUF file: {e}"))?;
616
617 let hidden_size = get_meta_u32(&content.metadata, "eurobert.embedding_length")? as usize;
619 let n_head = get_meta_u32(&content.metadata, "eurobert.attention.head_count")? as usize;
620 let head_dim = get_meta_u32(&content.metadata, "eurobert.attention.value_length")? as usize;
621 let rope_freq_base =
622 get_meta_f32(&content.metadata, "eurobert.rope.freq_base").unwrap_or(ROPE_FREQ_BASE);
623
624 let n_layers = content
626 .tensor_infos
627 .keys()
628 .filter_map(|name| {
629 let name = name.as_str();
630 if name.starts_with("blk.") && name.ends_with(".attn_q.weight") {
631 name.trim_start_matches("blk.")
633 .split('.')
634 .next()?
635 .parse::<usize>()
636 .ok()
637 } else {
638 None
639 }
640 })
641 .max()
642 .map(|max| max + 1)
643 .context("No layer tensors found in GGUF file")?;
644
645 info!(
646 hidden_size,
647 n_head, head_dim, n_layers, rope_freq_base, "Loading EuroBERT embedding model"
648 );
649
650 let tok_embd_qt = content
652 .tensor(&mut file, "token_embd.weight", &device)
653 .context("Failed to load token_embd.weight")?;
654 let tok_embd_f32 = tok_embd_qt
655 .dequantize(&device)
656 .context("Failed to dequantize token_embd.weight")?;
657 let tok_embeddings = Embedding::new(tok_embd_f32, hidden_size);
658
659 let output_norm_qt = content
661 .tensor(&mut file, "output_norm.weight", &device)
662 .context("Failed to load output_norm.weight")?;
663 let output_norm = output_norm_qt
664 .dequantize(&device)
665 .context("Failed to dequantize output_norm.weight")?;
666
667 let mut layers = Vec::with_capacity(n_layers);
669 for i in 0..n_layers {
670 let prefix = format!("blk.{i}");
671
672 let attn_q = QMatMul::from_qtensor(
673 content
674 .tensor(&mut file, &format!("{prefix}.attn_q.weight"), &device)
675 .with_context(|| format!("Failed to load {prefix}.attn_q.weight"))?,
676 )
677 .context("Failed to create QMatMul for attn_q")?;
678
679 let attn_k = QMatMul::from_qtensor(
680 content
681 .tensor(&mut file, &format!("{prefix}.attn_k.weight"), &device)
682 .with_context(|| format!("Failed to load {prefix}.attn_k.weight"))?,
683 )
684 .context("Failed to create QMatMul for attn_k")?;
685
686 let attn_v = QMatMul::from_qtensor(
687 content
688 .tensor(&mut file, &format!("{prefix}.attn_v.weight"), &device)
689 .with_context(|| format!("Failed to load {prefix}.attn_v.weight"))?,
690 )
691 .context("Failed to create QMatMul for attn_v")?;
692
693 let attn_o = QMatMul::from_qtensor(
694 content
695 .tensor(&mut file, &format!("{prefix}.attn_output.weight"), &device)
696 .with_context(|| format!("Failed to load {prefix}.attn_output.weight"))?,
697 )
698 .context("Failed to create QMatMul for attn_o")?;
699
700 let attn_norm = content
701 .tensor(&mut file, &format!("{prefix}.attn_norm.weight"), &device)
702 .with_context(|| format!("Failed to load {prefix}.attn_norm.weight"))?
703 .dequantize(&device)
704 .context("Failed to dequantize attn_norm")?;
705
706 let ffn_gate = QMatMul::from_qtensor(
707 content
708 .tensor(&mut file, &format!("{prefix}.ffn_gate.weight"), &device)
709 .with_context(|| format!("Failed to load {prefix}.ffn_gate.weight"))?,
710 )
711 .context("Failed to create QMatMul for ffn_gate")?;
712
713 let ffn_up = QMatMul::from_qtensor(
714 content
715 .tensor(&mut file, &format!("{prefix}.ffn_up.weight"), &device)
716 .with_context(|| format!("Failed to load {prefix}.ffn_up.weight"))?,
717 )
718 .context("Failed to create QMatMul for ffn_up")?;
719
720 let ffn_down = QMatMul::from_qtensor(
721 content
722 .tensor(&mut file, &format!("{prefix}.ffn_down.weight"), &device)
723 .with_context(|| format!("Failed to load {prefix}.ffn_down.weight"))?,
724 )
725 .context("Failed to create QMatMul for ffn_down")?;
726
727 let ffn_norm = content
728 .tensor(&mut file, &format!("{prefix}.ffn_norm.weight"), &device)
729 .with_context(|| format!("Failed to load {prefix}.ffn_norm.weight"))?
730 .dequantize(&device)
731 .context("Failed to dequantize ffn_norm")?;
732
733 layers.push(Layer {
734 attn_q,
735 attn_k,
736 attn_v,
737 attn_o,
738 attn_norm,
739 ffn_gate,
740 ffn_up,
741 ffn_down,
742 ffn_norm,
743 });
744 }
745
746 let (cos, sin) = precompute_freqs_cis(head_dim, rope_freq_base, &device)?;
748
749 let emb = Self {
751 tokenizer,
752 tok_embeddings,
753 layers,
754 output_norm,
755 cos,
756 sin,
757 head_dim,
758 n_head,
759 pad_id,
760 };
761
762 let v = emb.embed_documents(&["."])?;
764 anyhow::ensure!(
765 !v.is_empty() && !v[0].is_empty(),
766 "Embedder warm-up produced empty output"
767 );
768 anyhow::ensure!(
769 v[0].len() == hidden_size,
770 "Embedder warm-up produced wrong dimension: expected {hidden_size}, got {}",
771 v[0].len()
772 );
773
774 info!("Embedder initialized successfully (hidden_size={hidden_size}, layers={n_layers})");
775 Ok(emb)
776 }
777
778 pub fn embed_queries(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
780 self.embed_prefixed("Query: ", texts)
781 }
782
783 pub fn embed_documents(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
785 self.embed_prefixed("Document: ", texts)
786 }
787
788 fn embed_prefixed(&self, prefix: &str, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
790 let encodings: Vec<_> = texts
792 .iter()
793 .map(|t| {
794 let input = format!("{prefix}{t}");
795 self.tokenizer.encode(input, true)
796 })
797 .collect::<std::result::Result<Vec<_>, _>>()
798 .map_err(|e| anyhow!("Tokenization error: {e}"))?;
799
800 if encodings.is_empty() {
801 anyhow::bail!("Empty input");
802 }
803
804 let max_len = encodings
806 .iter()
807 .map(|e| e.len().min(MAX_SEQ_LEN))
808 .max()
809 .context("Empty encoding")?;
810
811 let batch_size = encodings.len();
812
813 let mut input_ids_vec = vec![i64::from(self.pad_id); batch_size * max_len];
815 let mut attention_mask_vec = vec![0i64; batch_size * max_len];
816
817 for (row, enc) in encodings.iter().enumerate() {
818 let ids = enc.get_ids();
819 let mask = enc.get_attention_mask();
820 let len = ids.len().min(MAX_SEQ_LEN);
821 for col in 0..len {
822 input_ids_vec[row * max_len + col] = i64::from(ids[col]);
823 attention_mask_vec[row * max_len + col] = i64::from(mask[col]);
824 }
825 }
826
827 let input_ids = Tensor::from_vec(input_ids_vec, (batch_size, max_len), &Device::Cpu)?;
828 let attention_mask =
829 Tensor::from_vec(attention_mask_vec, (batch_size, max_len), &Device::Cpu)?;
830
831 let embeddings = self.forward(&input_ids, &attention_mask)?;
833
834 let result = last_token_pool_and_l2_normalize(&embeddings, &attention_mask)?;
836
837 Ok(result)
838 }
839
840 fn forward(&self, input_ids: &Tensor, attention_mask: &Tensor) -> Result<Tensor> {
842 let (_batch_size, _seq_len) = input_ids.shape().dims2()?;
843
844 let mask = build_attn_mask(attention_mask, &Device::Cpu)?;
846
847 let mut h = self.tok_embeddings.forward(input_ids)?;
849 for layer in &self.layers {
853 h = layer.forward(&h, &mask, &self.cos, &self.sin, self.n_head, self.head_dim)?;
854 }
855
856 h = candle_nn::ops::rms_norm(&h, &self.output_norm, 1e-5)?;
858
859 Ok(h)
860 }
861}
862
863#[allow(
867 clippy::cast_precision_loss,
868 clippy::cast_possible_truncation,
869 clippy::cast_lossless
870)]
871fn precompute_freqs_cis(
872 head_dim: usize,
873 freq_base: f32,
874 device: &Device,
875) -> Result<(Tensor, Tensor)> {
876 #[allow(clippy::cast_precision_loss, clippy::cast_lossless)]
877 let theta: Vec<f32> = (0..head_dim)
878 .step_by(2)
879 .map(|i| 1.0_f32 / freq_base.powf(i as f32 / head_dim as f32))
880 .collect();
881
882 let theta = Tensor::from_vec(theta, (head_dim / 2,), device)?;
883 #[allow(clippy::cast_possible_truncation)]
884 let positions = Tensor::arange(0u32, MAX_SEQ_LEN as u32, device)?
885 .to_dtype(DType::F32)?
886 .reshape((MAX_SEQ_LEN, 1))?;
887
888 let idx_theta = positions.matmul(&theta.reshape((1, theta.elem_count()))?)?;
889 let cos = idx_theta.cos()?;
890 let sin = idx_theta.sin()?;
891
892 Ok((cos, sin))
893}
894
895fn build_attn_mask(attention_mask: &Tensor, device: &Device) -> Result<Tensor> {
907 let (batch_size, seq_len) = attention_mask.shape().dims2()?;
908
909 let mask_f32 = attention_mask.to_dtype(DType::F32)?;
912 let mask_a = mask_f32.reshape((batch_size, 1, 1, seq_len))?;
913 let mask_b = mask_f32.reshape((batch_size, 1, seq_len, 1))?;
914 let pairwise = mask_a.broadcast_mul(&mask_b)?;
915 let large_neg = Tensor::new(-1e10_f32, device)?.broadcast_as(pairwise.shape())?;
922 let zero = Tensor::new(0.0_f32, device)?.broadcast_as(pairwise.shape())?;
923 let mask_cond = pairwise.eq(&zero)?;
925 let mask = mask_cond.where_cond(&large_neg, &zero)?;
926
927 Ok(mask)
928}
929
930fn last_token_pool_and_l2_normalize(
937 embeddings: &Tensor,
938 attention_mask: &Tensor,
939) -> Result<Vec<Vec<f32>>> {
940 let (batch_size, _seq_len, hidden_size) = embeddings.shape().dims3()?;
941
942 let mut results = Vec::with_capacity(batch_size);
943
944 let seq_lengths: Vec<i64> = attention_mask.sum(1)?.to_vec1()?;
947
948 for (i, &seq_len) in seq_lengths.iter().enumerate().take(batch_size) {
949 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
950 let last_pos = (seq_len - 1).max(0) as usize;
951
952 let token_emb = embeddings.narrow(0, i, 1)?.narrow(1, last_pos, 1)?;
954 let token_emb = token_emb.reshape(hidden_size)?;
955
956 let norm = token_emb
958 .sqr()?
959 .sum_all()?
960 .sqrt()?
961 .to_scalar::<f32>()?
962 .max(1e-12);
963 let normalized = token_emb.broadcast_div(&Tensor::new(norm, token_emb.device())?)?;
964
965 let vec: Vec<f32> = normalized.to_vec1()?;
966 results.push(vec);
967 }
968
969 Ok(results)
970}
971
972#[cfg(test)]
975mod tests {
976 use super::*;
977 use crate::vector::cosine_similarity;
978
979 fn init_test_config() -> std::path::PathBuf {
982 use std::sync::OnceLock;
983 static CONFIG_INIT: OnceLock<tempfile::TempDir> = OnceLock::new();
984 let tmp = CONFIG_INIT
985 .get_or_init(|| tempfile::TempDir::new().expect("failed to create test temp dir"));
986 let root = tmp.path().to_path_buf();
987 let _ = crate::config::CONFIG.try_set_storage_root(root.clone());
988 root
989 }
990
991 fn test_embedder() -> Option<Embedder> {
1000 if std::env::var("MAHBOT_SKIP_EMBEDDER_TESTS").is_ok() {
1002 return None;
1003 }
1004
1005 let mut candidates = Vec::new();
1007
1008 if let Some(root) = crate::config::CONFIG.try_storage_root() {
1010 candidates.push(root.join("models"));
1011 }
1012
1013 if let Some(home) = std::env::var("HOME").ok().filter(|h| !h.is_empty()) {
1015 let real = std::path::PathBuf::from(&home)
1016 .join(".mahbot")
1017 .join("models");
1018 if !candidates.contains(&real) {
1019 candidates.push(real);
1020 }
1021 }
1022
1023 for models_dir in &candidates {
1025 let model_path = models_dir.join("v5-nano-retrieval-Q4_K_M.gguf");
1026 let tokenizer_path = models_dir.join("embed_tokenizer.json");
1027
1028 if model_path.exists() && tokenizer_path.exists() {
1029 match Embedder::load(&model_path, &tokenizer_path) {
1030 Ok(emb) => return Some(emb),
1031 Err(e) => {
1032 eprintln!("WARNING: Failed to load test embedder: {e}");
1033 return None;
1034 }
1035 }
1036 }
1037 }
1038
1039 let last_candidate = candidates.last().map(|p| p.display().to_string());
1041 eprintln!(
1042 "WARNING: Model files not found. Looked in: {}. \
1043 Set MAHBOT_SKIP_EMBEDDER_TESTS=1 to suppress this warning.",
1044 last_candidate.as_deref().unwrap_or("<none>")
1045 );
1046 None
1047 }
1048
1049 fn reset_global_state() {
1051 *global_embedder().write().unwrap_poison() = None;
1052 STATE.store(STATE_UNINIT, Ordering::Release);
1053 DOWNLOAD_SPAWNED.store(false, Ordering::Release);
1054 }
1055
1056 #[test]
1057 fn test_embedder_graceful_degradation() {
1058 let _root = init_test_config();
1060 reset_global_state();
1061
1062 let result = embed("test", false);
1064 assert!(
1065 result.is_none(),
1066 "embed() should return None when model not available"
1067 );
1068
1069 let guard = global_embedder().read().unwrap_poison();
1071 assert!(guard.is_none(), "global embedder should remain None");
1072 }
1073
1074 #[test]
1075 fn test_embedder_init() {
1076 let Some(emb) = test_embedder() else {
1077 return; };
1079 let v = emb.embed_documents(&["hello world"]).unwrap();
1080 assert_eq!(v.len(), 1);
1081 assert_eq!(v[0].len(), 768);
1083 let norm: f32 = v[0].iter().map(|x| x * x).sum::<f32>().sqrt();
1085 assert!(
1086 (norm - 1.0).abs() < 1e-5,
1087 "expected unit vector, got norm={norm}"
1088 );
1089 }
1090
1091 #[test]
1092 fn test_embed_documents() {
1093 let Some(emb) = test_embedder() else { return };
1094 let docs = &["first document", "second document about something"];
1095 let v = emb.embed_documents(docs).unwrap();
1096 assert_eq!(v.len(), 2);
1097 for vec in &v {
1098 assert_eq!(vec.len(), 768);
1099 let norm: f32 = vec.iter().map(|x| x * x).sum::<f32>().sqrt();
1100 assert!(
1101 (norm - 1.0).abs() < 1e-5,
1102 "expected unit vector, got norm={norm}"
1103 );
1104 }
1105 }
1106
1107 #[test]
1108 fn test_embed_queries() {
1109 let Some(emb) = test_embedder() else { return };
1110 let v = emb.embed_queries(&["what is rust?"]).unwrap();
1111 assert_eq!(v.len(), 1);
1112 assert_eq!(v[0].len(), 768);
1113 }
1114
1115 #[test]
1116 fn test_similar_embeddings_are_similar() {
1117 let Some(emb) = test_embedder() else { return };
1118 let v = emb
1119 .embed_documents(&[
1120 "rust programming language",
1121 "the rust programming language",
1122 "python programming language",
1123 ])
1124 .unwrap();
1125 let sim_01 = cosine_similarity(&v[0], &v[1]);
1126 let sim_02 = cosine_similarity(&v[0], &v[2]);
1127 assert!(
1128 sim_01 > sim_02,
1129 "rust/rust ({sim_01}) should be more similar than rust/python ({sim_02})"
1130 );
1131 }
1132
1133 #[test]
1134 fn test_empty_input_fails() {
1135 let Some(emb) = test_embedder() else { return };
1136 let result = emb.embed_documents(&[]);
1137 assert!(result.is_err(), "empty input should produce an error");
1138 }
1139}