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;
19const KEYWORD_FALLBACK_CANDIDATE_MULTIPLIER: usize = 64;
20const KEYWORD_FALLBACK_MIN_CANDIDATES: usize = 256;
21const KEYWORD_FALLBACK_MAX_CANDIDATES: usize = 4096;
22
23#[derive(Debug, Error)]
24pub enum SearchError {
25 #[error("db error: {0}")]
26 Db(#[from] DbError),
27 #[error("embed error: {0}")]
28 Embed(#[from] crate::embedder::EmbedError),
29 #[error("embedder returned {actual} vectors for {expected} inputs")]
30 EmbedBatchSizeMismatch { expected: usize, actual: usize },
31 #[error("semantic search requires an embedder")]
32 EmbedderUnavailable,
33 #[error("turso error: {0}")]
34 Turso(#[from] turso::Error),
35}
36
37#[derive(Clone)]
39pub struct Searcher {
40 db: AimemDb,
41 embedder: Option<Arc<dyn Embedder>>,
42}
43
44impl std::fmt::Debug for Searcher {
45 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46 f.debug_struct("Searcher")
47 .field("db", &self.db)
48 .field(
49 "embedder",
50 &self.embedder.as_ref().map(|_| "Some(Arc<dyn Embedder>)"),
51 )
52 .finish()
53 }
54}
55
56impl Searcher {
57 pub fn new(db: AimemDb, embedder: Arc<dyn Embedder>) -> Self {
58 Self {
59 db,
60 embedder: Some(embedder),
61 }
62 }
63
64 pub fn keyword_only(db: AimemDb) -> Self {
65 Self { db, embedder: None }
66 }
67
68 pub fn embedder(&self) -> Option<Arc<dyn Embedder>> {
69 self.embedder.clone()
70 }
71
72 pub async fn vector_search(
74 &self,
75 query: &str,
76 wing: Option<&str>,
77 room: Option<&str>,
78 limit: usize,
79 ) -> Result<Vec<SearchResult>, SearchError> {
80 if limit == 0 {
81 return Ok(Vec::new());
82 }
83
84 let embedder = self
85 .embedder
86 .as_ref()
87 .ok_or(SearchError::EmbedderUnavailable)?;
88 let qvec = embedder.embed_one(query).await?;
89 self.db
90 .assert_embedding_profile(qvec.len(), embedder.provider_name(), embedder.model_name())
91 .await?;
92 let qvec_json = crate::embedder::to_vector32_json(&qvec);
93
94 let conn = self.db.read_conn()?;
95 let sql = build_vector_sql(wing, room);
96 let mut rows = match (wing, room) {
97 (Some(w), Some(r)) => {
98 conn.query(&sql, turso::params![qvec_json.as_str(), w, r, limit as i64])
99 .await?
100 }
101 (Some(w), None) => {
102 conn.query(&sql, turso::params![qvec_json.as_str(), w, limit as i64])
103 .await?
104 }
105 (None, Some(r)) => {
106 conn.query(&sql, turso::params![qvec_json.as_str(), r, limit as i64])
107 .await?
108 }
109 (None, None) => {
110 conn.query(&sql, turso::params![qvec_json.as_str(), limit as i64])
111 .await?
112 }
113 };
114
115 let mut results = Vec::new();
116 while let Some(row) = rows.next().await? {
117 let drawer = row_to_drawer(&row)?;
118 let dist = match row.get_value(9)? {
119 turso::Value::Real(f) => f as f32,
120 turso::Value::Integer(i) => i as f32,
121 _ => 1.0,
122 };
123 let similarity = (1.0_f32 - dist).clamp(0.0, 1.0);
124 results.push(SearchResult { drawer, similarity });
125 }
126 Ok(results)
127 }
128
129 pub async fn keyword_search_scored(
134 &self,
135 query: &str,
136 wing: Option<&str>,
137 room: Option<&str>,
138 limit: usize,
139 ) -> Result<Vec<KeywordSearchResult>, SearchError> {
140 if limit == 0 {
141 return Ok(Vec::new());
142 }
143
144 match self.keyword_fts_search(query, wing, room, limit).await {
145 Ok(results) if !results.is_empty() => Ok(results),
146 Ok(_) => self.keyword_like_search(query, wing, room, limit).await,
147 Err(err) if should_fallback_to_like_search(&err) => {
148 warn!(
149 error = %err,
150 query,
151 "AiMem FTS keyword search unavailable; falling back to LIKE search"
152 );
153 self.keyword_like_search(query, wing, room, limit).await
154 }
155 Err(err) => Err(err),
156 }
157 }
158
159 pub async fn keyword_search(
161 &self,
162 query: &str,
163 wing: Option<&str>,
164 room: Option<&str>,
165 limit: usize,
166 ) -> Result<Vec<Drawer>, SearchError> {
167 Ok(self
168 .keyword_search_scored(query, wing, room, limit)
169 .await?
170 .into_iter()
171 .map(|result| result.drawer)
172 .collect())
173 }
174
175 pub async fn keyword_fallback_search(
177 &self,
178 query: &str,
179 wing: Option<&str>,
180 room: Option<&str>,
181 limit: usize,
182 ) -> Result<Vec<Drawer>, SearchError> {
183 let exact = self.keyword_search(query, wing, room, limit).await?;
184 if !exact.is_empty() || limit == 0 {
185 return Ok(exact);
186 }
187
188 let mut merged = Vec::new();
189 let mut seen = HashSet::new();
190
191 for token in keyword_tokens(query) {
192 for drawer in self.keyword_search(&token, wing, room, limit).await? {
193 if seen.insert(drawer.id.clone()) {
194 merged.push(drawer);
195 if merged.len() >= limit {
196 return Ok(merged);
197 }
198 }
199 }
200 }
201
202 Ok(merged)
203 }
204
205 pub async fn hybrid_search(
207 &self,
208 query: &str,
209 wing: Option<&str>,
210 room: Option<&str>,
211 limit: usize,
212 ) -> Result<Vec<HybridSearchResult>, SearchError> {
213 if limit == 0 {
214 return Ok(Vec::new());
215 }
216
217 let candidate_limit = limit
218 .saturating_mul(HYBRID_CANDIDATE_MULTIPLIER)
219 .max(limit)
220 .max(HYBRID_MIN_CANDIDATES);
221
222 if self.embedder.is_none() {
223 return Ok(fuse_hybrid_results(
224 self.keyword_search_scored(query, wing, room, candidate_limit)
225 .await?,
226 Vec::new(),
227 limit,
228 ));
229 }
230
231 let keyword_future = self.keyword_search_scored(query, wing, room, candidate_limit);
232 let vector_future = self.vector_search(query, wing, room, candidate_limit);
233 let (keyword_results, vector_results) = tokio::join!(keyword_future, vector_future);
234
235 match (keyword_results, vector_results) {
236 (Ok(keyword_hits), Ok(vector_hits)) => {
237 Ok(fuse_hybrid_results(keyword_hits, vector_hits, limit))
238 }
239 (Ok(keyword_hits), Err(err)) => {
240 warn!(
241 error = %err,
242 query,
243 "AiMem vector branch failed during hybrid search; returning keyword-only results"
244 );
245 Ok(fuse_hybrid_results(keyword_hits, Vec::new(), limit))
246 }
247 (Err(err), Ok(vector_hits)) => {
248 warn!(
249 error = %err,
250 query,
251 "AiMem keyword branch failed during hybrid search; returning vector-only results"
252 );
253 Ok(fuse_hybrid_results(Vec::new(), vector_hits, limit))
254 }
255 (Err(keyword_err), Err(vector_err)) => {
256 warn!(
257 keyword_error = %keyword_err,
258 vector_error = %vector_err,
259 query,
260 "AiMem hybrid search failed in both keyword and vector branches"
261 );
262 Err(keyword_err)
263 }
264 }
265 }
266
267 pub async fn find_duplicates(
269 &self,
270 content: &str,
271 threshold: f32,
272 limit: usize,
273 ) -> Result<Vec<SearchResult>, SearchError> {
274 let results = self.vector_search(content, None, None, limit).await?;
275 Ok(results
276 .into_iter()
277 .filter(|r| r.similarity >= threshold)
278 .collect())
279 }
280
281 async fn keyword_fts_search(
282 &self,
283 query: &str,
284 wing: Option<&str>,
285 room: Option<&str>,
286 limit: usize,
287 ) -> Result<Vec<KeywordSearchResult>, SearchError> {
288 let conn = self.db.conn()?;
292 let sql = build_keyword_fts_sql(wing, room);
293
294 let mut rows = match (wing, room) {
295 (Some(w), Some(r)) => {
296 conn.query(&sql, turso::params![query, w, r, limit as i64])
297 .await?
298 }
299 (Some(w), None) => {
300 conn.query(&sql, turso::params![query, w, limit as i64])
301 .await?
302 }
303 (None, Some(r)) => {
304 conn.query(&sql, turso::params![query, r, limit as i64])
305 .await?
306 }
307 (None, None) => {
308 conn.query(&sql, turso::params![query, limit as i64])
309 .await?
310 }
311 };
312
313 let mut results = Vec::new();
314 while let Some(row) = rows.next().await? {
315 let drawer = row_to_drawer(&row)?;
316 let score = match row.get_value(9)? {
317 turso::Value::Real(f) => f as f32,
318 turso::Value::Integer(i) => i as f32,
319 _ => 0.0,
320 };
321 results.push(KeywordSearchResult { drawer, score });
322 }
323 Ok(results)
324 }
325
326 async fn keyword_like_search(
327 &self,
328 query: &str,
329 wing: Option<&str>,
330 room: Option<&str>,
331 limit: usize,
332 ) -> Result<Vec<KeywordSearchResult>, SearchError> {
333 let conn = self.db.read_conn()?;
334 let candidate_limit = limit
335 .saturating_mul(KEYWORD_FALLBACK_CANDIDATE_MULTIPLIER)
336 .max(limit)
337 .max(KEYWORD_FALLBACK_MIN_CANDIDATES)
338 .min(KEYWORD_FALLBACK_MAX_CANDIDATES);
339 let sql = build_keyword_fallback_sql(wing, room);
340
341 let mut rows = match (wing, room) {
342 (Some(w), Some(r)) => {
343 conn.query(&sql, turso::params![w, r, candidate_limit as i64])
344 .await?
345 }
346 (Some(w), None) => {
347 conn.query(&sql, turso::params![w, candidate_limit as i64])
348 .await?
349 }
350 (None, Some(r)) => {
351 conn.query(&sql, turso::params![r, candidate_limit as i64])
352 .await?
353 }
354 (None, None) => {
355 conn.query(&sql, turso::params![candidate_limit as i64])
356 .await?
357 }
358 };
359
360 let mut results = Vec::new();
361 while let Some(row) = rows.next().await? {
362 let drawer = row_to_drawer(&row)?;
363 let score = like_match_score(&drawer, query);
364 if score > 0.0 {
365 results.push(KeywordSearchResult { score, drawer });
366 }
367 }
368
369 results.sort_by(|left, right| {
370 right
371 .score
372 .partial_cmp(&left.score)
373 .unwrap_or(std::cmp::Ordering::Equal)
374 .then_with(|| right.drawer.filed_at.cmp(&left.drawer.filed_at))
375 });
376 results.truncate(limit);
377 Ok(results)
378 }
379}
380
381#[derive(Debug)]
382struct HybridAccumulator {
383 drawer: Drawer,
384 raw_rrf: f32,
385 semantic_similarity: Option<f32>,
386 keyword_score: Option<f32>,
387}
388
389fn build_vector_sql(wing: Option<&str>, room: Option<&str>) -> String {
392 let filter = match (wing, room) {
393 (Some(_), Some(_)) => "AND wing = ?2 AND room = ?3",
394 (Some(_), None) => "AND wing = ?2",
395 (None, Some(_)) => "AND room = ?2",
396 (None, None) => "",
397 };
398 let limit_param = match (wing, room) {
399 (Some(_), Some(_)) => "?4",
400 (Some(_), None) | (None, Some(_)) => "?3",
401 (None, None) => "?2",
402 };
403 format!(
404 "SELECT id, wing, room, content, parts, source_file, chunk_index, added_by, filed_at, \
405 vector_distance_cos(embedding, vector32(?1)) AS dist \
406 FROM drawers \
407 WHERE embedding IS NOT NULL {filter} \
408 ORDER BY dist LIMIT {limit_param}"
409 )
410}
411
412fn build_keyword_fts_sql(wing: Option<&str>, room: Option<&str>) -> String {
413 let filter = match (wing, room) {
414 (Some(_), Some(_)) => "AND d.wing = ?2 AND d.room = ?3",
415 (Some(_), None) => "AND d.wing = ?2",
416 (None, Some(_)) => "AND d.room = ?2",
417 (None, None) => "",
418 };
419 let limit_param = match (wing, room) {
420 (Some(_), Some(_)) => "?4",
421 (Some(_), None) | (None, Some(_)) => "?3",
422 (None, None) => "?2",
423 };
424 format!(
425 "SELECT d.id, d.wing, d.room, d.content, d.parts, d.source_file, d.chunk_index, d.added_by, d.filed_at, \
426 fts_score(search_text, ?1) AS score \
427 FROM drawers_fts \
428 JOIN drawers d ON d.id = drawers_fts.drawer_id \
429 WHERE fts_match(search_text, ?1) {filter} \
430 ORDER BY score DESC LIMIT {limit_param}"
431 )
432}
433
434fn build_keyword_fallback_sql(wing: Option<&str>, room: Option<&str>) -> String {
435 let filter = match (wing, room) {
436 (Some(_), Some(_)) => "WHERE wing = ?1 AND room = ?2",
437 (Some(_), None) => "WHERE wing = ?1",
438 (None, Some(_)) => "WHERE room = ?1",
439 (None, None) => "",
440 };
441 let limit_param = match (wing, room) {
442 (Some(_), Some(_)) => "?3",
443 (Some(_), None) | (None, Some(_)) => "?2",
444 (None, None) => "?1",
445 };
446 format!(
447 "SELECT id, wing, room, content, parts, source_file, chunk_index, added_by, filed_at \
448 FROM drawers \
449 {filter} \
450 ORDER BY filed_at DESC LIMIT {limit_param}"
451 )
452}
453
454fn fuse_hybrid_results(
455 keyword_hits: Vec<KeywordSearchResult>,
456 vector_hits: Vec<SearchResult>,
457 limit: usize,
458) -> Vec<HybridSearchResult> {
459 if limit == 0 {
460 return Vec::new();
461 }
462
463 let mut fused = HashMap::<String, HybridAccumulator>::new();
464
465 for (rank, result) in vector_hits.into_iter().enumerate() {
466 let id = result.drawer.id.clone();
467 let similarity = result.similarity;
468 let entry = fused.entry(id).or_insert_with(|| HybridAccumulator {
469 drawer: result.drawer,
470 raw_rrf: 0.0,
471 semantic_similarity: None,
472 keyword_score: None,
473 });
474 entry.raw_rrf += rrf_component(rank, HYBRID_VECTOR_WEIGHT);
475 entry.semantic_similarity = Some(
476 entry
477 .semantic_similarity
478 .map(|current| current.max(similarity))
479 .unwrap_or(similarity),
480 );
481 }
482
483 for (rank, result) in keyword_hits.into_iter().enumerate() {
484 let id = result.drawer.id.clone();
485 let score = result.score;
486 let entry = fused.entry(id).or_insert_with(|| HybridAccumulator {
487 drawer: result.drawer,
488 raw_rrf: 0.0,
489 semantic_similarity: None,
490 keyword_score: None,
491 });
492 entry.raw_rrf +=
493 rrf_component(rank, HYBRID_KEYWORD_WEIGHT) * keyword_score_multiplier(score);
494 entry.keyword_score = Some(
495 entry
496 .keyword_score
497 .map(|current| current.max(score))
498 .unwrap_or(score),
499 );
500 }
501
502 let total_weight = (if fused
503 .values()
504 .any(|entry| entry.semantic_similarity.is_some())
505 {
506 HYBRID_VECTOR_WEIGHT
507 } else {
508 0.0
509 }) + (if fused.values().any(|entry| entry.keyword_score.is_some()) {
510 HYBRID_KEYWORD_WEIGHT
511 } else {
512 0.0
513 });
514
515 if total_weight <= 0.0 {
516 return Vec::new();
517 }
518
519 let scale = (HYBRID_RRF_K + 1.0) / total_weight;
520 let mut results = fused
521 .into_values()
522 .map(|entry| HybridSearchResult {
523 drawer: entry.drawer,
524 score: (entry.raw_rrf * scale).clamp(0.0, 1.0),
525 semantic_similarity: entry.semantic_similarity,
526 keyword_score: entry.keyword_score,
527 })
528 .collect::<Vec<_>>();
529
530 results.sort_by(|left, right| {
531 right
532 .score
533 .partial_cmp(&left.score)
534 .unwrap_or(std::cmp::Ordering::Equal)
535 .then_with(|| {
536 right
537 .keyword_score
538 .unwrap_or_default()
539 .partial_cmp(&left.keyword_score.unwrap_or_default())
540 .unwrap_or(std::cmp::Ordering::Equal)
541 })
542 .then_with(|| {
543 right
544 .semantic_similarity
545 .unwrap_or_default()
546 .partial_cmp(&left.semantic_similarity.unwrap_or_default())
547 .unwrap_or(std::cmp::Ordering::Equal)
548 })
549 .then_with(|| right.drawer.filed_at.cmp(&left.drawer.filed_at))
550 });
551 results.truncate(limit);
552 results
553}
554
555fn rrf_component(rank: usize, weight: f32) -> f32 {
556 weight / (HYBRID_RRF_K + rank as f32 + 1.0)
557}
558
559fn keyword_score_multiplier(score: f32) -> f32 {
560 1.0 + (score / (score + 1.0)).clamp(0.0, 1.0)
561}
562
563fn keyword_tokens(query: &str) -> Vec<String> {
564 let normalized = normalize_keyword_text(query);
565 let mut tokens = normalized
566 .split_whitespace()
567 .map(|part| {
568 part.trim_matches(|c: char| !is_keyword_char(c) && c != '_' && c != '-')
569 .to_string()
570 })
571 .filter(|part| part.len() >= 2)
572 .collect::<Vec<_>>();
573
574 let compact = compact_keyword_text(&normalized);
575 if contains_cjk_or_kana(&compact) {
576 tokens.extend(char_ngrams(&compact, 2));
577 tokens.extend(char_ngrams(&compact, 3));
578 }
579
580 if tokens.is_empty() {
581 if !compact.is_empty() {
582 tokens.push(compact);
583 }
584 }
585 tokens.sort();
586 tokens.dedup();
587 tokens
588}
589
590fn like_match_score(drawer: &Drawer, query: &str) -> f32 {
591 let raw_haystack = format!(
592 "{}\n{}\n{}\n{}",
593 drawer.content,
594 drawer.wing,
595 drawer.room,
596 drawer.source_file.as_deref().unwrap_or("")
597 );
598 let haystack = normalize_keyword_text(&raw_haystack);
599 let haystack_compact = compact_keyword_text(&haystack);
600 let normalized_query = normalize_keyword_text(query);
601 let normalized_query = normalized_query.trim();
602 let query_compact = compact_keyword_text(normalized_query);
603 let tokens = keyword_tokens(query);
604
605 let mut score = 0.0;
606 if !normalized_query.is_empty() && haystack.contains(&normalized_query) {
607 score += 2.0;
608 }
609
610 if !tokens.is_empty() {
611 let hits = tokens
612 .iter()
613 .filter(|token| haystack.contains(token.as_str()))
614 .count() as f32;
615 score += hits / tokens.len() as f32;
616 }
617
618 score += ngram_overlap_score(&query_compact, &haystack_compact, 2).max(ngram_overlap_score(
619 &query_compact,
620 &haystack_compact,
621 3,
622 ));
623
624 score
625}
626
627fn normalize_keyword_text(text: &str) -> String {
628 text.chars()
629 .flat_map(|ch| ch.to_lowercase())
630 .map(|ch| if is_keyword_char(ch) { ch } else { ' ' })
631 .collect::<String>()
632}
633
634fn compact_keyword_text(text: &str) -> String {
635 text.chars().filter(|ch| is_keyword_char(*ch)).collect()
636}
637
638fn is_keyword_char(ch: char) -> bool {
639 ch.is_alphanumeric() || contains_cjk_or_kana_char(ch)
640}
641
642fn contains_cjk_or_kana(text: &str) -> bool {
643 text.chars().any(contains_cjk_or_kana_char)
644}
645
646fn contains_cjk_or_kana_char(ch: char) -> bool {
647 matches!(
648 ch as u32,
649 0x3040..=0x309F | 0x30A0..=0x30FF | 0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xF900..=0xFAFF | 0xFF66..=0xFF9F )
656}
657
658fn char_ngrams(text: &str, n: usize) -> Vec<String> {
659 let chars = text.chars().collect::<Vec<_>>();
660 if n == 0 || chars.len() < n {
661 return Vec::new();
662 }
663 chars
664 .windows(n)
665 .map(|window| window.iter().collect())
666 .collect()
667}
668
669fn ngram_overlap_score(query: &str, haystack: &str, n: usize) -> f32 {
670 let query_ngrams = char_ngrams(query, n);
671 if query_ngrams.is_empty() {
672 return 0.0;
673 }
674 let haystack_ngrams = char_ngrams(haystack, n).into_iter().collect::<HashSet<_>>();
675 let hits = query_ngrams
676 .iter()
677 .filter(|gram| haystack_ngrams.contains(*gram))
678 .count() as f32;
679 hits / query_ngrams.len() as f32
680}
681
682fn should_fallback_to_like_search(err: &SearchError) -> bool {
683 let SearchError::Turso(inner) = err else {
684 return false;
685 };
686 let message = inner.to_string().to_ascii_lowercase();
687 message.contains("fts")
688 || message.contains("syntax error")
689 || message.contains("no such function")
690 || message.contains("no such table")
691}
692
693fn row_to_drawer(row: &turso::Row) -> Result<Drawer, DbError> {
694 Ok(Drawer {
695 id: val_str(row, 0),
696 wing: val_str(row, 1),
697 room: val_str(row, 2),
698 content: val_str(row, 3),
699 parts: parse_parts(&val_str(row, 4))?,
700 source_file: {
701 let s = val_str(row, 5);
702 if s.is_empty() { None } else { Some(s) }
703 },
704 chunk_index: row
705 .get_value(6)
706 .ok()
707 .and_then(|v| v.as_integer().copied())
708 .unwrap_or(0),
709 added_by: val_str(row, 7),
710 filed_at: val_str(row, 8),
711 })
712}
713
714fn parse_parts(raw: &str) -> Result<Vec<crate::types::ContentPart>, DbError> {
715 if raw.is_empty() || raw == "[]" {
716 Ok(Vec::new())
717 } else {
718 Ok(serde_json::from_str(raw)?)
719 }
720}
721
722fn val_str(row: &turso::Row, idx: usize) -> String {
723 match row.get_value(idx) {
724 Ok(turso::Value::Text(s)) => s,
725 Ok(turso::Value::Null) | Err(_) => String::new(),
726 Ok(v) => format!("{v:?}"),
727 }
728}
729
730#[cfg(test)]
731mod tests {
732 use super::*;
733 use crate::types::ContentPart;
734 use async_trait::async_trait;
735 use tempfile::tempdir;
736
737 #[derive(Debug)]
738 struct TestEmbedder;
739
740 #[async_trait]
741 impl Embedder for TestEmbedder {
742 async fn embed(
743 &self,
744 inputs: &[Vec<ContentPart>],
745 ) -> Result<Vec<Vec<f32>>, crate::embedder::EmbedError> {
746 Ok(inputs
747 .iter()
748 .map(|parts| {
749 let text = parts
750 .iter()
751 .filter_map(ContentPart::as_text)
752 .collect::<Vec<_>>()
753 .join(" ");
754 embed_text(&text)
755 })
756 .collect())
757 }
758
759 fn dimension(&self) -> usize {
760 2
761 }
762
763 fn provider_name(&self) -> &str {
764 "test"
765 }
766
767 fn model_name(&self) -> &str {
768 "test-2d"
769 }
770 }
771
772 fn embed_text(text: &str) -> Vec<f32> {
773 let lower = text.to_ascii_lowercase();
774 if lower.contains("semantic-only") {
775 vec![1.0, 0.0]
776 } else if lower.contains("alpha-42") || lower.contains("timeout") {
777 vec![0.85, 0.15]
778 } else {
779 vec![0.0, 1.0]
780 }
781 }
782
783 #[tokio::test]
784 async fn keyword_search_scored_backfills_missing_fts_rows_on_reopen() -> anyhow::Result<()> {
785 let dir = tempdir()?;
786 let db_path = dir.path().join("aimem.db");
787
788 let db = AimemDb::open(&db_path).await?;
789 let drawer = Drawer::new(
790 "drawer_alpha",
791 "attachments",
792 "subject-1",
793 "alpha-42 timeout note for worker restart",
794 "tester",
795 );
796 db.insert_drawer(&drawer, None).await?;
797
798 let conn = db.conn()?;
799 conn.execute(
800 "DELETE FROM drawers_fts WHERE drawer_id = ?1",
801 [drawer.id.as_str()],
802 )
803 .await?;
804 drop(db);
805
806 let db = AimemDb::open(&db_path).await?;
807 let searcher = Searcher::keyword_only(db);
808 let hits = searcher
809 .keyword_search_scored("alpha-42", Some("attachments"), Some("subject-1"), 5)
810 .await?;
811
812 assert_eq!(
813 hits.first().map(|hit| hit.drawer.id.as_str()),
814 Some("drawer_alpha")
815 );
816 Ok(())
817 }
818
819 #[tokio::test]
820 async fn hybrid_search_rrf_promotes_exact_keyword_hit_over_semantic_only_hit()
821 -> anyhow::Result<()> {
822 let db = AimemDb::memory().await?;
823 let embedder = Arc::new(TestEmbedder);
824 let searcher = Searcher::new(db.clone(), embedder);
825
826 let semantic_only = Drawer::new(
827 "drawer_semantic",
828 "attachments",
829 "subject-1",
830 "semantic-only memory about retries and restarts",
831 "tester",
832 );
833 db.insert_drawer_with_profile(&semantic_only, Some(&[1.0, 0.0]), "test", "test-2d")
834 .await?;
835
836 let keyword_hit = Drawer::new(
837 "drawer_keyword",
838 "attachments",
839 "subject-1",
840 "alpha-42 timeout happened after worker restart",
841 "tester",
842 );
843 db.insert_drawer_with_profile(&keyword_hit, Some(&[0.85, 0.15]), "test", "test-2d")
844 .await?;
845
846 let hits = searcher
847 .hybrid_search(
848 "alpha-42 timeout",
849 Some("attachments"),
850 Some("subject-1"),
851 5,
852 )
853 .await?;
854
855 assert_eq!(
856 hits.first().map(|hit| hit.drawer.id.as_str()),
857 Some("drawer_keyword")
858 );
859 Ok(())
860 }
861
862 #[tokio::test]
863 async fn keyword_search_scored_uses_cjk_ngram_fallback_when_fts_has_no_phrase_hit()
864 -> anyhow::Result<()> {
865 let db = AimemDb::memory().await?;
866 let target = Drawer::new(
867 "drawer_cjk_target",
868 "attachments",
869 "subject-1",
870 "文档里的项目代号是青竹。",
871 "tester",
872 );
873 db.insert_drawer(&target, None).await?;
874 let distractor = Drawer::new(
875 "drawer_cjk_distractor",
876 "attachments",
877 "subject-1",
878 "扫描PDF里的预约时间是6月18日早上九点半。",
879 "tester",
880 );
881 db.insert_drawer(&distractor, None).await?;
882
883 let searcher = Searcher::keyword_only(db);
884 let hits = searcher
885 .keyword_search_scored(
886 "文档里的项目代号是什么?",
887 Some("attachments"),
888 Some("subject-1"),
889 5,
890 )
891 .await?;
892
893 assert_eq!(
894 hits.first().map(|hit| hit.drawer.id.as_str()),
895 Some("drawer_cjk_target")
896 );
897 Ok(())
898 }
899
900 #[tokio::test]
901 async fn keyword_search_scored_uses_japanese_ngram_fallback_when_fts_has_no_phrase_hit()
902 -> anyhow::Result<()> {
903 let db = AimemDb::memory().await?;
904 let target = Drawer::new(
905 "drawer_ja_target",
906 "attachments",
907 "subject-1",
908 "資料のプロジェクト名は北風ノート。",
909 "tester",
910 );
911 db.insert_drawer(&target, None).await?;
912 let distractor = Drawer::new(
913 "drawer_ja_distractor",
914 "attachments",
915 "subject-1",
916 "レシートのアレルギー欄にはピーナッツ抜きと書かれている。",
917 "tester",
918 );
919 db.insert_drawer(&distractor, None).await?;
920
921 let searcher = Searcher::keyword_only(db);
922 let hits = searcher
923 .keyword_search_scored(
924 "資料のプロジェクト名は何ですか?",
925 Some("attachments"),
926 Some("subject-1"),
927 5,
928 )
929 .await?;
930
931 assert_eq!(
932 hits.first().map(|hit| hit.drawer.id.as_str()),
933 Some("drawer_ja_target")
934 );
935 Ok(())
936 }
937
938 #[tokio::test]
939 async fn delete_drawer_removes_keyword_hits_from_fts_sidecar() -> anyhow::Result<()> {
940 let db = AimemDb::memory().await?;
941 let drawer = Drawer::new(
942 "drawer_delete",
943 "attachments",
944 "subject-1",
945 "remove alpha-42 from keyword index",
946 "tester",
947 );
948 db.insert_drawer(&drawer, None).await?;
949
950 let searcher = Searcher::keyword_only(db.clone());
951 assert!(
952 !searcher
953 .keyword_search_scored("alpha-42", Some("attachments"), Some("subject-1"), 5)
954 .await?
955 .is_empty()
956 );
957
958 assert!(db.delete_drawer("drawer_delete").await?);
959 assert!(
960 searcher
961 .keyword_search_scored("alpha-42", Some("attachments"), Some("subject-1"), 5)
962 .await?
963 .is_empty()
964 );
965 Ok(())
966 }
967}