Skip to main content

code_repo_wiki/search/
text.rs

1//! BM25 全文搜索引擎——SQLite FTS5 持久化
2//!
3//! 通过 SQLite FTS5 虚拟表实现全文搜索,BM25 排序由 SQLite 内置完成。
4//! 支持并发读取(WAL 模式),写操作自动排队。
5
6use std::path::Path;
7use anyhow::Result;
8
9use crate::model::CodeNode;
10use super::store::SearchStore;
11
12/// BM25 全文搜索引擎
13///
14/// 内部委托 SearchStore(SQLite FTS5)完成索引和搜索。
15/// 公开 API 保持不变,供 pipeline 和 CLI 调用。
16pub struct TextEngine {
17    store: SearchStore,
18}
19
20impl TextEngine {
21    /// 打开或创建持久化搜索引擎。
22    ///
23    /// path 指向 SQLite 数据库文件(.db),不存在时自动创建。
24    /// 返回 (engine, need_reindex):need_reindex=true 表示旧 schema 已
25    /// 迁移重建(索引为空),调用方必须全量重索引(增量路径只补
26    /// changed_files 会丢失旧实体)。
27    pub fn open(path: impl AsRef<Path>) -> Result<(Self, bool)> {
28        let (store, need_reindex) = SearchStore::open(path)?;
29        Ok((Self { store }, need_reindex))
30    }
31
32    /// 索引一个 CodeNode。
33    pub fn index(&mut self, node: &CodeNode, source_code: &str) -> Result<()> {
34        self.store.insert_entities_batch(&[(node.clone(), source_code.to_string())])
35    }
36
37    /// 批量索引多个实体。
38    pub fn index_batch(&mut self, items: &[(CodeNode, String)]) -> Result<()> {
39        self.store.insert_entities_batch(items)
40    }
41
42    /// BM25 搜索,返回 (CodeNode, score) 按相关性降序。
43    pub fn search(&self, query: &str, limit: usize) -> Result<Vec<(CodeNode, f64)>> {
44        if query.is_empty() {
45            return Ok(Vec::new());
46        }
47        self.store.search_fts(query, limit)
48    }
49
50    /// 删除指定文件路径关联的所有索引条目。
51    pub fn remove_by_file(&mut self, file_path: &str) -> Result<usize> {
52        self.store.delete_entities_by_file(file_path)
53    }
54
55    /// 清空索引。
56    pub fn clear(&mut self) -> Result<()> {
57        self.store.clear_entities()
58    }
59
60    /// 当前索引中的文档数。
61    pub fn doc_count(&self) -> usize {
62        self.store.entity_count().unwrap_or(0)
63    }
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69    use crate::model::{NodeId, NodeKind};
70
71    fn make_node(name: &str, kind: NodeKind) -> CodeNode {
72        CodeNode {
73            id: NodeId::new(0), kind, name: name.into(),
74            file_path: Some("src/test.rs".into()),
75            line_range: Some((1, 5)),
76            doc_comment: None,
77            signature: Some(format!("fn {}()", name)), visibility: None,
78            module_path: vec![],
79        }
80    }
81
82    fn tmp_path(label: &str) -> std::path::PathBuf {
83        use std::sync::atomic::{AtomicU64, Ordering};
84        static COUNTER: AtomicU64 = AtomicU64::new(0);
85        let mut p = std::env::temp_dir();
86        p.push(format!("text_fts_{}_{}.db", label, COUNTER.fetch_add(1, Ordering::Relaxed)));
87        let _ = std::fs::remove_file(&p);
88        p
89    }
90
91    #[test]
92    fn test_index_and_search() -> Result<()> {
93        let (mut engine, _) = TextEngine::open(tmp_path("index_search"))?;
94        engine.index(&make_node("add_user", NodeKind::Function), "fn add_user(name: &str)")?;
95        engine.index(&make_node("delete_user", NodeKind::Function), "fn delete_user(id: u64)")?;
96        let results = engine.search("add_user", 10)?;
97        assert!(!results.is_empty());
98        assert!(results[0].0.name.contains("add_user"));
99        Ok(())
100    }
101
102    #[test]
103    fn test_empty_engine() -> Result<()> {
104        let (engine, _) = TextEngine::open(tmp_path("empty"))?;
105        assert!(engine.search("anything", 10)?.is_empty());
106        Ok(())
107    }
108
109    #[test]
110    fn test_persistence() -> Result<()> {
111        let path = tmp_path("persist");
112        {
113            let (mut engine, _) = TextEngine::open(&path)?;
114            engine.index(&make_node("persist_test", NodeKind::Function), "fn test()")?;
115        }
116        let (engine, _) = TextEngine::open(&path)?;
117        assert_eq!(engine.doc_count(), 1);
118        let results = engine.search("persist_test", 10)?;
119        assert!(!results.is_empty());
120        Ok(())
121    }
122
123    #[test]
124    fn test_clear() -> Result<()> {
125        let (mut engine, _) = TextEngine::open(tmp_path("clear"))?;
126        engine.index(&make_node("x", NodeKind::Function), "")?;
127        assert_eq!(engine.doc_count(), 1);
128        engine.clear()?;
129        assert_eq!(engine.doc_count(), 0);
130        Ok(())
131    }
132
133    #[test]
134    fn test_remove_by_file() -> Result<()> {
135        let (mut engine, _) = TextEngine::open(tmp_path("remove"))?;
136        let node_a = CodeNode {
137            id: NodeId::new(0), kind: NodeKind::Function,
138            name: "alpha_unique".into(),
139            file_path: Some("src/alpha.rs".into()),
140            line_range: Some((1, 3)), doc_comment: None,
141            signature: None, module_path: vec![], visibility: None,
142        };
143        let node_b = CodeNode {
144            id: NodeId::new(1), kind: NodeKind::Function,
145            name: "beta_unique".into(),
146            file_path: Some("src/beta.rs".into()),
147            line_range: Some((1, 3)), doc_comment: None,
148            signature: None, module_path: vec![], visibility: None,
149        };
150        engine.index_batch(&[(node_a, "alpha".into()), (node_b, "beta".into())])?;
151        assert_eq!(engine.doc_count(), 2);
152
153        let removed = engine.remove_by_file("src/alpha.rs")?;
154        assert_eq!(removed, 1);
155        assert_eq!(engine.doc_count(), 1);
156        Ok(())
157    }
158
159    /// t12:短关键词基线——BM25 token 精确匹配对短查询可用
160    /// (CoREB 论文的短查询退化是 embedding 检索问题,FTS5 不受影响;
161    /// 这同时是"不引入 reranker"决策的本地证据之一)
162    #[test]
163    fn test_short_keyword_baseline() -> Result<()> {
164        let (mut engine, _) = TextEngine::open(tmp_path("short_keyword"))?;
165        engine.index(&make_node("a_helper", NodeKind::Function), "fn a_helper(x: u32)")?;
166        engine.index(&make_node("udp_send", NodeKind::Function), "fn udp_send(sock: u32)")?;
167        // 1 字符 token 查询:BM25 token 精确匹配,含单字符 token 的实体命中
168        let short = engine.search("a", 10)?;
169        assert!(
170            short.iter().any(|(n, _)| n.name == "a_helper"),
171            "1 字符 token 查询应命中 a_helper"
172        );
173        // 2 字符 token 精确查询
174        let two = engine.search("udp", 10)?;
175        assert!(two.iter().any(|(n, _)| n.name == "udp_send"), "2 字符 token 应命中");
176        Ok(())
177    }
178}