Skip to main content

code_repo_wiki/search/
store.rs

1//! SQLite 存储层:为搜索引擎提供 FTS5 全文索引
2//!
3//! 设计要点:
4//! - WAL 模式:支持多读单写并发
5//! - busy_timeout 5s:写锁等待而非立即失败
6//! - FTS5 虚拟表:BM25 排序由 SQLite 内置完成
7//!
8//! 向量持久化已迁出(v6):语义检索改用 sqlite-vec 扩展
9//! (src/search/vecdb.rs),本文件只承载 FTS5 全文搜索。
10//!
11//! v36 schema v2:新增 `tokens` 列承载 CJK 2-gram(见 tokenize.rs)。
12//! FTS5 默认分词器 unicode61 把连续汉字当作单一 token(无词边界),
13//! 中文子串检索必然零命中;tokens 列写入 CJK 2-gram 后查询侧做同构
14//! 展开即可恢复中文命中。旧库(无 tokens 列)用 PRAGMA user_version
15//! 检测并在 open 时 DROP 重建(返回 need_reindex 由调用方回退全量
16//! 文本重索引——见 lib.rs update_search_index_incremental)。
17
18use std::path::Path;
19
20use anyhow::{Context, Result};
21use rusqlite::Connection;
22
23use crate::model::CodeNode;
24use crate::search::tokenize::extract_keywords;
25
26/// FTS5 建表 SQL(schema v2:末列 tokens 存 CJK 2-gram)
27///
28/// user_version 约定:0/1 = 旧 schema(无 tokens 列,需重建);
29/// 2 = 当前 schema。
30const CREATE_ENTITIES_V2: &str = "CREATE VIRTUAL TABLE IF NOT EXISTS entities USING fts5(
31    name,
32    kind,
33    signature,
34    source,
35    file_path,
36    node_json,
37    tokens
38);";
39
40/// SQLite 搜索引擎存储
41///
42/// 管理一个 SQLite 数据库文件,包含:
43/// - `entities` FTS5 虚拟表(全文搜索)
44pub struct SearchStore {
45    conn: Connection,
46}
47
48impl SearchStore {
49    /// 打开或创建数据库,初始化表结构。
50    ///
51    /// 设置 WAL 模式和 busy_timeout 以支持并发读取。
52    /// 返回 (store, need_reindex):need_reindex=true 表示旧 schema 已
53    /// 迁移重建(表为空),调用方必须全量重索引文本(增量路径不能只
54    /// 补 changed_files,否则旧实体索引丢失)。
55    pub fn open(path: impl AsRef<Path>) -> Result<(Self, bool)> {
56        let conn = Connection::open(path.as_ref())
57            .context("打开 SQLite 数据库失败")?;
58
59        // WAL 模式:允许并发读,写操作排队
60        conn.pragma_update(None, "journal_mode", "WAL")
61            .context("设置 WAL 模式失败")?;
62        // 写锁等待 5 秒
63        conn.busy_timeout(std::time::Duration::from_secs(5))
64            .context("设置 busy_timeout 失败")?;
65
66        // schema 版本检测(user_version 是 SQLite 内建持久化版本槽位)
67        let version: i64 = conn
68            .query_row("PRAGMA user_version", [], |row| row.get(0))
69            .context("读取 user_version 失败")?;
70        let table_exists: bool = conn
71            .query_row(
72                "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'entities')",
73                [],
74                |row| row.get(0),
75            )
76            .context("查询 entities 表存在性失败")?;
77
78        if !table_exists {
79            // 全新数据库:直接建 v2 表
80            conn.execute_batch(CREATE_ENTITIES_V2)
81                .context("创建 FTS5 表失败")?;
82            conn.pragma_update(None, "user_version", 2)
83                .context("写入 user_version 失败")?;
84            return Ok((Self { conn }, false));
85        }
86
87        if version < 2 {
88            // 旧 schema(v1 无 tokens 列):FTS5 虚拟表无法 ALTER 加列,
89            // DROP 重建为 v2。旧索引数据随之清空,返回 need_reindex 由
90            // 调用方决定全量重索引时机。
91            conn.execute_batch("DROP TABLE IF EXISTS entities;")
92                .context("删除旧 FTS5 表失败")?;
93            conn.execute_batch(CREATE_ENTITIES_V2)
94                .context("重建 FTS5 表失败")?;
95            conn.pragma_update(None, "user_version", 2)
96                .context("写入 user_version 失败")?;
97            return Ok((Self { conn }, true));
98        }
99
100        Ok((Self { conn }, false))
101    }
102
103    // ==================== FTS5 全文搜索 ====================
104
105    /// 从实体文本提取 CJK 2-gram token 串(空格分隔,供 tokens 列)
106    ///
107    /// 英文词不进 tokens 列:name/signature/source 原列已被 unicode61
108    /// 正确分词,覆盖英文检索;tokens 列专为中文子串检索服务,避免
109    /// 索引体积成倍膨胀。与查询侧展开(build_match_terms)共用
110    /// extract_keywords,切分逻辑单一真源(tokenize.rs)。
111    fn cjk_tokens(parts: &[&str]) -> String {
112        let mut out: Vec<String> = Vec::new();
113        for part in parts {
114            for k in extract_keywords(part) {
115                // 2-gram 全部为 CJK 字才进 tokens 列
116                if k.chars().all(|c| matches!(c as u32, 0x4E00..=0x9FFF | 0x3400..=0x4DBF | 0xF900..=0xFAFF)) {
117                    out.push(k);
118                }
119            }
120        }
121        out.join(" ")
122    }
123
124    /// 批量插入实体到 FTS5 表
125    pub fn insert_entities_batch(&self, items: &[(CodeNode, String)]) -> Result<()> {
126        let mut stmt = self.conn.prepare(
127            "INSERT INTO entities (name, kind, signature, source, file_path, node_json, tokens)
128             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)"
129        ).context("准备 FTS5 插入语句失败")?;
130
131        for (node, source) in items {
132            let node_json = serde_json::to_string(node)
133                .context("序列化 CodeNode 失败")?;
134            let tokens = Self::cjk_tokens(&[
135                &node.name,
136                node.signature.as_deref().unwrap_or(""),
137                source,
138            ]);
139            stmt.execute(rusqlite::params![
140                node.name,
141                node.kind.as_str(),
142                node.signature.as_deref().unwrap_or(""),
143                source,
144                // file_path 列写入时统一归一化(票 08):该列是删除/过滤的
145                // 索引键,全链路(写入、删除、比较)必须同基准。node_json
146                // 里的 file_path 保留平台原样(用于搜索结果展示与节点信息)。
147                crate::incremental::norm_sep(node.file_path.as_deref().unwrap_or("")).as_str(),
148                node_json,
149                tokens,
150            ]).context("插入 FTS5 条目失败")?;
151        }
152        Ok(())
153    }
154
155    /// 把用户查询展开为 FTS5 词表:CJK 段 2-gram + 英文词原样
156    ///
157    /// 与写入侧共用 extract_keywords,保证同构(写入 tokens 列的
158    /// 2-gram 查询时必能构造出来)。extract_keywords 输出已剥离
159    /// FTS5 特殊字符(引号/括号/逻辑词按非字母数字分隔),词表
160    /// 不会引发 MATCH 语法错误;空串/纯标点返回空词表。
161    fn build_match_terms(query: &str) -> Vec<String> {
162        extract_keywords(query)
163    }
164
165    /// FTS5 全文搜索,按 BM25 相关性排序
166    pub fn search_fts(&self, query: &str, limit: usize) -> Result<Vec<(CodeNode, f64)>> {
167        // 展开词表:空词表(空串/纯标点)→ 空结果(与 hybrid 引擎的
168        // 退化语义一致,v35 审计:text 模式此前直接透传导致 FTS5
169        // 语法错误上抛中断,三引擎行为不一致)
170        let terms = Self::build_match_terms(query);
171        if terms.is_empty() {
172            return Ok(Vec::new());
173        }
174        // 列集合约束:英文词落在 name/signature/source 原列,CJK 2-gram
175        // 落在 tokens 列,单 MATCH 表达式统一命中。
176        let match_expr = format!("{{name signature source tokens}} : ({})", terms.join(" OR "));
177
178        // FTS5 MATCH 查询,bm25() 返回负数(越小越相关)
179        let sql = format!(
180            "SELECT node_json, bm25(entities) as rank
181             FROM entities
182             WHERE entities MATCH ?1
183             ORDER BY rank
184             LIMIT {}",
185            limit
186        );
187        let mut stmt = self.conn.prepare(&sql)
188            .context("准备 FTS5 查询语句失败")?;
189
190        // 词表来自 extract_keywords 切分(已剥离特殊字符),正常不会
191        // 语法错误;残留错误统一转为空结果 + 告警(三引擎一致,不向
192        // 上层传播查询中断)。
193        let rows = match stmt.query_map(rusqlite::params![match_expr], |row| {
194            let node_json: String = row.get(0)?;
195            let rank: f64 = row.get(1)?;
196            Ok((node_json, rank))
197        }) {
198            Ok(rows) => rows,
199            Err(e) => {
200                tracing::warn!("FTS5 查询语法错误,返回空结果: {} (query: {})", e, query);
201                return Ok(Vec::new());
202            }
203        };
204
205        let mut results = Vec::new();
206        for row in rows {
207            let (node_json, rank) = row.context("读取 FTS5 结果行失败")?;
208            if let Ok(node) = serde_json::from_str::<CodeNode>(&node_json) {
209                // bm25() 返回负数,取反作为正分数
210                results.push((node, -rank));
211            }
212        }
213        Ok(results)
214    }
215
216    /// 删除指定文件的所有 FTS5 条目
217    ///
218    /// 参数与 file_path 列同基准:写入时已归一化(票 08),删除键也归一化,
219    /// 保证 Windows 反斜杠路径(调用方传入)与入库正斜杠键精确匹配。
220    pub fn delete_entities_by_file(&self, file_path: &str) -> Result<usize> {
221        let count = self.conn.execute(
222            "DELETE FROM entities WHERE file_path = ?1",
223            rusqlite::params![crate::incremental::norm_sep(file_path)],
224        ).context("删除 FTS5 条目失败")?;
225        Ok(count)
226    }
227
228    /// 获取 FTS5 表中的文档总数
229    pub fn entity_count(&self) -> Result<usize> {
230        let count: usize = self.conn.query_row(
231            "SELECT COUNT(*) FROM entities",
232            [],
233            |row| row.get(0),
234        ).context("查询 FTS5 文档数失败")?;
235        Ok(count)
236    }
237
238    /// 清空 FTS5 表
239    pub fn clear_entities(&self) -> Result<()> {
240        self.conn.execute("DELETE FROM entities", [])
241            .context("清空 FTS5 表失败")?;
242        Ok(())
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249    use crate::model::{NodeId, NodeKind};
250
251    fn tmp_db_path(label: &str) -> std::path::PathBuf {
252        let mut p = std::env::temp_dir();
253        p.push(format!("store_test_{}_{}.db", label, std::process::id()));
254        let _ = std::fs::remove_file(&p);
255        p
256    }
257
258    fn make_node(name: &str, file: &str) -> CodeNode {
259        CodeNode {
260            id: NodeId::new(0),
261            kind: NodeKind::Function,
262            name: name.into(),
263            file_path: Some(file.into()),
264            line_range: Some((1, 10)),
265            doc_comment: None,
266            signature: Some(format!("fn {}()", name)), visibility: None,
267            module_path: vec![],
268        }
269    }
270
271    #[test]
272    fn test_fts_insert_and_search() {
273        let (store, need_reindex) = SearchStore::open(tmp_db_path("fts")).unwrap();
274        assert!(!need_reindex, "新库无需重建");
275        let items = vec![
276            (make_node("authenticate", "src/auth.rs"), "fn authenticate(user: &str)".to_string()),
277            (make_node("save_session", "src/storage.rs"), "fn save_session(id: u64)".to_string()),
278        ];
279        store.insert_entities_batch(&items).unwrap();
280        assert_eq!(store.entity_count().unwrap(), 2);
281
282        let results = store.search_fts("authenticate", 5).unwrap();
283        assert!(!results.is_empty());
284        assert_eq!(results[0].0.name, "authenticate");
285    }
286
287    #[test]
288    fn test_fts_delete_by_file() {
289        let (store, _) = SearchStore::open(tmp_db_path("fts_del")).unwrap();
290        let items = vec![
291            (make_node("alpha", "src/a.rs"), "alpha code".to_string()),
292            (make_node("beta", "src/b.rs"), "beta code".to_string()),
293        ];
294        store.insert_entities_batch(&items).unwrap();
295
296        let removed = store.delete_entities_by_file("src/a.rs").unwrap();
297        assert_eq!(removed, 1);
298        assert_eq!(store.entity_count().unwrap(), 1);
299    }
300
301    /// CJK 检索:写入侧 tokens 列承载 2-gram,查询侧同构展开命中
302    #[test]
303    fn test_fts_cjk_substring_search() {
304        let (store, _) = SearchStore::open(tmp_db_path("fts_cjk")).unwrap();
305        let items = vec![
306            (make_node("提取配置", "src/config.rs"), "fn 提取配置() 读取合并后的配置".to_string()),
307            (make_node("save_session", "src/storage.rs"), "fn save_session(id: u64)".to_string()),
308        ];
309        store.insert_entities_batch(&items).unwrap();
310
311        // 中文子串检索:unicode61 下「配置」是「提取配置」整串的一部分,
312        // v1 schema 必零命中;v2 经 tokens 列 2-gram(提取/取配/配置)命中
313        let results = store.search_fts("配置", 5).unwrap();
314        assert_eq!(results.len(), 1, "中文 2-gram 应命中实体");
315        assert_eq!(results[0].0.name, "提取配置");
316
317        // 中文 + 英文混合查询:两腿词表统一进同一 MATCH
318        let mixed = store.search_fts("配置 session", 5).unwrap();
319        assert_eq!(mixed.len(), 2, "混合查询应同时命中中文与英文实体");
320    }
321
322    /// 旧 schema 迁移:v1 表(无 tokens 列)open 后重建为 v2 并返回
323    /// need_reindex,二次 open 幂等(不再触发重建)
324    #[test]
325    fn test_fts_legacy_schema_migration() {
326        let path = tmp_db_path("fts_migrate");
327        let _ = std::fs::remove_file(&path);
328        {
329            // 手工构造 v1 旧库:v1 建表 + user_version=1 + 一条旧数据
330            let conn = rusqlite::Connection::open(&path).unwrap();
331            conn.execute_batch(
332                "CREATE VIRTUAL TABLE entities USING fts5(
333                    name, kind, signature, source, file_path, node_json
334                );"
335            ).unwrap();
336            conn.pragma_update(None, "user_version", 1).unwrap();
337            conn.execute(
338                "INSERT INTO entities (name, kind, signature, source, file_path, node_json)
339                 VALUES ('old_fn', 'function', 'fn old_fn()', 'old code', 'src/old.rs', '{}')",
340                [],
341            ).unwrap();
342        }
343
344        // 第一次 open:检测旧 schema,重建为 v2,返回 need_reindex
345        let (store, need_reindex) = SearchStore::open(&path).unwrap();
346        assert!(need_reindex, "旧 schema 必须触发重建标记");
347        assert_eq!(store.entity_count().unwrap(), 0, "旧索引数据已清空");
348
349        // 新数据写入后中文检索可用(v2 tokens 列生效)
350        let items = vec![
351            (make_node("验证迁移", "src/new.rs"), "fn 验证迁移()".to_string()),
352        ];
353        store.insert_entities_batch(&items).unwrap();
354        let results = store.search_fts("迁移", 5).unwrap();
355        assert_eq!(results.len(), 1, "迁移后 v2 检索正常");
356        assert_eq!(results[0].0.name, "验证迁移");
357
358        // 第二次 open:已是 v2,幂等无重建
359        let (_, need_reindex2) = SearchStore::open(&path).unwrap();
360        assert!(!need_reindex2, "二次 open 不应再触发重建");
361    }
362
363    /// 空串/纯标点查询:返回空结果而非 FTS5 语法错误(三引擎一致)
364    #[test]
365    fn test_fts_punctuation_query_returns_empty() {
366        let (store, _) = SearchStore::open(tmp_db_path("fts_punct")).unwrap();
367        let items = vec![(make_node("alpha", "src/a.rs"), "alpha code".to_string())];
368        store.insert_entities_batch(&items).unwrap();
369
370        let empty = store.search_fts("", 5).unwrap();
371        assert!(empty.is_empty(), "空串查询返回空");
372        let punct = store.search_fts("!!!---", 5).unwrap();
373        assert!(punct.is_empty(), "纯标点查询返回空(v1 会语法错误上抛)");
374    }
375}