Skip to main content

context_forge/storage/
turso_searcher.rs

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
16/// Turso-backed searcher using a standalone tantivy index for real BM25 scoring.
17///
18/// On FTS queries: tantivy returns scored IDs → turso fetches full rows by ID.
19/// On match-all: SQL ORDER BY timestamp DESC (no scoring needed).
20pub struct TursoSearcher {
21    db: Arc<turso::Database>,
22    fts: Arc<FtsIndex>,
23}
24
25impl TursoSearcher {
26    /// Create a new searcher sharing the given turso database and tantivy index.
27    #[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        // --- tantivy BM25 search ---
55        let searcher = self.fts.reader.searcher();
56        // parse_query_lenient: OR-joins terms by default and never errors on bad syntax.
57        let (content_query, _errors) =
58            QueryParser::for_index(searcher.index(), vec![self.fts.content_field])
59                .parse_query_lenient(query);
60
61        // When a scope is requested, combine the content query with an exact-match
62        // scope term so TopDocs is already scoped — no overfetch or Rust-side filter needed.
63        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        // Collect (score, id) pairs from the tantivy index.
85        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        // Fetch full rows from turso by ID.
102        // IDs are UUID v7 strings generated internally — safe to inline directly.
103        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        // Zip scores back in tantivy's ranked order.
124        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        // tantivy already sorted by score DESC; preserve that order.
136        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}