Linkers Tech Blog

リンカーズ株式会社の開発者ブログです。

全文検索を日本語向けにチューニングする

はじめに

情報システム部の横山です。弊社のビジネスマッチングサービス・LFBで、全文検索のチューニングを行ったので、今回はその時の知見を共有します。

読者の想定レベル

Elasticsearch(以下ES)の設定方法についてある程度わかる人を想定しています。もし、Elasticsearchの日本語対応のチューニングを行う必要があるけれども、Elasticsearchのことが何も分からないという方は、先にanalyzerの仕組み等を学ぶ必要があります。

  • analyzerはchar filterとtokenizerとtoken filterで構成されることを知っている
  • indexへのmappingの仕方を知っている
  • ESの検索クエリがある程度読める

日本語の検索は難しい

日本語はとても検索が難しい言語です。「東京都内の企業でプログラマを募集しています」という文章があるとします。この文章を品詞で分解すると、概ね以下のようになるはずです。

東京/都内/の/企業/で/プログラマ/を/募集/し/て/い/ます

この文章は「東京」や「プログラマ」という単語が含まれていますが、仮に品詞で分割しない前提だと「京都」「ログ」「ラマ」などの文字も文章中に含まれています。検索時に、もし品詞に関連した何かしらの処理を行っていなかったら、どうなるでしょうか。「京都」で検索したときには京都に関連したものがヒットして欲しいのに、東京都に関連するものと優先度を区別することができない。「ラマ」で検索したいのに「プログラマ」と区別することができない……というのが想像できるでしょうか。「ラマ」と「プログラマ」を見分けるために、検索機能には、日本語の文章を品詞で分解するための形態素解析の処理がどこかに必要であるというのはこれでご理解頂けたと思います。

しかし、正しく品詞で分割できれば良いのですが、形態素解析は未知の単語に弱く、また、入力される文章の文法が常に正しいものとも限らないのがつらいところです。

ESで日本語の形態素解析を行う時に使うプラグインでよく知られたものに analysis-kuromoji があります。 そのtokenizerに「新開地銀だら」という文字列を与えてみましょう。「新開地銀だら」は架空の企業名ですが、神戸の新開地にある魚屋さんということにします。

GET /_analyze
{
  "tokenizer": "kuromoji_tokenizer",
  "text": "新開地銀だら"
}

すると、実際に返ってくるのはJSONですが、以下のように分割された結果が返ってきます。

新開/地銀/だら

もし、「新開地銀だら」が新開地にある魚屋であるという前知識があったのならば、新開地/銀だら、で分割して欲しいと思うことでしょう。もちろん、これは架空の企業名なので Kuromoji が「新開地銀だら」を知っているわけがありません。知らないものはどうしようもないので、結果として「地銀」で区切ってしまいます。誤って分割したトークンをインデックスとして保存すると、そこに対して「新開地」や「銀だら」を探しに行っても、検索はヒットしません。「新開」や「地銀」は見つかるけれど、「新開地」なんてインデックスは作っていないからです。

形態素解析が常に正しく機能しない前提でも、形態素解析のメリットは得たい。そして、同時に検索漏れも無くしたい。 日本語の検索はこのような複雑な問題に取り組む必要があるため、ある程度複雑な設定や実装が必要になります。

基本的な設計

インデックスやクエリの設計方法によって、検索結果には明確な違いが現れます。ここでは、形態素解析を利用して「京都」で検索した時に「京都」の方が「東京都」よりも優先して表示でき、同時に「新開地銀だら」に対して「銀だら」で検索してもヒットするような漏れの無い検索を「良い設計」とします。逆に検索漏れが発生するものを「悪い設計」として、その一例を紹介します。

悪い設計

形態素解析のtokenizer + ngramのtoken filterで済ませてインデックスを分けずに済ませる方法を紹介している記事を目にしたことがあります。 この方法がどういう方法かというと、例えば「リンカーズソーシングは素晴らしいサービスです」という文章があったとして、そこにまず形態素解析のtokenizerをかけます。すると以下のように分解されます。(ちなみにリンカーズソーシングというのは弊社のサービスで、形態素解析時の辞書にはない単語です)

リンカーズソーシング/は/素晴らしい/サービス/です

このままだと、「リンカーズ」でも「ソーシング」でも検索がヒットしません。「リンカーズソーシング」という1単語で認識されているためです。弊社「リンカーズ」の「ソーシング」なんだな、という判断は辞書にないためできません。 そのため、この分解された結果の単語に対してngramのtoken filterをかけてみます。そして以下のようにngramでバラバラにして保存します。この例だと、min_gram: 1、max_gram: 50などの設定だとします。

リ,リン,リンカ,リンカー,リンカーズ,リンカーズソ,リンカーズソー,リンカーズソーシ,リンカーズソーシン,リンカーズソーシング,
ン,ンカ,ンカー,ンカーズ,ンカーズソ,ンカーズソー,ンカーズソーシ,ンカーズソーシン,ンカーズソーシング,
カ,カー,カーズ,カーズソ,カーズソー,カーズソーシ,カーズソーシン,カーズソーシング,
......ソーシング,
...

すると「リンカーズ」も「ソーシング」もトークンができます。インデックス側ではなく検索クエリ側にはngramのフィルタをかけるとノイズになるので、単に形態素解析だけをかけて検索すると、「リンカーズ」で検索しても「ソーシング」で検索しても検索漏れを起こさないようにすることはできます。

それならば問題ないのではないか、と思うかもしれません。しかし一方で、プログラマという単語に対してngramのtoken filterをかけてしまうので「プログラマ」と「ラマ」の区別がまた付かなくなってしまったり、そもそも最初から形態素解析が全然うまくいっていない「新開地銀だら」の例だと検索漏れが起きてしまうので、この方法はオススメできません。

良い設計

検索漏れが発生せず、形態素解析のメリットも享受できる一番基本的な良い設計は、Elasticsearchの公式ブログに書かれている『Elasticsearchで日本語の全文検索の機能を実装する』を見て、内容を理解するのが最も良いと筆者は判断しました。もし日本語の検索を行う必要があった場合、このブログ記事は必ず読むべきです。

この公式ブログの記事では、形態素解析とngramのインデックスを別々に作り、それらに対して検索クエリ側でも両方に検索をかけるアプローチを取っています。

設計の説明を完全に別のブログ記事に委譲してしまいましたが、この記事の残りでは、具体的に弊社はどのように実装したのかなどを後述していきます。

analyzerの設定例

今回の例では同義語辞書を使わないため、インデックス時も検索時も同じanalyzerを使用するものとします。定義は以下の通りです。 今回はKuromojiを使用していますが、パフォーマンスなどの状況が許すのであればsudachiを使う方が良さそうです。

"analysis" : {
  "tokenizer" : {
    "my_ngram_tokenizer" : {
      "token_chars" : [ "letter", "digit" ],
      "min_gram" : "1",
      "type" : "ngram",
      "max_gram" : "2"
    }
  },
  "analyzer" : {
    "my_kuromoji_analyzer" : {
      "filter" : [ "kuromoji_baseform", "kuromoji_part_of_speech", "ja_stop", "kuromoji_stemmer" ],
      "char_filter" : [ "icu_normalizer" ],
      "type" : "custom",
      "tokenizer" : "kuromoji_tokenizer"
    },
    "my_ngram_analyzer" : {
      "type" : "custom",
      "char_filter" : [ "icu_normalizer" ],
      "tokenizer" : "my_ngram_tokenizer"
    },
  }
}

Kuromoji側もngram側もicu_normalizerで文字をノーマライズさせています。また、ngram側ではmin_gram: 1にしていますが、これは1文字での検索が行えるようにするためにこうしています。

mappingの設定例

インデックスさせたい1つ1つの項目(titleやdescription等)に対して個別にmappingすることもできますが、それだと定義するのが大変なので、dynamic_templatesを使ってindexのマッピングを楽に行うことができます。全項目名のうち("match" : "*"の部分)、stringの値を持つフィールドに対して("match_mapping_type" : "string"の部分)analyzedとngramと名付けたインデックスをそれぞれ作成するようにしています。"term_vector" : "with_positions_offsets"は、LFBのUIでハイライトを使用しているため、そのパフォーマンスが落ちないように付けています。また、"type" : "keyword"は、Term-level queriesを使って検索を行う箇所があるため作成しています。もし使わないのであれば無くても良いでしょう。

以下のdynamic_templatesが適用された時に作られるのは、analyzerを通さずに作る"type" : "keyword"のデフォルトのフィールド、my_kuromoji_analyzerを通して作られるanalyzedのフィールド、my_ngram_analyzerを通して作られるngramのフィールドの3つです。

"mappings" : {
  "dynamic_templates" : [
    {
      "string_template" : {
        "match" : "*",
        "match_mapping_type" : "string",
        "mapping" : {
          "fields" : {
            "analyzed" : {
              "analyzer" : "my_kuromoji_analyzer",
              "term_vector" : "with_positions_offsets",
              "type" : "text"
            },
            "ngram" : {
              "analyzer" : "my_ngram_analyzer",
              "term_vector" : "with_positions_offsets",
              "type" : "text"
            }
          },
          "ignore_above" : 30000,
          "type" : "keyword"
        }
      }
    }
  ],

このテンプレートで、例えば案件(demands)のタイトル(title)demands.titleという項目に適用させるとすると、以下のようなmappingのフィールドが作成されることになります。

"properties" : {
  "demands" : {
    "properties" : {
      "title" : {
        "type" : "keyword",
        "ignore_above" : 30000,
        "fields" : {
        "analyzed" : {
          "type" : "text",
          "term_vector" : "with_positions_offsets",
          "analyzer" : "my_kuromoji_analyzer"
        },
        "ngram" : {
          "type" : "text",
          "term_vector" : "with_positions_offsets",
          "analyzer" : "my_ngram_analyzer"
          }
        }
      },

そして、実際の検索では、demands.titleはkeywordなのでTerm-level queriesを使って完全一致検索を行うことができます。analyzerを使ってトークンが作られているdemands.title.analyzeddemands.title.ngramはFull text queriesを使って検索を行うことができます。

検索

要件

「木材 千葉」という検索文字列が来た時に、「木材」と「千葉」が両方含まれる案件がヒットする、というのをゴールにしましょう。それぞれの単語は検索対象のカラムのどこかに入っていれば良いものとします。

例えば、以下のような案件がヒットします。ポイントは、「木材」と「千葉」が別々のカラムに入っていることがあるところです。

案件名: お好みの大きさに木材をカットします
案件説明: 千葉県長生郡に本社を置く製材所で...

クエリ

以下のクエリだと要件通りの検索ができます。Kuromojiで解析した結果でヒットした時は、ngramでヒットした時の5倍スコア(^5の部分)が出るようにしています。

「木材 千葉」 のように空欄が入って検索された場合、まずその空欄を、RubyやJavaのようなバックエンドで利用している言語の処理で分割してからクエリを組み立ててES側に送る必要があります。

まず「木材」でKuromojiとngramの両方に検索をかけて、そして「千葉」でもKuromojiとngramで検索をかけています。Kuromojiでの形態素解析がうまくいかなかった時でも、ngram側で一致すれば結果が出てくるようになっています。一方で、「千葉」のクエリや「木材」のクエリやmustの直下にあるので、どちらともヒットする必要があります。

{
  "query": {
    "bool": {
      "must": [
        {
          "dis_max": {
            "queries": [
              {
                "multi_match": {
                  "query": "木材",
                  "fields": [
                    "demands.title.analyzed^5",
                    "demands.description.analyzed^5"
                  ],
                  "type": "phrase",
                  "analyzer": "my_kuromoji_analyzer"
                }
              },
              {
                "multi_match": {
                  "query": "木材",
                  "fields": [
                    "demands.title.ngram^1",
                    "demands.description.ngram^1"
                  ],
                  "type": "phrase",
                  "analyzer": "my_ngram_analyzer"
                }
              }
            ]
          }
        },
        {
          "dis_max": {
            "queries": [
              {
                "multi_match": {
                  "query": "千葉",
                  "fields": [
                    "demands.title.analyzed^5",
                    "demands.description.analyzed^5"
                  ],
                  "type": "phrase",
                  "analyzer": "my_kuromoji_analyzer"
                }
              },
              {
                "multi_match": {
                  "query": "千葉",
                  "fields": [
                    "demands.title.ngram^1",
                    "demands.description.ngram^1"
                  ],
                  "type": "phrase",
                  "analyzer": "my_ngram_analyzer"
                }
              }
            ]
          }
        }
      ]
    }
  }
}

"type": "phrase"について

"type": "phrase"をつけるのはとても大切です。phraseは熟語という意味です。例えば英語だと、"be able to"は熟語で、beとableとtoの3単語から成り立っています。英語は半角スペースで単語が区切られているため、日本語のような形態素解析処理をしなくても簡単に単語が分けられます。そのため、半角スペースで分割するようなtokenizerの処理で単語ごとにトークンにするであろうことが想像できます。phraseを使うと、その名の通り"be able to"というような熟語、単語の連なりをヒットさせるようにできるのですが、これはつまりトークンの順番を見てヒットさせているということです。

日本語の形態素解析されたトークンで同様に考えてみましょう。Kuromojiで解析した結果、例えば「精密部品」は「精密」と「部品」に分割されます。これにphraseをつけて検索すると、それぞれの単語「精密」「部品」単体で検索するのではなく、「精密部品」というまとまりとして検索を行うということになります。

今度は、日本語が2gramで分割されたトークンで考えて見ましょう。解析された結果、「精密部品」は「精密」「密部」「部品」で分割されます。そのまま検索すると「精密」「密部」「部品」でそれぞれ検索をかけてしまって、例えば「密部」が含まれる「隠密部隊」もヒットするところ、phraseを使えば「精密部品」だけをヒットさせることができます。

ngramで検索する時はphraseは必須ですし、形態素解析で検索する時もあったほうが便利でしょう。「精密部品」で検索したら「精密部品」だけがヒットして、「精密 部品」とスペースで分けたら、「精密」と「部品」がバラバラにヒットするようにしたほうが利便性が高いはずです。

テスト

実装が終わったら正しく実装できてるか、テストを行う必要があります。いくつかのテスト入力の案を紹介します。 もちろんどんなテストが必要かはその要件次第ですが、一例として今回挙げた仕様に対して有効なテストを紹介します。

  • 「千葉」で検索した時に、「千」と「葉」が別々に出る、「千尋の言葉」等がヒットしない。
    • 観点: 単語が分割されずに検索できるか
  • 「京都」で検索した時に、「東京都」よりも「京都」の方が優先してヒットする
    • 観点: 形態素解析結果が活かせているか
    • 類似:「ラマ」で検索した時に、「プログラマ」より「ラマ」の方が優先してヒットする
  • 「リンカーズ」もしくは「ソーシング」で検索した時に、「リンカーズソーシング」がヒットする
    • 観点: 未知の単語の一部分しか含まれない入力で検索漏れが発生しないか
  • 「新開地」もしくは「銀だら」で検索した時に、「新開地銀だら」がヒットする
    • 観点: 形態素解析がうまく効かない状態で、検索漏れが発生しないか
  • 「精密部品」で「精密に作られた部品」はヒットせず、「精密部品」のみヒットする
    • 観点: 複数単語で構成される言葉を正しく検索できるか
  • 「森」で検索した時に、「青森県」や「森脇アパート」がヒットする
    • 観点: 1文字で検索できるか
  • 「森 アパート」で検索した時に、「森脇アパート」がヒットして「青森県」「横山アパート」等がヒットしない
    • 観点: 1文字の検索 + 複数文字の検索が正しく機能しているか
  • 「千葉 木材」で「千葉」と「木材」が別々の検索対象カラムに含まれているものがヒットする
    • 観点: 複数のカラムに渡って単語が分散している時、正しく検索できるか

まとめ

日本語の全文検索が便利に機能するようにするには、それなりに複雑なチューニングが必要です。その複雑さは、日本語の性質に由来するものです。 日本語の文章を形態素解析とngramの両方にインデックス化し、どちらに対しても検索を行うアプローチがあり、その一例をこの記事では紹介しました。検索漏れを発生させず、単語の意味を汲み取って結果に反映させたいケースで、この方法は有用です。

この記事ではKuromojiでの実装方法について記載しましたが、Sudachiを使う選択肢も勿論あるので、その場合はanalyzerをSudachiに合ったやり方に書き換えるなどしてチューニングを行ってください。

謝辞

「新開地銀だら」という、完全に架空の企業名であり形態素解析が絶妙にうまくいかない例は、情報システム部の大河原さんが考えてくれました。ありがとうございました。 架空の企業名なのでKuromojiなどのユーザー辞書には決して登録しないでください。