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}