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