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}