Rust in Everything
基于 tantivy 0.27.0

高亮片段只有 50 个汉字

主线位置:查询主线之后。SnippetGenerator 在原文上重新分词,按 max_num_chars 切成一个个窗口,挑分数最高的一个,to_html 把命中的词包上 <b>。切窗口、选片段的过程见高亮,本页讲中文下的两个坑:长度和 panic。

flowchart TB
  tok["jieba 搜索模式"] --> sub["先出子词 中华 华人 等"]
  sub --> cut["共和国 超出窗口"]
  cut --> nf["新片段从 142 开始"]
  nf --> long["再出长词 130 到 151"]
  long --> add["仍算进新片段"]
  add --> neg["130 减 142 下溢"]
  neg --> panic["to_html 越界 panic"]

实验在 exp-tantivy/pitb-lab,bin p26_snippet、p26_min、p26_fix(release,p26_min 另有 debug),jieba 用 tantivy-jieba 0.19 加 LowerCaser。M2 Max、macOS 27、rustc 1.99.0,tantivy 0.27.0。

现象

片段太短。 一段 106 个字的中文,查 合并:

text: 106 chars, 318 bytes
max_num_chars=150: fragment 49 chars / 147 bytes
max_num_chars=450: fragment 106 chars / 318 bytes

默认设置下片段只有 49 个字。英文的 150 大约是两行摘要,中文只有一行半不到。

偶尔 panic。 原文是 65 个 x 后面跟「中华人民共和国」,只查「中华人民共和国」这一个词:

create(): fragment_range=142..151 highlighted=[18446744073709551604..9]

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

debug 构建在更早的地方停下:

thread 'main' (6536114) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tantivy-0.27.0/src/snippet/mod.rs:263:25:
attempt to subtract with overflow

这不是精心构造才有的输入。p26_snippet 拿内容仓库 docs/ 下当时的 1089 个 Markdown 文件,每个文件里 jieba 切出的每个三字以上的中文词各生成一次片段(默认 150):

search_mode=true parser_terms=false: files=1089 snippets=24852 panics=594 files_with_panic=429 start_before_fragment=594 end_past_fragment=0 kinds={"src/snippet/mod.rs:#: end byte index # is out of bounds for string of length #": 594}
search_mode=false parser_terms=false: files=1089 snippets=24398 panics=0 files_with_panic=0 start_before_fragment=0 end_past_fragment=0 kinds={}
search_mode=true parser_terms=true: files=1089 snippets=24852 panics=494 files_with_panic=375 start_before_fragment=494 end_past_fragment=0 kinds={"src/snippet/mod.rs:#: end byte index # is out of bounds for string of length #": 494}
  • 第一行:查询词只有那个长词,2.4% 的片段 panic,429 个文件里至少有一次。
  • 第二行:同样的做法,jieba 换成非搜索模式,一次都没有。
  • 第三行:查询词换成搜索模式下这个词切出的所有子词(QueryParser 解析一个长词时 query_terms 报的就是这些),仍有 494 次。

原因

长度按字节比。 search_fragments 用 next.offset_to - fragment.start_offset 和 DEFAULT_MAX_NUM_CHARS 比较,偏移是字节,一个汉字 3 字节。tag 之后 main 上的 #3199 已改成数字符,尚未发版(高亮)。

panic 来自 jieba 的搜索模式。 JiebaTokenizer::new() 默认就是搜索模式:长词先输出它的子词,最后才输出长词本身。这段原文的最后几个词元:

tokens: ["中华@130..136", "华人@133..139", "人民@136..142", "共和@142..148", "共和国@142..151", "中华人民共和国@130..151"]

search_fragments 扫到 共和国 时,151 − 0 超过 150,于是收起第一个片段,新片段从 共和国 的起点 142 开始。下一个词元是长词,151 − 142 = 9 装得下,try_add_token 把它加进新片段,记下高亮区间 130..151,起点在片段之前。select_best_fragment_combination 选中这个片段后,把区间换成相对片段起点:

            .map(|item| item.start - fragment.start_offset..item.end - fragment.start_offset)

130 − 142 在 usize 上下溢。debug 构建在这一行(263)报「attempt to subtract with overflow」;release 不检查溢出,得到 2^64 − 12 = 18446744073709551604,snippet() 正常返回,到 to_html 切片时才在 158 行 panic。上面 594 次全部是这一种(start_before_fragment)。

这是上游的问题。高亮一页讲过 n-gram 分词器的另一种越界:片段终点被后来的短词往回拉。tag 之后 main 上的 9f2f1b2f6(「Fix snippet panic with overlapping tokens」)只修那一种,把终点改成只增不减;select_best_fragment_combination 里的这个减法在 main 上没有变,词元起点早于片段起点的情况仍然存在。这个结论是读 main 的代码得出的,没有在 main 上运行。

修法

中文把上限放到约三倍。 set_max_num_chars(450),上面的 106 个字就整段进来了。中英混排时没有准确的换算。

片段用非搜索模式的分词器。 索引可以继续用搜索模式,生成片段时换一个不输出重叠词的分析器。SnippetGenerator::create 固定用字段的分词器,要换就用 SnippetGenerator::new,权重照 create 的算法自己算:

/// Like SnippetGenerator::create, but with a tokenizer of our choice.
fn snippet_generator(s: &Searcher, q: &dyn Query, field: Field, tokenizer: TextAnalyzer, max: usize) -> tantivy::Result<SnippetGenerator> {
  let mut terms: BTreeSet<&Term> = BTreeSet::new();
  q.query_terms(&mut |t, _| if t.field() == field { terms.insert(t); });
  let mut weights: BTreeMap<String, Score> = BTreeMap::new();
  for t in terms {
    if let Some(text) = t.value().as_str() {
      let df = s.doc_freq(t)?;
      if df > 0 {
        weights.insert(text.to_string(), 1.0 / (1.0 + df as Score));
      }
    }
  }
  Ok(SnippetGenerator::new(weights, tokenizer, field, max))
}

let gen = snippet_generator(&searcher, query.as_ref(), body, jieba(false), 450)?;

同一篇原文,QueryParser 解析「中华人民共和国」得到的查询:

query terms: ["中华", "中华人民共和国", "华人", "人民", "共和", "共和国"]
fixed: fragment_range=0..151 highlighted=[130..151] html ends with "x x <b>中华人民共和国</b>"

非搜索模式只输出长词,子词不出现在原文的分词结果里,权重表里多出来的子词不起作用,高亮的是整个长词。

最后一道保险。 渲染片段的代码包一层 std::panic::catch_unwind,panic 时退回不带高亮的摘要。release 构建里 snippet() 不会 panic、to_html() 才会,所以要包住 to_html。

怎么检查

  • 拿自己的语料跑一遍:每篇文档、每个长词生成一次片段,检查 snippet.highlighted() 里有没有 start > end 的区间。这一步不用调 to_html,不会 panic。
  • 搜索接口的错误日志里搜 out of bounds for string of length、attempt to subtract with overflow。
  • 量一下片段的字符数:中文站点上长期在 50 字上下,就是还在用默认的 150 字节。

小结

  • max_num_chars 在 0.27.0 按字节算,默认 150 只有约 50 个汉字;设成 450 左右。
  • jieba 默认的搜索模式先出子词、后出长词,长词可能被算进下一个片段、高亮起点早于片段起点;release 在 to_html 报 end byte index 18446744073709551604 is out of bounds,debug 报减法溢出。本机语料上 1089 个文件里 429 个能触发。
  • 这是上游问题,main 上的 9f2f1b2f6 修的是 n-gram 那一种,不覆盖这里。修法:生成片段时用非搜索模式的分词器(SnippetGenerator::new),并用 catch_unwind 兜底。

💬 关联讨论

发起讨论
加载中...