Skip to main content

macrame/vector/
search.rs

1use std::collections::HashMap;
2
3use crate::error::Result;
4use crate::vector::registry::declared_dimension;
5use crate::vector::{EmbeddingCodec, ModelName};
6
7/// Search result container for vector similarity or hybrid search (§5.9).
8#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
9pub struct VectorSearchResult {
10    pub concept_id: String,
11    /// Cosine distance: 0.0 is identical, larger is further. Ascending order.
12    pub score: f32,
13}
14
15/// Store or replace a concept's vector for one model (§4.1, Doctrine VII).
16///
17/// An embedding is derived, so re-embedding the same concept under the same
18/// model replaces the row rather than versioning it — the ledger records that
19/// the concept changed, and the vector is recomputed from the concept. Nothing
20/// here writes to `transaction_log`; there are no triggers on this table.
21///
22/// The dimension is checked against the model's *declared* dimension before the
23/// statement is built, so the caller gets [`DimMismatch`] naming both numbers
24/// rather than the engine's `dimensions are different: 2 != 4`.
25///
26/// # Prefer [`crate::Database::upsert_embeddings`]
27///
28/// This takes a **bare connection** and is therefore §4.7 invariant 2's third
29/// hole: a write that does not cross the actor's channel. Hidden from the docs
30/// alongside [`crate::Database::raw`] (D-091) so the documented path is the one
31/// that preserves the single-writer property; still public, for the reason
32/// [D-068](../../docs/architecture/s13-decision-register.md#d-068) gives.
33///
34/// [`DimMismatch`]: crate::error::DbError::DimMismatch
35#[doc(hidden)]
36pub async fn upsert_embedding(
37    conn: &libsql::Connection,
38    model: &ModelName,
39    concept_id: &str,
40    vector: &[f32],
41) -> Result<()> {
42    let blob = encode_for_model(conn, model, vector).await?;
43
44    // `model.table()` is a bare identifier by construction; the values bind.
45    conn.execute(
46        &format!(
47            "INSERT INTO {table} (concept_id, embedding) VALUES (?1, ?2)
48             ON CONFLICT(concept_id) DO UPDATE SET embedding = excluded.embedding",
49            table = model.table()
50        ),
51        libsql::params![concept_id, blob],
52    )
53    .await?;
54    Ok(())
55}
56
57/// Store or replace one chunk of vectors for a model, in a single transaction.
58///
59/// The dimension is resolved **once per chunk**, not once per row.
60/// [`declared_dimension`] is a `PRAGMA table_info` round trip, so resolving it
61/// per row turns a bulk embed into one round trip per vector — and the answer
62/// cannot change inside a chunk, because the chunk holds the write lock and the
63/// dimension is a property of a table only `register_model` creates.
64///
65/// Atomic per chunk, not across chunks: a failure partway leaves earlier chunks
66/// committed. That is the right trade here in a way it would not be for
67/// assertions — an embedding is a derived artifact (Doctrine VII), so a
68/// partially written batch is recoverable by re-embedding, whereas a partially
69/// written history is not recoverable at all.
70pub(crate) async fn upsert_embedding_chunk(
71    conn: &libsql::Connection,
72    model: &ModelName,
73    rows: &[(String, Vec<f32>)],
74) -> Result<usize> {
75    if rows.is_empty() {
76        return Ok(0);
77    }
78
79    let dim = declared_dimension(conn, model).await?;
80    let sql = format!(
81        "INSERT INTO {table} (concept_id, embedding) VALUES (?1, ?2)
82         ON CONFLICT(concept_id) DO UPDATE SET embedding = excluded.embedding",
83        table = model.table()
84    );
85
86    let tx = conn
87        .transaction_with_behavior(libsql::TransactionBehavior::Immediate)
88        .await?;
89
90    // Prepared once per chunk, for the same reason the dimension is resolved once
91    // per chunk and the same reason the edge chunk hoists its insert (D-056): the
92    // statement text is identical for every row, and the embedding tables carry a
93    // DiskANN index whose maintenance is compiled into each preparation.
94    //
95    // `reset()` between rows is required — libsql's `Statement::execute` binds and
96    // steps without resetting first.
97    let stmt = tx.prepare(&sql).await?;
98
99    let res: Result<()> = async {
100        for (concept_id, vector) in rows {
101            let blob = EmbeddingCodec::encode(vector, dim, model.as_str())?;
102            stmt.reset();
103            stmt.execute(libsql::params![concept_id.as_str(), blob])
104                .await?;
105        }
106        Ok(())
107    }
108    .await;
109
110    // Dropped before either arm ends the transaction: a live statement on the
111    // connection is what makes SQLite refuse to commit or roll back.
112    drop(stmt);
113
114    match res {
115        Ok(()) => {
116            tx.commit().await?;
117            Ok(rows.len())
118        }
119        Err(e) => {
120            let _ = tx.rollback().await;
121            Err(e)
122        }
123    }
124}
125
126/// Top-k nearest neighbours for `query_vec` under `model` (§5.9).
127///
128/// Goes through `vector_top_k`, which consults the DiskANN index, rather than
129/// scanning the table and sorting: the index is what §9's "top-10 over 100K
130/// concepts in ≤20 ms" budget assumes, and an `ORDER BY vector_distance_cos(…)`
131/// over the whole table is linear in the corpus no matter how small `k` is.
132/// `vector_top_k` yields base-table rowids, so the distance is recomputed on the
133/// k rows it selects — k distance evaluations, not one per concept.
134pub async fn search_vector(
135    conn: &libsql::Connection,
136    query_vec: &[f32],
137    model: &ModelName,
138    top_k: usize,
139) -> Result<Vec<VectorSearchResult>> {
140    if top_k == 0 {
141        return Ok(Vec::new());
142    }
143    let blob = encode_for_model(conn, model, query_vec).await?;
144
145    let sql = format!(
146        "SELECT e.concept_id, vector_distance_cos(e.embedding, ?1)
147           FROM vector_top_k('{index}', ?1, ?2) AS t
148           JOIN {table} AS e ON e.rowid = t.id
149          ORDER BY 2 ASC",
150        index = model.index(),
151        table = model.table(),
152    );
153
154    let mut rows = conn
155        .query(&sql, libsql::params![blob, top_k as i64])
156        .await?;
157
158    let mut results = Vec::new();
159    while let Some(row) = rows.next().await? {
160        results.push(VectorSearchResult {
161            concept_id: row.get(0)?,
162            // The distance is computed by the engine over a non-null F32_BLOB
163            // column, so a null here would mean the schema is not what we think.
164            score: row.get::<f64>(1)? as f32,
165        });
166    }
167    Ok(results)
168}
169
170/// Validate a vector against the model's declared dimension, then encode it.
171///
172/// The dimension comes from `F32_BLOB(n)` in the table's own column type, not
173/// from the caller and not from a table this crate maintains. That matters: the
174/// previous implementation called
175/// `EmbeddingCodec::encode(query_vec, query_vec.len(), model)`, comparing the
176/// length against itself, so the check was true by construction and
177/// `DimMismatch` was unreachable through the search path.
178async fn encode_for_model(
179    conn: &libsql::Connection,
180    model: &ModelName,
181    vector: &[f32],
182) -> Result<Vec<u8>> {
183    let dim = declared_dimension(conn, model).await?;
184    EmbeddingCodec::encode(vector, dim, model.as_str())
185}
186
187/// Compute Reciprocal Rank Fusion (RRF) score fusion algorithm: RRF(d) = \sum \frac{1}{k + r(d)} with k=60 (§5.9).
188pub fn reciprocal_rank_fusion(
189    vector_ranks: &[String],
190    keyword_ranks: &[String],
191    k: usize,
192) -> Vec<(String, f64)> {
193    let mut scores = HashMap::new();
194
195    for (rank, id) in vector_ranks.iter().enumerate() {
196        let score = 1.0 / ((k + rank + 1) as f64);
197        *scores.entry(id.clone()).or_insert(0.0) += score;
198    }
199
200    for (rank, id) in keyword_ranks.iter().enumerate() {
201        let score = 1.0 / ((k + rank + 1) as f64);
202        *scores.entry(id.clone()).or_insert(0.0) += score;
203    }
204
205    let mut sorted: Vec<_> = scores.into_iter().collect();
206    // Score descending, then id ascending. The tie-break is not cosmetic: ties
207    // are the *common* case here, because two documents at the same pair of
208    // ranks in the two arms score identically by construction, and symmetric
209    // inputs (a document at rank 3 in one arm, another at rank 3 in the other)
210    // tie exactly. Sorting on the score alone left those in `HashMap` iteration
211    // order, so the same query could return the same set in a different order on
212    // the next run — the procedural-versus-structural determinism trap D-047
213    // names, arriving here as a search result that will not sit still.
214    sorted.sort_by(|a, b| {
215        b.1.partial_cmp(&a.1)
216            .unwrap_or(std::cmp::Ordering::Equal)
217            .then_with(|| a.0.cmp(&b.0))
218    });
219    sorted
220}