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}