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}