1use 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#[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 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 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 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 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 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 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
379fn 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}