本站的搜索模块
主线位置:写入主线和查询主线的使用端。写入一侧,本站每次 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_uid | STRING | STORED | {kind}:{ref_id},delete_term 用它 |
kind | STRING | STORED | FAST | blog、doc、case、板块名等,查询时按它过滤 |
ref_id、url、created_at | STRING | 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())
});
}
这个函数来自 #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_doc2035,比存活的 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 倍(
embassy241 → 310 µs,wasmtime33 → 142 µs;省略号省掉的三行是异步 运行时296 µs、所有权 借用227 µs、embassy241 µ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。
问: 长命 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 是字符偏移(分词),查询和文档用同一个分析器,偏移一致,短语照样能对上;全文检索 切出的 全文、全文检索 在同一个位置,短语查询也能处理。查询文本里如果带引号,引号里的空格会作为一个空白词元进入短语。短语查询本身怎么按位置求交,见短语与其他查询,解析器的整条路径见查询解析器。
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.1 | 0.27.0 | 对本站 |
|---|---|---|---|
| 索引格式 | INDEX_FORMAT_VERSION = 7 | 7 | 旧索引直接打开 |
tantivy-tokenizer-api | 0.7.0 | 0.7.0 | tantivy-jieba 0.19 不用升 |
| 提交、GC、合并的日志 | info! | debug!(#3013) | 默认 info 下不再输出 |
stemmer feature | rust-stemmers | frostem(#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。