Skip to main content

aimem_core/
search.rs

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