1use std::sync::Arc;
2use std::time::Duration;
3
4use async_trait::async_trait;
5use tantivy::collector::TopDocs;
6use tantivy::query::{BooleanQuery, Occur, QueryParser, TermQuery};
7use tantivy::schema::{IndexRecordOption, OwnedValue};
8use tantivy::{TantivyDocument, Term};
9
10use crate::engine::MATCH_ALL_QUERY;
11use crate::entry::ScoredEntry;
12use crate::storage::fts_index::FtsIndex;
13use crate::storage::turso_storage::turso_row_to_entry;
14use crate::traits::Searcher;
15
16pub struct TursoSearcher {
21 db: Arc<turso::Database>,
22 fts: Arc<FtsIndex>,
23}
24
25impl TursoSearcher {
26 #[must_use]
28 pub(crate) fn new(db: Arc<turso::Database>, fts: Arc<FtsIndex>) -> Self {
29 Self { db, fts }
30 }
31}
32
33#[async_trait]
34impl Searcher for TursoSearcher {
35 async fn search_semantic(
36 &self,
37 embedding: &[f32],
38 scope: Option<&str>,
39 limit: usize,
40 ) -> crate::Result<Vec<crate::entry::ScoredEntry>> {
41 self.search_semantic_impl(embedding, scope, limit).await
42 }
43
44 async fn search(
45 &self,
46 query: &str,
47 scope: Option<&str>,
48 limit: usize,
49 ) -> crate::Result<Vec<ScoredEntry>> {
50 if query == MATCH_ALL_QUERY {
51 return self.search_all(scope, limit).await;
52 }
53
54 let searcher = self.fts.reader.searcher();
56 let (content_query, _errors) =
58 QueryParser::for_index(searcher.index(), vec![self.fts.content_field])
59 .parse_query_lenient(query);
60
61 let tantivy_query: Box<dyn tantivy::query::Query> = if let Some(s) = scope {
64 let scope_term = Term::from_field_text(self.fts.scope_field, s);
65 Box::new(BooleanQuery::new(vec![
66 (Occur::Must, content_query),
67 (
68 Occur::Must,
69 Box::new(TermQuery::new(scope_term, IndexRecordOption::Basic)),
70 ),
71 ]))
72 } else {
73 content_query
74 };
75
76 let top_docs = searcher
77 .search(&tantivy_query, &TopDocs::with_limit(limit).order_by_score())
78 .map_err(|e| crate::Error::Migration(e.to_string()))?;
79
80 if top_docs.is_empty() {
81 return Ok(Vec::new());
82 }
83
84 let mut scored_ids: Vec<(f32, String)> = Vec::with_capacity(top_docs.len());
86 for (score, doc_address) in top_docs {
87 let doc: TantivyDocument = searcher
88 .doc(doc_address)
89 .map_err(|e| crate::Error::Migration(e.to_string()))?;
90 if let Some(OwnedValue::Str(id_str)) =
91 doc.get_first(self.fts.id_field).map(OwnedValue::from)
92 {
93 scored_ids.push((score, id_str));
94 }
95 }
96
97 if scored_ids.is_empty() {
98 return Ok(Vec::new());
99 }
100
101 let id_list: String = scored_ids
104 .iter()
105 .map(|(_, id)| format!("'{}'", id.replace('\'', "''")))
106 .collect::<Vec<_>>()
107 .join(", ");
108 let sql = format!(
109 "SELECT id, content, timestamp, kind, scope, session_id, token_count, metadata \
110 FROM entries WHERE id IN ({id_list})"
111 );
112
113 let conn = self.db.connect()?;
114 conn.busy_timeout(Duration::from_secs(5))?;
115
116 let mut rows = conn.query(&sql, ()).await?;
117 let mut id_to_entry = std::collections::HashMap::new();
118 while let Some(row) = rows.next().await? {
119 let entry = turso_row_to_entry(&row)?;
120 id_to_entry.insert(entry.id.clone(), entry);
121 }
122
123 let mut result: Vec<ScoredEntry> = scored_ids
125 .into_iter()
126 .filter_map(|(score, id)| {
127 let entry = id_to_entry.remove(&id)?;
128 Some(ScoredEntry {
129 score: f64::from(score),
130 entry,
131 })
132 })
133 .collect();
134
135 result.sort_by(|a, b| {
137 b.score
138 .partial_cmp(&a.score)
139 .unwrap_or(std::cmp::Ordering::Equal)
140 });
141
142 Ok(result)
143 }
144}
145
146impl TursoSearcher {
147 async fn search_semantic_impl(
148 &self,
149 embedding: &[f32],
150 scope: Option<&str>,
151 limit: usize,
152 ) -> crate::Result<Vec<crate::entry::ScoredEntry>> {
153 let vec_str = format!(
154 "[{}]",
155 embedding
156 .iter()
157 .map(ToString::to_string)
158 .collect::<Vec<_>>()
159 .join(",")
160 );
161 let scope_owned = scope.map(str::to_owned);
162 let conn = self.db.connect()?;
163 conn.busy_timeout(Duration::from_secs(5))?;
164
165 let mut rows = conn
166 .query(
167 "SELECT id, content, timestamp, kind, scope, session_id, token_count, metadata, \
168 vector_distance_cos(embedding, vector32(?1)) as dist \
169 FROM entries \
170 WHERE embedding IS NOT NULL AND (?2 IS NULL OR scope = ?2) \
171 ORDER BY dist ASC \
172 LIMIT ?3",
173 (
174 vec_str,
175 scope_owned,
176 i64::try_from(limit).unwrap_or(i64::MAX),
177 ),
178 )
179 .await?;
180
181 let mut result = Vec::new();
182 while let Some(row) = rows.next().await? {
183 let entry = turso_row_to_entry(&row)?;
184 let dist = match row.get_value(8)? {
185 turso::Value::Real(f) => f,
186 #[allow(
187 clippy::cast_precision_loss,
188 reason = "integer distances are 0 or 1; lossless"
189 )]
190 turso::Value::Integer(i) => i as f64,
191 _ => 1.0,
192 };
193 let similarity = (1.0 - dist).max(0.0);
194 result.push(crate::entry::ScoredEntry {
195 entry,
196 score: similarity,
197 });
198 }
199
200 tracing::debug!(
201 count = %result.len(),
202 "turso semantic search complete"
203 );
204 Ok(result)
205 }
206
207 async fn search_all(
208 &self,
209 scope: Option<&str>,
210 limit: usize,
211 ) -> crate::Result<Vec<ScoredEntry>> {
212 let conn = self.db.connect()?;
213 conn.busy_timeout(Duration::from_secs(5))?;
214 let scope_owned = scope.map(str::to_owned);
215
216 let mut rows = conn
217 .query(
218 "SELECT id, content, timestamp, kind, scope, session_id, token_count, metadata \
219 FROM entries \
220 WHERE (?1 IS NULL OR scope = ?1) \
221 ORDER BY timestamp DESC \
222 LIMIT ?2",
223 (scope_owned, i64::try_from(limit).unwrap_or(i64::MAX)),
224 )
225 .await?;
226
227 let mut result = Vec::new();
228 while let Some(row) = rows.next().await? {
229 let entry = turso_row_to_entry(&row)?;
230 result.push(ScoredEntry { score: 1.0, entry });
231 }
232
233 Ok(result)
234 }
235}