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