1#![warn(clippy::all)]
2
3use crate::core::{
4 db::Database,
5 types::{
6 AnchorKind, KnowledgeStatus, KnowledgeTier, MemoryDomain, MemoryKind, RouteDecision,
7 SearchResult,
8 },
9 utils::source_file_or_synthetic,
10};
11use crate::embed::{EmbedError, Embedder};
12use mempal_search_core::{DEFAULT_RRF_K, build_fts_match_query, reciprocal_rank_fusion};
13use thiserror::Error;
14
15use crate::search::filter::build_filter_clause;
16
17pub mod filter;
18pub mod rerank;
19pub mod route;
20
21pub type Result<T> = std::result::Result<T, SearchError>;
22
23#[derive(Debug, Clone, Default, PartialEq, Eq)]
24pub struct SearchFilters {
25 pub memory_kind: Option<String>,
26 pub domain: Option<String>,
27 pub field: Option<String>,
28 pub tier: Option<String>,
29 pub status: Option<String>,
30 pub anchor_kind: Option<String>,
31}
32
33#[derive(Debug, Clone, Default, PartialEq, Eq)]
34pub struct SearchOptions {
35 pub filters: SearchFilters,
36 pub with_neighbors: bool,
37}
38
39#[derive(Debug, Error)]
40pub enum SearchError {
41 #[error("failed to embed search query")]
42 EmbedQuery(#[source] EmbedError),
43 #[error("embedder returned no query vector")]
44 MissingQueryVector,
45 #[error("failed to count candidate drawers")]
46 CountCandidateDrawers(#[source] rusqlite::Error),
47 #[error("failed to count total drawers")]
48 CountTotalDrawers(#[source] rusqlite::Error),
49 #[error("failed to serialize query vector")]
50 SerializeQueryVector(#[source] serde_json::Error),
51 #[error("top_k does not fit into i64")]
52 InvalidTopK,
53 #[error("failed to prepare search statement")]
54 PrepareSearch(#[source] rusqlite::Error),
55 #[error("failed to execute search query")]
56 ExecuteSearch(#[source] rusqlite::Error),
57 #[error("failed to collect search rows")]
58 CollectSearchRows(#[source] rusqlite::Error),
59 #[error("failed to load taxonomy entries")]
60 LoadTaxonomy(#[source] crate::core::db::DbError),
61 #[error("failed to run keyword search")]
62 KeywordSearch(#[source] crate::core::db::DbError),
63 #[error("failed to load neighbor chunks")]
64 LoadNeighbors(#[source] crate::core::db::DbError),
65}
66
67pub async fn search<E: Embedder + ?Sized>(
68 db: &Database,
69 embedder: &E,
70 query: &str,
71 wing: Option<&str>,
72 room: Option<&str>,
73 top_k: usize,
74) -> Result<Vec<SearchResult>> {
75 search_with_options(
76 db,
77 embedder,
78 query,
79 wing,
80 room,
81 SearchOptions::default(),
82 top_k,
83 )
84 .await
85}
86
87pub async fn search_with_filters<E: Embedder + ?Sized>(
88 db: &Database,
89 embedder: &E,
90 query: &str,
91 wing: Option<&str>,
92 room: Option<&str>,
93 filters: &SearchFilters,
94 top_k: usize,
95) -> Result<Vec<SearchResult>> {
96 search_with_options(
97 db,
98 embedder,
99 query,
100 wing,
101 room,
102 SearchOptions {
103 filters: filters.clone(),
104 with_neighbors: false,
105 },
106 top_k,
107 )
108 .await
109}
110
111pub async fn search_with_options<E: Embedder + ?Sized>(
112 db: &Database,
113 embedder: &E,
114 query: &str,
115 wing: Option<&str>,
116 room: Option<&str>,
117 options: SearchOptions,
118 top_k: usize,
119) -> Result<Vec<SearchResult>> {
120 if top_k == 0 {
121 return Ok(Vec::new());
122 }
123
124 let route = resolve_route(db, query, wing, room)?;
125
126 let embeddings = embedder
127 .embed(&[query])
128 .await
129 .map_err(SearchError::EmbedQuery)?;
130 let query_vector = embeddings
131 .into_iter()
132 .next()
133 .ok_or(SearchError::MissingQueryVector)?;
134
135 search_with_vector_options(db, query, &query_vector, route, options, top_k)
136}
137
138pub fn search_with_vector(
139 db: &Database,
140 query: &str,
141 query_vector: &[f32],
142 route: RouteDecision,
143 top_k: usize,
144) -> Result<Vec<SearchResult>> {
145 search_with_vector_options(
146 db,
147 query,
148 query_vector,
149 route,
150 SearchOptions::default(),
151 top_k,
152 )
153}
154
155pub fn search_with_vector_and_filters(
156 db: &Database,
157 query: &str,
158 query_vector: &[f32],
159 route: RouteDecision,
160 filters: &SearchFilters,
161 top_k: usize,
162) -> Result<Vec<SearchResult>> {
163 search_with_vector_options(
164 db,
165 query,
166 query_vector,
167 route,
168 SearchOptions {
169 filters: filters.clone(),
170 with_neighbors: false,
171 },
172 top_k,
173 )
174}
175
176pub fn search_with_vector_options(
177 db: &Database,
178 query: &str,
179 query_vector: &[f32],
180 route: RouteDecision,
181 options: SearchOptions,
182 top_k: usize,
183) -> Result<Vec<SearchResult>> {
184 if top_k == 0 {
185 return Ok(Vec::new());
186 }
187
188 let vector_results =
190 search_by_vector_with_filters(db, query_vector, route.clone(), &options.filters, top_k)?;
191
192 let fts_results = search_fts_with_filters(db, query, &route, &options.filters, top_k)?;
193
194 let mut results = if fts_results.is_empty() {
195 vector_results
196 } else {
197 rrf_merge(vector_results, fts_results, top_k)
198 };
199
200 inject_tunnel_hints(db, &mut results);
202 if options.with_neighbors && top_k <= 10 {
203 inject_chunk_neighbors(db, &mut results)?;
204 }
205
206 Ok(results)
207}
208
209fn inject_chunk_neighbors(db: &Database, results: &mut [SearchResult]) -> Result<()> {
210 for result in results {
211 let Some(chunk_index) = result.chunk_index else {
212 continue;
213 };
214 let neighbors = db
215 .neighbor_chunks(
216 &result.source_file,
217 &result.wing,
218 result.room.as_deref(),
219 chunk_index,
220 )
221 .map_err(SearchError::LoadNeighbors)?;
222 if neighbors.prev.is_some() || neighbors.next.is_some() {
223 result.neighbors = Some(neighbors);
224 }
225 }
226
227 Ok(())
228}
229
230fn inject_tunnel_hints(db: &Database, results: &mut [SearchResult]) {
233 let tunnels = match db.find_tunnels() {
234 Ok(t) => t,
235 Err(_) => return,
236 };
237 if tunnels.is_empty() {
238 return;
239 }
240
241 let tunnel_map: std::collections::HashMap<&str, &[String]> = tunnels
243 .iter()
244 .map(|(room, wings)| (room.as_str(), wings.as_slice()))
245 .collect();
246
247 for result in results.iter_mut() {
248 if let Some(room) = result.room.as_deref() {
249 if let Some(wings) = tunnel_map.get(room) {
250 result.tunnel_hints = wings
251 .iter()
252 .filter(|w| *w != &result.wing)
253 .cloned()
254 .collect();
255 }
256 }
257
258 if let Ok(explicit_hints) = db.explicit_tunnel_hints(&result.wing, result.room.as_deref()) {
259 result.tunnel_hints.extend(explicit_hints);
260 }
261 result.tunnel_hints.sort();
262 result.tunnel_hints.dedup();
263 }
264}
265
266fn rrf_merge(
267 vector_results: Vec<SearchResult>,
268 fts_results: Vec<SearchResult>,
269 top_k: usize,
270) -> Vec<SearchResult> {
271 let vector_hits = vector_results
272 .into_iter()
273 .map(|result| (result.drawer_id.clone(), result))
274 .collect::<Vec<_>>();
275 let fts_hits = fts_results
276 .into_iter()
277 .map(|result| (result.drawer_id.clone(), result))
278 .collect::<Vec<_>>();
279
280 reciprocal_rank_fusion(vec![vector_hits, fts_hits], top_k, DEFAULT_RRF_K)
281 .into_iter()
282 .map(|hit| {
283 let mut result = hit.item;
284 result.similarity = hit.score as f32;
285 result
286 })
287 .collect()
288}
289
290pub fn search_by_vector(
291 db: &Database,
292 query_vector: &[f32],
293 route: RouteDecision,
294 top_k: usize,
295) -> Result<Vec<SearchResult>> {
296 search_by_vector_with_filters(db, query_vector, route, &SearchFilters::default(), top_k)
297}
298
299fn search_by_vector_with_filters(
300 db: &Database,
301 query_vector: &[f32],
302 route: RouteDecision,
303 filters: &SearchFilters,
304 top_k: usize,
305) -> Result<Vec<SearchResult>> {
306 if top_k == 0 {
307 return Ok(Vec::new());
308 }
309
310 let applied_wing = route.wing.as_deref();
311 let applied_room = route.room.as_deref();
312 let memory_kind = filters.memory_kind.as_deref();
313 let domain = filters.domain.as_deref();
314 let field = filters.field.as_deref();
315 let tier = filters.tier.as_deref();
316 let status = filters.status.as_deref();
317 let anchor_kind = filters.anchor_kind.as_deref();
318
319 let count_sql = format!(
320 "SELECT COUNT(*) FROM drawers d {}",
321 build_filter_clause("d", 1)
322 );
323 let candidate_count: i64 = db
324 .conn()
325 .query_row(
326 &count_sql,
327 (
328 applied_wing,
329 applied_room,
330 memory_kind,
331 domain,
332 field,
333 tier,
334 status,
335 anchor_kind,
336 ),
337 |row| row.get(0),
338 )
339 .map_err(SearchError::CountCandidateDrawers)?;
340 if candidate_count == 0 {
341 return Ok(Vec::new());
342 }
343 let total_count: i64 = db
344 .conn()
345 .query_row(
346 "SELECT COUNT(*) FROM drawers WHERE deleted_at IS NULL",
347 [],
348 |row| row.get(0),
349 )
350 .map_err(SearchError::CountTotalDrawers)?;
351
352 const SQLITE_VEC_KNN_MAX: i64 = 4096;
356 let knn_k: i64 = total_count.min(SQLITE_VEC_KNN_MAX);
357
358 let query_json =
359 serde_json::to_string(query_vector).map_err(SearchError::SerializeQueryVector)?;
360 let top_k = i64::try_from(top_k).map_err(|_| SearchError::InvalidTopK)?;
361
362 let search_sql = format!(
363 r#"
364 WITH matches AS (
365 SELECT id, distance
366 FROM drawer_vectors
367 WHERE embedding MATCH vec_f32(?1)
368 AND k = ?2
369 )
370 SELECT d.id, d.content, d.wing, d.room, d.source_file,
371 d.memory_kind, d.domain, d.field, d.statement, d.tier, d.status,
372 d.anchor_kind, d.anchor_id, d.parent_anchor_id, d.chunk_index, matches.distance
373 FROM matches
374 JOIN drawers d ON d.id = matches.id
375 {}
376 ORDER BY matches.distance ASC
377 LIMIT ?11
378 "#,
379 build_filter_clause("d", 3)
380 );
381
382 let mut statement = db
383 .conn()
384 .prepare(&search_sql)
385 .map_err(SearchError::PrepareSearch)?;
386 let results = statement
387 .query_map(
388 (
389 query_json.as_str(),
390 knn_k,
391 applied_wing,
392 applied_room,
393 memory_kind,
394 domain,
395 field,
396 tier,
397 status,
398 anchor_kind,
399 top_k,
400 ),
401 |row| {
402 let distance: f64 = row.get(15)?;
403 map_search_result_row(row, &route, (1.0_f64 - distance) as f32)
404 },
405 )
406 .map_err(SearchError::ExecuteSearch)?
407 .collect::<std::result::Result<Vec<_>, _>>()
408 .map_err(SearchError::CollectSearchRows)?;
409
410 Ok(results)
411}
412
413fn search_fts_with_filters(
414 db: &Database,
415 query: &str,
416 route: &RouteDecision,
417 filters: &SearchFilters,
418 limit: usize,
419) -> Result<Vec<SearchResult>> {
420 let Some(match_query) = build_fts_match_query(query) else {
421 return Ok(Vec::new());
422 };
423 let limit = i64::try_from(limit).map_err(|_| SearchError::InvalidTopK)?;
424 let sql = format!(
425 r#"
426 SELECT d.id, d.content, d.wing, d.room, d.source_file,
427 d.memory_kind, d.domain, d.field, d.statement, d.tier, d.status,
428 d.anchor_kind, d.anchor_id, d.parent_anchor_id, d.chunk_index, fts.rank
429 FROM drawers_fts fts
430 JOIN drawers d ON d.rowid = fts.rowid
431 {}
432 AND drawers_fts MATCH ?1
433 ORDER BY fts.rank
434 LIMIT ?10
435 "#,
436 build_filter_clause("d", 2)
437 );
438 let mut statement = db
439 .conn()
440 .prepare(&sql)
441 .map_err(SearchError::PrepareSearch)?;
442 statement
443 .query_map(
444 (
445 match_query.as_str(),
446 route.wing.as_deref(),
447 route.room.as_deref(),
448 filters.memory_kind.as_deref(),
449 filters.domain.as_deref(),
450 filters.field.as_deref(),
451 filters.tier.as_deref(),
452 filters.status.as_deref(),
453 filters.anchor_kind.as_deref(),
454 limit,
455 ),
456 |row| map_search_result_row(row, route, 0.0),
457 )
458 .map_err(SearchError::ExecuteSearch)?
459 .collect::<std::result::Result<Vec<_>, _>>()
460 .map_err(SearchError::CollectSearchRows)
461}
462
463fn map_search_result_row(
464 row: &rusqlite::Row<'_>,
465 route: &RouteDecision,
466 similarity: f32,
467) -> rusqlite::Result<SearchResult> {
468 let drawer_id: String = row.get(0)?;
469 let source_file = row.get::<_, Option<String>>(4)?;
470 Ok(SearchResult {
471 drawer_id: drawer_id.clone(),
472 content: row.get(1)?,
473 wing: row.get(2)?,
474 room: row.get(3)?,
475 source_file: source_file_or_synthetic(&drawer_id, source_file.as_deref()),
476 memory_kind: memory_kind_from_str(&row.get::<_, String>(5)?)?,
477 domain: memory_domain_from_str(&row.get::<_, String>(6)?)?,
478 field: row.get(7)?,
479 statement: row.get(8)?,
480 tier: row
481 .get::<_, Option<String>>(9)?
482 .map(|value| knowledge_tier_from_str(&value))
483 .transpose()?,
484 status: row
485 .get::<_, Option<String>>(10)?
486 .map(|value| knowledge_status_from_str(&value))
487 .transpose()?,
488 anchor_kind: anchor_kind_from_str(&row.get::<_, String>(11)?)?,
489 anchor_id: row.get(12)?,
490 parent_anchor_id: row.get(13)?,
491 similarity,
492 route: route.clone(),
493 chunk_index: row.get(14)?,
494 neighbors: None,
495 tunnel_hints: vec![],
496 })
497}
498
499fn invalid_enum_value(kind: &'static str, value: String) -> rusqlite::Error {
500 rusqlite::Error::FromSqlConversionFailure(
501 0,
502 rusqlite::types::Type::Text,
503 Box::new(std::io::Error::new(
504 std::io::ErrorKind::InvalidData,
505 format!("invalid {kind}: {value}"),
506 )),
507 )
508}
509
510fn memory_kind_from_str(value: &str) -> rusqlite::Result<MemoryKind> {
511 match value {
512 "evidence" => Ok(MemoryKind::Evidence),
513 "knowledge" => Ok(MemoryKind::Knowledge),
514 _ => Err(invalid_enum_value("memory_kind", value.to_string())),
515 }
516}
517
518fn memory_domain_from_str(value: &str) -> rusqlite::Result<MemoryDomain> {
519 match value {
520 "project" => Ok(MemoryDomain::Project),
521 "agent" => Ok(MemoryDomain::Agent),
522 "skill" => Ok(MemoryDomain::Skill),
523 "global" => Ok(MemoryDomain::Global),
524 _ => Err(invalid_enum_value("domain", value.to_string())),
525 }
526}
527
528fn knowledge_tier_from_str(value: &str) -> rusqlite::Result<KnowledgeTier> {
529 match value {
530 "qi" => Ok(KnowledgeTier::Qi),
531 "shu" => Ok(KnowledgeTier::Shu),
532 "dao_ren" => Ok(KnowledgeTier::DaoRen),
533 "dao_tian" => Ok(KnowledgeTier::DaoTian),
534 _ => Err(invalid_enum_value("tier", value.to_string())),
535 }
536}
537
538fn knowledge_status_from_str(value: &str) -> rusqlite::Result<KnowledgeStatus> {
539 match value {
540 "candidate" => Ok(KnowledgeStatus::Candidate),
541 "promoted" => Ok(KnowledgeStatus::Promoted),
542 "canonical" => Ok(KnowledgeStatus::Canonical),
543 "demoted" => Ok(KnowledgeStatus::Demoted),
544 "retired" => Ok(KnowledgeStatus::Retired),
545 _ => Err(invalid_enum_value("status", value.to_string())),
546 }
547}
548
549fn anchor_kind_from_str(value: &str) -> rusqlite::Result<AnchorKind> {
550 match value {
551 "global" => Ok(AnchorKind::Global),
552 "repo" => Ok(AnchorKind::Repo),
553 "worktree" => Ok(AnchorKind::Worktree),
554 _ => Err(invalid_enum_value("anchor_kind", value.to_string())),
555 }
556}
557
558pub fn resolve_route(
559 db: &Database,
560 query: &str,
561 wing: Option<&str>,
562 room: Option<&str>,
563) -> Result<RouteDecision> {
564 if wing.is_some() || room.is_some() {
565 let scope = match (wing, room) {
566 (Some(wing), Some(room)) => format!("{wing}/{room}"),
567 (Some(wing), None) => wing.to_string(),
568 (None, Some(room)) => format!("room={room}"),
569 (None, None) => "global".to_string(),
570 };
571 return Ok(RouteDecision {
572 wing: wing.map(ToOwned::to_owned),
573 room: room.map(ToOwned::to_owned),
574 confidence: 1.0,
575 reason: format!("explicit filters provided: {scope}"),
576 });
577 }
578
579 let taxonomy = db.taxonomy_entries().map_err(SearchError::LoadTaxonomy)?;
580 let route = route::route_query(query, &taxonomy);
581 if route.confidence >= 0.5 {
582 return Ok(route);
583 }
584
585 Ok(RouteDecision {
586 wing: None,
587 room: None,
588 confidence: route.confidence,
589 reason: route.reason,
590 })
591}