1use 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#[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 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 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 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 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 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
182fn 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}