Rust in Everything
基于 tantivy 0.27.0

本站的搜索模块

主线位置:写入主线和查询主线的使用端。写入一侧,本站每次 upsert 都走一遍完整的写入主线(Index::writer → add_document → commit),等后台合并做完再把 writer 丢掉;查询一侧,QueryParser::parse_query → Searcher::search(&query, &TopDocs) → searcher.doc(addr),和一次查询的路径相同。这一页不再走 tantivy 内部的各步,只看本站的调用方式带来了什么。

flowchart TB
  timer["每 600 s"] --> inc["增量刷新"]
  inc --> diffs["diff_for_reindex"]
  diffs --> up["upsert_documents"]
  diffs --> del["delete_documents"]
  up --> nw["new_writer 50 MB"]
  del --> nw
  nw --> cm["commit"]
  cm --> wm["等合并"]
  wm --> rl["reader.reload"]
  req["search_query"] --> q["engine.query"]
  q --> esc["截断与转义"]
  esc --> qp["QueryParser"]
  qp --> top["TopDocs 前 N 条"]
  top --> snip["make_snippet"]

搜索模块在框架仓库的 crates/modules/search,用到 tantivy 的是三个文件:engine.rs(1063 行,第 556 行起是单元测试)管 schema、写入和查询;indexer.rs(777 行)收集文档、算增量差分;server.rs(193 行)是两个 server function。行号会变,下面按函数名指代。

本站从 0.26.1 升到了 0.27.0,tantivy-jieba 仍是 0.19.0。这一页按框架仓库 main 分支当前的代码(fad48a5)讲;Cargo.toml 写的是 tantivy = "0.27",默认 feature 全开。

实验的做法:把 engine.rs 里的 build_schema、open_or_create、replace_all、upsert_documents、delete_documents、query、escape_query、parser_safe 和 text.rs 原样复制进一个独立的 crate(exp-tantivy/site-lab,不依赖本站的任何 crate),tracing 换成 eprintln,再加两个开关:提交后怎么处理 writer(直接 drop,即早先的写法;先 wait_merging_threads,即现在的写法;或者整个引擎只用一个 writer)、注册哪个分析器。语料按 indexer.rs 的规则读本机的两个仓库:博客、五个板块和文档共 1197 篇(不含正在写的 tantivy 卷),案例 17 篇(读 assets/cases 下的 case.yaml 和 README.md,按 main.rs 注册的格式拼正文)。课程本机没有,论坛主题在线上数据库里,都不算。平台 M2 Max、macOS 27、rustc 1.99.0,release 构建;线程数和段数依赖核数(本机 available_parallelism() 为 12),计时时其他写手的实验在并行跑,1 分钟负载 2 到 11 之间,计时只看同一个进程里的前后对比。同一份代码另编了一份 0.26.1 的(site-lab-026)。

模块的样子

build_schema 建七个字段:

字段选项用途
doc_uidSTRING | STORED{kind}:{ref_id},delete_term 用它
kindSTRING | STORED | FASTblog、doc、case、板块名等,查询时按它过滤
ref_id、url、created_atSTRING | STORED只是取回来显示
title、body分词器 jieba_lower,WithFreqsAndPositions,STORED全文检索

模块注释和测试名(build_schema_has_six_fields)说的是六个字段,那是不算 doc_uid 的数法。STRING 是 raw 分词器,整串一个词,所以 delete_term(Term::from_field_text(doc_uid, "blog:hello")) 能精确命中一篇。换成分词字段,term 就得是分词后的某一个词,会删掉所有含这个词的文档(删除按 term 走的机制见提交)。

open_or_create 做四件事:

  • MmapDirectory::open(dir),目录来自环境变量 SEARCH_INDEX_DIR,默认 data/search-index;
  • Index::open_or_create(mmap, schema);返回 TantivyError::SchemaError 时(磁盘上的索引是另一个 schema)清空目录、重建一个空索引;
  • 注册分析器:JiebaTokenizer::default() 后接 LowerCaser,名字是 jieba_lower。分词器名存在 schema 里,分析器本身不在(见分词),所以每次打开都要重新注册;源码注释说,加 LowerCaser 时特意把名字从 jieba 改成 jieba_lower,好让旧索引走 schema 不匹配的分支被清空重建。换分析器的行为而不换名字,旧索引会被原样打开,新旧词元混在一起;
  • reader 用 ReloadPolicy::Manual,每次提交后由自己调 reader.reload(),不监听 meta.json。

最后用 reader.searcher().num_docs() == 0 判断是不是新索引,是就全量填充。引擎是一个全局单例,第一次查询时由 get_or_build 建起来,一把 tokio 的 Mutex 保证只有一个调用者去初始化。

写入:每次一个新 writer

三个写入函数共用同一个形状。upsert_documents:

  pub fn upsert_documents(&self, docs: &[IndexedDocument]) -> Result<usize, String> {
    if docs.is_empty() {
      return Ok(0);
    }
    let mut writer = self.new_writer()?;
    for d in docs {
      let term = Term::from_field_text(self.fields.doc_uid, &doc_uid(&d.kind, &d.ref_id));
      writer.delete_term(term);
      self.add_one(&mut writer, d)?;
    }
    self.commit_and_reload(writer)?;
    Ok(docs.len())
  }

  /// 提交并刷新 reader。writer 每次用完即弃,所以要等 commit 触发的后台合并做完再 drop,
  /// 否则合并结果被丢掉,段只增不减,删掉的旧版本也一直留在段里。
  fn commit_and_reload(&self, mut writer: IndexWriter) -> Result<(), String> {
    writer.commit().map_err(|e| format!("search: commit failed: {}", e))?;
    writer.wait_merging_threads().map_err(|e| format!("search: merge failed: {}", e))?;
    self.reader.reload().map_err(|e| format!("search: reader reload failed: {}", e))
  }

  fn new_writer(&self) -> Result<IndexWriter, String> {
    self.index.writer(50_000_000).map_err(|e| format!("search: writer init failed: {}", e))
  }

(本站代码是 2 空格缩进。)delete_documents 只有 delete_term,replace_all 先 delete_all_documents 再逐篇 add_document,三者最后都交给 commit_and_reload:提交,IndexWriter::wait_merging_threads 消费掉 writer、等这次提交引发的合并全部落地,再 reload,所以 reader 拿到的是合并后的段。配套的测试 repeated_upserts_keep_segments_merged 连续 upsert 20 篇,断言段数不超过 8。commit_and_reload 在调用它的 async 函数里同步执行,没有 spawn_blocking,提交和等合并的时间都占着一个 tokio 工作线程。

IndexWriter::delete_term 在 add_document 之前领 opstamp,所以删除只作用于它之前加进来的文档,紧接着加的新版本不受影响(opstamp 的规则见提交)。writer(50_000_000) 在 12 核上是 3 个索引线程,加上 segment_updater、4 个合并线程和 3 个压缩线程,一个 writer 一共 11 个线程(IndexWriter);每个索引线程交一个段,所以一次提交最多多出 3 个段。

谁调这些函数?main.rs 起一个 tokio::time::interval(600 s) 的循环,每轮调 reindex_incremental。它读索引目录下的 manifest.json,交给 diff_for_reindex:

  • 文件来源(博客、板块、文档)按 mtime 比,变了的 upsert,manifest 里有、磁盘上没有的删除;
  • 动态来源(案例、课程、论坛主题)没有可靠的版本号,每一轮全部 upsert,只靠上一轮的 uid 集合发现删除。

然后 upsert_documents(&diff.upserts) 一个 writer、一次提交,delete_documents(&diff.deletes) 再一个(列表为空就跳过)。interval 的第一个 tick 立即完成,所以每次启动也会先刷新一轮。

早先的写法:提交完直接 drop

commit_and_reload 是 fad48a5 合进 main 的。早先的写法是 commit 后直接 reader.reload(),函数返回时 writer 出作用域被 drop,中间没有 wait_merging_threads。下面几节用复制出来的引擎,按早先的写法量这一行缺了什么。

合并里的实验是「每次 upsert 一篇、旧版本在大段里」:40 次之后 43 个段。本站的刷新节奏不一样:每轮把同一批动态文档全部重写一遍。按这个节奏跑 144 轮(10 分钟一轮,正好一天;轮间停 200 ms),只刷新 17 篇案例:

file docs 1197, dyn docs 17
    full: segs= 11 alive/max_doc=1214/1214 files=  74 dir= 22.97MB df(wasmtime)=  5 top=case:wasmtime 39.3541 1696.7ms
  tick 1: segs= 14 alive/max_doc=1214/1231 files=  97 dir= 24.55MB df(wasmtime)=  6 top=case:wasmtime 37.5292  346.3ms
  tick 2: segs= 14 alive/max_doc=1214/1231 files= 121 dir= 31.96MB df(wasmtime)=  6 top=case:wasmtime 37.5292  371.9ms
...
 tick 72: segs= 14 alive/max_doc=1214/1231 files= 112 dir= 22.49MB df(wasmtime)=  6 top=case:wasmtime 37.5292  267.4ms
...
tick 144: segs= 14 alive/max_doc=1214/1231 files= 112 dir= 21.24MB df(wasmtime)=  6 top=case:wasmtime 37.5292  234.6ms
...
     end: segs= 14 alive/max_doc=1214/1231 files= 114 dir= 41.03MB df(wasmtime)=  6 top=case:wasmtime 37.5292    0.0ms

(每行:段数、存活文档数/各段 max_doc 之和、目录里的文件数和总大小、body 字段里 wasmtime 这个词的 doc_freq、查 wasmtime 的第一名和分数、这一轮的耗时。)

  • 全量填充之后这次是 11 个段(每次跑在 10 到 12 之间,看线程怎么分文档):replace_all 的 writer 一样被 drop,填充期间起的合并也丢了。
  • 第 1 轮多出 3 个段(3 个索引线程各交一个),17 篇案例的旧版本被标记删除,留在全量填充的那几个大段里,max_doc 比存活多 17。
  • 从第 2 轮起,段数和 max_doc 都不再变。每一轮删掉的,正是上一轮写进那 3 个小段的全部文档;一个段的文档全被删掉,提交时整个段就从 meta.json 里去掉了。

去掉空段的是 SegmentManager::remove_empty_segments,在 committed_segment_metas 里调用,每次 save_metas 写 meta.json 之前都会走到:

    fn remove_empty_segments(&self) {
        let mut registers_lock = self.write();
        registers_lock
            .committed
            .segment_entries()
            .iter()
            .filter(|segment| segment.meta().num_docs() == 0)
            .for_each(|segment| {
                registers_lock
                    .committed
                    .remove_segment(&segment.segment_id())
            });
    }
📝NOTE
冷知识:删空的段在提交时消失,是 0.9 才有的

这个函数来自 #472(222b7f258,标题「Tantivy-288 (#472)」,提交说明里列了一条「added SegmentManager#remove_empty_segments」),首次发布于 0.9。之前,一个段的文档全被删掉以后,它仍然留在 meta.json 里,文件也留在目录里,要等合并把它并掉才消失。这个提交在 committed_segment_metas 开头加了一次 remove_empty_segments(),同时加了一个叫 delete_all_docs 的测试,注释写着「empty segments should be erased」。早先的写法下,动态来源「每轮整批重写」时段数能停在 14,靠的就是这一行。(仓库里还有一个 2019-11 打的 0.1 标签也包含这个提交,按日期看不是首发版本。)

段数不涨,合并的事却每轮都在做。打开 tantivy 的 debug 日志跑 3 轮(这一次全量填充后是 10 个段,第 1 轮后 13 个):

[ 1131.1ms DEBUG segment_updater tantivy::indexer::segment_updater] Starting merge  - [Seg("37ff0eb6"), Seg("f10fde56"), ...
[ 1206.9ms DEBUG main tantivy::directory::mmap_directory] Releasing lock ".tantivy-writer.lock"
[ 1354.6ms DEBUG segment_updater tantivy::indexer::segment_updater] Starting merge  - [Seg("37ff0eb6"), Seg("f10fde56"), ...
[ 1392.2ms DEBUG main tantivy::directory::mmap_directory] Releasing lock ".tantivy-writer.lock"
[ 1787.9ms DEBUG segment_updater tantivy::indexer::segment_updater] Starting merge  - [Seg("37ff0eb6"), Seg("f10fde56"), ...
[ 1877.5ms DEBUG main tantivy::directory::mmap_directory] Releasing lock ".tantivy-writer.lock"

每次提交,默认的 LogMergePolicy 都起一次合并:全量填充、第 1、2、3 轮的四次提交,分别合并 8、10、13、13 个段(每行只留了前两个段 id),从第 2 轮起就是全部的段;几十毫秒后 writer 被 drop、释放目录锁,日志里没有一条 End merge。合并线程照样把新段写完,结果没人认领(机制见合并的「wait_merging_threads 与 Drop」)。下一轮的新 writer 不知道这些,又对同一批段发起合并。所以:

  • 每一轮都把整个索引重写成一个新段再扔掉,占一个合并线程几百毫秒;
  • 两轮之间目录里多躺着一份孤儿段:上面 tick 144 是 21.24 MB,停 500 ms 等合并写完后 end 变成 41.03 MB,多出约 20 MB,下一次提交的 GC 才把它删掉。

夹带一篇改过的文件:段数和删除一起涨

早先的写法下,只要某一轮的批里有一篇会活下去的文档,那一轮的段就删不空。把条件改成每轮再 upsert 一篇文件来源(相当于每 10 分钟改一篇文章,是上限;实际改文件跟着部署走),在同一个进程里先跑 144 轮,再把所有段合并成一个,前后各测一次查询(每个查询 50 次取最小):

after full build: segments 11 num_docs 1214 max_doc 1214 df(body:wasmtime) 5
  rust         min   202us/50  top3 ["blog:rust-iters 13.94", "blog:truth-rust 13.25", "blog:pattern-matching-in-rust 13.17"]
  异步 运行时       min   399us/50  top3 ["doc:tauri/04-app/async-runtime 56.57", "doc:tokio/01-overview/runtime-anatomy 48.28", "doc:tauri/03-runtime 41.56"]
  wasmtime     min    47us/50  top3 ["case:wasmtime 39.35", "wasm:wasmtime-plugin-sandbox 26.31", "blog:wasmi-1-0 9.50"]
  所有权 借用       min   290us/50  top3 ["doc:tenferro/02-tensor/storage 73.71", "blog:rust-ownership 67.50", "doc:serde/02-core/lifetimes 28.89"]
  embassy      min   251us/50  top3 ["case:embassy 26.69", "doc:embassy/04-sync/futures 23.71", "doc:embassy 22.68"]
after 144 ticks: segments 157 num_docs 1214 max_doc 2035 df(body:wasmtime) 50
  rust         min   428us/50  top3 ["blog:rust-iters 13.70", "blog:truth-rust 12.73", "blog:pattern-matching-in-rust 12.68"]
  异步 运行时       min   859us/50  top3 ["doc:tauri/04-app/async-runtime 57.32", "doc:tokio/01-overview/runtime-anatomy 47.26", "doc:tauri/03-runtime 43.06"]
  wasmtime     min   142us/50  top3 ["case:wasmtime 24.20", "wasm:wasmtime-plugin-sandbox 15.37", "blog:wasmi-1-0 6.27"]
  所有权 借用       min   557us/50  top3 ["doc:tenferro/02-tensor/storage 72.57", "blog:rust-ownership 67.21", "doc:serde/02-core/lifetimes 27.81"]
  embassy      min   310us/50  top3 ["case:embassy 21.44", "doc:embassy/04-sync/futures 18.58", "doc:embassy 17.78"]
merge of 157 segments took 405ms
after merging all: segments 1 num_docs 1214 max_doc 1214 df(body:wasmtime) 5
  rust         min   148us/50  top3 ["blog:rust-iters 13.94", "blog:truth-rust 13.25", "blog:pattern-matching-in-rust 13.17"]
...
  wasmtime     min    33us/50  top3 ["case:wasmtime 39.35", "wasm:wasmtime-plugin-sandbox 26.30", "blog:wasmi-1-0 9.46"]
...
  • 段:144 轮后 157 个。每轮新出的 3 个段里,装着那篇文件的一个活下来,另外两个下一轮被删空、去掉,于是每轮净增一个。
  • 删除:max_doc 2035,比存活的 1214 多 821 篇,都是和那篇文件同段的案例旧版本。默认 LogMergePolicy 的 DEFAULT_DEL_DOCS_RATIO_BEFORE_MERGE 是 1.0,判断用 >,删除比例永远到不了,只有合并能清掉它们,而合并全被丢掉了。
  • BM25:被删的文档在合并前仍计入 doc_freq 和文档总数(fieldnorm 与删除位图)。wasmtime 的 doc_freq 从 5 涨到 50,IDF 跟着降,case:wasmtime 的分数从 39.35 掉到 24.20,同一个词的另外两篇也掉了三到四成。掉得多的是词出现在动态文档里的查询(wasmtime、embassy),rust 这种出现在上千篇里的词几乎不动。这几个查询的前三名顺序没变,但分数尺度变了,词在不在动态文档里会决定它在混合查询里占多大分量。
  • 查询耗时:157 个段时,五个查询是合并成一个段之后的 1.3 到 4.3 倍(embassy 241 → 310 µs,wasmtime 33 → 142 µs;省略号省掉的三行是 异步 运行时 296 µs、所有权 借用 227 µs、embassy 241 µs)。全部合并只要 405 ms,合并后分数回到起点。

用 0.26.1 编同一份代码跑同一个程序:144 轮后同样 157 个段,max_doc 1975,wasmtime 的 doc_freq 46、分数 24.61,合并后 39.35,形状一致(差别来自每轮哪几篇落进同一个段,跟线程调度有关)。

等合并之后

现在的写法(commit_and_reload)和早先的写法,再加上「整个引擎只用一个 writer、用 Mutex 包起来反复提交」作对照,各跑 48 轮(每轮夹带一篇文件,轮间停 1.5 s,比一次合并的耗时长,接近真实的 10 分钟间隔):

ticks-drop-edit1-s1500.txt:    full: segs= 10 alive/max_doc=1214/1214 files=  68 dir= 23.16MB df(wasmtime)=  5 top=case:wasmtime 39.3541  980.1ms
ticks-drop-edit1-s1500.txt: tick 48: segs= 60 alive/max_doc=1214/1534 files= 408 dir= 23.26MB df(wasmtime)= 23 top=case:wasmtime 27.9842  257.6ms
ticks-drop-edit1-s1500.txt:tick ms: first10 median 238.1, last10 median 257.6, overall median 248.6, total 12015
ticks-wait-edit1-s1500.txt:    full: segs=  3 alive/max_doc=1214/1214 files=  22 dir= 20.13MB df(wasmtime)=  5 top=case:wasmtime 39.3541 1138.6ms
ticks-wait-edit1-s1500.txt:  tick 9: segs=  4 alive/max_doc=1214/1232 files=  29 dir= 19.94MB df(wasmtime)=  6 top=case:wasmtime 37.5286  190.9ms
ticks-wait-edit1-s1500.txt: tick 48: segs=  1 alive/max_doc=1214/1214 files=  10 dir= 19.88MB df(wasmtime)=  5 top=case:wasmtime 39.3489  444.6ms
ticks-wait-edit1-s1500.txt:tick ms: first10 median 223.1, last10 median 216.7, overall median 216.7, total 12402
ticks-long-edit1-s1500.txt:  tick 9: segs=  4 alive/max_doc=1214/1232 files=  29 dir= 19.93MB df(wasmtime)=  6 top=case:wasmtime 37.5286  166.8ms
ticks-long-edit1-s1500.txt: tick 48: segs=  8 alive/max_doc=1214/1251 files=  55 dir= 20.07MB df(wasmtime)=  7 top=case:wasmtime 36.1595  213.4ms
ticks-long-edit1-s1500.txt:tick ms: first10 median 205.4, last10 median 214.1, overall median 200.3, total 9564

(drop 是早先的写法,wait 是现在的写法,long 是一个长命 writer。)

  • 早先的写法:48 轮 60 个段,和上一节一样每轮加一。
  • 现在的写法:段数在 1 到 8 之间来回,凑够 8 个就合并成 1 个,删除随合并清掉,doc_freq 和分数回到起点;第 48 轮正好轮到合并,这一轮 444.6 ms,其余轮 200 ms 上下。全量填充也只剩 3 到 4 个段,两轮之间目录里不再有孤儿段。
  • 长命 writer:同样被压在 8 以下。
  • 每轮的中位耗时,早先的写法 248.6 ms,另两种 216.7 ms 和 200.3 ms:早先每轮在后台多做一次全量合并,和提交抢 CPU。
📝NOTE
没有蠢问题

问: 长命 writer 每轮还省掉建线程的开销,本站为什么选了每次等合并?

答: 等合并只改一个函数,不动模块的其他假设。长命 writer 要一直攥着 .tantivy-writer.lock,全量重建、增量刷新、删除都得经过同一把 Mutex 拿它,get_or_build 那套「并发首查只让一个人建 writer」的逻辑也要跟着改;它的合并在后台落地,ReloadPolicy::Manual 的 reader 看不见,要等下一次提交后的 reload(实验里不手动 reload 时,合并已经把 meta.json 变成 1 个段,searcher 里 wasmtime 的 doc_freq 还是 6)。而且提交间隔比合并耗时短时,长命 writer 也压不住段数:轮间只停 200 ms、每轮夹带一篇文件跑 144 轮,结束时 150 个段,wait_merging_threads 之后也还是 150 个。debug 日志里每次合并结束都跟着两行:

[ 2264.9ms WARN  segment_updater tantivy::indexer::segment_manager] segment_ids: [Seg("423ee717"), Seg("9c2e1857"), Seg("9fc3182f"), Seg("a101e66f"), Seg("c1ab9f4d"), Seg("8ee64d78"), Seg("11d06498"), Seg("4a7754f5")], committed_ids: [Seg("8ee64d78"), Seg("11d06498"), Seg("9fc3182f"), Seg("c1ab9f4d"), Seg("f3d685ff"), Seg("56678ba6"), Seg("b229ceea"), Seg("423ee717"), Seg("9c2e1857")], uncommitted_ids []
[ 2265.3ms WARN  segment_updater tantivy::indexer::segment_manager] couldn't find segment in SegmentManager

合并的输入里有两个段(a101e66f、4a7754f5)在合并进行时被下一次提交删空,按上面的 remove_empty_segments 去掉了;SegmentManager::end_merge 找不全输入段,返回错误,合并出来的新段就用不上。本站每次等合并做完才返回,下一次提交不会和合并重叠,没有这个问题。

分词:一半的词元是空白和标点

分词已经量过:jieba 把空格、标点也当词输出。这里换成本站真正写进索引的文本(markdown_to_plain 处理后的标题和正文,1214 篇),再建两个索引对照:jieba_lower,以及在它后面加一个 filter、丢掉不含任何 char::is_alphanumeric 字符的词元:

docs 1214 chars 11347104 tokens 5705374 without alnum 3072295 (53.8%), of which whitespace-only 1384090 (24.3%)
jieba_lower: segments 4 dir 20.14MB body total_num_tokens 5696560 avg 4692/doc
+HasAlnum: segments 6 dir 17.04MB body total_num_tokens 2627124 avg 2164/doc

570 万个词元里 53.8% 不含字母数字,其中纯空白的占全部的 24.3%(markdown_to_plain 保留换行,代码块以外的缩进和空格也都在)。它们都进了倒排表,也都算进 fieldnorm:body 的平均长度是 4692 个词元,去掉之后 2164;索引从 20.14 MB 降到 17.04 MB。

对检索结果的影响,用 14 个输入在两个索引上比:命中的文档数全部相同(不带引号时查询先按空白切开再分词,空白词元进不了查询;见下一节);有结果的 10 个查询里 8 个前 10 名的顺序变了,前 10 名的交集最少 7 篇。原因在 BM25 的长度归一化:dl / avgdl 里分子分母都被标点和空白撑大,但撑大的比例因文而异,代码多、列表多的文档空白多。

要去掉它们,tantivy 自带的 AlphaNumOnlyFilter 不能用(只认 ASCII,汉字词全被删掉,见分词),实验里用的是照着它改的一个 filter,判断换成 Unicode:

impl<T: TokenStream> TokenStream for HasAlnumStream<T> {
  fn advance(&mut self) -> bool {
    while self.tail.advance() {
      if self.tail.token().text.chars().any(char::is_alphanumeric) {
        return true;
      }
    }
    false
  }
  // ...
}

这是一次排序行为的改动,不是纯优化:命中集合不变,排序会变。接进本站时还要照 jieba_lower 当初的做法换一个分词器名,否则旧索引会被原样打开。

查询:截断、转义、短语

query 的前半段:

    let truncated: String = text.trim().chars().take(MAX_QUERY_CHARS).collect();
    let q = truncated.trim();
    if q.is_empty() {
      return Ok(vec![]);
    }

    let searcher = self.reader.searcher();
    let mut parser = QueryParser::for_index(&self.index, vec![self.fields.title, self.fields.body]);
    parser.set_field_boost(self.fields.title, 3.0);

    // ...
    let escaped = escape_query(q);
    let final_query = match kind_filter {
      Some(k) if !k.is_empty() => format!("({}) AND kind:{}", escaped, k),
      _ => escaped,
    };

    let query =
      parser.parse_query(&final_query).or_else(|_| parser.parse_query(&parser_safe(q)))?;
    let top = searcher.search(&query, &TopDocs::with_limit(limit).order_by_score())?;
  • 输入先截到 MAX_QUERY_CHARS = 256 个字符(按 char 数,不是字节)。
  • escape_query 把 16 个 ASCII 字符 + - ! ( ) { } [ ] ^ ~ * ? : \ / 一律换成空格,再交给 QueryParser;按类型过滤时外面再包一层 (...) AND kind:<k>,k 已经在 server.rs 的 normalize_kind 里按白名单校验过。解析失败时退到 parser_safe:只留字母数字、空白和非 ASCII 字符。
  • 默认字段是 title 和 body,title 加权 3 倍,多个词之间是默认的 OR;结果数由 clamp_limit 限在 1 到 50。

没被转义的语法照常生效。在复制出来的引擎上数命中的文档数:

"rust tokio": docs containing it literally    0, matched by the site's query  798
"rust AND tokio": docs containing it literally    0, matched by the site's query  201
"rust NOT tokio": docs containing it literally    0, matched by the site's query  301
"rust -tokio": docs containing it literally    0, matched by the site's query  798
"rust,tokio": docs containing it literally    0, matched by the site's query    0
"rust、tokio": docs containing it literally    0, matched by the site's query    0

AND、NOT 和双引号都还是运算符;- 被换成空格,rust -tokio 和 rust tokio 一样。全角标点不在转义表里,也不是查询语法里的分隔符,rust,tokio 整个是一个「词」。

一个「词」经分析器出来不止一个词元时,generate_literals_for_str 把它们连同 token.position 做成短语查询(字段索引了位置时)。jieba 正好会把一个不带空格的中文输入切成好几个词元,所以:

"所有权": docs containing it literally   62, matched by the site's query   61
"借用": docs containing it literally  169, matched by the site's query  169
"所有权和借用": docs containing it literally    2, matched by the site's query    2
"异步运行时": docs containing it literally   28, matched by the site's query   25
"生命周期": docs containing it literally   92, matched by the site's query   92
"智能指针": docs containing it literally    5, matched by the site's query    5
"所有权 借用": docs containing it literally    0, matched by the site's query  197

不带空格的中文输入,效果接近「原文里连着出现」:所有权和借用 只命中 2 篇,所有权借用 一篇都没有,而 所有权 借用 是两个词的 OR,命中 197 篇。少数差几篇的(异步运行时 28 对 25),是 jieba 在上下文里切法不同,比如原文的「运行时间」切成「运行」「时间」,和查询里的「运行」「时」对不上。jieba 的 position 是字符偏移(分词),查询和文档用同一个分析器,偏移一致,短语照样能对上;全文检索 切出的 全文、全文检索 在同一个位置,短语查询也能处理。查询文本里如果带引号,引号里的空格会作为一个空白词元进入短语。短语查询本身怎么按位置求交,见短语与其他查询,解析器的整条路径见查询解析器。

❗IMPORTANT
动脑筋:上面的复制引擎里,rust tokio 命中 798 篇。把中间的空格换成全角逗号 rust,tokio,kind_filter 为 None,命中几篇?

平台同上,jieba_lower 分析器,答案在文末。

高亮:自己写的 snippet

本站没有用 tantivy 的 SnippetGenerator(见高亮)。make_snippet 取查询里按空白切开后最长的一段,先在正文前 2048 字节里不分大小写地找,找不到再找全文,以命中处为中心截 200 个字符。它不看 tantivy 的词元偏移,所以 所有权 借用 的高亮只围着 所有权 转,短语查询命中的位置和高亮的位置也可能不是同一处。

从 0.26.1 到 0.27.0

对这个模块来说,升级带来的变化有四处:

项0.26.10.27.0对本站
索引格式INDEX_FORMAT_VERSION = 77旧索引直接打开
tantivy-tokenizer-api0.7.00.7.0tantivy-jieba 0.19 不用升
提交、GC、合并的日志info!debug!(#3013)默认 info 下不再输出
stemmer featurerust-stemmersfrostem(#3028)本站不用词干,只是编进来的库换了

格式一行实测过:0.26.1 编的程序建索引,0.27.0 编的程序打开、查询:

tantivy tantivy v0.26.1, index_format v7 fresh=true
segments 3 num_docs 1214
  73.7084 doc:tenferro/02-tensor/storage
  67.4961 blog:rust-ownership
  28.8853 doc:serde/02-core/lifetimes
tantivy tantivy v0.27.0, index_format v7 fresh=false
segments 3 num_docs 1214
  73.7084 doc:tenferro/02-tensor/storage
  67.4961 blog:rust-ownership
  28.8853 doc:serde/02-core/lifetimes

fresh=false 说明没有走 schema 不匹配的清空分支,分数一模一样。

日志一行:本站在 main.rs 里用 tracing_subscriber::fmt().with_env_filter(...).try_init(),默认过滤是 info。tracing-subscriber 的默认 feature 含 tracing-log,try_init 会装上 LogTracer,tantivy 经 log crate 打的记录也进了这个输出。用同样的初始化代码、一个 writer 提交一次:

2026-10-29T09:25:04.504834Z  INFO tantivy::indexer::segment_updater: save metas
2026-10-29T09:25:04.533998Z  INFO tantivy::indexer::index_writer: Preparing commit
2026-10-29T09:25:04.589621Z  INFO tantivy::indexer::index_writer: Prepared commit 2
2026-10-29T09:25:04.589646Z  INFO tantivy::indexer::prepared_commit: committing 2
2026-10-29T09:25:04.589671Z  INFO tantivy::indexer::segment_updater: save metas
2026-10-29T09:25:04.597769Z  INFO tantivy::indexer::segment_updater: Running garbage collection
2026-10-29T09:25:04.597782Z  INFO tantivy::directory::managed_directory: Garbage collect
tantivy tantivy v0.26.1, index_format v7

0.27.0 编出来的同一个程序只打印最后一行。#3013(cac4ba940,「chore: downgrade all info! logs to debug!」,首次发布于 0.27.0)把 managed_directory.rs、file_watcher.rs、index_writer.rs、prepared_commit.rs、segment_updater.rs 里的 info! 全换成了 debug!。本站每轮刷新至少一次提交,0.26.1 下这些行每 10 分钟出现一批;0.27.0 下要看它们得把 RUST_LOG 里 tantivy 调到 debug。stemmer 那一行的来历见怎么读这一卷。

前面几节在 0.26.1 上重跑过:只刷新案例的 48 轮同样停在 14 个段;夹带文件的 144 轮结果在上文;分词统计、两个索引的命中数、rust,tokio 和 所有权借用 的 0 篇都相同。

动脑筋答案

"rust tokio": docs containing it literally    0, matched by the site's query  798
"rust,tokio": docs containing it literally    0, matched by the site's query    0

0 篇。escape_query 不认全角逗号,QueryParser 也只按空白切词,rust,tokio 是一个词;jieba 把它切成 rust、,、tokio 三个词元,于是成了一个短语查询:

PhraseQuery { field: Field(4), phrase_terms: [(0, Term(body:type=Str, "rust")), (4, Term(body:type=Str, ",")), (5, Term(body:type=Str, "tokio"))], slop: 0 }

(程序把 Debug 输出里的 field=4, 换成了 body:。title 字段上还有一个同样的短语,乘 3 倍权重,两者 OR。)要命中,原文里必须连着出现 rust,tokio,本站语料里一篇也没有。0、4、5 是 jieba 的字符偏移。去掉标点词元的那个索引上,短语变成 (0, rust)、(5, tokio),同样 0 篇。

小结

  • 七个字段,doc_uid 用 STRING 做 upsert 的键;分析器是 jieba 加 LowerCaser,名字 jieba_lower,改分析器时靠改名字触发清空重建。
  • 每次写入新开一个 50 MB 的 writer(12 核上 3 个索引线程),commit_and_reload 里提交、wait_merging_threads、reload,再把 writer 丢掉;整段同步跑在 async 调用方里。
  • 早先的写法提交后直接 drop,后台合并每次都被丢掉:动态来源整批重写时段数靠 remove_empty_segments 停在 14,但每轮白白重写一遍整个索引;批里夹带一篇会活下来的文档时,144 轮后 157 个段、821 篇删除的旧版本,wasmtime 的 doc_freq 从 5 到 50,分数从 39.35 掉到 24.20,0.26.1 上形状相同。
  • 等合并之后段数压在 8 以下,删除随合并清掉,轮到合并的那一轮约 445 ms;长命 writer 在提交间隔短于合并耗时时,合并会因为输入段被删空而用不上。
  • jieba 的空白和标点占词元的一半以上,正文平均长度从 2164 撑到 4692;去掉它们命中集合不变、排序会变,需要一个按 Unicode 判断的 filter 并换分词器名。
  • 查询先截到 256 个字符、转义 16 个 ASCII 符号;AND、NOT、引号仍生效;不带空格的中文输入是短语查询,全角标点会把几个词粘成一个短语。
  • 0.26.1 和 0.27.0 的索引格式都是 v7,tokenizer-api 都是 0.7.0;0.27.0 起 tantivy 的提交日志降到 debug,stemmer 换成 frostem。

💬 关联讨论

发起讨论
加载中...