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 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}