Rust in Everything
基于 tantivy 0.27.0

高亮

主线位置:查询主线之后。searcher.doc(addr) 取回原文以后,SnippetGenerator 从原文里挑一段包含查询词的文字,把词用 <b> 标出来,做成搜索结果下面那两行摘要。它不读倒排表、不读位置,只用查询里的词、整个 Searcher 的文档频率和这个字段的分词器,在原文上重新分一遍词。

flowchart TB
  q["Query"] --> qt["query_terms 收集词"]
  qt --> df["doc_freq 算权重"]
  df --> gen["SnippetGenerator"]
  doc["存储的原文"] --> gen
  gen --> tok["重新分词"]
  tok --> frag["按窗口切片段"]
  frag --> best["选总分最高"]
  best --> html["to_html 合并并转义"]

整个功能在一个文件里:src/snippet/mod.rs,946 行,一半是测试,入口是 SnippetGenerator。实验在 exp-tantivy/srchb-lab:英文用 wiki.json 1000 篇(body 是 TEXT | STORED);中文是本页自己写的一段 147 个字的说明文字,字段用 jieba 加 LowerCaser,和分词里的注册方式一样。平台 M2 Max、macOS 27、rustc 1.99.0,release。

建生成器:词和权重

SnippetGenerator::create 做三件事:

        let mut terms: BTreeSet<&Term> = BTreeSet::new();
        query.query_terms(&mut |term, _| {
            if term.field() == field {
                terms.insert(term);
            }
        });
        let mut terms_text: BTreeMap<String, Score> = Default::default();
        for term in terms {
            // ...
            let doc_freq = searcher.doc_freq(term)?;
            if doc_freq > 0 {
                let score = 1.0 / (1.0 + doc_freq as Score);
                terms_text.insert(term_str.to_string(), score);
            }
        }
        let tokenizer = searcher.index().tokenizer_for_field(field)?;
  1. 用 Query::query_terms 收集查询里这个字段上的词。别的字段上的词不算:查询是 title:old,给 body 建的生成器什么词都没有。
  2. 每个词的权重是 1 / (1 + doc_freq):越少见越重要,和 idf 是同一个直觉,但不是 BM25。doc_freq 来自整个 Searcher,索引里一篇都没有的词直接丢掉。
  3. 取这个字段的分词器,后面在原文上重新分词要用。

query_terms 只有一部分查询实现了:TermQuery、PhraseQuery、PhrasePrefixQuery(只报前面的完整词,不报前缀)、TermSetQuery、BooleanQuery 和各种包装。FuzzyTermQuery、RegexQuery、RangeQuery 用的是默认的空实现:

--- fuzzy olf~1: is_empty=true html=""

短语查询报的是组成短语的每个词,高亮时不检查它们是不是挨着。"old man" 和不加引号的 old man 生成的片段完全一样,后面那个孤零零的 old(在 old mand 里)也被标出来了:

--- "old man" max=150 range=2228..2375 highlighted=[97..100, 101..104, 128..131]
Pitha&quot;, a lunatic. The village elders after analysing the document, asked Sundarar to follow the <b>old</b> <b>man</b>. Sundarar followed the <b>old</b> mand and served

切片段:一个窗口接一个窗口

snippet_from_doc 把文档里这个字段的所有文本值用空格接起来、去掉首尾空白,交给 snippet。文档必须是从文档存储取回的,字段要 STORED;没存原文的话可以自己拿到文本、直接调 snippet(text)。

search_fragments 只扫一遍分词结果:

    while let Some(next) = token_stream.next() {
        if (next.offset_to - fragment.start_offset) > max_num_chars {
            if fragment.score > 0.0 {
                fragments.push(fragment)
            };
            fragment = FragmentCandidate::new(next.offset_from);
        }
        fragment.try_add_token(next, terms);
    }

当前片段装不下下一个词,就收起来、从这个词开始一个新片段。片段之间不重叠,也不是「以命中词为中心」的滑动窗口:第一个片段从原文第 0 字节开始,之后每个片段从某个词的开头开始,词落在哪个窗口里是由前面的文字长度决定的。try_add_token 把词小写后查权重表,命中就累加分数、记下字节区间。一个片段里同一个词出现三次就加三次。上限默认是 DEFAULT_MAX_NUM_CHARS = 150。

select_best_fragment_combination 取总分最高的片段,同分取最靠前的。函数名里的 combination 名不副实:它只挑一个片段,不会把几段用「...」拼起来(模块文档还这么写)。结果是一个 Snippet:片段文字、片段在原文里的字节区间 fragment_range(0.27.0 新增,#3134),以及相对片段起点的高亮区间。没有任何片段命中时返回空的 Snippet,to_html() 是空字符串,不会退回「原文的前 150 个字」。

wiki 的 doc 225(正文 5407 字节)上:

--- old man max=150 range=2228..2375 highlighted=[97..100, 101..104, 128..131]
Pitha&quot;, a lunatic. The village elders after analysing the document, asked Sundarar to follow the <b>old</b> <b>man</b>. Sundarar followed the <b>old</b> mand and served
--- old man max=60 range=2311..2368 highlighted=[14..17, 18..21, 45..48]
to follow the <b>old</b> <b>man</b>. Sundarar followed the <b>old</b> mand and
--- temple max=150 range=0..148 highlighted=[17..23, 50..56, 107..113]
Kripapureeswarar <b>Temple</b>

Kripapureeswarar Perumal <b>Temple</b> (also called Arutkondanathar or Thiruvennainallur <b>temple</b>) in Thiruvennainallur, a panchayat

temple 的片段里有换行:片段是原文的一段原样切片,换行和多余的空白都留着,前端显示前要自己处理。

📝NOTE
没有蠢问题

问: 我自己注册了一个 n-gram 分词器,to_html 偶尔 panic:「end byte index 3 is out of bounds for string of length 2」。是我用错了吗?

答: 不是,这是 0.27.0 的一个边界问题。n-gram 在同一个位置先输出长词、再输出下一个位置的短词,try_add_token 每次都把片段终点设成「当前词的终点」,短词会把终点往回拉;如果被拉回去之前,长词正好是命中的词,高亮区间就超出了片段。最小的复现是 NgramTokenizer::new(1, 3, false)、原文 abc、查 abc、set_max_num_chars(2):

max=150: fragment="abc" highlighted=[0..3]
  html=<b>abc</b>
max=2: fragment="ab" highlighted=[0..3]

thread 'main' (6419955) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tantivy-0.27.0/src/snippet/mod.rs:160:57:
end byte index 3 is out of bounds for string of length 2

tag 之后 main 上的 9f2f1b2f6(「Fix snippet panic with overlapping tokens」)把终点改成只增不减(self.stop_offset.max(token.offset_to)),尚未发版。会输出重叠词的分词器都可能碰到。tantivy-jieba 0.19 的 JiebaTokenizer::new() 默认是搜索模式,会在「中华」「华人」「人民」「共和」「共和国」之后再输出「中华人民共和国」,触发的是另一处:盖住前面几个词的长词起点落在片段起点之前,同一个文件第 263 行的减法下溢,9f2f1b2f6 没有覆盖这种情况,见常见坑。

to_html:合并区间、转义

to_html 先把高亮区间交给 collapse_overlapped_ranges 排序、去重、合并,再交替输出「转义后的普通文字」和「前缀 + 转义后的高亮文字 + 后缀」。转义用 htmlescape::encode_minimal,所以原文里的 " 变成了上面的 &quot;。前后缀默认 <b>、</b>,可以用 set_snippet_prefix_postfix 换掉;前后缀本身不转义。

合并只发生在区间真正重叠时,merge_overlapping_ranges 的判断是 last.end > range.start。首尾相接的两个区间不合并。

📝NOTE
冷知识:文档说相邻的区间会合并,代码从来不合并

collapse_overlapped_ranges 是 #1473(17093e8ff,「Collapse overlapped highlighted ranges」,首次发布于 0.19)加的。在那之前 to_html 直接按顺序遍历 self.highlighted,每次输出 fragment[start_from..item.start];n-gram 这类分词器会产生互相重叠的词,后一个区间的起点小于前一个的终点,切片区间就倒过来了。当时的注释写的是「[0..1, 1..2] -> [0..1, 1..2] # no overlap」,和代码一致。#2474(95a4ddea3,「Fix: Improve collapse_overlapped_ranges function」,首次发布于 0.24)把它拆成先排序去重、再合并两步,判断仍是严格的 >,代码里的注释也写着「Do not overlap or only adjacent, add new scope.」;但函数文档的例子改成了「[0..1, 1..2] -> [0..2] # adjacent, merged」。0.27.0 的文档和代码就这样互相矛盾。下面的中文例子里,jieba 把「倒排索引」切成首尾相接的「倒排」「索引」,输出的是两对标签。

中文:150 是字节

jieba 给的 offset_from、offset_to 是字节偏移,切片段时 next.offset_to - fragment.start_offset 比的也是字节。一个汉字在 UTF-8 里占 3 字节,所以默认的 150 在中文里只有 50 个字左右。实验的那段中文是 147 个字、415 字节,第二篇文档只有一句「合并是后台做的。」:

text: 147 chars, 415 bytes
--- 倒排索引 max=150 -> fragment 42 chars / 116 bytes, range 299..415
,被删除的文档才真正从<b>倒排</b><b>索引</b>里消失。<b>倒排</b><b>索引</b>的词典用 FST 存储,按字典序排列。
--- 合并 FST max=150 -> fragment 50 chars / 150 bytes, range 149..299
每个段各自查一遍倒排索引,再把结果<b>合并</b>起来。段太多会拖慢查询,所以后台线程会把小段<b>合并</b>成大段。<b>合并</b>时
--- 合并 FST max=450 -> fragment 147 chars / 415 bytes, range 0..415
tantivy 的索引由若干个段组成。每个段都是不可变的,写入时先在内存里建倒排表,提交时再写成文件。查询时,每个段各自查一遍倒排索引,再把结果<b>合并</b>起来。段太多会拖慢查询,所以后台线程会把小段<b>合并</b>成大段。<b>合并</b>时,被删除的文档才真正从倒排索引里消失。倒排索引的词典用 <b>FST</b> 存储,按字典序排列。
tokens: ["\"倒排\"@0..6 pos0", "\"索引\"@6..12 pos2", "\",\"@12..15 pos4", "\"合并\"@15..21 pos5"]

几处细节:

  • 第一个片段从 , 开始:片段从词的边界开始,jieba 把标点也当成词输出(分词里实测过空白和标点占的比例),所以片段可能以标点开头。
  • 「倒排索引」是一个 PhraseQuery(不加空格的中文会被解析成短语,见查询解析器),高亮时拆成「倒排」「索引」两个词各标各的;「倒排表」里的「倒排」没有被标出来,因为 jieba 把「倒排表」切成了「倒」「排表」。
  • 想要大约 150 个字,就把上限设成 450 左右;文字里中英混排时没有准确的换算。

tag 之后 main 上的 66f830225(#3199,「Count chars, not bytes, for the snippet max_num_chars」,2026-10-26,尚未发版)改成了数字符,说明里写「For non-ASCII text, snippets were therefore much shorter than requested: about half for Cyrillic or Greek, a third for CJK.」0.27.0 上仍按字节。

❗IMPORTANT
动脑筋:查询 合并 FST、上限 150 字节时,被选中的片段里会不会有 FST?

「合并」在两篇文档里都出现,doc_freq 是 2;fst 只在第一篇里,doc_freq 是 1。答案在动脑筋答案。

动脑筋答案

不会。被选中的是只有「合并」的那一段:

--- 合并 FST max=150 -> fragment 50 chars / 150 bytes, range 149..299
每个段各自查一遍倒排索引,再把结果<b>合并</b>起来。段太多会拖慢查询,所以后台线程会把小段<b>合并</b>成大段。<b>合并</b>时

权重是「合并」1/(1+2) = 0.333,fst 1/(1+1) = 0.5。415 字节的原文被切成三个窗口:0..149 里没有查询词,被丢掉;149..299 里「合并」出现三次,总分 1.0;299..415 里只有一个 FST,0.5。分数按出现次数累加,所以一个常见词多出现几次,就能压过只出现一次的稀有词。上限放到 450 时整段原文是一个窗口,两个词都在里面。

小结

  • SnippetGenerator::create 用 query_terms 收集这个字段上的词,权重 1/(1+doc_freq);模糊、正则、范围查询不报告词,生成的片段为空。
  • 原文要能拿到:snippet_from_doc 读存储的字段值,否则自己调 snippet(text)。高亮在原文上重新分词完成,不读位置,短语里的词不管挨不挨着都会被标出。
  • 片段是首尾相接、互不重叠的窗口,按命中词权重之和选一个;同一个词多次出现会累加;没有命中就是空串。
  • to_html 排序、去重、合并重叠(不合并相邻)的区间,用 encode_minimal 转义;前后缀可以换;fragment_range 是 0.27.0 新增的原文字节区间。
  • 0.27.0 的上限按字节算,中文的 150 约等于 50 个字;main 上已改成按字符(#3199)和修掉 n-gram 重叠词的 panic,都还没有发版。

💬 关联讨论

发起讨论
加载中...