Skip to main content

ryu_search/
message_index.rs

1//! Semantic index over past chat messages (the backing store for the
2//! `search_conversations` builtin tool).
3//!
4//! ## Why this is a separate store (Core vs Gateway + encryption posture)
5//!
6//! Conversation message bodies are encrypted at rest in `conversations.db`
7//! (`ConversationStore` / `ryu_crypto::FieldCipher`, the `enc:v1:` envelope).
8//! The unified retrieval store (`retrieval.rs` `chunks` table) holds *plaintext*
9//! content. Adding conversations as a third source there would copy decrypted
10//! message text into a plaintext store — an at-rest-encryption regression. So
11//! instead this index lives in its own `~/.ryu/message-embeddings.db` and stores
12//! **only vectors + metadata** (`message_id`, `conversation_id`, `role`,
13//! `embed_model`, `embed_dims`, `created_at`) — never the message text. On search
14//! the KNN returns `message_id`s, and the caller re-reads + decrypts the snippet
15//! from `conversations.db`. The vector BLOB itself is left plaintext, consistent
16//! with `spaces.rs` (the secret is the message content, which is never copied out;
17//! a vector is a deterministic, non-reversible derivative).
18//!
19//! ## Vector convention
20//!
21//! Follows the `spaces.rs` sqlite-vec `vec0` convention (efficient KNN), not the
22//! brute-force cosine scan in `retrieval.rs`: a chat db can accumulate many
23//! messages. The `vec0` table width is fixed at creation to the active embedder's
24//! dims; rows are tagged with `embed_model` so a query embedded by a different
25//! model never matches an incomparable vector space (stale rows are skipped until
26//! re-embedded).
27//!
28//! ## Indexing + backfill (fail-open)
29//!
30//! - **On write:** `ConversationStore::append_message` spawns a best-effort task
31//!   that embeds the *plaintext* (before sealing) and inserts a vector row. A
32//!   failure (embed sidecar down, etc.) is logged and dropped — it never blocks
33//!   or slows the chat write, and the DB mutex is never held across the embed.
34//! - **Lazy backfill:** the first search embeds any not-yet-indexed messages
35//!   (decrypting stored content first via the conversation cipher), so the feature
36//!   returns hits for chats already on disk. A failed embed during backfill is
37//!   non-fatal — that message is simply skipped this round.
38
39use 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/// A KNN hit from the message index: the `message_id` + its (squared L2) distance.
50/// The snippet/content is intentionally NOT stored here — the caller re-reads and
51/// decrypts it from `conversations.db`.
52#[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    /// Squared L2 distance from the query vector (smaller = closer).
59    pub distance: f32,
60}
61
62/// sqlite-vec-backed index of chat-message embeddings. Cheap to clone (`Arc`
63/// inside). Stores vectors + metadata only; never message text.
64#[derive(Clone)]
65pub struct MessageIndex {
66    conn: Arc<Mutex<Connection>>,
67    embedder: Arc<dyn SearchEmbedder>,
68    /// Width of the `message_vectors` vec0 table, fixed at creation.
69    dims: Arc<AtomicUsize>,
70}
71
72impl MessageIndex {
73    /// Open (or create) the message index at `path` using the supplied embedder.
74    /// The default db path (`~/.ryu/message-embeddings.db`) and the
75    /// registry-driven embedder choice are resolved Core-side by the
76    /// `search_host` shim.
77    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        // The vec0 table width is fixed at creation. If the active embedder now
86        // produces a *different* dimensionality than what the on-disk index was
87        // built for (the user swapped to a different-dimension embedding model and
88        // restarted), a `MATCH` against the new query width would error. Follow the
89        // `spaces.rs` precedent: drop + recreate the vector table at the new width
90        // and clear the metadata so the next search cleanly re-backfills, rather
91        // than failing. Detected via the stored `embed_dims` (any row's value).
92        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    /// Drop + recreate the vec0 table (and clear metadata) when the active embedder
101    /// dims differ from the width the on-disk index was created with. A no-op for
102    /// a fresh/empty index or when the dims already match.
103    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    /// Open an in-memory index with the supplied embedder at that embedder's dims.
126    /// Used by tests (both this crate's and Core's, via the `search_host` shim).
127    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        // Metadata table. NOTE: `message_id` is a plain TEXT column, NOT a foreign
140        // key — `messages` lives in a *separate* database file (`conversations.db`),
141        // so a cross-db FK is impossible. Deleting a conversation therefore orphans
142        // its vectors; search skips message ids that no longer resolve, and a
143        // dedicated cleanup sweep is a known follow-up.
144        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        // vec0 virtual table holds only the vector, keyed by the metadata rowid.
160        // Width is fixed at creation to the active embedder dims.
161        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    /// Snapshot the embedder (cheap `Arc` clone) so embedding I/O never holds a
170    /// lock.
171    pub fn embedder(&self) -> Arc<dyn SearchEmbedder> {
172        self.embedder.clone()
173    }
174
175    /// Index a single message's embedding. Idempotent on `message_id` (re-indexing
176    /// replaces the prior row + vector). The `embedding` length must equal the
177    /// table width; a mismatch is rejected (a vec0 insert would otherwise error).
178    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        // Re-indexing replaces the prior row + vector. vec0 virtual tables do NOT
197        // support UPSERT, so we delete-then-insert: look up any existing rowid,
198        // drop its vector, then re-insert metadata + vector under a fresh rowid.
199        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    /// The set of message ids already indexed (used to compute the backfill set).
239    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    /// KNN search. Embeds `query`, runs a cosine-distance KNN over the vec0 table
251    /// filtered to the *current* embedder's model (incomparable vector spaces are
252    /// skipped), optionally scoping to a set of conversation ids. Returns hits
253    /// ordered nearest-first.
254    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        // vec0 KNN must over-fetch when we post-filter by conversation, since the
265        // `WHERE k = ?` clause caps the candidate set before our metadata filter.
266        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    /// Default embed dims used by the crate's tests (mirrors Core's
311    /// `registry::DEFAULT_EMBED_DIMS`).
312    const TEST_DIMS: usize = 768;
313
314    fn test_embedder() -> Arc<dyn SearchEmbedder> {
315        Arc::new(LocalHashingEmbedder::new(TEST_DIMS))
316    }
317
318    /// Index a few messages with distinct tokens, then search for a query that
319    /// shares tokens with one of them and assert it ranks first. Uses the local
320    /// (network-free) hashing embedder, so no embed sidecar is required.
321    #[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    /// Conversation-scoped search returns only hits from the requested conversation.
363    #[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    /// A dims mismatch between the on-disk index and the active embedder triggers
389    /// a clean recreate (no MATCH error), leaving an empty index to re-backfill.
390    #[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        // Seed a metadata row tagged with a *different* width.
396        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        // Reconcile to the new (full) dims: should clear the stale row.
404        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        // And the recreated vec0 table accepts inserts at the new width.
410        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    /// Re-indexing the same message id replaces (not duplicates) its vector row.
423    #[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}