Skip to main content

llm_kernel/graph/
search.rs

1//! FTS5 full-text search for knowledge graph nodes.
2
3use rusqlite::{Connection, params};
4
5use crate::error::{KernelError, Result};
6
7use super::types::{GraphNode, NODE_COLUMNS_PREFIXED, escape_like, row_to_node};
8
9/// Quote a raw user string as a single FTS5 phrase literal.
10///
11/// Callers pass free-form text (search boxes, LLM-generated hints). Handing that
12/// straight to `MATCH` lets `"`, `*`, `NEAR`, `-` and friends be parsed as query
13/// syntax — a stray quote turns the whole recall into a syntax error. Wrapping in
14/// double quotes (with `"` doubled) makes the input an opaque phrase.
15fn fts_phrase(query: &str) -> String {
16    format!("\"{}\"", query.replace('"', "\"\""))
17}
18
19/// Search nodes using FTS5 MATCH, ranked by BM25 relevance then importance.
20///
21/// The query is escaped as a phrase literal, so arbitrary user text is safe.
22/// A malformed-query error is degraded to an empty result rather than an `Err`:
23/// full-text is one signal among several in [`crate::graph::recall::smart_recall`], and a bad hint must
24/// not take down the whole recall.
25pub fn search_nodes(conn: &Connection, query: &str, limit: usize) -> Result<Vec<GraphNode>> {
26    let sql = format!(
27        "SELECT {NODE_COLUMNS_PREFIXED}
28         FROM nodes n
29         JOIN nodes_fts ON n.rowid = nodes_fts.rowid
30         WHERE nodes_fts MATCH ?1
31         ORDER BY bm25(nodes_fts), n.importance DESC
32         LIMIT ?2"
33    );
34    let mut stmt = conn
35        .prepare(&sql)
36        .map_err(|e| KernelError::Store(e.to_string()))?;
37    let rows = match stmt.query_map(params![fts_phrase(query), limit as i64], row_to_node) {
38        Ok(rows) => rows,
39        // Malformed FTS5 expression — treat as "no lexical matches".
40        Err(_) => return Ok(Vec::new()),
41    };
42    Ok(rows.filter_map(|r| r.ok()).collect())
43}
44
45/// Search nodes across every available lexical backend, de-duplicated by id.
46///
47/// FTS5's `trigram` tokenizer cannot match queries shorter than three
48/// characters, which silently breaks most CJK lookups (a 2-syllable Korean query
49/// like `"매수"` returns nothing even when many nodes contain it). With the
50/// `graph-cjk` feature the substring path in [`crate::graph::cjk::search_nodes_cjk`] covers exactly
51/// that gap, so the union is strictly better than either source alone:
52/// FTS contributes ranked multi-character hits, CJK contributes short and
53/// mid-word ones.
54///
55/// FTS results keep their BM25 order and come first; CJK-only hits follow.
56/// Without `graph-cjk` this is exactly [`search_nodes`].
57pub fn search_nodes_hybrid(conn: &Connection, query: &str, limit: usize) -> Result<Vec<GraphNode>> {
58    let mut out = search_nodes(conn, query, limit)?;
59
60    #[cfg(feature = "graph-cjk")]
61    {
62        // The CJK substring path always runs: FTS and CJK match different query
63        // shapes (trigram needs ≥3 chars; CJK covers short and mid-word hits),
64        // so an FTS-only result can be non-empty yet still miss the CJK-only
65        // matches. De-dup by id and keep FTS's BM25 order first.
66        use std::collections::HashSet;
67        let seen: HashSet<String> = out.iter().map(|n| n.id.clone()).collect();
68        let fresh: Vec<GraphNode> = super::cjk::search_nodes_cjk(conn, query, limit)?
69            .into_iter()
70            .filter(|n| !seen.contains(&n.id))
71            .collect();
72        out.extend(fresh);
73        out.truncate(limit);
74    }
75
76    Ok(out)
77}
78
79/// Dynamic filter query: filter by tag, node_type, and/or project.
80pub fn query_nodes(
81    conn: &Connection,
82    tag: Option<&str>,
83    node_type: Option<&str>,
84    project: Option<&str>,
85    limit: usize,
86) -> Result<Vec<GraphNode>> {
87    let limit = limit.min(200);
88
89    let mut condition_strs: Vec<&str> = vec![];
90    let mut param_vals: Vec<Box<dyn rusqlite::ToSql>> = vec![];
91
92    if let Some(t) = tag {
93        condition_strs.push("(',' || tags || ',' LIKE '%,' || ? || ',%' ESCAPE '\\')");
94        param_vals.push(Box::new(escape_like(t)));
95    }
96    if let Some(nt) = node_type {
97        condition_strs.push("type = ?");
98        param_vals.push(Box::new(nt.to_string()));
99    }
100    if let Some(p) = project {
101        condition_strs.push("(',' || projects || ',' LIKE '%,' || ? || ',%' ESCAPE '\\')");
102        param_vals.push(Box::new(escape_like(p)));
103    }
104
105    let where_clause = if condition_strs.is_empty() {
106        String::new()
107    } else {
108        format!("WHERE {}", condition_strs.join(" AND "))
109    };
110
111    let node_columns = super::types::NODE_COLUMNS;
112    let sql = format!(
113        "SELECT {node_columns} FROM nodes {where_clause} ORDER BY updated DESC LIMIT {}",
114        limit as i64,
115    );
116
117    let mut stmt = conn
118        .prepare(&sql)
119        .map_err(|e| KernelError::Store(e.to_string()))?;
120    let refs: Vec<&dyn rusqlite::ToSql> = param_vals.iter().map(|b| b.as_ref()).collect();
121    let nodes: Vec<GraphNode> = stmt
122        .query_map(refs.as_slice(), row_to_node)
123        .map_err(|e| KernelError::Store(e.to_string()))?
124        .filter_map(|r| r.ok())
125        .collect();
126    Ok(nodes)
127}
128
129/// Ordering for [`query_nodes_ex`].
130#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
131pub enum NodeOrder {
132    /// `created DESC` — newest first. Activates `idx_nodes_created`.
133    #[default]
134    CreatedDesc,
135    /// `created ASC` — oldest first (timeline reconstruction).
136    CreatedAsc,
137    /// `updated DESC`.
138    UpdatedDesc,
139    /// `importance DESC`.
140    ImportanceDesc,
141}
142
143/// Structured node query with paging, time range, and ordering.
144///
145/// `#[non_exhaustive]` + `Default` lets callers add filters in future releases
146/// without breaking struct-literal construction (`NodeQuery { limit: 50, ..Default::default() }`).
147#[derive(Debug, Clone)]
148#[non_exhaustive]
149pub struct NodeQuery {
150    /// Tag exact-match (CSV membership test). Use for `symbol`-scoped queries.
151    pub tag: Option<String>,
152    /// Node type filter (`decision`, `stock`, ...).
153    pub node_type: Option<String>,
154    /// Project scope filter.
155    pub project: Option<String>,
156    /// `created >=` (RFC3339/ISO8601). Uses `idx_nodes_created`.
157    pub since: Option<String>,
158    /// `created <` (RFC3339/ISO8601).
159    pub until: Option<String>,
160    /// Result ordering.
161    pub order_by: NodeOrder,
162    /// Max rows. Capped at 200 to bound work. Defaults to 50.
163    pub limit: usize,
164    /// Skip this many rows.
165    pub offset: usize,
166}
167
168impl Default for NodeQuery {
169    fn default() -> Self {
170        Self {
171            tag: None,
172            node_type: None,
173            project: None,
174            since: None,
175            until: None,
176            order_by: NodeOrder::default(),
177            limit: 50,
178            offset: 0,
179        }
180    }
181}
182
183impl NodeQuery {
184    fn order_clause(&self) -> &'static str {
185        match self.order_by {
186            NodeOrder::CreatedDesc => "created DESC",
187            NodeOrder::CreatedAsc => "created ASC",
188            NodeOrder::UpdatedDesc => "updated DESC",
189            NodeOrder::ImportanceDesc => "importance DESC",
190        }
191    }
192}
193
194/// Dynamic filter query: tag, node_type, project, time range, paging, ordering.
195///
196/// `limit` is capped at 200 server-side but supports `offset` for true paging
197/// without materializing the full table client-side.
198pub fn query_nodes_ex(conn: &Connection, q: &NodeQuery) -> Result<Vec<GraphNode>> {
199    let limit = q.limit.min(200) as i64;
200    let offset = q.offset as i64;
201
202    let mut condition_strs: Vec<&str> = vec![];
203    let mut param_vals: Vec<Box<dyn rusqlite::ToSql>> = vec![];
204
205    if let Some(t) = &q.tag {
206        condition_strs.push("(',' || tags || ',' LIKE '%,' || ? || ',%' ESCAPE '\\')");
207        param_vals.push(Box::new(escape_like(t)));
208    }
209    if let Some(nt) = &q.node_type {
210        condition_strs.push("type = ?");
211        param_vals.push(Box::new(nt.clone()));
212    }
213    if let Some(p) = &q.project {
214        condition_strs.push("(',' || projects || ',' LIKE '%,' || ? || ',%' ESCAPE '\\')");
215        param_vals.push(Box::new(escape_like(p)));
216    }
217    if let Some(s) = &q.since {
218        condition_strs.push("created >= ?");
219        param_vals.push(Box::new(s.clone()));
220    }
221    if let Some(u) = &q.until {
222        condition_strs.push("created < ?");
223        param_vals.push(Box::new(u.clone()));
224    }
225
226    let where_clause = if condition_strs.is_empty() {
227        String::new()
228    } else {
229        format!("WHERE {}", condition_strs.join(" AND "))
230    };
231    let order = q.order_clause();
232    let node_columns = super::types::NODE_COLUMNS;
233    let sql = format!(
234        "SELECT {node_columns} FROM nodes {where_clause} ORDER BY {order} LIMIT ? OFFSET ?"
235    );
236
237    param_vals.push(Box::new(limit));
238    param_vals.push(Box::new(offset));
239
240    let mut stmt = conn
241        .prepare(&sql)
242        .map_err(|e| KernelError::Store(e.to_string()))?;
243    let refs: Vec<&dyn rusqlite::ToSql> = param_vals.iter().map(|b| b.as_ref()).collect();
244    let nodes: Vec<GraphNode> = stmt
245        .query_map(refs.as_slice(), row_to_node)
246        .map_err(|e| KernelError::Store(e.to_string()))?
247        .filter_map(|r| r.ok())
248        .collect();
249    Ok(nodes)
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255    use crate::graph::schema::init_graph_schema;
256    use crate::graph::store::upsert_node;
257    use crate::graph::types::GraphNode;
258    use rusqlite::Connection;
259
260    fn mem_db() -> Connection {
261        let conn = Connection::open_in_memory().unwrap();
262        init_graph_schema(&conn).unwrap();
263        conn
264    }
265
266    fn test_node(id: &str, title: &str, body: &str, tags: Vec<&str>) -> GraphNode {
267        GraphNode {
268            id: id.to_string(),
269            node_type: "concept".to_string(),
270            title: title.to_string(),
271            body: body.to_string(),
272            tags: tags.into_iter().map(|s| s.to_string()).collect(),
273            projects: vec![],
274            agents: vec![],
275            created: "2026-01-01T00:00:00Z".to_string(),
276            updated: "2026-01-01T00:00:00Z".to_string(),
277            importance: 0.7,
278            access_count: 0,
279            accessed_at: String::new(),
280        }
281    }
282
283    #[test]
284    fn search_finds_by_title() {
285        let conn = mem_db();
286        upsert_node(
287            &conn,
288            &test_node("n1", "Rust ownership", "borrow checker", vec![]),
289        )
290        .unwrap();
291        upsert_node(&conn, &test_node("n2", "Python GIL", "global lock", vec![])).unwrap();
292        let results = search_nodes(&conn, "Rust", 10).unwrap();
293        assert_eq!(results.len(), 1);
294        assert_eq!(results[0].id, "n1");
295    }
296
297    #[test]
298    fn search_finds_by_body() {
299        let conn = mem_db();
300        upsert_node(
301            &conn,
302            &test_node("n1", "Title", "machine learning models", vec![]),
303        )
304        .unwrap();
305        let results = search_nodes(&conn, "machine learning", 10).unwrap();
306        assert_eq!(results.len(), 1);
307    }
308
309    #[test]
310    fn query_filters_by_tag() {
311        let conn = mem_db();
312        upsert_node(&conn, &test_node("n1", "A", "body", vec!["rust", "async"])).unwrap();
313        upsert_node(&conn, &test_node("n2", "B", "body", vec!["python"])).unwrap();
314        let results = query_nodes(&conn, Some("rust"), None, None, 10).unwrap();
315        assert_eq!(results.len(), 1);
316        assert_eq!(results[0].id, "n1");
317    }
318
319    #[test]
320    fn query_filters_by_type() {
321        let conn = mem_db();
322        let mut n1 = test_node("n1", "A", "body", vec![]);
323        n1.node_type = "decision".to_string();
324        upsert_node(&conn, &n1).unwrap();
325        let results = query_nodes(&conn, None, Some("decision"), None, 10).unwrap();
326        assert_eq!(results.len(), 1);
327    }
328
329    #[test]
330    fn query_tag_wildcard_is_escaped() {
331        let conn = mem_db();
332        upsert_node(&conn, &test_node("n1", "A", "body", vec!["rust"])).unwrap();
333        // "ru%t" would match "rust" as a LIKE wildcard, but escape_like prevents it
334        let results = query_nodes(&conn, Some("ru%t"), None, None, 10).unwrap();
335        assert!(results.is_empty());
336    }
337
338    #[test]
339    fn query_project_wildcard_is_escaped() {
340        let conn = mem_db();
341        let mut n1 = test_node("n1", "A", "body", vec![]);
342        n1.projects = vec!["myproj".to_string()];
343        upsert_node(&conn, &n1).unwrap();
344        let results = query_nodes(&conn, None, None, Some("my%"), 10).unwrap();
345        assert!(results.is_empty());
346    }
347
348    #[test]
349    fn fts_query_with_quotes_does_not_error() {
350        // A raw `"` used to be parsed as FTS5 syntax and blew up the whole recall.
351        let conn = mem_db();
352        upsert_node(&conn, &test_node("n1", "quoted", "body", vec![])).unwrap();
353        assert!(search_nodes(&conn, "say \"hello\"", 10).is_ok());
354        assert!(search_nodes(&conn, "trailing *", 10).is_ok());
355        assert!(search_nodes(&conn, "NEAR OR AND", 10).is_ok());
356    }
357
358    #[cfg(feature = "graph-cjk")]
359    #[test]
360    fn hybrid_matches_short_korean_that_trigram_misses() {
361        // Regression baseline from the TradingAgentOS KB: a 2-syllable Korean
362        // query is invisible to the trigram tokenizer but present in the corpus.
363        let conn = mem_db();
364        upsert_node(
365            &conn,
366            &test_node(
367                "d1",
368                "SK하이닉스 판정",
369                "매수 의견을 유지한다",
370                vec!["hold"],
371            ),
372        )
373        .unwrap();
374
375        // trigram alone: no hit for the 2-char query.
376        assert!(search_nodes(&conn, "매수", 10).unwrap().is_empty());
377        // hybrid: CJK substring path finds it.
378        assert_eq!(search_nodes_hybrid(&conn, "매수", 10).unwrap().len(), 1);
379        assert_eq!(search_nodes_hybrid(&conn, "SK", 10).unwrap().len(), 1);
380    }
381
382    #[cfg(feature = "graph-cjk")]
383    #[test]
384    fn hybrid_negative_control_absent_term_stays_empty() {
385        // Guards against a "LIKE matches anything" regression: a term that is
386        // genuinely not in the corpus must still return nothing.
387        let conn = mem_db();
388        upsert_node(&conn, &test_node("d1", "SK하이닉스", "매수 의견", vec![])).unwrap();
389        assert!(search_nodes_hybrid(&conn, "반도체", 10).unwrap().is_empty());
390        assert!(
391            search_nodes_hybrid(&conn, "존재하지않는단어", 10)
392                .unwrap()
393                .is_empty()
394        );
395    }
396
397    #[cfg(feature = "graph-cjk")]
398    #[test]
399    fn hybrid_dedups_nodes_found_by_both_paths() {
400        let conn = mem_db();
401        upsert_node(&conn, &test_node("d1", "삼성전자", "반도체 실적", vec![])).unwrap();
402        // "삼성전자" is long enough for trigram AND matches the CJK substring path.
403        assert_eq!(search_nodes_hybrid(&conn, "삼성전자", 10).unwrap().len(), 1);
404    }
405
406    // ── query_nodes_ex (NodeQuery) coverage ───────────────────────────────
407    // AC1: the paging/time-range/tag paths of query_nodes_ex had no direct
408    // tests — only the legacy query_nodes tag tests existed. These exercise the
409    // new structured API directly.
410
411    fn test_node_dated(id: &str, created: &str, tags: Vec<&str>) -> GraphNode {
412        GraphNode {
413            id: id.to_string(),
414            node_type: "concept".to_string(),
415            title: id.to_string(),
416            body: String::new(),
417            tags: tags.into_iter().map(|s| s.to_string()).collect(),
418            projects: vec![],
419            agents: vec![],
420            created: created.to_string(),
421            updated: created.to_string(),
422            importance: 0.7,
423            access_count: 0,
424            accessed_at: String::new(),
425        }
426    }
427
428    #[test]
429    fn query_ex_paginates_with_offset() {
430        let conn = mem_db();
431        // 5 nodes, ordered by created DESC by default.
432        for i in 1..=5 {
433            upsert_node(
434                &conn,
435                &test_node_dated(&format!("n{i}"), &format!("2026-01-0{i}T00:00:00Z"), vec![]),
436            )
437            .unwrap();
438        }
439        let page1 = query_nodes_ex(
440            &conn,
441            &NodeQuery {
442                limit: 2,
443                offset: 0,
444                ..Default::default()
445            },
446        )
447        .unwrap();
448        let page2 = query_nodes_ex(
449            &conn,
450            &NodeQuery {
451                limit: 2,
452                offset: 2,
453                ..Default::default()
454            },
455        )
456        .unwrap();
457        assert_eq!(page1.len(), 2);
458        assert_eq!(page2.len(), 2);
459        // created DESC → n5..n1; page1 = {n5,n4}, page2 = {n3,n2}, no overlap.
460        let p1: Vec<&str> = page1.iter().map(|n| n.id.as_str()).collect();
461        let p2: Vec<&str> = page2.iter().map(|n| n.id.as_str()).collect();
462        assert_eq!(p1, vec!["n5", "n4"]);
463        assert_eq!(p2, vec!["n3", "n2"]);
464        assert!(p1.iter().all(|id| !p2.contains(id)));
465    }
466
467    #[test]
468    fn query_ex_filters_by_time_range() {
469        let conn = mem_db();
470        upsert_node(
471            &conn,
472            &test_node_dated("old", "2025-06-01T00:00:00Z", vec![]),
473        )
474        .unwrap();
475        upsert_node(
476            &conn,
477            &test_node_dated("mid", "2026-01-01T00:00:00Z", vec![]),
478        )
479        .unwrap();
480        upsert_node(
481            &conn,
482            &test_node_dated("new", "2026-06-01T00:00:00Z", vec![]),
483        )
484        .unwrap();
485
486        let in_window = query_nodes_ex(
487            &conn,
488            &NodeQuery {
489                since: Some("2026-01-01T00:00:00Z".to_string()),
490                until: Some("2026-06-01T00:00:00Z".to_string()),
491                limit: 50,
492                ..Default::default()
493            },
494        )
495        .unwrap();
496        let ids: Vec<&str> = in_window.iter().map(|n| n.id.as_str()).collect();
497        // since is inclusive (>=), until exclusive (<): only "mid" qualifies.
498        assert_eq!(ids, vec!["mid"]);
499    }
500
501    #[test]
502    fn query_ex_filters_by_tag() {
503        let conn = mem_db();
504        upsert_node(
505            &conn,
506            &test_node_dated("n1", "2026-01-01T00:00:00Z", vec!["AAPL"]),
507        )
508        .unwrap();
509        upsert_node(
510            &conn,
511            &test_node_dated("n2", "2026-01-02T00:00:00Z", vec!["MSFT"]),
512        )
513        .unwrap();
514        let results = query_nodes_ex(
515            &conn,
516            &NodeQuery {
517                tag: Some("AAPL".to_string()),
518                limit: 50,
519                ..Default::default()
520            },
521        )
522        .unwrap();
523        let ids: Vec<&str> = results.iter().map(|n| n.id.as_str()).collect();
524        assert_eq!(ids, vec!["n1"]);
525    }
526
527    #[test]
528    fn query_ex_respects_limit_cap() {
529        let conn = mem_db();
530        upsert_node(
531            &conn,
532            &test_node_dated("n1", "2026-01-01T00:00:00Z", vec![]),
533        )
534        .unwrap();
535        // Request 10_000 rows — server-side cap must clamp to 200.
536        let results = query_nodes_ex(
537            &conn,
538            &NodeQuery {
539                limit: 10_000,
540                ..Default::default()
541            },
542        )
543        .unwrap();
544        assert!(results.len() <= 200);
545        assert_eq!(results.len(), 1); // only one node exists
546    }
547}