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            ..Default::default()
281        }
282    }
283
284    #[test]
285    fn search_finds_by_title() {
286        let conn = mem_db();
287        upsert_node(
288            &conn,
289            &test_node("n1", "Rust ownership", "borrow checker", vec![]),
290        )
291        .unwrap();
292        upsert_node(&conn, &test_node("n2", "Python GIL", "global lock", vec![])).unwrap();
293        let results = search_nodes(&conn, "Rust", 10).unwrap();
294        assert_eq!(results.len(), 1);
295        assert_eq!(results[0].id, "n1");
296    }
297
298    #[test]
299    fn search_finds_by_body() {
300        let conn = mem_db();
301        upsert_node(
302            &conn,
303            &test_node("n1", "Title", "machine learning models", vec![]),
304        )
305        .unwrap();
306        let results = search_nodes(&conn, "machine learning", 10).unwrap();
307        assert_eq!(results.len(), 1);
308    }
309
310    #[test]
311    fn query_filters_by_tag() {
312        let conn = mem_db();
313        upsert_node(&conn, &test_node("n1", "A", "body", vec!["rust", "async"])).unwrap();
314        upsert_node(&conn, &test_node("n2", "B", "body", vec!["python"])).unwrap();
315        let results = query_nodes(&conn, Some("rust"), None, None, 10).unwrap();
316        assert_eq!(results.len(), 1);
317        assert_eq!(results[0].id, "n1");
318    }
319
320    #[test]
321    fn query_filters_by_type() {
322        let conn = mem_db();
323        let mut n1 = test_node("n1", "A", "body", vec![]);
324        n1.node_type = "decision".to_string();
325        upsert_node(&conn, &n1).unwrap();
326        let results = query_nodes(&conn, None, Some("decision"), None, 10).unwrap();
327        assert_eq!(results.len(), 1);
328    }
329
330    #[test]
331    fn query_tag_wildcard_is_escaped() {
332        let conn = mem_db();
333        upsert_node(&conn, &test_node("n1", "A", "body", vec!["rust"])).unwrap();
334        // "ru%t" would match "rust" as a LIKE wildcard, but escape_like prevents it
335        let results = query_nodes(&conn, Some("ru%t"), None, None, 10).unwrap();
336        assert!(results.is_empty());
337    }
338
339    #[test]
340    fn query_project_wildcard_is_escaped() {
341        let conn = mem_db();
342        let mut n1 = test_node("n1", "A", "body", vec![]);
343        n1.projects = vec!["myproj".to_string()];
344        upsert_node(&conn, &n1).unwrap();
345        let results = query_nodes(&conn, None, None, Some("my%"), 10).unwrap();
346        assert!(results.is_empty());
347    }
348
349    #[test]
350    fn fts_query_with_quotes_does_not_error() {
351        // A raw `"` used to be parsed as FTS5 syntax and blew up the whole recall.
352        let conn = mem_db();
353        upsert_node(&conn, &test_node("n1", "quoted", "body", vec![])).unwrap();
354        assert!(search_nodes(&conn, "say \"hello\"", 10).is_ok());
355        assert!(search_nodes(&conn, "trailing *", 10).is_ok());
356        assert!(search_nodes(&conn, "NEAR OR AND", 10).is_ok());
357    }
358
359    #[cfg(feature = "graph-cjk")]
360    #[test]
361    fn hybrid_matches_short_korean_that_trigram_misses() {
362        // Regression baseline from the TradingAgentOS KB: a 2-syllable Korean
363        // query is invisible to the trigram tokenizer but present in the corpus.
364        let conn = mem_db();
365        upsert_node(
366            &conn,
367            &test_node(
368                "d1",
369                "SK하이닉스 판정",
370                "매수 의견을 유지한다",
371                vec!["hold"],
372            ),
373        )
374        .unwrap();
375
376        // trigram alone: no hit for the 2-char query.
377        assert!(search_nodes(&conn, "매수", 10).unwrap().is_empty());
378        // hybrid: CJK substring path finds it.
379        assert_eq!(search_nodes_hybrid(&conn, "매수", 10).unwrap().len(), 1);
380        assert_eq!(search_nodes_hybrid(&conn, "SK", 10).unwrap().len(), 1);
381    }
382
383    #[cfg(feature = "graph-cjk")]
384    #[test]
385    fn hybrid_negative_control_absent_term_stays_empty() {
386        // Guards against a "LIKE matches anything" regression: a term that is
387        // genuinely not in the corpus must still return nothing.
388        let conn = mem_db();
389        upsert_node(&conn, &test_node("d1", "SK하이닉스", "매수 의견", vec![])).unwrap();
390        assert!(search_nodes_hybrid(&conn, "반도체", 10).unwrap().is_empty());
391        assert!(
392            search_nodes_hybrid(&conn, "존재하지않는단어", 10)
393                .unwrap()
394                .is_empty()
395        );
396    }
397
398    #[cfg(feature = "graph-cjk")]
399    #[test]
400    fn hybrid_dedups_nodes_found_by_both_paths() {
401        let conn = mem_db();
402        upsert_node(&conn, &test_node("d1", "삼성전자", "반도체 실적", vec![])).unwrap();
403        // "삼성전자" is long enough for trigram AND matches the CJK substring path.
404        assert_eq!(search_nodes_hybrid(&conn, "삼성전자", 10).unwrap().len(), 1);
405    }
406
407    // ── query_nodes_ex (NodeQuery) coverage ───────────────────────────────
408    // AC1: the paging/time-range/tag paths of query_nodes_ex had no direct
409    // tests — only the legacy query_nodes tag tests existed. These exercise the
410    // new structured API directly.
411
412    fn test_node_dated(id: &str, created: &str, tags: Vec<&str>) -> GraphNode {
413        GraphNode {
414            id: id.to_string(),
415            node_type: "concept".to_string(),
416            title: id.to_string(),
417            body: String::new(),
418            tags: tags.into_iter().map(|s| s.to_string()).collect(),
419            projects: vec![],
420            agents: vec![],
421            created: created.to_string(),
422            updated: created.to_string(),
423            importance: 0.7,
424            access_count: 0,
425            accessed_at: String::new(),
426            ..Default::default()
427        }
428    }
429
430    #[test]
431    fn query_ex_paginates_with_offset() {
432        let conn = mem_db();
433        // 5 nodes, ordered by created DESC by default.
434        for i in 1..=5 {
435            upsert_node(
436                &conn,
437                &test_node_dated(&format!("n{i}"), &format!("2026-01-0{i}T00:00:00Z"), vec![]),
438            )
439            .unwrap();
440        }
441        let page1 = query_nodes_ex(
442            &conn,
443            &NodeQuery {
444                limit: 2,
445                offset: 0,
446                ..Default::default()
447            },
448        )
449        .unwrap();
450        let page2 = query_nodes_ex(
451            &conn,
452            &NodeQuery {
453                limit: 2,
454                offset: 2,
455                ..Default::default()
456            },
457        )
458        .unwrap();
459        assert_eq!(page1.len(), 2);
460        assert_eq!(page2.len(), 2);
461        // created DESC → n5..n1; page1 = {n5,n4}, page2 = {n3,n2}, no overlap.
462        let p1: Vec<&str> = page1.iter().map(|n| n.id.as_str()).collect();
463        let p2: Vec<&str> = page2.iter().map(|n| n.id.as_str()).collect();
464        assert_eq!(p1, vec!["n5", "n4"]);
465        assert_eq!(p2, vec!["n3", "n2"]);
466        assert!(p1.iter().all(|id| !p2.contains(id)));
467    }
468
469    #[test]
470    fn query_ex_filters_by_time_range() {
471        let conn = mem_db();
472        upsert_node(
473            &conn,
474            &test_node_dated("old", "2025-06-01T00:00:00Z", vec![]),
475        )
476        .unwrap();
477        upsert_node(
478            &conn,
479            &test_node_dated("mid", "2026-01-01T00:00:00Z", vec![]),
480        )
481        .unwrap();
482        upsert_node(
483            &conn,
484            &test_node_dated("new", "2026-06-01T00:00:00Z", vec![]),
485        )
486        .unwrap();
487
488        let in_window = query_nodes_ex(
489            &conn,
490            &NodeQuery {
491                since: Some("2026-01-01T00:00:00Z".to_string()),
492                until: Some("2026-06-01T00:00:00Z".to_string()),
493                limit: 50,
494                ..Default::default()
495            },
496        )
497        .unwrap();
498        let ids: Vec<&str> = in_window.iter().map(|n| n.id.as_str()).collect();
499        // since is inclusive (>=), until exclusive (<): only "mid" qualifies.
500        assert_eq!(ids, vec!["mid"]);
501    }
502
503    #[test]
504    fn query_ex_filters_by_tag() {
505        let conn = mem_db();
506        upsert_node(
507            &conn,
508            &test_node_dated("n1", "2026-01-01T00:00:00Z", vec!["AAPL"]),
509        )
510        .unwrap();
511        upsert_node(
512            &conn,
513            &test_node_dated("n2", "2026-01-02T00:00:00Z", vec!["MSFT"]),
514        )
515        .unwrap();
516        let results = query_nodes_ex(
517            &conn,
518            &NodeQuery {
519                tag: Some("AAPL".to_string()),
520                limit: 50,
521                ..Default::default()
522            },
523        )
524        .unwrap();
525        let ids: Vec<&str> = results.iter().map(|n| n.id.as_str()).collect();
526        assert_eq!(ids, vec!["n1"]);
527    }
528
529    #[test]
530    fn query_ex_respects_limit_cap() {
531        let conn = mem_db();
532        upsert_node(
533            &conn,
534            &test_node_dated("n1", "2026-01-01T00:00:00Z", vec![]),
535        )
536        .unwrap();
537        // Request 10_000 rows — server-side cap must clamp to 200.
538        let results = query_nodes_ex(
539            &conn,
540            &NodeQuery {
541                limit: 10_000,
542                ..Default::default()
543            },
544        )
545        .unwrap();
546        assert!(results.len() <= 200);
547        assert_eq!(results.len(), 1); // only one node exists
548    }
549}