$shibayu36->blog;

株式会社はてなでエンジニアをしています。プログラミングや読書のことなどについて書いています。

Elasticsearchのインデックス定義を設計する手順

 Elasticsearchを使おうとすると、まずアプリケーションの仕様にしたがってインデックス定義やマッピング定義を設計しなければならない。これはMySQLを使っていてスキーマを考えるフェーズに相当する。

 この時、考えることが非常に多く、いろいろなドキュメントを参照し設計したので、今回はその手順について書いていきたいと思う。

 インデックスやマッピングが何かという話は、次の記事を参考に。

 また対象とするElasticsearchのversionは記事執筆時点の安定版の2.3.5とする。

今回サンプルとする例

 実際のプロジェクトを参考例にすることは流石にできないので、今回はブログの記事を検索するという例で説明する。Elasticsearchにはブログの記事の情報を入れ、全文検索やフィルタリングができるようにしたい。

 ブログのアーキテクチャとしては、単純に1つのブログに複数の記事が紐付いているというものを考える。記事にはタイトル、本文があり、カテゴリが複数付けられる。またPVやブックマーク数などがデータとしてあるとする。

インデックスやマッピングを設計する手順

 僕が考えるときは、以下のように設計をしていった。

  • Elasticsearchでやりたいことを決める
  • フィールドとその型を決める
  • 全文検索のためにどのようにアナライズするか考える
  • その他細かい検討を行う
  • 最終的なインデックス定義のJSONを作る

Elasticsearchでやりたいことを決める

 何をやりたいかを明確にしておかなければ、インデックスをきれいに設計できないと考えた。そこでインデックスを設計する前にやりたいことを考えてみる。例えば次のものが考えられる。

  • (1) ユーザーが検索ワードを入れることで、タイトル・本文・カテゴリからいい感じに全文検索できる
  • (2) 特定のブログに絞り込んで検索ができる
  • (3) 公開されている記事のみに絞り込む
  • (4) 人気順に並べる
  • (5) 投稿順に並べる
  • (6) カテゴリ名の完全一致で検索する
  • etc...

 (1)は例えば「Ruby 設計」とかで調べた時に、タイトル・本文・カテゴリに「Ruby」と「設計」が含まれるものを検索するといったもの。いい感じにというのは、例えば「Ruby 設計」という検索クエリで、「Ruby入門」「アプリケーション設計」というカテゴリが付いている記事もヒットさせたいとか、大文字小文字関係なしにヒットさせたいなどがある。(2)〜(5)については普通の要求である。(6)に関しては、例えばあるカテゴリがついた記事一覧を出したい時などに利用したい。


 ひとまず今回はやりたいことはこれだけとして、インデックス定義の設計に入る。

フィールドとその型を決める

 やりたいことが決まれば、次にどのような情報をElasticsearchに入れるかや、それぞれのフィールドの型を決めることができる。今回のやりたいこと(1)〜(6)を踏まえて、フィールドとその型を決めてみる。どのような型が利用できるかは、Field datatypes | Elasticsearch Reference [2.3] | Elastic を参考にする。

 まずどのような情報が必要か考える。

  • (1)から、タイトル、本文、カテゴリを入れる必要がある
    • これらは全文検索をするために何かしら処理を加えなければならない
    • 処理については「全文検索のために、どのようにアナライズするか考える」で説明する
  • (2)から、記事が紐づくブログのIDを含める必要がある
  • (3)から記事のステータスが必要
  • (4)から人気順に必要な情報、今回はPVとブックマーク数を含める必要がある
  • (5)から投稿日時が必要である
  • (6)はカテゴリのフィールドがあればいいように見えるが、実は(1)のために利用するフィールドを(6)で使い回すことが出来ないので、別のフィールドが必要となる
    • 後で説明する


 入れるべき情報は分かったので、次にそれぞれの型を考える。

  • タイトル、本文は全文検索のために何かしら処理を加えたstring型となる
  • カテゴリは複数つけられるので、stringの配列となる
    • 全文検索のために何かしら処理を加える必要がある
    • ただし、Elasticsearchではarray型というのは存在せず 、あるフィールドには複数のデータを入れられるため、string型にしておく
  • ブログのIDはlong
  • 記事のステータスはstring
    • 公開状態published、下書き状態draftがあるとする
  • PVはinteger
  • ブックマーク数はinteger
  • 投稿日時はdateで、formatにdate_time_no_millisを利用する


 以上から、フィールドと型が決まった。

  • title : string, 全文検索のためanalyzerの定義が必要
  • content : string, 全文検索のためanalyzerの定義が必要
  • categories : string, 全文検索のためanalyzerの定義が必要
  • blog_id : long
  • status : string
  • pv : integer
  • bookmark_count : integer
  • published_at : date, date_time_no_millis

全文検索のためにどのようにアナライズするか考える

 フィールドと型が決まったが、title、content、categoriesには「全文検索のためanalyzerの定義が必要」と書いてあり、ここについてはまだ決まっていない。そこで次に全文検索のためにどのようにアナライズするか考えてみる。

 (1)のユースケースでは「いい感じに」という漠然としたワードを使ったので、もう少し具体化する。

  • 日本語として検索したい
  • 大文字や小文字を気にせず検索したい
    • RUBY」と検索しても「Ruby」や「ruby」を含む記事がヒットして欲しい
  • 全角半角を気にせず検索したい
    • 「データベース」と検索しても、「データベース」を含む記事がヒットして欲しい
  • 1つのカテゴリの一部分に含まれていても検索したい
    • Ruby」で検索しても、「Ruby入門」カテゴリがついた記事がヒットして欲しい
  • 「に」「が」のような、日本語の助詞がヒットすると精度が悪くなるので、省きたい


 これらを満たすように、Analyzerを定義してみる。Analyzerの概念については他の記事を参考にして欲しい。以前ElasticsearchのAnalyzerを理解するため全文検索の仕組みをシンプルに考える - $shibayu36->blog;でまとめたものや、Analysis and Analyzers | Elasticsearch: The Definitive Guide [2.x] | Elasticが参考になるだろう。


 大文字小文字や全角半角を気にせず検索するためには、正規化を行う必要がある。全角半角が含まれた状態でトークナイズされると、「データベース」というワードは「データ」「ベース」と分割されてしまい、意図しない結果となるので、トークナイズより前に行う必要がある。これはICU Analysis Pluginという便利なものがあり、この中のICU Normalization Character Filterを利用すれば実現できる。このプラグインだけで、NFKC正規化と大文字小文字の正規化を行ってくれるため、大文字小文字全角半角の正規化ができる。

 また全文検索のため、トークナイズを行う必要がある。日本語の全文検索のためにはkuromoji analyzerというプラグインがある。これのkuromoji_tokenizerを利用すれば日本語の品詞単位でトークナイズできる。また検索しやすさのためにsearch modeというオプションを利用する。

 最後にトークナイズ結果から助詞を省きたい。kuromoji_part_of_speechを利用し、トークナイズ後に適用することで助詞を省くことができる。

 この手順はそのままCharacter filters, Tokenizer, Token filtersの概念にマッチするので、最終的に次の処理を行えば全文検索の準備ができる。

  • Character filters : icu_normalizer
  • Tokenizer : kuromoji_tokenizer, search mode
  • Token filters : kuromoji_part_of_speech

 処理にかけると、例えば「Ruby入門のブログ記事」のようなタイトルは、「ruby」「入門」「ブログ」「記事」のように分割され、「Ruby」や「ブログ」のような検索ワードにヒットするようになる。

カテゴリ名の完全一致で検索するために

 前述のとおり、(6)のユースケースを満たすためには、実はcategoriesだけではうまくいかない。なぜなら、「Ruby入門」「アプリケーション設計」というカテゴリが付いている記事の場合、先ほどの全文検索のための処理を通すと「ruby」「入門」「アプリケーション」「設計」のように分割されてしまい、結果として「Rubyアプリケーション」と検索してもヒットするようになってしまうためである。

 そこで、カテゴリ名の完全一致で検索するためには、全文検索用の処理を行わないフィールドを別に作っておく必要がある。これはカテゴリ検索のためのフィールドを役割ごとに二つ作ることで解決する。

  • categories : string, 全文検索のための処理を行わない
  • analyzed_categories : string, 全文検索のための処理を行う


 これにより、「Ruby入門」「アプリケーション設計」というカテゴリがあった場合

  • categories : 「Ruby入門」「アプリケーション設計」
  • analyzed_categories : 「ruby」「入門」「アプリケーション」「設計」

のようにデータが保存され、完全一致の時はcategories、全文検索の時はanalyzed_categoriesを利用するとうまく検索出来るようになる。

その他細かい検討を行う

 ここまでで大体のインデックスを設計することができた。あとは細かい点を検討する。

  • Dynamic Mappingの無効化
  • _sourceフィールドの無効化
  • インデックスにalias名を付けるか

Dynamic Mappingの無効化

 Dynamic Mapping | Elasticsearch: The Definitive Guide [2.x] | Elastic にあるように、デフォルトではDynamic Mappingという機能が有効化されている。Dynamic Mappingとはインデックス定義にないフィールド情報が送られてきたら自動でフィールドが作られる機能である。これの要不要を検討する。

 基本的にはインデックス定義をちゃんと作るアプリケーションでは、無効化しておいたほうが良いと思う。もしログ基盤を作る場合、はじめから内部に入れる情報を知ることは出来ないので、そのような場合のみ有効化すると良さそう。

 検討した結果、Dynamic Mapping機能は無効化にし、かつもし間違ったフィールド名を渡した時にエラーとなるように、strictモードにすることにした。

_sourceフィールドの無効化

 _source field | Elasticsearch Reference [2.3] | Elastic にあるように、デフォルトではドキュメントを作る時に渡したフィールド全てが_sourceフィールドに保存されている。もし非常に大きなドキュメントをインデックスする場合、_sourceフィールドを保存しているとデータ容量の点で問題が起こる可能性がある。そこで_sourceフィールドの要不要を検討する。


 _sourceフィールドを無効にするとデータ容量の問題は緩和されるが、その代わり様々なデメリットが存在する。例えばあるドキュメントの差分更新ができなくなる(update APIが使えなくなる)、ハイライト機能が使えなくなる、reindex APIが使えなくなるなどである。公式は、_sourceフィールドを無効にする前に圧縮オプションの見直しをすることをおすすめしている。

 もしそれでも_sourceフィールドを無効にしたい場合、store というオプションを併用し、必要なフィールドだけ保存すると良いだろう。


 今回は_sourceフィールドの無効化によるデメリットが大きいと考え、有効のままにしておく。

インデックスにalias名を付けるか

 Index Aliasesのドキュメントにあるとおり、インデックスにはalias名を付けることができる。alias名を付けることにより、停止せずに再インデックスして切り替えるなどといったことができるようになる。

 基本的には今後Elasticsearchを使うなら、alias名を付けておいたほうが良いと感じる。例えばblog-20160830のようにインデックス定義を作った日付をインデックスの実態としておき、alias名としてblogと付けておく。このようにすれば、もしインデックス定義を更新したいときはblog-20160911のように新しくインデックス定義を作り、blogというaliasの向き先をこちらに変えるということができる。

 他にもalias名を作ることで、Elasticsearch インデックス・エイリアス – Hello! Elasticsearch. – Mediumに紹介されているようなことが可能になる。


 結論としてalias名を付けたほうが良いという結論になったが、今回は説明を簡単にするため、alias名は付けないこととする。

最終的なインデックス定義のJSONを作る

 ここまででインデックス定義が決まった。

 ブログ記事をインデックスするので、インデックス名はblog、タイプ名はentryとする。

 entryタイプのフィールドと型は次のとおり。

  • title : string, 全文検索のためのanalyzerで処理
  • content : string, 全文検索のためanalyzerで処理
  • categories : string
  • analyzed_categories : string, 全文検索のためanalyzerで処理
  • blog_id : long
  • status : string
  • pv : integer
  • bookmark_count : integer
  • published_at : date, date_time_no_millis


 全文検索のためのanalyzer設計は以下のとおり。

  • Character filters : icu_normalizer
  • Tokenizer : kuromoji_tokenizerのsearch mode
  • Token filters : kuromoji_part_of_speech


 そしてオプションとしてDynamic Mappingを無効化する。


 最終的なインデックス定義のJSONは以下のようになった。

{
  "settings": {
    "index": {
      "analysis": {
        "tokenizer": {
          "ja_text_tokenizer": {
            "type": "kuromoji_tokenizer",
            "mode": "search"
          }
        },
        "analyzer": {
          "ja_text_analyzer": {
            "tokenizer": "ja_text_tokenizer",
            "type": "custom",
            "char_filter": [
              "icu_normalizer"
            ],
            "filter": [
              "kuromoji_part_of_speech"
            ]
          }
        }
      }
    }
  },
  "mappings": {
    "entry": {
      "dynamic": "strict",
      "properties": {
        "title": {
          "type": "string",
          "analyzer": "ja_text_analyzer"
        },
        "content": {
          "type": "string",
          "analyzer": "ja_text_analyzer"
        },
        "categories": {
          "type": "string",
          "index": "not_analyzed",
          "copy_to": "analyzed_categories"
        },
        "analyzed_categories": {
          "type": "string",
          "analyzer": "ja_text_analyzer"
        },
        "blog_id": {
          "type": "long"
        },
        "status": {
          "type": "string",
          "index": "not_analyzed"
        },
        "pv": {
          "type": "integer"
        },
        "bookmark_count": {
          "type": "integer"
        },
        "published_at": {
          "type": "date",
          "format": "date_time_no_millis"
        }
      }
    }
  }
}

 ほとんど先ほど説明したとおりだが、categoriesのフィールド定義のindex not_analyzedとcopy_toというオプションだけ説明をしていない。

 not_analyzedというのはstring型の全文検索のための処理をしないようにするオプション。デフォルトで勝手に全文検索の処理を行ってしまうので、明示的にnot_analyzedを指定している。

 またcategoriesとanalyzed_categoriesの両方のフィールドを作るために、copy_toというオプションを利用している。これによりcategoriesフィールドにデータを入れるだけで、analyzed_categoriesにもコピーされる。


 あとはこれで実際にブログ用インデックスを作ってみて、ドキュメントを試しに入れ、結果を見てみると良い。

 上記jsonをblog-mapping.jsonというファイルに保存した上で、Elasticsearchに定義を作る。

$ curl -XPOST http://localhost:9200/blog -d @blog-mapping.json

 次に以下のようにしてドキュメントを一つ作る。

$ curl -XPOST http://localhost:9201/blog/entry/1 -d '{
  "title": "Rubyによるアプリケーション設計入門",
  "content": "この記事ではRubyによるアプリケーション設計に入門するための情報を書きます。",
  "categories": ["Ruby入門", "アプリケーション設計"],
  "blog_id": 12345,
  "status": "published",
  "pv": 98765,
  "bookmark_count": 123,
  "published_at": "2016-08-30T12:34:56+09:00"
}'

 最後に実際にドキュメントを検索し、どのようにインデックスされているか確認する。

$ curl -XGET "http://localhost:9200/blog/entry/_search?pretty=1" -d'
{
   "query": {
       "match_all": {}
   },
   "fielddata_fields": ["title", "content", "categories", "analyzed_categories"]
}'

 すると以下の結果が返ってきて、意図通りにインデックスされていることが分かる。

{
  "took" : 123,
  "timed_out" : false,
  "_shards" : {
    "total" : 5,
    "successful" : 5,
    "failed" : 0
  },
  "hits" : {
    "total" : 1,
    "max_score" : 1.0,
    "hits" : [ {
      "_index" : "blog",
      "_type" : "entry",
      "_id" : "1",
      "_score" : 1.0,
      "_source" : {
        "title" : "Rubyによるアプリケーション設計入門",
        "content" : "この記事ではRubyによるアプリケーション設計に入門するための情報を書きます。",
        "categories" : [ "Ruby入門", "アプリケーション設計" ],
        "blog_id" : 12345,
        "status" : "published",
        "pv" : 98765,
        "bookmark_count" : 123,
        "published_at" : "2016-08-30T12:34:56+09:00"
      },
      "fields" : {
        "categories" : [ "Ruby入門", "アプリケーション設計" ],
        "analyzed_categories" : [ "ruby", "アプリケーション", "入門", "設計" ],
        "title" : [ "ruby", "アプリケーション", "入門", "設計" ],
        "content" : [ "ruby", "この", "する", "ため", "アプリケーション", "入門", "情報", "書き", "記事", "設計" ]
      }
    } ]
  }
}

まとめ

 今回はElasticsearchのインデックス定義を設計する手順についてまとめてみた。基本的にはこの手順をベースに、やりたいことに従って、フィールドを増やす、別のタイプを作る、全文検索のための処理をチューニングするなどを行っていくと良いと思う。