Skip to main content

ratel_ai_core/
embedding_config.rs

1//! Which embedding model backs a catalog's semantic/hybrid retrieval.
2//!
3//! The model is chosen per catalog and declared once, then used for both
4//! document and query embedding so the two sides can never land in different
5//! vector spaces. Four sources: the built-in default (`bge-small`), any
6//! BERT-family HuggingFace repo or on-disk directory (loaded in-process via
7//! Candle), and an OpenAI-compatible HTTP endpoint (any model, incl. Ollama).
8//!
9//! [`EmbeddingModel::resolve`] turns the cross-SDK [`EmbeddingSpec`] DTO into a
10//! validated model. The source is named explicitly: a bare string is a **local
11//! directory path**, and every other source is a keyed object
12//! (`{huggingface}` / `{local}` / `{ollama}` / `{url, model}`), symmetric across
13//! the board. Resolution/validation **lives here** (in the core) so both SDKs
14//! share one implementation instead of two that could drift. See ADR-0012.
15
16use std::path::{Path, PathBuf};
17
18use crate::embedding::EmbedderError;
19
20/// The built-in default: bge-small, pinned to a commit so embeddings are
21/// reproducible. These are the canonical identity of the zero-config model;
22/// `embedding.rs` loads against them.
23pub(crate) const DEFAULT_REPO: &str = "BAAI/bge-small-en-v1.5";
24pub(crate) const DEFAULT_REVISION: &str = "5c38ec7c405ec4b44b94cc5a9bb96e735b38267a";
25/// bge asymmetric-retrieval query prefix; only the query side gets it.
26pub(crate) const DEFAULT_QUERY_INSTRUCTION: &str =
27    "Represent this sentence for searching relevant passages: ";
28
29/// Default Ollama OpenAI-compatible embeddings route. The `{ollama: model}`
30/// shortcut expands to this; a non-default host uses the full `{url, model}` form.
31pub(crate) const OLLAMA_DEFAULT_URL: &str = "http://localhost:11434/v1/embeddings";
32
33/// How a BERT model's per-token outputs are collapsed into one sentence vector.
34/// A model is *trained* with one mode — using the other silently degrades ranking
35/// — so it is auto-detected from the repo's `1_Pooling/config.json`, with this as
36/// an explicit override.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum Pooling {
39    /// The `[CLS]` (first) token's vector. bge is CLS-pooled.
40    Cls,
41    /// Masked average of all token vectors. e5/gte/MiniLM/mpnet are mean-pooled.
42    Mean,
43}
44
45impl Pooling {
46    pub(crate) fn as_str(self) -> &'static str {
47        match self {
48            Pooling::Cls => "cls",
49            Pooling::Mean => "mean",
50        }
51    }
52}
53
54impl std::str::FromStr for Pooling {
55    type Err = EmbedderError;
56    fn from_str(s: &str) -> Result<Self, Self::Err> {
57        match s.to_ascii_lowercase().as_str() {
58            "cls" => Ok(Pooling::Cls),
59            "mean" => Ok(Pooling::Mean),
60            other => Err(cfg(format!(
61                "unknown pooling '{other}'; expected 'cls' or 'mean'"
62            ))),
63        }
64    }
65}
66
67/// Model-identity suffix for the fingerprint / process-cache key. Pooling and the
68/// asymmetric prefixes change the produced vectors, so two configs differing only
69/// in these must not share a cached embedder or pass the drift check.
70pub(crate) fn fingerprint_suffix(
71    pooling: Option<Pooling>,
72    query_prefix: &str,
73    doc_prefix: &str,
74) -> String {
75    let mut s = String::new();
76    if let Some(p) = pooling {
77        push_fingerprint_field(&mut s, "pool", p.as_str());
78    }
79    if !query_prefix.is_empty() {
80        push_fingerprint_field(&mut s, "q", query_prefix);
81    }
82    if !doc_prefix.is_empty() {
83        push_fingerprint_field(&mut s, "d", doc_prefix);
84    }
85    s
86}
87
88pub(crate) fn huggingface_fingerprint(repo: &str, revision: &str) -> String {
89    fingerprint("hf", &[("repo", repo), ("revision", revision)])
90}
91
92pub(crate) fn local_fingerprint(path: &str) -> String {
93    fingerprint("local", &[("path", path)])
94}
95
96pub(crate) fn endpoint_fingerprint(url: &str, model: &str) -> String {
97    fingerprint("endpoint", &[("url", url), ("model", model)])
98}
99
100/// The embedding model backing a catalog's semantic/hybrid engines.
101#[derive(Debug, Clone, PartialEq)]
102pub enum EmbeddingModel {
103    /// Built-in `bge-small-en-v1.5`, pinned. The zero-config default.
104    Default,
105    /// A BERT-family HuggingFace repo, loaded in-process via Candle. `revision`
106    /// defaults to `main`; `pooling` is auto-detected when `None`. `download`
107    /// (default `false`) must be opted into for Ratel to fetch it — otherwise it
108    /// must already be in the local cache (Ratel auto-downloads only the default).
109    HuggingFace {
110        /// HuggingFace repo id (e.g. `intfloat/e5-small-v2`).
111        repo: String,
112        /// Git revision to pin; `None` → `main`.
113        revision: Option<String>,
114        /// Query-side prefix for asymmetric models.
115        query_prefix: Option<String>,
116        /// Document-side prefix for asymmetric models.
117        doc_prefix: Option<String>,
118        /// Pooling override; `None` auto-detects.
119        pooling: Option<Pooling>,
120        /// Opt in to downloading if not already cached.
121        download: bool,
122    },
123    /// A BERT-family model directory on disk (`config.json` / `tokenizer.json` /
124    /// `model.safetensors`), loaded in-process via Candle.
125    Local {
126        /// Path to the model directory.
127        path: PathBuf,
128        /// Query-side prefix for asymmetric models.
129        query_prefix: Option<String>,
130        /// Document-side prefix for asymmetric models.
131        doc_prefix: Option<String>,
132        /// Pooling override; `None` auto-detects.
133        pooling: Option<Pooling>,
134    },
135    /// An OpenAI-compatible `/embeddings` HTTP endpoint (OpenAI, Ollama, TEI,
136    /// vLLM…). `api_key_env` names the env var holding the key (read at call time).
137    /// Pooling lives server-side, so there is no `pooling` here.
138    Endpoint {
139        /// Full endpoint URL.
140        url: String,
141        /// Model name sent in the request body.
142        model: String,
143        /// Env var holding the bearer key; `None` for no auth.
144        api_key_env: Option<String>,
145        /// Query-side prefix for asymmetric models.
146        query_prefix: Option<String>,
147        /// Document-side prefix for asymmetric models.
148        doc_prefix: Option<String>,
149    },
150}
151
152/// Normalized, cross-SDK embedding config as forwarded by the native bindings.
153/// Exactly one *primary* source must be set: either `spec` (the raw string
154/// shortcut) or one of `huggingface` / `local` / `ollama` / `url`. The rest are
155/// modifiers.
156#[derive(Debug, Clone, Default)]
157pub struct EmbeddingSpec {
158    /// Raw string shortcut — a **local model directory path** only. A repo-id or
159    /// URL string is rejected in favor of the explicit `huggingface`/`url` keys.
160    pub spec: Option<String>,
161    /// Primary source: a HuggingFace repo id.
162    pub huggingface: Option<String>,
163    /// Primary source: a local model directory path.
164    pub local: Option<String>,
165    /// Primary source: an Ollama model name (served via the local Ollama endpoint).
166    pub ollama: Option<String>,
167    /// Primary source: a full OpenAI-compatible endpoint URL.
168    pub url: Option<String>,
169    /// Model name for an endpoint source.
170    pub model: Option<String>,
171    /// Git revision for a HuggingFace source.
172    pub revision: Option<String>,
173    /// Env var holding the endpoint bearer key.
174    pub api_key_env: Option<String>,
175    /// Query-side prefix for asymmetric models.
176    pub query_prefix: Option<String>,
177    /// Document-side prefix for asymmetric models (e.g. e5's `"passage: "`).
178    pub doc_prefix: Option<String>,
179    /// `"cls"` | `"mean"` — overrides auto-detection for an in-process model.
180    pub pooling: Option<String>,
181    /// Opt in to letting Ratel download a HuggingFace model that is not yet
182    /// cached (default `false`; the built-in default always downloads).
183    pub download: Option<bool>,
184}
185
186fn cfg(message: impl Into<String>) -> EmbedderError {
187    EmbedderError::Config {
188        message: message.into(),
189    }
190}
191
192impl EmbeddingModel {
193    /// Validate a concrete Rust model value. SDK configs normally enter through
194    /// [`Self::resolve`], but Rust callers can construct public enum variants
195    /// directly; this keeps that path subject to the same nonblank-field rules.
196    ///
197    /// # Errors
198    ///
199    /// [`EmbedderError::Config`] when a required source, model, URL, or env-var
200    /// name is blank.
201    pub fn validate(&self) -> Result<(), EmbedderError> {
202        match self {
203            EmbeddingModel::Default => Ok(()),
204            EmbeddingModel::HuggingFace { repo, .. } => validate_nonblank("huggingface", repo),
205            EmbeddingModel::Local { path, .. } => {
206                validate_nonblank("local", &path.to_string_lossy())
207            }
208            EmbeddingModel::Endpoint {
209                url,
210                model,
211                api_key_env,
212                ..
213            } => {
214                validate_nonblank("url", url)?;
215                validate_nonblank("model", model)?;
216                if let Some(api_key_env) = api_key_env {
217                    validate_nonblank("api_key_env", api_key_env)?;
218                }
219                Ok(())
220            }
221        }
222    }
223
224    /// Validate and resolve a spec into a concrete model. Runs at catalog
225    /// construction, so config mistakes surface immediately (not at first search).
226    pub fn resolve(spec: EmbeddingSpec) -> Result<EmbeddingModel, EmbedderError> {
227        for (name, value) in [
228            ("spec", spec.spec.as_deref()),
229            ("huggingface", spec.huggingface.as_deref()),
230            ("local", spec.local.as_deref()),
231            ("ollama", spec.ollama.as_deref()),
232            ("url", spec.url.as_deref()),
233            ("model", spec.model.as_deref()),
234            ("api_key_env", spec.api_key_env.as_deref()),
235        ] {
236            if value.is_some_and(|value| value.trim().is_empty()) {
237                return Err(cfg(format!("embedding '{name}' must not be blank")));
238            }
239        }
240
241        let primaries = [
242            ("spec", spec.spec.is_some()),
243            ("huggingface", spec.huggingface.is_some()),
244            ("local", spec.local.is_some()),
245            ("ollama", spec.ollama.is_some()),
246            ("url", spec.url.is_some()),
247        ];
248        let set: Vec<&str> = primaries
249            .iter()
250            .filter(|(_, present)| *present)
251            .map(|(key, _)| *key)
252            .collect();
253        match set.len() {
254            0 => {
255                return Err(cfg(
256                    "no embedding source given; pass a local directory path, or one of \
257                     huggingface/local/ollama/url",
258                ));
259            }
260            1 => {}
261            _ => {
262                return Err(cfg(format!(
263                    "conflicting embedding keys {set:?}; give exactly one of \
264                     spec/huggingface/local/ollama/url",
265                )));
266            }
267        }
268
269        // Parse the pooling override once (validates the string); only in-process
270        // sources may carry it.
271        let pooling = spec
272            .pooling
273            .as_deref()
274            .map(str::parse::<Pooling>)
275            .transpose()?;
276
277        // `download` is a HuggingFace-only fetch policy.
278        if spec.download.is_some() && set[0] != "huggingface" {
279            return Err(cfg("'download' is only valid for a HuggingFace repo"));
280        }
281
282        let model = match set[0] {
283            "spec" => infer_from_string(spec.spec.as_deref().unwrap(), &spec, pooling),
284            "huggingface" => {
285                reject_endpoint_only(&spec, "a HuggingFace repo")?;
286                Ok(EmbeddingModel::HuggingFace {
287                    repo: spec.huggingface.unwrap(),
288                    revision: spec.revision,
289                    query_prefix: spec.query_prefix,
290                    doc_prefix: spec.doc_prefix,
291                    pooling,
292                    download: spec.download.unwrap_or(false),
293                })
294            }
295            "local" => {
296                reject_endpoint_only(&spec, "a local model")?;
297                if spec.revision.is_some() {
298                    return Err(cfg("'revision' is only valid for a HuggingFace repo"));
299                }
300                Ok(EmbeddingModel::Local {
301                    path: PathBuf::from(spec.local.unwrap()),
302                    query_prefix: spec.query_prefix,
303                    doc_prefix: spec.doc_prefix,
304                    pooling,
305                })
306            }
307            "ollama" => {
308                if spec.model.is_some() {
309                    return Err(cfg(
310                        "'model' is redundant with 'ollama' (the ollama value is the model name)",
311                    ));
312                }
313                if spec.api_key_env.is_some() {
314                    return Err(cfg(
315                        "'api_key_env' is not valid with the Ollama shortcut; use a full endpoint 'url'",
316                    ));
317                }
318                reject_in_process_only(&spec, pooling)?;
319                Ok(EmbeddingModel::Endpoint {
320                    url: OLLAMA_DEFAULT_URL.to_string(),
321                    model: spec.ollama.unwrap(),
322                    api_key_env: None,
323                    query_prefix: spec.query_prefix,
324                    doc_prefix: spec.doc_prefix,
325                })
326            }
327            "url" => {
328                reject_in_process_only(&spec, pooling)?;
329                let model = spec
330                    .model
331                    .ok_or_else(|| cfg("endpoint embedding requires both 'url' and 'model'"))?;
332                Ok(EmbeddingModel::Endpoint {
333                    url: spec.url.unwrap(),
334                    model,
335                    api_key_env: spec.api_key_env,
336                    query_prefix: spec.query_prefix,
337                    doc_prefix: spec.doc_prefix,
338                })
339            }
340            _ => unreachable!("primary key set is closed"),
341        }?;
342        model.validate()?;
343        Ok(model)
344    }
345
346    /// The query-side instruction prefix (bge is asymmetric). Empty unless the
347    /// model sets one; the built-in default carries bge's instruction.
348    pub(crate) fn query_prefix(&self) -> &str {
349        match self {
350            EmbeddingModel::Default => DEFAULT_QUERY_INSTRUCTION,
351            EmbeddingModel::HuggingFace { query_prefix, .. }
352            | EmbeddingModel::Local { query_prefix, .. }
353            | EmbeddingModel::Endpoint { query_prefix, .. } => {
354                query_prefix.as_deref().unwrap_or("")
355            }
356        }
357    }
358
359    /// The document-side prefix (asymmetric models like e5 use `"passage: "`).
360    /// Empty unless the model sets one.
361    pub(crate) fn doc_prefix(&self) -> &str {
362        match self {
363            EmbeddingModel::Default => "",
364            EmbeddingModel::HuggingFace { doc_prefix, .. }
365            | EmbeddingModel::Local { doc_prefix, .. }
366            | EmbeddingModel::Endpoint { doc_prefix, .. } => doc_prefix.as_deref().unwrap_or(""),
367        }
368    }
369
370    /// The explicit pooling override, if any. The built-in default is pinned to
371    /// CLS (so it never needs the pooling-config fetch); an in-process model uses
372    /// its field (auto-detected when `None`); an endpoint pools server-side.
373    pub(crate) fn pooling_override(&self) -> Option<Pooling> {
374        match self {
375            EmbeddingModel::Default => Some(Pooling::Cls),
376            EmbeddingModel::HuggingFace { pooling, .. } | EmbeddingModel::Local { pooling, .. } => {
377                *pooling
378            }
379            EmbeddingModel::Endpoint { .. } => None,
380        }
381    }
382
383    /// Human-readable model name for telemetry (the `model` field of load
384    /// events). Friendlier than the fingerprint.
385    pub(crate) fn display_name(&self) -> String {
386        match self {
387            EmbeddingModel::Default => DEFAULT_REPO.to_string(),
388            EmbeddingModel::HuggingFace { repo, .. } => repo.clone(),
389            EmbeddingModel::Local { path, .. } => path.display().to_string(),
390            EmbeddingModel::Endpoint { url, model, .. } => format!("{model} @ {url}"),
391        }
392    }
393
394    /// Pre-load identity used to **key the process model cache** so two catalogs
395    /// on the same model load it once. HF `revision` may still be `main` here;
396    /// the resolved-with-SHA form is [`crate::embedding::Embedder::fingerprint`],
397    /// stamped on the dense cache after load.
398    pub(crate) fn configured_fingerprint(&self) -> String {
399        let base = match self {
400            EmbeddingModel::Default => huggingface_fingerprint(DEFAULT_REPO, DEFAULT_REVISION),
401            EmbeddingModel::HuggingFace { repo, revision, .. } => {
402                huggingface_fingerprint(repo, revision.as_deref().unwrap_or("main"))
403            }
404            EmbeddingModel::Local { path, .. } => local_fingerprint(&path.display().to_string()),
405            EmbeddingModel::Endpoint { url, model, .. } => endpoint_fingerprint(url, model),
406        };
407        // Pooling + prefixes change the vectors, so they are part of the identity.
408        format!(
409            "{base}{}",
410            fingerprint_suffix(
411                self.pooling_override(),
412                self.query_prefix(),
413                self.doc_prefix()
414            )
415        )
416    }
417
418    /// Process-cache identity for the client/embedder instance. Credentials do
419    /// not change the vector space, so [`Self::configured_fingerprint`] excludes
420    /// them; the cached endpoint client does capture the *name* of the env var it
421    /// reads, however, so that non-secret name must distinguish client instances.
422    pub(crate) fn embedder_cache_key(&self) -> String {
423        let vector_identity = self.configured_fingerprint();
424        match self {
425            EmbeddingModel::Endpoint { api_key_env, .. } => match api_key_env {
426                Some(name) => {
427                    let mut key = vector_identity;
428                    push_fingerprint_field(&mut key, "api_key_env", name);
429                    key
430                }
431                None => format!("{vector_identity}|api_key_env=none"),
432            },
433            _ => vector_identity,
434        }
435    }
436}
437
438fn fingerprint(kind: &str, fields: &[(&str, &str)]) -> String {
439    let mut fingerprint = kind.to_string();
440    for (name, value) in fields {
441        push_fingerprint_field(&mut fingerprint, name, value);
442    }
443    fingerprint
444}
445
446fn push_fingerprint_field(fingerprint: &mut String, name: &str, value: &str) {
447    fingerprint.push_str(&format!("|{name}={}:{}", value.len(), value));
448}
449
450fn validate_nonblank(name: &str, value: &str) -> Result<(), EmbedderError> {
451    if value.trim().is_empty() {
452        return Err(cfg(format!("embedding '{name}' must not be blank")));
453    }
454    Ok(())
455}
456
457/// Reject endpoint-only modifiers on an in-process (HF/local) source.
458fn reject_endpoint_only(spec: &EmbeddingSpec, what: &str) -> Result<(), EmbedderError> {
459    if spec.model.is_some() {
460        return Err(cfg(format!(
461            "'model' is only valid with an endpoint 'url', not {what}"
462        )));
463    }
464    if spec.api_key_env.is_some() {
465        return Err(cfg(format!(
466            "'api_key_env' is only valid with an endpoint 'url', not {what}"
467        )));
468    }
469    Ok(())
470}
471
472/// Reject in-process-only modifiers on an endpoint source: `revision` (no HF
473/// fetch) and `pooling` (the server pools).
474fn reject_in_process_only(
475    spec: &EmbeddingSpec,
476    pooling: Option<Pooling>,
477) -> Result<(), EmbedderError> {
478    if spec.revision.is_some() {
479        return Err(cfg("'revision' is only valid for a HuggingFace repo"));
480    }
481    if pooling.is_some() {
482        return Err(cfg(
483            "'pooling' is only valid for an in-process model (huggingface/local); \
484             an endpoint pools server-side",
485        ));
486    }
487    Ok(())
488}
489
490/// Interpret the raw string shortcut, which is a **local model directory path
491/// only** — every non-path source (HuggingFace, endpoint) uses the explicit
492/// keyed object, symmetric with `{ollama}`/`{url}`. A URL or a repo-id-looking
493/// string is rejected with a pointer to the right object form, so the source is
494/// never guessed from an ambiguous string.
495fn infer_from_string(
496    s: &str,
497    spec: &EmbeddingSpec,
498    pooling: Option<Pooling>,
499) -> Result<EmbeddingModel, EmbedderError> {
500    if spec.model.is_some() || spec.api_key_env.is_some() {
501        return Err(cfg(
502            "'model'/'api_key_env' are only valid with an endpoint 'url'; a bare string \
503             is only a local model directory path",
504        ));
505    }
506    if spec.revision.is_some() {
507        return Err(cfg("'revision' is only valid for a HuggingFace repo; use \
508             {\"huggingface\": \"…\", \"revision\": \"…\"}"));
509    }
510    if looks_like_url(s) {
511        return Err(cfg(format!(
512            "'{s}' looks like an endpoint URL but has no model name; use \
513             {{\"url\": \"{s}\", \"model\": \"…\"}}"
514        )));
515    }
516    if looks_like_path(s) || Path::new(s).is_dir() {
517        return Ok(EmbeddingModel::Local {
518            path: PathBuf::from(s),
519            query_prefix: spec.query_prefix.clone(),
520            doc_prefix: spec.doc_prefix.clone(),
521            pooling,
522        });
523    }
524    // Not a path → most likely a HuggingFace repo id. The bare-string form is
525    // local-only, so name the explicit object rather than guess the source.
526    Err(cfg(format!(
527        "'{s}' is not a local directory path; to use a HuggingFace repo pass \
528         {{\"huggingface\": \"{s}\"}}, or give an absolute/relative directory \
529         path for a local model"
530    )))
531}
532
533/// A `scheme://…` URL. Requires `://`, so a Windows `C:\…` path never matches.
534fn looks_like_url(s: &str) -> bool {
535    match s.find("://") {
536        Some(idx) if idx > 0 => {
537            let scheme = &s[..idx];
538            scheme
539                .chars()
540                .next()
541                .is_some_and(|c| c.is_ascii_alphabetic())
542                && scheme
543                    .chars()
544                    .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '.' | '-'))
545        }
546        _ => false,
547    }
548}
549
550/// An unambiguous local-path intent — even if the path doesn't exist yet, so a
551/// mistyped local path errors as "not found" rather than a phantom HF repo.
552fn looks_like_path(s: &str) -> bool {
553    s.starts_with('/')
554        || s.starts_with("./")
555        || s.starts_with("../")
556        || s.starts_with('~')
557        || s.starts_with(r"\\") // UNC
558        || s.starts_with(r".\")
559        || s.starts_with(r"..\")
560        || is_windows_drive(s)
561}
562
563/// `C:\` or `C:/` — a drive letter, colon, separator.
564fn is_windows_drive(s: &str) -> bool {
565    let b = s.as_bytes();
566    b.len() >= 3 && b[0].is_ascii_alphabetic() && b[1] == b':' && (b[2] == b'\\' || b[2] == b'/')
567}
568
569#[cfg(test)]
570mod tests {
571    use super::*;
572
573    fn from_str(s: &str) -> Result<EmbeddingModel, EmbedderError> {
574        EmbeddingModel::resolve(EmbeddingSpec {
575            spec: Some(s.to_string()),
576            ..Default::default()
577        })
578    }
579
580    #[test]
581    fn bare_repo_id_string_is_rejected_pointing_to_huggingface() {
582        // A repo-id-looking string is not a path → rejected with a pointer to the
583        // explicit object form, so the source is never guessed.
584        let err = from_str("BAAI/bge-base-en-v1.5").unwrap_err();
585        assert!(matches!(err, EmbedderError::Config { .. }));
586        assert!(err.to_string().contains("huggingface"), "got: {err}");
587    }
588
589    #[test]
590    fn huggingface_object_infers_default_revision() {
591        assert_eq!(
592            EmbeddingModel::resolve(EmbeddingSpec {
593                huggingface: Some("BAAI/bge-base-en-v1.5".into()),
594                ..Default::default()
595            })
596            .unwrap(),
597            EmbeddingModel::HuggingFace {
598                repo: "BAAI/bge-base-en-v1.5".into(),
599                revision: None,
600                query_prefix: None,
601                doc_prefix: None,
602                pooling: None,
603                download: false,
604            }
605        );
606    }
607
608    #[test]
609    fn absolute_and_relative_paths_infer_local_even_when_absent() {
610        for p in [
611            "/opt/models/x",
612            "./models/x",
613            "../x",
614            "~/models/x",
615            r"\\host\share\x",
616        ] {
617            assert!(
618                matches!(from_str(p).unwrap(), EmbeddingModel::Local { .. }),
619                "{p} should infer Local"
620            );
621        }
622    }
623
624    #[test]
625    fn windows_drive_path_is_local_not_url() {
626        for p in [r"C:\models\bge", "C:/models/bge"] {
627            assert!(
628                matches!(from_str(p).unwrap(), EmbeddingModel::Local { .. }),
629                "{p} should infer Local, not be mistaken for a URL"
630            );
631        }
632    }
633
634    #[test]
635    fn existing_directory_infers_local() {
636        let dir = tempfile::tempdir().unwrap();
637        let path = dir.path().to_str().unwrap();
638        assert!(matches!(
639            from_str(path).unwrap(),
640            EmbeddingModel::Local { .. }
641        ));
642    }
643
644    #[test]
645    fn bare_url_string_is_rejected_needs_model() {
646        let err = from_str("https://api.openai.com/v1/embeddings").unwrap_err();
647        assert!(matches!(err, EmbedderError::Config { .. }));
648        assert!(err.to_string().contains("model"), "got: {err}");
649    }
650
651    #[test]
652    fn ollama_object_expands_to_localhost_endpoint() {
653        let m = EmbeddingModel::resolve(EmbeddingSpec {
654            ollama: Some("nomic-embed-text".into()),
655            ..Default::default()
656        })
657        .unwrap();
658        assert_eq!(
659            m,
660            EmbeddingModel::Endpoint {
661                url: OLLAMA_DEFAULT_URL.into(),
662                model: "nomic-embed-text".into(),
663                api_key_env: None,
664                query_prefix: None,
665                doc_prefix: None,
666            }
667        );
668    }
669
670    #[test]
671    fn endpoint_object_requires_model() {
672        let err = EmbeddingModel::resolve(EmbeddingSpec {
673            url: Some("https://api.openai.com/v1/embeddings".into()),
674            ..Default::default()
675        })
676        .unwrap_err();
677        assert!(err.to_string().contains("'url' and 'model'"), "got: {err}");
678    }
679
680    #[test]
681    fn ollama_and_url_together_conflict() {
682        let err = EmbeddingModel::resolve(EmbeddingSpec {
683            ollama: Some("nomic".into()),
684            url: Some("http://host:11434/v1/embeddings".into()),
685            model: Some("nomic".into()),
686            ..Default::default()
687        })
688        .unwrap_err();
689        assert!(err.to_string().contains("conflicting"), "got: {err}");
690    }
691
692    #[test]
693    fn huggingface_object_with_revision() {
694        let m = EmbeddingModel::resolve(EmbeddingSpec {
695            huggingface: Some("BAAI/bge-base-en-v1.5".into()),
696            revision: Some("abc123".into()),
697            ..Default::default()
698        })
699        .unwrap();
700        assert_eq!(
701            m,
702            EmbeddingModel::HuggingFace {
703                repo: "BAAI/bge-base-en-v1.5".into(),
704                revision: Some("abc123".into()),
705                query_prefix: None,
706                doc_prefix: None,
707                pooling: None,
708                download: false,
709            }
710        );
711    }
712
713    #[test]
714    fn empty_spec_is_rejected() {
715        assert!(EmbeddingModel::resolve(EmbeddingSpec::default()).is_err());
716    }
717
718    #[test]
719    fn blank_source_and_endpoint_fields_are_rejected() {
720        for spec in [
721            EmbeddingSpec {
722                huggingface: Some("   ".into()),
723                ..Default::default()
724            },
725            EmbeddingSpec {
726                local: Some("\t".into()),
727                ..Default::default()
728            },
729            EmbeddingSpec {
730                ollama: Some("\n".into()),
731                ..Default::default()
732            },
733            EmbeddingSpec {
734                url: Some(" ".into()),
735                model: Some("model".into()),
736                ..Default::default()
737            },
738            EmbeddingSpec {
739                url: Some("http://localhost/v1/embeddings".into()),
740                model: Some(" ".into()),
741                ..Default::default()
742            },
743            EmbeddingSpec {
744                url: Some("http://localhost/v1/embeddings".into()),
745                model: Some("model".into()),
746                api_key_env: Some(" ".into()),
747                ..Default::default()
748            },
749        ] {
750            assert!(matches!(
751                EmbeddingModel::resolve(spec),
752                Err(EmbedderError::Config { .. })
753            ));
754        }
755    }
756
757    #[test]
758    fn ollama_rejects_api_key_env_instead_of_ignoring_it() {
759        let err = EmbeddingModel::resolve(EmbeddingSpec {
760            ollama: Some("nomic-embed-text".into()),
761            api_key_env: Some("OLLAMA_KEY".into()),
762            ..Default::default()
763        })
764        .unwrap_err();
765        assert!(matches!(err, EmbedderError::Config { .. }));
766        assert!(err.to_string().contains("api_key_env"));
767    }
768
769    #[test]
770    fn download_defaults_false_and_is_huggingface_only() {
771        // Default off — explicit HF models are cache-only unless opted in.
772        assert!(matches!(
773            EmbeddingModel::resolve(EmbeddingSpec {
774                huggingface: Some("org/m".into()),
775                ..Default::default()
776            })
777            .unwrap(),
778            EmbeddingModel::HuggingFace {
779                download: false,
780                ..
781            }
782        ));
783        // Opt in.
784        assert!(matches!(
785            EmbeddingModel::resolve(EmbeddingSpec {
786                huggingface: Some("org/m".into()),
787                download: Some(true),
788                ..Default::default()
789            })
790            .unwrap(),
791            EmbeddingModel::HuggingFace { download: true, .. }
792        ));
793        // Meaningless on a non-HF source.
794        let err = EmbeddingModel::resolve(EmbeddingSpec {
795            ollama: Some("nomic".into()),
796            download: Some(true),
797            ..Default::default()
798        })
799        .unwrap_err();
800        assert!(err.to_string().contains("download"), "got: {err}");
801    }
802
803    #[test]
804    fn default_query_prefix_is_bge_instruction() {
805        assert_eq!(
806            EmbeddingModel::Default.query_prefix(),
807            DEFAULT_QUERY_INSTRUCTION
808        );
809        assert_eq!(
810            EmbeddingModel::Endpoint {
811                url: "u".into(),
812                model: "m".into(),
813                api_key_env: None,
814                query_prefix: None,
815                doc_prefix: None,
816            }
817            .query_prefix(),
818            ""
819        );
820    }
821
822    #[test]
823    fn fingerprints_are_distinct_per_source() {
824        // The default carries its pinned CLS pooling + bge query prefix.
825        assert_eq!(
826            EmbeddingModel::Default.configured_fingerprint(),
827            format!(
828                "hf|repo={}:{}|revision={}:{}|pool=3:cls|q={}:{}",
829                DEFAULT_REPO.len(),
830                DEFAULT_REPO,
831                DEFAULT_REVISION.len(),
832                DEFAULT_REVISION,
833                DEFAULT_QUERY_INSTRUCTION.len(),
834                DEFAULT_QUERY_INSTRUCTION
835            )
836        );
837        assert_eq!(
838            EmbeddingModel::HuggingFace {
839                repo: "r".into(),
840                revision: None,
841                query_prefix: None,
842                doc_prefix: None,
843                pooling: None,
844                download: false,
845            }
846            .configured_fingerprint(),
847            "hf|repo=1:r|revision=4:main"
848        );
849        assert_eq!(
850            EmbeddingModel::Endpoint {
851                url: "u".into(),
852                model: "m".into(),
853                api_key_env: None,
854                query_prefix: None,
855                doc_prefix: None,
856            }
857            .configured_fingerprint(),
858            "endpoint|url=1:u|model=1:m"
859        );
860    }
861
862    #[test]
863    fn endpoint_client_cache_key_includes_env_name_but_vector_identity_does_not() {
864        let endpoint = |api_key_env: &str| EmbeddingModel::Endpoint {
865            url: "https://example.test/v1/embeddings".into(),
866            model: "embed-v1".into(),
867            api_key_env: Some(api_key_env.into()),
868            query_prefix: None,
869            doc_prefix: None,
870        };
871        let a = endpoint("KEY_A");
872        let b = endpoint("KEY_B");
873
874        assert_eq!(a.configured_fingerprint(), b.configured_fingerprint());
875        assert_ne!(a.embedder_cache_key(), b.embedder_cache_key());
876        assert!(a.embedder_cache_key().contains("KEY_A"));
877    }
878
879    #[test]
880    fn fingerprint_fields_cannot_collide_through_delimiters() {
881        let endpoint = |url: &str, model: &str, query_prefix: &str, doc_prefix: Option<&str>| {
882            EmbeddingModel::Endpoint {
883                url: url.into(),
884                model: model.into(),
885                api_key_env: None,
886                query_prefix: Some(query_prefix.into()),
887                doc_prefix: doc_prefix.map(str::to_string),
888            }
889        };
890
891        assert_ne!(
892            endpoint("https://example.test#a", "b", "", None).configured_fingerprint(),
893            endpoint("https://example.test", "a#b", "", None).configured_fingerprint()
894        );
895        assert_ne!(
896            endpoint("u", "m", "x|d=y", None).configured_fingerprint(),
897            endpoint("u", "m", "x", Some("y")).configured_fingerprint()
898        );
899    }
900
901    #[test]
902    fn endpoint_cache_key_distinguishes_no_key_from_literal_sentinel_name() {
903        let endpoint = |api_key_env| EmbeddingModel::Endpoint {
904            url: "u".into(),
905            model: "m".into(),
906            api_key_env,
907            query_prefix: None,
908            doc_prefix: None,
909        };
910
911        assert_ne!(
912            endpoint(None).embedder_cache_key(),
913            endpoint(Some("<none>".into())).embedder_cache_key()
914        );
915    }
916
917    #[test]
918    fn pooling_override_parses_and_is_rejected_on_endpoint() {
919        // Valid on huggingface.
920        assert!(matches!(
921            EmbeddingModel::resolve(EmbeddingSpec {
922                huggingface: Some("org/m".into()),
923                pooling: Some("mean".into()),
924                ..Default::default()
925            })
926            .unwrap(),
927            EmbeddingModel::HuggingFace {
928                pooling: Some(Pooling::Mean),
929                ..
930            }
931        ));
932        // A bad value is a Config error.
933        assert!(
934            EmbeddingModel::resolve(EmbeddingSpec {
935                huggingface: Some("org/m".into()),
936                pooling: Some("median".into()),
937                ..Default::default()
938            })
939            .is_err()
940        );
941        // Meaningless on an endpoint (the server pools).
942        let err = EmbeddingModel::resolve(EmbeddingSpec {
943            ollama: Some("nomic".into()),
944            pooling: Some("mean".into()),
945            ..Default::default()
946        })
947        .unwrap_err();
948        assert!(err.to_string().contains("pooling"), "got: {err}");
949    }
950
951    #[test]
952    fn doc_prefix_threads_through_and_affects_fingerprint() {
953        let m = EmbeddingModel::resolve(EmbeddingSpec {
954            huggingface: Some("intfloat/e5-small-v2".into()),
955            query_prefix: Some("query: ".into()),
956            doc_prefix: Some("passage: ".into()),
957            ..Default::default()
958        })
959        .unwrap();
960        assert_eq!(m.doc_prefix(), "passage: ");
961        assert!(m.configured_fingerprint().contains("|d=9:passage: "));
962        // Pooling is part of identity: same repo, different pooling → different key.
963        let cls = EmbeddingModel::resolve(EmbeddingSpec {
964            huggingface: Some("org/m".into()),
965            pooling: Some("cls".into()),
966            ..Default::default()
967        })
968        .unwrap();
969        let mean = EmbeddingModel::resolve(EmbeddingSpec {
970            huggingface: Some("org/m".into()),
971            pooling: Some("mean".into()),
972            ..Default::default()
973        })
974        .unwrap();
975        assert_ne!(cls.configured_fingerprint(), mean.configured_fingerprint());
976    }
977}