Skip to main content

ryu_search/
message_fts.rs

1//! Full-text (FTS5) index over past chat messages — the keyword/lexical
2//! complement to the semantic KNN index in `message_index.rs`.
3//!
4//! ## Why a separate FTS store (and the encryption-at-rest tradeoff)
5//!
6//! Conversation message bodies are encrypted at rest in `conversations.db`
7//! (`ConversationStore` / `ryu_crypto::FieldCipher`, the `enc:v1:` envelope).
8//! Like `super::message_index`, this index lives in its own
9//! `~/.ryu/message-fts.db` and stores **no retrievable message text** — it uses a
10//! CONTENTLESS FTS5 table (`content=''`), which keeps only the tokenized inverted
11//! index plus a parallel metadata table (`message_id`, `conversation_id`, `role`,
12//! `created_at`). On match the search returns `message_id`s; the caller re-reads
13//! and decrypts the snippet from `conversations.db`.
14//!
15//! HONEST TRADEOFF: a tokenized inverted index is inherently more word-recoverable
16//! than a vector — an attacker with `message-fts.db` can recover the SET of terms
17//! per message even though the message body in `conversations.db` stays encrypted.
18//! Contentless FTS5 is the best available mitigation (no retrievable column text),
19//! but the term index itself is word-recoverable. This is a deliberate, accepted
20//! tradeoff because the feature explicitly requests FTS5 (the in-memory
21//! inverted-index alternative has the identical property). Exposure is limited by
22//! keeping the feature default-OFF: the index is only ever populated for users who
23//! opt in (population happens lazily on search, which only runs when the FTS recall
24//! pref is enabled).
25//!
26//! ## Append-only, network-free
27//!
28//! Messages are immutable and append-only, so no FTS DELETE/UPDATE is ever needed
29//! (contentless FTS5 does not support them without `contentless_delete=1`, which we
30//! do not set). Inserts are idempotent on `message_id`. Unlike the semantic index,
31//! FTS needs NO embedder, so indexing and lazy backfill are network-free and can
32//! never be blocked by a down embed sidecar.
33
34use std::collections::HashSet;
35use std::path::PathBuf;
36use std::sync::Arc;
37
38use anyhow::{Context, Result};
39use rusqlite::{params, Connection};
40use tokio::sync::Mutex;
41
42/// A full-text hit from the message FTS index: the `message_id` + metadata + a
43/// bounded relevance score. The snippet/content is intentionally NOT stored here —
44/// the caller re-reads and decrypts it from `conversations.db`.
45#[derive(Debug, Clone)]
46pub struct MessageFtsHit {
47    pub message_id: String,
48    pub conversation_id: String,
49    pub role: String,
50    pub created_at: i64,
51    /// Relevance score in `(0, 1]` (higher is more relevant), derived from the
52    /// FTS5 `bm25()` rank.
53    pub score: f32,
54}
55
56/// FTS5-backed full-text index of chat-message bodies. Cheap to clone (`Arc`
57/// inside). Stores only the tokenized inverted index + metadata; never retrievable
58/// message text.
59#[derive(Clone)]
60pub struct MessageFtsIndex {
61    conn: Arc<Mutex<Connection>>,
62}
63
64impl MessageFtsIndex {
65    /// Open (or create) the FTS index at `path`. A plain `rusqlite` connection —
66    /// FTS5 ships with the `bundled` SQLite amalgamation, so no extension load is
67    /// needed (unlike sqlite-vec's `open_vec_connection`). The default db path
68    /// (`~/.ryu/message-fts.db`) is resolved Core-side by the `search_host` shim.
69    pub fn open(path: PathBuf) -> Result<Self> {
70        if let Some(parent) = path.parent() {
71            std::fs::create_dir_all(parent)
72                .with_context(|| format!("creating message-fts db dir {}", parent.display()))?;
73        }
74        let conn = Connection::open(&path)
75            .with_context(|| format!("opening message-fts db {}", path.display()))?;
76        Self::init_schema(&conn)?;
77        Ok(Self {
78            conn: Arc::new(Mutex::new(conn)),
79        })
80    }
81
82    /// Open an in-memory FTS index. Used by tests (this crate's and Core's).
83    pub fn open_in_memory() -> Result<Self> {
84        let conn = Connection::open_in_memory().context("opening in-memory message-fts db")?;
85        Self::init_schema(&conn)?;
86        Ok(Self {
87            conn: Arc::new(Mutex::new(conn)),
88        })
89    }
90
91    fn init_schema(conn: &Connection) -> Result<()> {
92        // Metadata table. NOTE: `message_id` is a plain TEXT column, NOT a foreign
93        // key — `messages` lives in a *separate* database file (`conversations.db`),
94        // so a cross-db FK is impossible. Deleting a conversation therefore orphans
95        // its FTS rows; search skips message ids that no longer resolve, and a
96        // dedicated cleanup sweep is a known follow-up.
97        conn.execute_batch(
98            "PRAGMA journal_mode = WAL;
99             CREATE TABLE IF NOT EXISTS fts_messages (
100                 rowid           INTEGER PRIMARY KEY,
101                 message_id      TEXT UNIQUE NOT NULL,
102                 conversation_id TEXT NOT NULL,
103                 role            TEXT NOT NULL,
104                 created_at      INTEGER NOT NULL
105             );
106             CREATE INDEX IF NOT EXISTS idx_fts_messages_conversation
107                 ON fts_messages(conversation_id);",
108        )
109        .context("initializing fts_messages schema")?;
110
111        // Contentless FTS5 virtual table: stores only the tokenized inverted index
112        // for `body`, never the retrievable column text (`content=''`). Keyed by the
113        // metadata rowid. If this CREATE fails the build shipped SQLite without
114        // FTS5 — surface it loudly rather than silently degrading.
115        conn.execute_batch(
116            "CREATE VIRTUAL TABLE IF NOT EXISTS message_fts USING fts5(body, content='');",
117        )
118        .context("initializing message_fts FTS5 table (is FTS5 compiled in?)")?;
119        Ok(())
120    }
121
122    /// Index a single message's body for full-text search. Idempotent on
123    /// `message_id`: a message already present is a no-op (append-only content is
124    /// immutable, so there is nothing to update). Empty/whitespace bodies are
125    /// skipped.
126    pub async fn index_message(
127        &self,
128        message_id: &str,
129        conversation_id: &str,
130        role: &str,
131        text: &str,
132        created_at: i64,
133    ) -> Result<()> {
134        if text.trim().is_empty() {
135            return Ok(());
136        }
137        let conn = self.conn.lock().await;
138        // INSERT OR IGNORE keeps this idempotent on the UNIQUE `message_id`. When
139        // the row already existed the insert is ignored and `changes()` is 0 — bail
140        // BEFORE touching `last_insert_rowid()`, which would otherwise return a
141        // STALE prior rowid and misalign the FTS body row.
142        conn.execute(
143            "INSERT OR IGNORE INTO fts_messages
144                 (message_id, conversation_id, role, created_at)
145             VALUES (?1, ?2, ?3, ?4)",
146            params![message_id, conversation_id, role, created_at],
147        )
148        .context("inserting fts_messages metadata row")?;
149        if conn.changes() == 0 {
150            // Already indexed — nothing to do.
151            return Ok(());
152        }
153        let rowid = conn.last_insert_rowid();
154        conn.execute(
155            "INSERT INTO message_fts (rowid, body) VALUES (?1, ?2)",
156            params![rowid, text],
157        )
158        .context("inserting message_fts body row")?;
159        Ok(())
160    }
161
162    /// The set of message ids already indexed (used to compute the backfill set).
163    pub async fn indexed_ids(&self) -> Result<HashSet<String>> {
164        let conn = self.conn.lock().await;
165        let mut stmt = conn.prepare("SELECT message_id FROM fts_messages")?;
166        let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
167        let mut set = HashSet::new();
168        for row in rows {
169            set.insert(row?);
170        }
171        Ok(set)
172    }
173
174    /// Full-text search over indexed message bodies. Sanitizes `query` into a safe
175    /// FTS5 MATCH expression (arbitrary chat text can otherwise trigger a syntax
176    /// error), runs a `bm25()`-ranked search, and optionally post-filters to a set
177    /// of conversation ids. Returns hits ordered most-relevant-first. An
178    /// empty/unusable query yields no hits (never an error).
179    pub async fn search(
180        &self,
181        query: &str,
182        limit: usize,
183        conversation_ids: Option<&[String]>,
184    ) -> Result<Vec<MessageFtsHit>> {
185        if limit == 0 {
186            return Ok(Vec::new());
187        }
188        let Some(match_expr) = sanitize_fts_query(query) else {
189            return Ok(Vec::new());
190        };
191        let conn = self.conn.lock().await;
192        // Over-fetch when post-filtering by conversation so the metadata filter
193        // does not starve the capped result set.
194        let fetch = match conversation_ids {
195            Some(ids) if !ids.is_empty() => limit.saturating_mul(8).max(64),
196            _ => limit,
197        };
198        // Do NOT alias the FTS table: `bm25()` and `MATCH` need the real table
199        // reference. `bm25()` is more-negative-is-better, so ascending order puts
200        // the best hits first.
201        let mut stmt = conn.prepare(
202            "SELECT m.message_id, m.conversation_id, m.role, m.created_at, bm25(message_fts)
203             FROM message_fts
204             JOIN fts_messages m ON m.rowid = message_fts.rowid
205             WHERE message_fts MATCH ?1
206             ORDER BY bm25(message_fts)
207             LIMIT ?2",
208        )?;
209        let rows = stmt.query_map(params![match_expr, fetch as i64], |row| {
210            let bm25 = row.get::<_, f64>(4)? as f32;
211            Ok(MessageFtsHit {
212                message_id: row.get(0)?,
213                conversation_id: row.get(1)?,
214                role: row.get(2)?,
215                created_at: row.get(3)?,
216                score: bm25_to_relevance(bm25),
217            })
218        })?;
219        let mut out = Vec::new();
220        for row in rows {
221            let hit = row?;
222            if let Some(ids) = conversation_ids {
223                if !ids.is_empty() && !ids.iter().any(|c| c == &hit.conversation_id) {
224                    continue;
225                }
226            }
227            out.push(hit);
228            if out.len() >= limit {
229                break;
230            }
231        }
232        Ok(out)
233    }
234}
235
236/// Map an FTS5 `bm25()` rank (unbounded, more-negative-is-better) to a bounded
237/// relevance hint in `(0, 1]` (higher is more relevant). Monotonic, so ranking is
238/// preserved; it is a relevance indicator, not an exact probability.
239fn bm25_to_relevance(bm25: f32) -> f32 {
240    let strength = (-bm25).max(0.0);
241    strength / (1.0 + strength)
242}
243
244/// Turn arbitrary user/chat text into a safe FTS5 `MATCH` expression, or `None`
245/// when no usable terms remain. Splitting on any non-alphanumeric char both strips
246/// every FTS5 meta-char (`:` column filters, `*` prefixes, quotes, `AND`/`OR`
247/// operators embedded in punctuation, etc.) AND aligns the query tokens with how
248/// FTS5's default `unicode61` tokenizer split the stored text. Each surviving term
249/// is wrapped in double quotes (so it is a literal phrase, never an operator) and
250/// the terms are OR-joined so any single term match surfaces the row.
251fn sanitize_fts_query(raw: &str) -> Option<String> {
252    let terms: Vec<String> = raw
253        .split(|c: char| !c.is_alphanumeric())
254        .filter(|t| !t.is_empty())
255        .map(|t| format!("\"{t}\""))
256        .collect();
257    if terms.is_empty() {
258        return None;
259    }
260    Some(terms.join(" OR "))
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    /// THE required round-trip: index three messages with distinct terms in
268    /// distinct conversations, search a query term, and assert the right past
269    /// session row surfaces first. Network-free (no embedder).
270    #[tokio::test]
271    async fn fts_round_trip_surfaces_matching_session() {
272        let index = MessageFtsIndex::open_in_memory().expect("open index");
273        let docs = [
274            ("m1", "c1", "assistant", "rust borrow checker lifetimes"),
275            ("m2", "c1", "user", "favourite pizza toppings pepperoni"),
276            ("m3", "c2", "user", "singapore travel itinerary"),
277        ];
278        for (id, conv, role, text) in docs {
279            index
280                .index_message(id, conv, role, text, 0)
281                .await
282                .expect("index");
283        }
284
285        let hits = index.search("pizza", 5, None).await.expect("search");
286        assert!(!hits.is_empty(), "expected at least one hit");
287        assert_eq!(hits[0].message_id, "m2", "pizza query must surface m2");
288        assert_eq!(hits[0].conversation_id, "c1");
289    }
290
291    /// Conversation-scoped search returns only hits from the requested conversation.
292    #[tokio::test]
293    async fn search_scopes_to_conversation_ids() {
294        let index = MessageFtsIndex::open_in_memory().expect("open index");
295        for (id, conv) in [("m1", "c1"), ("m2", "c2")] {
296            index
297                .index_message(id, conv, "user", "alpha beta gamma", 0)
298                .await
299                .expect("index");
300        }
301        let hits = index
302            .search("alpha beta", 5, Some(&["c2".to_owned()]))
303            .await
304            .expect("search");
305        assert!(!hits.is_empty());
306        assert!(
307            hits.iter().all(|h| h.conversation_id == "c2"),
308            "all hits must be scoped to c2"
309        );
310    }
311
312    /// Re-indexing the same message id is idempotent (no duplicate rows).
313    #[tokio::test]
314    async fn reindex_is_idempotent() {
315        let index = MessageFtsIndex::open_in_memory().expect("open index");
316        index
317            .index_message("m1", "c1", "user", "hello world", 0)
318            .await
319            .expect("index");
320        index
321            .index_message("m1", "c1", "user", "hello world", 1)
322            .await
323            .expect("reindex");
324        let ids = index.indexed_ids().await.expect("ids");
325        assert_eq!(ids.len(), 1, "re-index must not duplicate the row");
326        // And the single row is still searchable exactly once.
327        let hits = index.search("hello", 5, None).await.expect("search");
328        assert_eq!(hits.len(), 1);
329    }
330
331    /// A query full of FTS5 meta-chars must not error and still matches on the
332    /// surviving term tokens.
333    #[tokio::test]
334    async fn sanitize_handles_meta_chars() {
335        let index = MessageFtsIndex::open_in_memory().expect("open index");
336        index
337            .index_message("m1", "c1", "user", "the foo and the bar baz", 0)
338            .await
339            .expect("index");
340        // Raw query with column-filter, prefix, unbalanced quote, operator-like text.
341        let hits = index
342            .search("foo:bar* \"baz AND", 5, None)
343            .await
344            .expect("search must not error on meta-chars");
345        assert!(!hits.is_empty(), "term tokens should still match");
346        assert_eq!(hits[0].message_id, "m1");
347    }
348
349    /// `sanitize_fts_query` strips meta-chars into quoted OR-joined terms, and
350    /// returns `None` when nothing usable remains.
351    #[test]
352    fn sanitize_fts_query_shapes_terms() {
353        assert_eq!(
354            sanitize_fts_query("foo:bar* \"baz"),
355            Some("\"foo\" OR \"bar\" OR \"baz\"".to_owned())
356        );
357        assert_eq!(sanitize_fts_query("   "), None);
358        assert_eq!(sanitize_fts_query("!!! @@@ ---"), None);
359        assert_eq!(sanitize_fts_query("solo"), Some("\"solo\"".to_owned()));
360    }
361
362    /// bm25 relevance mapping is bounded and monotonic (better rank → higher score).
363    #[test]
364    fn bm25_relevance_is_bounded_and_monotonic() {
365        let strong = bm25_to_relevance(-5.0);
366        let weak = bm25_to_relevance(-0.5);
367        assert!(strong > weak);
368        assert!((0.0..=1.0).contains(&strong));
369        assert!((0.0..=1.0).contains(&weak));
370        // A non-negative (degenerate) bm25 maps to 0.
371        assert_eq!(bm25_to_relevance(1.0), 0.0);
372    }
373}