LinderaをTantivyで使えるようにした

「[Rust初心者がRust製の日本語形態素解析器の開発を引き継いでみた](https://qiita.com/mosuka/items/0fdaaf91f5530d427dc7)」の続きです。

## 前回のおさらい

前回の記事では、Lindera(Rust製日本語形態素解析器)のCLIを紹介しました。

```
% echo "関西国際空港限定トートバッグ" | lindera
関西国際空港    名詞,固有名詞,組織,*,*,*,関西国際空港,カンサイコクサイクウコウ,カンサイコクサイクーコー
限定    名詞,サ変接続,*,*,*,*,限定,ゲンテイ,ゲンテイ
トートバッグ    UNK,*,*,*,*,*,*,*,*
EOS
```

上記のような使い勝手です。テキストを標準入力やファイルから読み込んで形態素解析を行うことまでできます。このCLIを利用して[出力フォーマット](https://qiita.com/mosuka/items/0fdaaf91f5530d427dc7#%E5%87%BA%E5%8A%9B%E3%83%95%E3%82%A9%E3%83%BC%E3%83%9E%E3%83%83%E3%83%88)をJSONなどにすれば、シェルスクリプトである程度のことが可能ですが、Linderaはライブラリターゲットが本来の提供なので、ライブラリとしての使い方を紹介します。


## ライブラリとしての使い方

Linderaをライブラリとしてアプリケーションに組み込む例はLinderaのリポジトリに記載しています。

https://github.com/lindera-morphology/lindera

```rust
use lindera::tokenizer::Tokenizer;

fn main() -> std::io::Result<()> {
    // create tokenizer
    let mut tokenizer = Tokenizer::new("normal", "");

    // tokenize the text
    let tokens = tokenizer.tokenize("関西国際空港限定トートバッグ");

    // output the tokens
    for token in tokens {
        println!("{}", token.text);
    }

    Ok(())
}
```

上記のように、ライブラリの使い方としては非常に簡単です。このコードを実行すると、次のように出力されます。

```
関西国際空港
限定
トートバッグ
```

`Tokenizer::new()` の第一引数でトークナイズモード(`normal`または`decompose`)の変更ができます。
また、第二引数で使用する形態素辞書のパスを指定することができます。上記の例では`""`(空文字列)となっていますが、これは内包されているデフォルトのIPADICを使用します。もし別の辞書([UniDic](https://github.com/lindera-morphology/lindera-unidic-builder)、[IPADIC-NEologd](https://github.com/lindera-morphology/lindera-ipadic-neologd-builder)、[ko-dic](https://github.com/lindera-morphology/lindera-ko-dic-builder))を使用したいのであれば、その辞書が置かれているディレクトリパスを指定してください。

このように、Linderaは簡単にRustアプリケーションに組み込むことができます。

## Tantivyから使う

さて、このLinderaですが、元々[全文検索サーバを作っていて](https://qiita.com/mosuka/items/3517ef4a1eb07fa2661f)、その中で日本語の形態素解析に利用したいということから、[開発を引き継い](https://qiita.com/mosuka/items/0fdaaf91f5530d427dc7#%E9%96%8B%E7%99%BA%E3%82%92%E5%BC%95%E3%81%8D%E7%B6%99%E3%81%90%E3%81%93%E3%81%A8%E3%81%AB%E3%81%AA%E3%81%A3%E3%81%9F%E7%B5%8C%E7%B7%AF)でいます。

その全文検索サーバ([Bayard](https://github.com/bayard-search/bayard))の中で利用している全文検索ライブラリが[Tantivy](https://github.com/tantivy-search/tantivy)です。
TantivyはGoogle Search SWEの[Paul Masurel](https://twitter.com/fulmicoton)氏が開発するRust製の全文検索ライブラリです。こちらのベンチマークによると非常に高いパフォーマンスを発揮しています。
https://tantivy-search.github.io/bench/

- [Video](https://www.youtube.com/watch?v=wUV-DQ5PNdo)
- [資料](https://github.com/tantivy-search/communication/blob/master/slides/mercari-2019/tantivy.md)

しかし、残念なことに、日本語を扱おうとすると、LuceneのKuromoji相当の日本語形態素辞書を利用したトークナイザが存在しません。
[TinySegmanter](http://chasen.org/~taku/software/TinySegmenter/)のRust移植版[tantivy-tokenizer-tiny-segmenter](https://github.com/osyoyu/tantivy-tokenizer-tiny-segmenter)が利用可能だったのですが、Tantivy v0.12.0からTokenizer周りのAPIが大きく変更されたため、最新のTantivyには対応できていません。
ということで、TantivyからLinderaを利用できるように、Tantivy向けのLinderaTokenizerを作りました。リポジトリはこちらです。
https://github.com/lindera-morphology/lindera-tantivy

TantivyからLinderaを利用するためのサンプルコードを掲載します。

```rust
use lindera_tantivy::tokenizer::LinderaTokenizer;
use tantivy::schema::{IndexRecordOption, Schema, TextFieldIndexing, TextOptions};
use tantivy::{Index, doc};
use tantivy::query::QueryParser;
use tantivy::collector::TopDocs;

fn main() -> tantivy::Result<()> {
    // create schema builder
    let mut schema_builder = Schema::builder();

    // add id field
    let id = schema_builder.add_text_field(
        "id",
        TextOptions::default()
            .set_indexing_options(
                TextFieldIndexing::default()
                    .set_tokenizer("raw")
                    .set_index_option(IndexRecordOption::Basic),
            )
            .set_stored(),
    );

    // add title field
    let title = schema_builder.add_text_field(
        "title",
        TextOptions::default()
            .set_indexing_options(
                TextFieldIndexing::default()
                    .set_tokenizer("lang_ja")
                    .set_index_option(IndexRecordOption::WithFreqsAndPositions),
            )
            .set_stored(),
    );

    // add body field
    let body = schema_builder.add_text_field(
        "body",
        TextOptions::default()
            .set_indexing_options(
                TextFieldIndexing::default()
                    .set_tokenizer("lang_ja")
                    .set_index_option(IndexRecordOption::WithFreqsAndPositions),
            )
            .set_stored(),
    );

    // build schema
    let schema = schema_builder.build();

    // create index on memory
    let index = Index::create_in_ram(schema.clone());

    // register Lindera tokenizer
    index
        .tokenizers()
        .register("lang_ja", LinderaTokenizer::new("decompose", ""));

    // create index writer
    let mut index_writer = index.writer(50_000_000)?;

    // add document
    index_writer.add_document(doc!(
    id => "1",
    title => "成田国際空港",
    body => "成田国際空港（なりたこくさいくうこう、英: Narita International Airport）は、千葉県成田市南東部から芝山町北部にかけて建設された日本最大の国際拠点空港である[1]。首都圏東部（東京の東60km）に位置している。空港コードはNRT。"
    ));

    // add document
    index_writer.add_document(doc!(
    id => "2",
    title => "東京国際空港",
    body => "東京国際空港（とうきょうこくさいくうこう、英語: Tokyo International Airport）は、東京都大田区にある日本最大の空港。通称は羽田空港（はねだくうこう、英語: Haneda Airport）であり、単に「羽田」と呼ばれる場合もある。空港コードはHND。"
    ));

    // add document
    index_writer.add_document(doc!(
    id => "3",
    title => "関西国際空港",
    body => "関西国際空港（かんさいこくさいくうこう、英: Kansai International Airport）は大阪市の南西35㎞に位置する西日本の国際的な玄関口であり、関西三空港の一つとして大阪国際空港（伊丹空港）、神戸空港とともに関西エアポート株式会社によって一体運営が行われている。"
    ));

    // commit
    index_writer.commit()?;

    // create reader
    let reader = index.reader()?;

    // create searcher
    let searcher = reader.searcher();

    // create querhy parser
    let query_parser = QueryParser::for_index(&index, vec![title, body]);

    // parse query
    let query_str = "東京";
    let query = query_parser.parse_query(query_str)?;
    println!("Query String: {}", query_str);

    // search
    let top_docs = searcher.search(&query, &TopDocs::with_limit(10))?;
    println!("Search Result:");
    for (_, doc_address) in top_docs {
        let retrieved_doc = searcher.doc(doc_address)?;
        println!("{}", schema.to_json(&retrieved_doc));
    }

    Ok(())
}
```

ポイントは２箇所あります。
まず１点目ですが、`set_tokenizer()`で使用する予定のトークナイザ(アナライザ)名を設定しておき、schemaを作成します。下記抜粋を参照してください。

```rust
    // add title field
    let title = schema_builder.add_text_field(
        "title",
        TextOptions::default()
            .set_indexing_options(
                TextFieldIndexing::default()
                    .set_tokenizer("lang_ja")
                    .set_index_option(IndexRecordOption::WithFreqsAndPositions),
            )
            .set_stored(),
    );
```

２点目が、インデックスに`register()`で、上記抜粋で指定したトークナイザ名で、使用するトークナイザのインスタンスを登録します。下記抜粋を参照してください。

```rust
    // register Lindera tokenizer
    index
        .tokenizers()
        .register("lang_ja", LinderaTokenizer::new("decompose", ""));
```

このサンプルはリポジトに含まれていませうので、次のように実行できます。

```
% git clone https://github.com/lindera-morphology/lindera-tantivy.git
% cd lindera-tantivy
% cargo run --example basic_example
```

サンプルでは、「成田国際空港」、「東京国際空港」、「関西国際空港」に関する3件のドキュメントをインデックスし、「東京」というクエリ文字列で、`title`と`body`フィールドを検索します。
実行結果は次のように表示されます。

```
Query String: 東京
Search Result:
{"body":["東京国際空港（とうきょうこくさいくうこう、英語: Tokyo International Airport）は、東京都大田区にある日本最大の空港。通称は羽田空港（はねだくうこう、英語: Haneda Airport）であり、単に「羽田」と呼ばれる場合もある。空港コードはHND。"],"id":["2"],"title":["東京国際空港"]}
{"body":["成田国際空港（なりたこくさいくうこう、英: Narita International Airport）は、千葉県成田市南東部から芝山町北部にかけて建設された日本最大の国際拠点空港である[1]。首都圏東部（東京の東60km）に位置している。空港コードはNRT。"],"id":["1"],"title":["成田国際空港"]}
```

いかがでしょうか。形態素解析されたキーワードでドキュメントを検索できていますね。上手くいったようです。

## おわりに

私達のような日本語を扱うサービスで全文検索機能を実装しようとすると、どんなに高いパフォーマンスを発揮する全文検索ライブラリだとしても、日本語を上手く扱えないとなかなかサービスに適用するまでには至りませんでした。
しかし、LinderaをTantivyに対応させたことで、Tantivy + Linderaで日本語の全文検索を行うことができるようになります。
日本語を扱う上で、Lucene + Kuromojiほど多機能ではありませんが、選択肢の一つとして候補に上げてもらえたら幸いです。