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