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))
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)) = doc.get_first(self.fts.id_field) {
91 scored_ids.push((score, id_str.to_owned()));
92 }
93 }
94
95 if scored_ids.is_empty() {
96 return Ok(Vec::new());
97 }
98
99 let id_list: String = scored_ids
102 .iter()
103 .map(|(_, id)| format!("'{}'", id.replace('\'', "''")))
104 .collect::<Vec<_>>()
105 .join(", ");
106 let sql = format!(
107 "SELECT id, content, timestamp, kind, scope, session_id, token_count, metadata \
108 FROM entries WHERE id IN ({id_list})"
109 );
110
111 let conn = self.db.connect()?;
112 conn.busy_timeout(Duration::from_secs(5))?;
113
114 let mut rows = conn.query(&sql, ()).await?;
115 let mut id_to_entry = std::collections::HashMap::new();
116 while let Some(row) = rows.next().await? {
117 let entry = turso_row_to_entry(&row)?;
118 id_to_entry.insert(entry.id.clone(), entry);
119 }
120
121 let mut result: Vec<ScoredEntry> = scored_ids
123 .into_iter()
124 .filter_map(|(score, id)| {
125 let entry = id_to_entry.remove(&id)?;
126 Some(ScoredEntry {
127 score: f64::from(score),
128 entry,
129 })
130 })
131 .collect();
132
133 result.sort_by(|a, b| {
135 b.score
136 .partial_cmp(&a.score)
137 .unwrap_or(std::cmp::Ordering::Equal)
138 });
139
140 Ok(result)
141 }
142}
143
144impl TursoSearcher {
145 async fn search_semantic_impl(
146 &self,
147 embedding: &[f32],
148 scope: Option<&str>,
149 limit: usize,
150 ) -> crate::Result<Vec<crate::entry::ScoredEntry>> {
151 let vec_str = format!(
152 "[{}]",
153 embedding
154 .iter()
155 .map(ToString::to_string)
156 .collect::<Vec<_>>()
157 .join(",")
158 );
159 let scope_owned = scope.map(str::to_owned);
160 let conn = self.db.connect()?;
161 conn.busy_timeout(Duration::from_secs(5))?;
162
163 let mut rows = conn
164 .query(
165 "SELECT id, content, timestamp, kind, scope, session_id, token_count, metadata, \
166 vector_distance_cos(embedding, vector32(?1)) as dist \
167 FROM entries \
168 WHERE embedding IS NOT NULL AND (?2 IS NULL OR scope = ?2) \
169 ORDER BY dist ASC \
170 LIMIT ?3",
171 (
172 vec_str,
173 scope_owned,
174 i64::try_from(limit).unwrap_or(i64::MAX),
175 ),
176 )
177 .await?;
178
179 let mut result = Vec::new();
180 while let Some(row) = rows.next().await? {
181 let entry = turso_row_to_entry(&row)?;
182 let dist = match row.get_value(8)? {
183 turso::Value::Real(f) => f,
184 #[allow(
185 clippy::cast_precision_loss,
186 reason = "integer distances are 0 or 1; lossless"
187 )]
188 turso::Value::Integer(i) => i as f64,
189 _ => 1.0,
190 };
191 let similarity = (1.0 - dist).max(0.0);
192 result.push(crate::entry::ScoredEntry {
193 entry,
194 score: similarity,
195 });
196 }
197
198 tracing::debug!(
199 count = %result.len(),
200 "turso semantic search complete"
201 );
202 Ok(result)
203 }
204
205 async fn search_all(
206 &self,
207 scope: Option<&str>,
208 limit: usize,
209 ) -> crate::Result<Vec<ScoredEntry>> {
210 let conn = self.db.connect()?;
211 conn.busy_timeout(Duration::from_secs(5))?;
212 let scope_owned = scope.map(str::to_owned);
213
214 let mut rows = conn
215 .query(
216 "SELECT id, content, timestamp, kind, scope, session_id, token_count, metadata \
217 FROM entries \
218 WHERE (?1 IS NULL OR scope = ?1) \
219 ORDER BY timestamp DESC \
220 LIMIT ?2",
221 (scope_owned, i64::try_from(limit).unwrap_or(i64::MAX)),
222 )
223 .await?;
224
225 let mut result = Vec::new();
226 while let Some(row) = rows.next().await? {
227 let entry = turso_row_to_entry(&row)?;
228 result.push(ScoredEntry { score: 1.0, entry });
229 }
230
231 Ok(result)
232 }
233}