Skip to main content

aimem_core/
search.rs

1//! Semantic search and FTS over the AiMem store.
2
3use std::collections::HashSet;
4
5use crate::{
6    db::{AimemDb, DbError},
7    embedder::Embedder,
8    types::{Drawer, SearchResult},
9};
10use thiserror::Error;
11
12#[derive(Debug, Error)]
13pub enum SearchError {
14    #[error("db error: {0}")]
15    Db(#[from] DbError),
16    #[error("embed error: {0}")]
17    Embed(#[from] crate::embedder::EmbedError),
18    #[error("semantic search requires an embedder")]
19    EmbedderUnavailable,
20    #[error("turso error: {0}")]
21    Turso(#[from] turso::Error),
22}
23
24/// Searcher — vector + keyword search over the AiMem store.
25#[derive(Debug, Clone)]
26pub struct Searcher {
27    db: AimemDb,
28    embedder: Option<Embedder>,
29}
30
31impl Searcher {
32    pub fn new(db: AimemDb, embedder: Embedder) -> Self {
33        Self {
34            db,
35            embedder: Some(embedder),
36        }
37    }
38
39    pub fn keyword_only(db: AimemDb) -> Self {
40        Self { db, embedder: None }
41    }
42
43    /// Semantic vector search using Turso's `vector_distance_cos`.
44    pub async fn vector_search(
45        &self,
46        query: &str,
47        wing: Option<&str>,
48        room: Option<&str>,
49        limit: usize,
50    ) -> Result<Vec<SearchResult>, SearchError> {
51        let qvec = self
52            .embedder
53            .as_ref()
54            .ok_or(SearchError::EmbedderUnavailable)?
55            .embed_one(query)?;
56        let qvec_json = Embedder::to_vector32_json(&qvec);
57
58        let conn = self.db.conn()?;
59
60        // Build dynamic SQL + gather rows using turso params! macro
61        let sql = build_vector_sql(wing, room);
62        let mut rows = match (wing, room) {
63            (Some(w), Some(r)) => {
64                conn.query(&sql, turso::params![qvec_json.as_str(), w, r, limit as i64])
65                    .await?
66            }
67            (Some(w), None) => {
68                conn.query(&sql, turso::params![qvec_json.as_str(), w, limit as i64])
69                    .await?
70            }
71            (None, Some(r)) => {
72                conn.query(&sql, turso::params![qvec_json.as_str(), r, limit as i64])
73                    .await?
74            }
75            (None, None) => {
76                conn.query(&sql, turso::params![qvec_json.as_str(), limit as i64])
77                    .await?
78            }
79        };
80
81        let mut results = Vec::new();
82        while let Some(row) = rows.next().await? {
83            let drawer = row_to_drawer(&row);
84            let dist = match row.get_value(8)? {
85                turso::Value::Real(f) => f as f32,
86                turso::Value::Integer(i) => i as f32,
87                _ => 1.0,
88            };
89            let similarity = (1.0_f32 - dist).clamp(0.0, 1.0);
90            results.push(SearchResult { drawer, similarity });
91        }
92        Ok(results)
93    }
94
95    /// Keyword search using SQL LIKE.
96    pub async fn keyword_search(
97        &self,
98        query: &str,
99        wing: Option<&str>,
100        room: Option<&str>,
101        limit: usize,
102    ) -> Result<Vec<Drawer>, SearchError> {
103        let conn = self.db.conn()?;
104        let like = format!("%{}%", query.replace('%', "\\%").replace('_', "\\_"));
105        let sql = build_keyword_sql(wing, room);
106
107        let mut rows = match (wing, room) {
108            (Some(w), Some(r)) => {
109                conn.query(&sql, turso::params![like.as_str(), w, r, limit as i64])
110                    .await?
111            }
112            (Some(w), None) => {
113                conn.query(&sql, turso::params![like.as_str(), w, limit as i64])
114                    .await?
115            }
116            (None, Some(r)) => {
117                conn.query(&sql, turso::params![like.as_str(), r, limit as i64])
118                    .await?
119            }
120            (None, None) => {
121                conn.query(&sql, turso::params![like.as_str(), limit as i64])
122                    .await?
123            }
124        };
125
126        let mut drawers = Vec::new();
127        while let Some(row) = rows.next().await? {
128            drawers.push(row_to_drawer(&row));
129        }
130        Ok(drawers)
131    }
132
133    /// Keyword search with token fallback.
134    pub async fn keyword_fallback_search(
135        &self,
136        query: &str,
137        wing: Option<&str>,
138        room: Option<&str>,
139        limit: usize,
140    ) -> Result<Vec<Drawer>, SearchError> {
141        let exact = self.keyword_search(query, wing, room, limit).await?;
142        if !exact.is_empty() || limit == 0 {
143            return Ok(exact);
144        }
145
146        let mut merged = Vec::new();
147        let mut seen = HashSet::new();
148
149        for token in query
150            .split_whitespace()
151            .map(|part| part.trim_matches(|c: char| !c.is_alphanumeric() && c != '_' && c != '-'))
152            .filter(|part| part.len() >= 2)
153        {
154            for drawer in self.keyword_search(token, wing, room, limit).await? {
155                if seen.insert(drawer.id.clone()) {
156                    merged.push(drawer);
157                    if merged.len() >= limit {
158                        return Ok(merged);
159                    }
160                }
161            }
162        }
163
164        Ok(merged)
165    }
166
167    /// Duplicate check.
168    pub async fn find_duplicates(
169        &self,
170        content: &str,
171        threshold: f32,
172        limit: usize,
173    ) -> Result<Vec<SearchResult>, SearchError> {
174        let results = self.vector_search(content, None, None, limit).await?;
175        Ok(results
176            .into_iter()
177            .filter(|r| r.similarity >= threshold)
178            .collect())
179    }
180}
181
182// ── SQL builders ──────────────────────────────────────────────────────────────
183
184fn build_vector_sql(wing: Option<&str>, room: Option<&str>) -> String {
185    let filter = match (wing, room) {
186        (Some(_), Some(_)) => "AND wing = ?2 AND room = ?3",
187        (Some(_), None) => "AND wing = ?2",
188        (None, Some(_)) => "AND room = ?2",
189        (None, None) => "",
190    };
191    let limit_param = match (wing, room) {
192        (Some(_), Some(_)) => "?4",
193        (Some(_), None) | (None, Some(_)) => "?3",
194        (None, None) => "?2",
195    };
196    format!(
197        "SELECT id, wing, room, content, source_file, chunk_index, added_by, filed_at, \
198         vector_distance_cos(embedding, vector32(?1)) AS dist \
199         FROM drawers \
200         WHERE embedding IS NOT NULL {filter} \
201         ORDER BY dist LIMIT {limit_param}"
202    )
203}
204
205fn build_keyword_sql(wing: Option<&str>, room: Option<&str>) -> String {
206    let filter = match (wing, room) {
207        (Some(_), Some(_)) => "AND wing = ?2 AND room = ?3",
208        (Some(_), None) => "AND wing = ?2",
209        (None, Some(_)) => "AND room = ?2",
210        (None, None) => "",
211    };
212    let limit_param = match (wing, room) {
213        (Some(_), Some(_)) => "?4",
214        (Some(_), None) | (None, Some(_)) => "?3",
215        (None, None) => "?2",
216    };
217    format!(
218        "SELECT id, wing, room, content, source_file, chunk_index, added_by, filed_at \
219         FROM drawers \
220         WHERE content LIKE ?1 ESCAPE '\\' {filter} \
221         ORDER BY filed_at DESC LIMIT {limit_param}"
222    )
223}
224
225fn row_to_drawer(row: &turso::Row) -> Drawer {
226    Drawer {
227        id: val_str(row, 0),
228        wing: val_str(row, 1),
229        room: val_str(row, 2),
230        content: val_str(row, 3),
231        source_file: {
232            let s = val_str(row, 4);
233            if s.is_empty() { None } else { Some(s) }
234        },
235        chunk_index: row
236            .get_value(5)
237            .ok()
238            .and_then(|v| v.as_integer().copied())
239            .unwrap_or(0),
240        added_by: val_str(row, 6),
241        filed_at: val_str(row, 7),
242    }
243}
244
245fn val_str(row: &turso::Row, idx: usize) -> String {
246    match row.get_value(idx) {
247        Ok(turso::Value::Text(s)) => s,
248        Ok(turso::Value::Null) | Err(_) => String::new(),
249        Ok(v) => format!("{v:?}"),
250    }
251}