Rustの形態素解析ライブラリ - VibratoとLindera
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.toml
にvibrato
を追加します。また、辞書ファイルがzstd
で圧縮されているためこちらも追加しておきましょう。
vibrato = "0.5.0"
zstd = "0.12.3"
特に複雑なことはないのでソースコードをドンと見せます。
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ファイルで定義します。齋藤飛鳥やビットコインを定義します。
神保町,1293,1293,334,カスタム名詞,ジンボチョウ
斎藤飛鳥,1293,1293,0,カスタム名詞,サイトウアスカ
ChatGPT,1293,1293,0,カスタム名詞,チャットジーピーティー
ビットコイン,1293,1293,0,カスタム名詞,ビットコイン
ようこそ,3,3,-1000,感動詞,ヨーコソ,Welcome,欢迎欢迎,Benvenuto,Willkommen
ソースコードの以下のコメントアウトした部分を有効にしてユーザ辞書をロードします。
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に上記のソースコードを教えた後で、以下のようなプロンプトを続けて与えます。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
をインストールします。ユーザ辞書のコンパイルに必要になります。
cargo install lindera-cli
次にユーザ辞書ファイルlindera-user.csv
を作成しましょう。CSV形式で定義します。どこでも良いですが本説明では$HOME直下におきます。
ビットコイン,カスタム名詞,トウキョウスカイツリーエキ
斎藤飛鳥,カスタム名詞,サイトウアスカ
ゆりやんレトリィバァ,カスタム名詞,ユリヤンレトリィバァ
先ほどインストールした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
{
"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でググってみてください。
"user_dictionary": {
"path": "/Users/yamada/resources/lindera-user.bin"
},
プログラムの作成
cargoライブラリをインストールします。
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"] }
プログラム本体を作成します。設定ファイルのロードの方法が特殊ですが、特に説明は不要かと思います。
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のファクトリ・クロージャーの外でインスタンスを生成する必要があります。クロージャー内で生成するとスレッドローカルになります。
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を受けて、形態素解析をします。
#[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ヘビィなタスクには向いていないことに注意してください。
Discussion