1use 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#[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 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.conn()?;
81
82 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 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.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 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 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
204fn 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}