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;
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/// Searcher — vector + keyword search over the AiMem store.
38#[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    /// Semantic vector search using Turso's `vector_distance_cos`.
73    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    /// Keyword search with scores.
130    ///
131    /// Uses Turso FTS/Tantivy when available, and falls back to LIKE-based
132    /// scoring for resilience or substring-style queries.
133    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    /// Keyword search returning drawers only.
160    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    /// Keyword search with token fallback.
176    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    /// Hybrid keyword + vector search fused with reciprocal-rank fusion (RRF).
206    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    /// Duplicate check.
268    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        // Turso's FTS/custom-index path may perform internal index work while
289        // evaluating fts_match/fts_score, so it cannot run on the query_only
290        // read connection even though this is logically a read query.
291        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
389// ── SQL builders ──────────────────────────────────────────────────────────────
390
391fn 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 // Hiragana
650            | 0x30A0..=0x30FF // Katakana
651            | 0x3400..=0x4DBF // CJK Extension A
652            | 0x4E00..=0x9FFF // CJK Unified Ideographs
653            | 0xF900..=0xFAFF // CJK Compatibility Ideographs
654            | 0xFF66..=0xFF9F // Halfwidth Katakana
655    )
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}