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