Skip to main content

mneme/store/
search.rs

1use std::collections::HashMap;
2use std::str::FromStr;
3
4use chrono::{DateTime, Utc};
5use fuzzy_matcher::skim::SkimMatcherV2;
6use fuzzy_matcher::FuzzyMatcher;
7use uuid::Uuid;
8
9use crate::store::memory::{
10    Importance, MatchType, Memory, MemoryType, Scope, SearchQuery, SearchResult,
11};
12
13/// Pesos para cada componente de la búsqueda híbrida.
14#[derive(Debug, Clone, Copy)]
15pub struct SearchWeights {
16    /// Peso para FTS5 (default 0.5).
17    pub fts: f64,
18    /// Peso para fuzzy matching (default 0.2).
19    pub fuzzy: f64,
20    /// Peso para búsqueda semántica (default 0.3).
21    pub semantic: f64,
22}
23
24impl Default for SearchWeights {
25    fn default() -> Self {
26        Self {
27            fts: 0.5,
28            fuzzy: 0.2,
29            semantic: 0.3,
30        }
31    }
32}
33
34impl SearchWeights {
35    /// Renormaliza pesos cuando embeddings están deshabilitados.
36    /// fts=0.7, fuzzy=0.3, semantic=0.0.
37    pub fn renormalize_without_semantic(&self) -> Self {
38        Self {
39            fts: 0.7,
40            fuzzy: 0.3,
41            semantic: 0.0,
42        }
43    }
44}
45
46/// Motor de búsqueda multi-señal con RRF (Reciprocal Rank Fusion).
47/// Combina FTS5 + fuzzy + semántica + entidades + recencia usando RRF.
48pub struct SearchEngine;
49
50impl Default for SearchEngine {
51    fn default() -> Self {
52        Self::new()
53    }
54}
55
56/// Resultado individual de un matcher.
57struct RankedSignal {
58    memory_id: Uuid,
59    rank: usize,
60    /// Pre-computed score for debugging/logging purposes.
61    #[allow(dead_code)]
62    score: f64,
63}
64
65/// Constante RRF (k=60 es el valor estándar).
66const RRF_K: f64 = 60.0;
67
68impl SearchEngine {
69    /// Crea un nuevo SearchEngine.
70    pub fn new() -> Self {
71        Self
72    }
73
74    /// Multi-signal search using Reciprocal Rank Fusion.
75    ///
76    /// Señales:
77    /// - FTS5 full-text search
78    /// - Fuzzy title matching
79    /// - Semantic (cosine) similarity
80    /// - Entity name matching
81    /// - Temporal recency
82    ///
83    /// Cada señal produce un ranking. RRF fusiona los rankings en un score final.
84    pub fn search(
85        &self,
86        conn: &rusqlite::Connection,
87        query: &SearchQuery,
88        _weights: &SearchWeights,
89        semantic_scores: Option<&HashMap<Uuid, f32>>,
90    ) -> crate::error::Result<Vec<SearchResult>> {
91        let mut all_signals: Vec<RankedSignal> = Vec::new();
92        let mut seen_ids: std::collections::HashSet<Uuid> = std::collections::HashSet::new();
93
94        // 1. FTS5 signal
95        if query.text.len() >= 3 {
96            let fts_results = self.search_fts(conn, query)?;
97            for (rank, result) in fts_results.iter().enumerate() {
98                all_signals.push(RankedSignal {
99                    memory_id: result.memory.id,
100                    rank,
101                    score: result.score,
102                });
103                seen_ids.insert(result.memory.id);
104            }
105        }
106
107        // 2. Fuzzy signal
108        let fuzzy_results = self.search_fuzzy(conn, query)?;
109        for (rank, result) in fuzzy_results.iter().enumerate() {
110            all_signals.push(RankedSignal {
111                memory_id: result.memory.id,
112                rank,
113                score: result.score,
114            });
115            seen_ids.insert(result.memory.id);
116        }
117
118        // 3. Semantic signal
119        if let Some(scores) = semantic_scores {
120            let mut semantic_ranked: Vec<(Uuid, f32)> =
121                scores.iter().map(|(id, s)| (*id, *s)).collect();
122            semantic_ranked
123                .sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
124            for (rank, (id, _)) in semantic_ranked.iter().enumerate() {
125                all_signals.push(RankedSignal {
126                    memory_id: *id,
127                    rank,
128                    score: f64::from(*scores.get(id).unwrap_or(&0.0)),
129                });
130                seen_ids.insert(*id);
131            }
132        }
133
134        // 4. Entity signal: search entity names matching the query
135        let entity_matches = Self::search_entities(conn, &query.text, query.project.as_deref());
136        for (rank, memory_id) in entity_matches.iter().enumerate() {
137            all_signals.push(RankedSignal {
138                memory_id: *memory_id,
139                rank,
140                score: 1.0,
141            });
142            seen_ids.insert(*memory_id);
143        }
144
145        // 5. Temporal recency signal: prefer recently accessed
146        let recency_results = self.search_recency(conn, query);
147        for (rank, memory_id) in recency_results.iter().enumerate() {
148            if seen_ids.contains(memory_id) {
149                all_signals.push(RankedSignal {
150                    memory_id: *memory_id,
151                    rank,
152                    score: 1.0,
153                });
154            }
155        }
156
157        // Apply RRF: for each unique memory, sum 1/(k + rank) across all signals
158        let mut rrf_scores: HashMap<Uuid, f64> = HashMap::new();
159        for signal in &all_signals {
160            let entry = rrf_scores.entry(signal.memory_id).or_insert(0.0);
161            *entry += 1.0 / (RRF_K + signal.rank as f64);
162        }
163
164        // Build full Memory objects for scored IDs
165        let mut filtered: Vec<SearchResult> = Vec::new();
166        for (id, rrf_score) in &rrf_scores {
167            if let Ok(Some(memory)) = Self::get_memory(conn, *id) {
168                // Apply filters
169                if let Some(memory_type) = &query.memory_type {
170                    if &memory.memory_type != memory_type {
171                        continue;
172                    }
173                }
174                if let Some(importance) = &query.importance {
175                    if &memory.importance != importance {
176                        continue;
177                    }
178                }
179                if !query.tags.is_empty() && !query.tags.iter().all(|tag| memory.tags.contains(tag))
180                {
181                    continue;
182                }
183
184                let cosine = semantic_scores.and_then(|s| s.get(id).copied());
185                let match_type = Self::best_match_type(&all_signals, *id);
186
187                // Apply importance boost
188                let final_score = rrf_score * memory.importance.boost_factor();
189
190                filtered.push(SearchResult {
191                    memory,
192                    score: final_score,
193                    snippet: None,
194                    match_type,
195                    cosine_score: cosine,
196                });
197            }
198        }
199
200        // Sort by score DESC
201        filtered.sort_by(|a, b| {
202            b.score
203                .partial_cmp(&a.score)
204                .unwrap_or(std::cmp::Ordering::Equal)
205        });
206        filtered.truncate(query.limit as usize);
207
208        // Extract snippets if requested
209        if query.include_snippet {
210            for result in &mut filtered {
211                result.snippet = self.extract_snippet(&result.memory.content, &query.text);
212            }
213        }
214
215        Ok(filtered)
216    }
217
218    /// Search for memories whose entities match the query text.
219    fn search_entities(
220        conn: &rusqlite::Connection,
221        query: &str,
222        project: Option<&str>,
223    ) -> Vec<Uuid> {
224        let like_pattern = format!("%{}%", query);
225        let mut results = Vec::new();
226
227        let sql = if let Some(_proj) = project {
228            "SELECT DISTINCT e.memory_id FROM memory_entities e
229             JOIN memories m ON m.id = e.memory_id
230             WHERE e.entity_name LIKE ?1 AND m.project = ?2 AND m.deleted_at IS NULL
231             LIMIT 20"
232        } else {
233            "SELECT DISTINCT e.memory_id FROM memory_entities e
234             JOIN memories m ON m.id = e.memory_id
235             WHERE e.entity_name LIKE ?1 AND m.deleted_at IS NULL
236             LIMIT 20"
237        };
238
239        if let Some(proj) = project {
240            if let Ok(mut stmt) = conn.prepare(sql) {
241                if let Ok(rows) = stmt.query_map(rusqlite::params![like_pattern, proj], |row| {
242                    row.get::<_, String>(0)
243                }) {
244                    for id_str in rows.flatten() {
245                        if let Ok(id) = Uuid::parse_str(&id_str) {
246                            results.push(id);
247                        }
248                    }
249                }
250            }
251        } else {
252            if let Ok(mut stmt) = conn.prepare(sql) {
253                if let Ok(rows) = stmt.query_map(rusqlite::params![like_pattern], |row| {
254                    row.get::<_, String>(0)
255                }) {
256                    for id_str in rows.flatten() {
257                        if let Ok(id) = Uuid::parse_str(&id_str) {
258                            results.push(id);
259                        }
260                    }
261                }
262            }
263        }
264
265        results
266    }
267
268    /// Temporal recency signal: most recently accessed/updated memories.
269    fn search_recency(&self, conn: &rusqlite::Connection, query: &SearchQuery) -> Vec<Uuid> {
270        let mut results = Vec::new();
271        let limit_i64 = (query.limit * 2) as i64;
272
273        // Use two separate blocks to avoid Rust type incompatibility
274        if let Some(ref project) = query.project {
275            let sql = "SELECT id FROM memories
276                       WHERE project = ?1 AND deleted_at IS NULL
277                       ORDER BY last_accessed_at IS NULL, last_accessed_at DESC, updated_at DESC
278                       LIMIT ?2";
279            if let Ok(mut stmt) = conn.prepare(sql) {
280                if let Ok(rows) = stmt.query_map(rusqlite::params![project, limit_i64], |row| {
281                    row.get::<_, String>(0)
282                }) {
283                    for id_str in rows.flatten() {
284                        if let Ok(id) = Uuid::parse_str(&id_str) {
285                            results.push(id);
286                        }
287                    }
288                }
289            }
290        } else {
291            let sql = "SELECT id FROM memories
292                       WHERE deleted_at IS NULL
293                       ORDER BY last_accessed_at IS NULL, last_accessed_at DESC, updated_at DESC
294                       LIMIT ?1";
295            if let Ok(mut stmt) = conn.prepare(sql) {
296                if let Ok(rows) =
297                    stmt.query_map(rusqlite::params![limit_i64], |row| row.get::<_, String>(0))
298                {
299                    for id_str in rows.flatten() {
300                        if let Ok(id) = Uuid::parse_str(&id_str) {
301                            results.push(id);
302                        }
303                    }
304                }
305            }
306        }
307
308        results
309    }
310
311    /// Determina el mejor match type para una memoria basado en sus señales.
312    fn best_match_type(signals: &[RankedSignal], memory_id: Uuid) -> MatchType {
313        // Find the signal with highest score
314        let best = signals
315            .iter()
316            .filter(|s| s.memory_id == memory_id)
317            .min_by_key(|s| s.rank);
318
319        match best {
320            Some(s) if s.rank < 10 => MatchType::Fts,
321            Some(_) => MatchType::Fuzzy,
322            None => MatchType::Fts,
323        }
324    }
325
326    /// Retrieves a single memory by ID from the connection.
327    fn get_memory(conn: &rusqlite::Connection, id: Uuid) -> rusqlite::Result<Option<Memory>> {
328        let mut stmt = conn.prepare(
329            "SELECT id, project, scope, title, content, what, why, context, learned,
330             memory_type, importance, tags, topic_key, access_count, revision_count,
331             duplicate_count, normalized_hash, created_at, updated_at, last_accessed_at, last_seen_at, deleted_at,
332             deprecated_at, deprecated_reason, supersedes_id, context_inject_count, origin_peer,
333             is_encrypted, encrypted_for, valid_from, valid_until, provenance
334             FROM memories WHERE id = ?1 AND deleted_at IS NULL"
335        )?;
336
337        let result = stmt.query_row(rusqlite::params![id.to_string()], |row| {
338            Ok(Memory {
339                id: Uuid::parse_str(&row.get::<_, String>(0)?).map_err(|e| {
340                    rusqlite::Error::FromSqlConversionFailure(
341                        0,
342                        rusqlite::types::Type::Text,
343                        Box::new(e),
344                    )
345                })?,
346                project: row.get(1)?,
347                scope: Scope::from_str(&row.get::<_, String>(2)?).map_err(|e| {
348                    rusqlite::Error::FromSqlConversionFailure(
349                        2,
350                        rusqlite::types::Type::Text,
351                        Box::new(e),
352                    )
353                })?,
354                title: row.get(3)?,
355                content: row.get(4)?,
356                what: row.get(5)?,
357                why: row.get(6)?,
358                context: row.get(7)?,
359                learned: row.get(8)?,
360                memory_type: MemoryType::from_str(&row.get::<_, String>(9)?).map_err(|e| {
361                    rusqlite::Error::FromSqlConversionFailure(
362                        9,
363                        rusqlite::types::Type::Text,
364                        Box::new(e),
365                    )
366                })?,
367                importance: Importance::from_str(&row.get::<_, String>(10)?).map_err(|e| {
368                    rusqlite::Error::FromSqlConversionFailure(
369                        10,
370                        rusqlite::types::Type::Text,
371                        Box::new(e),
372                    )
373                })?,
374                tags: serde_json::from_str(&row.get::<_, String>(11)?).unwrap_or_default(),
375                topic_key: row.get(12)?,
376                access_count: row.get(13)?,
377                revision_count: row.get(14)?,
378                duplicate_count: row.get(15)?,
379                normalized_hash: row.get(16)?,
380                created_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(17)?)
381                    .map_err(|e| {
382                        rusqlite::Error::FromSqlConversionFailure(
383                            17,
384                            rusqlite::types::Type::Text,
385                            Box::new(e),
386                        )
387                    })?
388                    .with_timezone(&Utc),
389                updated_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(18)?)
390                    .map_err(|e| {
391                        rusqlite::Error::FromSqlConversionFailure(
392                            18,
393                            rusqlite::types::Type::Text,
394                            Box::new(e),
395                        )
396                    })?
397                    .with_timezone(&Utc),
398                last_accessed_at: row.get::<_, Option<String>>(19)?.and_then(|s| {
399                    DateTime::parse_from_rfc3339(&s)
400                        .ok()
401                        .map(|d| d.with_timezone(&Utc))
402                }),
403                last_seen_at: row.get::<_, Option<String>>(20)?.and_then(|s| {
404                    DateTime::parse_from_rfc3339(&s)
405                        .ok()
406                        .map(|d| d.with_timezone(&Utc))
407                }),
408                deleted_at: row.get::<_, Option<String>>(21)?.and_then(|s| {
409                    DateTime::parse_from_rfc3339(&s)
410                        .ok()
411                        .map(|d| d.with_timezone(&Utc))
412                }),
413                deprecated_at: row.get::<_, Option<String>>(22)?.and_then(|s| {
414                    DateTime::parse_from_rfc3339(&s)
415                        .ok()
416                        .map(|d| d.with_timezone(&Utc))
417                }),
418                deprecated_reason: row.get(23)?,
419                supersedes_id: row.get(24)?,
420                context_inject_count: row.get(25)?,
421                origin_peer: row.get(26)?,
422                is_encrypted: row
423                    .get::<_, Option<bool>>(27)
424                    .unwrap_or(Some(false))
425                    .unwrap_or(false),
426                encrypted_for: row.get::<_, Option<String>>(28).unwrap_or(None),
427                valid_from: row.get::<_, Option<String>>(29)?.and_then(|s| {
428                    DateTime::parse_from_rfc3339(&s)
429                        .ok()
430                        .map(|d| d.with_timezone(&Utc))
431                }),
432                valid_until: row.get::<_, Option<String>>(30)?.and_then(|s| {
433                    DateTime::parse_from_rfc3339(&s)
434                        .ok()
435                        .map(|d| d.with_timezone(&Utc))
436                }),
437                provenance: row.get(31)?,
438            })
439        });
440
441        match result {
442            Ok(memory) => Ok(Some(memory)),
443            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
444            Err(e) => Err(e),
445        }
446    }
447
448    fn search_fts(
449        &self,
450        conn: &rusqlite::Connection,
451        query: &SearchQuery,
452    ) -> crate::error::Result<Vec<SearchResult>> {
453        let mut sql = String::from(
454            "SELECT m.id, m.project, m.scope, m.title, m.content, m.what, m.why, m.context, m.learned,
455             m.memory_type, m.importance, m.tags, m.topic_key, m.access_count, m.revision_count,
456             m.duplicate_count, m.normalized_hash, m.created_at, m.updated_at, m.last_accessed_at, m.last_seen_at, m.deleted_at,
457             0.0 as rank_score
458             FROM memories_fts fts
459             JOIN memories m ON m.rowid = fts.rowid
460             WHERE memories_fts MATCH ?1 AND m.deleted_at IS NULL",
461        );
462
463        let mut params: Vec<&dyn rusqlite::ToSql> = Vec::new();
464        params.push(&query.text);
465
466        if let Some(project) = &query.project {
467            sql.push_str(" AND m.project = ?");
468            params.push(project);
469        }
470
471        sql.push_str(" ORDER BY m.updated_at DESC LIMIT ?");
472        let limit = query.limit as i64;
473        params.push(&limit);
474
475        let mut stmt = conn.prepare(&sql)?;
476        let rows = stmt.query_map(params.as_slice(), |row| {
477            let _rank: f64 = row.get(22)?;
478            let score = 1.0; // FTS match gets base score 1.0
479
480            Ok(SearchResult {
481                memory: Memory {
482                    id: Uuid::parse_str(&row.get::<_, String>(0)?).map_err(|e| {
483                        rusqlite::Error::FromSqlConversionFailure(
484                            0,
485                            rusqlite::types::Type::Text,
486                            Box::new(e),
487                        )
488                    })?,
489                    project: row.get(1)?,
490                    scope: Scope::from_str(&row.get::<_, String>(2)?).map_err(|e| {
491                        rusqlite::Error::FromSqlConversionFailure(
492                            2,
493                            rusqlite::types::Type::Text,
494                            Box::new(e),
495                        )
496                    })?,
497                    title: row.get(3)?,
498                    content: row.get(4)?,
499                    what: row.get(5)?,
500                    why: row.get(6)?,
501                    context: row.get(7)?,
502                    learned: row.get(8)?,
503                    memory_type: MemoryType::from_str(&row.get::<_, String>(9)?).map_err(|e| {
504                        rusqlite::Error::FromSqlConversionFailure(
505                            9,
506                            rusqlite::types::Type::Text,
507                            Box::new(e),
508                        )
509                    })?,
510                    importance: Importance::from_str(&row.get::<_, String>(10)?).map_err(|e| {
511                        rusqlite::Error::FromSqlConversionFailure(
512                            10,
513                            rusqlite::types::Type::Text,
514                            Box::new(e),
515                        )
516                    })?,
517                    tags: serde_json::from_str(&row.get::<_, String>(11)?).unwrap_or_default(),
518                    topic_key: row.get(12)?,
519                    access_count: row.get(13)?,
520                    revision_count: row.get(14)?,
521                    duplicate_count: row.get(15)?,
522                    normalized_hash: row.get(16)?,
523                    created_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(17)?)
524                        .map_err(|e| {
525                            rusqlite::Error::FromSqlConversionFailure(
526                                17,
527                                rusqlite::types::Type::Text,
528                                Box::new(e),
529                            )
530                        })?
531                        .with_timezone(&Utc),
532                    updated_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(18)?)
533                        .map_err(|e| {
534                            rusqlite::Error::FromSqlConversionFailure(
535                                18,
536                                rusqlite::types::Type::Text,
537                                Box::new(e),
538                            )
539                        })?
540                        .with_timezone(&Utc),
541                    last_accessed_at: row
542                        .get::<_, Option<String>>(19)?
543                        .and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
544                        .map(|d| d.with_timezone(&Utc)),
545                    last_seen_at: row
546                        .get::<_, Option<String>>(20)?
547                        .and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
548                        .map(|d| d.with_timezone(&Utc)),
549                    deleted_at: row
550                        .get::<_, Option<String>>(21)?
551                        .and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
552                        .map(|d| d.with_timezone(&Utc)),
553                    deprecated_at: None,
554                    deprecated_reason: None,
555                    supersedes_id: None,
556                    context_inject_count: 0,
557                    origin_peer: None,
558                    is_encrypted: false,
559                    encrypted_for: None,
560                    valid_from: None,
561                    valid_until: None,
562                    provenance: None,
563                },
564                score,
565                snippet: None,
566                match_type: MatchType::Fts,
567                cosine_score: None,
568            })
569        })?;
570
571        let mut results = Vec::new();
572        for row in rows {
573            results.push(row?);
574        }
575        Ok(results)
576    }
577
578    fn search_fuzzy(
579        &self,
580        conn: &rusqlite::Connection,
581        query: &SearchQuery,
582    ) -> crate::error::Result<Vec<SearchResult>> {
583        let matcher = SkimMatcherV2::default();
584
585        let mut sql = String::from(
586            "SELECT id, project, scope, title, content, what, why, context, learned,
587             memory_type, importance, tags, topic_key, access_count, revision_count,
588             duplicate_count, normalized_hash, created_at, updated_at, last_accessed_at, last_seen_at, deleted_at
589             FROM memories WHERE deleted_at IS NULL",
590        );
591        let mut params: Vec<&dyn rusqlite::ToSql> = Vec::new();
592
593        if let Some(project) = &query.project {
594            sql.push_str(" AND project = ?");
595            params.push(project);
596        }
597
598        let mut stmt = conn.prepare(&sql)?;
599        let rows = stmt.query_map(params.as_slice(), |row| {
600            Ok(Memory {
601                id: Uuid::parse_str(&row.get::<_, String>(0)?).map_err(|e| {
602                    rusqlite::Error::FromSqlConversionFailure(
603                        0,
604                        rusqlite::types::Type::Text,
605                        Box::new(e),
606                    )
607                })?,
608                project: row.get(1)?,
609                scope: Scope::from_str(&row.get::<_, String>(2)?).map_err(|e| {
610                    rusqlite::Error::FromSqlConversionFailure(
611                        2,
612                        rusqlite::types::Type::Text,
613                        Box::new(e),
614                    )
615                })?,
616                title: row.get(3)?,
617                content: row.get(4)?,
618                what: row.get(5)?,
619                why: row.get(6)?,
620                context: row.get(7)?,
621                learned: row.get(8)?,
622                memory_type: MemoryType::from_str(&row.get::<_, String>(9)?).map_err(|e| {
623                    rusqlite::Error::FromSqlConversionFailure(
624                        9,
625                        rusqlite::types::Type::Text,
626                        Box::new(e),
627                    )
628                })?,
629                importance: Importance::from_str(&row.get::<_, String>(10)?).map_err(|e| {
630                    rusqlite::Error::FromSqlConversionFailure(
631                        10,
632                        rusqlite::types::Type::Text,
633                        Box::new(e),
634                    )
635                })?,
636                tags: serde_json::from_str(&row.get::<_, String>(11)?).unwrap_or_default(),
637                topic_key: row.get(12)?,
638                access_count: row.get(13)?,
639                revision_count: row.get(14)?,
640                duplicate_count: row.get(15)?,
641                normalized_hash: row.get(16)?,
642                created_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(17)?)
643                    .map_err(|e| {
644                        rusqlite::Error::FromSqlConversionFailure(
645                            17,
646                            rusqlite::types::Type::Text,
647                            Box::new(e),
648                        )
649                    })?
650                    .with_timezone(&Utc),
651                updated_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(18)?)
652                    .map_err(|e| {
653                        rusqlite::Error::FromSqlConversionFailure(
654                            18,
655                            rusqlite::types::Type::Text,
656                            Box::new(e),
657                        )
658                    })?
659                    .with_timezone(&Utc),
660                last_accessed_at: row
661                    .get::<_, Option<String>>(19)?
662                    .and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
663                    .map(|d| d.with_timezone(&Utc)),
664                last_seen_at: row
665                    .get::<_, Option<String>>(20)?
666                    .and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
667                    .map(|d| d.with_timezone(&Utc)),
668                deleted_at: row
669                    .get::<_, Option<String>>(21)?
670                    .and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
671                    .map(|d| d.with_timezone(&Utc)),
672                deprecated_at: None,
673                deprecated_reason: None,
674                supersedes_id: None,
675                context_inject_count: 0,
676                origin_peer: None,
677                is_encrypted: false,
678                encrypted_for: None,
679                valid_from: None,
680                valid_until: None,
681                provenance: None,
682            })
683        })?;
684
685        let mut results = Vec::new();
686        for row in rows {
687            let memory = row?;
688            if let Some(score) = matcher.fuzzy_match(&memory.title, &query.text) {
689                let normalized_score = (score as f64) / 100.0; // Normalize to ~0-1
690                results.push(SearchResult {
691                    memory,
692                    score: normalized_score,
693                    snippet: None,
694                    match_type: MatchType::Fuzzy,
695                    cosine_score: None,
696                });
697            }
698        }
699
700        // Sort and truncate
701        results.sort_by(|a, b| {
702            b.score
703                .partial_cmp(&a.score)
704                .unwrap_or(std::cmp::Ordering::Equal)
705        });
706        results.truncate(query.limit as usize);
707
708        Ok(results)
709    }
710
711    fn extract_snippet(&self, content: &str, query: &str) -> Option<String> {
712        let lower_content = content.to_lowercase();
713        let lower_query = query.to_lowercase();
714
715        if let Some(pos) = lower_content.find(&lower_query) {
716            let start = pos.saturating_sub(50);
717            let end = (pos + query.len() + 50).min(content.len());
718            Some(content[start..end].to_string())
719        } else {
720            Some(content[..content.len().min(100)].to_string())
721        }
722    }
723}