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#[derive(Debug, Clone, Copy)]
15pub struct SearchWeights {
16 pub fts: f64,
18 pub fuzzy: f64,
20 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 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
46pub struct SearchEngine;
49
50impl Default for SearchEngine {
51 fn default() -> Self {
52 Self::new()
53 }
54}
55
56struct RankedSignal {
58 memory_id: Uuid,
59 rank: usize,
60 #[allow(dead_code)]
62 score: f64,
63}
64
65const RRF_K: f64 = 60.0;
67
68impl SearchEngine {
69 pub fn new() -> Self {
71 Self
72 }
73
74 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 fn best_match_type(signals: &[RankedSignal], memory_id: Uuid) -> MatchType {
313 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 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; 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; 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 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}