“違い”を生む“同じ”:Elasticsearchのパワーを増大させる“同義語”
同義語が検索エンジニアの業務に不可欠で、最重要のテクニックの1つであることは間違いありません。初心者はときにその重要性を低く見積もりがちですが、実際に同義語なしに動作できる検索システムは、ほぼ存在しないと言えるでしょう。一方熟練エンジニアでも、同義語を活用することの複雑さや、機微を軽視してしまうことがあります。同義語フィルターは分析プロセスの一環で、入力テキストを検索可能な用語に変換します。使いはじめるのは比較的簡単ですが、使い方は極めて多様であり、現実のシナリオにうまく適用させるには、同義語というコンセプトをあらかじめ深く理解しておく必要があります。
Elasticsearchでの分析に、最近いくつかの機能向上が導入されました。中でも特に注目すべきなのが、検索時アナライザーのリロード機能です。これは、検索用の同義語を変更してからリロードすることができる機能です。本ブログ記事ではこの新しいAPIをご紹介するとともに、同義語の使用に関するよくあるご質問と答え、および、いくつかの注意点をお伝えしてまいります。
同義語を使う理由
同義語の便利さやフレキシビリティーを理解するために、まずは現在の検索エンジンの内部がどのように動作しているか、簡単に確認しておきましょう。検索エンジン内では、ドキュメントやクエリが分析され、最小の単位に分割されます。この単位はしばしば「トークン」と呼ばれますが、その本質は、抽象的なシンボルです。検索は、単純な文字列の類似性を使ってマッチングプロセスを実行します。ちょっとしたスペルミスや単数形/複数形の違いからクエリがドキュメントの単語にマッチしない(例:ドキュメントにはhouseとあるが、検索入力がhousesなのでヒットしない)ことがあるのもそのためです。最も一般的な問題の中には、ステミング(語幹処理)やファジークエリで解決できるものもあります。しかしこれらの技術では、関連性のある概念やアイデア同士の、あるいはドキュメントやクエリごとに微妙に異なって使われるボキャブラリーのギャップを埋めることはできません。
そこで登場するのが同義語です。ちなみにsynonyms(同義語)の語源となったギリシャ語の接頭辞は σύν(=syn、“together”/「一緒」)と ὄνομα (=ónoma、“name”/「名前」)です。語源からもわかるように、同義語とは、同一の言語/ドメインの中でまったく同じ、あるいはほぼ同じ意味をもつ別の語のことです。実際には、一般的な同義語(例:“だるい”と“眠い”)から、略語(“kg”と“キログラム”)、eコマースにおける入力のバリエーション(“iPhone”と“アイフォン”と“アイフォーン”)、さらに、地域による違い(英国の“lift”と米国の“elevator”)、専門家と一般の人の用語の違い(“げっ歯目”と“リス”)、単に同じ概念を別の表現で示すケース(“宇宙”と“コスモス”)まで、幅広いパターンがあります。適切な同義語のルールが提供されることで、検索エンジニアはそのドメインでどの語が似た意味を持ち、類似のものとして扱うべきなのかについて情報を提供できます。
検索エンジンが、ドキュメントとクエリでどの語をマッチさせるべきか把握していることは重要です。一見異なる単語同士であっても、です。同義語は高度にドメイン固有の情報です。したがって、ユーザー側で適切なルールを提供する必要があります。カスタムアナライザーで使える同義語フィルターは、格納のためにインデックスするとき、あるいはクエリするときの、いずれのタイミングでも、ユーザー定義のルールに基づいてトークンを入れ替えたり、トークンを追加したりすることができます。たとえば、ある単語の複数の派生形をインデックス済みドキュメントに入れることも、クエリの単語を増やしてより多くのドキュメントにマッチさせることもできます。この2つのアプローチの長所と短所については、後ほど詳しくご説明します。
同義語の使用に注意が必要となるケース
同義語フィルターは非常にフレキシブルなツールですが、状況によっては過度な使用につながりかねません。たとえば、総当たり式に置換するステミングとして使われたり、膨大な同義語ファイルに動詞や名詞などの文法的な派生形を含める使い方です。アプローチとして可能ではありますが、本来使うべきステミングや見出語化を使用する場合に比べると、一般的にはパフォーマンスが低下し、メンテナンスも難しくなります。スペルエラー(誤字)の修正についても同じことです。たとえばeコマース向けの設定などで、よくある誤字の数(パターン)がいくつかしかないという場合は、同義語を使用して誤字を修正する対応がしばしば理にかなっています。問題となるのは、一般的な誤字です。一般的な誤字には、ファジーマッチや、文字ngram技術を使う方がよりサステナブルなアプローチです。さらに、分析チェインで同義語拡張の代替手法を検討することも必要です。制限の多い分析プロセスでは、Ingestパイプラインのドキュメントや、他のクライアントサイドプロセスを拡張する方が、同義語を使うよりもずっとフレキシブルで、管理しやすくなることがあります。たとえば、事前処理パイプラインで、または投入時に、一般的な固有表現抽出(NER)フレームワークを使い、固有の識別子でエンコードすれば、ドキュメントにある固有表現を検知することができます。ユーザーからのクエリをElasticsearchに送る前にも同じプロセスを適用すれば同じ効果があり、制御性は高くなります。
また、一般的な用語に属する動物の特定の種をグループ化するとか、ドメインの分類学のサポートを構築するなど、他の“同一性”の領域に同義語を使いたくなってしまうこともあります。これは非常に面白い部分で、大いに追求の余地があります。しかし、同義語が常に最適な選択肢となるわけではなく、不注意な使い方をすると、システムに予期せぬ挙動を生じさせることもあることを覚えておいてください。
インデックス時vs検索時
同義語はアナライザーで使われます。アナライザーはインデックス時、または検索時に使えます。Elasticsearchの同義語フィルターの使用に関して最もよくある質問の1つが、「同義語フィルターは、インデックス時に使うべき?それとも検索時?両方で使うべき?」というものです。 はじめに、インデックス時に同義語フィルターを適用する方法について検討してみましょう。この場合、インデックスされたドキュメント内の用語は、永遠に置換または拡張され、結果は検索インデックスに存続しています。
インデックス時の同義語フィルターの使用には、複数のデメリットがあります。
- すべての同義語をインデックスする必要があるため、インデックスサイズが大きくなる可能性がある。
- 用語の統計に依存する検索スコアリングにおいて、同義語もカウントされることで悪影響を受ける可能性があり、頻度の低い単語で統計に歪みがでる。
- 再インデックスしない限り、既存のドキュメントの同義語ルールを変更できない。
特に最後の2つは、大きなデメリットです。インデックス時に同義語フィルターを使用する場合、唯一メリットとなる可能性があるのはパフォーマンスです。拡張プロセスのコストを事前に負担しておくことにより、クエリ時に毎回同義語フィルターを実施する必要がなく、より多くの用語にマッチできる可能性があります。しかし、こうした点が実際に争点となることはありません。
一方で、検索時のアナライザーで同義語フィルターを使用する場合、上述の多くの問題は生じません。
- インデックスサイズに影響が出ない。
- 用語の統計全体は同じに保たれる。
- 同義語ルールを変更するにあたり、ドキュメントの再インデックスは必要ない。
一般的に、このようなメリットが、「クエリのたびに同義語拡張を実施する必要がある」という唯一のデメリットを上回り、よく多くの用語をマッチさせられる可能性があります。さらに、検索時に同義語拡張を実施する場合、より洗練されたsynonym_graph
トークンフィルターを使用することも可能になります。これは、複数の単語の同義語を正確に処理することができ、検索アナライザーの一部としてのみ使えるよう設計されているフィルターです。
概して、検索時に同義語フィルターを使用することのメリットは、インデックス時に同義語フィルターを使用することでわずかにパフォーマンスが向上することのメリットを上回ります。
しかしこれまでは、検索時に同義語フィルターを使うにあたって、注意すべき点がありました。同義語ルールの変更にドキュメントの再インデックスは必要ありませんが、インデックスを一時的に閉じ、再度開かなくてはなりませんでした。これは、アナライザーがインデックス作成時と、ノードをリスタートするとき、そして閉じたインデックスを再び開くときにインスタンス化されるためです。インデックスにもわかるような形で同義語ルールファイルに変更を加えるには、最初にすべてのノードのファイルを更新し、インデックスを一度閉じて、再び開くことが必要でした。というのが、以前の注意点でした。実はもう、過去のことになっています。
同義語をリロード
Elasticsearch 7.3より、同義語ファイルの変更を参照するためのインデックスの再オープンが不要になりました。必要に応じてアナライザーリソースのリロードをトリガーする、新しいエンドポイントが追加されています。この新しいエンドポイントをコールすると、“アップデート可能”としてマークされたコンポーネントをもつインデックスのすべてのアナライザーがリロードされます。その代わり、一連のコンポーネントは検索時にしか使用することができなくなります。
同義語フィルターを“アップデート可能”としてマークし、リロードAPIをコールすると、各ノードの同義語設定ファイルが、分析プロセスに確認できる形で変更されます。フィルタ定義の一部である同義語ルール(synonyms
パラメーターで指定)のアップデートはできませんが、主にそれらはアドホックなテスト目的に利用されるものでしょう。いずれの場合も、設定ファイルを使用して同義語を設定する手法には、複数のメリットがあります。
- 管理のしやすさ、です。運用中のシステムには、多数の同義語ルールが存在することがあります。同義語ルールは検索関連性に大きく影響するため、設定に欠かせない部分として扱うべきであり、バージョンコントロールとアップデートに際してのテストも必要です。
- 同義語が、他のソースから生じることや、データを実行するアルゴリズムにより作成されることもよくあります。ファイルから読み込むようにすれば、フィルター設定内で定義するプロセスを省くことができます。
- 同じ同義語ファイルを異なるフィルターに使用できます。
- 大規模な同義語ルールセットは、インデックス設定のメタ情報を格納するElasticsearchクラスターステータスでより大きなメモリーを占めます。クラスターサイズを不必要に大きくしないために、大規模な同義語ルールセットは設定ファイルに入れておくことが賢明です。
デモンストレーションを行ってみましょう。次の単一のルールを含むmy_synonyms.txt
ファイルが、Elasticsearchノードのconfig
ディレクトリに入っているという設定で開始します。ファイルには、次のルールのみ含まれていることとします。
universe, cosmos
次に、同義語フィルターでこのファイルを参照するアナライザーを定義します。
PUT /synonym_test { "settings": { "index": { "analysis": { "analyzer": { "synonym_analyzer": { "tokenizer": "whitespace", "filter": ["my_synonyms"] } }, "filter": { "my_synonyms": { "type": "synonym", "synonyms_path": "my_synonyms.txt", "updateable": true } } } } } }
この同義語フィルターがupdateable
(アップデート可能)になっている点に注目してください。ここが重要です。新登場のリロードエンドポイントのコールでは、“アップデート可能”なフィルターしかリロードされません。これには、アップデート可能なフィルターを含むアナライザーが、インデックス時に使用できなくなるという副次的な利点もあります。ひとまず、_analyze
エンドポイントを使った簡単なテストを実行して、同義語が正しく適用されているか確認しましょう。
GET /synonym_test/_analyze { "analyzer": "synonym_analyzer", "text": "cosmos" }
ここで、2つのトークンが返ってくる必要があります。1つ目の“universe”は、期待通りになりました。synonyms.txt
ファイルに2行目を追加して、新しいルールを加えてみましょう。
lift, elevator
以前であれば、この変更を適用するために一度インデックスを閉じて、再び開く必要がありました。現在は、新しいエンドポイントをコールするだけになっています。
POST /synonym_test/_reload_search_analyzers
このリクエストに本文は必要ありません。リクエストを典型的なインデックスワイルドカードパターンを使用して1つ以上のインデックスに限定することができます。レスポンスには、どのアナライザーがリロードされ、どのノードが影響を受けたかという情報が含まれています。
{ [...], "reload_details": [{ "index": "synonym_test", "reloaded_analyzers": ["synonym_analyzer"], "reloaded_node_ids": ["FXbmbgG_SsOrNRssrYcPow"] }] }
いま、上述の_analyze
リクエストを“lift”という語について実行すると、2番目の同義語トークンとして“elevator”が返されるようになりました。
いくつかの注意点もあります。説明した通り、updateable
(アップデート可能)としてマークされているフィルターは検索時に使用されます。したがって、さきほど定義した同義語アナライザーを、あるフィールドに適切な方法で使用する場合、次のようになります。
POST /synonym_test/_mapping { "properties": { "text_field": { "type": "text", "analyzer": "standard", "search_analyzer": "synonym_analyzer" } } }
また、リロードはファイルから読み込まれた同義語に対してしか動作しません。フィルター内の同義語の定義を変更するプロセスはサポートされていない点に注意してください。最後に、実際の手順では、同義語ファイルへのアップデートが、クラスターのすべてのノードに適用されていることを確認する必要があります。一部のノードのアナライザーが別のバージョンのファイルを参照している場合、検索で使用されたノードによって、異なる結果が返される可能性があります。同義語に関連してこの現象が生じた場合は、はじめに各ノードにある同義語ファイルが同じであることを確認してから、リロードを再トリガーしてください。
ここまでの内容を、簡単にまとめましょう。新しい_reload_search_analyzer
エンドポイントを使うと、インデックスを再度開くことなく、クエリ時の同義語をすばやく修正、および変更することができます。たとえば、クエリログを調べて、ユーザーがインデックス済みドキュメントに存在しない、別の語彙で検索しているかどうかを判断し、すぐに同義語の追加を適用する、といった対応が可能です。同義語の追加により、関連性スコアリングに予期せぬ副次的影響が生じることもあります。そのため、本番環境に直接変更を適用する前に、なんらかのテスト(A/Bテストや、ranking evaluation APIなど)を実施する方が賢明です。
分析チェインギャングの一員になる
同義語フィルターに関してもう1つのよくある質問は、「より複雑な分析チェインでの挙動はどうなるのか」というものです。多くのシナリオで、同義語フィルターの前に、lowercase
(小文字)フィルターなどの一般的な文字やトークンのフィルターが使われます。したがって、分析チェインを通過するすべてのトークンは、同義語フィルターが適用されるまえに、小文字化されることになります。そうすると、「同義語ルールに入力する同義語は、すべて小文字にしておかないとマッチしない」ということなのでしょうか?シンプルな事例で試してみましょう。
PUT /test_index { "settings": { "index": { "analysis": { "analyzer": { "synonym_analyzer": { "tokenizer": "whitespace", "filter": ["lowercase", "my_synonyms"] } }, "filter": { "my_synonyms": { "type": "synonym", "synonyms": ["Eins, Uno, One", "Cosmos => Universe"] } } } } } } GET /test_index/_analyze { "analyzer": "synonym_analyzer", "text": "one" }
上の例で、小文字の入力テキストが3つのトークンに拡張されていることを検証できました。小文字化は同義語フィルタールールにも適用されています。上の例の右側で“Cosmos => Universe”に置換するルールは、ご覧の通り小文字の出力で書き直されています。
GET /test_index/_analyze { "analyzer": "synonym_analyzer", "text": "cosmos" }
一般的に、同義語フィルターは、先行する分析チェインで使われるトークナイザーとフィルターで自身の入力テキストを書き直します。しかし、これには例外もあります。スタックされたトークンを出力するいくつかのフィルター(例:common_grams
やphonetic
フィルターなど)は、同義語フィルターに先行することができず、先行して実行しようとするとエラーを起こします。それらを除くword compoundフィルターや同義語フィルターは、別の同義語フィルターよりもチェイン内で先に実行される場合、その同義語フィルターの書き直しではスキップされます。同義語フィルターを連結するには、この2つ目のルールが重要です。次の例で実際に再現してみましょう。
2つ以上の同義語フィルターを連続で投入すると、何が起きるのでしょうか?先行するフィルターの出力が後続のフィルターの入力となり、同義語フィルターの変更がある種推移的なオペレーションになるでしょうか?次の例を試してみます。
PUT /synonym_chaining { "settings": { "index": { "analysis": { "filter": { "first_synonyms": { "type": "synonym", "synonyms": ["a => b", "e => f"] }, "second_synonyms": { "type": "synonym", "synonyms": ["b => c", "d => e"] } }, "analyzer": { "synonym_analyzer": { "filter": [ "first_synonyms", "second_synonyms" ], "tokenizer": "whitespace" } } } } } } GET /synonym_chaining/_analyze { "analyzer": "synonym_analyzer", "text": "a" }
出力トークンは“c”となり、2つのフィルターが連続的に適用されたことを示します。最初のフィルターが“a”を“b”に、2つ目のフィルターがこれを“c”に置換しています。もし入力に“d”を試したら、(最初のルールが適用されず)“e”に置換されますが、代わりに“e”を入れると、最初のフィルターでトークンが“f”に置換され、2番目のフィルターでは何もマッチしないままになります。
先行するトークンフィルターに対する書き直しの例外について説明したことを覚えているでしょうか。上記の例で、もしsecond_synonyms
フィルターが1番目のフィルターのルールをルールセットに適用していたら、自らのd => e
ルールをd => f
に変更していたでしょう(先行フィルターのe => f
ルールが適用されるため)。この挙動は、Elasticsearchの初期のバージョンでしばしば混乱の原因となっていました。現在、後続のフィルターで同義語ルールを処理する場合に、同義語フィルターがスキップされる仕様となっているのはこのためです。バージョン6.6以降では、新しい動作になっています。
バック・トゥ・ザ・フューチャー
本ブログ記事では、「同義語を使ってできること」の一角を簡単にお伝えしつつ、使い方に関してよくある質問への回答をご紹介しました。同義語は、活用すれば検索システムの再現率向上につながるパワフルなツールです。一方で、理解したり、試行しておくべきポイントがいくつかあり、特にシステムの関連性テストとの接続においては重要です。
Elasticsearch 7.3より導入されたreload search-time analyzersAPIを使うと、インデックスを閉じてから再び開く必要がなくなることで、以前よりこの種の試行を手軽に行うことができます。また、インデックスをオフラインにすることなく同義語のルールをアップデートして、検索時に適用させることができます。この機能向上は、大規模なクラスターで同義語をより管理しやすくするため、Elasticが導入を検討している多数の改善のうちの1つです。ご感想やフィードバック、ご質問がございましたら、ぜひディスカッションフォーラムまでお寄せください。分析と検索を一層お楽しみいただけましたら幸いです。