Rust in Everything
基于 tantivy 0.27.0

分词

主线位置:写入主线和查询主线各经过一次。写入时 SegmentWriter 把每个文本值交给该字段的 TextAnalyzer,吐出的每个 token 变成一个词(见 Term);查询时 QueryParser 用同一个名字的分析器把 old man 切成 old、man。两次用的必须是同一套规则,否则写进去的词和查的词对不上。

flowchart TB
  sch["schema 里的名字 default"] --> tm["TokenizerManager"]
  tm --> ta["TextAnalyzer"]
  text["The Old Man"] --> st["SimpleTokenizer"]
  ta --> st
  st --> rl["RemoveLongFilter 40"]
  rl --> lc["LowerCaser"]
  lc --> toks["the old man"]
  toks --> w["写入:IndexingTerm"]
  toks --> q["查询:Term"]

代码分两处:trait 定义在独立的 tantivy-tokenizer-api crate(0.7.0,src/lib.rs 180 行),实现在 src/tokenizer/(9111 行,其中 ascii_folding_filter.rs 4066 行、stop_word_filter/stopwords.rs 2319 行是数据表)。

实验平台:M2 Max、macOS 27、rustc 1.99.0,tantivy 0.27.0,tantivy-jieba 0.19.0,release 构建。

tokenizer-api:三个 trait

Token 有五个字段:offset_from、offset_to(原文里的字节区间,高亮用)、position(第几个词,短语查询用)、text、position_length。三个 trait:

pub trait Tokenizer: 'static + Clone + Send + Sync {
    /// The token stream returned by this Tokenizer.
    type TokenStream<'a>: TokenStream;
    /// Creates a token stream for a given `str`.
    fn token_stream<'a>(&'a mut self, text: &'a str) -> Self::TokenStream<'a>;
}
pub trait TokenStream {
    /// Advance to the next token
    ///
    /// Returns false if there are no other tokens.
    fn advance(&mut self) -> bool;

    /// Returns a reference to the current token.
    fn token(&self) -> &Token;

    /// Returns a mutable reference to the current token.
    fn token_mut(&mut self) -> &mut Token;
    // ...
}
pub trait TokenFilter: 'static + Send + Sync {
    /// The Tokenizer type returned by this filter, typically parametrized by the underlying
    /// Tokenizer.
    type Tokenizer<T: Tokenizer>: Tokenizer;
    /// Wraps a Tokenizer and returns a new one.
    fn transform<T: Tokenizer>(self, tokenizer: T) -> Self::Tokenizer<T>;
}
  • Tokenizer 是工厂:给一段文本,造一个流。token_stream 拿的是 &'a mut self,流可以借用分词器内部的缓冲区,比如 SimpleTokenizer 自己带一个 token: Token,每段文本 reset() 后复用,String 的容量不用每次重新分配。
  • TokenStream 不是 Iterator:advance 推进,token 借出当前那个。因为 token 是同一块内存反复改写,借出去的引用不能活过下一次 advance,Iterator::next 表达不了这种借用。
  • TokenFilter 也不直接处理 token,而是把一个 Tokenizer 包成另一个 Tokenizer,Tokenizer<T> 是泛型关联类型。

这三个 trait 单独成 crate,是为了让第三方分词器只依赖它:crate 文档写着「This is an separate crate from tantivy, so implementers don't need to update for each new tantivy version.」0.27.0 的 tantivy 依赖的仍是 tantivy-tokenizer-api 0.7,所以给 0.26 写的分词器不用改就能接 0.27。

📝NOTE
冷知识:token_stream 从 &self 变成 &mut self,为的是一个 200 字节的 String

0.19 的签名是 fn token_stream<'a>(&self, text: &'a str) -> Self::TokenStream<'a>。分词器拿不到可变借用,每段文本都得新建一个 Token,而 Token::default() 里是 text: String::with_capacity(200):每个字段值一次堆分配,哪怕它只有一个词。

提交 fdecb7927(「tokenizer-api: reduce Tokenizer overhead」,#2062)改成 &'a mut self,提交说明写着「Previously a new Token for each text encountered was created, which contains String::with_capacity(200). In the new API the token_stream gets mutable access to the tokenizer, this allows state to be shared (in this PR Token is shared).」同一个提交给 Token 加了 reset(),Token::default() 改成 String::new(),LowerCaser 和 AsciiFoldingFilter 的缓冲区也挪进了分词器里。说明的最后一句「Ideally the allocation for the BoxTokenStream would also be removed」至今没做:TextAnalyzer::token_stream 返回的还是 BoxTokenStream。首次发布在 0.20。

TextAnalyzer:把 filter 链装进一个 box

TextAnalyzer 只有一个字段 tokenizer: Box<dyn BoxableTokenizer>。构造靠 builder:

    pub fn filter<F: TokenFilter>(self, token_filter: F) -> TextAnalyzerBuilder<F::Tokenizer<T>> {
        TextAnalyzerBuilder {
            tokenizer: token_filter.transform(self.tokenizer),
        }
    }

filter 每调一次,类型就多包一层:TextAnalyzer::builder(SimpleTokenizer::default()).filter(RemoveLongFilter::limit(40)).filter(LowerCaser) 在 build() 之前的类型是 LowerCaserFilter<RemoveLongFilterWrapper<SimpleTokenizer>>,整条链在编译期是一个具体类型,各层的 advance 可以内联在一起。build() 只在最外面装一次 box。需要在运行时拼链(比如按配置文件决定加哪些 filter)时用 filter_dynamic,每加一层就 box 一次,测试 test_text_analyzer_with_filters_boxed 演示了这种用法。

装进 box 后 token_stream 返回 BoxTokenStream<'a>,每段文本一次堆分配。BoxableTokenizer 的存在是因为 Tokenizer 有泛型关联类型、又要求 Clone,不能直接做成 trait 对象;它把「造一个 boxed 流」和「clone 自己」两件事改写成对象安全的方法。

链上每一层都是「拿下游的流,改它的当前 token」。RemoveLongFilter 的判断:

    fn predicate(&self, token: &Token) -> bool {
        token.text.len() < self.token_length_limit
    }

len() 是字节数,< 是严格小于:limit(40) 保留不超过 39 字节的词。模块文档(src/tokenizer/mod.rs 开头)说 default「removes tokens that are longer than 40 chars」,「chars」和「longer than」都不准确:一个汉字 3 字节,13 个汉字(39 字节)保留,14 个就丢了。被丢掉的词不改后面词的 position,位置照样往后数。

LowerCaser 对纯 ASCII 走 make_ascii_lowercase 原地改,否则逐字符 to_lowercase 写进自己的缓冲区再 swap。

TokenizerManager 和默认的四个名字

TokenizerManager 是 Arc<RwLock<HashMap<String, TextAnalyzer>>>。get 返回的是 clone:每个写入线程、每次查询解析拿到自己的一份分析器,各自的缓冲区互不干扰。Default 注册四个名字:

    fn default() -> TokenizerManager {
        let manager = TokenizerManager::new();
        manager.register(RAW_TOKENIZER_NAME, RawTokenizer::default());
        manager.register(
            DEFAULT_TOKENIZER_NAME,
            TextAnalyzer::builder(SimpleTokenizer::default())
                .filter(RemoveLongFilter::limit(40))
                .filter(LowerCaser)
                .build(),
        );
        #[cfg(feature = "stemmer")]
        {
            use crate::tokenizer::stemmer::{Language, Stemmer};
            manager.register(
                "en_stem",
                TextAnalyzer::builder(SimpleTokenizer::default())
                    .filter(RemoveLongFilter::limit(40))
                    .filter(LowerCaser) // The stemmer does not lowercase
                    .filter(Stemmer::new(Language::English))
                    .build(),
            );
        }
        manager.register("whitespace", WhitespaceTokenizer::default());
        manager
    }
名字链说明
rawRawTokenizer整个值一个词,STRING 和快速字段默认用它
defaultSimpleTokenizer → RemoveLongFilter(40) → LowerCaserTEXT 用它
en_stem同上再加 Stemmer(English)只在 stemmer feature 下注册(默认开)
whitespaceWhitespaceTokenizer按 ASCII 空白切,不转小写,不限长度

ngram、regex、停用词、ASCII 折叠这些都有实现,但不在默认注册里,要自己 register。en_stem 背后的词干库在 0.27.0 从不再维护的 rust-stemmers 换成了 frostem(#3028,CHANGELOG 记的是它修掉了某些希腊语单词上的 panic)。

SimpleTokenizer 的切分规则只有一条:连续的 char::is_alphanumeric() 字符是一个词,其他字符都是分隔符。

    fn advance(&mut self) -> bool {
        self.token.text.clear();
        self.token.position = self.token.position.wrapping_add(1);
        while let Some((offset_from, c)) = self.chars.next() {
            if c.is_alphanumeric() {
                let offset_to = self.search_token_end();
                self.token.offset_from = offset_from;
                self.token.offset_to = offset_to;
                self.token.text.push_str(&self.text[offset_from..offset_to]);
                return true;
            }
        }
        false
    }

汉字在 Unicode 里是字母类(is_alphanumeric 为真),所以一串没有标点、空格的中文,连同夹在里面的英文和数字,都是一个词。

实测:四个默认名字和 jieba

同一段文本交给四个默认分析器和 tantivy-jieba,打印 词@position[offset_from..offset_to]:

--- "The Old Man and the Sea" (23 bytes)
     default (6 tokens): the@0[0..3] | old@1[4..7] | man@2[8..11] | and@3[12..15] | the@4[16..19] | sea@5[20..23]
         raw (1 tokens): The Old Man and the Sea@0[0..23]
     en_stem (6 tokens): the@0[0..3] | old@1[4..7] | man@2[8..11] | and@3[12..15] | the@4[16..19] | sea@5[20..23]
  whitespace (6 tokens): The@0[0..3] | Old@1[4..7] | Man@2[8..11] | and@3[12..15] | the@4[16..19] | Sea@5[20..23]
       jieba (11 tokens): The@0[0..3] |  @3[3..4] | Old@4[4..7] |  @7[7..8] | Man@8[8..11] |  @11[11..12] | and@12[12..15] |  @15[15..16] | the@16[16..19] |  @19[19..20] | Sea@20[20..23]
 jieba+lower (11 tokens): the@0[0..3] |  @3[3..4] | old@4[4..7] |  @7[7..8] | man@8[8..11] |  @11[11..12] | and@12[12..15] |  @15[15..16] | the@16[16..19] |  @19[19..20] | sea@20[20..23]
--- "tantivy是Lucene风格的全文检索库,用Rust编写。" (59 bytes)
     default (1 tokens): 用rust编写@1[43..56]
         raw (1 tokens): tantivy是Lucene风格的全文检索库,用Rust编写。@0[0..59]
     en_stem (1 tokens): 用rust编写@1[43..56]
  whitespace (1 tokens): tantivy是Lucene风格的全文检索库,用Rust编写。@0[0..59]
       jieba (14 tokens): tantivy@0[0..7] | 是@7[7..10] | Lucene@8[10..16] | 风格@14[16..22] | 的@16[22..25] | 全文@17[25..31] | 检索@19[31..37] | 全文检索@17[25..37] | 库@21[37..40] | ,@22[40..43] | 用@23[43..46] | Rust@24[46..50] | 编写@28[50..56] | 。@30[56..59]
 jieba+lower (14 tokens): tantivy@0[0..7] | 是@7[7..10] | lucene@8[10..16] | 风格@14[16..22] | 的@16[22..25] | 全文@17[25..31] | 检索@19[31..37] | 全文检索@17[25..37] | 库@21[37..40] | ,@22[40..43] | 用@23[43..46] | rust@24[46..50] | 编写@28[50..56] | 。@30[56..59]

(en_stem 在英文上的作用要换个例子才看得出:Running runners ran 得到 run、runner、ran。)

  • 中文句子在 default 下只剩一个词。逗号前的 tantivy是Lucene风格的全文检索库 正好 40 字节,被 RemoveLongFilter 丢掉;逗号后的 用rust编写 13 字节,保留,position 是 1,因为被丢的那个占了 0。搜「检索」查不到这句话,搜「用rust编写」才查得到。
  • whitespace 只认 ASCII 空白,中文全角逗号不算,整句一个词。
  • jieba 的 position 不是词的序号,是字符偏移:Lucene 从第 8 个字符开始,风格 从第 14 个。搜索模式(JiebaTokenizer::new() 的默认)在长词之外再输出其中的短词,全文、检索、全文检索 三个词,后者和前者 position 相同。
  • jieba 把空格、,、。 也当词输出,它们会作为词进倒排表,也计入 fieldnorm。

把本站内容仓库的 1027 篇 Markdown(不含正在写的 tantivy 卷;17,591,869 字节,汉字 1,775,507 个)整个过一遍,只看统计:

指标defaultjieba + LowerCaser
输出的词1,975,8667,106,907
其中不含任何字母数字的词(空白、标点、符号)04,376,590(61.6%)
含汉字的词383,435(未过 RemoveLongFilter 前)
因 ≥ 40 字节被丢掉的词10,381,含汉字 159,055 个(占全部汉字 9.0%)链上没有这个 filter
分词耗时(5 次取最小)0.090 s(195.8 MB/s)1.005 s(17.5 MB/s)

(单线程;计时时 uptime 的 1 分钟负载约 4.1,其他写手的实验在并行跑,紧接着再跑一次得到 0.091 s 和 0.995 s;jieba 的词典在计时前已加载。)default 下 9% 的汉字直接从索引里消失,剩下的 91% 也只是以「两个非字母数字字符之间的整段」为单位存在,一个两字词要和整段完全相等才能命中。中文字段必须换分词器。

名字存在 schema 里,分析器不在

schema 里的 "tokenizer": "default" 只是一个字符串(见 Schema 与字段),分析器本身挂在 Index 的 TokenizerManager 上,是运行时状态,不落盘。Index::open_in_dir 打开一个索引时拿到的是 TokenizerManager::default(),自定义的名字每次打开都要重新 register。名字在三处被查:

  1. 写入:InvertedIndexPluginWriter::new 在索引线程开始写一个新段时,给每个字段查一次:
        let per_field_text_analyzers = schema
            .fields()
            .map(|(_, field_entry)| {
                let text_options = match field_entry.field_type() {
                    FieldType::Str(ref text_options) => text_options.get_indexing_options(),
                    FieldType::JsonObject(ref json_object_options) => {
                        json_object_options.get_text_indexing_options()
                    }
                    _ => None,
                };
                let tokenizer_name = text_options
                    .map(|text_index_option| text_index_option.tokenizer())
                    .unwrap_or("default");
                tokenizer_manager.get(tokenizer_name).ok_or_else(|| {
                    TantivyError::SchemaError(format!(
                        "Error getting tokenizer for field: {}",
                        field_entry.name()
                    ))
                })
            })
            .collect::<Result<Vec<_>, _>>()?;
  1. 查询:QueryParser 在 compute_logical_ast_for_leaf 里查,找不到返回 QueryParserError::UnknownTokenizer。
  2. 快速字段:set_fast("名字") 的名字在另一个管理器 Index::fast_field_tokenizer() 里查,由 FastFieldsWriter::from_schema_and_tokenizer_manager 在建段时报错。

实验:一个 title 字段,分词器名字是 "jieba"。

  println!("== A. index time, tokenizer never registered");
  let index = Index::create_in_ram(schema.clone());
  println!("tokenizer_for_field: {:?}", index.tokenizer_for_field(title).err());
  // writer_with_num_threads(1, 15_000_000),然后 add_document 三次,再 commit
== A. index time, tokenizer never registered
tokenizer_for_field: Some(InvalidArgument("No Tokenizer found for field FieldEntry { name: \"title\", field_type: Str(TextOptions { indexing: Some(TextFieldIndexing { record: WithFreqsAndPositions, fieldnorms: true, tokenizer: \"jieba\" }), stored: true, fast: None, coerce: false }) }"))
writer(): Ok
add_document #0: Ok(0)
add_document #1: Ok(1)
add_document #2: Ok(2)
commit: Err(SchemaError("Error getting tokenizer for field: title"))
== B. index with jieba, reopen without registering
parse_query: Err(UnknownTokenizer { tokenizer: "jieba", field: "title" })
  display: The tokenizer '"jieba"' for the field '"title"' is unknown
parse_query_lenient: errors=[UnknownTokenizer { tokenizer: "jieba", field: "title" }], hits=0
== C. reopen and register a different analyzer under the same name
query "检索" -> TermQuery(Term(field=0, type=Str, "检索")) hits=1
query "全文检索引擎" -> TermQuery(Term(field=0, type=Str, "全文检索引擎")) hits=0
query "rust" -> TermQuery(Term(field=0, type=Str, "rust")) hits=1
with jieba again: TermQuery(Term(field=0, type=Str, "检索")) hits=1
== D. empty TokenizerManager, schema with no text field
add_document: Ok(0)
commit: Err(SchemaError("Error getting tokenizer for field: n"))
  • A,写入侧:writer() 成功,add_document 也成功,错误到 commit 才出现。分析器是在索引线程收到第一篇文档、新建段的时候才去取的;线程返回错误后退出,错误在 prepare_commit 等线程结束时才被带回来。错误文本里没有分词器的名字,只有字段名。三次 add_document 是紧挨着调的,线程还没来得及退出;另一次实验在第一篇之后等了 200 毫秒再写第二篇,第二篇返回的是 Err(ErrorInThread("An index writer was killed.. A worker thread encountered an error (io::Error most likely) or panicked.")),而真正的原因仍要等 commit 才拿得到。
  • B,查询侧:用 jieba 建好、提交,再用 Index::open_in_dir 打开但不注册。parse_query 直接返回 UnknownTokenizer;parse_query_lenient 把它当成可容忍的错误,返回一个匹配不到任何文档的查询,命中 0。
  • C,名字对、分析器不对:重新打开后把 default 的分析器注册成 "jieba"。不报任何错。检索 恰好在 default 下也是一个完整的词,命中;全文检索引擎 在 default 下是一个词,在 jieba 写入的索引里不存在,命中 0。哪些查询能命中取决于两种切法碰巧在哪里一致,从结果上很难看出来。
  • D,空的管理器:Index::builder().tokenizers(TokenizerManager::new()) 配一个空管理器,schema 里只有一个 u64 字段,commit 照样失败,错误里的字段名是 n。上面那段代码对非文本字段 unwrap_or("default"),所以只要管理器里没有 default,任何 schema 都写不进去。这个分析器对非文本字段从来不会被用到。

快速字段那一处:tag: STRING | TextOptions::default().set_fast("jieba"),只在 index.tokenizers() 里注册 jieba,commit 返回 InvalidArgument("Tokenizer jieba not found"),因为快速字段查的是另一个管理器。

📝NOTE
冷知识:分词器没注册,以前是静默地什么都不索引

0.20 的 SegmentWriter::for_segment 是这样取分析器的:

                text_options
                    .and_then(|text_index_option| {
                        let tokenizer_name = &text_index_option.tokenizer();
                        tokenizer_manager.get(tokenizer_name)
                    })
                    .unwrap_or_default()

找不到名字就 unwrap_or_default(),而 TextAnalyzer::default() 是 TextAnalyzer::from(EmptyTokenizer),它的 advance 永远返回 false。文档照常提交、照常能按 id 取回来,只是这个字段一个词也没进倒排表,查什么都是 0。

提交 59962097d(「fix: #2078 return error when tokenizer not found while indexing」,#2093)改成现在的 ok_or_else 加 SchemaError,并加了测试 test_show_error_when_tokenizer_not_registered,断言 commit 的错误文本是「Schema error: 'Error getting tokenizer for field: title'」。实验 A 和 D 里的报错都来自这次修改;D 那种「没有文本字段也要求有 default」的副作用,是同一次修改把 unwrap_or_default() 换成 .unwrap_or("default") 带来的。首次发布在 0.21。0.27.0 的段插件重构(#2993)把这段代码原样搬进了 inverted_index_plugin.rs。

commit 失败之后,这个 IndexWriter 并没有坏到用不了,而是更糟:prepare_commit 先用 mem::take 拿走所有线程句柄,逐个 join,第一个出错的 ? 直接返回,后面那句 self.add_indexing_worker()? 没有执行:

        let former_workers_join_handle = std::mem::take(&mut self.workers_join_handle);

        for worker_handle in former_workers_join_handle {
            let indexing_worker_result = worker_handle
                .join()
                .map_err(|e| TantivyError::ErrorInThread(format!("{e:?}")))?;
            indexing_worker_result?;
            self.add_indexing_worker()?;
        }

结果是 writer 手里一个索引线程也没有了。继续 add_document 返回 Ok,再 commit 也返回 Ok,但文档进的是一个没人读的 channel。实验:失败一次以后,即使补上 register("jieba", ...),再写一篇、提交,num_docs 仍是 0;循环写下去,第 10,000 篇之后的那次 add_document 永远阻塞(channel 容量 PIPELINE_MAX_SIZE_IN_DOCS = 10_000,runq 20 秒后杀掉):

commit #1: Err(SchemaError("Error getting tokenizer for field: title"))
added 9998
added 9999
[runq: killed after 20s]

所以 commit 返回错误之后,这个 writer 只能丢掉,重新开一个。线程模型本身见 IndexWriter。

📝NOTE
没有蠢问题

问: QueryParser::for_index 是在 register 之前建的,它能看到后来注册的分词器吗?

答: 能。for_index 拿的是 index.tokenizers().clone(),而 TokenizerManager 的 clone 只是多一个 Arc,register 往同一张表里写。实测先建解析器、再注册,parse_query 成功。反过来不行:Index::set_tokenizers 是整个换掉一个新的管理器,换之前建的解析器还指着旧表(实测仍能解析),换之后新建的解析器在新表里找不到 "mine",报 UnknownTokenizer。注册用 index.tokenizers().register(...),不要用 set_tokenizers 去「加」一个分词器。

tantivy-jieba 怎么接进来

tantivy-jieba 0.19.0 的依赖里没有 tantivy,只有 jieba-rs 0.8、lazy_static 和 tantivy-tokenizer-api 0.7(tantivy 0.26 只是 dev-dependency)。它实现的是 api crate 里的 Tokenizer:

$ cargo tree --offline -i tantivy-tokenizer-api
tantivy-tokenizer-api v0.7.0
├── tantivy v0.27.0
│   └── schema-exp v0.1.0 (~/exp-tantivy/schema-exp)
└── tantivy-jieba v0.19.0
    └── schema-exp v0.1.0 (~/exp-tantivy/schema-exp)

两边是同一个 tantivy-tokenizer-api,TokenizerManager::register 要求的 TextAnalyzer: From<T> 就成立(From<T: Tokenizer + Clone>),JiebaTokenizer 可以直接注册,也可以当链的起点再加 filter。如果两边依赖的 api 版本不同,Cargo 会编进两份 crate,两个同名的 Tokenizer trait 互不相认。手头没有依赖别的 api 版本的分词器,于是把 0.7.0 的源码复制一份、版本号改成 0.8.0 作为路径依赖,对它实现一个最小的分词器,再交给 tantivy 0.27.0 的 register:

error[E0277]: the trait bound `WholeText: tantivy::tokenizer::Tokenizer` is not satisfied
  --> src/main.rs:31:29
   |
31 |   manager.register("whole", WholeText);
   |           --------          ^^^^^^^^^ unsatisfied trait bound
   |           |
   |           required by a bound introduced by this call
   |
...
note: there are multiple different versions of crate `tantivy_tokenizer_api` in the dependency graph
  --> ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tantivy-tokenizer-api-0.7.0/src/lib.rs:56:1
   |
56 | pub trait Tokenizer: 'static + Clone + Send + Sync {
   | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ this is the expected trait
   |
  ::: fakeapi/src/lib.rs:56:1
   |
56 | pub trait Tokenizer: 'static + Clone + Send + Sync {
   | -------------------------------------------------- this is the found trait
...

rustc 直接点出了「multiple different versions of crate」。升级 tantivy 时先看 cargo tree -i tantivy-tokenizer-api 是不是只有一行版本号。

JiebaTokenizer::token_stream 的做法和 SimpleTokenizer 不同:它先调 jieba.tokenize(text, mode, true)(第三个参数开 HMM,识别词典外的新词)把整段切完,得到一个 Vec,advance 再逐个吐出。offset 由词的指针减原文指针得到;position 默认取 jieba 返回的 start,也就是上面看到的字符偏移,with_ordinal_position_mode(true) 才是序号。它不借用分词器里的缓冲区:每段文本分配一个 Vec 装下全部切分结果,Token 也是每个流新建一个,只在流内部复用它的 String。

上面的统计里 jieba 输出的词六成是空白和标点。tantivy 自带的 AlphaNumOnlyFilter 看起来能去掉它们,但它的判断是 c.is_ascii_alphanumeric(),只认 ASCII:

["rust"]

用 Rust 编写的全文检索库。 经过 jieba + AlphaNumOnlyFilter + LowerCaser,汉字词全部被删掉,只剩 rust。要去掉标点又保留汉字,得自己写一个按 char::is_alphanumeric 判断的 filter。

本站的搜索模块注册了一个 jieba_lower(jieba 加 LowerCaser),细节和它带来的影响在本站的搜索模块。

❗IMPORTANT
动脑筋:一个索引由若干个不可变的段和一个元数据文件组成。 交给 default,输出几个词?交给 jieba 呢?这句 72 字节,只有句末一个标点。答案在文末。

动脑筋答案

--- "一个索引由若干个不可变的段和一个元数据文件组成。" (72 bytes)
     default (0 tokens): 
         raw (1 tokens): 一个索引由若干个不可变的段和一个元数据文件组成。@0[0..72]
     en_stem (0 tokens): 
  whitespace (1 tokens): 一个索引由若干个不可变的段和一个元数据文件组成。@0[0..72]
       jieba (17 tokens): 一个@0[0..6] | 索引@2[6..12] | 由@4[12..15] | 若干@5[15..21] | 若干个@5[15..24] | 不可@8[24..30] | 变@10[30..33] | 的@11[33..36] | 段@12[36..39] | 和@13[39..42] | 一个@14[42..48] | 元@16[48..51] | 数据@17[51..57] | 文件@19[57..63] | 数据文件@17[51..63] | 组成@21[63..69] | 。@23[69..72]

default 是 0 个。SimpleTokenizer 把句号前的 23 个汉字当成一个 69 字节的词,RemoveLongFilter(40) 把它丢掉,句号本身不是字母数字,不成词。这个字段值写进索引后,倒排表里一个词也没有,fieldnorm 是 0,用这一句的任何部分都搜不到它,也不会有任何报错或日志。jieba 输出 17 个,含搜索模式补出的 若干、数据 两个短词和句末的 。;不可变 被切成了 不可 和 变。

小结

  • Tokenizer、TokenStream、TokenFilter 在独立的 tantivy-tokenizer-api 里(0.27.0 仍是 0.7);token_stream 拿 &mut self 以复用 Token 缓冲(0.20 起,#2062)。
  • TextAnalyzer 在编译期把 filter 链嵌成一个具体类型,只在最外层 box 一次。
  • 默认注册 raw、default、en_stem(stemmer feature)、whitespace。default 按 is_alphanumeric 切词,丢掉 ≥ 40 字节的词:中文整段成一个词,长段直接消失,本站语料上 9% 的汉字因此不进索引。
  • schema 只存分词器名字。没注册时:写入在 commit 才报 SchemaError,之后这个 writer 不再有索引线程,后续写入静默丢失、满 10,000 篇后阻塞;查询报 UnknownTokenizer;同名不同实现不报错,结果悄悄变少。空的 TokenizerManager 连纯数值的 schema 都写不进去。
  • 快速字段的分词器名字在另一个管理器里查。
  • tantivy-jieba 只依赖 tantivy-tokenizer-api;它的 position 默认是字符偏移,会把空白和标点当词输出,AlphaNumOnlyFilter 只认 ASCII,不能用来清理中文结果。

💬 关联讨论

发起讨论
加载中...