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 std::time::Duration;
19
20use crate::error::{DbError, Result};
21use crate::vector::search::{decay_factor, rerank_depth};
22use crate::vector::{reciprocal_rank_fusion, search_vector, ModelName, VectorSearchResult};
23
24/// The `k` in `1/(k + rank)`, from the paper and from §5.9.
25///
26/// It damps the contribution of top ranks so that agreement between the two arms
27/// outweighs a single arm's confidence: at k = 60 the gap between rank 1 and
28/// rank 2 is small, so a document both arms rank tenth beats one that is first in
29/// one list and absent from the other. Lower it and the fusion approaches "best
30/// of either arm"; raise it and it approaches "appears in both".
31pub const RRF_K: usize = 60;
32
33/// One fused result, with the evidence for its position.
34///
35/// The per-arm ranks are carried out rather than discarded because a fused score
36/// alone is unreadable: `0.032` says nothing, while "rank 2 by vector, absent
37/// from keyword" says exactly why a document placed where it did. This is the
38/// same reasoning that makes `FilteredVectorSearch` return its `CostEstimate`.
39#[derive(Debug, Clone, PartialEq)]
40pub struct HybridHit {
41 pub concept_id: String,
42 /// Fused RRF score. Higher is better; the scale is not meaningful on its own.
43 pub score: f64,
44 /// 1-based rank in the vector arm, or `None` if that arm did not return it.
45 pub vector_rank: Option<usize>,
46 /// 1-based rank in the keyword arm, or `None`.
47 pub keyword_rank: Option<usize>,
48}
49
50/// Turn arbitrary user text into an FTS5 MATCH expression that cannot be a
51/// syntax error and cannot mean something the user did not write.
52///
53/// FTS5's match syntax is a language: `AND`, `OR`, `NOT`, `NEAR`, prefix `*`,
54/// column filters like `title:`, and quoted phrases. Passing a raw search box
55/// through to it has two failure modes, and neither is acceptable as a default.
56/// A query containing an unbalanced quote or a bare `AND` raises
57/// `SQLITE_ERROR` — the user typed a search and got an exception. And a query
58/// containing `NOT` silently *means* something: searching for `cats not dogs`
59/// quietly excludes documents, which is a wrong answer rather than an error.
60///
61/// So each run of alphanumeric characters becomes one double-quoted term and
62/// everything else is dropped, leaving implicit AND between terms. A caller who
63/// genuinely wants the query language can pass it through with
64/// [`HybridSearch::raw_match`].
65pub fn escape_fts5_query(input: &str) -> String {
66 let mut out = String::with_capacity(input.len() + 8);
67 for token in input.split(|c: char| !c.is_alphanumeric()) {
68 if token.is_empty() {
69 continue;
70 }
71 if !out.is_empty() {
72 out.push(' ');
73 }
74 out.push('"');
75 out.push_str(token);
76 out.push('"');
77 }
78 out
79}
80
81/// Keyword search over concept text, best match first (§5.9).
82///
83/// Ranked by `bm25`, which FTS5 returns as a *negative* number whose magnitude
84/// grows with relevance, so ascending order is best-first. Retired concepts are
85/// excluded: a soft-deleted concept is not a search result, and the index cannot
86/// filter on `retired` itself because external-content FTS5 indexes only the
87/// columns it was declared over.
88///
89/// **The visibility predicate is the vector arm's, spliced rather than
90/// repeated** (0.13.19, W9.4,
91/// [D-192](../../docs/architecture/s13-decision-register.md#d-192)). This
92/// function carried its own `AND c.retired = 0` from the day it was written,
93/// and W9.3 wrote the shared constant without folding this copy into it. Two
94/// literals that must agree is [D-030](../../docs/architecture/s13-decision-register.md#d-030)'s
95/// failure class, and W9.4 is the release that would have made them disagree:
96/// adding the valid-time bound to one and not the other is F-31 again with a
97/// different column.
98///
99/// `as_of_valid` bounds each hit against its own valid interval. Absent, the
100/// statement is what 0.13.18 issued. FTS5 is not consulted about it either way:
101/// the MATCH selects on text and the bound is applied to the joined `concepts`
102/// row, which is the only place either fact lives.
103///
104/// The join names `c.rowid_pk` rather than `c.rowid` (v8, D-119). They are the
105/// same value — an `INTEGER PRIMARY KEY` *is* the rowid — but `concepts_fts`
106/// declares `content_rowid='rowid_pk'`, and the join should say which key it is
107/// joining on rather than rely on the alias holding.
108pub async fn keyword_search(
109 conn: &libsql::Connection,
110 query: &str,
111 top_k: usize,
112 as_of_valid: Option<&str>,
113 half_life: Option<Duration>,
114) -> Result<Vec<(String, f64)>> {
115 if top_k == 0 || query.trim().is_empty() {
116 return Ok(Vec::new());
117 }
118 let reference = match (half_life, as_of_valid) {
119 (Some(_), None) => return Err(DbError::HalfLifeWithoutInstant),
120 (Some(_), Some(t)) => Some(t),
121 (None, _) => None,
122 };
123
124 // Deeper than the answer when the answer is about to be reordered, for the
125 // reason `rerank_depth` states.
126 let want = match half_life {
127 Some(_) => rerank_depth(top_k),
128 None => top_k,
129 };
130 let age_column = if half_life.is_some() {
131 ", c.valid_from"
132 } else {
133 ""
134 };
135
136 let sql = format!(
137 "SELECT c.id, bm25(concepts_fts) AS rank{age_column}
138 FROM concepts_fts
139 JOIN concepts c ON c.rowid_pk = concepts_fts.rowid
140 WHERE concepts_fts MATCH ?1
141 AND {visible}
142 ORDER BY rank ASC, c.id ASC
143 LIMIT ?2",
144 visible = crate::vector::search::visible_concept(as_of_valid.map(|_| 3)),
145 );
146
147 let mut params: Vec<libsql::Value> = vec![query.into(), (want as i64).into()];
148 if let Some(t) = as_of_valid {
149 params.push(t.into());
150 }
151 let mut rows = conn.query(&sql, params).await?;
152 let mut out: Vec<(String, f64)> = Vec::new();
153 while let Some(row) = rows.next().await? {
154 let id: String = row.get(0)?;
155 let rank: f64 = row.get(1)?;
156 let rank = match (reference, half_life) {
157 (Some(reference), Some(half_life)) => {
158 let valid_from: String = row.get(2)?;
159 decayed_rank(rank, decay_factor(reference, &valid_from, half_life)?)
160 }
161 _ => rank,
162 };
163 out.push((id, rank));
164 }
165
166 if half_life.is_some() {
167 out.sort_by(|a, b| {
168 a.1.partial_cmp(&b.1)
169 .unwrap_or(std::cmp::Ordering::Equal)
170 .then_with(|| a.0.cmp(&b.0))
171 });
172 out.truncate(top_k);
173 }
174 Ok(out)
175}
176
177/// A bm25 rank, decayed, still a bm25-shaped rank (0.13.20, W9.5, D-193).
178///
179/// **This is where the two surfaces stop being the same operation.**
180/// [`crate::vector::search::decayed_distance`] has to convert, because a
181/// distance multiplied by a factor in (0, 1] gets *smaller* and a smaller
182/// distance is a better hit. Here the plain multiply is already right, and for
183/// a reason worth stating rather than relying on: bm25 arrives **negative**,
184/// with magnitude growing in relevance, so it is a negated similarity already.
185/// Multiplying moves a hit toward zero, and toward zero is toward the far end
186/// of an ascending best-first list — exactly the demotion decay is for.
187///
188/// So the operation that would have been the bug on the vector surface is the
189/// correct one here, and writing them as one shared helper would have made one
190/// of the two wrong. `a_half_life_ranks_by_age_in_every_arm` asserts both
191/// orders, which is what stops that from being a comment nobody re-checks.
192///
193/// A non-negative rank is left alone rather than multiplied. FTS5 does not
194/// produce one on this path, and if it ever did, multiplying would move it
195/// toward zero from the *other* side — an improvement, which is the one thing
196/// decay must never be.
197fn decayed_rank(rank: f64, factor: f64) -> f64 {
198 if rank < 0.0 {
199 rank * factor
200 } else {
201 rank
202 }
203}
204
205/// A hybrid search over one model's vectors and the concept-text index (§5.9).
206///
207/// Mirrors [`crate::graph::FilteredVectorSearch`] and `TraversalBuilder`, which
208/// is the crate's shape for a read with options.
209#[derive(Debug, Clone)]
210pub struct HybridSearch {
211 model: ModelName,
212 query_text: String,
213 query_vector: Vec<f32>,
214 top_k: usize,
215 depth: Option<usize>,
216 rrf_k: usize,
217 raw_match: bool,
218 as_of_valid: Option<String>,
219 half_life: Option<Duration>,
220}
221
222impl HybridSearch {
223 /// `query_text` feeds the keyword arm, `query_vector` the vector arm. They
224 /// are separate parameters because the crate does not embed text — that is
225 /// the caller's model, run in the caller's process (Doctrine VII), and the
226 /// two arms may legitimately be given different framings of one question.
227 pub fn new(model: ModelName, query_text: impl Into<String>, query_vector: Vec<f32>) -> Self {
228 Self {
229 model,
230 query_text: query_text.into(),
231 query_vector,
232 top_k: 10,
233 depth: None,
234 rrf_k: RRF_K,
235 raw_match: false,
236 as_of_valid: None,
237 half_life: None,
238 }
239 }
240
241 pub fn top_k(mut self, k: usize) -> Self {
242 self.top_k = k;
243 self
244 }
245
246 /// How deep to read each arm before fusing. Defaults to `max(5 × top_k, 50)`.
247 ///
248 /// Fusing two top-`k` lists is not the same as the top `k` of the fusion: a
249 /// document ranked 12th by both arms can outscore one ranked 1st by a single
250 /// arm, and it is invisible if neither list was read past 10. Depth is what
251 /// buys those, and it costs one larger `LIMIT` per arm rather than an extra
252 /// round trip.
253 pub fn depth(mut self, depth: usize) -> Self {
254 self.depth = Some(depth);
255 self
256 }
257
258 /// Override the RRF damping constant. See [`RRF_K`].
259 pub fn rrf_k(mut self, k: usize) -> Self {
260 self.rrf_k = k;
261 self
262 }
263
264 /// Pass `query_text` to FTS5 verbatim instead of escaping it.
265 ///
266 /// Opt-in, because it hands the caller's string to a query language: a
267 /// malformed expression becomes an engine error and `NOT` silently changes
268 /// what was asked. Correct for a caller building the expression themselves;
269 /// wrong for anything typed into a search box.
270 pub fn raw_match(mut self, raw: bool) -> Self {
271 self.raw_match = raw;
272 self
273 }
274
275 /// Read both arms at a valid-time instant (0.13.19, W9.4, F-32).
276 ///
277 /// **Both, and it could not be one.** RRF fuses two rank lists, so an
278 /// instant applied to one arm and not the other would fuse what was true
279 /// then with what is true now and return a single ranked list that is
280 /// neither — the fused score cannot say which arm the anachronism came
281 /// from, which is the property that makes a half-applied bound worse here
282 /// than on either arm alone.
283 ///
284 /// Named for [`crate::graph::TraversalBuilder::as_of_valid`], and it is the
285 /// same axis: *what was true*, bounded by the concept's own interval.
286 /// Absent, both arms read the corpus, unchanged.
287 pub fn as_of_valid(mut self, ts: impl Into<String>) -> Self {
288 self.as_of_valid = Some(ts.into());
289 self
290 }
291
292 /// Weight each arm's ranking by the age of what it matched (0.13.20, W9.5).
293 ///
294 /// **Both arms, and before the fusion rather than after it.** RRF adds
295 /// *ranks*; a decay applied to the fused score afterwards would be
296 /// penalising a number that is already scale-free and would leave both
297 /// arms' orderings — the only thing RRF reads — untouched. So each arm
298 /// decays its own similarity and re-sorts, and the fusion sees two lists
299 /// that already price age.
300 ///
301 /// Requires [`Self::as_of_valid`]: age is measured from the instant the
302 /// search reads at, and there is no other instant here to fall back to. The
303 /// arms raise [`DbError::HalfLifeWithoutInstant`] rather than defaulting to
304 /// now.
305 pub fn half_life(mut self, half_life: Duration) -> Self {
306 self.half_life = Some(half_life);
307 self
308 }
309
310 fn effective_depth(&self) -> usize {
311 self.depth.unwrap_or_else(|| rerank_depth(self.top_k))
312 }
313
314 /// Run both arms and fuse them (§5.9).
315 pub async fn execute(&self, conn: &libsql::Connection) -> Result<Vec<HybridHit>> {
316 if self.top_k == 0 {
317 return Ok(Vec::new());
318 }
319 let depth = self.effective_depth();
320
321 // The vector arm. An unregistered model is a typed error from here, and
322 // is deliberately not softened into "no vector results": a caller who
323 // named a model that does not exist asked a question this cannot answer.
324 let at = self.as_of_valid.as_deref();
325 let vector: Vec<VectorSearchResult> = search_vector(
326 conn,
327 &self.query_vector,
328 &self.model,
329 depth,
330 at,
331 self.half_life,
332 )
333 .await?;
334
335 let match_expr = if self.raw_match {
336 self.query_text.clone()
337 } else {
338 escape_fts5_query(&self.query_text)
339 };
340 let keyword = keyword_search(conn, &match_expr, depth, at, self.half_life).await?;
341
342 let vector_ids: Vec<String> = vector.iter().map(|v| v.concept_id.clone()).collect();
343 let keyword_ids: Vec<String> = keyword.iter().map(|(id, _)| id.clone()).collect();
344
345 let fused = reciprocal_rank_fusion(&vector_ids, &keyword_ids, self.rrf_k);
346
347 let rank_of = |list: &[String], id: &str| list.iter().position(|x| x == id).map(|i| i + 1);
348
349 Ok(fused
350 .into_iter()
351 .take(self.top_k)
352 .map(|(concept_id, score)| HybridHit {
353 vector_rank: rank_of(&vector_ids, &concept_id),
354 keyword_rank: rank_of(&keyword_ids, &concept_id),
355 concept_id,
356 score,
357 })
358 .collect())
359 }
360}
361
362#[cfg(test)]
363mod tests {
364 use super::*;
365
366 #[test]
367 fn escaping_turns_a_search_box_into_terms() {
368 assert_eq!(
369 escape_fts5_query("bitemporal ledger"),
370 r#""bitemporal" "ledger""#
371 );
372 // The operators that would otherwise change the meaning of the query.
373 assert_eq!(escape_fts5_query("cats NOT dogs"), r#""cats" "NOT" "dogs""#);
374 // The syntax errors: an unbalanced quote, a trailing operator, a column
375 // filter. None of these survive as syntax.
376 assert_eq!(escape_fts5_query(r#"a" OR "b"#), r#""a" "OR" "b""#);
377 assert_eq!(escape_fts5_query("title:macrame"), r#""title" "macrame""#);
378 assert_eq!(escape_fts5_query("trailing AND"), r#""trailing" "AND""#);
379 }
380
381 /// A query of nothing but punctuation escapes to the empty string, which
382 /// `keyword_search` must treat as "no keyword arm" rather than handing FTS5
383 /// an empty MATCH — that is a syntax error, not an empty result.
384 #[test]
385 fn a_query_with_no_terms_escapes_to_nothing() {
386 assert_eq!(escape_fts5_query("!!! ???"), "");
387 assert_eq!(escape_fts5_query(""), "");
388 }
389
390 #[test]
391 fn unicode_survives_escaping() {
392 assert_eq!(escape_fts5_query("Müller größe"), r#""Müller" "größe""#);
393 }
394}