Elasticsearchとkuromojiでちゃんとした日本語全文検索をやるメモ
elasticsearch

Elasticsearchとkuromojiでちゃんとした日本語全文検索をやるメモ

このエントリーをはてなブックマークに追加

技術推進室の浅井です。Elasticsearchで日本語全文検索をちゃんとやるための説明、日本語でちゃんと書かれているものが無くて少々困ったので、ちゃんと書いてみます。

Elasticsearchのインストール

※ 2013/12/17 13:30 インストールするJDKのバージョンを7u45から7u25に変更

※ 2013/12/17 12:50 JDKのバージョンについての説明を追記 @johtani さん指摘ありがとうございます

この記事内の説明でOracle JDK 7u45をインストールしていましたが、Apache Luceneが7u45を推奨していないため、7u25をインストールしたほうが良いようです。(後ほど記事内の説明も修正します 修正しました

http://lucene.472066.n3.nabble.com/What-is-recommended-version-of-jdk-1-7-td4097202.html

Ubuntu

Debianパッケージがあります。これ入れれば良いのですが、その前にJavaが必要です。いちおうドキュメントには下記のように書いてあるので、Oracleのものを入れましょう。まずはライセンスに同意を

The usual recommendation is to run the Oracle JDK with elasticsearch.

$ wget --no-cookies --no-check-certificate --header "Cookie: gpw_e24=http%3A%2F%2Fwww.oracle.com" "http://download.oracle.com/otn-pub/java/jdk/7u25-b15/server-jre-7u25-linux-x64.tar.gz" -O "server-jre-7u25-linux-x64.tar.gz"
$ sudo mkdir /usr/java
$ sudo tar xzf server-jre-7u25-linux-x64.tar.gz -C /usr/java/

※JDKでも良いのですが、余分なもの入れるのも嫌なのでServer JREにしています。

続いてElasticsearchをインストールします。

$ wget https://download.elasticsearch.org/elasticsearch/elasticsearch/elasticsearch-0.90.7.deb
$ sudo dpkg -i elasticsearch-0.90.7.deb
$ echo "export JAVA_HOME=/usr/java/jdk1.7.0_25" | sudo tee -a /etc/default/elasticsearch
$ sudo service elasticsearch restart

インストール後にJAVA_HOMEの設定をした上で起動しています。ちょっと動作を確認してみましょう。

$ curl -XGET localhost:9200/_cluster/health?pretty
{
  "cluster_name" : "elasticsearch",
  "status" : "green",
  "timed_out" : false,
  "number_of_nodes" : 1,
  "number_of_data_nodes" : 1,
  "active_primary_shards" : 0,
  "active_shards" : 0,
  "relocating_shards" : 0,
  "initializing_shards" : 0,
  "unassigned_shards" : 0
}

"status" : "green" で正しく動いてることが確認できました。(起動に時間がかかることがあるので、接続できなかったら少し待ってリトライすると良いです)

CentOS

こちらもJavaから入れます。Server JREを使うのでUbuntuと一緒です。まずはライセンスに同意を

$ wget --no-cookies --no-check-certificate --header "Cookie: gpw_e24=http%3A%2F%2Fwww.oracle.com" "http://download.oracle.com/otn-pub/java/jdk/7u25-b15/server-jre-7u25-linux-x64.tar.gz" -O "server-jre-7u25-linux-x64.tar.gz"
$ sudo mkdir /usr/java
$ sudo tar xzf server-jre-7u25-linux-x64.tar.gz -C /usr/java/

Elasticsearchはrpmを入れます。

$ sudo yum localinstall -y https://download.elasticsearch.org/elasticsearch/elasticsearch/elasticsearch-0.90.7.noarch.rpm
...
Complete!
$ echo "export JAVA_HOME=/usr/java/jdk1.7.0_25" | sudo tee -a /etc/sysconfig/elasticsearch
$ sudo service elasticsearch restart

インストール後にJAVA_HOMEの設定をした上で起動しています。このまま動作を確認します。(ここUbuntuと一緒です)

$ curl -XGET localhost:9200/_cluster/health?pretty
{
  "cluster_name" : "elasticsearch",
  "status" : "green",
  "timed_out" : false,
  "number_of_nodes" : 1,
  "number_of_data_nodes" : 1,
  "active_primary_shards" : 0,
  "active_shards" : 0,
  "relocating_shards" : 0,
  "initializing_shards" : 0,
  "unassigned_shards" : 0
}

基本的な設定

※ 2013/12/17 12:50 cluster.nameの説明を追記 @johtani さん指摘ありがとうございます

cluster.name

Elasticsearchはデフォルトでマルチキャストを使ってノードを探し、同じクラスタ名が設定されたノードとクラスタを組みます。このクラスタ名がデフォルトでelasticsearchになっているため、同じネットワークのノードとうっかり繋がってしまう可能性があります。とりあえず変えましょう。/etc/elasticsearch/elasticsearch.ymlに下記のような設定を追加しsudo service elasticsearch restart で再起動します。

cluster.name: es_kuromoji_demo

このあたり、VirtualBoxでホストオンリーアダプタを使っているとか、EC2で動かしているとか、マルチキャストで探しに行っても問題ない(探せない)環境であれば、あまり気にしなくても大丈夫です。また、運用する環境ではマルチキャストを無効にし、クラスタに参加するノードを明示的に指定するほうが安全に運用できるかと思います。(実際そうしています)

http://www.elasticsearch.org/guide/en/elasticsearch/reference/current/modules-discovery-zen.html

automatic index creation, dynamic mapping

自動的に何かをどうにかするやつです。闇です。葬りましょう。ダイナミックに型が決まりますが動的で柔軟なわけではなく、最初のドキュメント(のフィールド)登録時に自動で決まってしまってそれに縛られます。不幸を生むのでoffにしましょう。 http://www.elasticsearch.org/guide/en/elasticsearch/reference/current/docs-index_.html#index-creation http://www.elasticsearch.org/guide/en/elasticsearch/reference/current/mapping-dynamic-mapping.html

/etc/elasticsearch/elasticsearch.yml に下記設定を追加し、 sudo service elasticsearch restart で再起動します。

action.auto_create_index: false
index.mapper.dynamic: false

プラグインのインストール

下記2つ入れておきましょう。

  • elasticsearch-analysis-kuromoji 日本語形態素解析エンジンkuromojiを使って日本語全文検索を行うためのanalysis plugin
  • elasticsearch-HQ ノードやインデックスの状態をブラウザで見るためのsite plugin
$ sudo JAVA_HOME=/usr/java/jdk1.7.0_25 /usr/share/elasticsearch/bin/plugin -install elasticsearch/elasticsearch-analysis-kuromoji/1.6.0
-> Installing elasticsearch/elasticsearch-analysis-kuromoji/1.6.0…
Trying http://download.elasticsearch.org/elasticsearch/elasticsearch-analysis-kuromoji/elasticsearch-analysis-kuromoji-1.6.0.zip...
Downloading ………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………………DONE
Installed elasticsearch/elasticsearch-analysis-kuromoji/1.6.0 into /usr/share/elasticsearch/plugins/analysis-kuromoji
$ sudo JAVA_HOME=/usr/java/jdk1.7.0_25 /usr/share/elasticsearch/bin/plugin -install royrusso/elasticsearch-HQ
-> Installing royrusso/elasticsearch-HQ…
Trying https://github.com/royrusso/elasticsearch-HQ/archive/master.zip...
Downloading …………………………………………………DONE
Installed royrusso/elasticsearch-HQ into /usr/share/elasticsearch/plugins/HQ
Identified as a _site plugin, moving to _site structure …

site pluginはそのまま動きますが、analysis pluginは再起動必要っぽいのでsudo service elasticsearch restart でもう一度再起動しておきます。下記URLでHQの画面が見れます。 http://localhost:9200/_plugin/HQ/

プラグインがちゃんと入っているかどうかはREST APIでも確認できます。

$ curl -XGET localhost:9200/_nodes/plugin?pretty
{
  "ok" : true,
  "cluster_name" : "elasticsearch",
  "nodes" : {
    "e54cayVVTAS4JdxIXy5DpA" : {
      "name" : "Sludge",
      "transport_address" : "inet[/10.0.2.15:9300]",
      "hostname" : "vagrant-ubuntu-saucy-64",
      "version" : "0.90.7",
      "http_address" : "inet[/10.0.2.15:9200]",
      "plugins" : [ {
        "name" : "analysis-kuromoji",
        "description" : "Kuromoji analysis support",
        "jvm" : true,
        "site" : false
      }, {
        "name" : "HQ",
        "description" : "No description found for HQ.",
        "url" : "/_plugin/HQ/",
        "jvm" : false,
        "site" : true
      } ]
    }
  }
}

plugins のとこにkuromojiとHQが入ってますね。再起動してないとHQしか出ないです。

Rubyクライアントを使う

あとはインデックスを作ってドキュメントを登録して検索するだけなんですが、いい加減curl書くのしんどいのでRubyクライアントを使います。PHP, Perl, Python, Groovyなど他の言語のものもあるのでお好きなものをどうぞ。

$ gem install "elasticsearch" --no-ri --no-rdoc
Fetching: multi_json-1.8.2.gem (100%)
Successfully installed multi_json-1.8.2
Fetching: multipart-post-1.2.0.gem (100%)
Successfully installed multipart-post-1.2.0
Fetching: faraday-0.8.8.gem (100%)
Successfully installed faraday-0.8.8
Fetching: elasticsearch-transport-0.4.3.gem (100%)
Successfully installed elasticsearch-transport-0.4.3
Fetching: elasticsearch-api-0.4.3.gem (100%)
Successfully installed elasticsearch-api-0.4.3
Fetching: elasticsearch-0.4.3.gem (100%)
Successfully installed elasticsearch-0.4.3
6 gems installed
$ irb
> require "elasticsearch"
 => true
> es = Elasticsearch::Client.new hosts: "localhost:9200"
 => #<Elasticsearch::Transport::Client:...>
> es.info
 => {"ok"=>true, "status"=>200, "name"=>"Sludge", "version"=>{"number"=>"0.90.7", "build_hash"=>"36897d07dadcb70886db7f149e645ed3d44eb5f2", "build_timestamp"=>"2013-11-13T12:06:54Z", "build_snapshot"=>false, "lucene_version"=>"4.5.1"}, "tagline"=>"You Know, for Search"}

こんな感じで使えます。便利ですね。運用している環境でも、Rubyクライアントでドキュメントの登録・更新を行なっています。

日本語全文検索をちゃんと設定する

本題です。下記のデータを検索するケースを考えます。

data_id  title              description                  update_date
      1  自由が丘のディナー    美味しさや雰囲気で評価が高いお店  2013-12-01T12:00:00+0900
      2  丸の内の美味しいお店  OL目線でお気に入りのランチ      2013-12-02T12:00:00+0900
      3  渋谷のおすすめランチ     渋谷でよく行く雰囲気の良いお店    2013-12-03T12:00:00+0900

要件としては、title, description両方含めた全文検索ができて、大文字小文字全角半角が無視できて、update_dateでソートできれば良い感じ。あと検索結果を表示する際はあらためて別のデータストアから内容を取得する前提とします。

これを踏まえて、tokenizerとfilterを含むanalyzerの設定とmappingの設定を行います。下記はRubyクライアントから設定するコードです。

require "elasticsearch"
Elasticsearch::Client.new(hosts: "localhost:9200").indices.create index: "index1", body: {
  settings: {
    analysis: {
      filter: {
        pos_filter: {type: "kuromoji_part_of_speech", stoptags: ["助詞-格助詞-一般", "助詞-終助詞"]},
        greek_lowercase_filter: {type: "lowercase", language: "greek"}},
      analyzer: {
        kuromoji_analyzer: {
          type: "custom",
          tokenizer: "kuromoji_tokenizer",
          filter: ["kuromoji_baseform", "pos_filter", "greek_lowercase_filter", "cjk_width"]}}}},
  mappings: {
    default: {
      _id: {path: "data_id"},
      _timestamp: {enabled: true, path: "update_date"},
      _source: {enabled: false},
      _all: {enabled: true, analyzer: "kuromoji_analyzer"},
      properties: {
        data_id: {type: "integer", include_in_all: false, store: true},
        title: {type: "string", store: true, index: "analyzed", analyzer: "kuromoji_analyzer"},
        description: {type: "string", store: false, index: "analyzed", analyzer: "kuromoji_analyzer"},
        update_date: {type: "date", include_in_all: false, store: true}}}}}

以下、いくつか要点を説明します。

analyzerの設定

インデックス対象とクエリの解析に使うanalyzerの設定です。

kuromoji_analyzer: {
  type: "custom",
  tokenizer: "kuromoji_tokenizer",
  filter: ["kuromoji_baseform", "pos_filter", "greek_lowercase_filter", "cjk_width"]}

kuromoji_analyzerという名前を定義し、tokenizerとしてkuromoji_tokenizerを使うよう設定しています。これでまずは日本語形態素解析が行われるようになります。加えてfilterを4つ設定しています。

kuromoji_baseform

日本語の動詞と形容詞を原型に戻します。ドキュメント内の単語も原型に戻されてインデックスされ、検索クエリの単語も原型に戻されるようにし、マッチ精度を高めます。

pos_filter

下記で定義した品詞のフィルタです。特定の品詞をインデックスからもクエリからも除外し、マッチ精度を高めます。

pos_filter: {type: "kuromoji_part_of_speech", stoptags: ["助詞-格助詞-一般", "助詞-終助詞"]}
greek_lowercase_filter

下記で定義した英字を小文字化するフィルタです。他のフィルタと同様、インデックス内もクエリもすべて小文字化し、大文字小文字を無視した検索となるようにします。

greek_lowercase_filter: {type: "lowercase", language: "greek"}}
cjk_width

全角英数字を半角に直し、半角カタカナを全角に直すなど、漢字圏の文字の全半角を整え、マッチしやすくします。

デフォルトのtokenizerの設定は?

/etc/elasticsearch/elasticsearch.yml で設定をすると、デフォルトのtokenizerを変えられます。こんな感じに。

index.analysis.analyzer.default.type: custom
index.analysis.analyzer.default.tokenizer: kuromoji_tokenizer

これをやっても良いのですが、analyzerの設定がうまくいってるのかがわかりにくくなるし、ここに頼ると管理しにくくなるのでインデックス作成時にきちんと指定することをおすすめします。日本語で書かれたブログ記事などはこれに頼った説明が多く、ちゃんとanalyzerとmappingの設定を説明しているものがなくてちょっと困りました。

type定義

defaultという名前のtypeを1つ定義してて、そこで特殊フィールドについて下記定義をしています。

_id: {path: "data_id"},
_timestamp: {enabled: true, path: "update_date"},
_source: {enabled: false},
_all: {enabled: true, analyzer: "kuromoji_analyzer"}

_id

Elasticsearchにドキュメントを登録すると、自動的に_idという値が生成されます。この値はドキュメントを更新(上書き)するときや削除するときに必要となります。なので今回のようにユニークなIDが別途あるのであれば(たいていあると思いますが)、そっちの値を使えるようにしたいところです。そうじゃないと自動生成された値をどっかに保存しておかなくちゃいけなくて面倒ですしね。

_idの値はドキュメント登録時に指定することもできますし、別のフィールドを参照させることもできます。ここでは _id: {path: "data_id"} としてdata_idフィールドを参照するようにしています。 http://www.elasticsearch.org/guide/en/elasticsearch/reference/current/mapping-id-field.html

ちなみに、内部的な識別のためのユニークIDは_uidというものだそうで、_idがあってもなくても動くとドキュメントには書いてあります。大事なキーっぽいものをいじるのは不安がありますが、_idはクリティカルなものではないようですね。 http://www.elasticsearch.org/guide/en/elasticsearch/reference/current/mapping-uid-field.html

_timestamp

ドキュメントのタイムスタンプを保持するフィールドです。デフォルト無効ですが、今回は元データにタイムスタンプがあるので、そのフィールドを参照するように設定しています。 http://www.elasticsearch.org/guide/en/elasticsearch/reference/current/mapping-timestamp-field.html

_source

Elasticsearchはデフォルトで_sourceというフィールドに元のデータをすべて格納し、検索結果にもこのデータをすべて載っけます。今回の要件だとこれまるっきり不要で、検索結果はdata_idだけで良くて、あとは別途データストア(RDBなど)から取ってきます。不要どころかシャードのデータ量的にもトラフィック的にも邪魔になるのでオフにしています。 http://www.elasticsearch.org/guide/en/elasticsearch/reference/current/mapping-source-field.html

いちおう無意味な存在ではなくて、update apiなどで利用されているそうです。(update apiはドキュメントの差分データを受け取りますが、内部的には_sourceから元データを取ってきて、ドキュメントの再インデックスをする実装だそうです) というわけで_sourceをオフにするとupdate apiが使えないのですが、どうせ再インデックスするわけなんで更新時は全データ投げてしまえばよいです。(ここのトラフィックが問題になるくらい大きなドキュメントを更新しまくるなら別ですが)

_all

デフォルトでインデックスしたフィールドは全部_allという名前のフィールドにも入ります。全体を検索するときにこのフィールド便利なのですが、それなりにオーバーヘッドになるし単一のフィールドになることでスコアリングが柔軟にできなくなります。というわけで、使わなければオフにしたいのですが、今回は使うのでオンです。 http://www.elasticsearch.org/guide/en/elasticsearch/reference/current/mapping-all-field.html http://jontai.me/blog/2012/10/lucene-scoring-and-elasticsearch-_all-field/

_allに含めたくない、含める必要のないフィールドについては、include_in_all: falseとしてやればOKです。

ドキュメントを登録して検索

さっきのデータをドキュメントとして登録します。

require "elasticsearch"
es = Elasticsearch::Client.new(hosts: "localhost:9200")
es.index index: "index1", type: "default", body: {
  data_id: 1,
  title: "自由が丘のディナー",
  description: "美味しさや雰囲気で評価が高いお店",
  update_date: "2013-12-01T12:00:00+0900"}
es.index index: "index1", type: "default", body: {
  data_id:2,
  title: "丸の内の美味しいお店",
  description: "OL目線でお気に入りのランチ",
  update_date: "2013-12-02T12:00:00+0900"}
es.index index: "index1", type: "default", body: {
  data_id:3,
  title: "渋谷のおすすめランチ",
  description: "渋谷でよく行く雰囲気の良いお店",
  update_date: "2013-12-03T12:00:00+0900"}

そのまま検索してみます。まずは全件取得。searchの後ろのゴニョゴニョは表示のための処理です。

> es.search(index: "index1", body: {fields: ["title"]})["hits"]["hits"].each{|h|puts "%s: %s" % [h["_id"], h["fields"]["title"]]}
1: 自由が丘のディナー
2: 丸の内の美味しいお店
3: 渋谷のおすすめランチ

全件入れた順でそのまま出てきました。次は「ランチ」で検索してupdate_dateの降順にソートします。match: {_all: "ランチ"}_allフィールドに対して検索を行なっています。

> es.search(index: "index1", body: {query: {match: {_all: "ランチ"}}, fields: ["title"], sort: [{update_date: {order: "desc"}}]})["hits"]["hits"].each{|h|puts "%s: %s" % [h["_id"], h["fields"]["title"]]}
3: 渋谷のおすすめランチ
2: 丸の内の美味しいお店

titleまたはdescriptionで「ランチ」にマッチしたものが、新しい順で出てきました。「渋谷のおすすめランチ」は半角カナですが正しく検索できていることがわかります。

次は「美味しい ランチ ol」で検索します。複合語でand検索する前提で、operator: "and"をクエリに設定しています。

> es.search(index: "index1", body: {query: {match: {_all: {query: "美味しい ランチ ol", operator: "and"}}}, fields: ["title"]})["hits"]["hits"].each{|h|puts "%s: %s" % [h["_id"], h["fields"]["title"]]}
2: 丸の内の美味しいお店

「OL目線でお気に入りのランチ」というdescriptionにひっかかっています。大文字小文字全角半角を無視して検索できる、という要件に沿うことができています。

最後に「美味しい お店」で検索してみます。

> es.search(index: "index1", body: {query: {match: {_all: {query: "美味しい お店", operator: "and"}}}, fields: ["title"]})["hits"]["hits"].each{|h|puts "%s: %s" % [h["_id"], h["fields"]["title"]]}
1: 自由が丘のディナー
2: 丸の内の美味しいお店

自由が丘の方は、descriptionの「美味しさや雰囲気で評価が高いお店」の「美味しさ」がひっかかっています。kuromiji_baseformフィルタによって動詞と形容詞が原型でインデックスされているためです。このあたりは求められる利便性に合わせてどうぞ。

というわけで良い具合に検索できてますね。ここではElasticsearchで日本語全文検索をちゃんとやる方法を紹介しました。実際にサービスに適用して運用するにはもっと複雑なスキーマだったり、スコアリングやファセットが必要になったりするかと思いますし、Elasticsearch側の設定や運用もあれこれ必要になります。そのあたりはそのうち誰かが書きますたぶん。


※下記サイトでElasticsearch動かしています。良ければちょっと検索を試してみてください。


名無しのエンジニア
Mroonga 3.11に追加されるDATETIME型のORDER BY最適化
AngularJS on Rails