エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

検索基盤チームのElasticsearch×Sudachi移行戦略と実践

f:id:abctail30:20210807174702p:plain

エムスリーエンジニアリンググループ AI・機械学習チームでソフトウェアエンジニアをしている中村(@po3rin) です。最近、AI・機械学習チーム配下の検索基盤チームでElasticsearchのAnalyzerをKuromojiからSudachiに移行しました。今回はSudachi移行の背景と、Sudachiの概要、実際に移行するにあたってのプロセスや注意事項をお話しします。

対象読者

  • Elasticsearchを少しでも使ったことがある方
  • ElasticsearchとSudachiの連携に興味がある
  • 表記ゆれ等の課題を解決したい検索エンジニア

なぜSudahchiに移行したのか

検索基盤チームが抱えていた検索の課題

エムスリー検索基盤チームでは医療ドメイン特化の検索エンジンを作成しています。弊社の検索の課題として「表記揺れ」と「複合語の分割」が挙げられます。

医療ドメインは特に表記揺れの大きな分野なので、これらの解決は検索エンジンの精度向上に寄与します。例えば「胃がん/胃ガン/胃癌」などの平仮名/カタカナ/漢字の揺れがあります。Kuromojiではシノニムで全ての専門用語の表記揺れを定義して吸収するという対策があります。

// シノニム辞書で胃癌の表記揺れに対応する例
胃がん,胃ガン,胃癌

しかし、医療用語の表記揺れパターンは非常に多く、全てをシノニム辞書でまかなうのは困難です。また辞書の運用コストも上がります。

また、医療用語は複合語(2つ以上の単語が連結して、別の1つの語を形造ったもの)が非常に多く、「労作性狭心症冠動脈ステント留置後」のように、どこまでを構成要素単位とするかの判定に曖昧性が生じるものが多く、これが検索の品質を下げる原因になります。

特に医療用語では「〇〇性」という表現が頻出し、病名の分類を示すために多く用いられています。例えば「先天性内反足」は先天性という意味的な分類を含んだ病名であり、これが医療用語の複合語を多くしている原因の1つです。他にも「血性」「外傷性」などいろんな「〇〇性」を含む複合語が存在します。医療用語の複合語の構成要素を決定するための研究も存在し、医療用語の構成語の意味的カテゴリーを分類する試みがあります。

Kuromojiのユーザー辞書にこれらの医療複合語を含むと大きな問題が発生します。Kuromojiはユーザー辞書に連接コストを含む事ができず、ユーザー辞書に定義してある単語の単位で形態素解析されてしまいます。下記はKuromojiユーザー辞書を使って「先天性内反足」を定義する例です。

// Kuromojiユーザー辞書で医療複合語を定義する例
先天性内反足,先天性内反足,センテンセイナイハンソク,名詞

このように定義した場合、ドキュメントにユーザー辞書に定義されている「先天性内反足」が出てきても「先天性内反足」というトークンでインデックスされるので、クエリ「内反足」でそのドキュメントにヒットしません。Kuromojiに関してはn-bestで切っていく方法が挙げられますが、最適な複合語の分割になっていない事が多く、おかしな検索結果になってしまう事があります。

まとめると我々のWANTは下記に集約されます。

  • 医療用語の表記揺れに対応したい
  • 医療複合語の辞書を正しく分割したい

余談ですが、ここで紹介した以外にも医療自然言語処理の分野では医療ドメイン特有の課題があります。それらに興味のある方は下記の書籍がオススメです。

Sudachiとは

これまでに紹介した問題を解決するためにエムスリー検索基盤チームではkurmojiからSudachiに移行しました。Sudachiは日本語形態素解析器であり、開発はワークス徳島人工知能NLP研究所が主に行なっています。

Sudachiでは分割モード(A/B/C)が選べ、利用シーンに応じて分割方法を変える事ができます。

$ echo '心房細動' | sudachipy -m C -s full
心房細動        名詞,固有名詞,一般,*,*,*        心房細動

echo '心房細動' | sudachipy -m B -s full
心房    名詞,普通名詞,一般,*,*,*        心房
細動    名詞,普通名詞,一般,*,*,*        細動

また、Sudachiでは定期的に辞書に新語・固有表現が追加されており、最新のアップデートでは「まん防/まん延防止等重点措置」などが追加されたりと、かなり新しい単語も追加してくれます。

そしてSudachiでは柔軟なユーザー辞書定義、正規化機能が組み込まれており、我々が持つ課題と非常に相性が良いです。

例えば「胃がん」や「胃ガン」は「胃癌」にデフォルトで正規化できます。

$ echo '胃がん' | sudachipy -m C -s full
胃がん  名詞,普通名詞,一般,*,*,*        胃癌

$ echo '胃ガン' | sudachipy -m C -s full
胃ガン  名詞,普通名詞,一般,*,*,*        胃癌

Sudachiのユーザー辞書では連接コストや分割モードごとの分割方法が定義でき、表現力豊かにユーザー辞書を扱えます。例えば「発作性心房細動」のような複合語は下記のようにユーザー辞書を作成します。

// Sudachiユーザー辞書で「発作性心房細動」を定義する例
発作性心房細動,4786,4786,5000,発作性心房細動,名詞,普通名詞,一般,*,*,*,,発作性心房細動,*,C,584006/434835/428494/619020,2756385/428494/619020,*

あとでユーザー辞書フォーマットについては説明しますが、このように定義しておくことで分割モードBでは「発作性/心房/細動」、モードCでは「発作性心房細動」と形態素解析できます。これは完全一致の場合はスコアを高くして、部分一致でもヒットさせたいという用途では非常に有用です。

このようにSudachiを利用すると医療用語特有の表記揺れや複合語問題に対してある程度対応できるようになります。

Sudachiへの移行戦略と実践

Elasticsearchをゼロから使い始めるときは、Sudachiプラグインのドキュメントや解説記事を見ればすぐに使い始めることができます。

github.com

しかし、すでにElasticsearchを運用している場合は、既存の辞書やシノニム辞書がすでに存在し、それらの既存リソースを移行するのは少し手前のかかる作業です。

また、Analyzerの変更は検索結果に大きな影響があるので事前に検索結果を確認できるようにしておく必要があります。

我々の移行では以下の移行プロセスを計画しました。

  • 今使っているKuromojiユーザー辞書をSudachiユーザー辞書に移行する
  • 今使っているシノニム辞書からSudachi正規化機能で賄えるものを削除する
  • 平仮名/カタカナの正規化辞書を作る
  • 移行時のSudachi切り替え戦略
  • 移行後の影響の事前確認

ここからは実際にエムスリー検索基盤チームがどのようにSudachiに移行したかをお話しします。

今使っているKuromojiユーザー辞書をSudachiユーザー辞書に移行する

SudachiからKuromojiに移行する際にすでに運用しているユーザー辞書があるならそれをSudachi辞書に変換してあげる必要があります。ユーザー辞書を作った元になるコーパスがあったとしてもSudachi用に変換スクリプトを新しく書くのはコストがかかります。そこで検索基盤チームで開発したkuro2sudachiという辞書変換ツールを使って辞書の移行しました。このツールはOSSとして公開しています。

github.com

Kuromojiユーザー辞書で付与している品詞ごとに連接コスト、分割方法を指定でき、かなり柔軟に辞書を変換できます。

例えば設定ファイルで下記を定義します。

{
    "名詞": {
        "sudachi_pos": "名詞,普通名詞,一般,*,*,*",
        "left_id": 4786,
        "right_id": 4786,
        "cost": 5000,
        "split_mode": "C",
        "unit_div_mode": [
            "A", "B"
        ]
    }
}

名詞という品詞がついた単語をSudachiの品詞体系に合わせて名詞,普通名詞,一般,*,*,*に変換します。品詞ごとに連接コスト、利用する分割モードを定義できます。kuro2sudachiの内部ではSudachiPyをモジュールとして利用し、形態素解析した結果を分割情報として登録しています。

これでkuro2sudachiを実行すると、下記のように品詞変換、連接コスト付与、分割モード定義済みのSudachi辞書が手に入ります。

$ cat kuromoji_dict.txt
融合たんぱく質,融合たんぱく質,融合たんぱく質,名詞
発作性心房細動,発作性心房細動,発作性心房細動,名詞

$ kuro2sudachi kuromoji_dict.txt -o sudachi_user_dict.txt -c convert_config.json --ignore

$ cat sudachi_user_dict.txt
融合たんぱく質,4786,4786,5000,融合たんぱく質,名詞,普通名詞,一般,*,*,*,,融合たんぱく質,*,C,*,660881/810248,*
発作性心房細動,4786,4786,5000,発作性心房細動,名詞,普通名詞,一般,*,*,*,,発作性心房細動,*,C,584006/434835/428494/619020,2756385/428494/619020,*

Sudachiの辞書フォーマットに関しては公式のドキュメントをご覧ください。あとで詳しく説明しますがkuro2sudachiでは分割情報を構成語のIDに変換してユーザー辞書に記載します。これはユーザー辞書のビルド速度をあげるためです。

github.com

今使っているシノニム辞書からSudachi正規化機能でまかなえるものを削除する

Sudachiの正規化で事足りる名寄せをシノニムに定義する必要はないので、それらを削除して軽いシノニム辞書にしていきたいところです。

例えば弊社のシノニム辞書には「たんぱく質,蛋白質」という定義がありましたが、これはSudachiの正規化で揺れを吸収できます。

echo 'たんぱく質' | sudachipy -m C
たんぱく質      名詞,普通名詞,一般,*,*,*        蛋白質

今回の移行では私がOSSとして公開しているchinormfilterというツールを使ってこれを実現しました。

github.com

このツールを使ってSudachi正規化でまかなえるものを削除します。

$ cat tests/text.txt
レナリドミド,レナリドマイド
リンゴ => 林檎
飲む,呑む
tlc => tlc,全肺気量
リンたんぱく質,リン蛋白質,リンタンパク質

$ chinormfilter tests/test.txt -o out.txt

$ cat out.txt
レナリドミド,レナリドマイド
tlc => tlc,全肺気量

このプロセスで弊社ではシノニム辞書を340行から284行に減らしました。

平仮名/カタカナの正規化辞書を作る

Sudachiではある程度平仮名/カタカナ/漢字の表記揺れに対応してくれますが、全てに対応してくれる訳ではありません。例えば「これすてろーる」はSudachi辞書で未定義であり、形態素解析するとおかしな結果になります。

echo 'これすてろーる' | sudachipy -m C -s core
これ    代名詞,*,*,*,*,*        此れ
すてろ  動詞,一般,*,*,下一段-タ行,命令形        捨てる
ー      補助記号,一般,*,*,*,*   ー
る      助動詞,*,*,*,文語助動詞-リ,連体形-一般  り

医療ドメインの検索では多くのカタカナが出現し、ユーザーは平仮名でこれらの単語を検索します。そのため、デフォルトのSudachiで正規化できない平仮名もカタカナに寄せていきたいところです。そこで今回の移行に合わせて平仮名をカタカナに正規化したSudachiユーザー辞書を作成しました。これを実現するためのhirakanadicというツールをOSSとして公開しています。

github.com

このツールでは出てきた未定義のカタカナを検知して、その平仮名表現をカタカナに正規化する形でユーザー辞書を作成します。

$ cat example/input.txt
コレステロール値
陰のうヘルニア
濾胞性リンパ腫
コリネバクテリウム・ウルセランス感染症

$ hirakanadic example/input.txt -o out.txt

$ cat out.txt
これすてろーる,5146,5146,7000,これすてろーる,名詞,普通名詞,一般,*,*,*,コレステロール,コレステロール,*,*,*,*,*
へるにあ,5146,5146,7000,へる000,にあ,名詞,普通名詞,一般,*,*,*,ヘルニア,ヘルニア,*,*,*,*,*
こりねばくてりうむ,5146,5146,7こりねばくてりうむ,名詞,普通名詞,一般,*,*,*,コリネバクテリウム,コリネバクテリウム,*,*,*,*,*
うるせらんす,5146,5146,7000,うるせらんす,名詞,普通名詞,一般,*,*,*,ウルセランス,ウルセランス,*,*,*,*,*

平仮名を全てシノニムで定義する方法もありますが、このようにSudachiユーザー辞書で正規化を定義した方がSudachiの方針に合わせられるので、この方法を採用しています。

移行時のSudachi切り替え戦略

AnalyzerのABテストをやる際には同じindexに2つのAnalyzerが必要です。例えばtitleとtitle_sudachiなど、それぞれのAnalyzer毎にフィールドを分けると、APIのリリース順序が複雑になり、データ投入Batchの改修も必要になってきます。

そこで我々は移行戦略としてElasticsearchのmulti-fieldsを採用することにしました。

www.elastic.co

同じフィールドに複数の方法でドキュメントを格納するように設定できます。これを使うと下記のようなmappingになります。

// ...
      "title": {
        "type": "text",
        "analyzer": "knuth_doc_analyzer",
        "search_analyzer": "knuth_search_analyzer",
        "fields": {
          "sudachi": {
            "type": "text",
            "analyzer": "sudachi_doc_analyzer",
          }
        }
      },
// ...

こうしておくとAPIを修正前にAliasを切り替えても、対象フィールドがtitleで変わらないので影響なく動かせます。Aliasを切り替えてから、ABテスト介入群のクエリの向き先をtitle.sudachiに変えてあげれば移行完了です。このメリットはデータ投入Batchなどが投入する先のフィールドの変更が必要ないことです。このようにmulti-fieldsを採用することでAnalyzerの移行をスムーズに進めることができます。

移行後の影響の事前確認

Analyzerの移行時には検索結果が大きく変わる恐れがあります。リリース前に結果の変化がユーザーにとって問題がないかを事前チェックする必要があります。

弊社では下記のようにKuromoji/Sudachiの結果をCSVで出力し、スプレッドシートでインポートして比較するようにしました。スプレッドシートで出すのはビジネスサイドの人間でも確認できるようにするためです。

キーワード,before_hits,after_hits,before_tokens,after_tokens_a,after_tokens_c
ぼうこう,結石,2,1156,ボウコウ/結石,膀胱/結石,膀胱/結石  

// 以下省略

スプレッドシートの表記だとこのようになります。

f:id:abctail30:20210807160750p:plain
スプレッドシートでAnalyzer移行前後比較

before/afterはそれぞれKuromoji/Sudachiの結果ですbefore_hitsは移行前のヒット数after_hitsは移行後のヒット数です。abefore_tokensは移行前の結果でfter_tokens_a/after_tokens_cはぞれぞれSudachi分割モードA/Bの結果です。

事前チェックのポイントとして、ヒット数が前後で大きく減少しているものは再現率が大きく下がっている可能性があります。それらを定性的に確認して問題が起きていないか確認しましょう。逆にヒット数が大きく増えている場合は、品詞フィルターの設定ミスで検索に必要なトークンが削られている可能性があるので、そちらもチェックしておきましょう。

このように事前に移行時の問題をある程度確認できます。我々はこの方法で移行時の品詞フィルターの設定ミスやSudachiで対応できていない表記揺れの問題などを検出できました。

弊社のリリース後のABテストについては現在実施中です。ABテストの結果と詳しい方法の話はまた後日ブログで報告しようと思います。

Sudachi移行時のハマりポイント

最後にSudachi移行時にハマったポイントをお話しします。

A分割とSudachiシノニム辞書の噛み合わせが悪い

A分割モードでSudachiが提供しているシノニム辞書を使おうとするとおかしな挙動をしたり、エラーが出たりします。これはSudachiシノニム辞書に登録されている単語が基本的にC分割単位の単語であるため、A分割モードで使おうとするとおかしなtokenizeになります。

実際に弊社では「自宅療養」という単語でエラーになることを発見しました。

理由を追ってみましょう。Sudachiシノニム辞書に「自宅」というシノニムが定義されています。

御家,お家,おうち,御宅,お宅,自宅

ここで「自宅療養」という単語をA分割モード+Sudachiシノニム辞書の組み合わせでAnalyzeすると下記の結果になります(少し長いので折りたたんでいます)。

offsetやpositionをみると明らかにシノニムグラフがおかしくなっています。これはヒットしたシノニムがさらにSudachiで分割されてしまう結果起きる問題です。

実際に分割モードA/Cで形態素解析した結果は下記になります。

echo 'お家' | sudachipy -m A -s full
お      接頭辞,*,*,*,*,*        御
家      名詞,普通名詞,一般,*,*,*        家

echo 'お家' | sudachipy -m C -s full
お家    名詞,普通名詞,一般,*,*,*        御家

この結果からA分割モードだとシノニム展開した単語がさらに形態素解析されてしまうことがわかります。弊社では一旦、A分割モードを利用するAnalyzerだけシノニム辞書を外すことで対処しています。また、他の回避策としてはA分割ではないシノニムだけを削除したシノニム辞書を作成する方法もあります。抜本的な解決方法はまだ調査中なので、同じ問題に当たった方の解決策があれば是非教えていただきたいです。

ユーザー辞書のビルド速度 ~ 構成語IDか構成語情報か ~

Sudachiのユーザー辞書の分割情報は構成語のIDまたは構成語情報を記載することになります。

// 構成語IDで分割情報を記述
融合たんぱく質,4786,4786,5000,融合たんぱく質,名詞,普通名詞,一般,*,*,*,,融合たんぱく質,*,C,*,660881/810248,*

// 構成語情報で分割情報を記述
融合たんぱく質,4786,4786,5000,融合たんぱく質,名詞,普通名詞,一般,*,*,*,,融合たんぱく質,*,C,*,"融合,名詞,普通名詞,サ変可能,*,*,*,ユウゴウ/たんぱく質,名詞,普通名詞,一般,*,*,*,タンパクシツ",*

最初kuro2sudachiは辞書のメンテナンス容易性(どのように分割しているかがすぐにわかる状態)のために分割情報を構成語情報で記述するパターンを採用していました。しかし、辞書をビルドする時に非常に時間がかかり、ビルドが全く終わらないという現象が発生していました。

原因はSudachiPyで構成語情報からIDを引っ張ってくるコードが辞書の線形探索をしている為です。弊社から現在TRIEを使う実装を提案しています。

github.com

こちらまだマージされないので、kuro2sudachiでは構成語IDで記述することで一旦対処しています。

Sudachi移行の結果

移行の結果、様々なクエリで検索結果が改善しました。この章ではKuromojiとSudachiの移行時の前後比較を示し、Sudachiのメリットを再確認します。ユーザー辞書、シノニム辞書に登録しているものは変えていないのでKuromojiとSudachiの純粋な機能比較です。ちなみにSudachiで使っているシステム辞書はsudachi_full.dicを利用しています。

最後に、私が感じたSudachiに移行することで発生するデメリットも簡単に紹介します。

システム辞書の充実度

Kuromojiだと「めがしら」などが辞書になかったために形態素解析の結果がおかしくなっていたが、Sudachi辞書は語彙が豊富なため「めがしら」が検索でき、今までヒットしなかったドキュメントがヒットするようになりました。

query kuromoji sudachi C mode ヒット数変化 備考
めがしら 痒み メ / シラ / 痒イ 目頭 / 痒み 0 → 791 「めがしら」がシステム辞書にある
首こり お灸 首 / コリル / 灸 首凝り / 灸 48 → 6 ヒット数は下がったがPrecisionが上がっている
憩室 予防 憩 / 室 / 予防 憩室 / 防止 / 予防 65 → 78 「憩室」がシステム辞書にある

ちなみにKuromojiの方で平仮名がカタカナになっているのは、filterで平仮名を強制的にカタカナに寄せている影響です。これは平仮名を強制的にカタカナに寄せるという地獄の対策をしていた為です。

一方で「すべり症 」などはSudachiにも定義がなかったので、語彙が多いとはいえ、やはりこちらでもユーザー辞書で語彙を補完する必要はありそうです。

正規化

Sudachiの正規化で「ぼうこう」が「膀胱」に正規化できるなど、Recallが大幅に向上しました。

query kuromoji mode sudachi C mode ヒット数変化 備考
ぼうこう 結石 ボウコウ / 結石 膀胱 / 結石 2 → 1156 「ぼうこう/膀胱」を寄せれている
メトトレキサート 抗がん剤 メトトレキサート / 抗ガン剤 メトトレキサート / 抗癌剤 13 → 17 「抗がん剤/抗癌剤」を寄せれている

一方でSudachi正規化では「治る/直る」に寄せていたりするので、アプリケーションによっては正規化が少しやりすぎになる可能性もあります。

echo '治る' | sudachipy -m C -s core
治る    動詞,一般,*,*,五段-ラ行,終止形-一般     直る

このようなものはユーザー辞書で上書き可能なので、もし気になる正規化があれば上書きしておくことをお勧めします。

分割情報定義

ユーザー辞書に連接コストや分割情報が適切に定義できるようになり、ユーザー辞書で「手指こわばり」があっても「手指が強張っている」にヒットするようになりました。

query kuromoji sudachi A mode sudachi C mode ヒット数変化 備考
手指こわばり 手指こわばり 手指 / 強張る 手指こわばり 8 → 425 分割情報が分割モード毎に利用できている
脳腫瘍 初期症状 脳腫瘍 / 初期 / 症状 脳 / 腫瘍 / 初期 / 症状 脳腫瘍 / 初期症状 22 → 25 分割情報で「脳の腫瘍」に「脳腫瘍」がヒットするようになった

デメリット: ユーザー辞書の管理コスト

一方でデメリットも存在すると思っています。辞書が表現豊かになる反動で、ユーザー辞書の管理コストが少し上がります。まずはユーザー辞書フォーマットですが、Kuromojiと比較すると非常に複雑になっているのがわかります。ハマりポイントで説明したように大きな辞書をビルドする際には構成語ID指定をする必要があるので、どんな分割になっているか一見分からなくなっています。

$ cat kuromoji_dict.txt
融合たんぱく質,融合たんぱく質,融合たんぱく質,名詞
発作性心房細動,発作性心房細動,発作性心房細動,名詞

$ cat sudachi_user_dict.txt
融合たんぱく質,4786,4786,5000,融合たんぱく質,名詞,普通名詞,一般,*,*,*,,融合たんぱく質,*,C,*,660881/810248,*
発作性心房細動,4786,4786,5000,発作性心房細動,名詞,普通名詞,一般,*,*,*,,発作性心房細動,*,C,584006/434835/428494/619020,2756385/428494/619020,*

また、Kuromojiの時に比べてユーザー辞書のビルドなどの工程が余分に発生します(もちろん自動化すれば良いですが)。我々の場合は検索品質向上のメリットが辞書の管理コストを上回ると考えています。

おまけ

これまでに紹介した一連のSudachi移行の中でエムスリーの検索基盤チームから「ユーザー辞書のビルド速度」で紹介したPR以外に1つのIssueと1つのPRを作成しました。

まずは数詞を含む分割情報がビルドできない問題です。これはSudachi辞書フォーマットのパーサーが「見出しとしての 1 」を「行番号1」に誤認していたために起きていた問題でした。こちらはすでに修正がマージされましたが最新のリリースであるv0.5.2には入っていないので同じ問題に当たった場合はdevelopブランチを利用しましょう。 github.com

あとはExceptionをreturnしているミスの修正とエラーメッセージの詳細化です。こちらもマージされました。 github.com

Sudachiは本番運用に十分耐えられる品質を持っていますが、まだまだ成長過程なので、これからもどんどんコントリビュートしていきたいです。

まとめ

今回はElasticsearchでSudachiを利用する方法と、エムスリー検索基盤チームでKuromojiからSudachiに移行した話をしました。Sudachiに移行して検索の体験が非常に良くなりましたが、まだ理想の医療検索エンジンとは言えないので、これからもどんどん検索改善をしていきます。

移行時に作ったツールもOSSとして公開しているのでPRお待ちしております!

kuro2sudachi(辞書変換) github.com

chinormfilter(Sudachi正規化でまかなえるシノニムの削除) github.com

hirakanadic(Sudachi正規化できない平仮名の正規化辞書生成) github.com

We're hiring !!!

エムスリーでは検索&推薦基盤の開発&改善を通して医療を前進させるエンジニアを募集しています! 社内では日々検索や推薦についての議論が活発に行われています。

「ちょっと話を聞いてみたいかも」という人はこちらから! jobs.m3.com