Skip to main content

macrame/vector/
search.rs

1use std::collections::HashMap;
2use std::time::Duration;
3
4use crate::error::{DbError, Result};
5use crate::vector::registry::declared_dimension;
6use crate::vector::{EmbeddingCodec, ModelName};
7
8/// Search result container for vector similarity or hybrid search (§5.9).
9#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
10#[non_exhaustive]
11pub struct VectorSearchResult {
12    pub concept_id: String,
13    /// Cosine distance: 0.0 is identical, larger is further. Ascending order.
14    pub score: f32,
15}
16
17/// Store or replace a concept's vector for one model (§4.1, Doctrine VII).
18///
19/// An embedding is derived, so re-embedding the same concept under the same
20/// model replaces the row rather than versioning it — the ledger records that
21/// the concept changed, and the vector is recomputed from the concept. Nothing
22/// here writes to `transaction_log`; there are no triggers on this table.
23///
24/// The dimension is checked against the model's *declared* dimension before the
25/// statement is built, so the caller gets [`DimMismatch`] naming both numbers
26/// rather than the engine's `dimensions are different: 2 != 4`.
27///
28/// # Prefer [`crate::Database::upsert_embeddings`]
29///
30/// This takes a **bare connection** and is therefore §4.7 invariant 2's third
31/// hole: a write that does not cross the actor's channel. Hidden from the docs
32/// alongside [`crate::Database::raw`] (D-091) so the documented path is the one
33/// that preserves the single-writer property; still public, for the reason
34/// [D-068](../../docs/architecture/s13-decision-register.md#d-068) gives.
35///
36/// [`DimMismatch`]: crate::error::DbError::DimMismatch
37#[doc(hidden)]
38pub async fn upsert_embedding(
39    conn: &libsql::Connection,
40    model: &ModelName,
41    concept_id: &str,
42    vector: &[f32],
43) -> Result<()> {
44    let blob = encode_for_model(conn, model, vector).await?;
45
46    // `model.table()` is a bare identifier by construction; the values bind.
47    conn.execute(
48        &format!(
49            "INSERT INTO {table} (concept_id, embedding) VALUES (?1, ?2)
50             ON CONFLICT(concept_id) DO UPDATE SET embedding = excluded.embedding",
51            table = model.table()
52        ),
53        libsql::params![concept_id, blob],
54    )
55    .await?;
56    Ok(())
57}
58
59/// Store or replace one chunk of vectors for a model, in a single transaction.
60///
61/// The dimension is resolved **once per chunk**, not once per row.
62/// [`declared_dimension`] is a `PRAGMA table_info` round trip, so resolving it
63/// per row turns a bulk embed into one round trip per vector — and the answer
64/// cannot change inside a chunk, because the chunk holds the write lock and the
65/// dimension is a property of a table only `register_model` creates.
66///
67/// Atomic per chunk, not across chunks: a failure partway leaves earlier chunks
68/// committed. That is the right trade here in a way it would not be for
69/// assertions — an embedding is a derived artifact (Doctrine VII), so a
70/// partially written batch is recoverable by re-embedding, whereas a partially
71/// written history is not recoverable at all.
72pub(crate) async fn upsert_embedding_chunk(
73    conn: &libsql::Connection,
74    model: &ModelName,
75    rows: &[(String, Vec<f32>)],
76) -> Result<usize> {
77    if rows.is_empty() {
78        return Ok(0);
79    }
80
81    let dim = declared_dimension(conn, model).await?;
82    let sql = format!(
83        "INSERT INTO {table} (concept_id, embedding) VALUES (?1, ?2)
84         ON CONFLICT(concept_id) DO UPDATE SET embedding = excluded.embedding",
85        table = model.table()
86    );
87
88    let tx = conn
89        .transaction_with_behavior(libsql::TransactionBehavior::Immediate)
90        .await?;
91
92    // Prepared once per chunk, for the same reason the dimension is resolved once
93    // per chunk and the same reason the edge chunk hoists its insert (D-056): the
94    // statement text is identical for every row, and the embedding tables carry a
95    // DiskANN index whose maintenance is compiled into each preparation.
96    //
97    // `reset()` between rows is required — libsql's `Statement::execute` binds and
98    // steps without resetting first.
99    let stmt = tx.prepare(&sql).await?;
100
101    let res: Result<()> = async {
102        for (concept_id, vector) in rows {
103            let blob = EmbeddingCodec::encode(vector, dim, model.as_str())?;
104            stmt.reset();
105            stmt.execute(libsql::params![concept_id.as_str(), blob])
106                .await?;
107        }
108        Ok(())
109    }
110    .await;
111
112    // Dropped before either arm ends the transaction: a live statement on the
113    // connection is what makes SQLite refuse to commit or roll back.
114    drop(stmt);
115
116    match res {
117        Ok(()) => {
118            tx.commit().await?;
119            Ok(rows.len())
120        }
121        Err(e) => {
122            let _ = tx.rollback().await;
123            Err(e)
124        }
125    }
126}
127
128/// **The visibility predicate every vector read applies** (0.13.18, W9.3,
129/// [D-191](../../docs/architecture/s13-decision-register.md#d-191)).
130///
131/// Written once and spliced, because the alternative is what produced F-31:
132/// [`crate::vector::keyword_search`] carried `AND c.retired = 0` from the day it
133/// was written and nothing propagated the obligation to the vector arm, so one
134/// half of `hybrid_search` saw a retirement and the other did not.
135///
136/// It is bound to the alias **`c`**, and every query splicing it joins
137/// `concepts AS c`. That join is an inner join and is not a second filter: the
138/// embedding tables carry a foreign key to `concepts` ([§4.6](../../docs/architecture/s4-schema.md)),
139/// so a vector with no concept behind it cannot exist and the join drops
140/// nothing on its own.
141pub(crate) const VISIBLE_CONCEPT: &str = "c.retired = 0";
142
143/// [`VISIBLE_CONCEPT`], and the valid-time bound when the read states an instant
144/// (0.13.19, W9.4, [D-192](../../docs/architecture/s13-decision-register.md#d-192)).
145///
146/// `at_param` is the **1-based statement parameter** the instant is bound to,
147/// which differs per query and so cannot be baked into a constant: the vector
148/// search has three parameters ahead of it, the keyword search two, and the
149/// pre-filter's is a function of the candidate chunk. Passing the index rather
150/// than the value keeps the instant a bound parameter on every path.
151///
152/// `None` yields the retirement predicate alone and nothing else changes, which
153/// is [D-155](../../docs/architecture/s13-decision-register.md#d-155)'s rule:
154/// an absent knob leaves the mechanism alone. The bound is the crate's
155/// half-open interval — `valid_from <= t AND t < valid_to`, the same one
156/// `hydrate_current` and the traversal CTE apply — so a row whose validity has
157/// just ended at `t` is out and one whose validity begins at `t` is in.
158pub(crate) fn visible_concept(at_param: Option<usize>) -> String {
159    match at_param {
160        None => VISIBLE_CONCEPT.to_string(),
161        Some(p) => {
162            format!("{VISIBLE_CONCEPT} AND c.valid_from <= ?{p} AND ?{p} < c.valid_to")
163        }
164    }
165}
166
167/// How deep a surface must read before it re-ranks, given a final `top_k`.
168///
169/// `max(5 × top_k, 50)` — [`crate::vector::HybridSearch::depth`]'s rule,
170/// promoted to a function in 0.13.20 because decay needs it for the same
171/// reason fusion does: **re-ranking a top-k is not the top-k of the
172/// re-ranking.** A row the index ranked eleventh can outrank one it ranked
173/// first once age is priced in, and it is invisible if the list was never read
174/// past ten.
175///
176/// It is a bound and not a guarantee, and saying so is the honest form. Decay
177/// only ever *demotes*, so a row outside the pool enters the answer only if
178/// five times as many rows ahead of it were pushed below it — the same trade
179/// `depth` has made since 0.5.5, priced as one larger `LIMIT` rather than a
180/// second round trip.
181pub(crate) fn rerank_depth(top_k: usize) -> usize {
182    (top_k * 5).max(50)
183}
184
185/// `0.5 ^ (age / half_life)`: **1.0 at zero age, 0.5 at one half-life**, and
186/// asymptotically zero after that.
187///
188/// `reference` is the instant age is measured *from* and `valid_from` is when
189/// the concept became true, both canonical. A `valid_from` after `reference`
190/// cannot arise where this is called — the same instant bounds the query —
191/// and is clamped to zero age rather than trusted to underflow.
192///
193/// A zero half-life is the defined limit of the formula rather than an error:
194/// anything with any age at all is fully decayed, and only what became true at
195/// the instant itself survives. That is a strange thing to ask for and an
196/// unambiguous one, which is the bar for not adding a refusal.
197pub(crate) fn decay_factor(reference: &str, valid_from: &str, half_life: Duration) -> Result<f64> {
198    let age = crate::util::timestamp::parse(reference)?
199        .duration_since(crate::util::timestamp::parse(valid_from)?)
200        .unwrap_or(Duration::ZERO);
201    if half_life.is_zero() {
202        return Ok(if age.is_zero() { 1.0 } else { 0.0 });
203    }
204    Ok(0.5f64.powf(age.as_secs_f64() / half_life.as_secs_f64()))
205}
206
207/// A cosine **distance**, decayed, still a distance (0.13.20, W9.5, D-193).
208///
209/// **This is the sign trap, and it is a conversion rather than a multiply.** A
210/// decay factor in (0, 1] multiplied into a *similarity* penalises age
211/// correctly; multiplied into a *distance* it makes stale rows look nearer.
212/// `vector_distance_cos` returns `1 - cosθ` in [0, 2], so similarity is
213/// `(2 - d) / 2` in [0, 1] — mapped to a non-negative range **before** the
214/// multiply, because scaling a negative similarity toward zero would improve
215/// it, which is the same trap wearing a second face.
216///
217/// The result is a distance again, so the surface's contract is unchanged:
218/// smaller is better and the list still ascends. At `factor == 1.0` this is the
219/// identity, which is the property `decay_is_the_identity_at_zero_age` pins.
220pub(crate) fn decayed_distance(distance: f32, factor: f64) -> f32 {
221    let similarity = ((2.0 - distance as f64) / 2.0).clamp(0.0, 1.0);
222    (2.0 - 2.0 * similarity * factor) as f32
223}
224
225/// Top-k nearest **visible** neighbours for `query_vec` under `model` (§5.9).
226///
227/// Goes through `vector_top_k`, which consults the DiskANN index, rather than
228/// scanning the table and sorting: the index is what §9's "top-10 over 100K
229/// concepts in ≤20 ms" budget assumes, and an `ORDER BY vector_distance_cos(…)`
230/// over the whole table is linear in the corpus no matter how small `k` is.
231/// `vector_top_k` yields base-table rowids, so the distance is recomputed on the
232/// k rows it selects — k distance evaluations, not one per concept.
233///
234/// # `top_k` is a count, and keeping it one is the whole of the loop
235///
236/// The index chooses its `k` rows before the visibility predicate can see them,
237/// so a filter applied afterwards returns fewer than `k` whenever a retired
238/// concept is among them. Letting `top_k` become a *ceiling* would be a silent
239/// behaviour change for every existing caller, so the index is asked for a
240/// larger `k'` instead — the escalation
241/// [`crate::graph::FilteredVectorSearch`] already performs against the same
242/// problem, and the inflation `CostEstimator::k_prime` computes from
243/// selectivity.
244///
245/// It runs **only when the first pass comes up short**, which is the case where
246/// something was actually filtered out. A corpus with nothing retired — the
247/// overwhelmingly common one — pays one query and no count, which is why the
248/// loop is here rather than a selectivity estimate computed up front: that
249/// would put two `COUNT(*)`s on every search to serve the case that almost
250/// never arises.
251///
252/// Termination is by exhaustion, not by a retry budget. `k'` doubles until the
253/// index has been asked for the whole table, and a `k'` at or above the row
254/// count means what came back **is** every visible neighbour — a complete
255/// answer, not a truncated one. The row count is read at most once and only on
256/// the escalating path.
257///
258/// # `as_of_valid`: what was true then, or the corpus (0.13.19, W9.4, F-32)
259///
260/// With an instant, a concept is a result only while its own valid interval
261/// contains it — the half-open bound the whole crate uses, and the same clause
262/// the visibility predicate already carries, so it costs no extra join. Without
263/// one, the statement is byte-for-byte what 0.13.18 issued: an absent knob
264/// leaves the mechanism alone
265/// ([D-155](../../docs/architecture/s13-decision-register.md#d-155)).
266///
267/// It is `as_of_valid` and not `as_of` because
268/// [`crate::graph::TraversalBuilder::as_of_valid`] split that word into two
269/// axes in 0.13.2, and one spelling per axis is the point of having split it.
270/// **Transaction time is deliberately not offered here**: reading the index as
271/// it stood at a past `recorded_at` would mean searching vectors that have
272/// since been replaced, and the DiskANN index holds one row per concept with no
273/// history to search. A caller who wants that asks the ledger, not the index.
274///
275/// The escalation above needs no adjustment for it. It keys on a pass coming up
276/// short and not on **why** it came up short, so a corpus thinned by valid time
277/// re-asks the index exactly as one thinned by retirement does.
278pub async fn search_vector(
279    conn: &libsql::Connection,
280    query_vec: &[f32],
281    model: &ModelName,
282    top_k: usize,
283    as_of_valid: Option<&str>,
284    half_life: Option<Duration>,
285) -> Result<Vec<VectorSearchResult>> {
286    if top_k == 0 {
287        return Ok(Vec::new());
288    }
289    // Age is measured from the instant the search reads at, and there is no
290    // other instant on this path to fall back to.
291    let reference = match (half_life, as_of_valid) {
292        (Some(_), None) => return Err(DbError::HalfLifeWithoutInstant),
293        (Some(_), Some(t)) => Some(t),
294        (None, _) => None,
295    };
296    let blob = encode_for_model(conn, model, query_vec).await?;
297
298    // Decay reorders, so the pool that gets ranked has to be deeper than the
299    // answer. With no half-life this is `top_k` and the statement is what
300    // 0.13.19 issued, `valid_from` included.
301    let want = match half_life {
302        Some(_) => rerank_depth(top_k),
303        None => top_k,
304    };
305    let age_column = if half_life.is_some() {
306        ", c.valid_from"
307    } else {
308        ""
309    };
310
311    // `?2` is what the index is asked for and `?3` is what the ranking pool
312    // needs; they are the same on the first pass and diverge on escalation.
313    let sql = format!(
314        "SELECT e.concept_id, vector_distance_cos(e.embedding, ?1){age_column}
315           FROM vector_top_k('{index}', ?1, ?2) AS t
316           JOIN {table} AS e ON e.rowid = t.id
317           JOIN concepts AS c ON c.id = e.concept_id
318          WHERE {visible}
319          ORDER BY 2 ASC
320          LIMIT ?3",
321        index = model.index(),
322        table = model.table(),
323        visible = visible_concept(as_of_valid.map(|_| 4)),
324    );
325
326    let mut k_prime = want;
327    let mut indexed: Option<usize> = None;
328
329    loop {
330        let mut params: Vec<libsql::Value> = vec![
331            blob.clone().into(),
332            (k_prime as i64).into(),
333            (want as i64).into(),
334        ];
335        if let Some(t) = as_of_valid {
336            params.push(t.into());
337        }
338        let mut rows = conn.query(&sql, params).await?;
339
340        let mut results = Vec::new();
341        while let Some(row) = rows.next().await? {
342            let hit = VectorSearchResult {
343                concept_id: row.get(0)?,
344                // The distance is computed by the engine over a non-null
345                // F32_BLOB column, so a null here would mean the schema is not
346                // what we think.
347                score: row.get::<f64>(1)? as f32,
348            };
349            let valid_from: Option<String> = match reference {
350                Some(_) => Some(row.get(2)?),
351                None => None,
352            };
353            results.push((hit, valid_from));
354        }
355
356        if results.len() >= want {
357            return rank_by_age(results, reference, half_life, top_k);
358        }
359
360        let n = match indexed {
361            Some(n) => n,
362            None => {
363                let n = indexed_rows(conn, model).await?;
364                indexed = Some(n);
365                n
366            }
367        };
368        // The index has already been asked for everything it holds, so this is
369        // every visible neighbour there is.
370        if k_prime >= n {
371            return rank_by_age(results, reference, half_life, top_k);
372        }
373        k_prime = k_prime.saturating_mul(2).min(n);
374    }
375}
376
377/// Apply decay to a retrieved pool, reorder, and cut it to `top_k`.
378///
379/// With no half-life this is the identity but for the truncation, and the
380/// truncation is already the `LIMIT`: the rows arrive ordered from the engine
381/// and are handed back untouched, which is what keeps 0.13.19's answer exactly
382/// 0.13.19's answer.
383///
384/// With one, the sort breaks ties on the id. Two rows at an identical decayed
385/// distance must not swap between runs, or the same query answers differently
386/// on two machines — `run_pre_filter` merges its chunks under the same rule.
387fn rank_by_age(
388    results: Vec<(VectorSearchResult, Option<String>)>,
389    reference: Option<&str>,
390    half_life: Option<Duration>,
391    top_k: usize,
392) -> Result<Vec<VectorSearchResult>> {
393    let (Some(reference), Some(half_life)) = (reference, half_life) else {
394        return Ok(results.into_iter().map(|(hit, _)| hit).collect());
395    };
396
397    let mut out = Vec::with_capacity(results.len());
398    for (mut hit, valid_from) in results {
399        // Selected on this path and only on this path, so its absence is a
400        // programming error rather than a row that lacks a validity.
401        let valid_from = valid_from.unwrap_or_default();
402        let factor = decay_factor(reference, &valid_from, half_life)?;
403        hit.score = decayed_distance(hit.score, factor);
404        out.push(hit);
405    }
406    out.sort_by(|a, b| {
407        a.score
408            .partial_cmp(&b.score)
409            .unwrap_or(std::cmp::Ordering::Equal)
410            .then_with(|| a.concept_id.cmp(&b.concept_id))
411    });
412    out.truncate(top_k);
413    Ok(out)
414}
415
416/// How many vectors `model` holds — the ceiling `search_vector` escalates to.
417///
418/// Read lazily and at most once per search: see [`search_vector`] for why it is
419/// not computed up front.
420async fn indexed_rows(conn: &libsql::Connection, model: &ModelName) -> Result<usize> {
421    let n: i64 = conn
422        .query(&format!("SELECT COUNT(*) FROM {}", model.table()), ())
423        .await?
424        .next()
425        .await?
426        .map(|row| row.get(0))
427        .transpose()?
428        .unwrap_or(0);
429    Ok(n.max(0) as usize)
430}
431
432/// Validate a vector against the model's declared dimension, then encode it.
433///
434/// The dimension comes from `F32_BLOB(n)` in the table's own column type, not
435/// from the caller and not from a table this crate maintains. That matters: the
436/// previous implementation called
437/// `EmbeddingCodec::encode(query_vec, query_vec.len(), model)`, comparing the
438/// length against itself, so the check was true by construction and
439/// `DimMismatch` was unreachable through the search path.
440async fn encode_for_model(
441    conn: &libsql::Connection,
442    model: &ModelName,
443    vector: &[f32],
444) -> Result<Vec<u8>> {
445    let dim = declared_dimension(conn, model).await?;
446    EmbeddingCodec::encode(vector, dim, model.as_str())
447}
448
449/// Compute Reciprocal Rank Fusion (RRF) score fusion algorithm: RRF(d) = \sum \frac{1}{k + r(d)} with k=60 (§5.9).
450pub fn reciprocal_rank_fusion(
451    vector_ranks: &[String],
452    keyword_ranks: &[String],
453    k: usize,
454) -> Vec<(String, f64)> {
455    let mut scores = HashMap::new();
456
457    for (rank, id) in vector_ranks.iter().enumerate() {
458        let score = 1.0 / ((k + rank + 1) as f64);
459        *scores.entry(id.clone()).or_insert(0.0) += score;
460    }
461
462    for (rank, id) in keyword_ranks.iter().enumerate() {
463        let score = 1.0 / ((k + rank + 1) as f64);
464        *scores.entry(id.clone()).or_insert(0.0) += score;
465    }
466
467    let mut sorted: Vec<_> = scores.into_iter().collect();
468    // Score descending, then id ascending. The tie-break is not cosmetic: ties
469    // are the *common* case here, because two documents at the same pair of
470    // ranks in the two arms score identically by construction, and symmetric
471    // inputs (a document at rank 3 in one arm, another at rank 3 in the other)
472    // tie exactly. Sorting on the score alone left those in `HashMap` iteration
473    // order, so the same query could return the same set in a different order on
474    // the next run — the procedural-versus-structural determinism trap D-047
475    // names, arriving here as a search result that will not sit still.
476    sorted.sort_by(|a, b| {
477        b.1.partial_cmp(&a.1)
478            .unwrap_or(std::cmp::Ordering::Equal)
479            .then_with(|| a.0.cmp(&b.0))
480    });
481    sorted
482}
483
484#[cfg(test)]
485mod decay_tests {
486    use super::*;
487
488    const HOUR: Duration = Duration::from_secs(3600);
489    const T0: &str = "2026-01-01T00:00:00.000000Z";
490    const T1: &str = "2026-01-01T01:00:00.000000Z";
491    const T2: &str = "2026-01-01T02:00:00.000000Z";
492
493    /// The definition, at the two points where it is a definition rather than
494    /// an interpolation.
495    #[test]
496    fn a_half_life_halves_at_a_half_life() {
497        assert_eq!(decay_factor(T0, T0, HOUR).unwrap(), 1.0);
498        assert_eq!(decay_factor(T1, T0, HOUR).unwrap(), 0.5);
499        assert_eq!(decay_factor(T2, T0, HOUR).unwrap(), 0.25);
500    }
501
502    /// A concept that becomes true after the instant the search reads at cannot
503    /// reach this on any real path — the same instant bounds the query — and
504    /// clamps rather than underflowing if one ever does.
505    #[test]
506    fn a_future_validity_is_zero_age_rather_than_negative() {
507        assert_eq!(decay_factor(T0, T1, HOUR).unwrap(), 1.0);
508    }
509
510    /// The limit of the formula, defined rather than refused: everything with
511    /// any age at all is gone, and only what began at the instant survives.
512    #[test]
513    fn a_zero_half_life_is_the_limit_and_not_a_nan() {
514        assert_eq!(decay_factor(T0, T0, Duration::ZERO).unwrap(), 1.0);
515        assert_eq!(decay_factor(T1, T0, Duration::ZERO).unwrap(), 0.0);
516    }
517
518    /// **The sign, stated as arithmetic.** An undecayed hit is unchanged, and a
519    /// decayed one is *further away* — never nearer, which is what multiplying
520    /// the distance would have produced.
521    #[test]
522    fn decay_moves_a_hit_away_and_never_toward() {
523        let near = 0.2_f32;
524        assert_eq!(decayed_distance(near, 1.0), near);
525        assert!(decayed_distance(near, 0.5) > near);
526        assert!(decayed_distance(near, 0.01) > decayed_distance(near, 0.5));
527        // Bounded by the far end of the cosine range rather than running away.
528        assert!(decayed_distance(near, 0.0) <= 2.0);
529    }
530
531    /// Order within one age is the raw order: decay reprices, it does not
532    /// reshuffle what it has not aged differently.
533    #[test]
534    fn one_factor_preserves_the_distance_order() {
535        assert!(decayed_distance(0.1, 0.7) < decayed_distance(0.9, 0.7));
536    }
537}