Elasticsearch による全文検索システムの開発

f:id:monex_engineer:20190910111405j:plain


こんにちは。マネックス・ラボの戸谷です。

今回は以前の記事 ファンド検索の最適化 でお話した Elasticsearch による全文検索システムの開発についてお話をしたいと思います。

Elasticsearch とは?

JSON ドキュメントを複数登録しておくことで全文検索ができるようになり、検索ワードからスコアリングを行い、該当ドキュメントを取得することができるようになります。

「どの文字列を」「どのように検索するか」は予め Elasticsearch に設定しておくことで調整することができます。

また、AWS ではフルマネージド・サービスとして用意されています。

なお、AWS の CloudSearch は、Elasticsearch がベースになっていて、より簡易に全文検索を実現できますが、シンプルが故に細かい調整ができないため、細かい検索要件がある場合は Elasticsearch をオススメします。

検索アルゴリズムについて

全文検索には大きく分けて、2つのアルゴリズムがあります。

1つは N-Gram。そして、もう一つは形態素解析になります。

N-Gram によるインデックス

N-Gram は N 文字ごとに文字列を分割して検索キーワードとしてインデックスしておく方式です。

例えば、N が 2文字の場合は「bigram」と呼ばれ、対象の文字列が「こんにちは」なら、「こん」「んに」「にち」「ちは」の 4つのインデックスが作成されます。

N-Gram による検索

上記の例で bigram による「こんにちは」をインデックスした場合、検索する際にどのように検索ワードを解析するかで検索結果が変わります。

例えば、検索ワードが「こんにち」の場合、何もしなければヒットしません。

「こんにち」というワードは、「こん」「んに」「にち」「ちは」とイコールではないからです。

これを解決するためには、「こんにち」というワードも分解してインデックスと突き合わせる必要があります。

同じ bigram で分割した場合「こん」「んに」「にち」の 3つになり、インデックスとの合致数が多くなり、検索スコアが上がり、検索結果にヒットするようになります。

形態素解析について

N-Gram は単純な仕組みで文字列を分割していくのに対し、形態素解析は日本語の品詞ごとに分割することができます。

「本日の定例は非常に良かったです」の場合、「本日」「の」「定例」「は」「非常」「に」「良かった」「です」のようになります。

ただし、デメリットもあり、「形態素解析は良い」の場合、「形態素解析」「は」「良い」に分割され、「形態素」で検索した際に、「形態素解析」とイコールではないためヒットしない等の弊害があります。

そのため、N-Gram は広い範囲でヒットし、形態素解析は精度は高いものの、狭い範囲でしかヒットしない、といった違いがあります。

ローカル環境での開発

例えば、本番運用では AWS の Elasticsearch を利用し、ローカルPCでデバッグ開発する場合、以下をインストールする必要があります。

  1. AWS で利用するものと同じバージョンの Elasticsearch をインストールする
  2. 日本語の形態素解析を利用する場合には「kuromoji」「analysis-icu」というElasticsearchプラグインをインストールする

どのようにインデックスするか?

Elasticsearch で全文検索を実現するためには、どのようにインデックスし、それをどのように検索するか? ということを考える必要があります。

私のオススメは N-Gram と形態素解析、および類義語をハイブリッドでインデックスしておく手法になります。

以下にインデックス定義の作成クエリの例を記載します。

$ curl -sS -w '\n' -X PUT 'http://localhost:9200/hoge?pretty' -H 'Content-Type:application/json' -d '{
  "settings": {
    "index": {
      "mapping": {
        "total_fields": {
          "limit": "5000"
        }
      },
      "max_result_window": "100000",
      "analysis": {
        "filter": {
          "katakana_readingform": {
            "type": "kuromoji_readingform",
            "use_romaji": "false"
          },
          "stopwords_filter": {
            "type": "stop",
            "stopwords": [
              "the",
              "and"
            ]
          },
          "synonym_filter": {
            "type": "synonym",
            "synonyms": [
              "nasdaq,ナスダック",
              "jasdaq,ジャスダック",
              "金,ゴールド,gold",
              "リート,reit"
            ]
          }
        },
        "analyzer": {
          "trigram_analyzer": {
            "filter": [
              "ja_stop",
              "stopwords_filter",
              "lowercase",
              "kuromoji_number",
              "kuromoji_part_of_speech",
              "kuromoji_stemmer"
            ],
            "char_filter": [
              "icu_normalizer",
              "kuromoji_iteration_mark"
            ],
            "type": "custom",
            "stopwords": "_none_",
            "tokenizer": "trigram_tokenizer"
          },
          "ja_analyzer": {
            "filter": [
              "ja_stop",
              "stopwords_filter",
              "lowercase",
              "kuromoji_number",
              "kuromoji_part_of_speech",
              "kuromoji_stemmer"
            ],
            "char_filter": [
              "icu_normalizer",
              "kuromoji_iteration_mark"
            ],
            "type": "custom",
            "tokenizer": "ja_tokenizer"
          },
          "std_analyzer": {
            "filter": [
              "synonym_filter",
              "ja_stop",
              "stopwords_filter",
              "lowercase",
              "kuromoji_number",
              "kuromoji_part_of_speech",
              "kuromoji_stemmer"
            ],
            "char_filter": [
              "icu_normalizer",
              "kuromoji_iteration_mark"
            ],
            "type": "custom",
            "stopwords": "_none_",
            "tokenizer": "keyword"
          }
        },
        "tokenizer": {
          "ja_tokenizer": {
            "mode": "normal",
            "type": "kuromoji_tokenizer",
            "discard_punctuation": "false"
          },
          "trigram_tokenizer": {
            "token_chars": [
              "letter",
              "digit",
              "symbol",
              "punctuation"
            ],
            "min_gram": "3",
            "type": "ngram",
            "max_gram": "4"
          }
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "fuga": {
        "type": "text",
        "analyzer": "std_analyzer",
        "search_analyzer": "std_analyzer",
        "fields": {
          "keyword": {
            "type": "keyword",
            "ignore_above": 256
          }
        }
      },
      "ja_fuga": {
        "type": "text",
        "analyzer": "ja_analyzer",
        "search_analyzer": "std_analyzer",
        "fields": {
          "keyword": {
            "type": "keyword",
            "ignore_above": 256
          }
        }
      },
      "ng_fuga": {
        "type": "text",
        "analyzer": "trigram_analyzer",
        "search_analyzer": "trigram_analyzer",
        "fields": {
          "keyword": {
            "type": "keyword",
            "ignore_above": 256
          }
        }
      }
    }
  }
}'

どのように検索するか?

インデックス定義を作成し、そこにデータを投入した後に、どのようにキーワード検索をするのか、例をご説明します。

  1. キーワード検索の最大スコアを事前に取得する
  2. 最大スコアから相対的に足切りライン(最小スコア)を決めて、スコアの低いドキュメントはヒットしないようにする

キーワード検索の最大スコアを事前に取得する

以下にクエリの例を記載します。

$ curl -sS -w '\n' -X POST 'http://localhost:9200/hoge/_search?pretty' -H 'Content-Type:application/json' -d '{
  "query": {
    "function_score": {
      "query": {
        "bool":{
          "must":[
            {"multi_match": {
                "query": "aaaa",
                "fields": [
                  "fuga^29",
                  "ja_fuga^70",
                  "ng_fuga^3"
                ]
              }
            },
            {"multi_match": {
                "query": "bbbb",
                "fields": [
                  "fuga^29",
                  "ja_fuga^70",
                  "ng_fuga^3"
                ]
              }
            },
            {"range": {
                "count": {
                  "gte": 100, "lte": 500
                }
              }
            }
          ]
        }
      },
      "boost_mode": "replace",
      "script_score" : {
        "script" : {
          "source": "Math.round(_score * 10) / 10"
        }
      }
    }
  },
  "min_score": 0,
  "from": 0,
  "size": 1,
  "_source": ["_score"],
  "sort": [
    {"_score": "desc"}
  ]
}'

最大スコアを元にキーワード検索を行う

以下にクエリの例を記載します。

$ curl -sS -w '\n' -X POST 'http://localhost:9200/hoge/_search?pretty' -H 'Content-Type:application/json' -d '{
  "query": {
    "function_score": {
      "query": {
        "bool":{
          "must":[
            {"multi_match": {
                "query": "aaaa",
                "fields": [
                  "fuga^29",
                  "ja_fuga^70",
                  "ng_fuga^3"
                ]
              }
            },
            {"multi_match": {
                "query": "bbbb",
                "fields": [
                  "fuga^29",
                  "ja_fuga^70",
                  "ng_fuga^3"
                ]
              }
            },
            {"range": {
                "count": {
                  "gte": 100, "lte": 500
                }
              }
            }
          ]
        }
      },
      "boost_mode": "replace",
      "script_score" : {
        "script" : {
          "params": {
            "maxScore": 74290,
            "minScore": 40.30434
          },
          "source": "def score = _score.doubleValue() / params.maxScore * 100; if (score >= params.minScore) { return Math.round(score / 1) * 1; } else { return 0; }"
        }
      }
    }
  },
  "min_score": 0.0000001,
  "from": 0,
  "size": 30,
  "_source": [
    "fuga"
  ],
  "sort": [
    {"_score": "desc"}
  ]
}'

スコアの解析

開発をしていると、意図しないドキュメントがヒットしたり、検索順位が想定外だったりすることがあります。

その場合は以下ように、クエリの最初に「"explain": true」を付加することで、検索結果のスコアリングを解析することができます。

$ curl -sS -w '\n' -X POST 'http://localhost:9200/hoge/_search?pretty' -H 'Content-Type:application/json' -d '{
  "explain": true,
  "query": {
    "function_score": {
      "query": {
        "bool":{
          "must":[
            {"multi_match": {
                "query": "aaaa",
                "fields": [
                  "fuga^29",
                  "ja_fuga^70",
                  "ng_fuga^3"
                ]
              }
            },
            {"multi_match": {
                "query": "bbbb",
                "fields": [
                  "fuga^29",
                  "ja_fuga^70",
                  "ng_fuga^3"
                ]
              }
            },
            {"range": {
                "count": {
                  "gte": 100, "lte": 500
                }
              }
            }
          ]
        }
      },
      "boost_mode": "replace",
      "script_score" : {
        "script" : {
          "params": {
            "maxScore": 74290,
            "minScore": 40.30434
          },
          "source": "def score = _score.doubleValue() / params.maxScore * 100; if (score >= params.minScore) { return Math.round(score / 1) * 1; } else { return 0; }"
        }
      }
    }
  },
  "min_score": 0.0000001,
  "from": 0,
  "size": 30,
  "_source": [
    "fuga"
  ],
  "sort": [
    {"_score": "desc"}
  ]
}'

インデックスの更新

Elasticsearch にはドキュメントの更新クエリが用意されていますが、夜間バッチ処理などで全件規模で更新する際は以下のような手順が推奨されています。

  1. aliasを作成
  2. 現状のindexをaliasに紐づける
  3. 夜間バッチで新しいindexを作成する
  4. 正常に処理が終了したら新しいindexをaliasに紐づける(aliasの向き変え)
  5. 古いindexを削除する

Elasticseach を操作するクエリ

その他、各 Elasticseach を操作するクエリを以下に参考情報として記載します。


インデックス定義を削除する

$ curl -sS -w '\n' -X DELETE 'http://localhost:9200/hoge?pretty' -H 'Content-Type:application/json' -d ''


現在のインデックス定義を確認する

$ curl -sS -w '\n' -X GET 'http://localhost:9200/hoge/_settings?pretty' -H 'Content-Type:application/json' -d ''


現在のインデックス定義のマッピング設定を確認する

$ curl -sS -w '\n' -X GET 'http://localhost:9200/hoge/_mapping?pretty' -H 'Content-Type:application/json' -d ''


全件検索してみる(デフォルトで 10件しか返さない)

$ curl -sS -w '\n' -X POST 'http://localhost:9200/hoge/_search?pretty' -H 'Content-Type:application/json' -d '{
  "query": {
    "match_all": {}
  }
}'


キーワードを解析してみる

$ curl -sS -w '\n' -X POST 'http://localhost:9200/hoge/_analyze?pretty' -H 'Content-Type:application/json' -d '{
  "analyzer": "trigram_analyzer",
  "text": "こんにちは"
}'


最後に

是非、この機会に投資信託の検索機能をご利用ください♪

fund.monex.co.jp


戸谷 洋紀マネックス・ラボ マネージャー