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))
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)) = 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        // Fetch full rows from turso by ID.
100        // IDs are UUID v7 strings generated internally — safe to inline directly.
101        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        // Zip scores back in tantivy's ranked order.
122        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        // tantivy already sorted by score DESC; preserve that order.
134        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}