1use std::collections::HashMap;
40use std::path::{Path, PathBuf};
41use std::sync::{Arc, Mutex, OnceLock};
42use std::time::{Duration, Instant};
43
44use candle_core::{DType, Device, IndexOp, Tensor};
45use candle_nn::VarBuilder;
46use candle_transformers::models::bert::{BertModel, Config};
47use hf_hub::api::sync::{ApiBuilder, ApiRepo};
48use hf_hub::{Repo, RepoType};
49use serde::Deserialize;
50use tokenizers::{Tokenizer, TruncationDirection, TruncationParams, TruncationStrategy};
51
52use crate::embedding_config::{
53 DEFAULT_REPO, DEFAULT_REVISION, EmbeddingModel, LocalContentIdentity, OLLAMA_DEFAULT_URL,
54 Pooling, endpoint_fingerprint, fingerprint_suffix, huggingface_fingerprint,
55 local_content_fingerprint, local_fingerprint, parse_pooling_config, resolve_local_model_files,
56 stamp_local_hash_paths,
57};
58use crate::trace::{EmbedderLoadStatus, TraceEvent, TraceSink};
59
60const ENDPOINT_TIMEOUT_SECS: u64 = 30;
63
64const ENDPOINT_BATCH_SIZE: usize = 64;
66
67const ENDPOINT_RESPONSE_LIMIT_BYTES: u64 = 64 * 1024 * 1024;
69
70const DEFAULT_SLOW_LOAD_MS: u64 = 5_000;
73
74const SLOW_LOAD_REASON: &str = "embedding model load was slow — this machine may be underpowered \
76 for in-process CPU inference; expect slow embedding builds and queries";
77
78#[derive(Debug, Clone)]
82pub enum EmbedderError {
83 Download {
86 model: String,
88 source: String,
90 },
91 CacheUnwritable {
94 source: String,
96 },
97 Load {
100 model: String,
102 source: String,
104 },
105 Inference {
107 source: String,
109 },
110 EmbeddingsNotBuilt,
114 DimensionMismatch {
119 expected: usize,
121 got: usize,
123 model: String,
125 },
126 ModelMismatch {
130 built: String,
132 active: String,
134 },
135 Config {
139 message: String,
141 },
142 NotCached {
146 model: String,
148 revision: Option<String>,
150 },
151}
152
153impl EmbedderError {
154 fn hint(&self) -> &'static str {
156 match self {
157 EmbedderError::Download { .. } => {
158 "check source availability, connectivity, and the configured model identifier"
159 }
160 EmbedderError::CacheUnwritable { .. } => {
161 "check ~/.cache/huggingface permissions and free disk space (or set HF_HOME)"
162 }
163 EmbedderError::Load { .. } => {
164 "check that model files and configuration are present, readable, and compatible"
165 }
166 EmbedderError::Inference { .. } => {
167 "check model input/configuration or the endpoint response and retry"
168 }
169 EmbedderError::EmbeddingsNotBuilt => {
170 "embed the corpus before running a semantic/hybrid search"
171 }
172 EmbedderError::DimensionMismatch { .. } => {
173 "the configured model changed; re-embed the corpus with the new model"
174 }
175 EmbedderError::ModelMismatch { .. } => {
176 "the configured model changed; re-embed the corpus with the new model"
177 }
178 EmbedderError::Config { .. } => {
179 "give exactly one embedding source (a model id / path / url) with its required fields"
180 }
181 EmbedderError::NotCached { .. } => {
182 "Ratel auto-downloads only the default model; pre-download this one, pass download=true, or use a local path / endpoint"
183 }
184 }
185 }
186}
187
188impl std::fmt::Display for EmbedderError {
189 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
190 let hint = self.hint();
191 match self {
192 EmbedderError::Download { model, source } => write!(
193 f,
194 "failed to download embedding model {model}: {source} (hint: {hint})"
195 ),
196 EmbedderError::CacheUnwritable { source } => write!(
197 f,
198 "embedding model cache is not writable: {source} (hint: {hint})"
199 ),
200 EmbedderError::Load { model, source } => write!(
201 f,
202 "failed to load embedding model {model}: {source} (hint: {hint})"
203 ),
204 EmbedderError::Inference { source } => {
205 write!(f, "embedding failed: {source} (hint: {hint})")
206 }
207 EmbedderError::DimensionMismatch {
208 expected,
209 got,
210 model,
211 } => write!(
212 f,
213 "embedding dimension mismatch for {model}: expected {expected}, got {got} (hint: {hint})"
214 ),
215 EmbedderError::ModelMismatch { built, active } => write!(
216 f,
217 "embedding model mismatch: cache was built with {built}, active model is {active} (hint: {hint})"
218 ),
219 EmbedderError::Config { message } => write!(f, "{message} (hint: {hint})"),
220 EmbedderError::NotCached { model, revision } => {
221 let rev = revision
222 .as_deref()
223 .map(|r| format!(" --revision {r}"))
224 .unwrap_or_default();
225 write!(
226 f,
227 "embedding model {model} is not in the local HuggingFace cache — download it \
228 first: `huggingface-cli download {model}{rev}` (hint: {hint})"
229 )
230 }
231 EmbedderError::EmbeddingsNotBuilt => {
232 write!(
233 f,
234 "embeddings are not computed for semantic search (hint: {hint})"
235 )
236 }
237 }
238 }
239}
240
241impl std::error::Error for EmbedderError {}
242
243pub(crate) struct Embedded<T> {
247 pub(crate) value: T,
248 pub(crate) fingerprint: String,
249}
250
251pub(crate) trait Embedder: Send + Sync {
255 fn embed_doc(&self, text: &str) -> Result<Vec<f32>, EmbedderError>;
256 fn embed_query(&self, text: &str) -> Result<Vec<f32>, EmbedderError>;
257
258 fn embed_batch(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, EmbedderError> {
262 texts.iter().map(|t| self.embed_doc(t)).collect()
263 }
264
265 fn embed_query_with_identity(&self, text: &str) -> Result<Embedded<Vec<f32>>, EmbedderError> {
269 Ok(Embedded {
270 value: self.embed_query(text)?,
271 fingerprint: self.fingerprint(),
272 })
273 }
274
275 fn embed_batch_with_identity(
278 &self,
279 texts: &[String],
280 ) -> Result<Embedded<Vec<Vec<f32>>>, EmbedderError> {
281 Ok(Embedded {
282 value: self.embed_batch(texts)?,
283 fingerprint: self.fingerprint(),
284 })
285 }
286
287 fn embed_batch_with_artifact_identity(
292 &self,
293 texts: &[String],
294 ) -> Result<Embedded<Vec<Vec<f32>>>, EmbedderError> {
295 self.embed_batch_with_identity(texts)
296 }
297
298 fn fingerprint(&self) -> String {
303 "unknown".to_string()
304 }
305
306 fn artifact_identity(&self) -> Result<String, EmbedderError> {
309 Ok(self.fingerprint())
310 }
311}
312
313struct DownloadNotice {
317 model: String,
318 bytes: u64,
319}
320
321#[derive(Default)]
323struct LoadNotices {
324 download: Option<DownloadNotice>,
326 pooling_assumed: Option<String>,
329}
330
331type ResolvedEmbedder = (Arc<dyn Embedder>, Option<u64>, LoadNotices);
342
343fn embedder_for(model: &EmbeddingModel) -> Result<ResolvedEmbedder, EmbedderError> {
344 static CELL: OnceLock<Mutex<HashMap<String, LoadSlot<dyn Embedder>>>> = OnceLock::new();
345 let cache = CELL.get_or_init(|| Mutex::new(HashMap::new()));
346 let mut notices = LoadNotices::default();
347 let (emb, load_ms) = get_or_load_keyed(cache, &model.embedder_cache_key(), || {
348 let (emb, n) = build_embedder(model)?;
349 notices = n;
350 Ok(emb)
351 })?;
352 Ok((emb, load_ms, notices))
353}
354
355fn build_embedder(
358 model: &EmbeddingModel,
359) -> Result<(Arc<dyn Embedder>, LoadNotices), EmbedderError> {
360 let query_prefix = model.query_prefix();
361 let doc_prefix = model.doc_prefix();
362 let pooling = model.pooling_override();
363 match model {
364 EmbeddingModel::Default => {
365 let (e, n) = CandleEmbedder::load_hf(
367 DEFAULT_REPO,
368 DEFAULT_REVISION,
369 query_prefix,
370 doc_prefix,
371 pooling,
372 true,
373 )?;
374 Ok((Arc::new(e), n))
375 }
376 EmbeddingModel::HuggingFace {
377 repo,
378 revision,
379 download,
380 ..
381 } => {
382 let (e, n) = CandleEmbedder::load_hf(
383 repo,
384 revision.as_deref().unwrap_or("main"),
385 query_prefix,
386 doc_prefix,
387 pooling,
388 *download,
389 )?;
390 Ok((Arc::new(e), n))
391 }
392 EmbeddingModel::Local { path, .. } => {
393 let (e, n) = CandleEmbedder::load_path(path, query_prefix, doc_prefix, pooling)?;
394 Ok((Arc::new(e), n))
395 }
396 EmbeddingModel::Endpoint {
397 url,
398 model,
399 api_key_env,
400 ..
401 } => {
402 let e = EndpointEmbedder::new(
403 url.clone(),
404 model.clone(),
405 api_key_env.clone(),
406 query_prefix.into(),
407 doc_prefix.into(),
408 )?;
409 Ok((Arc::new(e), LoadNotices::default()))
410 }
411 }
412}
413
414type LoadSlot<T> = Arc<Mutex<Option<Arc<T>>>>;
418
419fn get_or_load_keyed<T: ?Sized>(
430 cache: &Mutex<HashMap<String, LoadSlot<T>>>,
431 key: &str,
432 load: impl FnOnce() -> Result<Arc<T>, EmbedderError>,
433) -> Result<(Arc<T>, Option<u64>), EmbedderError> {
434 let slot = {
436 let mut guard = cache.lock().expect("embedder cache mutex poisoned");
437 Arc::clone(guard.entry(key.to_string()).or_default())
438 };
439 let mut slot = slot.lock().expect("embedder cache slot mutex poisoned");
441 if let Some(existing) = slot.as_ref() {
442 return Ok((existing.clone(), None));
443 }
444 let started = Instant::now();
445 let loaded = load()?; let took_ms = started.elapsed().as_millis() as u64;
447 *slot = Some(loaded.clone());
448 Ok((loaded, Some(took_ms)))
449}
450
451pub(crate) fn embedder_with_telemetry(
456 model: &EmbeddingModel,
457 sink: &dyn TraceSink,
458) -> Result<Arc<dyn Embedder>, EmbedderError> {
459 let display = model.display_name();
460 let (result, load_ms, notices) = match embedder_for(model) {
461 Ok((emb, ms, notices)) => (Ok(emb), ms, notices),
462 Err(e) => (Err(e), None, LoadNotices::default()),
463 };
464 if let Some(DownloadNotice { model, bytes }) = notices.download {
465 let mb = bytes as f64 / 1_048_576.0;
466 eprintln!("ratel: downloaded embedding model {model} ({mb:.0} MB, one-time)");
467 sink.record(TraceEvent::EmbedderDownload { model, bytes });
468 }
469 if let Some(model) = notices.pooling_assumed {
470 eprintln!(
471 "ratel: pooling not detected for {model}, assuming mean; \
472 set pooling=\"cls\"|\"mean\" to override"
473 );
474 sink.record(TraceEvent::EmbedderPoolingAssumed {
475 model,
476 pooling: "mean".to_string(),
477 });
478 }
479 if let Some(event) = embedder_load_event(&display, load_ms, result.as_ref().err()) {
480 if let TraceEvent::EmbedderLoad {
481 status,
482 took_ms,
483 reason,
484 ..
485 } = &event
486 && !matches!(status, EmbedderLoadStatus::Ok)
487 {
488 eprintln!(
489 "ratel: embedding model load {status:?} ({took_ms}ms): {}",
490 reason.as_deref().unwrap_or("")
491 );
492 }
493 sink.record(event);
494 }
495 result
496}
497
498fn embedder_load_event(
502 model: &str,
503 load_ms: Option<u64>,
504 error: Option<&EmbedderError>,
505) -> Option<TraceEvent> {
506 match (load_ms, error) {
507 (_, Some(err)) => Some(TraceEvent::EmbedderLoad {
508 model: model.to_string(),
509 status: EmbedderLoadStatus::Failed,
510 took_ms: load_ms.unwrap_or(0),
511 reason: Some(err.to_string()),
512 }),
513 (Some(ms), None) => {
514 let slow = ms > slow_load_ms();
515 Some(TraceEvent::EmbedderLoad {
516 model: model.to_string(),
517 status: if slow {
518 EmbedderLoadStatus::Slow
519 } else {
520 EmbedderLoadStatus::Ok
521 },
522 took_ms: ms,
523 reason: slow.then(|| SLOW_LOAD_REASON.to_string()),
524 })
525 }
526 (None, None) => None,
527 }
528}
529
530fn slow_load_ms() -> u64 {
531 std::env::var("RATEL_EMBED_SLOW_MS")
532 .ok()
533 .and_then(|v| v.parse().ok())
534 .unwrap_or(DEFAULT_SLOW_LOAD_MS)
535}
536
537const EMBED_BATCH_CHUNK: usize = 32;
545
546pub(crate) struct CandleEmbedder {
547 model: BertModel,
548 tokenizer: Tokenizer,
549 device: Device,
550 pooling: Pooling,
551 query_prefix: String,
552 doc_prefix: String,
553 fingerprint: String,
554 local_source: Option<LocalArtifactSource>,
555}
556
557struct LocalArtifactSource {
559 dir: PathBuf,
560 hash_paths: [PathBuf; 3],
561 identity: LocalContentIdentity,
562}
563
564struct Loaded {
566 config: PathBuf,
567 tokenizer: PathBuf,
568 weights: PathBuf,
569 pooling: Pooling,
570}
571
572impl CandleEmbedder {
573 fn load_hf(
581 repo_id: &str,
582 revision: &str,
583 query_prefix: &str,
584 doc_prefix: &str,
585 pooling_override: Option<Pooling>,
586 allow_download: bool,
587 ) -> Result<(Self, LoadNotices), EmbedderError> {
588 let device = Device::Cpu;
589 let repo_spec =
590 Repo::with_revision(repo_id.to_string(), RepoType::Model, revision.to_string());
591 let cache_repo = hf_hub::Cache::from_env().repo(repo_spec.clone());
592
593 let (config_path, tokenizer_path, weights_path, pooling_file, download) = if allow_download
594 {
595 let was_cached = cache_repo.get("model.safetensors").is_some()
599 || cache_repo.get("pytorch_model.bin").is_some();
600 if !was_cached {
601 eprintln!(
602 "ratel: downloading embedding model {repo_id} (one-time; this may take a moment)…"
603 );
604 }
605 let api = ApiBuilder::from_env()
606 .build()
607 .map_err(|e| EmbedderError::Download {
608 model: repo_id.to_string(),
609 source: e.to_string(),
610 })?;
611 let repo = api.repo(repo_spec);
612 let config = fetch_cached(&repo, "config.json", repo_id)?;
613 let tokenizer = fetch_cached(&repo, "tokenizer.json", repo_id)?;
614 let weights = match fetch_cached(&repo, "model.safetensors", repo_id) {
616 Ok(p) => p,
617 Err(EmbedderError::Download { source, .. }) if is_not_found(&source) => {
618 fetch_cached(&repo, "pytorch_model.bin", repo_id)?
619 }
620 Err(e) => return Err(e),
621 };
622 let pooling_file = fetch_optional(&repo, "1_Pooling/config.json");
623 let notice = (!was_cached).then(|| DownloadNotice {
624 model: repo_id.to_string(),
625 bytes: [&config, &tokenizer, &weights]
626 .iter()
627 .filter_map(|p| std::fs::metadata(p).ok().map(|m| m.len()))
628 .sum(),
629 });
630 (config, tokenizer, weights, pooling_file, notice)
631 } else {
632 let not_cached = || EmbedderError::NotCached {
635 model: repo_id.to_string(),
636 revision: (revision != "main").then(|| revision.to_string()),
637 };
638 let config = cache_repo.get("config.json").ok_or_else(not_cached)?;
639 let tokenizer = cache_repo.get("tokenizer.json").ok_or_else(not_cached)?;
640 let weights = cache_repo
641 .get("model.safetensors")
642 .or_else(|| cache_repo.get("pytorch_model.bin"))
643 .ok_or_else(not_cached)?;
644 let pooling_file = cache_repo.get("1_Pooling/config.json");
645 (config, tokenizer, weights, pooling_file, None)
646 };
647
648 let detected =
650 pooling_override.or_else(|| pooling_file.and_then(|p| detect_pooling_file(&p)));
651 let (pooling, pooling_assumed) = resolve_pooling(detected);
652 let notices = LoadNotices {
653 download,
654 pooling_assumed: pooling_assumed.then(|| repo_id.to_string()),
655 };
656
657 let sha = snapshot_sha(&weights_path).unwrap_or_else(|| revision.to_string());
660 let loaded = Loaded {
661 config: config_path,
662 tokenizer: tokenizer_path,
663 weights: weights_path,
664 pooling,
665 };
666 let embedder = Self::build(
667 device,
668 &loaded,
669 query_prefix,
670 doc_prefix,
671 huggingface_fingerprint(repo_id, &sha),
672 repo_id,
673 None,
674 )?;
675 Ok((embedder, notices))
676 }
677
678 fn load_path(
681 dir: &Path,
682 query_prefix: &str,
683 doc_prefix: &str,
684 pooling_override: Option<Pooling>,
685 ) -> Result<(Self, LoadNotices), EmbedderError> {
686 let device = Device::Cpu;
687 let name = dir.display().to_string();
688 let files = resolve_local_model_files(dir)?;
689 let load_stamps = stamp_local_hash_paths(&files, &name)?;
690 let hash_paths = [
691 files.config.clone(),
692 files.tokenizer.clone(),
693 files.weights.clone(),
694 ];
695
696 let detected = pooling_override.or_else(|| {
697 files
698 .pooling_config
699 .as_ref()
700 .and_then(|p| detect_pooling_file(p))
701 });
702 let (pooling, pooling_assumed) = resolve_pooling(detected);
703 let notices = LoadNotices {
704 download: None,
705 pooling_assumed: pooling_assumed.then(|| name.clone()),
706 };
707
708 let loaded = Loaded {
709 config: files.config,
710 tokenizer: files.tokenizer,
711 weights: files.weights,
712 pooling,
713 };
714 let local_source = Some(LocalArtifactSource {
715 dir: dir.to_path_buf(),
716 hash_paths,
717 identity: LocalContentIdentity::new(load_stamps),
718 });
719 let embedder = Self::build(
720 device,
721 &loaded,
722 query_prefix,
723 doc_prefix,
724 local_fingerprint(&name),
725 &name,
726 local_source,
727 )?;
728 Ok((embedder, notices))
729 }
730
731 fn build(
734 device: Device,
735 loaded: &Loaded,
736 query_prefix: &str,
737 doc_prefix: &str,
738 base_fingerprint: String,
739 model_name: &str,
740 local_source: Option<LocalArtifactSource>,
741 ) -> Result<Self, EmbedderError> {
742 let load_err = |source: String| EmbedderError::Load {
743 model: model_name.to_string(),
744 source,
745 };
746
747 let config_bytes = std::fs::read(&loaded.config).map_err(|e| load_err(e.to_string()))?;
748 let config: Config =
749 serde_json::from_slice(&config_bytes).map_err(|e| load_err(e.to_string()))?;
750
751 let mut tokenizer =
752 Tokenizer::from_file(&loaded.tokenizer).map_err(|e| load_err(e.to_string()))?;
753 tokenizer
756 .with_truncation(Some(TruncationParams {
757 max_length: config.max_position_embeddings,
758 strategy: TruncationStrategy::LongestFirst,
759 direction: TruncationDirection::Right,
760 stride: 0,
761 }))
762 .map_err(|e| load_err(e.to_string()))?;
763
764 let is_safetensors =
767 loaded.weights.extension().and_then(|e| e.to_str()) == Some("safetensors");
768 let vb = if is_safetensors {
769 unsafe {
770 VarBuilder::from_mmaped_safetensors(&[&loaded.weights], DType::F32, &device)
771 .map_err(|e| load_err(e.to_string()))?
772 }
773 } else {
774 VarBuilder::from_pth(&loaded.weights, DType::F32, &device)
775 .map_err(|e| load_err(e.to_string()))?
776 };
777 let model = BertModel::load(vb, &config).map_err(|e| EmbedderError::Load {
778 model: model_name.to_string(),
779 source: format!(
780 "{e} — if this is not a BERT-family model it can't run in-process; \
781 serve it in a local model server and use {{\"ollama\": \"…\"}} or \
782 {{\"url\", \"model\"}} (e.g. Ollama at {OLLAMA_DEFAULT_URL})"
783 ),
784 })?;
785
786 let fingerprint = format!(
788 "{base_fingerprint}{}",
789 fingerprint_suffix(Some(loaded.pooling), query_prefix, doc_prefix)
790 );
791 Ok(Self {
792 model,
793 tokenizer,
794 device,
795 pooling: loaded.pooling,
796 query_prefix: query_prefix.to_string(),
797 doc_prefix: doc_prefix.to_string(),
798 fingerprint,
799 local_source,
800 })
801 }
802
803 fn embed(&self, text: &str) -> Result<Vec<f32>, EmbedderError> {
804 self.embed_inner(text)
805 .map_err(|e| EmbedderError::Inference {
806 source: e.to_string(),
807 })
808 }
809
810 fn embed_inner(&self, text: &str) -> candle_core::Result<Vec<f32>> {
811 let encoding = self
812 .tokenizer
813 .encode(text, true)
814 .map_err(|e| candle_core::Error::Msg(e.to_string()))?;
815 let ids = encoding.get_ids();
816 let input_ids = Tensor::new(ids, &self.device)?.unsqueeze(0)?; let token_type_ids = input_ids.zeros_like()?;
818 let mask: Vec<u32> = encoding.get_attention_mask().to_vec();
819 let attention_mask = Tensor::new(mask.as_slice(), &self.device)?.unsqueeze(0)?;
820
821 let sequence_output =
823 self.model
824 .forward(&input_ids, &token_type_ids, Some(&attention_mask))?;
825 let pooled = match self.pooling {
826 Pooling::Cls => sequence_output.i((0, 0))?, Pooling::Mean => mean_pool(&sequence_output, &attention_mask)?,
830 };
831 let vec = pooled.to_vec1::<f32>()?;
832 Ok(l2_normalize(vec))
833 }
834
835 fn embed_batch_inner(&self, texts: &[String]) -> candle_core::Result<Vec<Vec<f32>>> {
842 let mut out = Vec::with_capacity(texts.len());
843 for chunk in texts.chunks(EMBED_BATCH_CHUNK) {
844 let inputs: Vec<String> = if self.doc_prefix.is_empty() {
846 chunk.to_vec()
847 } else {
848 chunk
849 .iter()
850 .map(|t| format!("{}{}", self.doc_prefix, t))
851 .collect()
852 };
853 let encodings = self
854 .tokenizer
855 .encode_batch(inputs, true)
856 .map_err(|e| candle_core::Error::Msg(e.to_string()))?;
857 let n = encodings.len();
858 let max_len = encodings
859 .iter()
860 .map(|e| e.get_ids().len())
861 .max()
862 .unwrap_or(0);
863 let mut ids = vec![0u32; n * max_len];
866 let mut mask = vec![0u32; n * max_len];
867 for (row, enc) in encodings.iter().enumerate() {
868 let e_ids = enc.get_ids();
869 let e_mask = enc.get_attention_mask();
870 let base = row * max_len;
871 ids[base..base + e_ids.len()].copy_from_slice(e_ids);
872 mask[base..base + e_mask.len()].copy_from_slice(e_mask);
873 }
874 let input_ids = Tensor::from_vec(ids, (n, max_len), &self.device)?;
875 let attention_mask = Tensor::from_vec(mask, (n, max_len), &self.device)?;
876 let token_type_ids = input_ids.zeros_like()?;
877
878 let sequence_output =
880 self.model
881 .forward(&input_ids, &token_type_ids, Some(&attention_mask))?;
882 let pooled = match self.pooling {
883 Pooling::Cls => sequence_output.narrow(1, 0, 1)?.squeeze(1)?, Pooling::Mean => mean_pool_batch(&sequence_output, &attention_mask)?,
887 };
888 for row in pooled.to_vec2::<f32>()? {
889 out.push(l2_normalize(row));
890 }
891 }
892 Ok(out)
893 }
894}
895
896fn mean_pool(sequence_output: &Tensor, attention_mask: &Tensor) -> candle_core::Result<Tensor> {
899 let mask = attention_mask.to_dtype(DType::F32)?.unsqueeze(2)?; let summed = sequence_output.broadcast_mul(&mask)?.sum(1)?; let counts = mask.sum(1)?; summed.broadcast_div(&counts)?.i(0) }
904
905fn mean_pool_batch(
909 sequence_output: &Tensor,
910 attention_mask: &Tensor,
911) -> candle_core::Result<Tensor> {
912 let mask = attention_mask.to_dtype(DType::F32)?.unsqueeze(2)?; let summed = sequence_output.broadcast_mul(&mask)?.sum(1)?; let counts = mask.sum(1)?; summed.broadcast_div(&counts) }
917
918impl Embedder for CandleEmbedder {
919 fn embed_doc(&self, text: &str) -> Result<Vec<f32>, EmbedderError> {
920 if self.doc_prefix.is_empty() {
921 self.embed(text)
922 } else {
923 self.embed(&format!("{}{}", self.doc_prefix, text))
924 }
925 }
926
927 fn embed_query(&self, text: &str) -> Result<Vec<f32>, EmbedderError> {
928 if self.query_prefix.is_empty() {
929 self.embed(text)
930 } else {
931 self.embed(&format!("{}{}", self.query_prefix, text))
932 }
933 }
934
935 fn embed_batch(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, EmbedderError> {
936 self.embed_batch_inner(texts)
937 .map_err(|e| EmbedderError::Inference {
938 source: e.to_string(),
939 })
940 }
941
942 fn fingerprint(&self) -> String {
943 self.fingerprint.clone()
944 }
945
946 fn artifact_identity(&self) -> Result<String, EmbedderError> {
947 let Some(source) = &self.local_source else {
948 return Ok(self.fingerprint.clone());
949 };
950 let paths: [&Path; 3] = [
951 &source.hash_paths[0],
952 &source.hash_paths[1],
953 &source.hash_paths[2],
954 ];
955 let content_id = source.identity.content_id(&source.dir, &paths)?;
956 Ok(format!(
957 "{}{}",
958 local_content_fingerprint(&content_id),
959 fingerprint_suffix(Some(self.pooling), &self.query_prefix, &self.doc_prefix)
960 ))
961 }
962
963 fn embed_batch_with_artifact_identity(
964 &self,
965 texts: &[String],
966 ) -> Result<Embedded<Vec<Vec<f32>>>, EmbedderError> {
967 if self.local_source.is_none() {
968 return self.embed_batch_with_identity(texts);
969 }
970 Ok(Embedded {
971 value: self.embed_batch(texts)?,
972 fingerprint: self.artifact_identity()?,
973 })
974 }
975}
976
977fn detect_pooling_file(path: &Path) -> Option<Pooling> {
980 let bytes = std::fs::read(path).ok()?;
981 parse_pooling_config(&bytes)
982}
983
984fn resolve_pooling(detected: Option<Pooling>) -> (Pooling, bool) {
987 match detected {
988 Some(p) => (p, false),
989 None => (Pooling::Mean, true),
990 }
991}
992
993fn fetch_optional(repo: &ApiRepo, file: &str) -> Option<PathBuf> {
996 repo.get(file).ok()
997}
998
999fn is_not_found(source: &str) -> bool {
1002 let l = source.to_lowercase();
1003 l.contains("404") || l.contains("not found") || l.contains("entry not found")
1004}
1005
1006fn snapshot_sha(weights_path: &Path) -> Option<String> {
1010 let name = weights_path.parent()?.file_name()?.to_str()?;
1011 (name.len() == 40 && name.chars().all(|c| c.is_ascii_hexdigit())).then(|| name.to_string())
1012}
1013
1014pub(crate) struct EndpointEmbedder {
1019 url: String,
1020 model: String,
1021 api_key_env: Option<String>,
1022 query_prefix: String,
1023 doc_prefix: String,
1024 agent: ureq::Agent,
1025 fingerprint: String,
1026}
1027
1028#[derive(Deserialize)]
1030struct EmbeddingsResponse {
1031 data: Vec<EmbeddingData>,
1032 #[serde(default)]
1033 model: Option<String>,
1034}
1035
1036#[derive(Deserialize)]
1037struct EmbeddingData {
1038 embedding: Vec<f32>,
1039 index: usize,
1040}
1041
1042struct ParsedEmbeddings {
1043 vectors: Vec<Vec<f32>>,
1044 model: Option<String>,
1045}
1046
1047impl EndpointEmbedder {
1048 fn new(
1049 url: String,
1050 model: String,
1051 api_key_env: Option<String>,
1052 query_prefix: String,
1053 doc_prefix: String,
1054 ) -> Result<Self, EmbedderError> {
1055 let agent: ureq::Agent = ureq::Agent::config_builder()
1056 .timeout_global(Some(Duration::from_secs(ENDPOINT_TIMEOUT_SECS)))
1057 .build()
1058 .into();
1059 let fingerprint = format!(
1061 "{}{}",
1062 endpoint_fingerprint(&url, &model),
1063 fingerprint_suffix(None, &query_prefix, &doc_prefix)
1064 );
1065 Ok(Self {
1066 url,
1067 model,
1068 api_key_env,
1069 query_prefix,
1070 doc_prefix,
1071 agent,
1072 fingerprint,
1073 })
1074 }
1075
1076 fn api_key(&self) -> Result<Option<String>, EmbedderError> {
1080 match &self.api_key_env {
1081 None => Ok(None),
1082 Some(var) => std::env::var(var)
1083 .map(Some)
1084 .map_err(|_| EmbedderError::Config {
1085 message: format!(
1086 "api_key_env=\"{var}\" but that environment variable is not set"
1087 ),
1088 }),
1089 }
1090 }
1091
1092 fn request_chunk(&self, inputs: &[String]) -> Result<Embedded<Vec<Vec<f32>>>, EmbedderError> {
1093 let key = self.api_key()?;
1094 let body = serde_json::json!({ "model": self.model, "input": inputs });
1095 let mut req = self
1096 .agent
1097 .post(&self.url)
1098 .header("content-type", "application/json");
1099 if let Some(k) = key {
1100 req = req.header("authorization", &format!("Bearer {k}"));
1101 }
1102 let mut resp = req.send_json(&body).map_err(|e| self.classify(e))?;
1103 let parsed: EmbeddingsResponse = resp
1104 .body_mut()
1105 .with_config()
1106 .limit(ENDPOINT_RESPONSE_LIMIT_BYTES)
1107 .read_json()
1108 .map_err(|e| EmbedderError::Inference {
1109 source: format!("malformed or oversized endpoint response: {e}"),
1110 })?;
1111 let parsed = parse_embeddings(parsed, inputs.len())?;
1112 let resolved_model = parsed.model.as_deref().unwrap_or(&self.model);
1113 Ok(Embedded {
1114 value: parsed.vectors,
1115 fingerprint: self.fingerprint_for_model(resolved_model),
1116 })
1117 }
1118
1119 fn request(&self, inputs: &[String]) -> Result<Embedded<Vec<Vec<f32>>>, EmbedderError> {
1120 if inputs.is_empty() {
1121 return Ok(Embedded {
1122 value: Vec::new(),
1123 fingerprint: self.fingerprint.clone(),
1124 });
1125 }
1126
1127 let mut vectors = Vec::with_capacity(inputs.len());
1128 let mut fingerprint: Option<String> = None;
1129 let mut dimension = None;
1130 for chunk in inputs.chunks(ENDPOINT_BATCH_SIZE) {
1131 let embedded = self.request_chunk(chunk)?;
1132 if let Some(first) = &fingerprint {
1133 if first != &embedded.fingerprint {
1134 return Err(EmbedderError::ModelMismatch {
1135 built: first.clone(),
1136 active: embedded.fingerprint,
1137 });
1138 }
1139 } else {
1140 fingerprint = Some(embedded.fingerprint.clone());
1141 }
1142 let chunk_dimension = embedded
1143 .value
1144 .first()
1145 .expect("non-empty request chunk has a non-empty response")
1146 .len();
1147 if let Some(expected) = dimension {
1148 if expected != chunk_dimension {
1149 return Err(EmbedderError::Inference {
1150 source: format!(
1151 "endpoint returned mixed embedding dimensions across chunks: expected {expected}, got {chunk_dimension}"
1152 ),
1153 });
1154 }
1155 } else {
1156 dimension = Some(chunk_dimension);
1157 }
1158 vectors.extend(embedded.value);
1159 }
1160 Ok(Embedded {
1161 value: vectors,
1162 fingerprint: fingerprint.expect("non-empty input produced at least one chunk"),
1163 })
1164 }
1165
1166 fn fingerprint_for_model(&self, model: &str) -> String {
1167 format!(
1168 "{}{}",
1169 endpoint_fingerprint(&self.url, model),
1170 fingerprint_suffix(None, &self.query_prefix, &self.doc_prefix)
1171 )
1172 }
1173
1174 fn classify(&self, e: ureq::Error) -> EmbedderError {
1177 let status = match &e {
1178 ureq::Error::StatusCode(code) => Some(*code),
1179 _ => None,
1180 };
1181 let is_local_ollama =
1182 self.url.contains("localhost:11434") || self.url.contains("127.0.0.1:11434");
1183 match status {
1184 Some(401) | Some(403) => EmbedderError::Config {
1185 message: format!(
1186 "endpoint rejected the request ({}); check api_key_env / the key",
1187 status.unwrap()
1188 ),
1189 },
1190 Some(404) => {
1192 let hint = if is_local_ollama {
1193 format!(" — run: ollama pull {}", self.model)
1194 } else {
1195 String::new()
1196 };
1197 EmbedderError::Download {
1198 model: self.model.clone(),
1199 source: format!("endpoint returned 404 for model '{}'{hint}", self.model),
1200 }
1201 }
1202 _ if is_local_ollama => EmbedderError::Download {
1205 model: self.model.clone(),
1206 source: format!(
1207 "could not reach Ollama at {} ({e}) — is it running? start it with \
1208 `ollama serve`, then `ollama pull {}`",
1209 self.url, self.model
1210 ),
1211 },
1212 _ => EmbedderError::Download {
1213 model: self.model.clone(),
1214 source: e.to_string(),
1215 },
1216 }
1217 }
1218}
1219
1220fn parse_embeddings(
1223 resp: EmbeddingsResponse,
1224 expected_len: usize,
1225) -> Result<ParsedEmbeddings, EmbedderError> {
1226 if resp.data.len() != expected_len {
1227 return Err(EmbedderError::Inference {
1228 source: format!(
1229 "endpoint returned {} embeddings for {expected_len} inputs",
1230 resp.data.len()
1231 ),
1232 });
1233 }
1234 if resp
1235 .model
1236 .as_deref()
1237 .is_some_and(|model| model.trim().is_empty())
1238 {
1239 return Err(EmbedderError::Inference {
1240 source: "endpoint returned a blank model identity".into(),
1241 });
1242 }
1243
1244 let mut ordered: Vec<Option<Vec<f32>>> = (0..expected_len).map(|_| None).collect();
1245 let mut dimension = None;
1246 for data in resp.data {
1247 if data.index >= expected_len {
1248 return Err(EmbedderError::Inference {
1249 source: format!(
1250 "endpoint returned out-of-range embedding index {} for {expected_len} inputs",
1251 data.index
1252 ),
1253 });
1254 }
1255 if ordered[data.index].is_some() {
1256 return Err(EmbedderError::Inference {
1257 source: format!("endpoint returned duplicate embedding index {}", data.index),
1258 });
1259 }
1260 let vector = normalize_endpoint_vector(data.embedding, &mut dimension)?;
1261 ordered[data.index] = Some(vector);
1262 }
1263
1264 let vectors = ordered
1265 .into_iter()
1266 .enumerate()
1267 .map(|(index, vector)| {
1268 vector.ok_or_else(|| EmbedderError::Inference {
1269 source: format!("endpoint response is missing embedding index {index}"),
1270 })
1271 })
1272 .collect::<Result<_, _>>()?;
1273 Ok(ParsedEmbeddings {
1274 vectors,
1275 model: resp.model,
1276 })
1277}
1278
1279fn normalize_endpoint_vector(
1280 mut vector: Vec<f32>,
1281 dimension: &mut Option<usize>,
1282) -> Result<Vec<f32>, EmbedderError> {
1283 if vector.is_empty() {
1284 return Err(EmbedderError::Inference {
1285 source: "endpoint returned an empty embedding vector".into(),
1286 });
1287 }
1288 if vector.iter().any(|value| !value.is_finite()) {
1289 return Err(EmbedderError::Inference {
1290 source: "endpoint returned a non-finite embedding value".into(),
1291 });
1292 }
1293 match *dimension {
1294 Some(expected) if vector.len() != expected => {
1295 return Err(EmbedderError::Inference {
1296 source: format!(
1297 "endpoint returned mixed embedding dimensions: expected {expected}, got {}",
1298 vector.len()
1299 ),
1300 });
1301 }
1302 None => *dimension = Some(vector.len()),
1303 Some(_) => {}
1304 }
1305
1306 let norm = vector
1307 .iter()
1308 .map(|value| f64::from(*value).powi(2))
1309 .sum::<f64>()
1310 .sqrt();
1311 if !norm.is_finite() || norm == 0.0 {
1312 return Err(EmbedderError::Inference {
1313 source: "endpoint returned a zero or non-normalizable embedding vector".into(),
1314 });
1315 }
1316 for value in &mut vector {
1317 *value = (f64::from(*value) / norm) as f32;
1318 }
1319 Ok(vector)
1320}
1321
1322impl Embedder for EndpointEmbedder {
1323 fn embed_doc(&self, text: &str) -> Result<Vec<f32>, EmbedderError> {
1324 self.embed_batch_with_identity(std::slice::from_ref(&text.to_string()))?
1325 .value
1326 .into_iter()
1327 .next()
1328 .ok_or_else(|| EmbedderError::Inference {
1329 source: "endpoint returned no embedding".into(),
1330 })
1331 }
1332
1333 fn embed_query(&self, text: &str) -> Result<Vec<f32>, EmbedderError> {
1334 Ok(self.embed_query_with_identity(text)?.value)
1335 }
1336
1337 fn embed_batch(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, EmbedderError> {
1338 Ok(self.embed_batch_with_identity(texts)?.value)
1339 }
1340
1341 fn embed_query_with_identity(&self, text: &str) -> Result<Embedded<Vec<f32>>, EmbedderError> {
1342 let q = if self.query_prefix.is_empty() {
1343 text.to_string()
1344 } else {
1345 format!("{}{}", self.query_prefix, text)
1346 };
1347 let embedded = self.request(&[q])?;
1348 let fingerprint = embedded.fingerprint;
1349 let value = embedded
1350 .value
1351 .into_iter()
1352 .next()
1353 .ok_or_else(|| EmbedderError::Inference {
1354 source: "endpoint returned no embedding".into(),
1355 })?;
1356 Ok(Embedded { value, fingerprint })
1357 }
1358
1359 fn embed_batch_with_identity(
1360 &self,
1361 texts: &[String],
1362 ) -> Result<Embedded<Vec<Vec<f32>>>, EmbedderError> {
1363 if self.doc_prefix.is_empty() {
1364 self.request(texts)
1365 } else {
1366 let prefixed: Vec<String> = texts
1367 .iter()
1368 .map(|t| format!("{}{}", self.doc_prefix, t))
1369 .collect();
1370 self.request(&prefixed)
1371 }
1372 }
1373
1374 fn fingerprint(&self) -> String {
1375 self.fingerprint.clone()
1376 }
1377}
1378
1379fn fetch_cached(repo: &ApiRepo, file: &str, model: &str) -> Result<PathBuf, EmbedderError> {
1390 const MAX_ATTEMPTS: u32 = 30;
1393 const BACKOFF: Duration = Duration::from_secs(1);
1394
1395 let mut attempt = 1;
1396 loop {
1397 match repo.get(file) {
1398 Ok(path) => return Ok(path),
1399 Err(e) => {
1400 let msg = e.to_string();
1401 if attempt < MAX_ATTEMPTS && is_lock_contention(&msg) {
1402 attempt += 1;
1403 std::thread::sleep(BACKOFF);
1404 continue;
1405 }
1406 return Err(classify_fetch_error(model, &msg));
1407 }
1408 }
1409 }
1410}
1411
1412fn is_lock_contention(err: &str) -> bool {
1416 err.contains("Lock acquisition failed")
1417}
1418
1419fn classify_fetch_error(model: &str, msg: &str) -> EmbedderError {
1423 let lower = msg.to_lowercase();
1424 let unwritable = lower.contains("permission denied")
1425 || lower.contains("read-only")
1426 || lower.contains("no space")
1427 || lower.contains("os error 13") || lower.contains("os error 28") || lower.contains("os error 30"); if unwritable {
1431 EmbedderError::CacheUnwritable {
1432 source: msg.to_string(),
1433 }
1434 } else {
1435 EmbedderError::Download {
1436 model: model.to_string(),
1437 source: msg.to_string(),
1438 }
1439 }
1440}
1441
1442fn l2_normalize(mut v: Vec<f32>) -> Vec<f32> {
1445 let norm = v.iter().map(|x| x * x).sum::<f32>().sqrt();
1446 if norm > 0.0 {
1447 for x in &mut v {
1448 *x /= norm;
1449 }
1450 }
1451 v
1452}
1453
1454#[cfg(test)]
1455mod tests {
1456 use std::io::{Read, Write};
1457 use std::net::TcpListener;
1458 use std::sync::mpsc;
1459
1460 use super::*;
1461 use crate::{Origin, SearchMethod, Tool, ToolRegistry};
1462
1463 #[test]
1464 fn only_lock_contention_is_retried() {
1465 assert!(is_lock_contention(
1467 "Lock acquisition failed: /home/u/.cache/huggingface/hub/models--BAAI--bge-small-en-v1.5/blobs/abc.lock"
1468 ));
1469 assert!(!is_lock_contention("request error: connection refused"));
1471 assert!(!is_lock_contention("Http(reqwest::Error { status: 404 })"));
1472 assert!(!is_lock_contention(
1473 "No such file or directory (os error 2)"
1474 ));
1475 }
1476
1477 #[test]
1478 fn classifies_cache_permission_and_space_as_unwritable() {
1479 assert!(matches!(
1480 classify_fetch_error("m", "Permission denied (os error 13)"),
1481 EmbedderError::CacheUnwritable { .. }
1482 ));
1483 assert!(matches!(
1484 classify_fetch_error("m", "No space left on device (os error 28)"),
1485 EmbedderError::CacheUnwritable { .. }
1486 ));
1487 assert!(matches!(
1488 classify_fetch_error("m", "Read-only file system (os error 30)"),
1489 EmbedderError::CacheUnwritable { .. }
1490 ));
1491 }
1492
1493 #[test]
1494 fn classifies_network_and_http_as_download() {
1495 assert!(matches!(
1496 classify_fetch_error("m", "error sending request: dns error: failed to lookup"),
1497 EmbedderError::Download { .. }
1498 ));
1499 assert!(matches!(
1500 classify_fetch_error("m", "Http status client error (404 Not Found)"),
1501 EmbedderError::Download { .. }
1502 ));
1503 }
1504
1505 #[test]
1506 fn error_display_carries_source_and_hint() {
1507 let s = EmbedderError::Download {
1508 model: "embed-v1 @ https://embeddings.example.test".into(),
1509 source: "connection refused".into(),
1510 }
1511 .to_string();
1512 assert!(s.contains("connection refused"), "got: {s}");
1513 assert!(s.contains("hint:"), "got: {s}");
1514 assert!(!s.contains("revision"), "got: {s}");
1515
1516 let load = EmbedderError::Load {
1517 model: "/models/embed".into(),
1518 source: "missing config.json".into(),
1519 }
1520 .to_string();
1521 assert!(!load.contains("re-download"), "got: {load}");
1522
1523 let inference = EmbedderError::Inference {
1524 source: "endpoint returned duplicate index 0".into(),
1525 }
1526 .to_string();
1527 assert!(!inference.contains("underpowered"), "got: {inference}");
1528 }
1529
1530 #[test]
1531 fn get_or_load_keyed_does_not_cache_failure_and_reports_latency_once() {
1532 let cache: Mutex<HashMap<String, LoadSlot<i32>>> = Mutex::new(HashMap::new());
1533 let boom = || {
1534 Err::<Arc<i32>, _>(EmbedderError::Inference {
1535 source: "boom".into(),
1536 })
1537 };
1538 assert!(get_or_load_keyed(&cache, "k", boom).is_err());
1540 let (v, ms) =
1542 get_or_load_keyed(&cache, "k", || Ok::<_, EmbedderError>(Arc::new(7))).unwrap();
1543 assert_eq!(*v, 7);
1544 assert!(ms.is_some(), "the loading call reports latency");
1545 let (v2, ms2) =
1547 get_or_load_keyed(&cache, "k", || Ok::<_, EmbedderError>(Arc::new(999))).unwrap();
1548 assert_eq!(*v2, 7);
1549 assert!(ms2.is_none(), "warm reuse reports no load latency");
1550 }
1551
1552 #[test]
1553 fn distinct_key_loads_run_concurrently() {
1554 use std::sync::Barrier;
1555 use std::sync::atomic::{AtomicUsize, Ordering};
1556 use std::thread;
1557
1558 let cache: Arc<Mutex<HashMap<String, LoadSlot<i32>>>> =
1563 Arc::new(Mutex::new(HashMap::new()));
1564 let barrier = Arc::new(Barrier::new(2));
1565 let inflight = Arc::new(AtomicUsize::new(0));
1566 let (done_tx, done_rx) = mpsc::channel();
1567
1568 for (i, key) in ["a", "b"].into_iter().enumerate() {
1569 let cache = Arc::clone(&cache);
1570 let barrier = Arc::clone(&barrier);
1571 let inflight = Arc::clone(&inflight);
1572 let done_tx = done_tx.clone();
1573 thread::spawn(move || {
1574 let result = get_or_load_keyed(&cache, key, || {
1575 inflight.fetch_add(1, Ordering::SeqCst);
1576 barrier.wait(); Ok::<_, EmbedderError>(Arc::new(i as i32))
1578 });
1579 let _ = done_tx.send(result.map(|(v, _)| *v));
1580 });
1581 }
1582 drop(done_tx);
1583
1584 let mut got = Vec::new();
1585 for _ in 0..2 {
1586 let value = done_rx
1587 .recv_timeout(Duration::from_secs(5))
1588 .expect("distinct-key loads did not run concurrently (map lock held across load)");
1589 got.push(value.unwrap());
1590 }
1591 got.sort_unstable();
1592 assert_eq!(got, vec![0, 1]);
1593 assert_eq!(inflight.load(Ordering::SeqCst), 2);
1594 }
1595
1596 #[test]
1597 fn same_key_load_is_single_flight() {
1598 use std::sync::Barrier;
1599 use std::sync::atomic::{AtomicUsize, Ordering};
1600 use std::thread;
1601
1602 const THREADS: usize = 8;
1605 let cache: Arc<Mutex<HashMap<String, LoadSlot<i32>>>> =
1606 Arc::new(Mutex::new(HashMap::new()));
1607 let loads = Arc::new(AtomicUsize::new(0));
1608 let start = Arc::new(Barrier::new(THREADS));
1609 let (tx, rx) = mpsc::channel();
1610
1611 for _ in 0..THREADS {
1612 let cache = Arc::clone(&cache);
1613 let loads = Arc::clone(&loads);
1614 let start = Arc::clone(&start);
1615 let tx = tx.clone();
1616 thread::spawn(move || {
1617 start.wait(); let result = get_or_load_keyed(&cache, "shared", || {
1619 loads.fetch_add(1, Ordering::SeqCst);
1620 Ok::<_, EmbedderError>(Arc::new(42))
1621 });
1622 let (value, ms) = result.unwrap();
1623 let _ = tx.send((*value, ms.is_some()));
1624 });
1625 }
1626 drop(tx);
1627
1628 let mut reported = 0;
1629 for _ in 0..THREADS {
1630 let (value, loaded) = rx.recv_timeout(Duration::from_secs(5)).unwrap();
1631 assert_eq!(value, 42);
1632 if loaded {
1633 reported += 1;
1634 }
1635 }
1636 assert_eq!(
1637 loads.load(Ordering::SeqCst),
1638 1,
1639 "load must run exactly once per key"
1640 );
1641 assert_eq!(reported, 1, "exactly one caller reports the load latency");
1642 }
1643
1644 #[test]
1645 fn load_event_flags_slow_ok_failed_and_warm() {
1646 assert!(matches!(
1648 embedder_load_event("m", Some(10_000), None),
1649 Some(TraceEvent::EmbedderLoad {
1650 status: EmbedderLoadStatus::Slow,
1651 took_ms: 10_000,
1652 ..
1653 })
1654 ));
1655 assert!(matches!(
1657 embedder_load_event("m", Some(5), None),
1658 Some(TraceEvent::EmbedderLoad {
1659 status: EmbedderLoadStatus::Ok,
1660 ..
1661 })
1662 ));
1663 let err = EmbedderError::Inference { source: "x".into() };
1665 assert!(matches!(
1666 embedder_load_event("m", None, Some(&err)),
1667 Some(TraceEvent::EmbedderLoad {
1668 status: EmbedderLoadStatus::Failed,
1669 ..
1670 })
1671 ));
1672 assert!(embedder_load_event("m", None, None).is_none());
1674 }
1675
1676 #[test]
1677 fn endpoint_embeddings_are_normalized_and_ordered_by_index() {
1678 let resp = EmbeddingsResponse {
1681 model: Some("resolved-model".into()),
1682 data: vec![
1683 EmbeddingData {
1684 embedding: vec![0.0, 3.0], index: 1,
1686 },
1687 EmbeddingData {
1688 embedding: vec![4.0, 0.0], index: 0,
1690 },
1691 ],
1692 };
1693 let out = parse_embeddings(resp, 2).expect("parse");
1694 assert_eq!(out.vectors[0], vec![1.0, 0.0], "index 0 first, normalized");
1695 assert_eq!(out.vectors[1], vec![0.0, 1.0], "index 1 second, normalized");
1696 assert_eq!(out.model.as_deref(), Some("resolved-model"));
1697 }
1698
1699 #[test]
1700 fn endpoint_response_count_mismatch_errors() {
1701 let resp = EmbeddingsResponse {
1702 model: None,
1703 data: vec![EmbeddingData {
1704 embedding: vec![1.0],
1705 index: 0,
1706 }],
1707 };
1708 assert!(matches!(
1709 parse_embeddings(resp, 2),
1710 Err(EmbedderError::Inference { .. })
1711 ));
1712 }
1713
1714 fn response(vectors: &[(usize, Vec<f32>)]) -> EmbeddingsResponse {
1715 EmbeddingsResponse {
1716 model: None,
1717 data: vectors
1718 .iter()
1719 .cloned()
1720 .map(|(index, embedding)| EmbeddingData { embedding, index })
1721 .collect(),
1722 }
1723 }
1724
1725 #[test]
1726 fn endpoint_response_requires_an_exact_index_permutation() {
1727 assert!(
1728 serde_json::from_value::<EmbeddingsResponse>(serde_json::json!({
1729 "data": [{ "embedding": [1.0] }]
1730 }))
1731 .is_err(),
1732 "index is required"
1733 );
1734 for malformed in [
1735 response(&[(0, vec![1.0]), (0, vec![1.0])]),
1736 response(&[(0, vec![1.0]), (2, vec![1.0])]),
1737 ] {
1738 assert!(matches!(
1739 parse_embeddings(malformed, 2),
1740 Err(EmbedderError::Inference { .. })
1741 ));
1742 }
1743 }
1744
1745 #[test]
1746 fn endpoint_response_rejects_invalid_vectors() {
1747 for malformed in [
1748 response(&[(0, vec![])]),
1749 response(&[(0, vec![0.0, 0.0])]),
1750 response(&[(0, vec![f32::NAN, 1.0])]),
1751 response(&[(0, vec![f32::INFINITY, 1.0])]),
1752 response(&[(0, vec![1.0, 0.0]), (1, vec![1.0])]),
1753 ] {
1754 let expected_len = malformed.data.len();
1755 assert!(matches!(
1756 parse_embeddings(malformed, expected_len),
1757 Err(EmbedderError::Inference { .. })
1758 ));
1759 }
1760 }
1761
1762 #[test]
1763 fn endpoint_rejects_a_response_over_64_mib() {
1764 let (url, requests_rx, server) = mock_endpoint(vec![MockReply::Oversized]);
1765 let embedder = EndpointEmbedder::new(
1766 url,
1767 "requested-model".into(),
1768 None,
1769 String::new(),
1770 String::new(),
1771 )
1772 .unwrap();
1773
1774 let err = embedder
1775 .embed_batch(&["one".to_string()])
1776 .expect_err("oversized response must fail");
1777 let requests = requests_rx.recv_timeout(Duration::from_secs(5)).unwrap();
1778 server.join().unwrap();
1779
1780 assert!(err.to_string().contains("oversized"), "got: {err}");
1781 assert_eq!(requests.len(), 1);
1782 }
1783
1784 #[test]
1785 fn endpoint_batches_65_inputs_as_64_plus_1_and_preserves_global_order() {
1786 let (url, requests_rx, server) = mock_endpoint(vec![
1787 MockReply::Embeddings("resolved-model"),
1788 MockReply::Embeddings("resolved-model"),
1789 ]);
1790
1791 let embedder = EndpointEmbedder::new(
1792 url,
1793 "requested-model".into(),
1794 None,
1795 String::new(),
1796 String::new(),
1797 )
1798 .unwrap();
1799 let inputs = (0..65).map(|index| index.to_string()).collect::<Vec<_>>();
1800 let embedded = embedder.embed_batch_with_identity(&inputs).unwrap();
1801 let requests = requests_rx.recv_timeout(Duration::from_secs(5)).unwrap();
1802 server.join().unwrap();
1803
1804 assert_eq!(
1805 requests
1806 .iter()
1807 .map(|request| request.inputs.len())
1808 .collect::<Vec<_>>(),
1809 vec![64, 1]
1810 );
1811 assert_eq!(
1812 requests
1813 .into_iter()
1814 .flat_map(|request| request.inputs)
1815 .collect::<Vec<_>>(),
1816 inputs
1817 );
1818 assert_eq!(embedded.value.len(), 65);
1819 assert!(embedded.fingerprint.contains("resolved-model"));
1820 }
1821
1822 #[test]
1823 fn second_chunk_failure_commits_neither_chunk_and_retry_sends_all_inputs() {
1824 let (url, requests_rx, server) = mock_endpoint(vec![
1825 MockReply::Embeddings("resolved-model"),
1826 MockReply::Status(500),
1827 MockReply::Embeddings("resolved-model"),
1828 MockReply::Embeddings("resolved-model"),
1829 ]);
1830 let mut registry = ToolRegistry::with_embedding(EmbeddingModel::Endpoint {
1831 url,
1832 model: "requested-model".into(),
1833 api_key_env: None,
1834 query_prefix: None,
1835 doc_prefix: None,
1836 });
1837 for index in 0..65 {
1838 registry.register(tool_for_endpoint(index));
1839 }
1840
1841 assert!(registry.build_embeddings().is_err());
1842 registry.build_embeddings().unwrap();
1843 let requests = requests_rx.recv_timeout(Duration::from_secs(5)).unwrap();
1844 server.join().unwrap();
1845
1846 assert_eq!(
1847 requests
1848 .iter()
1849 .map(|request| request.inputs.len())
1850 .collect::<Vec<_>>(),
1851 vec![64, 1, 64, 1]
1852 );
1853 }
1854
1855 #[test]
1856 fn endpoint_cache_separates_api_key_env_names_and_sends_each_bearer_token() {
1857 const KEY_A: &str = "RATEL_CORE_ENDPOINT_TEST_KEY_A";
1858 const KEY_B: &str = "RATEL_CORE_ENDPOINT_TEST_KEY_B";
1859 unsafe {
1861 std::env::set_var(KEY_A, "alpha-token");
1862 std::env::set_var(KEY_B, "beta-token");
1863 }
1864 let (url, requests_rx, server) = mock_endpoint(vec![
1865 MockReply::Embeddings("resolved-model"),
1866 MockReply::Embeddings("resolved-model"),
1867 ]);
1868 for (id, env_name) in [("a", KEY_A), ("b", KEY_B)] {
1869 let mut registry = ToolRegistry::with_embedding(EmbeddingModel::Endpoint {
1870 url: url.clone(),
1871 model: "requested-model".into(),
1872 api_key_env: Some(env_name.into()),
1873 query_prefix: None,
1874 doc_prefix: None,
1875 });
1876 registry.register(Tool {
1877 id: id.into(),
1878 name: id.into(),
1879 description: "endpoint auth test".into(),
1880 experimental_searchable_description: None,
1881 input_schema: serde_json::json!({}),
1882 output_schema: serde_json::json!({}),
1883 });
1884 registry.build_embeddings().unwrap();
1885 }
1886 let requests = requests_rx.recv_timeout(Duration::from_secs(5)).unwrap();
1887 server.join().unwrap();
1888 unsafe {
1889 std::env::remove_var(KEY_A);
1890 std::env::remove_var(KEY_B);
1891 }
1892
1893 assert_eq!(
1894 requests
1895 .into_iter()
1896 .map(|request| request.authorization)
1897 .collect::<Vec<_>>(),
1898 vec![
1899 Some("Bearer alpha-token".into()),
1900 Some("Bearer beta-token".into())
1901 ]
1902 );
1903 }
1904
1905 #[test]
1906 fn response_model_drift_is_hard_and_rebuild_adopts_the_new_identity() {
1907 let (url, requests_rx, server) = mock_endpoint(vec![
1908 MockReply::Embeddings("model-a"),
1909 MockReply::Embeddings("model-b"),
1910 MockReply::Embeddings("model-b"),
1911 MockReply::Embeddings("model-b"),
1912 ]);
1913 let mut registry = ToolRegistry::with_embedding(EmbeddingModel::Endpoint {
1914 url,
1915 model: "requested-model".into(),
1916 api_key_env: None,
1917 query_prefix: None,
1918 doc_prefix: None,
1919 });
1920 registry.register(tool_for_endpoint(0));
1921 registry.build_embeddings().unwrap();
1922
1923 assert!(matches!(
1924 registry.search_with_method("tool", 1, Origin::Direct, SearchMethod::Semantic),
1925 Err(EmbedderError::ModelMismatch { .. })
1926 ));
1927 registry.rebuild_embeddings().unwrap();
1928 assert_eq!(
1929 registry
1930 .search_with_method("tool", 1, Origin::Direct, SearchMethod::Semantic)
1931 .unwrap()
1932 .len(),
1933 1
1934 );
1935 let requests = requests_rx.recv_timeout(Duration::from_secs(5)).unwrap();
1936 server.join().unwrap();
1937 assert_eq!(requests.len(), 4);
1938 }
1939
1940 fn tool_for_endpoint(index: usize) -> Tool {
1941 Tool {
1942 id: format!("tool-{index}"),
1943 name: format!("tool-{index}"),
1944 description: format!("endpoint tool {index}"),
1945 experimental_searchable_description: None,
1946 input_schema: serde_json::json!({}),
1947 output_schema: serde_json::json!({}),
1948 }
1949 }
1950
1951 #[derive(Clone, Copy)]
1952 enum MockReply {
1953 Embeddings(&'static str),
1954 Status(u16),
1955 Oversized,
1956 }
1957
1958 struct MockRequest {
1959 inputs: Vec<String>,
1960 authorization: Option<String>,
1961 }
1962
1963 type MockServer = (
1964 String,
1965 mpsc::Receiver<Vec<MockRequest>>,
1966 std::thread::JoinHandle<()>,
1967 );
1968
1969 fn mock_endpoint(replies: Vec<MockReply>) -> MockServer {
1970 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1971 listener.set_nonblocking(true).unwrap();
1972 let url = format!("http://{}/v1/embeddings", listener.local_addr().unwrap());
1973 let (requests_tx, requests_rx) = mpsc::channel();
1974 let server = std::thread::spawn(move || {
1975 let deadline = Instant::now() + Duration::from_secs(5);
1976 let mut replies = std::collections::VecDeque::from(replies);
1977 let mut requests = Vec::new();
1978 while let Some(reply) = replies.front().copied() {
1979 match listener.accept() {
1980 Ok((mut stream, _)) => {
1981 stream.set_nonblocking(false).unwrap();
1982 let (body, authorization) = read_http_request(&mut stream);
1983 let inputs = body["input"]
1984 .as_array()
1985 .expect("input array")
1986 .iter()
1987 .map(|value| value.as_str().expect("string input").to_string())
1988 .collect::<Vec<_>>();
1989 write_mock_response(&mut stream, reply, inputs.len());
1990 requests.push(MockRequest {
1991 inputs,
1992 authorization,
1993 });
1994 replies.pop_front();
1995 }
1996 Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
1997 if Instant::now() >= deadline {
1998 break;
1999 }
2000 std::thread::sleep(Duration::from_millis(5));
2001 }
2002 Err(error) => panic!("accept failed: {error}"),
2003 }
2004 }
2005 requests_tx.send(requests).unwrap();
2006 });
2007 (url, requests_rx, server)
2008 }
2009
2010 fn write_mock_response(stream: &mut std::net::TcpStream, reply: MockReply, input_len: usize) {
2011 let (status, response) = match reply {
2012 MockReply::Embeddings(model) => {
2013 let data = (0..input_len)
2014 .map(|index| {
2015 serde_json::json!({
2016 "index": index,
2017 "embedding": [1.0, 0.0]
2018 })
2019 })
2020 .collect::<Vec<_>>();
2021 (
2022 "200 OK",
2023 serde_json::json!({ "data": data, "model": model }).to_string(),
2024 )
2025 }
2026 MockReply::Status(code) => {
2027 ("500 Internal Server Error", format!("{{\"code\":{code}}}"))
2028 }
2029 MockReply::Oversized => {
2030 write_oversized_response(stream);
2031 return;
2032 }
2033 };
2034 write!(
2035 stream,
2036 "HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
2037 response.len(),
2038 response
2039 )
2040 .unwrap();
2041 }
2042
2043 fn write_oversized_response(stream: &mut std::net::TcpStream) {
2044 const PREFIX: &[u8] = b"{\"data\":[],\"padding\":\"";
2045 const SUFFIX: &[u8] = b"\"}";
2046 const CHUNK: &[u8] = &[b'x'; 64 * 1024];
2047 let padding_len = ENDPOINT_RESPONSE_LIMIT_BYTES;
2048 let content_len = PREFIX.len() as u64 + padding_len + SUFFIX.len() as u64;
2049 write!(
2050 stream,
2051 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {content_len}\r\nconnection: close\r\n\r\n"
2052 )
2053 .unwrap();
2054 if stream.write_all(PREFIX).is_err() {
2055 return;
2056 }
2057 let mut remaining = padding_len;
2058 while remaining > 0 {
2059 let len = remaining.min(CHUNK.len() as u64) as usize;
2060 if stream.write_all(&CHUNK[..len]).is_err() {
2061 return;
2062 }
2063 remaining -= len as u64;
2064 }
2065 let _ = stream.write_all(SUFFIX);
2066 }
2067
2068 fn read_http_request(stream: &mut std::net::TcpStream) -> (serde_json::Value, Option<String>) {
2069 stream
2070 .set_read_timeout(Some(Duration::from_secs(2)))
2071 .unwrap();
2072 let mut request = Vec::new();
2073 let mut buffer = [0_u8; 4096];
2074 loop {
2075 let read = stream.read(&mut buffer).unwrap();
2076 assert!(read > 0, "connection closed before request body");
2077 request.extend_from_slice(&buffer[..read]);
2078 if let Some(header_end) = request.windows(4).position(|window| window == b"\r\n\r\n") {
2079 let body_start = header_end + 4;
2080 let headers = std::str::from_utf8(&request[..header_end]).unwrap();
2081 let content_len = headers
2082 .lines()
2083 .find_map(|line| {
2084 let (name, value) = line.split_once(':')?;
2085 name.eq_ignore_ascii_case("content-length")
2086 .then(|| value.trim().parse::<usize>().unwrap())
2087 })
2088 .expect("content-length");
2089 if request.len() >= body_start + content_len {
2090 let authorization = headers.lines().find_map(|line| {
2091 let (name, value) = line.split_once(':')?;
2092 name.eq_ignore_ascii_case("authorization")
2093 .then(|| value.trim().to_string())
2094 });
2095 let body =
2096 serde_json::from_slice(&request[body_start..body_start + content_len])
2097 .unwrap();
2098 return (body, authorization);
2099 }
2100 }
2101 }
2102 }
2103
2104 #[test]
2105 fn mean_pool_averages_only_unmasked_tokens() {
2106 let dev = Device::Cpu;
2107 let seq = Tensor::new(&[[[1.0f32, 2.0], [3.0, 4.0]]], &dev).unwrap();
2109 let all = Tensor::new(&[[1u32, 1]], &dev).unwrap();
2111 assert_eq!(
2112 mean_pool(&seq, &all).unwrap().to_vec1::<f32>().unwrap(),
2113 vec![2.0, 3.0]
2114 );
2115 let first = Tensor::new(&[[1u32, 0]], &dev).unwrap();
2117 assert_eq!(
2118 mean_pool(&seq, &first).unwrap().to_vec1::<f32>().unwrap(),
2119 vec![1.0, 2.0]
2120 );
2121 }
2122
2123 #[test]
2124 fn mean_pool_batch_averages_each_row_over_its_own_unmasked_tokens() {
2125 let dev = Device::Cpu;
2126 let seq = Tensor::new(
2128 &[[[1.0f32, 2.0], [3.0, 4.0]], [[5.0, 6.0], [7.0, 8.0]]],
2129 &dev,
2130 )
2131 .unwrap();
2132 let mask = Tensor::new(&[[1u32, 1], [1, 0]], &dev).unwrap();
2134 assert_eq!(
2135 mean_pool_batch(&seq, &mask)
2136 .unwrap()
2137 .to_vec2::<f32>()
2138 .unwrap(),
2139 vec![vec![2.0, 3.0], vec![5.0, 6.0]]
2140 );
2141 }
2142
2143 #[test]
2144 fn parse_pooling_config_maps_cls_mean_and_none() {
2145 assert_eq!(
2146 parse_pooling_config(br#"{"pooling_mode_cls_token": true}"#),
2147 Some(Pooling::Cls)
2148 );
2149 assert_eq!(
2150 parse_pooling_config(br#"{"pooling_mode_mean_tokens": true}"#),
2151 Some(Pooling::Mean)
2152 );
2153 assert_eq!(
2155 parse_pooling_config(br#"{"pooling_mode_max_tokens": true}"#),
2156 None
2157 );
2158 assert_eq!(parse_pooling_config(b"not json"), None);
2159 }
2160
2161 #[test]
2162 fn resolve_pooling_assumes_mean_and_flags_it() {
2163 assert_eq!(resolve_pooling(Some(Pooling::Cls)), (Pooling::Cls, false));
2164 assert_eq!(resolve_pooling(Some(Pooling::Mean)), (Pooling::Mean, false));
2165 assert_eq!(resolve_pooling(None), (Pooling::Mean, true));
2166 }
2167
2168 #[test]
2169 fn local_load_path_does_not_content_hash() {
2170 use crate::embedding_config::{test_content_hash_calls, test_reset_content_hash_calls};
2171
2172 let dir = tempfile::tempdir().unwrap();
2173 std::fs::write(dir.path().join("config.json"), b"not-a-bert-config").unwrap();
2177 std::fs::write(dir.path().join("tokenizer.json"), b"{}").unwrap();
2178 std::fs::write(dir.path().join("model.safetensors"), b"weights").unwrap();
2179
2180 test_reset_content_hash_calls();
2181 let result = CandleEmbedder::load_path(dir.path(), "", "", None);
2182 assert!(
2183 matches!(result, Err(EmbedderError::Load { .. })),
2184 "expected load failure on invalid config"
2185 );
2186 assert_eq!(
2187 test_content_hash_calls(),
2188 0,
2189 "CandleEmbedder::load_path must not digest Local model bytes"
2190 );
2191 }
2192
2193 #[test]
2194 fn is_not_found_distinguishes_missing_file_from_network_error() {
2195 assert!(is_not_found("Http status client error (404 Not Found)"));
2196 assert!(is_not_found("Entry Not Found"));
2197 assert!(!is_not_found("error sending request: connection refused"));
2198 }
2199
2200 #[test]
2201 fn endpoint_missing_api_key_env_is_a_config_error() {
2202 let e = EndpointEmbedder::new(
2203 "http://localhost:11434/v1/embeddings".into(),
2204 "nomic".into(),
2205 Some("RATEL_TEST_DEFINITELY_UNSET_KEY".into()),
2206 String::new(),
2207 String::new(),
2208 )
2209 .unwrap();
2210 let err = e.api_key().unwrap_err();
2211 assert!(matches!(err, EmbedderError::Config { .. }));
2212 assert!(err.to_string().contains("RATEL_TEST_DEFINITELY_UNSET_KEY"));
2213 }
2214
2215 #[test]
2216 #[ignore = "downloads the ~130 MB bge model; run with `cargo test -- --ignored`"]
2217 fn embeds_to_unit_norm_384_vectors_deterministically() {
2218 let e = embedder_for(&EmbeddingModel::Default)
2219 .expect("load embedder")
2220 .0;
2221 let a = e.embed_doc("read a file from disk").expect("embed");
2222 let b = e.embed_doc("read a file from disk").expect("embed");
2223 assert_eq!(a.len(), 384, "bge-small is 384-dim");
2224 assert_eq!(a, b, "same text must embed identically (determinism)");
2225 let norm = a.iter().map(|x| x * x).sum::<f32>().sqrt();
2226 assert!((norm - 1.0).abs() < 1e-3, "expected unit norm, got {norm}");
2227 }
2228
2229 #[test]
2230 #[ignore = "downloads the ~130 MB bge model; run with `cargo test -- --ignored`"]
2231 fn embed_batch_matches_looping_embed_doc_cls() {
2232 let e = embedder_for(&EmbeddingModel::Default)
2236 .expect("load embedder")
2237 .0;
2238 let docs: Vec<String> = (0..EMBED_BATCH_CHUNK + 5)
2239 .map(|i| format!("{}read a file from disk", "word ".repeat(i % 7)))
2240 .collect();
2241 let batched = e.embed_batch(&docs).expect("embed_batch");
2242 let looped: Vec<Vec<f32>> = docs
2243 .iter()
2244 .map(|d| e.embed_doc(d).expect("embed_doc"))
2245 .collect();
2246 assert_eq!(
2247 batched, looped,
2248 "batched embedding must be bit-for-bit identical to the per-doc path"
2249 );
2250 }
2251
2252 #[test]
2253 #[ignore = "downloads a mean-pooled model (gte-small); run with `cargo test -- --ignored`"]
2254 fn embed_batch_matches_looping_embed_doc_mean() {
2255 let model = EmbeddingModel::HuggingFace {
2257 repo: "thenlper/gte-small".into(),
2258 revision: None,
2259 query_prefix: None,
2260 doc_prefix: None,
2261 pooling: None, download: true, };
2264 let e = embedder_for(&model).expect("load gte-small").0;
2265 let docs: Vec<String> = (0..EMBED_BATCH_CHUNK + 3)
2266 .map(|i| format!("{}deploy the service", "x ".repeat(i % 5)))
2267 .collect();
2268 let batched = e.embed_batch(&docs).expect("embed_batch");
2269 let looped: Vec<Vec<f32>> = docs
2270 .iter()
2271 .map(|d| e.embed_doc(d).expect("embed_doc"))
2272 .collect();
2273 assert_eq!(
2274 batched, looped,
2275 "mean-pooled batched embedding must be bit-for-bit identical to the per-doc path"
2276 );
2277 }
2278
2279 #[test]
2280 #[ignore = "downloads the ~130 MB bge model; run with `cargo test -- --ignored`"]
2281 fn query_prefix_changes_the_embedding() {
2282 let e = embedder_for(&EmbeddingModel::Default)
2283 .expect("load embedder")
2284 .0;
2285 let doc = e.embed_doc("delete a file").expect("embed");
2286 let query = e.embed_query("delete a file").expect("embed");
2287 assert_ne!(doc, query, "query instruction prefix must shift the vector");
2288 }
2289
2290 #[test]
2291 #[ignore = "downloads the ~130 MB bge model; run with `cargo test -- --ignored`"]
2292 fn ranks_synonyms_above_lexically_unrelated_text() {
2293 let e = embedder_for(&EmbeddingModel::Default)
2295 .expect("load embedder")
2296 .0;
2297 let q = e.embed_query("remove a file").expect("embed");
2298 let delete = e
2299 .embed_doc("delete a path from the filesystem")
2300 .expect("embed");
2301 let weather = e
2302 .embed_doc("get the current weather forecast")
2303 .expect("embed");
2304 let dot = |a: &[f32], b: &[f32]| a.iter().zip(b).map(|(x, y)| x * y).sum::<f32>();
2305 assert!(
2306 dot(&q, &delete) > dot(&q, &weather),
2307 "semantic match should beat an unrelated tool"
2308 );
2309 }
2310
2311 #[test]
2312 #[ignore = "downloads a mean-pooled model (gte-small); run with `cargo test -- --ignored`"]
2313 fn mean_pooled_model_ranks_synonyms_correctly() {
2314 let model = EmbeddingModel::HuggingFace {
2318 repo: "thenlper/gte-small".into(),
2319 revision: None,
2320 query_prefix: None,
2321 doc_prefix: None,
2322 pooling: None, download: true, };
2325 let e = embedder_for(&model).expect("load gte-small").0;
2326 let q = e.embed_query("remove a file").expect("embed");
2327 let delete = e
2328 .embed_doc("delete a path from the filesystem")
2329 .expect("embed");
2330 let weather = e
2331 .embed_doc("get the current weather forecast")
2332 .expect("embed");
2333 let dot = |a: &[f32], b: &[f32]| a.iter().zip(b).map(|(x, y)| x * y).sum::<f32>();
2334 assert!(
2335 dot(&q, &delete) > dot(&q, &weather),
2336 "mean-pooled semantic match should beat an unrelated tool"
2337 );
2338 }
2339}