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