🦀

Rustの形態素解析ライブラリ - VibratoとLindera

2023/05/22に公開

Rust製の形態素解析ライブラリVibratoのご紹介です。MeCab互換で爆速のようです。Python版もあります。WASMのデモサイトもあります。
学術的な難しい話はできないので、興味のある方は開発者の方のページをご覧ください。

また比較も兼ねて、同じくRust製の形態素解析ライブラリのLinderaも紹介します。最後におまけでActix Webサーバに組み込み、APIとして形態素解析を提供する方法も説明します。

Vibrato

辞書ファイルのダウンロード

最初にVibrato専用の辞書ファイルをダウンロードします。コンパイル済みのものがありますのでこちらからダウンロードします。

辞書ファイルにはいろいろ種類があります。少し古いですがIPA+MeCabがサイズが7.5MBと一番小さくて使いやすいためこちらを使用します。ダウンロードしたら展開してください。この説明では$HOMEに展開します。

tar xvf ipadic-mecab-2_7_0.tar.xz

Rustのプログラムからライブラリとして呼び出します。Cargo.tomlvibratoを追加します。また、辞書ファイルがzstdで圧縮されているためこちらも追加しておきましょう。

vibrato = "0.5.0"
zstd = "0.12.3"

特に複雑なことはないのでソースコードをドンと見せます。

tokenizer.rs
use std::fs::File;
use vibrato::{Dictionary, Tokenizer};


// MeCab tokenizer
pub fn mecab() {
    let home_dir = "/Users/yamada"; // 辞書ファイルを置いた場所に書き換えて。
    // let dict_path = "bccwj-suw+unidic-cwj-3_1_1/system.dic.zst";
    // let dict_path = "bccwj-suw+unidic-cwj-3_1_1-extracted+compact/system.dic.zst";
    let dict_path = "ipadic-mecab-2_7_0/system.dic.zst";
    let dict_full_path = format!("{}/{}", home_dir, dict_path);
    let user_lex_csv = format!("{}/{}", home_dir, "user_lex2.csv"); // ユーザ辞書

    // 辞書ファイルのロード
    let reader = zstd::Decoder::new(File::open(dict_full_path).unwrap()).unwrap();
    let dict = Dictionary::read(reader).unwrap();
    // let dict = dict.reset_user_lexicon_from_reader(Some(File::open(user_lex_csv).unwrap())).unwrap();

    // トークナイザーの生成
    let tokenizer = Tokenizer::new(dict)
        .ignore_space(true).unwrap()
        .max_grouping_len(24);

    // ワーカーの生成。mutableです。
    let mut worker = tokenizer.new_worker();

    // 形態素解析する文章
    let text = "東京都斎藤飛鳥本とカレーの街ビットコインサムアルトマン ChatGPT";

    // 文章をセット。繰り返したい場合は、これを再度呼び出し、ワーカーを使い回す。
    worker.reset_sentence(text);
    worker.tokenize(); // 形態素解析の実行。mutable self

    println!("num_tokens: {}", worker.num_tokens());

    // 抽出したトークンをループで表示する
    worker.token_iter()
        .filter(|t| { // 絞り込み
            let words: Vec<&str> = t.feature().split(',').collect();
            let subwords: Vec<&str> = words[0].split('-').collect();
            subwords[0] == "名詞" || subwords[0] == "カスタム名詞"
        })
        .for_each(|t| { // 出力
            println!("{}: {}", t.surface(), t.feature());
        });
}

実行しましょう。ご覧のように名詞が抽出できました。

num_tokens: 11
東京: 名詞,固有名詞,地域,一般,*,*,東京,トウキョウ,トーキョー
都: 名詞,接尾,地域,*,*,*,都,ト,ト
斎藤: 名詞,固有名詞,人名,姓,*,*,斎藤,サイトウ,サイトー
飛鳥: 名詞,固有名詞,人名,名,*,*,飛鳥,アスカ,アスカ
本: 名詞,一般,*,*,*,*,本,ホン,ホン
カレー: 名詞,固有名詞,地域,一般,*,*,カレー,カレー,カレー
街: 名詞,一般,*,*,*,*,街,マチ,マチ
ビットコインサムアルトマン: 名詞,一般,*,*,*,*,*
ChatGPT: 名詞,固有名詞,組織,*,*,*,*

よくみると、齋藤飛鳥が分割されたり、ビットコインとサムアルトマンが繋がってしまっていますね。これはデフォルトの辞書IPAに最新の用語が登録されてないためです。

ユーザ辞書

切り出したい単語をユーザ辞書に登録することで問題が解決します。最新の用語や業界に特化した用語などを追加しましょう。

ユーザ辞書は以下のようなCSVファイルで定義します。齋藤飛鳥やビットコインを定義します。

user_lex.csv
神保町,1293,1293,334,カスタム名詞,ジンボチョウ
斎藤飛鳥,1293,1293,0,カスタム名詞,サイトウアスカ
ChatGPT,1293,1293,0,カスタム名詞,チャットジーピーティー
ビットコイン,1293,1293,0,カスタム名詞,ビットコイン
ようこそ,3,3,-1000,感動詞,ヨーコソ,Welcome,欢迎欢迎,Benvenuto,Willkommen

ソースコードの以下のコメントアウトした部分を有効にしてユーザ辞書をロードします。

tokenizer.rs
    let reader = zstd::Decoder::new(File::open(dict_full_path).unwrap()).unwrap();
    let dict = Dictionary::read(reader).unwrap();
    let dict = dict.reset_user_lexicon_from_reader(Some(File::open(user_lex_csv).unwrap())).unwrap(); // この行を有効化する。ユーザ辞書をロードします。

再度、実行しましょう。

num_tokens: 11
東京: 名詞,固有名詞,地域,一般,*,*,東京,トウキョウ,トーキョー
都: 名詞,接尾,地域,*,*,*,都,ト,ト
斎藤飛鳥: カスタム名詞,サイトウアスカ
本: 名詞,一般,*,*,*,*,本,ホン,ホン
カレー: 名詞,固有名詞,地域,一般,*,*,カレー,カレー,カレー
街: 名詞,一般,*,*,*,*,街,マチ,マチ
ビットコイン: カスタム名詞,ビットコイン
サムアルトマン: 名詞,一般,*,*,*,*,*
ChatGPT: カスタム名詞,チャットジーピーティー

上手くいきましたー!

ChatGPTはできるのか?

さて、今やChatGPTとCopilot無しでは仕事にならない体になってしまいましたが(GPT-4の性能には恐怖さえ覚えますね)、ChatGPT様は形態素解析ができるのか試してみました。

カタカタカタ。。。

ChatGPT様のお言葉
形態素解析は、文章を単語や単語の最小単位に分割するプロセスです。
日本語の形態素解析は特に複雑で、単語の境界が明確でないことが多いです。
以下に文章を単語で分割してみます。

東京都
斎藤
飛鳥
本
と
カレー
の
街
ビットコイン
サム
アルトマン
ChatGPT
この分割はあくまで推測であり、実際の単語の境界は文章の文脈や意図によります。
例えば、"斎藤飛鳥"は一つの名前である可能性があります。
また、"ビットコイン" "サム" "アルトマン"はそれぞれ独立した単語かもしれませんし、
"ビットコインサムアルトマン"という一つの単語や名称かもしれません。
また、"ChatGPT"は一つの単語として扱われます。

形態素解析の右斜め上をいっています。

マルチスレッドで実行してみる

ズボラな私はすぐChatGPTに頼ってしまいます。。。

ChatGPTに上記のソースコードを教えた後で、以下のようなプロンプトを続けて与えます。Certainly!と言ってカタカタとコードを生成してくれます。本当、頼もしいです。近い将来、ソースコードではなくプロンプトをGitHubにコミットすればCI&CDでコード生成する時代が来そうです。

プロンプト
can you modify the above code in order to
- process multiple texts
- create a tokenizer and wrap it in an Arc
- create a new worker in each OS thread with tokenizer.new_worker()

こんな感じのソースコードになります。

巨大なテキストファイルを投げて、4スレッドで実行します。

私のポンコツMacで各スレッド22万トークンでトータル14秒で完了しました。2倍の8スレッドにすると倍の34秒になります。形態素解析がCPUバウンドな処理であり、私のMacの論理コア数が4であるためです。

Lindera

LinderaもRust製の形態素解析になります。詳細は開発者のページをご覧ください。

インストール方法

まずはCLIツールlinderaをインストールします。ユーザ辞書のコンパイルに必要になります。

Lindera
cargo install lindera-cli

次にユーザ辞書ファイルlindera-user.csvを作成しましょう。CSV形式で定義します。どこでも良いですが本説明では$HOME直下におきます。

lindera-user.csv
ビットコイン,カスタム名詞,トウキョウスカイツリーエキ
斎藤飛鳥,カスタム名詞,サイトウアスカ
ゆりやんレトリィバァ,カスタム名詞,ユリヤンレトリィバァ

先ほどインストールしたlindera-cliでコンパイルします。./resources/lindera-user.binが生成されます。

ユーザ辞書のコンパイル
lindera build --build-user-dic --dic-type=ipadic ./lindera-user.csv ./resources

設定ファイルの作成

lindera_ipadic_conf.jsonという名前で設定ファイルを作成します。どこでも良いですが、本説明では$HOME直下におきます。

設定ファイル lindera_ipadic_conf.json
lindera_ipadic_conf.json
{
  "character_filters": [
    {
      "kind": "unicode_normalize",
      "args": {
        "kind": "nfkc"
      }
    },
    {
      "kind": "japanese_iteration_mark",
      "args": {
        "normalize_kanji": true,
        "normalize_kana": true
      }
    }
  ],
  "tokenizer": {
    "dictionary": {
      "kind": "ipadic"
    },
    "user_dictionary": {
      "path": "/Users/yamada/resources/lindera-user.bin"
    },
    "mode": "normal"
  },
  "token_filters": [
    {
      "kind": "japanese_compound_word",
      "args": {
        "kind": "ipadic",
        "tags": [
          "名詞,数"
        ],
        "new_tag": "名詞,数"
      }
    },
    {
      "kind": "japanese_number",
      "args": {
        "tags": [
          "名詞,数"
        ]
      }
    },
    {
      "kind": "japanese_stop_tags",
      "args": {
        "tags": [
          "接続詞",
          "助詞",
          "助詞,格助詞",
          "助詞,格助詞,一般",
          "助詞,格助詞,引用",
          "助詞,格助詞,連語",
          "助詞,係助詞",
          "助詞,副助詞",
          "助詞,間投助詞",
          "助詞,並立助詞",
          "助詞,終助詞",
          "助詞,副助詞/並立助詞/終助詞",
          "助詞,連体化",
          "助詞,副詞化",
          "助詞,特殊",
          "助動詞",
          "記号",
          "記号,一般",
          "記号,読点",
          "記号,句点",
          "記号,空白",
          "記号,括弧閉",
          "その他,間投",
          "フィラー",
          "非言語音"
        ]
      }
    },
    {
      "kind": "japanese_katakana_stem",
      "args": {
        "min": 3
      }
    }
  ]
}

22行目にユーザ辞書のパスを設定する箇所があります。先ほどのユーザ辞書ファイルへのパスを指定します。細かな設定に関しては、Elasticsearchやkuromojiでググってみてください。

lindera_ipadic_conf.json
    "user_dictionary": {
      "path": "/Users/yamada/resources/lindera-user.bin"
    },

プログラムの作成

cargoライブラリをインストールします。

Cargo.toml
lindera-tokenizer = { version = "0.24.0", features = ["ipadic"] }
lindera-dictionary = "0.24.0"
lindera-core = "0.24.0"
lindera-analyzer = { version = "0.24.0", features = ["ipadic", "ipadic-filter"] }

プログラム本体を作成します。設定ファイルのロードの方法が特殊ですが、特に説明は不要かと思います。

lindera.rs
use std::{fs, path::PathBuf};

use lindera_analyzer::analyzer::Analyzer;
use lindera_core::LinderaResult;

fn main() -> LinderaResult<()> {
    // 設定ファイルのロード。ご自身のパスに修正してください。
    let path = PathBuf::from(r"/Users/yamada/lindera_ipadic_conf.json");
    let config_bytes = fs::read(path).unwrap();
    let analyzer = Analyzer::from_slice(&config_bytes).unwrap();

    let mut text = "東京都斎藤飛鳥本とカレーの街ビットコインサムアルトマン ChatGPT".to_string();
    
    let tokens = analyzer.analyze(&mut text)?; // 形態素解析を実行します

    // 結果を出力します。
    for token in tokens {
        println!(
            "token: {:?}, start: {:?}, end: {:?}, details: {:?}",
            token.text,
            token.byte_start,
            token.byte_end,
            token.details
        );
    }

    Ok(())
}

実行しましょう。齋藤飛鳥とビットコインがカスタム名詞として抽出できました。

実行結果
token: "東京", start: 0, end: 6, details: ["名詞", "固有名詞", "地域", "一般", "*", "*", "東京", "トウキョウ", "トーキョー"]
token: "都", start: 6, end: 9, details: ["名詞", "接尾", "地域", "*", "*", "*", "都", "ト", "ト"]
token: "斎藤飛鳥", start: 9, end: 21, details: ["カスタム名詞", "*", "*", "*", "*", "*", "斎藤飛鳥", "サイトウアスカ", "*"]
token: "本", start: 21, end: 24, details: ["名詞", "一般", "*", "*", "*", "*", "本", "ホン", "ホン"]
token: "カレー", start: 27, end: 36, details: ["名詞", "固有名詞", "地域", "一般", "*", "*", "カレー", "カレー", "カレー"]
token: "街", start: 39, end: 42, details: ["名詞", "一般", "*", "*", "*", "*", "街", "マチ", "マチ"]
token: "ビットコイン", start: 42, end: 60, details: ["カスタム名詞", "*", "*", "*", "*", "*", "ビットコイン", "ビットコイン", "*"]
token: "サム", start: 60, end: 66, details: ["名詞", "固有名詞", "人名", "名", "*", "*", "サム", "サム", "サム"]
token: "アルト", start: 66, end: 75, details: ["名詞", "一般", "*", "*", "*", "*", "アルト", "アルト", "アルト"]
token: "マン", start: 75, end: 81, details: ["名詞", "固有名詞", "地域", "一般", "*", "*", "マン", "マン", "マン"]
token: " ", start: 81, end: 82, details: ["UNK"]
token: "ChatGPT", start: 82, end: 89, details: ["UNK"]

Vibratoよりはやや遅いですが、それでも全然速いです。さすがRust実装です。

Actixから利用する

おまけでActixアプリケーションからLinderaを利用する方法を説明します。修正箇所のみポイントを絞って説明します。

main.rsnのweb::DataにlinderaのAnalyzerをラップしてスレッド間でシェアします。OSスレッド間でシェアするにはHttpServerのファクトリ・クロージャーの外でインスタンスを生成する必要があります。クロージャー内で生成するとスレッドローカルになります。

main.rs
    let path = PathBuf::from(r"/Users/yamada/lindera_ipadic_conf.json");
    let config_bytes = fs::read(path).unwrap();
    let analyzer = Analyzer::from_slice(&config_bytes).unwrap();
    let analyzer_data = web::Data::new(analyzer);

    // Actix server
    HttpServer::new(move || {
        // analyzer_data is shared across threads as it is defined outside of the closure.
        App::new()
            .app_data(analyzer_data.clone())

次にリクエスト・ハンドラーでAnalyzerを受けて、形態素解析をします。

main.rs
#[post("/tokenize")]
async fn tokenize(analyzer: web::Data<Analyzer>) -> Result<web::Json< MyResponse>, actix_web::Error> {
    let mut text = "東京都斎藤飛鳥本とカレーの街ビットコインサムアルトマン ChatGPT".to_string();

    let tokens = analyzer.analyze(&mut text).unwrap();

    for token in tokens {

マイクロサービス化

Kubernetesのマイクロサービスとしてデプロイしました。マイクロサービス化することで、Go Gin、Python FastAPI、Next.js(Node.js)、Apollo、Rust ActixとさまざまなWebサーバ実装を状況に応じて選択できて便利です。また、形態素解析や機械学習のようにCPUバウンドなサービスをI/OバウンドなNode.jsなどから切り離せるメリットもあります。

Rustを使用する意味としてはお財布に優しいことが一番です。辞書のサイズにもよりますが、メモリ使用量75MB、30rpsでもCPU使用量はちょっとピクリとする程度でクラウドのリソースを食いません。

こちらの記事でも書きましたが、Actixの実装はTokioを使用しており、I/Oバウンドな処理を想定しています。形態素解析のようなCPUヘビィなタスクには向いていないことに注意してください。

https://zenn.dev/tfutada/articles/5e87d6e7131e8e

Discussion