Skip to main content

engram_core/
search_handler.rs

1use std::collections::HashMap;
2use std::sync::Arc;
3
4use serde::Deserialize;
5use serde_json::{Value, json};
6
7use engram_router::Mode;
8
9use crate::config::SearchConfig;
10use crate::error::CoreError;
11use crate::indexes::instrumentation::ReaderTracker;
12use crate::lock_helpers;
13use crate::server::ServerState;
14use crate::timestamp::current_utc_timestamp;
15
16const MAX_QUERY_LENGTH: usize = 5_000;
17
18#[derive(Deserialize)]
19#[allow(dead_code)]
20struct SearchParams {
21    query: String,
22    limit: Option<usize>,
23    mode: Option<String>,
24    project: Option<String>,
25    tags: Option<Vec<String>>,
26}
27
28pub async fn handle(state: &Arc<ServerState>, params: Value) -> Result<Value, CoreError> {
29    let parsed: SearchParams = serde_json::from_value(params)
30        .map_err(|error| CoreError::DispatchError(error.to_string()))?;
31    if parsed.query.len() > MAX_QUERY_LENGTH {
32        return Err(CoreError::DispatchError(format!(
33            "query exceeds maximum length of {MAX_QUERY_LENGTH} bytes"
34        )));
35    }
36    let detected_mode = resolve_mode(&parsed);
37    let top_k = resolve_top_k(&parsed, state, detected_mode);
38    let query_embedding = embed_query(state, &parsed.query).await?;
39    let vector_results = search_vector_index(state, &query_embedding, top_k).await?;
40    let sparse_results = search_fts(state, &parsed.query, top_k).await?;
41    let merged = merge_results(&vector_results, &sparse_results, &state.config.search);
42    let limited = limit_results(merged, top_k);
43    let memories = load_memories(state, &limited).await?;
44    let filtered = filter_by_tags(memories, &parsed.tags);
45    Ok(json!(filtered))
46}
47
48fn resolve_mode(params: &SearchParams) -> Mode {
49    match &params.mode {
50        Some(mode_string) => {
51            Mode::parse(mode_string).unwrap_or_else(|_| Mode::detect(&params.query))
52        }
53        None => Mode::detect(&params.query),
54    }
55}
56
57fn resolve_top_k(params: &SearchParams, state: &Arc<ServerState>, mode: Mode) -> usize {
58    if let Some(limit) = params.limit {
59        return limit;
60    }
61    let router = lock_helpers::lock_router(state);
62    let decision = router.decide(mode, 0.5);
63    decision.top_k
64}
65
66async fn embed_query(state: &Arc<ServerState>, query: &str) -> Result<Vec<f32>, CoreError> {
67    let query_owned = query.to_string();
68    let state_clone = Arc::clone(state);
69    tokio::task::spawn_blocking(move || {
70        let provider = state_clone.embedding_provider.as_ref();
71        let text_gen_ref = state_clone
72            .text_generator
73            .as_deref()
74            .map(|generator| generator as &dyn engram_llm_client::TextGenerator);
75        state_clone
76            .embedder
77            .embed_query(&query_owned, provider, text_gen_ref)
78            .map_err(|error| {
79                CoreError::Api(engram_llm_client::ApiError::EmbeddingApiUnavailable(
80                    error.to_string(),
81                ))
82            })
83    })
84    .await
85    .map_err(|error| CoreError::SocketError(error.to_string()))?
86}
87
88async fn search_vector_index(
89    state: &Arc<ServerState>,
90    query_embedding: &[f32],
91    top_k: usize,
92) -> Result<Vec<(String, f32)>, CoreError> {
93    let embedding_owned = query_embedding.to_vec();
94    let state_clone = Arc::clone(state);
95    tokio::task::spawn_blocking(move || {
96        let _tracker = ReaderTracker::new();
97        let indexes = lock_helpers::read_indexes(&state_clone);
98        let raw_results = indexes
99            .search(&embedding_owned, top_k)
100            .map_err(CoreError::Hnsw)?;
101        let resolved: Vec<(String, f32)> = raw_results
102            .into_iter()
103            .filter_map(|(node_id, score)| {
104                indexes
105                    .resolve_node_id(node_id)
106                    .map(|memory_id| (memory_id.to_string(), score))
107            })
108            .collect();
109        Ok(resolved)
110    })
111    .await
112    .map_err(|error| CoreError::SocketError(error.to_string()))?
113}
114
115async fn search_fts(
116    state: &Arc<ServerState>,
117    query: &str,
118    top_k: usize,
119) -> Result<Vec<(String, f64)>, CoreError> {
120    let query_owned = query.to_string();
121    let state_clone = Arc::clone(state);
122    tokio::task::spawn_blocking(move || {
123        let database = lock_helpers::lock_db(&state_clone);
124        let fts_results = database.search_fts(&query_owned, top_k)?;
125        let scored: Vec<(String, f64)> = fts_results
126            .into_iter()
127            .map(|fts| (fts.memory.id, fts.rank.abs()))
128            .collect();
129        Ok::<_, CoreError>(scored)
130    })
131    .await
132    .map_err(|error| CoreError::SocketError(error.to_string()))?
133}
134
135/// Fuses dense-vector and full-text hits via weighted Reciprocal Rank Fusion.
136/// Both input slices arrive sorted best-first (HNSW by descending similarity,
137/// FTS5 by ascending rank), so only each hit's rank position contributes —
138/// the raw similarity/rank scores are intentionally ignored. A memory present
139/// in both lists accumulates both weighted reciprocal-rank terms.
140fn merge_results(
141    vector_results: &[(String, f32)],
142    sparse_results: &[(String, f64)],
143    search: &SearchConfig,
144) -> Vec<(String, f64)> {
145    let k = search.rrf_k as f64;
146    let mut combined: HashMap<String, f64> = HashMap::new();
147    for (rank, (memory_id, _)) in vector_results.iter().enumerate() {
148        *combined.entry(memory_id.clone()).or_insert(0.0) +=
149            search.vector_weight / (k + (rank + 1) as f64);
150    }
151    for (rank, (memory_id, _)) in sparse_results.iter().enumerate() {
152        *combined.entry(memory_id.clone()).or_insert(0.0) +=
153            search.sparse_weight / (k + (rank + 1) as f64);
154    }
155    let mut results: Vec<(String, f64)> = combined.into_iter().collect();
156    results.sort_by(|a, b| b.1.total_cmp(&a.1));
157    results
158}
159
160fn limit_results(results: Vec<(String, f64)>, top_k: usize) -> Vec<(String, f64)> {
161    results.into_iter().take(top_k).collect()
162}
163
164fn filter_by_tags(memories: Vec<Value>, required_tags: &Option<Vec<String>>) -> Vec<Value> {
165    let required = match required_tags {
166        Some(tags) if !tags.is_empty() => tags,
167        _ => return memories,
168    };
169    let required_lower: Vec<String> = required.iter().map(|tag| tag.to_lowercase()).collect();
170    memories
171        .into_iter()
172        .filter(|memory| memory_has_all_tags(memory, &required_lower))
173        .collect()
174}
175
176fn memory_has_all_tags(memory: &Value, required_lower: &[String]) -> bool {
177    let raw = match memory.get("tags").and_then(Value::as_str) {
178        Some(value) if !value.is_empty() => value,
179        _ => return false,
180    };
181    let stored_tags: Vec<String> = match serde_json::from_str(raw) {
182        Ok(parsed) => parsed,
183        Err(_) => return false,
184    };
185    let stored_lower: Vec<String> = stored_tags.iter().map(|tag| tag.to_lowercase()).collect();
186    required_lower
187        .iter()
188        .all(|required_tag| stored_lower.contains(required_tag))
189}
190
191async fn load_memories(
192    state: &Arc<ServerState>,
193    scored_results: &[(String, f64)],
194) -> Result<Vec<Value>, CoreError> {
195    let memory_ids: Vec<String> = scored_results.iter().map(|(id, _)| id.clone()).collect();
196    let scores: HashMap<String, f64> = scored_results.iter().cloned().collect();
197    let state_clone = Arc::clone(state);
198    tokio::task::spawn_blocking(move || {
199        let database = lock_helpers::lock_db(&state_clone);
200        let timestamp = current_utc_timestamp();
201        let mut results = Vec::new();
202        for memory_id in &memory_ids {
203            let memory = match database.get_memory(memory_id) {
204                Ok(m) => m,
205                Err(_) => continue,
206            };
207            let _ = database.track_search(memory_id, &timestamp);
208            let score = scores.get(memory_id.as_str()).copied().unwrap_or(0.0);
209            results.push(json!({
210                "id": memory.id,
211                "score": score,
212                "context": memory.context,
213                "action": memory.action,
214                "result": memory.result,
215                "memory_type": memory.memory_type,
216                "tags": memory.tags,
217            }));
218        }
219        Ok::<Vec<Value>, CoreError>(results)
220    })
221    .await
222    .map_err(|error| CoreError::SocketError(error.to_string()))?
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    fn vector_hit(id: &str) -> (String, f32) {
230        (id.to_string(), 0.0)
231    }
232
233    fn sparse_hit(id: &str) -> (String, f64) {
234        (id.to_string(), 0.0)
235    }
236
237    #[test]
238    fn merge_ranks_by_position_not_score() {
239        let vector = vec![vector_hit("a"), vector_hit("b")];
240        let merged = merge_results(&vector, &[], &SearchConfig::default());
241        assert_eq!(merged[0].0, "a");
242        assert_eq!(merged[1].0, "b");
243        assert!(merged[0].1 > merged[1].1);
244    }
245
246    #[test]
247    fn merge_sums_contributions_for_shared_id() {
248        let search = SearchConfig::default();
249        let vector = vec![vector_hit("a"), vector_hit("b")];
250        let sparse = vec![sparse_hit("a"), sparse_hit("c")];
251        let merged = merge_results(&vector, &sparse, &search);
252
253        let k = search.rrf_k as f64;
254        let score_a = merged.iter().find(|(id, _)| id == "a").unwrap().1;
255        let expected_a = search.vector_weight / (k + 1.0) + search.sparse_weight / (k + 1.0);
256        assert!((score_a - expected_a).abs() < 1e-12);
257
258        let score_b = merged.iter().find(|(id, _)| id == "b").unwrap().1;
259        assert!(score_a > score_b);
260    }
261
262    // Asymmetric weights must drive the final ranking, not just rank position.
263    // Construction: X is rank-1 in the dense-vector list and absent from sparse;
264    // Y is rank-1 in sparse and far down the vector list (rank-9). With heavy
265    // vector weighting (0.9 / 0.1) the dense source dominates and X leads; the
266    // symmetric default (0.7 / 0.3) lets Y's strong sparse hit win instead, so
267    // the two configs produce opposite orderings.
268    //
269    // Exact RRF (k = 60), heavy-vector 0.9 / 0.1:
270    //   X = 0.9 / (60 + 1)              = 0.014754098360655738
271    //   Y = 0.9 / (60 + 9) + 0.1/(60+1) = 0.014682822523164649  → X > Y
272    // Symmetric default 0.7 / 0.3:
273    //   X = 0.7 / (60 + 1)              = 0.011475409836065573
274    //   Y = 0.7 / (60 + 9) + 0.3/(60+1) = 0.015062960323117129  → Y > X
275    //
276    // Y sits at vector rank-9 because, sharing both lists, it accrues the heavy
277    // vector term too; only past rank-8 does X's single rank-1 vector hit
278    // overtake Y's rank-9 vector hit plus its rank-1 sparse hit at 0.9 / 0.1.
279    #[test]
280    fn asymmetric_weights_drive_final_ranking() {
281        let vector = vec![
282            vector_hit("x"),
283            vector_hit("v2"),
284            vector_hit("v3"),
285            vector_hit("v4"),
286            vector_hit("v5"),
287            vector_hit("v6"),
288            vector_hit("v7"),
289            vector_hit("v8"),
290            vector_hit("y"),
291        ];
292        let sparse = vec![sparse_hit("y")];
293
294        let heavy_vector = SearchConfig {
295            rrf_k: 60,
296            vector_weight: 0.9,
297            sparse_weight: 0.1,
298        };
299        let merged = merge_results(&vector, &sparse, &heavy_vector);
300
301        let score_x = merged.iter().find(|(id, _)| id == "x").unwrap().1;
302        let score_y = merged.iter().find(|(id, _)| id == "y").unwrap().1;
303        let expected_x = 0.9 / 61.0;
304        let expected_y = 0.9 / 69.0 + 0.1 / 61.0;
305        assert!((score_x - expected_x).abs() < 1e-12);
306        assert!((score_y - expected_y).abs() < 1e-12);
307        assert!(
308            score_x > score_y,
309            "heavy vector weighting must let the vector rank-1 id win: x={score_x}, y={score_y}"
310        );
311        let x_position = merged.iter().position(|(id, _)| id == "x").unwrap();
312        let y_position = merged.iter().position(|(id, _)| id == "y").unwrap();
313        assert!(x_position < y_position, "x must outrank y under 0.9 / 0.1");
314
315        let symmetric = SearchConfig::default();
316        let merged_symmetric = merge_results(&vector, &sparse, &symmetric);
317        let symmetric_x = merged_symmetric.iter().find(|(id, _)| id == "x").unwrap().1;
318        let symmetric_y = merged_symmetric.iter().find(|(id, _)| id == "y").unwrap().1;
319        assert!(
320            symmetric_y > symmetric_x,
321            "the symmetric default must produce the opposite order: x={symmetric_x}, y={symmetric_y}"
322        );
323    }
324
325    #[test]
326    fn merge_empty_inputs_yield_empty() {
327        let merged = merge_results(&[], &[], &SearchConfig::default());
328        assert!(merged.is_empty());
329    }
330
331    #[test]
332    fn merge_scores_sparse_only_doc_absent_from_vector() {
333        let search = SearchConfig::default();
334        let sparse = vec![sparse_hit("only_sparse")];
335        let merged = merge_results(&[], &sparse, &search);
336
337        let k = search.rrf_k as f64;
338        assert_eq!(merged.len(), 1);
339        assert_eq!(merged[0].0, "only_sparse");
340        let expected = search.sparse_weight / (k + 1.0);
341        assert!((merged[0].1 - expected).abs() < 1e-12);
342    }
343}