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("semantic search requires an embedder")]
20 EmbedderUnavailable,
21 #[error("turso error: {0}")]
22 Turso(#[from] turso::Error),
23}
24
25#[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 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 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 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 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 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
202fn 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}