Carpe Diem

備忘録

Elasticsearchの前方一致について考える

概要

以前Prefix Query の注意 - Carpe Diemで述べたように、PrefixQueryはsearch queryがnot_analyzedになるので意図しない結果になることがあります。
一方で前方一致は検索の利便性を向上させる上でメリットが大きいので、入れておきたい要素でもあります。

今回は前方一致を考える上で何が良いかを調べてみました。

環境

  • Elasticsearch 5.2.1

Prefix Query

メリット

  • 文字通り前方一致のクエリであることが分かる

デメリット

  • search analyzerは指定できないので、index時にnormalizeで小文字などにしてしまうと、大文字による検索でヒットしない
  • 内部的にはregexpによる一致なので短いと重い

特に後者は短い文字でも表示する場合だと非常に無駄が多く、

f:id:quoll00:20170227215716p:plain

ref:Elasticsearch Queries, or Term Queries are Really Fast! | Elastic

この表を見て分かるように4文字→5文字に変わるだけで倍の速度になりますし、3文字以下だと非常に遅いことがわかります。

Regexp Query

次にぱっと浮かぶのはRegex Queryでしょうか。

メリット

  • 正規表現で前方一致を表現できる(abc.*など)

デメリット

  • Prefix Queryで説明したように非常に重いです。

Edge ngram

他に浮かぶのはEdge ngramではないでしょうか。

メリット

  • 必ず前方からngramしていくので前方一致として使える

デメリット

  • ngramなので検索結果にノイズが入りやすい

Edge ngramのノイズ

どういったノイズが出るかですが、例えばquick brownで登録すると

q
qu
qui
quic
quick
b
br
bro
brow
brown

と登録されます。この時にbabyなどで検索すると、

b
ba
bab
baby

と同じように分解されるのですが、この時にbが一致してしまうため、スコアは低いですが結果に混ざってしまいます。

検証

実際にElasticsearchを使って検証してみます。

analyzer

まずはanalyzerを登録します。普通のedge_ngramです。

$ curl -XPUT 'localhost:9200/my_index' -d '
{
    "settings": {
        "analysis": {
            "filter": {
                "autocomplete_filter": {
                    "type": "edge_ngram",
                    "min_gram": 1,
                    "max_gram": 20
                }
            },
            "analyzer": {
                "autocomplete": {
                    "type": "custom",
                    "tokenizer": "standard",
                    "filter": [
                        "lowercase",
                        "autocomplete_filter"
                    ]
                }
            }
        }
    }
}
'

mapping

次に検索に使うフィールドにanalyzerを適用します。

$ curl -XPUT 'localhost:9200/my_index/_mapping/my_type' -d '
{
    "my_type": {
        "properties": {
            "name": {
                "type": "string",
                "analyzer": "autocomplete"
            }
        }
    }
}
'

データ投入

データを投入します。

$ curl -XPOST 'localhost:9200/my_index/my_type/_bulk' -d '
{ "index": { "_id": 1 }}
{ "name": "Brown foxes" }
{ "index": { "_id": 2 }}
{ "name": "Yellow furballs" }
'

検索クエリ

$ curl 'localhost:9200/my_index/my_type/_search?pretty' -d '
{
    "query": {
        "match": {
            "name": "brown fo"
        }
    }
}
'

結果

{
  "took" : 25,
  "timed_out" : false,
  "_shards" : {
    "total" : 5,
    "successful" : 5,
    "failed" : 0
  },
  "hits" : {
    "total" : 2,
    "max_score" : 1.072439,
    "hits" : [
      {
        "_index" : "my_index",
        "_type" : "my_type",
        "_id" : "1",
        "_score" : 1.072439,
        "_source" : {
          "name" : "Brown foxes"
        }
      },
      {
        "_index" : "my_index",
        "_type" : "my_type",
        "_id" : "2",
        "_score" : 0.43214047,
        "_source" : {
          "name" : "Yellow furballs"
        }
      }
    ]
  }
}

Yellow furballsの方も混ざっています。これはfoの方のfがあるせいです。

確認のためクエリのexplain

クエリをexplainしてみると

$ curl 'localhost:9200/my_index/my_type/_validate/query?explain' -d '
{
    "query": {
        "match": {
            "name": "brown fo"
        }
    }
}
'

以下のようにfがあることが分かります。

(name:b name:br name:bro name:brow name:brown) (name:f name:fo)

確認のためスコアのexplain

スコアのexplainをしてみると

$  curl 'localhost:9200/my_index/my_type/_search?pretty' -d '
{
    "explain": true,
    "query": {
        "match": {
            "name": "brown fo"
        }
    }
}
'

結果

{
      "_shard" : 0,
      "_node" : "-IxQc6BlRHKC2ZOQ7gAFYw",
      "_index" : "my_index",
      "_type" : "my_type",
      "_id" : "2",
      "_score" : 0.043822706,
      "_source" : {
        "name" : "Yellow furballs"
      },
      "_explanation" : {
        "value" : 0.043822702,
        "description" : "sum of:",
        "details" : [ {
          "value" : 0.043822702,
          "description" : "product of:",
          "details" : [ {
            "value" : 0.087645404,
            "description" : "sum of:",
            "details" : [ {
              "value" : 0.087645404,
              "description" : "sum of:",
              "details" : [ {
                "value" : 0.087645404,
                "description" : "weight(name:f in 1) [PerFieldSimilarity], result of:",

と、fによるスコアが出ています。

解決方法

ではこの問題をどう解決するかというと、searchのときのanalyzerにはedge ngramを使わなければいいのです。

検索クエリ

analyzerをstandardにしてみます。

$ curl 'localhost:9200/my_index/my_type/_search?pretty' -d '
{
    "query": {
        "match": {
            "name":  {
                "query":    "brown fo",
                "analyzer": "standard" 
            }
        }
    }
}
'

結果

{
  "took" : 10,
  "timed_out" : false,
  "_shards" : {
    "total" : 5,
    "successful" : 5,
    "failed" : 0
  },
  "hits" : {
    "total" : 1,
    "max_score" : 0.8271048,
    "hits" : [
      {
        "_index" : "my_index",
        "_type" : "my_type",
        "_id" : "1",
        "_score" : 0.8271048,
        "_source" : {
          "name" : "Brown foxes"
        }
      }
    ]
  }
}

Brown foxesだけ反応するようになりました。

まとめ

前方一致の場合はIndex AnalyzerのみEdge ngramにし、検索時は通常のanalyzerにすることが最も効率よくできそうです。

ソース