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}