Skip to main content

aimem_core/
search.rs

1//! Semantic + keyword search over the AiMem store.
2
3use std::collections::{HashMap, HashSet};
4use std::sync::Arc;
5
6use crate::{
7    db::{AimemDb, DbError},
8    embedder::Embedder,
9    types::{Drawer, HybridSearchResult, KeywordSearchResult, SearchResult},
10};
11use thiserror::Error;
12use tracing::warn;
13
14const HYBRID_RRF_K: f32 = 60.0;
15const HYBRID_VECTOR_WEIGHT: f32 = 1.0;
16const HYBRID_KEYWORD_WEIGHT: f32 = 1.0;
17const HYBRID_CANDIDATE_MULTIPLIER: usize = 4;
18const HYBRID_MIN_CANDIDATES: usize = 12;
19
20#[derive(Debug, Error)]
21pub enum SearchError {
22    #[error("db error: {0}")]
23    Db(#[from] DbError),
24    #[error("embed error: {0}")]
25    Embed(#[from] crate::embedder::EmbedError),
26    #[error("embedder returned {actual} vectors for {expected} inputs")]
27    EmbedBatchSizeMismatch { expected: usize, actual: usize },
28    #[error("semantic search requires an embedder")]
29    EmbedderUnavailable,
30    #[error("turso error: {0}")]
31    Turso(#[from] turso::Error),
32}
33
34/// Searcher — vector + keyword search over the AiMem store.
35#[derive(Clone)]
36pub struct Searcher {
37    db: AimemDb,
38    embedder: Option<Arc<dyn Embedder>>,
39}
40
41impl std::fmt::Debug for Searcher {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        f.debug_struct("Searcher")
44            .field("db", &self.db)
45            .field(
46                "embedder",
47                &self.embedder.as_ref().map(|_| "Some(Arc<dyn Embedder>)"),
48            )
49            .finish()
50    }
51}
52
53impl Searcher {
54    pub fn new(db: AimemDb, embedder: Arc<dyn Embedder>) -> Self {
55        Self {
56            db,
57            embedder: Some(embedder),
58        }
59    }
60
61    pub fn keyword_only(db: AimemDb) -> Self {
62        Self { db, embedder: None }
63    }
64
65    pub fn embedder(&self) -> Option<Arc<dyn Embedder>> {
66        self.embedder.clone()
67    }
68
69    /// Semantic vector search using Turso's `vector_distance_cos`.
70    pub async fn vector_search(
71        &self,
72        query: &str,
73        wing: Option<&str>,
74        room: Option<&str>,
75        limit: usize,
76    ) -> Result<Vec<SearchResult>, SearchError> {
77        if limit == 0 {
78            return Ok(Vec::new());
79        }
80
81        let embedder = self
82            .embedder
83            .as_ref()
84            .ok_or(SearchError::EmbedderUnavailable)?;
85        let qvec = embedder.embed_one(query).await?;
86        self.db
87            .assert_embedding_profile(qvec.len(), embedder.provider_name(), embedder.model_name())
88            .await?;
89        let qvec_json = crate::embedder::to_vector32_json(&qvec);
90
91        let conn = self.db.read_conn()?;
92        let sql = build_vector_sql(wing, room);
93        let mut rows = match (wing, room) {
94            (Some(w), Some(r)) => {
95                conn.query(&sql, turso::params![qvec_json.as_str(), w, r, limit as i64])
96                    .await?
97            }
98            (Some(w), None) => {
99                conn.query(&sql, turso::params![qvec_json.as_str(), w, limit as i64])
100                    .await?
101            }
102            (None, Some(r)) => {
103                conn.query(&sql, turso::params![qvec_json.as_str(), r, limit as i64])
104                    .await?
105            }
106            (None, None) => {
107                conn.query(&sql, turso::params![qvec_json.as_str(), limit as i64])
108                    .await?
109            }
110        };
111
112        let mut results = Vec::new();
113        while let Some(row) = rows.next().await? {
114            let drawer = row_to_drawer(&row)?;
115            let dist = match row.get_value(9)? {
116                turso::Value::Real(f) => f as f32,
117                turso::Value::Integer(i) => i as f32,
118                _ => 1.0,
119            };
120            let similarity = (1.0_f32 - dist).clamp(0.0, 1.0);
121            results.push(SearchResult { drawer, similarity });
122        }
123        Ok(results)
124    }
125
126    /// Keyword search with scores.
127    ///
128    /// Uses Turso FTS/Tantivy when available, and falls back to LIKE-based
129    /// scoring for resilience or substring-style queries.
130    pub async fn keyword_search_scored(
131        &self,
132        query: &str,
133        wing: Option<&str>,
134        room: Option<&str>,
135        limit: usize,
136    ) -> Result<Vec<KeywordSearchResult>, SearchError> {
137        if limit == 0 {
138            return Ok(Vec::new());
139        }
140
141        match self.keyword_fts_search(query, wing, room, limit).await {
142            Ok(results) if !results.is_empty() => Ok(results),
143            Ok(_) => self.keyword_like_search(query, wing, room, limit).await,
144            Err(err) if should_fallback_to_like_search(&err) => {
145                warn!(
146                    error = %err,
147                    query,
148                    "AiMem FTS keyword search unavailable; falling back to LIKE search"
149                );
150                self.keyword_like_search(query, wing, room, limit).await
151            }
152            Err(err) => Err(err),
153        }
154    }
155
156    /// Keyword search returning drawers only.
157    pub async fn keyword_search(
158        &self,
159        query: &str,
160        wing: Option<&str>,
161        room: Option<&str>,
162        limit: usize,
163    ) -> Result<Vec<Drawer>, SearchError> {
164        Ok(self
165            .keyword_search_scored(query, wing, room, limit)
166            .await?
167            .into_iter()
168            .map(|result| result.drawer)
169            .collect())
170    }
171
172    /// Keyword search with token fallback.
173    pub async fn keyword_fallback_search(
174        &self,
175        query: &str,
176        wing: Option<&str>,
177        room: Option<&str>,
178        limit: usize,
179    ) -> Result<Vec<Drawer>, SearchError> {
180        let exact = self.keyword_search(query, wing, room, limit).await?;
181        if !exact.is_empty() || limit == 0 {
182            return Ok(exact);
183        }
184
185        let mut merged = Vec::new();
186        let mut seen = HashSet::new();
187
188        for token in keyword_tokens(query) {
189            for drawer in self.keyword_search(&token, wing, room, limit).await? {
190                if seen.insert(drawer.id.clone()) {
191                    merged.push(drawer);
192                    if merged.len() >= limit {
193                        return Ok(merged);
194                    }
195                }
196            }
197        }
198
199        Ok(merged)
200    }
201
202    /// Hybrid keyword + vector search fused with reciprocal-rank fusion (RRF).
203    pub async fn hybrid_search(
204        &self,
205        query: &str,
206        wing: Option<&str>,
207        room: Option<&str>,
208        limit: usize,
209    ) -> Result<Vec<HybridSearchResult>, SearchError> {
210        if limit == 0 {
211            return Ok(Vec::new());
212        }
213
214        let candidate_limit = limit
215            .saturating_mul(HYBRID_CANDIDATE_MULTIPLIER)
216            .max(limit)
217            .max(HYBRID_MIN_CANDIDATES);
218
219        if self.embedder.is_none() {
220            return Ok(fuse_hybrid_results(
221                self.keyword_search_scored(query, wing, room, candidate_limit)
222                    .await?,
223                Vec::new(),
224                limit,
225            ));
226        }
227
228        let keyword_future = self.keyword_search_scored(query, wing, room, candidate_limit);
229        let vector_future = self.vector_search(query, wing, room, candidate_limit);
230        let (keyword_results, vector_results) = tokio::join!(keyword_future, vector_future);
231
232        match (keyword_results, vector_results) {
233            (Ok(keyword_hits), Ok(vector_hits)) => {
234                Ok(fuse_hybrid_results(keyword_hits, vector_hits, limit))
235            }
236            (Ok(keyword_hits), Err(err)) => {
237                warn!(
238                    error = %err,
239                    query,
240                    "AiMem vector branch failed during hybrid search; returning keyword-only results"
241                );
242                Ok(fuse_hybrid_results(keyword_hits, Vec::new(), limit))
243            }
244            (Err(err), Ok(vector_hits)) => {
245                warn!(
246                    error = %err,
247                    query,
248                    "AiMem keyword branch failed during hybrid search; returning vector-only results"
249                );
250                Ok(fuse_hybrid_results(Vec::new(), vector_hits, limit))
251            }
252            (Err(keyword_err), Err(vector_err)) => {
253                warn!(
254                    keyword_error = %keyword_err,
255                    vector_error = %vector_err,
256                    query,
257                    "AiMem hybrid search failed in both keyword and vector branches"
258                );
259                Err(keyword_err)
260            }
261        }
262    }
263
264    /// Duplicate check.
265    pub async fn find_duplicates(
266        &self,
267        content: &str,
268        threshold: f32,
269        limit: usize,
270    ) -> Result<Vec<SearchResult>, SearchError> {
271        let results = self.vector_search(content, None, None, limit).await?;
272        Ok(results
273            .into_iter()
274            .filter(|r| r.similarity >= threshold)
275            .collect())
276    }
277
278    async fn keyword_fts_search(
279        &self,
280        query: &str,
281        wing: Option<&str>,
282        room: Option<&str>,
283        limit: usize,
284    ) -> Result<Vec<KeywordSearchResult>, SearchError> {
285        let conn = self.db.read_conn()?;
286        let sql = build_keyword_fts_sql(wing, room);
287
288        let mut rows = match (wing, room) {
289            (Some(w), Some(r)) => {
290                conn.query(&sql, turso::params![query, w, r, limit as i64])
291                    .await?
292            }
293            (Some(w), None) => {
294                conn.query(&sql, turso::params![query, w, limit as i64])
295                    .await?
296            }
297            (None, Some(r)) => {
298                conn.query(&sql, turso::params![query, r, limit as i64])
299                    .await?
300            }
301            (None, None) => {
302                conn.query(&sql, turso::params![query, limit as i64])
303                    .await?
304            }
305        };
306
307        let mut results = Vec::new();
308        while let Some(row) = rows.next().await? {
309            let drawer = row_to_drawer(&row)?;
310            let score = match row.get_value(9)? {
311                turso::Value::Real(f) => f as f32,
312                turso::Value::Integer(i) => i as f32,
313                _ => 0.0,
314            };
315            results.push(KeywordSearchResult { drawer, score });
316        }
317        Ok(results)
318    }
319
320    async fn keyword_like_search(
321        &self,
322        query: &str,
323        wing: Option<&str>,
324        room: Option<&str>,
325        limit: usize,
326    ) -> Result<Vec<KeywordSearchResult>, SearchError> {
327        let conn = self.db.read_conn()?;
328        let like = format!("%{}%", query.replace('%', "\\%").replace('_', "\\_"));
329        let sql = build_keyword_like_sql(wing, room);
330
331        let mut rows = match (wing, room) {
332            (Some(w), Some(r)) => {
333                conn.query(&sql, turso::params![like.as_str(), w, r, limit as i64])
334                    .await?
335            }
336            (Some(w), None) => {
337                conn.query(&sql, turso::params![like.as_str(), w, limit as i64])
338                    .await?
339            }
340            (None, Some(r)) => {
341                conn.query(&sql, turso::params![like.as_str(), r, limit as i64])
342                    .await?
343            }
344            (None, None) => {
345                conn.query(&sql, turso::params![like.as_str(), limit as i64])
346                    .await?
347            }
348        };
349
350        let mut results = Vec::new();
351        while let Some(row) = rows.next().await? {
352            let drawer = row_to_drawer(&row)?;
353            results.push(KeywordSearchResult {
354                score: like_match_score(&drawer, query),
355                drawer,
356            });
357        }
358
359        results.sort_by(|left, right| {
360            right
361                .score
362                .partial_cmp(&left.score)
363                .unwrap_or(std::cmp::Ordering::Equal)
364                .then_with(|| right.drawer.filed_at.cmp(&left.drawer.filed_at))
365        });
366        results.truncate(limit);
367        Ok(results)
368    }
369}
370
371#[derive(Debug)]
372struct HybridAccumulator {
373    drawer: Drawer,
374    raw_rrf: f32,
375    semantic_similarity: Option<f32>,
376    keyword_score: Option<f32>,
377}
378
379// ── SQL builders ──────────────────────────────────────────────────────────────
380
381fn build_vector_sql(wing: Option<&str>, room: Option<&str>) -> String {
382    let filter = match (wing, room) {
383        (Some(_), Some(_)) => "AND wing = ?2 AND room = ?3",
384        (Some(_), None) => "AND wing = ?2",
385        (None, Some(_)) => "AND room = ?2",
386        (None, None) => "",
387    };
388    let limit_param = match (wing, room) {
389        (Some(_), Some(_)) => "?4",
390        (Some(_), None) | (None, Some(_)) => "?3",
391        (None, None) => "?2",
392    };
393    format!(
394        "SELECT id, wing, room, content, parts, source_file, chunk_index, added_by, filed_at, \
395         vector_distance_cos(embedding, vector32(?1)) AS dist \
396         FROM drawers \
397         WHERE embedding IS NOT NULL {filter} \
398         ORDER BY dist LIMIT {limit_param}"
399    )
400}
401
402fn build_keyword_fts_sql(wing: Option<&str>, room: Option<&str>) -> String {
403    let filter = match (wing, room) {
404        (Some(_), Some(_)) => "AND d.wing = ?2 AND d.room = ?3",
405        (Some(_), None) => "AND d.wing = ?2",
406        (None, Some(_)) => "AND d.room = ?2",
407        (None, None) => "",
408    };
409    let limit_param = match (wing, room) {
410        (Some(_), Some(_)) => "?4",
411        (Some(_), None) | (None, Some(_)) => "?3",
412        (None, None) => "?2",
413    };
414    format!(
415        "SELECT d.id, d.wing, d.room, d.content, d.parts, d.source_file, d.chunk_index, d.added_by, d.filed_at, \
416         fts_score(search_text, ?1) AS score \
417         FROM drawers_fts \
418         JOIN drawers d ON d.id = drawers_fts.drawer_id \
419         WHERE fts_match(search_text, ?1) {filter} \
420         ORDER BY score DESC LIMIT {limit_param}"
421    )
422}
423
424fn build_keyword_like_sql(wing: Option<&str>, room: Option<&str>) -> String {
425    let filter = match (wing, room) {
426        (Some(_), Some(_)) => "AND wing = ?2 AND room = ?3",
427        (Some(_), None) => "AND wing = ?2",
428        (None, Some(_)) => "AND room = ?2",
429        (None, None) => "",
430    };
431    let limit_param = match (wing, room) {
432        (Some(_), Some(_)) => "?4",
433        (Some(_), None) | (None, Some(_)) => "?3",
434        (None, None) => "?2",
435    };
436    format!(
437        "SELECT id, wing, room, content, parts, source_file, chunk_index, added_by, filed_at \
438         FROM drawers \
439         WHERE content LIKE ?1 ESCAPE '\\' {filter} \
440         ORDER BY filed_at DESC LIMIT {limit_param}"
441    )
442}
443
444fn fuse_hybrid_results(
445    keyword_hits: Vec<KeywordSearchResult>,
446    vector_hits: Vec<SearchResult>,
447    limit: usize,
448) -> Vec<HybridSearchResult> {
449    if limit == 0 {
450        return Vec::new();
451    }
452
453    let mut fused = HashMap::<String, HybridAccumulator>::new();
454
455    for (rank, result) in vector_hits.into_iter().enumerate() {
456        let id = result.drawer.id.clone();
457        let similarity = result.similarity;
458        let entry = fused.entry(id).or_insert_with(|| HybridAccumulator {
459            drawer: result.drawer,
460            raw_rrf: 0.0,
461            semantic_similarity: None,
462            keyword_score: None,
463        });
464        entry.raw_rrf += rrf_component(rank, HYBRID_VECTOR_WEIGHT);
465        entry.semantic_similarity = Some(
466            entry
467                .semantic_similarity
468                .map(|current| current.max(similarity))
469                .unwrap_or(similarity),
470        );
471    }
472
473    for (rank, result) in keyword_hits.into_iter().enumerate() {
474        let id = result.drawer.id.clone();
475        let score = result.score;
476        let entry = fused.entry(id).or_insert_with(|| HybridAccumulator {
477            drawer: result.drawer,
478            raw_rrf: 0.0,
479            semantic_similarity: None,
480            keyword_score: None,
481        });
482        entry.raw_rrf += rrf_component(rank, HYBRID_KEYWORD_WEIGHT);
483        entry.keyword_score = Some(
484            entry
485                .keyword_score
486                .map(|current| current.max(score))
487                .unwrap_or(score),
488        );
489    }
490
491    let total_weight = (if fused
492        .values()
493        .any(|entry| entry.semantic_similarity.is_some())
494    {
495        HYBRID_VECTOR_WEIGHT
496    } else {
497        0.0
498    }) + (if fused.values().any(|entry| entry.keyword_score.is_some()) {
499        HYBRID_KEYWORD_WEIGHT
500    } else {
501        0.0
502    });
503
504    if total_weight <= 0.0 {
505        return Vec::new();
506    }
507
508    let scale = (HYBRID_RRF_K + 1.0) / total_weight;
509    let mut results = fused
510        .into_values()
511        .map(|entry| HybridSearchResult {
512            drawer: entry.drawer,
513            score: (entry.raw_rrf * scale).clamp(0.0, 1.0),
514            semantic_similarity: entry.semantic_similarity,
515            keyword_score: entry.keyword_score,
516        })
517        .collect::<Vec<_>>();
518
519    results.sort_by(|left, right| {
520        right
521            .score
522            .partial_cmp(&left.score)
523            .unwrap_or(std::cmp::Ordering::Equal)
524            .then_with(|| {
525                right
526                    .keyword_score
527                    .unwrap_or_default()
528                    .partial_cmp(&left.keyword_score.unwrap_or_default())
529                    .unwrap_or(std::cmp::Ordering::Equal)
530            })
531            .then_with(|| {
532                right
533                    .semantic_similarity
534                    .unwrap_or_default()
535                    .partial_cmp(&left.semantic_similarity.unwrap_or_default())
536                    .unwrap_or(std::cmp::Ordering::Equal)
537            })
538            .then_with(|| right.drawer.filed_at.cmp(&left.drawer.filed_at))
539    });
540    results.truncate(limit);
541    results
542}
543
544fn rrf_component(rank: usize, weight: f32) -> f32 {
545    weight / (HYBRID_RRF_K + rank as f32 + 1.0)
546}
547
548fn keyword_tokens(query: &str) -> Vec<String> {
549    let mut tokens = query
550        .split_whitespace()
551        .map(|part| {
552            part.trim_matches(|c: char| !c.is_alphanumeric() && c != '_' && c != '-')
553                .to_ascii_lowercase()
554        })
555        .filter(|part| part.len() >= 2)
556        .collect::<Vec<_>>();
557    if tokens.is_empty() {
558        let compact = query.trim().to_ascii_lowercase();
559        if !compact.is_empty() {
560            tokens.push(compact);
561        }
562    }
563    tokens.sort();
564    tokens.dedup();
565    tokens
566}
567
568fn like_match_score(drawer: &Drawer, query: &str) -> f32 {
569    let haystack = format!(
570        "{}\n{}\n{}\n{}",
571        drawer.content,
572        drawer.wing,
573        drawer.room,
574        drawer.source_file.as_deref().unwrap_or("")
575    )
576    .to_ascii_lowercase();
577    let normalized_query = query.trim().to_ascii_lowercase();
578    let tokens = keyword_tokens(query);
579
580    let mut score = 0.0;
581    if !normalized_query.is_empty() && haystack.contains(&normalized_query) {
582        score += 2.0;
583    }
584
585    if !tokens.is_empty() {
586        let hits = tokens
587            .iter()
588            .filter(|token| haystack.contains(token.as_str()))
589            .count() as f32;
590        score += hits / tokens.len() as f32;
591    }
592
593    score
594}
595
596fn should_fallback_to_like_search(err: &SearchError) -> bool {
597    let SearchError::Turso(inner) = err else {
598        return false;
599    };
600    let message = inner.to_string().to_ascii_lowercase();
601    message.contains("fts")
602        || message.contains("syntax error")
603        || message.contains("no such function")
604        || message.contains("no such table")
605}
606
607fn row_to_drawer(row: &turso::Row) -> Result<Drawer, DbError> {
608    Ok(Drawer {
609        id: val_str(row, 0),
610        wing: val_str(row, 1),
611        room: val_str(row, 2),
612        content: val_str(row, 3),
613        parts: parse_parts(&val_str(row, 4))?,
614        source_file: {
615            let s = val_str(row, 5);
616            if s.is_empty() { None } else { Some(s) }
617        },
618        chunk_index: row
619            .get_value(6)
620            .ok()
621            .and_then(|v| v.as_integer().copied())
622            .unwrap_or(0),
623        added_by: val_str(row, 7),
624        filed_at: val_str(row, 8),
625    })
626}
627
628fn parse_parts(raw: &str) -> Result<Vec<crate::types::ContentPart>, DbError> {
629    if raw.is_empty() || raw == "[]" {
630        Ok(Vec::new())
631    } else {
632        Ok(serde_json::from_str(raw)?)
633    }
634}
635
636fn val_str(row: &turso::Row, idx: usize) -> String {
637    match row.get_value(idx) {
638        Ok(turso::Value::Text(s)) => s,
639        Ok(turso::Value::Null) | Err(_) => String::new(),
640        Ok(v) => format!("{v:?}"),
641    }
642}
643
644#[cfg(test)]
645mod tests {
646    use super::*;
647    use crate::types::ContentPart;
648    use async_trait::async_trait;
649    use tempfile::tempdir;
650
651    #[derive(Debug)]
652    struct TestEmbedder;
653
654    #[async_trait]
655    impl Embedder for TestEmbedder {
656        async fn embed(
657            &self,
658            inputs: &[Vec<ContentPart>],
659        ) -> Result<Vec<Vec<f32>>, crate::embedder::EmbedError> {
660            Ok(inputs
661                .iter()
662                .map(|parts| {
663                    let text = parts
664                        .iter()
665                        .filter_map(ContentPart::as_text)
666                        .collect::<Vec<_>>()
667                        .join(" ");
668                    embed_text(&text)
669                })
670                .collect())
671        }
672
673        fn dimension(&self) -> usize {
674            2
675        }
676
677        fn provider_name(&self) -> &str {
678            "test"
679        }
680
681        fn model_name(&self) -> &str {
682            "test-2d"
683        }
684    }
685
686    fn embed_text(text: &str) -> Vec<f32> {
687        let lower = text.to_ascii_lowercase();
688        if lower.contains("semantic-only") {
689            vec![1.0, 0.0]
690        } else if lower.contains("alpha-42") || lower.contains("timeout") {
691            vec![0.85, 0.15]
692        } else {
693            vec![0.0, 1.0]
694        }
695    }
696
697    #[tokio::test]
698    async fn keyword_search_scored_backfills_missing_fts_rows_on_reopen() -> anyhow::Result<()> {
699        let dir = tempdir()?;
700        let db_path = dir.path().join("aimem.db");
701
702        let db = AimemDb::open(&db_path).await?;
703        let drawer = Drawer::new(
704            "drawer_alpha",
705            "attachments",
706            "subject-1",
707            "alpha-42 timeout note for worker restart",
708            "tester",
709        );
710        db.insert_drawer(&drawer, None).await?;
711
712        let conn = db.conn()?;
713        conn.execute(
714            "DELETE FROM drawers_fts WHERE drawer_id = ?1",
715            [drawer.id.as_str()],
716        )
717        .await?;
718        drop(db);
719
720        let db = AimemDb::open(&db_path).await?;
721        let searcher = Searcher::keyword_only(db);
722        let hits = searcher
723            .keyword_search_scored("alpha-42", Some("attachments"), Some("subject-1"), 5)
724            .await?;
725
726        assert_eq!(
727            hits.first().map(|hit| hit.drawer.id.as_str()),
728            Some("drawer_alpha")
729        );
730        Ok(())
731    }
732
733    #[tokio::test]
734    async fn hybrid_search_rrf_promotes_exact_keyword_hit_over_semantic_only_hit()
735    -> anyhow::Result<()> {
736        let db = AimemDb::memory().await?;
737        let embedder = Arc::new(TestEmbedder);
738        let searcher = Searcher::new(db.clone(), embedder);
739
740        let semantic_only = Drawer::new(
741            "drawer_semantic",
742            "attachments",
743            "subject-1",
744            "semantic-only memory about retries and restarts",
745            "tester",
746        );
747        db.insert_drawer_with_profile(&semantic_only, Some(&[1.0, 0.0]), "test", "test-2d")
748            .await?;
749
750        let keyword_hit = Drawer::new(
751            "drawer_keyword",
752            "attachments",
753            "subject-1",
754            "alpha-42 timeout happened after worker restart",
755            "tester",
756        );
757        db.insert_drawer_with_profile(&keyword_hit, Some(&[0.85, 0.15]), "test", "test-2d")
758            .await?;
759
760        let hits = searcher
761            .hybrid_search(
762                "alpha-42 timeout",
763                Some("attachments"),
764                Some("subject-1"),
765                5,
766            )
767            .await?;
768
769        assert_eq!(
770            hits.first().map(|hit| hit.drawer.id.as_str()),
771            Some("drawer_keyword")
772        );
773        Ok(())
774    }
775
776    #[tokio::test]
777    async fn delete_drawer_removes_keyword_hits_from_fts_sidecar() -> anyhow::Result<()> {
778        let db = AimemDb::memory().await?;
779        let drawer = Drawer::new(
780            "drawer_delete",
781            "attachments",
782            "subject-1",
783            "remove alpha-42 from keyword index",
784            "tester",
785        );
786        db.insert_drawer(&drawer, None).await?;
787
788        let searcher = Searcher::keyword_only(db.clone());
789        assert!(
790            !searcher
791                .keyword_search_scored("alpha-42", Some("attachments"), Some("subject-1"), 5)
792                .await?
793                .is_empty()
794        );
795
796        assert!(db.delete_drawer("drawer_delete").await?);
797        assert!(
798            searcher
799                .keyword_search_scored("alpha-42", Some("attachments"), Some("subject-1"), 5)
800                .await?
801                .is_empty()
802        );
803        Ok(())
804    }
805}