不可視点

search guy at cookpad.com

Elasticsearchチュートリアル

目的

検索用サーバーとして最近注目されているElasticsearchですが、ついに1.0 RC1がリリースされたそうです。 Googleトレンドを見ても、この分野で先行するApache Solrに迫る勢いを感じます。

そういうわけで私もElasticsearchについて興味を持って調べてみましたが情報がちょっと少ないですね…

  • 「調べたけど断片的な情報しかない」
  • 「公式doc英語だし、専門用語が多すぎてわからん」
  • 「え、できること多すぎ。よくわからん。どれが重要?」

と言った感じで、最初ちょっと大変… そこで調べ始める人が、概観をつかむためのチュートリアルをつくろうと思います。 コマンドを全部実行する必要ありません。用語をおさえることで調べものが捗ることがひとつのゴールです。

自分の理解の整理も兼ねています。間違ってる箇所あったら教えて下さい。

part 1:ESを使ってレストラン検索を作る

レストラン検索を作ることで、Elasticsearchの基本的な使い方を紹介します。 今回はlivedoor グルメの研究用データセットを使いレストラン検索をつくります。 このデータセットには、

  • レストラン情報:20万店舗
    • 店名(名称、読み)
    • 住所(郵便番号、住所、緯度経度)
    • 最寄り駅
    • モーニングの有無
    • ランチの有無
    • 写真アップロード数
    • PV etc
  • 口コミ:20万件
    • user_id
    • 総合評価(1-5)
    • 料理評価(1-5)
    • サービス評価(1-5)
    • 雰囲気評価(1-5)
    • コストパフォマンス評価(1-5)
    • 口コミタイトル
    • 口コミ本文
    • 利用目的
    • 投稿日
    • 口コミへの投票ログ etc

が含まれます。すごすぎる。こんな神データセットを提供しているLINE株式会社はヤバい。感謝です。

レストラン検索のゴール

このデータセットを使って以下の検索ができるようにしてみましょう。

  • 「地名 料理ジャンル/店名」と言った形でレストランが探せる
  • メニュー名からレストランが探せる(例えば「ハンバーガー」が美味しいお店)
  • PV順・口コミが多い順・評価が高い順など検索結果をソートできる

これらを実現する方法は様々ですがElasticsearchならけっこう簡単にサクッとできます。

準備

データの準備

では、早速データを準備しましょう。

ESのセットアップ

まずは http://www.elasticsearch.org/downloads/1-0-0-rc1/ からダウンロードしてください。 どんなセットアップ方法でもいいですが、ZIPを選んで解凍して使うことにします。

さらに、どう考えても必須のpluginがあるので以下の手順でインストールします。

~/Downloads/elasticsearch-1.0.0.RC1
$ ls
LICENSE.txt    NOTICE.txt     README.textile bin            config         lib

# headというpluginのインストール

~/Downloads/elasticsearch-1.0.0.RC1 
$ bin/plugin -install mobz/elasticsearch-head

では早速起動してみましょう。

~/Downloads/elasticsearch-1.0.0.RC1 
$ bin/elasticsearch

ブラウザで http://localhost:9200/_plugin/head/ を開いてみてください。 http://mobz.github.io/elasticsearch-head/ で紹介されているような画面が出ればOKです。

livedoor グルメのデータセットのダウンロード

https://github.com/livedoor/datasets からldgourmet.tar.gzをダウンロードして下さい。

データの取り込み

さっそくデータをElasticsearchに登録してみましょう。

Elasticsearchのデータ構造

基本的に以下のような構造になっています。 先ほど起動したのは1nodeのクラスタです。

data model

MySQLで言うと、databaseindextabletypeと対応します。

インデックス・スキーマの定義

外部データをElasticsearch上でどのようなスキーマとして表現するか定義することをmappingと呼びます。

Elasticsearchはデータのスキーマを定義することなくデータを登録できますが、 あとあとそれが問題となってやり直すことがあったりします。 ここでは明示的にスキーマを与えてデータを登録します。

mapping

実際にmappingを行うにはJSONでmappingファイルを作成し、ElasticsearchにPOSTすればOKです。

# ldgourmetという名前のindexをmapping.jsonに書かれた内容に従って作成

curl -XPOST localhost:9200/ldgourmet -d @mapping.json

今回は以下の様なmappingにします。

mapping.json

{
  "settings": {
    "analysis": {
      "analyzer": {
        "ngram_analyzer": {
          "tokenizer": "ngram_tokenizer"
        }
      },
      "tokenizer": {
        "ngram_tokenizer": {
          "type": "nGram",
          "min_gram": "2",
          "max_gram": "3",
          "token_chars": [
            "letter",
            "digit"
          ]
        }
      }
    }
  },
  "mappings": {
    "restaurant": {
      "properties": {
        "restaurant_id": {
          "type": "integer"
        },
        "name": {
          "type": "string",
          "analyzer": "ngram_analyzer"
        },
        "name_alphabet": {
          "type": "string",
          "analyzer": "ngram_analyzer"
        },
        "name_kana": {
          "type": "string",
          "analyzer": "ngram_analyzer"
        },
        "address": {
          "type": "string",
          "analyzer": "ngram_analyzer"
        },
        "description": {
          "type": "string",
          "analyzer": "ngram_analyzer"
        },
        "purpose": {
          "type": "string",
          "analyzer": "ngram_analyzer"
        },
        "category": {
          "type": "string",
          "analyzer": "whitespace"
        },
        "photo_count": {
          "type": "integer"
        },
        "menu_count": {
          "type": "integer"
        },
        "access_count": {
          "type": "integer"
        },
        "closed": {
          "type": "boolean"
        },
        "location": {
          "type": "geo_point",
          "store": "yes"
        }
      }
    },
    "rating": {
      "properties": {
        "rating_id": {
          "type": "integer"
        },
        "total": {
          "type": "integer"
        },
        "food": {
          "type": "integer"
        },
        "service": {
          "type": "integer"
        },
        "atmosphere": {
          "type": "integer"
        },
        "cost_performance": {
          "type": "integer"
        },
        "title": {
          "type": "string",
          "analyzer": "ngram_analyzer"
        },
        "body": {
          "type": "string",
          "analyzer": "ngram_analyzer"
        },
        "purpose": {
          "type": "string",
          "analyzer": "ngram_analyzer"
        }
      }
    }
  }
}

ここで、integerstringなどのフィールドの型が出てきました。

core types で他にどんな型があるのか知ることが出来ます。

そして、analyzerという定義も出てきました。ここがいろいろ出てきて一番しんどいです…

文字列処理(analyzer)の定義

analyzerとは以下の様なものです。

文字列の分割方法を定義するtokenizerと、分割後の文字列の整形処理を定義するfilterによって構成されます。

例えば、tokenizerngramで文字列を分割し、

filterで大文字小文字を小文字に統一してしまうなどといった定義をすることが出来ます。

analyzerはいくつでも定義することが出来き、フィールドごとにどのanalyzerを利用するか決めることが出来ます。

例えば、店名は、「部分文字列でも検索できるようにしたい」という欲求があるので、ngram_analyzerを指定し、カテゴリはタグのようなもので、タグそれ自体を分割する必要はないのでwhitespace analyzerを利用するといった具合です。

上記のmapping.jsonにはこのanalyzer自体の定義や、どのフィールドでどのanalyzerを使うかの指定も含まれます。

今回は、簡単のためcategory以外は全部の文字列をngramで扱うことにします。

analyzerはelasticsearchに標準で登録されているものと、自分で組み合わせて定義するものがあります。

実際にデータを取り込む

データをJSONで表現し、POSTすることで登録していくことが出来ます。 正直ここは迷うことはない上に、実際には何らかの言語のライブラリを使って 呼び出すことになりますので詳細省きます。

クエリの組み立て

さていよいよデータが準備できました! クエリを投げてみましょう。

マッチングの方法

特別な設定をしない限り、登録したフィールド全てを検索可能です。 mappingを前提としつつもElasticsearchは多様な検索を実行することが出来ます。

queryとfilter

Elasticsearchの検索はqueryfilterによって行います。 それぞれ、query約40種類 filter約30種類あります。 フィールドの型やtypeの構成等によって適切なものを使えばかなり柔軟に検索できることがわかると思います。

ちなみに、両者の違いは

queryはスコアリングに影響する」

filterはスコアリングに影響しない、キャッシュされる」

ということを覚えておくと便利です。

全部を紹介するのは難しいので、ここでは1つか紹介するにとどめます。

「地名 料理ジャンル/店名」と言った形でレストランを探す

ここではsimple_query_stringを使ってみましょう。

$ curl -XGET 'http://localhost:9200/ldgourmet/restaurant/_search?pretty=true' -d '
{
  "query" : {
    "simple_query_string" : {
      "query": "目黒 とんき",
      "fields": ["_all"],
      "default_operator": "and"
    }
  }
}
'

Elasticsearchに対してクエリを発行するときは、localhost:9200/index名/type名/_searchに対して、JSONで書いたクエリを発行します。

simple_query_stringクエリは便利クエリの一つで、AND OR ()などの基本的な演算子と、検索対象のフィールドなどを指定できます。 他にもクエリのカッコの数が一致しなかったり、エラーが出そうなときにエラーにするかしないかなどが選べたりします。

より複雑なクエリを発行したい場合は、query_stringクエリを使うとよいでしょう。

fieldsに登録した覚えのないフィールド_allが有ります。 これは、Elasticserchの特別なフィールドの一つです。 このフィールドには自動的に登録されたすべてのフィールドがコピーされています。 なので、住所や店名、ジャンルといったデータから横断的に検索したい場合はこのフィールドが便利です。

他にも任意のフィールドを指定し検索することも出来ます。

$ curl -XGET 'http://localhost:9200/ldgourmet/restaurant/_search?pretty=true' -d '
{
  "query" : {
    "simple_query_string" : {
      "query": "白金台 カフェ ボエム",
      "fields": ["name", "name_kana", "address"],
      "default_operator": "and"
    }
  }
}
'
{
  "hits": {
    "hits": [
      {
        "_source": {
          "type": "restaurant",
          "closed": 0,
          "access_count": 10727,
          "menu_count": 0,
          "photo_count": 0,
          "purpose": "2,5,6,8",
          "_id": "6598",
          "name": "カフェ・ラ・ボエム",
          "name_alphabet": "cafe LA BOHEME",
          "name_kana": "かふぇらぼえむ",
          "category": "バー イタリアン ピザ パスタ カフェ",
          "address": "港区白金台4-19-17",
          "location": [
            139.43,
            35.38
          ],
          "description": "白金台駅から徒歩約5分  (料理・営業時間・カード・禁煙欄を更新しました。 東京グルメ ユーザー自治班 2007/12/9)"
        },
        "_score": 3.6026812,
        "_id": "6598",
        "_type": "restaurant",
        "_index": "ldgourmet"
      }
    ],
    "max_score": 3.6026812,
    "total": 1
  },
  "_shards": {
    "failed": 0,
    "successful": 5,
    "total": 5
  },
  "timed_out": false,
  "took": 5
}

こんなかんじで何十種類もクエリがあるので、面倒なことをほとんどElasticsearchに押し付けられそうですね。便利!

「メニュー名からレストランが探せる」ようにするのはpart 2以降で紹介する機能で実現しようと思いますので今回は割愛します。

検索結果の並び替え

マッチングの方法を理解し、とりあえず検索結果が帰ってくるところまで来ました。 ところで、検索アプリケーションでは1ページ目が重要であると言われています。 登録されたデータの件数が多くなるほど検索結果の並び替えは重要になってきます。

Elasticsearchでは検索結果の並び替えに使える仕組みが幾つか準備されていて、 かなり柔軟に検索結果の提示順序をコントロールできます。

PV順・口コミが多い順・評価が高い順など検索結果をソート

Elasticsearchで並び順をコントロールする方法は大きく2つあります。 ひとつは、クエリとドキュメントの関連性を一切考慮せず、特定のフィールドの値でソートする方法です。

$ curl -XGET 'http://localhost:9200/ldgourmet/restaurant/_search?pretty=true' -d '
{
  "query" : {
    "simple_query_string" : {
      "query": "東京 ラーメン",
      "fields": ["name", "name_kana", "address"],
      "default_operator": "and"
    }
  },
  "sort" : [{ "access_count" : {"order" : "desc", "missing" : "_last"}}]
}
'

このクエリでは、東京のラーメンのお店で一番PVが多かったお店を検索します。 結果には各ドキュメントの値がsortという項目で報告されます。クエリで指定したフィールドの値が表示されます。

{
  "hits": {
    "hits": [
      {
        "sort": [
          35328
        ],
        "_source": {
          "type": "restaurant",
          "closed": 0,
          "access_count": 35328,
          "menu_count": 0,
          "photo_count": 19,
          "purpose": "1,4",
          "_id": "304902",
          "name": "ラーメン二郎",
          "name_alphabet": null,
          "name_kana": "らーめんじろう",
          "category": "ラーメン全般 豚骨ラーメン 醤油ラーメン  ",
          "address": "西東京市谷戸町3-27-24ひばりヶ丘プラザ1F",
          "location": [
            139.32,
            35.44
          ],
          "description": "西武池袋線 ひばりヶ丘駅南口下車 徒歩3分  2006年6月11日(日)営業開始    【註】電話番号非公開です。  (from 東京グルメ 2006/06/11)    店名修正しました(サポート 2006/06/14)  "
        },
        "_score": null,
        "_id": "304902",
        "_type": "restaurant",
        "_index": "ldgourmet"
      },
      {
        "sort": [
          13432
        ],
        "_source": {
          "type": "restaurant",
          "closed": 1,
          "access_count": 13432,
          "menu_count": 0,
          "photo_count": 3,
          "purpose": "4",
          "_id": "15078",
          "name": "東京ラーメン",
          "name_alphabet": null,
          "name_kana": "とうきょうらーめん",
          "category": "醤油ラーメン 塩ラーメン 味噌ラーメン 餃子 ",
          "address": "葛飾区亀有5-49-6",
          "location": [
            139.51,
            35.45
          ],
          "description": "亀有駅から3分。北口を出て駅前ロータリーをまっすぐ行き、交差点で斜め右へ進み、街道に出たら、黄色い看板が目に入る。  古っぽいけど、入口は自動ドア。入って左側にセルフの給水器あり。  カウンターのみ12席。"
        },
        "_score": null,
        "_id": "15078",
        "_type": "restaurant",
        "_index": "ldgourmet"
      },
      ...],
    "max_score": null,
    "total": 46
  },
  "_shards": {
    "failed": 0,
    "successful": 5,
    "total": 5
  },
  "timed_out": false,
  "took": 5
}

より複雑な並び替え

もう一つは、スコアリングに基づく並び替えです。 検索結果の1ページ目をコントロールしようとした場合、 「PVが多くて」「レビューが付いてて」「駅から近くて」「現在地からも近く」「値段も手頃な」お店がいい。 など、実際の検索ユーザーの欲求にそって検索結果を並び替え、1ページ目にユーザーに響く結果を表示しなくてはなりません。

それを単一のフィールドのソートで表現するのはかなり難しいです。 そんなときはスコアリングに基づく並び替えが便利です。 Elasticsearchにおいてはfunction score queryというクエリが便利です。

余りにもできることが多いのでベストプラクティスが何かはちょっとわからないんですが一例をご紹介

ドキュメントのスコアを自分で定義する

$ curl -XGET -o result.json 'http://localhost:9200/ldgourmet/restaurant/_search?pretty=true' -d '
{
  "query" : {
    "function_score" : {
      "query" : {
        "simple_query_string" : {
          "query": "ラーメン",
          "fields": ["name", "name_kana", "address"],
          "default_operator": "and"
        }
      },
      "boost_mode": "replace",
      "script_score" : {
        "script" : "doc[\"access_count\"].value * doc[\"photo_count\"].value"
      }
    }
  }
}
'

このクエリでは、先ほどのsimple_query_stringでマッチさせたドキュメント群をaccess_count * photo_countというスコアを定義し、その値が大きい順にソートして返すようにしています。 scriptはjavascriptやmvel(デフォルト)が使えます。また、掛け算だけでなく多様な関数が準備されていて、 例えば、doc["location"].distance(lat, lon)とすると、任意の地点からの距離を自分のスコアの中で扱うことが出来ます。

望ましい条件を列挙し、どれかにヒットすれば加点

 curl -o result.json 'http://localhost:9200/ldgourmet/restaurant/_search?pretty=true' -d '
{
  "query" : {
    "function_score" : {
      "query" : {
        "simple_query_string" : {
          "query": "ハンバーガー",
          "fields": ["name", "name_kana", "address"],
          "default_operator": "and"
        }
      },
      "score_mode": "sum",
      "boost_mode": "replace",
      "functions" : [
        {
          "filter" : { "range" : { "access_count" : { "from" : 500 }}},
          "boost_factor" : 10
        },
        {
          "filter" : { "range" : { "photo_count" : { "from" : 3 }}},
          "boost_factor" : 10
        },
        {
          "filter" : { "geo_distance" : { "distance" : "2km", "location" : [139.43 , 35.38] }},
          "boost_factor" : 100
        }
      ]
    }
  }
}
'

この例では、白金台から2kmの距離にあればスコアが100点加算、access countが500以上か、photo_countが3以上であればそれぞれスコアが10点加算になります。 これによって、1ページ目は至近の店があればまずそれを。その次に有名店を表示できます。 すべての条件を満たす店舗があればかなり強い加点がされますが、それぞれの条件にマッチしないレストランもきちんと表示されます。

{
  "hits": {
    "hits": [
      {
        "_source": {
          "type": "restaurant",
          "closed": 1,
          "access_count": 687,
          "menu_count": 0,
          "photo_count": 1,
          "purpose": null,
          "_id": "10039",
          "name": "ザ・ハンバーガーイン",
          "name_alphabet": null,
          "name_kana": "ざはんばーがーいん",
          "category": "カフェ ハンバーガー   ",
          "address": "港区六本木3-15-22",
          "location": [
            139.44,
            35.39
          ],
          "description": "2005年10月25日で閉店されたそうです。  (from 東京グルメ 2006/11/09)"
        },
        "_score": 2232.2642,
        "_id": "10039",
        "_type": "restaurant",
        "_index": "ldgourmet"
      },
      {
        "_source": {
          "type": "restaurant",
          "closed": 0,
          "access_count": 1424,
          "menu_count": 0,
          "photo_count": 0,
          "purpose": "1,2,4",
          "_id": "328829",
          "name": "ザ・ハンバーガー・イン 一番東京ハンバーガー",
          "name_alphabet": "the Hamburger Inn",
          "name_kana": "ざはんばーがーいんいちばんとうきょうはんばーがー",
          "category": "ハンバーガー    ",
          "address": "港区西麻布2-25-23バルビゾン27 3F",
          "location": [
            139.43,
            35.39
          ],
          "description": null
        },
        "_score": 242.6197,
        "_id": "328829",
        "_type": "restaurant",
        "_index": "ldgourmet"
      },
      {
        "_source": {
          "type": "restaurant",
          "closed": 0,
          "access_count": 2846,
          "menu_count": 0,
          "photo_count": 0,
          "purpose": "4",
          "_id": "317834",
          "name": "ハンバーガーイン",
          "name_alphabet": null,
          "name_kana": "はんばーがーいん",
          "category": "ハンバーガー    ",
          "address": "港区西麻布1-11-6メンテルス六本木1F",
          "location": [
            139.43,
            35.39
          ],
          "description": null
        },
        "_score": 228.74405,
        "_id": "317834",
        "_type": "restaurant",
        "_index": "ldgourmet"
      },
      {
        "_source": {
          "type": "restaurant",
          "closed": 0,
          "access_count": 1642,
          "menu_count": 0,
          "photo_count": 0,
          "purpose": "1,2,3,4",
          "_id": "325617",
          "name": "一番東京ハンバーガー",
          "name_alphabet": null,
          "name_kana": "いちばんとうきょうはんばーがー",
          "category": "ハンバーガー サンドイッチ パン  ",
          "address": "渋谷区神宮前5-29-10",
          "location": [
            139.42,
            35.39
          ],
          "description": "店舗一時休止中"
        },
        "_score": 200.72649,
        "_id": "325617",
        "_type": "restaurant",
        "_index": "ldgourmet"
      },
      {
        "_source": {
          "type": "restaurant",
          "closed": 0,
          "access_count": 1189,
          "menu_count": 0,
          "photo_count": 0,
          "purpose": "4",
          "_id": "357191",
          "name": "ハンバーガー マクドナルド",
          "name_alphabet": null,
          "name_kana": "はんばーがーまくどなるどうぃんぐたかなわてん",
          "category": "ハンバーガー    ",
          "address": "港区高輪4-10-18京急ショッピングプラザ ウィング高輪 WEST 1F",
          "location": [
            139.44,
            35.37
          ],
          "description": null
        },
        "_score": 200.72649,
        "_id": "357191",
        "_type": "restaurant",
        "_index": "ldgourmet"
      },
      {
        "_source": {
          "type": "restaurant",
          "closed": 0,
          "access_count": 3287,
          "menu_count": 9,
          "photo_count": 5,
          "purpose": "1,3,4",
          "_id": "17019",
          "name": "エリックスハンバーガーショップ",
          "name_alphabet": "ericshamburgershop",
          "name_kana": "えりっくすはんばーがーしょっぷ",
          "category": "ハンバーガー その他軽食   ",
          "address": "世田谷区若林1-17-5メゾン川奈1F",
          "location": [
            139.39,
            35.38
          ],
          "description": "17:00〜19:00まで日替わりの1品が半額になるハッピーアワーあり。"
        },
        "_score": 171.55804,
        "_id": "17019",
        "_type": "restaurant",
        "_index": "ldgourmet"
      },
      {
        "_source": {
          "type": "restaurant",
          "closed": 0,
          "access_count": 402,
          "menu_count": 0,
          "photo_count": 0,
          "purpose": "1,3,4",
          "_id": "396955",
          "name": "今屋のハンバーガー",
          "name_alphabet": null,
          "name_kana": "いまやのはんばーがー",
          "category": "ハンバーガー    ",
          "address": "福岡市中央区西公園13-13 西公園展望台",
          "location": [
            130.22,
            33.35
          ],
          "description": null
        },
        "_score": 2.358243,
        "_id": "396955",
        "_type": "restaurant",
        "_index": "ldgourmet"
      },
      {
        "_source": {
          "type": "restaurant",
          "closed": 0,
          "access_count": 553,
          "menu_count": 0,
          "photo_count": 0,
          "purpose": null,
          "_id": "305259",
          "name": "ハンバーガージュリア",
          "name_alphabet": null,
          "name_kana": "はんばーがーじゅりあ",
          "category": "ハンバーガー    ",
          "address": "唐津市二タ子1丁目16-744",
          "location": [
            129.57,
            33.27
          ],
          "description": null
        },
        "_score": 2.063463,
        "_id": "305259",
        "_type": "restaurant",
        "_index": "ldgourmet"
      },
      {
        "_source": {
          "type": "restaurant",
          "closed": 0,
          "access_count": 383,
          "menu_count": 0,
          "photo_count": 0,
          "purpose": null,
          "_id": "354167",
          "name": "ハンバーガー&シェイク T's★Diner ",
          "name_alphabet": null,
          "name_kana": "はんばーがーあんどしぇいくてぃーずすたーだいなーほんてん",
          "category": "アメリカ料理 バー その他軽食  ",
          "address": "高槻市城北町2-11-5南園ビル1F",
          "location": [
            135.37,
            34.5
          ],
          "description": null
        },
        "_score": 1.7686824,
        "_id": "354167",
        "_type": "restaurant",
        "_index": "ldgourmet"
      },
      {
        "_source": {
          "type": "restaurant",
          "closed": 0,
          "access_count": 624,
          "menu_count": 0,
          "photo_count": 3,
          "purpose": "3",
          "_id": "415300",
          "name": "焼肉屋さんのハンバーガー あいかわ",
          "name_alphabet": null,
          "name_kana": "やきにくやさんのはんばーがー あいかわ",
          "category": "ハンバーガー    ",
          "address": "佐世保市有福町297-20",
          "location": [
            129.47,
            33.07
          ],
          "description": "16席・駐車場10台"
        },
        "_score": 1.7205129,
        "_id": "415300",
        "_type": "restaurant",
        "_index": "ldgourmet"
      }
    ],
    "max_score": 2232.2642,
    "total": 12
  },
  "_shards": {
    "failed": 0,
    "successful": 5,
    "total": 5
  },
  "timed_out": false,
  "took": 3
}

より高度な内容

データをElasticsearch上に登録し、簡単な検索を行うまでの流れを紹介しました。 以降は予定。飽きたら終了です。1.0 stableが出るまでに書きたい。