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.
85///
86/// The join names `c.rowid_pk` rather than `c.rowid` (v8, D-119). They are the
87/// same value — an `INTEGER PRIMARY KEY` *is* the rowid — but `concepts_fts`
88/// declares `content_rowid='rowid_pk'`, and the join should say which key it is
89/// joining on rather than rely on the alias holding.
90pub async fn keyword_search(
91    conn: &libsql::Connection,
92    query: &str,
93    top_k: usize,
94) -> Result<Vec<(String, f64)>> {
95    if top_k == 0 || query.trim().is_empty() {
96        return Ok(Vec::new());
97    }
98
99    let sql = "SELECT c.id, bm25(concepts_fts) AS rank
100                 FROM concepts_fts
101                 JOIN concepts c ON c.rowid_pk = concepts_fts.rowid
102                WHERE concepts_fts MATCH ?1
103                  AND c.retired = 0
104                ORDER BY rank ASC, c.id ASC
105                LIMIT ?2";
106
107    let mut rows = conn
108        .query(sql, libsql::params![query, top_k as i64])
109        .await?;
110    let mut out = Vec::new();
111    while let Some(row) = rows.next().await? {
112        out.push((row.get(0)?, row.get::<f64>(1)?));
113    }
114    Ok(out)
115}
116
117/// A hybrid search over one model's vectors and the concept-text index (§5.9).
118///
119/// Mirrors [`crate::graph::FilteredVectorSearch`] and `TraversalBuilder`, which
120/// is the crate's shape for a read with options.
121#[derive(Debug, Clone)]
122pub struct HybridSearch {
123    model: ModelName,
124    query_text: String,
125    query_vector: Vec<f32>,
126    top_k: usize,
127    depth: Option<usize>,
128    rrf_k: usize,
129    raw_match: bool,
130}
131
132impl HybridSearch {
133    /// `query_text` feeds the keyword arm, `query_vector` the vector arm. They
134    /// are separate parameters because the crate does not embed text — that is
135    /// the caller's model, run in the caller's process (Doctrine VII), and the
136    /// two arms may legitimately be given different framings of one question.
137    pub fn new(model: ModelName, query_text: impl Into<String>, query_vector: Vec<f32>) -> Self {
138        Self {
139            model,
140            query_text: query_text.into(),
141            query_vector,
142            top_k: 10,
143            depth: None,
144            rrf_k: RRF_K,
145            raw_match: false,
146        }
147    }
148
149    pub fn top_k(mut self, k: usize) -> Self {
150        self.top_k = k;
151        self
152    }
153
154    /// How deep to read each arm before fusing. Defaults to `max(5 × top_k, 50)`.
155    ///
156    /// Fusing two top-`k` lists is not the same as the top `k` of the fusion: a
157    /// document ranked 12th by both arms can outscore one ranked 1st by a single
158    /// arm, and it is invisible if neither list was read past 10. Depth is what
159    /// buys those, and it costs one larger `LIMIT` per arm rather than an extra
160    /// round trip.
161    pub fn depth(mut self, depth: usize) -> Self {
162        self.depth = Some(depth);
163        self
164    }
165
166    /// Override the RRF damping constant. See [`RRF_K`].
167    pub fn rrf_k(mut self, k: usize) -> Self {
168        self.rrf_k = k;
169        self
170    }
171
172    /// Pass `query_text` to FTS5 verbatim instead of escaping it.
173    ///
174    /// Opt-in, because it hands the caller's string to a query language: a
175    /// malformed expression becomes an engine error and `NOT` silently changes
176    /// what was asked. Correct for a caller building the expression themselves;
177    /// wrong for anything typed into a search box.
178    pub fn raw_match(mut self, raw: bool) -> Self {
179        self.raw_match = raw;
180        self
181    }
182
183    fn effective_depth(&self) -> usize {
184        self.depth.unwrap_or_else(|| (self.top_k * 5).max(50))
185    }
186
187    /// Run both arms and fuse them (§5.9).
188    pub async fn execute(&self, conn: &libsql::Connection) -> Result<Vec<HybridHit>> {
189        if self.top_k == 0 {
190            return Ok(Vec::new());
191        }
192        let depth = self.effective_depth();
193
194        // The vector arm. An unregistered model is a typed error from here, and
195        // is deliberately not softened into "no vector results": a caller who
196        // named a model that does not exist asked a question this cannot answer.
197        let vector: Vec<VectorSearchResult> =
198            search_vector(conn, &self.query_vector, &self.model, depth).await?;
199
200        let match_expr = if self.raw_match {
201            self.query_text.clone()
202        } else {
203            escape_fts5_query(&self.query_text)
204        };
205        let keyword = keyword_search(conn, &match_expr, depth).await?;
206
207        let vector_ids: Vec<String> = vector.iter().map(|v| v.concept_id.clone()).collect();
208        let keyword_ids: Vec<String> = keyword.iter().map(|(id, _)| id.clone()).collect();
209
210        let fused = reciprocal_rank_fusion(&vector_ids, &keyword_ids, self.rrf_k);
211
212        let rank_of = |list: &[String], id: &str| list.iter().position(|x| x == id).map(|i| i + 1);
213
214        Ok(fused
215            .into_iter()
216            .take(self.top_k)
217            .map(|(concept_id, score)| HybridHit {
218                vector_rank: rank_of(&vector_ids, &concept_id),
219                keyword_rank: rank_of(&keyword_ids, &concept_id),
220                concept_id,
221                score,
222            })
223            .collect())
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    #[test]
232    fn escaping_turns_a_search_box_into_terms() {
233        assert_eq!(
234            escape_fts5_query("bitemporal ledger"),
235            r#""bitemporal" "ledger""#
236        );
237        // The operators that would otherwise change the meaning of the query.
238        assert_eq!(escape_fts5_query("cats NOT dogs"), r#""cats" "NOT" "dogs""#);
239        // The syntax errors: an unbalanced quote, a trailing operator, a column
240        // filter. None of these survive as syntax.
241        assert_eq!(escape_fts5_query(r#"a" OR "b"#), r#""a" "OR" "b""#);
242        assert_eq!(escape_fts5_query("title:macrame"), r#""title" "macrame""#);
243        assert_eq!(escape_fts5_query("trailing AND"), r#""trailing" "AND""#);
244    }
245
246    /// A query of nothing but punctuation escapes to the empty string, which
247    /// `keyword_search` must treat as "no keyword arm" rather than handing FTS5
248    /// an empty MATCH — that is a syntax error, not an empty result.
249    #[test]
250    fn a_query_with_no_terms_escapes_to_nothing() {
251        assert_eq!(escape_fts5_query("!!! ???"), "");
252        assert_eq!(escape_fts5_query(""), "");
253    }
254
255    #[test]
256    fn unicode_survives_escaping() {
257        assert_eq!(escape_fts5_query("Müller größe"), r#""Müller" "größe""#);
258    }
259}