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}