sqlite_graphrag/storage/memories/
fts.rs1use super::rows::MemoryRow;
7use crate::errors::AppError;
8use rusqlite::{params, Connection};
9
10pub(super) fn preprocess_fts_query(raw: &str) -> String {
20 const SEPARATORS: &[char] = &['-', '.', '_', '/'];
21 const FTS5_SYNTAX: &[char] = &['"', '*', '(', ')', '^', ':'];
22 const FTS5_KEYWORDS: &[&str] = &["OR", "AND", "NOT", "NEAR"];
23
24 let sanitized: String = raw.chars().filter(|c| !FTS5_SYNTAX.contains(c)).collect();
25 let trimmed = sanitized.trim();
26 if trimmed.is_empty() {
27 return String::new();
28 }
29
30 let is_fts_keyword = |t: &str| FTS5_KEYWORDS.iter().any(|kw| kw.eq_ignore_ascii_case(t));
31
32 if !trimmed.chars().any(|c| SEPARATORS.contains(&c)) {
33 return trimmed
34 .split_whitespace()
35 .filter(|t| !is_fts_keyword(t))
36 .map(|t| format!("{t}*"))
37 .collect::<Vec<_>>()
38 .join(" ");
39 }
40 let tokens: Vec<&str> = trimmed
41 .split(|c: char| SEPARATORS.contains(&c) || c.is_whitespace())
42 .filter(|t| !t.is_empty() && !is_fts_keyword(t))
43 .collect();
44 if tokens.is_empty() {
45 return String::new();
46 }
47 let phrase = format!("\"{}\"", tokens.join(" "));
48 let prefix_terms: Vec<String> = tokens.iter().map(|t| format!("{t}*")).collect();
49 format!("{phrase} OR {}", prefix_terms.join(" OR "))
50}
51
52pub fn fts_search(
61 conn: &Connection,
62 query: &str,
63 namespace: &str,
64 memory_type: Option<&str>,
65 limit: usize,
66) -> Result<Vec<MemoryRow>, AppError> {
67 let fts_query = preprocess_fts_query(query);
68 if let Some(mt) = memory_type {
69 let mut stmt = conn.prepare_cached(
70 "SELECT m.id, m.namespace, m.name, m.type, m.description, m.body, m.body_hash,
71 m.session_id, m.source, m.metadata, m.created_at, m.updated_at, m.deleted_at
72 FROM fts_memories fts
73 JOIN memories m ON m.id = fts.rowid
74 WHERE fts_memories MATCH ?1 AND m.namespace = ?2 AND m.type = ?3 AND m.deleted_at IS NULL
75 ORDER BY rank LIMIT ?4",
76 )?;
77 let rows = stmt
78 .query_map(params![fts_query, namespace, mt, limit as i64], |r| {
79 Ok(MemoryRow {
80 id: r.get(0)?,
81 namespace: r.get(1)?,
82 name: r.get(2)?,
83 memory_type: r.get(3)?,
84 description: r.get(4)?,
85 body: r.get(5)?,
86 body_hash: r.get(6)?,
87 session_id: r.get(7)?,
88 source: r.get(8)?,
89 metadata: r.get(9)?,
90 created_at: r.get(10)?,
91 updated_at: r.get(11)?,
92 deleted_at: r.get(12)?,
93 })
94 })?
95 .collect::<Result<Vec<_>, _>>()?;
96 Ok(rows)
97 } else {
98 let mut stmt = conn.prepare_cached(
99 "SELECT m.id, m.namespace, m.name, m.type, m.description, m.body, m.body_hash,
100 m.session_id, m.source, m.metadata, m.created_at, m.updated_at, m.deleted_at
101 FROM fts_memories fts
102 JOIN memories m ON m.id = fts.rowid
103 WHERE fts_memories MATCH ?1 AND m.namespace = ?2 AND m.deleted_at IS NULL
104 ORDER BY rank LIMIT ?3",
105 )?;
106 let rows = stmt
107 .query_map(params![fts_query, namespace, limit as i64], |r| {
108 Ok(MemoryRow {
109 id: r.get(0)?,
110 namespace: r.get(1)?,
111 name: r.get(2)?,
112 memory_type: r.get(3)?,
113 description: r.get(4)?,
114 body: r.get(5)?,
115 body_hash: r.get(6)?,
116 session_id: r.get(7)?,
117 source: r.get(8)?,
118 metadata: r.get(9)?,
119 created_at: r.get(10)?,
120 updated_at: r.get(11)?,
121 deleted_at: r.get(12)?,
122 })
123 })?
124 .collect::<Result<Vec<_>, _>>()?;
125 Ok(rows)
126 }
127}
128
129#[allow(clippy::too_many_arguments)]
143pub fn sync_fts_after_update(
144 conn: &Connection,
145 memory_id: i64,
146 old_name: &str,
147 old_desc: &str,
148 old_body: &str,
149 new_name: &str,
150 new_desc: &str,
151 new_body: &str,
152) -> Result<(), AppError> {
153 conn.execute(
154 "INSERT INTO fts_memories(fts_memories, rowid, name, description, body)
155 VALUES('delete', ?1, ?2, ?3, ?4)",
156 params![memory_id, old_name, old_desc, old_body],
157 )?;
158 conn.execute(
159 "INSERT INTO fts_memories(rowid, name, description, body)
160 VALUES(?1, ?2, ?3, ?4)",
161 params![memory_id, new_name, new_desc, new_body],
162 )?;
163 Ok(())
164}