🌾

Elasticsearchで日本語を同義語展開する

2021/12/15に公開

全文検索における同義語展開の必要性

全文検索では、基本的に文字列のマッチにより検索を行います。しかし我々が言葉を扱うときには、同じものを違う表現で指し示すことが多々あります。

例えば「独占禁止法」と呼ばれる法律があります。これは経済憲法とも言われる大変重要な法律なのですが、日本では「昭和二十二年法律第五十四号(私的独占の禁止及び公正取引の確保に関する法律)」という法律がそれに該当し、独占禁止法という名前にはなっていません。これを皆、「独占禁止法」や「独禁法」といった代替可能な別表現(同義語)で呼んでいるわけです。

https://ja.wikipedia.org/wiki/私的独占の禁止及び公正取引の確保に関する法律

同法律には法令用語で言うところの「題名」は付されておらず、頭書の名称は制定時の公布文から引用したいわゆる「件名」である。独占禁止法ないし独禁法と略称されることも多い。

もし「独禁法」で検索して当該法律がヒットしなければ、ユーザーとしては不満足でしょう。検索システムのクオリティを向上させるためには、このようなことを検討しなければなりません。

同義語展開ではなく「正規化」による対応

上記では「私的独占の禁止及び公正取引の確保に関する法律」と「独禁法」を同一視したかったわけですが、ここまで違う表現ではなくても、表記の揺れにより検索マッチしないことも多々あります。そのようなときには、同義語展開ではなく正規化を行うことで対処できるケースが多いでしょう。

まずElasticsearchには、ICU Normalization Character FilterというUnicode正規化を行うフィルターがあります。これにより、例えば「㌶」→「ヘクタール」といった同義語をわざわざ登録する必要がなくなります。

https://www.elastic.co/guide/en/elasticsearch/plugins/current/analysis-icu-normalization-charfilter.html

加えて、日本語でよく使われるアナライザー(形態素解析器)のKuromojiSudachiでは、それぞれ正規化の仕組みがあります。

Kuromojiでの正規化

https://www.elastic.co/guide/en/elasticsearch/plugins/current/analysis-kuromoji.html

Elasticsearchで用意されている日本語のためのアナライザーKuromojiには、いくつかの正規化用フィルターが用意されています。以下に列挙します;

  • kuromoji_baseform
    • 語を原形へ変換
    • 例: 「飲み」→「飲む」
  • kuromoji_number
    • 漢数字をアラビア数字に正規化
    • 例: 「一〇〇〇」→「1000」
  • kuromoji_stemmer
    • カタカナ語の末尾にある長音記号を正規化
    • 例: 「サーバー」→「サーバ」
  • kuromoji_iteration_mark
    • 踊り字を展開
    • 例: 「こゝろ」 → 「こころ」

Sudachiでの正規化

Sudachiの場合は、「形態素解析器Sudachiのプラグインによる正規化」と「elasticsearch-sudachiフィルターによる正規化」という2種類のものがあります。

形態素解析器Sudachiのプラグインによる正規化

https://github.com/WorksApplications/Sudachi

Sudachiでは内部で正規化が行われています。これらはプラグインという形で提供されており、設定ファイルを変更することでその適用をコントロールしたりすることができます。以下にデフォルトで提供されているプラグインを列挙します;

  • 文字正規化
    • 小文字化(「A」→「a」)
    • Unicode正規化(NFKC, ただし設定ファイルrewrite.defに定義される抑制・置換を優先)
  • 長音正規化
    • 「~」や長音記号連続の正規化
    • 例: 「ゴーー〜〜ル!」→「ゴール」
  • 数詞正規化
    • 漢数詞や位取りの正規化
    • 例: 「一二三万二千円」→「1232000」

elasticsearch-sudachiフィルターによる正規化

https://github.com/WorksApplications/elasticsearch-sudachi

elasticsearch-sudachi(Elasticsearch用のSudachiプラグイン)のためには、公式に以下二つのフィルターが用意されています;

  • sudachi_baseform
    • 語を原形へ変換
    • 例: 「飲み」 → 「飲む」
  • sudachi_normalizedform
    • 語を正規化表記へ変換
    • (こちらを使う場合は、sudachi_baseformは不要)
    • 例: 「呑み」→「飲む」

後者の「正規化表記」について更に解説します。これは、Sudachi辞書に含まれる正規化表記情報を利用しています(ある意味で同義語辞書と似た言語資源情報と言えます)。具体的には、以下のような正規化表記の種類があります;

  • 送り違い
    • 例: 「打込む」 → 「打ち込む」
  • 字種
    • 例: 「かつ丼」 → 「カツ丼」
  • 異体字
    • 例: 「附属」 → 「付属」
  • 誤用
    • 例: 「シュミレーション」 → 「シミュレーション」
  • 縮約
    • 例: 「ちゃあ」 → 「ては」

これら辞書開発の詳細について興味のある方は、次の論文もぜひご参照ください; 形態素解析器『Sudachi』のための大規模辞書開発 (坂本ら 2018)

もちろんElasticsearchに限らず、SudachiやSudachiPyだけで形態素解析した場合にも、結果から正規化表記を得ることができます;

from sudachipy import tokenizer
from sudachipy import dictionary

tokenizer_obj = dictionary.Dictionary().create()

tokenizer_obj.tokenize("附属")[0].normalized_form()
# => '付属'
tokenizer_obj.tokenize("SUMMER")[0].normalized_form()
# => 'サマー'
tokenizer_obj.tokenize("シュミレーション")[0].normalized_form()
# => 'シミュレーション'

語によっては「同義語辞書による展開」ではなく、「適切な正規化表記を設定した語をユーザー辞書へ登録」することのほうが適している場合もあるでしょう(そしてその際には形態素解析結果自体が変わり得ます)。

ちなみにユーザー辞書には事前の文字正規化が必要なのですが、その詳細については以下の記事で解説しました。もしユーザー辞書を利用される方はご参照ください;

https://zenn.dev/sorami/articles/6bdb4bf6c7f207

また@po3rinさんによる以下の記事では、「今使っているシノニム辞書からSudachi正規化機能でまかなえるものを削除する」といった方針とコードが紹介されていたり、それに限らず一般にSudachiをElasticsearchへ導入したい方にとって参考になる情報が沢山述べられています;

https://www.m3tech.blog/entry/sudachi-es#Sudachiへの移行戦略と実践

Elasticsearchでの同義語展開

同義語展開フィルター: Synonym Graph Token Filter

Elasticsearchでは、同義語展開のためのトークンフィルターSynonym Graph Token Filterがデフォルトで用意されています。類似したSynonym Token Filterというものもありますが、Graph版では複数単語同義語を扱えたりとより洗練されています。ただしGraph版はインデックス時には利用できず、検索時にのみ使えるという制限があります(後述)。

https://www.elastic.co/guide/en/elasticsearch/reference/current/analysis-synonym-graph-tokenfilter.html

同義語辞書資源の準備

同義語展開を行うためには、そのための言語資源(辞書)が必要です。基本的にはそれぞれのサービスごとに辞書を人手で作り込む必要があるでしょう。そのためには、ドメイン知識を活用したり、クエリ履歴を参考にしたりすることが考えられるでしょう。

一般的に公開されているデータの例としては、Sudachi同義語辞書があります。これは専門家の手によって整備されている高品質な言語資源です(Apache2.0ライセンス、商用利用可)。形態素解析器Sudachiのために制作されている辞書ですが、それをElasticsearchで使える形に変換する方法については以下の記事で述べました;

https://zenn.dev/sorami/articles/d19afb40fbb838

同義語の階層(例)

法律分野では、デジタル庁による e-Gov法令検索「略称法令名一覧」 というページがあり、「独占禁止法」や「独禁法」といった語も掲載されています。これだけで十分ではないにしても、とっかかりとしては参考になるでしょう。このページからSudachi同義語辞書を作成する方法については以下の記事で述べました;

https://zenn.dev/sorami/articles/60131682bfa34f

同義語辞書ファイル: Solr Synonyms

Synonym Graph Token Filterでは、Solr SynonymsWordNetという2種類の同義語辞書形式に対応しています。当記事では簡単に記述できる前者のみを扱います。

Solr Synonyms形式では、基本的には、行ごとに、語をカンマ区切りで表記します。

曖昧,あいまい,不明確,あやふや,不明瞭,不確か
宛て先,あて先,宛先,送り先,送付先,届け先,発送先,配送先
粗筋,あらすじ,荒筋,概略,大略,概要,大要,要約,要旨,梗概,サマリー,サマリ,summary,レジュメ,レジメ,résumé,シノプシス,synopsis,アウトライン,outline=>粗筋,あらすじ,荒筋,概略,大略,概要,大要,要約,要旨,梗概,サマリー,サマリ,summary,レジュメ,レジメ,résumé,シノプシス,synopsis,アウトライン,outline,resume,概ね,あらまし,アブストラクト,abstract

また、 => と表記することで展開方向の抑制を行うことができます。例えば、以下の同義語グループがあったとします;

アイスクリーム,ice cream,ice=>アイスクリーム,ice cream,ice,アイス

この時「アイスクリーム」は、「アイスクリーム」「ice cream」「ice」「アイス」に展開されます。他方で 「アイス」は、「アイス」としか出力されず、同義語展開されません。このような例(「アイス」が「アイスクリーム」だけを指すとは限らない)のときには展開抑制が重要になるでしょう。

また、 => という表記がない行では、Synonym Graph Token Filterの expand オプションによって展開のされ方が異なります。これがtrueのときには「展開」し、falseのときは一つ目の語に「名寄せ」します(デフォルトではtrue)。以下に例を示します;

# このような同義語グループがあったとき...
A, B, C

# expand=true では以下と同じ: 一つ目(A)にまとめられる
A, B, C => A

# expand=false では以下と同じ: それぞれに展開される
A, B, C => A, B, C

同義語辞書のロード

同義語は、フィルターの synonyms というフィールドにべた書きすることもできますし、テキストファイルから読み込むこともできます。辞書ファイルは synonym_path で指定します(cofig locationからの相対パス);

// べた書き
"filter": {
  "synonym_graph": {
    "type": "synonym_graph",
  "synonyms": [ "A, B => C" ]
}
// テキストファイルからの読み込み
"filter": {
  "graph_synonyms": {
    "type": "synonym_graph",
    "synonyms_path": "analysis/synonym.txt"
  }
}

関連するフィルターのオプションとして lenient があります。これがtrueのときには、ある同義語辞書グループのパースに失敗しても、エラーにならずスキップして続行します(デフォルトではfalse)。

また、もしそのパスにファイルが存在しないときには、以下のようなエラーが発生します。空のファイルでもいいので存在するとエラーは発生しません。これは、アナライザーを作成するとき、もしくは後述する _reload_search_analyzers エンドポイントからリロードするときに発生します(後者の場合は、リロードされないだけで、元のアナライザーはそのまま動いています)。アナライザーを作成した後は、もう同義語はロードされているので、辞書ファイルがなくなっても動き続けます。しかし、ノードが再起動されるときにエラーが発生します;

{
  "type": "illegal_argument_exception",
  "reason": "IOException while reading synonyms_path_path: analysis/synonym.txt"
}

インデックスの再オープンを伴わない辞書リロード

Elasticsearch 7.3 から、インデックスを再オープンせずとも同義語辞書をリロードできるエンドポイントが追加されました。 POST <index>/_reload_search_analyzers というものです。これにより、インデックスの検索用アナライザーがリロードされます。サービス(インデックス)を止めずに同義語辞書を更新していきたいときに便利でしょう。

これを実行できるようにするには、事前にフィルターの設定で updateable フラグをtrueにしておく必要があります;

"filter": {
  "synonym": {
    "type": "synonym_graph",
    "synonyms_path": "analysis/synonym.txt",  
    "updateable": true                        
  }
}

https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-reload-analyzers.html

展開のタイミング: インデックス時 or 検索時

同義語展開フィルターはアナライザーに付属しますが、そのアナライザーがインデックス用か検索用か、という問題があります。

インデックス用アナライザーでの同義語展開は、インデックスサイズが大きくなる、同義語辞書が変わったときに全て再インデックスする必要がある、などのデメリットがあります。また上で述べたように、Synonym Graph Token Filterは検索時にしか利用することができません。加えて前節で述べたように、「インデックスの再オープンを伴わない辞書リロード」は検索用アナライザーでしかできません。

検索用アナライザーのデメリットは、クエリのたびに展開のオーバーヘッドがかかる、というものです。

以下の公式ブログ記事では「インデックス時vs検索時」について解説されていますが、基本的には「検索時が良い」という論調になっています;

https://www.elastic.co/jp/blog/boosting-the-power-of-elasticsearch-with-synonyms

概して、検索時に同義語フィルターを使用することのメリットは、インデックス時に同義語フィルターを使用することでわずかにパフォーマンスが向上することのメリットを上回ります。

kuromojiのN-best解とSearchモード: 同義語展開の併用は不可

Kuromojiトークナイザーには、N-Best解を利用するオプションがあります。これは、形態素解析の誤りや曖昧性を考慮するためのもので、従来は1-Best(モデルが最良と考える解析結果)だけが使われるところを、2位以下の候補もスコアによっては考慮する、というものです。イメージとしては、例えば「東京都」というテキストは「東京 / 都」とも「東 / 京都」とも解釈できるでしょうが、それらから唯一の正解を選ぶのではなく、もっともらしそうなもの全てを考慮するというものです。

https://www.elastic.co/guide/en/elasticsearch/plugins/current/analysis-kuromoji-tokenizer.html

しかし、このN-Best解と同義語展開は、私の知る限りでは併用できません。これは、複数のトークンが同じポジションになるためです。

同様に、Searchモード(長い語を短く分割)も同義語展開と併用できません。

参考情報

https://github.com/elastic/elasticsearch/pull/34331#issuecomment-430169172

https://chie8842.hatenablog.com/entry/2019/09/29/124500

https://qiita.com/chopstickexe/items/e114f700e91d92810a9a

検索結果のハイライト: fvhでは同義語展開に未対応

検索結果のヒットした部分をハイライトしたスニペットを取得する機能HighlighterがElasticsaerchにはあります。

このHighlighterには unified, plain, fvh という3つの種類があります。このうち fvh を利用した場合には、同義語展開が考慮されません。同義語展開を含める場合には unified など別の種類を利用する必要があります。

これは、私も厳密にはまだ仕組みを把握できていないのですが、公式ドキュメントにある以下の記述が関係しているようです;

https://www.elastic.co/guide/en/elasticsearch/reference/current/highlighting.html

The fvh highlighter does not support span queries. If you need support for span queries, try an alternative highlighter, such as the unified highlighter.

参考情報

https://discuss.elastic.co/t/fvh/193433


Discussion