ripgrep 搜不到中文,我用 Rust 自己写了一个本地搜索引擎
我日常用 ripgrep 搜代码,效率很高。但有一次搜自己写的中文笔记,死活搜不到——明明文档里写着"性能优化",
rg "性能优化"就是没结果。
后来才搞明白,ripgrep 是按正则匹配的,虽然能搜中文,但它不做分词,搜不到"高性能优化方案"这种变体。这让我动了念头:自己写一个支持中文分词的本地搜索工具。
先想清楚:我们到底要做什么
ripgrep、fd 这类工具的核心是正则匹配——你给个 pattern,它逐文件扫描。这对英文代码搜索来说够了,但中文有个天然的痛点:没有空格分隔。
"性能优化"和"高性能优化方案",人眼一看就知道是相关的,但正则匹配做不到。你需要的是分词 + 倒排索引——先把文本切成词,建一张"词 → 出现在哪些文件"的表,查的时候直接查表,而不是逐文件扫描。
这个工具的定位:不是替代 ripgrep,而是补它的盲区——搜中文文档、笔记、技术文章的时候,能理解语义,不只是字符匹配。
第一步:先跑起来,能搜就行
别一上来就搞倒排索引。先用最暴力的方式,让搜索能跑通。
cargo new minisearch
cd minisearch
cargo add walkdiruse std::fs;
use walkdir::WalkDir;
fn grep_search(root: &str, query: &str) {
for entry in WalkDir::new(root).into_iter().filter_map(|e| e.ok()) {
let path = entry.path();
// 只搜文本文件
if !path.extension().map_or(false, |ext| {
ext == "txt" || ext == "md" || ext == "rs" || ext == "toml"
}) {
continue;
}
if let Ok(content) = fs::read_to_string(path) {
for (line_num, line) in content.lines().enumerate() {
if line.contains(query) {
println!("{}:{} - {}", path.display(), line_num + 1, line.trim());
}
}
}
}
}
fn main() {
grep_search(".", "性能优化");
}这就是个 Rust 版的 grep -r,20 行搞定。能跑,但有两个问题:
- 搜不到变体——"性能优化"匹配不到"高性能优化方案"
- 每次全量扫描——文件多了就慢
第一个问题靠分词解决,第二个问题靠倒排索引解决。
第二个坎:中文分词
英文按空格切就行,中文不行。"我们中出了一个叛徒",你不能按字切——"我""们""中""出"了"一""个""叛""徒"完全没有意义。你得切成"我们 / 中 / 出 / 了 / 一个 / 叛徒"。
Rust 里做中文分词,最成熟的库是 jieba-rs——结巴分词的 Rust 实现,比 C++ 版本还快 33%。
cargo add jieba-rs先看看分词效果:
use jieba_rs::Jieba;
fn main() {
let jieba = Jieba::new();
let text = "用Rust实现高性能优化方案";
let tokens = jieba.cut(text, false);
println!("精确模式: {:?}", tokens);
let tokens_search = jieba.cut_for_search(text, false);
println!("搜索模式: {:?}", tokens_search);
}输出:
精确模式: ["用", "Rust", "实现", "高性能", "优化", "方案"]
搜索模式: ["用", "Rust", "实现", "高", "性能", "高性能", "优化", "方案"]
注意看搜索模式——它把"高性能"拆成了"高""性能""高性能"三个 token。这就是搜索引擎模式的精髓:宁可多切,不可漏切。 用户搜"性能"的时候,能匹配到包含"高性能"的文档。
jieba 有三种分词模式:精确模式(默认)、全模式、搜索引擎模式。建索引用搜索引擎模式(
cut_for_search),查关键词用精确模式(cut)。这个区分很重要,后面会用到。
核心:倒排索引
倒排索引的思路很简单:不是拿着关键词去遍历所有文件,而是提前建好一张表——词 → 出现在哪些文件的哪些位置。
打个比方:你去图书馆找"Rust所有权"相关的内容。暴力搜索是把每本书翻一遍;倒排索引是先看目录,直接定位到有"所有权"的那几本书的那几页。
数据结构长这样:
use std::collections::HashMap;
use serde::{Serialize, Deserialize};
// 一个文档中的一个位置
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Hit {
file_path: String,
line_num: usize,
line_content: String,
}
// 倒排索引:词 → 出现位置列表
struct InvertedIndex {
index: HashMap<String, Vec<Hit>>,
jieba: Jieba,
}建索引的过程:遍历文件 → 分词 → 写入 HashMap。
use jieba_rs::Jieba;
use std::collections::HashMap;
use std::fs;
use walkdir::WalkDir;
#[derive(Debug, Clone)]
struct Hit {
file_path: String,
line_num: usize,
line_content: String,
}
struct InvertedIndex {
index: HashMap<String, Vec<Hit>>,
jieba: Jieba,
}
impl InvertedIndex {
fn new() -> Self {
Self {
index: HashMap::new(),
jieba: Jieba::new(),
}
}
// 建索引:遍历目录下所有文件
fn build(&mut self, root: &str) {
for entry in WalkDir::new(root).into_iter().filter_map(|e| e.ok()) {
let path = entry.path();
if !path.extension().map_or(false, |ext| {
ext == "txt" || ext == "md" || ext == "rs"
}) {
continue;
}
let content = match fs::read_to_string(path) {
Ok(c) => c,
Err(_) => continue,
};
let path_str = path.display().to_string();
for (line_num, line) in content.lines().enumerate() {
// 搜索引擎模式:更细粒度的分词,提高召回率
let tokens = self.jieba.cut_for_search(line, false);
for token in tokens {
let word = token.word.to_string();
if word.chars().count() < 2 {
continue; // 跳过单字,减少噪音
}
self.index.entry(word).or_default().push(Hit {
file_path: path_str.clone(),
line_num: line_num + 1,
line_content: line.trim().to_string(),
});
}
}
}
}
// 查询
fn search(&self, query: &str) -> Vec<&Hit> {
// 查询用精确模式
let tokens = self.jieba.cut(query, false);
let mut results = Vec::new();
for token in tokens {
let word = token.word;
if word.chars().count() < 2 {
continue;
}
if let Some(hits) = self.index.get(word) {
results.extend(hits);
}
}
// 按文件和行号排序,去重
results.sort_by(|a, b| a.file_path.cmp(&b.file_path).then(a.line_num.cmp(&b.line_num)));
results.dedup_by(|a, b| a.file_path == b.file_path && a.line_num == b.line_num);
results
}
}实际用起来是这样的:
fn main() {
let mut index = InvertedIndex::new();
index.build("./my-notes"); // 建索引
let results = index.search("性能优化");
for hit in &results {
println!("{}:{} - {}", hit.file_path, hit.line_num, hit.line_content);
}
}搜"性能优化",能匹配到包含"高性能优化方案"的文档——因为建索引时用了 cut_for_search,"高性能"被拆成了"高""性能""高性能"三个 token,查询时"性能"命中了其中一个。
建索引用
cut_for_search,查询用cut。 这是搜索系统的经典套路:索引阶段追求召回率(宁多勿少),查询阶段追求精确度(别给太多噪音)。
加点料:模糊搜索
有时候用户记不清确切关键词。"倒排索引"打成了"倒排索应",或者记成了"反转索引"。这时候需要模糊匹配——允许一定的编辑距离。
编辑距离(Levenshtein distance):把一个词变成另一个词,最少需要几次增删改。"索引"到"索应"的距离是 1(改一个字)。
cargo add strsimuse strsim::normalized_levenshtein;
fn is_fuzzy_match(query: &str, target: &str, threshold: f64) -> bool {
normalized_levenshtein(query, target) >= threshold
}
fn main() {
println!("{}", is_fuzzy_match("索引", "索应", 0.7)); // true
println!("{}", is_fuzzy_match("索引", "数据库", 0.7)); // false
}给搜索加上模糊匹配:精确匹配优先,找不到再用模糊匹配兜底。
use strsim::normalized_levenshtein;
impl InvertedIndex {
fn search_fuzzy(&self, query: &str, threshold: f64) -> Vec<&Hit> {
// 先精确匹配
let exact_results = self.search(query);
if !exact_results.is_empty() {
return exact_results;
}
// 精确没命中,模糊匹配
let tokens = self.jieba.cut(query, false);
let mut results = Vec::new();
for token in tokens {
let word = token.word;
if word.chars().count() < 2 {
continue;
}
// 遍历索引里的所有词,找相似的
for (indexed_word, hits) in &self.index {
if normalized_levenshtein(word, indexed_word) >= threshold {
results.extend(hits.iter());
}
}
}
results.sort_by(|a, b| a.file_path.cmp(&b.file_path).then(a.line_num.cmp(&b.line_num)));
results.dedup_by(|a, b| a.file_path == b.file_path && a.line_num == b.line_num);
results
}
}说实话,模糊匹配的性能不咋地——遍历索引里所有词做编辑距离计算,O(n) 复杂度。但对个人笔记搜索来说够用了。如果你的索引有几十万词条,可以考虑用 BK-tree 之类的结构优化,不过那就是另一个话题了。
模糊匹配是兜底方案,不是主力。 大部分时候用户能搜到精确结果,模糊匹配只是在"搜不到"的时候给个台阶下。阈值建议设 0.7 左方,太低了噪音太多。
让搜索飞起来:性能优化
上面的版本能用,但有两个性能问题:建索引是单线程的,每次启动都要重新建索引。
并行建索引
rayon 把串行变并行,基本不改代码:
cargo add rayonuse rayon::prelude::*;
impl InvertedIndex {
fn build_parallel(&mut self, root: &str) {
// 先收集所有文件路径
let files: Vec<_> = WalkDir::new(root)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| {
e.path().extension().map_or(false, |ext| {
ext == "txt" || ext == "md" || ext == "rs"
})
})
.map(|e| e.path().to_path_buf())
.collect();
// 并行读取和分词
let partial_results: Vec<Vec<(String, Hit)>> = files
.par_iter()
.filter_map(|path| {
let content = fs::read_to_string(path).ok()?;
let path_str = path.display().to_string();
let jieba = Jieba::new(); // 每个线程自己的 Jieba 实例
let mut local_index: Vec<(String, Hit)> = Vec::new();
for (line_num, line) in content.lines().enumerate() {
let tokens = jieba.cut_for_search(line, false);
for token in tokens {
let word = token.word.to_string();
if word.chars().count() >= 2 {
local_index.push((word, Hit {
file_path: path_str.clone(),
line_num: line_num + 1,
line_content: line.trim().to_string(),
}));
}
}
}
Some(local_index)
})
.collect();
// 合并结果
for hits in partial_results {
for (word, hit) in hits {
self.index.entry(word).or_default().push(hit);
}
}
}
}1000 个 Markdown 文件,单线程建索引大概 2 秒,rayon 并行之后 300 毫秒左右。四核以上提升明显,核心数不够的话差距不大。
索引持久化
每次启动都重新建索引太浪费了。把索引序列化到磁盘,下次直接加载。
cargo add postcard serde -F serde/deriveuse serde::{Serialize, Deserialize};
use std::fs;
#[derive(Serialize, Deserialize)]
struct PersistedIndex {
entries: Vec<(String, Vec<Hit>)>,
}
impl InvertedIndex {
// 保存索引到磁盘
fn save(&self, path: &str) {
let persisted = PersistedIndex {
entries: self.index.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
};
let bytes = postcard::to_allocvec(&persisted).unwrap();
fs::write(path, bytes).unwrap();
}
// 从磁盘加载索引
fn load(path: &str) -> Option<Self> {
let bytes = fs::read(path).ok()?;
let persisted: PersistedIndex = postcard::from_bytes(&bytes).ok()?;
let mut index = HashMap::new();
for (word, hits) in persisted.entries {
index.insert(word, hits);
}
Some(Self {
index,
jieba: Jieba::new(),
})
}
}用起来就这样:
fn main() {
let index_path = ".minisearch.idx";
let index = if let Some(idx) = InvertedIndex::load(index_path) {
println!("从缓存加载索引");
idx
} else {
println!("首次建索引...");
let mut idx = InvertedIndex::new();
idx.build_parallel("./my-notes");
idx.save(index_path);
idx
};
let results = index.search("性能优化");
// ...
}索引缓存的坑:你改了文件但没重建索引,搜索结果就是旧的。简单的做法是检查索引文件和源目录的修改时间,过期了就重建。复杂点的做法是增量更新——只重建改过的文件。这个版本先不做增量,全量重建在几千文件规模下也就几秒。
最终效果
完整代码不多,核心就三个部分:分词(jieba)→ 建索引(HashMap)→ 查询。加上模糊搜索、并行建索引、索引持久化,总共 200 行左右。
和现有工具的对比:
| 工具 | 定位 | 中文分词 | 倒排索引 | 适用场景 |
|---|---|---|---|---|
| ripgrep | 正则文件搜索 | ❌ | ❌ | 搜代码、搜英文 |
| Everything | Windows 文件名搜索 | ❌ | ✅ | 按文件名找文件 |
| 本文工具 | 中文全文搜索 | ✅ | ✅ | 搜中文笔记、文档 |
说实话,这个工具不会替代 ripgrep——搜代码我仍然用 ripgrep。但搜中文笔记、技术文档的时候,分词 + 倒排索引的体验确实好很多。"性能优化"能搜到"高性能优化方案","所有权"能搜到"Rust 所有权系统详解",这种语义级别的匹配是正则做不到的。
如果你也有大量中文文档需要搜索,这个方案值得一试。 当然,功能还可以继续扩展:支持更多文件类型、支持正则查询、支持多关键词组合查询、支持增量索引更新……但核心的分词 + 倒排索引已经打通了,后续功能都是锦上添花了。