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}