Skip to main content

mempal_runtime/search/
mod.rs

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    // Hybrid search: vector + BM25, merged via RRF
189    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: for each result, check if its room exists in other wings
201    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
230/// For each search result, check if its room appears in other wings (tunnel).
231/// If so, add the other wing names as tunnel_hints.
232fn 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    // Build room → other-wings map
242    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    // sqlite-vec vec0 MATCH ... k = ? has a hard upper bound of 4096.
353    // Clamp to stay under the limit; BM25 side of the hybrid rerank still
354    // covers candidates that fall outside the KNN top-4096 by vector distance.
355    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}