Skip to main content

ratel_ai_core/
method.rs

1//! The retrieval method a registry uses to rank a query.
2//!
3//! Three engines coexist and are selectable per registry (a construction-time
4//! default) or per call (an explicit override). BM25 is the default — it needs
5//! no model and never fails, so the legacy `search`/`search_with_origin` paths
6//! stay infallible. Semantic and Hybrid rank against a prebuilt embedding cache;
7//! the model loads only when the caller explicitly builds the dense cache,
8//! never at install, registration, or inside a synchronous search (see
9//! `embedding.rs` and ADR-0011).
10
11use std::fmt;
12use std::str::FromStr;
13
14/// Which ranking engine a search uses.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
16pub enum SearchMethod {
17    /// Lexical BM25 over the flattened searchable text. Default; no model.
18    #[default]
19    Bm25,
20    /// Dense cosine similarity over embedded tool/skill text.
21    Semantic,
22    /// BM25 and dense arms fused by Reciprocal Rank Fusion (no reranker).
23    Hybrid,
24}
25
26impl SearchMethod {
27    /// The trace/`as_str` identifier used across the SDKs: `"bm25"`,
28    /// `"semantic"`, `"hybrid"`.
29    pub fn as_str(&self) -> &'static str {
30        match self {
31            SearchMethod::Bm25 => "bm25",
32            SearchMethod::Semantic => "semantic",
33            SearchMethod::Hybrid => "hybrid",
34        }
35    }
36}
37
38impl fmt::Display for SearchMethod {
39    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
40        f.write_str(self.as_str())
41    }
42}
43
44/// The identifier did not name a known method.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct ParseSearchMethodError(pub String);
47
48impl fmt::Display for ParseSearchMethodError {
49    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50        write!(
51            f,
52            "unknown search method {:?} (expected \"bm25\", \"semantic\", or \"hybrid\")",
53            self.0
54        )
55    }
56}
57
58impl std::error::Error for ParseSearchMethodError {}
59
60impl FromStr for SearchMethod {
61    type Err = ParseSearchMethodError;
62
63    /// Parse the SDK identifier: `"bm25"`, `"semantic"` (with `"dense"`
64    /// accepted as an alias), or `"hybrid"`.
65    ///
66    /// # Errors
67    ///
68    /// Any other string is a [`ParseSearchMethodError`] naming the rejected
69    /// input.
70    ///
71    /// # Examples
72    ///
73    /// ```
74    /// use ratel_ai_core::SearchMethod;
75    ///
76    /// assert_eq!("hybrid".parse::<SearchMethod>(), Ok(SearchMethod::Hybrid));
77    /// assert_eq!("dense".parse::<SearchMethod>(), Ok(SearchMethod::Semantic));
78    /// assert!("keyword".parse::<SearchMethod>().is_err());
79    /// ```
80    fn from_str(s: &str) -> Result<Self, Self::Err> {
81        match s {
82            "bm25" => Ok(SearchMethod::Bm25),
83            "semantic" | "dense" => Ok(SearchMethod::Semantic),
84            "hybrid" => Ok(SearchMethod::Hybrid),
85            other => Err(ParseSearchMethodError(other.to_string())),
86        }
87    }
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93
94    #[test]
95    fn default_is_bm25() {
96        assert_eq!(SearchMethod::default(), SearchMethod::Bm25);
97    }
98
99    #[test]
100    fn round_trips_through_str() {
101        for m in [
102            SearchMethod::Bm25,
103            SearchMethod::Semantic,
104            SearchMethod::Hybrid,
105        ] {
106            assert_eq!(m.as_str().parse::<SearchMethod>().unwrap(), m);
107        }
108    }
109
110    #[test]
111    fn dense_is_an_alias_for_semantic() {
112        assert_eq!(
113            "dense".parse::<SearchMethod>().unwrap(),
114            SearchMethod::Semantic
115        );
116    }
117
118    #[test]
119    fn unknown_method_is_rejected() {
120        assert!("keyword".parse::<SearchMethod>().is_err());
121    }
122}