Skip to main content

ryu_search/
lib.rs

1//! Conversation search primitive: the sqlite-vec (`vec0`) semantic KNN index
2//! ([`MessageIndex`]) and the contentless FTS5 lexical index
3//! ([`MessageFtsIndex`]) over past chat messages. Both stores hold vectors /
4//! inverted-index + metadata only — never message text; the caller re-reads and
5//! decrypts each hit's snippet from `conversations.db`.
6//!
7//! ## The embedder seam ([`SearchEmbedder`])
8//!
9//! The semantic index needs to turn text into vectors, but *which* embedder
10//! (local hashing vs. a registry-configured remote `/v1/embeddings` endpoint) is
11//! a per-consumer RAG concern that must stay out of this crate. So the embedder
12//! is injected as a narrow [`SearchEmbedder`] trait object at construction; Core
13//! wraps its `retrieval::Embedder` behind this in `apps/core/src/search_host.rs`.
14//! The crate never sees `ModelRegistry` and has ZERO dependency on `apps/core`.
15//!
16//! The default db paths (`~/.ryu/message-embeddings.db`, `~/.ryu/message-fts.db`)
17//! and the registry-driven embedder choice likewise stay Core-side (the host
18//! shim), mirroring the `ryu-storage` `open(path)` precedent.
19
20mod message_fts;
21mod message_index;
22
23pub use message_fts::{MessageFtsHit, MessageFtsIndex};
24pub use message_index::{MessageHit, MessageIndex};
25
26use anyhow::Result;
27use async_trait::async_trait;
28
29/// Narrow embedding seam for the semantic message index. Core wraps its
30/// registry-configured `retrieval::Embedder` behind this trait so the crate never
31/// depends on the model registry (per-consumer embedder config is a RAG concern,
32/// a later decomposition wave).
33#[async_trait]
34pub trait SearchEmbedder: Send + Sync {
35    /// The dimensionality this embedder produces (fixes the vec0 table width).
36    fn dims(&self) -> usize;
37
38    /// A stable identifier for the embedding model. Rows are tagged with it so a
39    /// query embedded by a different model never matches an incomparable vector
40    /// space.
41    fn model_id(&self) -> &str;
42
43    /// `true` for a deterministic local (network-free) embedder. Callers use this
44    /// to decide whether embedding work can run inline or must be spawned off the
45    /// request path.
46    fn is_local(&self) -> bool;
47
48    /// Embed a single piece of text into a normalized vector of length
49    /// [`dims`](Self::dims).
50    async fn embed(&self, text: &str) -> Result<Vec<f32>>;
51}
52
53use std::path::Path;
54
55use anyhow::Context;
56use rusqlite::Connection;
57
58/// Register the sqlite-vec extension exactly once for the whole process, then open
59/// a `vec0`-capable connection. Installed as a SQLite *auto-extension* so every
60/// connection opened afterwards gains the `vec0` virtual table.
61///
62/// A sibling copy of this registration lives in `apps/core/src/server/spaces.rs`;
63/// both pass the identical `sqlite_vec::sqlite3_vec_init` pointer (one unified
64/// crate) to `sqlite3_auto_extension`, which deduplicates identical registrations
65/// — so the two coexist harmlessly.
66pub(crate) fn open_vec_connection(path: &Path) -> Result<Connection> {
67    use std::sync::Once;
68    static REGISTER: Once = Once::new();
69    REGISTER.call_once(|| {
70        // SAFETY: `sqlite3_vec_init` has the SQLite extension entry-point ABI and
71        // sqlite3_auto_extension stores the pointer for use on connection open.
72        // Mirrors sqlite-vec's own documented rusqlite registration.
73        unsafe {
74            rusqlite::ffi::sqlite3_auto_extension(Some(std::mem::transmute(
75                sqlite_vec::sqlite3_vec_init as *const (),
76            )));
77        }
78    });
79    let conn = if path == Path::new(":memory:") {
80        Connection::open_in_memory().context("opening in-memory search db")?
81    } else {
82        Connection::open(path).with_context(|| format!("opening search db {}", path.display()))?
83    };
84    Ok(conn)
85}
86
87/// Encode an f32 vector as a little-endian byte BLOB (sqlite-vec wire format).
88pub(crate) fn encode_embedding(vec: &[f32]) -> Vec<u8> {
89    let mut bytes = Vec::with_capacity(vec.len() * 4);
90    for v in vec {
91        bytes.extend_from_slice(&v.to_le_bytes());
92    }
93    bytes
94}
95
96/// Deterministic local (network-free) embedder used by the crate's own unit
97/// tests. Copied verbatim from `apps/core/src/server/retrieval.rs`
98/// (`local_embed`/`tokenize`/`fnv1a`/`l2_normalize`) so the moved KNN tests keep
99/// byte-identical ranking semantics. Core's own in-memory test constructors wrap
100/// the *real* `Embedder::Local` via `search_host`, not this copy.
101#[cfg(test)]
102mod test_embedder {
103    use super::{Result, SearchEmbedder};
104    use async_trait::async_trait;
105
106    /// A normalized bag-of-token-hashes embedder. Model id `"local-hashing"`.
107    pub struct LocalHashingEmbedder {
108        dims: usize,
109    }
110
111    impl LocalHashingEmbedder {
112        pub fn new(dims: usize) -> Self {
113            Self { dims }
114        }
115    }
116
117    #[async_trait]
118    impl SearchEmbedder for LocalHashingEmbedder {
119        fn dims(&self) -> usize {
120            self.dims
121        }
122
123        fn model_id(&self) -> &str {
124            "local-hashing"
125        }
126
127        fn is_local(&self) -> bool {
128            true
129        }
130
131        async fn embed(&self, text: &str) -> Result<Vec<f32>> {
132            Ok(local_embed(text, self.dims))
133        }
134    }
135
136    fn local_embed(text: &str, dims: usize) -> Vec<f32> {
137        let mut vec = vec![0.0f32; dims];
138        for token in tokenize(text) {
139            let bucket = (fnv1a(&token) as usize) % dims;
140            vec[bucket] += 1.0;
141        }
142        l2_normalize(&mut vec);
143        vec
144    }
145
146    fn tokenize(text: &str) -> Vec<String> {
147        text.split(|c: char| !c.is_alphanumeric())
148            .filter(|s| !s.is_empty())
149            .map(|s| s.to_lowercase())
150            .collect()
151    }
152
153    fn fnv1a(s: &str) -> u64 {
154        const OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
155        const PRIME: u64 = 0x0000_0100_0000_01b3;
156        let mut hash = OFFSET;
157        for byte in s.as_bytes() {
158            hash ^= u64::from(*byte);
159            hash = hash.wrapping_mul(PRIME);
160        }
161        hash
162    }
163
164    fn l2_normalize(vec: &mut [f32]) {
165        let norm: f32 = vec.iter().map(|v| v * v).sum::<f32>().sqrt();
166        if norm > f32::EPSILON {
167            for v in vec.iter_mut() {
168                *v /= norm;
169            }
170        }
171    }
172}