返回文章列表

ripgrep 搜不到中文,我用 Rust 自己写了一个本地搜索引擎

492·4 分钟阅读
Rust搜索引擎中文搜索桌面应用

我日常用 ripgrep 搜代码,效率很高。但有一次搜自己写的中文笔记,死活搜不到——明明文档里写着"性能优化",rg "性能优化" 就是没结果。

后来才搞明白,ripgrep 是按正则匹配的,虽然能搜中文,但它不做分词,搜不到"高性能优化方案"这种变体。这让我动了念头:自己写一个支持中文分词的本地搜索工具

先想清楚:我们到底要做什么

ripgrep、fd 这类工具的核心是正则匹配——你给个 pattern,它逐文件扫描。这对英文代码搜索来说够了,但中文有个天然的痛点:没有空格分隔

"性能优化"和"高性能优化方案",人眼一看就知道是相关的,但正则匹配做不到。你需要的是分词 + 倒排索引——先把文本切成词,建一张"词 → 出现在哪些文件"的表,查的时候直接查表,而不是逐文件扫描。

这个工具的定位:不是替代 ripgrep,而是补它的盲区——搜中文文档、笔记、技术文章的时候,能理解语义,不只是字符匹配。

第一步:先跑起来,能搜就行

别一上来就搞倒排索引。先用最暴力的方式,让搜索能跑通。

cargo new minisearch
cd minisearch
cargo add walkdir
use 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 行搞定。能跑,但有两个问题:

  1. 搜不到变体——"性能优化"匹配不到"高性能优化方案"
  2. 每次全量扫描——文件多了就慢

第一个问题靠分词解决,第二个问题靠倒排索引解决。

第二个坎:中文分词

英文按空格切就行,中文不行。"我们中出了一个叛徒",你不能按字切——"我""们""中""出"了"一""个""叛""徒"完全没有意义。你得切成"我们 / 中 / 出 / 了 / 一个 / 叛徒"。

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 strsim
use 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 rayon
use 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/derive
use 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 所有权系统详解",这种语义级别的匹配是正则做不到的。

如果你也有大量中文文档需要搜索,这个方案值得一试。 当然,功能还可以继续扩展:支持更多文件类型、支持正则查询、支持多关键词组合查询、支持增量索引更新……但核心的分词 + 倒排索引已经打通了,后续功能都是锦上添花了。