Skip to main content

macrame/vector/
hybrid.rs

1//! Hybrid search: the keyword arm, and its fusion with the vector arm (§5.9).
2//!
3//! Dense vectors and keyword matching fail in opposite directions. An embedding
4//! finds a paraphrase and misses an exact identifier it never saw in training;
5//! BM25 finds the identifier and misses the paraphrase entirely. Reciprocal Rank
6//! Fusion combines them without either needing to know the other's score scale,
7//! which is the property that makes it usable here: cosine distance and BM25 are
8//! not comparable numbers, and any scheme that adds them is inventing a
9//! conversion nobody measured. RRF adds *ranks*, which are comparable by
10//! construction.
11//!
12//! Before this existed, `reciprocal_rank_fusion` was a pure function over two
13//! rank lists with nothing in the crate producing the keyword half and no FTS5
14//! table in the schema — §9 budgeted hybrid search at ≤50 ms for a path that
15//! could not run. The fusion function is unchanged in substance; what is new is
16//! everything that feeds it.
17
18use crate::error::Result;
19use crate::vector::{reciprocal_rank_fusion, search_vector, ModelName, VectorSearchResult};
20
21/// The `k` in `1/(k + rank)`, from the paper and from §5.9.
22///
23/// It damps the contribution of top ranks so that agreement between the two arms
24/// outweighs a single arm's confidence: at k = 60 the gap between rank 1 and
25/// rank 2 is small, so a document both arms rank tenth beats one that is first in
26/// one list and absent from the other. Lower it and the fusion approaches "best
27/// of either arm"; raise it and it approaches "appears in both".
28pub const RRF_K: usize = 60;
29
30/// One fused result, with the evidence for its position.
31///
32/// The per-arm ranks are carried out rather than discarded because a fused score
33/// alone is unreadable: `0.032` says nothing, while "rank 2 by vector, absent
34/// from keyword" says exactly why a document placed where it did. This is the
35/// same reasoning that makes `FilteredVectorSearch` return its `CostEstimate`.
36#[derive(Debug, Clone, PartialEq)]
37pub struct HybridHit {
38    pub concept_id: String,
39    /// Fused RRF score. Higher is better; the scale is not meaningful on its own.
40    pub score: f64,
41    /// 1-based rank in the vector arm, or `None` if that arm did not return it.
42    pub vector_rank: Option<usize>,
43    /// 1-based rank in the keyword arm, or `None`.
44    pub keyword_rank: Option<usize>,
45}
46
47/// Turn arbitrary user text into an FTS5 MATCH expression that cannot be a
48/// syntax error and cannot mean something the user did not write.
49///
50/// FTS5's match syntax is a language: `AND`, `OR`, `NOT`, `NEAR`, prefix `*`,
51/// column filters like `title:`, and quoted phrases. Passing a raw search box
52/// through to it has two failure modes, and neither is acceptable as a default.
53/// A query containing an unbalanced quote or a bare `AND` raises
54/// `SQLITE_ERROR` — the user typed a search and got an exception. And a query
55/// containing `NOT` silently *means* something: searching for `cats not dogs`
56/// quietly excludes documents, which is a wrong answer rather than an error.
57///
58/// So each run of alphanumeric characters becomes one double-quoted term and
59/// everything else is dropped, leaving implicit AND between terms. A caller who
60/// genuinely wants the query language can pass it through with
61/// [`HybridSearch::raw_match`].
62pub fn escape_fts5_query(input: &str) -> String {
63    let mut out = String::with_capacity(input.len() + 8);
64    for token in input.split(|c: char| !c.is_alphanumeric()) {
65        if token.is_empty() {
66            continue;
67        }
68        if !out.is_empty() {
69            out.push(' ');
70        }
71        out.push('"');
72        out.push_str(token);
73        out.push('"');
74    }
75    out
76}
77
78/// Keyword search over concept text, best match first (§5.9).
79///
80/// Ranked by `bm25`, which FTS5 returns as a *negative* number whose magnitude
81/// grows with relevance, so ascending order is best-first. Retired concepts are
82/// excluded: a soft-deleted concept is not a search result, and the index cannot
83/// filter on `retired` itself because external-content FTS5 indexes only the
84/// columns it was declared over.
85pub async fn keyword_search(
86    conn: &libsql::Connection,
87    query: &str,
88    top_k: usize,
89) -> Result<Vec<(String, f64)>> {
90    if top_k == 0 || query.trim().is_empty() {
91        return Ok(Vec::new());
92    }
93
94    let sql = "SELECT c.id, bm25(concepts_fts) AS rank
95                 FROM concepts_fts
96                 JOIN concepts c ON c.rowid = concepts_fts.rowid
97                WHERE concepts_fts MATCH ?1
98                  AND c.retired = 0
99                ORDER BY rank ASC, c.id ASC
100                LIMIT ?2";
101
102    let mut rows = conn
103        .query(sql, libsql::params![query, top_k as i64])
104        .await?;
105    let mut out = Vec::new();
106    while let Some(row) = rows.next().await? {
107        out.push((row.get(0)?, row.get::<f64>(1)?));
108    }
109    Ok(out)
110}
111
112/// A hybrid search over one model's vectors and the concept-text index (§5.9).
113///
114/// Mirrors [`crate::graph::FilteredVectorSearch`] and `TraversalBuilder`, which
115/// is the crate's shape for a read with options.
116#[derive(Debug, Clone)]
117pub struct HybridSearch {
118    model: ModelName,
119    query_text: String,
120    query_vector: Vec<f32>,
121    top_k: usize,
122    depth: Option<usize>,
123    rrf_k: usize,
124    raw_match: bool,
125}
126
127impl HybridSearch {
128    /// `query_text` feeds the keyword arm, `query_vector` the vector arm. They
129    /// are separate parameters because the crate does not embed text — that is
130    /// the caller's model, run in the caller's process (Doctrine VII), and the
131    /// two arms may legitimately be given different framings of one question.
132    pub fn new(model: ModelName, query_text: impl Into<String>, query_vector: Vec<f32>) -> Self {
133        Self {
134            model,
135            query_text: query_text.into(),
136            query_vector,
137            top_k: 10,
138            depth: None,
139            rrf_k: RRF_K,
140            raw_match: false,
141        }
142    }
143
144    pub fn top_k(mut self, k: usize) -> Self {
145        self.top_k = k;
146        self
147    }
148
149    /// How deep to read each arm before fusing. Defaults to `max(5 × top_k, 50)`.
150    ///
151    /// Fusing two top-`k` lists is not the same as the top `k` of the fusion: a
152    /// document ranked 12th by both arms can outscore one ranked 1st by a single
153    /// arm, and it is invisible if neither list was read past 10. Depth is what
154    /// buys those, and it costs one larger `LIMIT` per arm rather than an extra
155    /// round trip.
156    pub fn depth(mut self, depth: usize) -> Self {
157        self.depth = Some(depth);
158        self
159    }
160
161    /// Override the RRF damping constant. See [`RRF_K`].
162    pub fn rrf_k(mut self, k: usize) -> Self {
163        self.rrf_k = k;
164        self
165    }
166
167    /// Pass `query_text` to FTS5 verbatim instead of escaping it.
168    ///
169    /// Opt-in, because it hands the caller's string to a query language: a
170    /// malformed expression becomes an engine error and `NOT` silently changes
171    /// what was asked. Correct for a caller building the expression themselves;
172    /// wrong for anything typed into a search box.
173    pub fn raw_match(mut self, raw: bool) -> Self {
174        self.raw_match = raw;
175        self
176    }
177
178    fn effective_depth(&self) -> usize {
179        self.depth.unwrap_or_else(|| (self.top_k * 5).max(50))
180    }
181
182    /// Run both arms and fuse them (§5.9).
183    pub async fn execute(&self, conn: &libsql::Connection) -> Result<Vec<HybridHit>> {
184        if self.top_k == 0 {
185            return Ok(Vec::new());
186        }
187        let depth = self.effective_depth();
188
189        // The vector arm. An unregistered model is a typed error from here, and
190        // is deliberately not softened into "no vector results": a caller who
191        // named a model that does not exist asked a question this cannot answer.
192        let vector: Vec<VectorSearchResult> =
193            search_vector(conn, &self.query_vector, &self.model, depth).await?;
194
195        let match_expr = if self.raw_match {
196            self.query_text.clone()
197        } else {
198            escape_fts5_query(&self.query_text)
199        };
200        let keyword = keyword_search(conn, &match_expr, depth).await?;
201
202        let vector_ids: Vec<String> = vector.iter().map(|v| v.concept_id.clone()).collect();
203        let keyword_ids: Vec<String> = keyword.iter().map(|(id, _)| id.clone()).collect();
204
205        let fused = reciprocal_rank_fusion(&vector_ids, &keyword_ids, self.rrf_k);
206
207        let rank_of = |list: &[String], id: &str| list.iter().position(|x| x == id).map(|i| i + 1);
208
209        Ok(fused
210            .into_iter()
211            .take(self.top_k)
212            .map(|(concept_id, score)| HybridHit {
213                vector_rank: rank_of(&vector_ids, &concept_id),
214                keyword_rank: rank_of(&keyword_ids, &concept_id),
215                concept_id,
216                score,
217            })
218            .collect())
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    #[test]
227    fn escaping_turns_a_search_box_into_terms() {
228        assert_eq!(
229            escape_fts5_query("bitemporal ledger"),
230            r#""bitemporal" "ledger""#
231        );
232        // The operators that would otherwise change the meaning of the query.
233        assert_eq!(escape_fts5_query("cats NOT dogs"), r#""cats" "NOT" "dogs""#);
234        // The syntax errors: an unbalanced quote, a trailing operator, a column
235        // filter. None of these survive as syntax.
236        assert_eq!(escape_fts5_query(r#"a" OR "b"#), r#""a" "OR" "b""#);
237        assert_eq!(escape_fts5_query("title:macrame"), r#""title" "macrame""#);
238        assert_eq!(escape_fts5_query("trailing AND"), r#""trailing" "AND""#);
239    }
240
241    /// A query of nothing but punctuation escapes to the empty string, which
242    /// `keyword_search` must treat as "no keyword arm" rather than handing FTS5
243    /// an empty MATCH — that is a syntax error, not an empty result.
244    #[test]
245    fn a_query_with_no_terms_escapes_to_nothing() {
246        assert_eq!(escape_fts5_query("!!! ???"), "");
247        assert_eq!(escape_fts5_query(""), "");
248    }
249
250    #[test]
251    fn unicode_survives_escaping() {
252        assert_eq!(escape_fts5_query("Müller größe"), r#""Müller" "größe""#);
253    }
254}