用 Rust 写一个迷你全文搜索引擎:倒排索引

要做什么:几千篇文档,毫秒级全文检索

在内存里建一个迷你全文搜索引擎:给定查询词,按相关度排序返回文档。它不做磁盘持久化、不做增量更新、分词刻意从简——但倒排索引、BM25 打分、AND/OR 查询这些搜索引擎的骨架一件不少。写完你就知道 tantivy 这类现成库在替你做什么,以及什么时候该直接用库。

指标 目标值 怎么测
查询延迟 千级文档单次查询远低于 1ms(估算口径,未实测) cargo run 里循环查询千次计时取均值
AND 语义 结果文档必须同时包含全部查询词 单元测试断言
排序合理性 短而富含查询词的文档排在长而稀释的文档前 构造对比文档,单测断言
依赖 0 个第三方 crate,纯标准库 Cargo.toml 无 dependencies

倒排索引:用一次建索引的成本换无数次快查

正排是”文档 → 内容”,查询时全量扫描;倒排反过来,”词 → 出现它的文档列表”:

文档 0: "the quick brown fox jumps over the lazy dog"
文档 1: "quick quick quick dog"
文档 2: "a lazy cat sleeps"

倒排索引(posting list):
the   → {0}
quick → {0, 1}
lazy  → {0, 2}
dog   → {0, 1}
cat   → {2}
...

查 "quick AND dog" → {0,1} ∩ {0,1} = {0,1}
查 "quick OR cat"  → {0,1} ∪ {2}   = {0,1,2}

没有索引时,每个查询都要读完全部文档做匹配,成本与语料总量成正比;有了索引,查询成本只与”词的文档列表长度”相关。搜索引擎的全部魔法从这个交换开始:写多读少的数据,值得为读重建结构。

数据结构:HashMap 与 BTreeSet 的分工

use std::collections::{BTreeSet, HashMap};

type DocId = u32;   // 索引里数量最大的数据,id 宽度决定内存上限;u32 足够 42 亿篇

#[derive(Default)]
struct Index {
    docs: Vec<String>,                            // doc_id → 原文(展示用)
    doc_len: Vec<usize>,                          // 每篇 token 总数(BM25 长度归一用)
    tfs: Vec<HashMap<String, usize>>,             // 每篇的词频表
    postings: HashMap<String, BTreeSet<DocId>>,   // 词 → 出现它的文档集合
}

impl Index {
    fn add(&mut self, text: &str) -> DocId {
        let id = self.docs.len() as DocId;
        let tokens = tokenize(text);
        self.doc_len.push(tokens.len());
        let mut tf: HashMap<String, usize> = HashMap::new();
        for t in tokens {
            *tf.entry(t.clone()).or_insert(0) += 1;
            self.postings.entry(t).or_default().insert(id);
        }
        self.tfs.push(tf);
        self.docs.push(text.to_string());
        id
    }

    fn query_and(&self, terms: &[&str]) -> Vec<DocId> {
        let mut lists: Vec<&BTreeSet<DocId>> = terms
            .iter()
            .filter_map(|t| self.postings.get(*t))
            .collect();
        if lists.len() < terms.len() {
            return vec![];                        // 任一词不存在,AND 必为空
        }
        lists.sort_by_key(|l| l.len());           // 从最短的列表开始交,尽早缩小候选
        let mut acc: BTreeSet<DocId> = lists[0].clone();
        for other in &lists[1..] {
            acc = acc.intersection(*other).copied().collect();
            if acc.is_empty() {
                break;                            // 剪枝:空交集不用再算
            }
        }
        acc.into_iter().collect()
    }

    fn query_or(&self, terms: &[String]) -> BTreeSet<DocId> {
        let mut acc = BTreeSet::new();
        for t in terms {
            if let Some(list) = self.postings.get(t) {
                acc.extend(list.iter().copied());
            }
        }
        acc
    }
}

为什么 posting list 选 BTreeSet 而不是 HashSet:求交集两者都行,但 BTreeSet 的迭代天然有序——将来做”只搜最近 7 天的文档”这类按 doc_id 范围过滤时可以直接 range,HashSet 只能全量过滤。集合与迭代器的更多取舍见《Rust 集合与迭代器》。

反模式一:查询时全量扫描 docs 做 contains。千级文档一次查询扫几百 KB 还行,万级就开始疼,而且这条路没有任何优化空间——它就是线性扫描本身。索引的意义就是把”读时扫描”变成”写时组织”。

分词:中英文的诚实边界

fn is_cjk(c: char) -> bool {
    matches!(c as u32, 0x4E00..=0x9FFF)           // 基本区汉字,够本篇演示
}

fn tokenize(text: &str) -> Vec<String> {
    let mut tokens = Vec::new();
    let mut word = String::new();
    for ch in text.to_lowercase().chars() {
        if ch.is_ascii_alphanumeric() {
            word.push(ch);                        // 英文数字:攒成一个词
        } else if is_cjk(ch) {
            if !word.is_empty() {
                tokens.push(std::mem::take(&mut word));
            }
            tokens.push(ch.to_string());          // 中文:逐字成词(unigram)
        } else if !word.is_empty() {
            tokens.push(std::mem::take(&mut word));   // 分隔符:结算当前词
        }
    }
    if !word.is_empty() {
        tokens.push(word);
    }
    tokens
}

注意一个容易写错的地方:直接按”非字母数字”切分对中文无效——汉字本身是 alphanumeric,一整句中文会被当成一个超长 token,索引直接失效,所以中文必须显式逐字切开。

逐字切分的代价要说清楚:查”上海”会拆成”上””海”两个 token,一篇只含”海上有雾”的文档同样命中这两个 token——误召回。缓解办法是二元切分(bigram):相邻两字组合成附加 token,查询也按 bigram 匹配,精度立刻上去;再进一步就要真正的中文分词库。这是刻意留下的边界:本篇目标是索引与打分骨架,分词是另一个独立的深坑,用最诚实的方式绕开它。

BM25:给”命中”排个序

布尔查询只回答”有没有”,不回答”哪个更相关”。拿词频直接排序是错的——一篇十万字的文章几乎必然包含任何查询词更多次。反模式二:拿 tf 排序不做事后校正。BM25 用两个旋钮治这个病:k1 让词频的贡献饱和(出现 3 次和出现 300 次差别不大),b 让文档长度归一(长文档不占便宜):

const K1: f64 = 1.5;   // 词频饱和度
const B: f64 = 0.75;   // 长度归一强度

impl Index {
    fn avgdl(&self) -> f64 {
        if self.doc_len.is_empty() {
            return 1.0;
        }
        let total: usize = self.doc_len.iter().sum();
        (total as f64 / self.doc_len.len() as f64).max(1.0)
    }

    fn bm25(&self, terms: &[String], doc: DocId) -> f64 {
        let n = self.docs.len() as f64;
        let dl = self.doc_len[doc as usize] as f64;
        let avgdl = self.avgdl();
        let mut score = 0.0;
        for term in terms {
            let df = self.postings.get(term).map_or(0, |s| s.len()) as f64;
            if df == 0.0 {
                continue;
            }
            // idf:越少见的词信息量越大(与 tantivy/Lucene 同款公式)
            let idf = ((n - df + 0.5) / (df + 0.5) + 1.0).ln();
            let tf = self.tfs[doc as usize].get(term).copied().unwrap_or(0) as f64;
            score += idf * tf * (K1 + 1.0)
                / (tf + K1 * (1.0 - B + B * dl / avgdl));
        }
        score
    }

    fn search(&self, query: &str, top_k: usize) -> Vec<(DocId, f64)> {
        let terms = tokenize(query);
        let mut scored: Vec<(DocId, f64)> = self
            .query_or(&terms)                     // 候选集:任一词命中
            .into_iter()
            .map(|id| (id, self.bm25(&terms, id)))
            .collect();
        scored.sort_by(|a, b| b.1.total_cmp(&a.1));   // 分数降序
        scored.truncate(top_k);
        scored
    }
}

跑起来

fn main() {
    let mut idx = Index::default();
    idx.add("the quick brown fox jumps over the lazy dog");
    idx.add("quick quick quick dog");
    idx.add("a lazy cat sleeps");
    idx.add("上海花园的樱花开了,海上有雾");
    for (id, score) in idx.search("quick dog", 3) {
        println!("{score:.3}  #{}  {}", score, idx.docs[id as usize]);
    }
    for (id, score) in idx.search("上海", 3) {
        println!("{score:.3}  #{}  {}", score, idx.docs[id as usize]);
    }
}

Cargo.toml 不需要任何依赖。单元测试验证三条硬语义:

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn and_query_requires_all_terms() {
        let mut idx = Index::default();
        idx.add("quick brown");
        idx.add("quick dog");
        assert_eq!(idx.query_and(&["quick", "dog"]), vec![1]);
    }

    #[test]
    fn missing_term_means_empty_and() {
        let idx = Index::default();
        assert!(idx.query_and(&["nope"]).is_empty());
    }

    #[test]
    fn bm25_prefers_relevant_short_doc() {
        let mut idx = Index::default();
        idx.add("dog");
        idx.add("dog dog dog cat elephant giraffe zebra lion tiger");   // 长且稀释
        assert_eq!(idx.search("dog", 1)[0].0, 0);   // 短而准的排第一
    }
}

测试的组织方式(单测贴着代码、cargo test 怎么跑)见《Cargo 与测试》。本篇代码全程没用到 Result——所有操作都不可失败;等你把索引落盘、读取外部文件时它就会登场,系统讲法见《Rust 错误处理》。

与 tantivy 对比:什么时候直接用库

维度 本篇迷你引擎 tantivy
存储 全内存,进程退出即失 段文件落盘,支持增量提交
分词 逐字/空格的诚实近似 内建多语言 tokenizer 生态
打分 手写 BM25 演示口径 BM25 内建,参数可调
并发 单线程假设 多线程索引、并发搜索是设计目标
依赖 0 个 crate 一串依赖,编译时间与二进制体积都实在

诚实结论:这个迷你引擎的合理活动范围是”几千到几万篇文档、单进程、可全量重建索引”——配置项检索、小型站内搜索、内存里的过滤层。超出范围(百万文档、要持久化、要增量更新、中文分词质量要求高),直接用 tantivy,别自己补课;它尚处 1.0 之前的活跃开发期,小版本间有破坏性变更,所以这里不钉版本号,用法以官方文档为准。想把这个引擎挂到 HTTP 上变成搜索服务,《Axum 入门》是当前 Rust 后端的默认入口。

总结

  • 搜索引擎的本质是时间换空间再换时间:写时组织 posting list,读时只碰候选集
  • posting list 用 BTreeSet:有序性是免费送的,范围过滤和有序归并将来都用得上
  • 中文按”非字母数字”切分是错的——汉字是 alphanumeric,必须显式处理,逐字切分是最诚实的起点
  • BM25 的 k1 和 b 分别治”词频无限加权”与”长文档偏置”,比裸 tf 排序合理得多
  • 知道什么时候该换库,比会用库更重要:规模、持久化、分词质量任何一项越界,就去用 tantivy

版本核实(以官方文档为准):本篇代码仅用 Rust 标准库(std::collections),不依赖第三方 crate,近期稳定版工具链均可编译;对比对象 tantivy 处于 1.0 之前的活跃开发期,小版本间存在破坏性变更,故不钉具体版本号。文中性能数字均为估算口径,本站未实测。

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇