1use std::path::{Path, PathBuf};
40use std::sync::atomic::{AtomicUsize, Ordering};
41use std::sync::Arc;
42
43use anyhow::{Context, Result};
44use rusqlite::{params, Connection};
45use tokio::sync::Mutex;
46
47use crate::{encode_embedding, open_vec_connection, SearchEmbedder};
48
49#[derive(Debug, Clone)]
53pub struct MessageHit {
54 pub message_id: String,
55 pub conversation_id: String,
56 pub role: String,
57 pub created_at: i64,
58 pub distance: f32,
60}
61
62#[derive(Clone)]
65pub struct MessageIndex {
66 conn: Arc<Mutex<Connection>>,
67 embedder: Arc<dyn SearchEmbedder>,
68 dims: Arc<AtomicUsize>,
70}
71
72impl MessageIndex {
73 pub fn open(path: PathBuf, embedder: Arc<dyn SearchEmbedder>) -> Result<Self> {
78 if let Some(parent) = path.parent() {
79 std::fs::create_dir_all(parent)
80 .with_context(|| format!("creating message-index db dir {}", parent.display()))?;
81 }
82 let conn = open_vec_connection(&path)?;
83 let dims = embedder.dims();
84 Self::init_schema(&conn, dims)?;
85 Self::reconcile_dims(&conn, dims)?;
93 Ok(Self {
94 conn: Arc::new(Mutex::new(conn)),
95 embedder,
96 dims: Arc::new(AtomicUsize::new(dims)),
97 })
98 }
99
100 fn reconcile_dims(conn: &Connection, dims: usize) -> Result<()> {
104 let stored: Option<i64> = conn
105 .query_row(
106 "SELECT embed_dims FROM message_embeddings LIMIT 1",
107 [],
108 |row| row.get(0),
109 )
110 .ok();
111 if let Some(stored) = stored {
112 if stored as usize != dims {
113 conn.execute_batch(&format!(
114 "DROP TABLE IF EXISTS message_vectors;
115 DELETE FROM message_embeddings;
116 CREATE VIRTUAL TABLE message_vectors
117 USING vec0(rowid INTEGER PRIMARY KEY, embedding float[{dims}]);"
118 ))
119 .context("recreating message_vectors for new embedder dims")?;
120 }
121 }
122 Ok(())
123 }
124
125 pub fn open_in_memory(embedder: Arc<dyn SearchEmbedder>) -> Result<Self> {
128 let dims = embedder.dims();
129 let conn = open_vec_connection(Path::new(":memory:"))?;
130 Self::init_schema(&conn, dims)?;
131 Ok(Self {
132 conn: Arc::new(Mutex::new(conn)),
133 embedder,
134 dims: Arc::new(AtomicUsize::new(dims)),
135 })
136 }
137
138 fn init_schema(conn: &Connection, dims: usize) -> Result<()> {
139 conn.execute_batch(
145 "PRAGMA journal_mode = WAL;
146 CREATE TABLE IF NOT EXISTS message_embeddings (
147 message_id TEXT PRIMARY KEY,
148 conversation_id TEXT NOT NULL,
149 role TEXT NOT NULL,
150 embed_model TEXT NOT NULL,
151 embed_dims INTEGER NOT NULL,
152 created_at INTEGER NOT NULL
153 );
154 CREATE INDEX IF NOT EXISTS idx_msg_emb_conversation
155 ON message_embeddings(conversation_id);",
156 )
157 .context("initializing message_embeddings schema")?;
158
159 conn.execute_batch(&format!(
162 "CREATE VIRTUAL TABLE IF NOT EXISTS message_vectors
163 USING vec0(rowid INTEGER PRIMARY KEY, embedding float[{dims}]);"
164 ))
165 .context("initializing message_vectors vec0 table")?;
166 Ok(())
167 }
168
169 pub fn embedder(&self) -> Arc<dyn SearchEmbedder> {
172 self.embedder.clone()
173 }
174
175 pub async fn index_message(
179 &self,
180 message_id: &str,
181 conversation_id: &str,
182 role: &str,
183 embedding: &[f32],
184 embed_model: &str,
185 created_at: i64,
186 ) -> Result<()> {
187 let dims = self.dims.load(Ordering::Relaxed);
188 if embedding.len() != dims {
189 anyhow::bail!(
190 "embedding length {} does not match index width {dims}",
191 embedding.len()
192 );
193 }
194 let bytes = encode_embedding(embedding);
195 let conn = self.conn.lock().await;
196 let existing_rowid: Option<i64> = conn
200 .query_row(
201 "SELECT rowid FROM message_embeddings WHERE message_id = ?1",
202 [message_id],
203 |row| row.get(0),
204 )
205 .ok();
206 if let Some(rowid) = existing_rowid {
207 conn.execute("DELETE FROM message_vectors WHERE rowid = ?1", [rowid])
208 .context("deleting stale message vector")?;
209 conn.execute(
210 "DELETE FROM message_embeddings WHERE message_id = ?1",
211 [message_id],
212 )
213 .context("deleting stale message_embeddings row")?;
214 }
215 conn.execute(
216 "INSERT INTO message_embeddings
217 (message_id, conversation_id, role, embed_model, embed_dims, created_at)
218 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
219 params![
220 message_id,
221 conversation_id,
222 role,
223 embed_model,
224 dims as i64,
225 created_at
226 ],
227 )
228 .context("inserting message_embeddings row")?;
229 let rowid = conn.last_insert_rowid();
230 conn.execute(
231 "INSERT INTO message_vectors (rowid, embedding) VALUES (?1, ?2)",
232 params![rowid, bytes],
233 )
234 .context("inserting message vector")?;
235 Ok(())
236 }
237
238 pub async fn indexed_ids(&self) -> Result<std::collections::HashSet<String>> {
240 let conn = self.conn.lock().await;
241 let mut stmt = conn.prepare("SELECT message_id FROM message_embeddings")?;
242 let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
243 let mut set = std::collections::HashSet::new();
244 for row in rows {
245 set.insert(row?);
246 }
247 Ok(set)
248 }
249
250 pub async fn search(
255 &self,
256 query: &str,
257 limit: usize,
258 conversation_ids: Option<&[String]>,
259 ) -> Result<Vec<MessageHit>> {
260 let model_id = self.embedder.model_id().to_string();
261 let query_vec = self.embedder.embed(query).await?;
262 let bytes = encode_embedding(&query_vec);
263 let conn = self.conn.lock().await;
264 let fetch = match conversation_ids {
267 Some(ids) if !ids.is_empty() => limit.saturating_mul(8).max(64),
268 _ => limit,
269 };
270 let mut stmt = conn.prepare(
271 "SELECT m.message_id, m.conversation_id, m.role, m.created_at, v.distance
272 FROM message_vectors v
273 JOIN message_embeddings m ON m.rowid = v.rowid
274 WHERE v.embedding MATCH ?1
275 AND k = ?2
276 AND m.embed_model = ?3
277 ORDER BY v.distance",
278 )?;
279 let rows = stmt.query_map(params![bytes, fetch as i64, model_id], |row| {
280 Ok(MessageHit {
281 message_id: row.get(0)?,
282 conversation_id: row.get(1)?,
283 role: row.get(2)?,
284 created_at: row.get(3)?,
285 distance: row.get::<_, f64>(4)? as f32,
286 })
287 })?;
288 let mut out = Vec::new();
289 for row in rows {
290 let hit = row?;
291 if let Some(ids) = conversation_ids {
292 if !ids.is_empty() && !ids.iter().any(|c| c == &hit.conversation_id) {
293 continue;
294 }
295 }
296 out.push(hit);
297 if out.len() >= limit {
298 break;
299 }
300 }
301 Ok(out)
302 }
303}
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308 use crate::test_embedder::LocalHashingEmbedder;
309
310 const TEST_DIMS: usize = 768;
313
314 fn test_embedder() -> Arc<dyn SearchEmbedder> {
315 Arc::new(LocalHashingEmbedder::new(TEST_DIMS))
316 }
317
318 #[tokio::test]
322 async fn knn_round_trip_ranks_token_overlap_first() {
323 let index = MessageIndex::open_in_memory(test_embedder()).expect("open index");
324 let model = index.embedder().model_id().to_string();
325
326 let docs = [
327 (
328 "m1",
329 "c1",
330 "user",
331 "the quick brown fox jumps over the lazy dog",
332 ),
333 (
334 "m2",
335 "c1",
336 "assistant",
337 "rust borrow checker lifetimes and ownership",
338 ),
339 (
340 "m3",
341 "c2",
342 "user",
343 "favourite pizza toppings pepperoni mushroom",
344 ),
345 ];
346 for (id, conv, role, text) in docs {
347 let emb = index.embedder().embed(text).await.expect("embed");
348 index
349 .index_message(id, conv, role, &emb, &model, 0)
350 .await
351 .expect("index");
352 }
353
354 let hits = index
355 .search("rust ownership and lifetimes", 3, None)
356 .await
357 .expect("search");
358 assert!(!hits.is_empty(), "expected at least one hit");
359 assert_eq!(hits[0].message_id, "m2", "rust message should rank first");
360 }
361
362 #[tokio::test]
364 async fn search_scopes_to_conversation_ids() {
365 let index = MessageIndex::open_in_memory(test_embedder()).expect("open index");
366 let model = index.embedder().model_id().to_string();
367 for (id, conv, text) in [
368 ("m1", "c1", "alpha beta gamma"),
369 ("m2", "c2", "alpha beta gamma"),
370 ] {
371 let emb = index.embedder().embed(text).await.expect("embed");
372 index
373 .index_message(id, conv, "user", &emb, &model, 0)
374 .await
375 .expect("index");
376 }
377 let hits = index
378 .search("alpha beta", 5, Some(&["c2".to_owned()]))
379 .await
380 .expect("search");
381 assert!(!hits.is_empty());
382 assert!(
383 hits.iter().all(|h| h.conversation_id == "c2"),
384 "all hits must be scoped to c2"
385 );
386 }
387
388 #[tokio::test]
391 async fn reconcile_dims_recreates_on_mismatch() {
392 let dims = TEST_DIMS;
393 let conn = open_vec_connection(Path::new(":memory:")).expect("conn");
394 MessageIndex::init_schema(&conn, dims).expect("schema");
395 conn.execute(
397 "INSERT INTO message_embeddings
398 (message_id, conversation_id, role, embed_model, embed_dims, created_at)
399 VALUES ('m1', 'c1', 'user', 'old-model', ?1, 0)",
400 params![(dims / 2) as i64],
401 )
402 .expect("seed row");
403 MessageIndex::reconcile_dims(&conn, dims).expect("reconcile");
405 let remaining: i64 = conn
406 .query_row("SELECT COUNT(*) FROM message_embeddings", [], |r| r.get(0))
407 .expect("count");
408 assert_eq!(remaining, 0, "stale rows cleared on dims change");
409 let index = MessageIndex {
411 conn: Arc::new(Mutex::new(conn)),
412 embedder: test_embedder(),
413 dims: Arc::new(AtomicUsize::new(dims)),
414 };
415 let emb = index.embedder().embed("hello").await.expect("embed");
416 index
417 .index_message("m2", "c1", "user", &emb, "local-hashing", 0)
418 .await
419 .expect("insert at new width");
420 }
421
422 #[tokio::test]
424 async fn reindex_is_idempotent() {
425 let index = MessageIndex::open_in_memory(test_embedder()).expect("open index");
426 let model = index.embedder().model_id().to_string();
427 let emb = index.embedder().embed("hello world").await.expect("embed");
428 index
429 .index_message("m1", "c1", "user", &emb, &model, 0)
430 .await
431 .expect("index");
432 index
433 .index_message("m1", "c1", "user", &emb, &model, 1)
434 .await
435 .expect("reindex");
436 let ids = index.indexed_ids().await.expect("ids");
437 assert_eq!(ids.len(), 1, "re-index must not duplicate the row");
438 }
439}