Elasticsearch で作る検索エンジン ― 理論と実践 (1/2)

こんにちは。エンジニアの岩永です。

先日 Wantedly では Elasticsearch と検索エンジンについて勉強会を開催しました。

Elasticsearchで作る検索エンジン実践会 @Wantedly (2016/07/07 19:30〜)
概要 Wantedly が内部向けにやっている勉強会に20名様だけご招待。 63,000回。Google は一秒間にこれだけの検索をしていると言われています。 1.2年ごとに世界中の情報が倍になっている現代において、 検索はユーザが目的のものに素早くアクセスする手助けをしています。 情報に素早くアクセスできるというのはどんなサービスでも重要なことです。 しかし、検索エンジンを作ると言っても、実際に何に気をつけて作っていけばいいのかわからないという方も多いと思います。 今回の実践会では GitHub の I
http://wantedly.connpass.com/event/35009/

カバー画像は Elasticsearch 開発元である Elastic 社の Jun Ohtani さんがおみやげに持ってきてくれたグッズです。(ありがとうございました!)

さて、Wantedly には大きく5つの検索機能があり、すべて Elasticsearch で作られています。

- サイト内検索
- 個人のつながりの検索
- 企業向けのダイレクトスカウト機能
- 募集推薦チャットボット
- Sync のメッセージ検索

このシリーズでは2回に渡って、Elasticsearch で検索エンジンを設計する際に知っておきたいことをお伝えできればと思います。

Elasticsearch の基本

Elasticsearch はドキュメント指向な全文検索エンジンです。

RDB でパフォーマンスを考えて検索エンジンを作ろうとすると、非正規化を進めることになり、変更に非常に弱くなってしまいます。
一方、Elasticsearch ではビジネス要件を JSON 構造へ落とし込みインデックスするだけで、
それなりに速い検索が出来るわけです。

構成

Elasticsearch を RDB と構成的に比較をすると、概ね以下の様な関係になっています。

データスキーマ

インデックスやデータスキーマは基本的に JSON 構造に落としこむだけですが、
その際に、データ量や更新頻度等を考慮すると後々幸せです。
こちらの記事を参考にしてみてください。


また、どのようなデータ型が使えるかはこちらのページにかかれています。

Field のオプションについて知りたい場合は、

Field の命名規則

Elasticsearch には、「Index 内で同名の field は同じ型でなければならない」という制約があります。 これによる名前の衝突を防ぐために、field 名にはハンガリアン記法を使うことをおすすめします。

例えば、

- s_name (string)
- b_admin (boolean)
- i_age (integer)
- n_address (nested)

それから、複数の値が入る (つまり array) field では複数形と単数形を使い分けておきましょう。

- i_user_id (integer)
- i_user_ids (array of integers)

他にも、analyzer を区別するためにこのような suffix を用意しても良いかもしれません。

- s_name_ja (日本語に特化した analyzer)
- s_name_en (欧文に特化した analyzer)
- s_name_phonetic (漢字をよみで検索できる analyzer)

上級者向けの情報
Template という仕組みと合わせて使うとこの命名規則は非常に有利になります。
Prefix や suffix というパターンを予め定義しておくだけで、新しい field が必要になっても template から自動で field が作成されるので、都度マッピングを更新する必要がなくなります。
Customizing Dynamic Mapping | Elasticsearch: The Definitive Guide [2.x] | Elastic

検索の基本 ―「引っかける」と「上げる」

さて、ここからは検索の理論とともに、アルゴリズムを Elasticsearch でどのように実現していくかを説明したいと思います。

まず知っておきたいのは、
検索には「引っかける」と「上げる」の2つのフェーズがあることです。
そして、その基本は OR 検索です。

検索エンジンには色々な入力があります。
例えば人を探すとき「Wantedly 岩永」といったように会社名と苗字を掛けあわせて入力することがありますよね?

そういった時には、「基本は OR で検索し、AND になっている項目を上位に表示する」ことが重要です。 先ほどの例で言うと、

2値 { Wantedly, 岩永 } が入力された時に、
まずは Wantedly ∨ 岩永 で広くフィルタリングしておき、
Wantedly ∧ 岩永 にマッチする項目が上位に表示されるように調整します。

こうしておくことで、どちらかの条件が間違っていた場合でも、もう片方の条件からユーザに候補を表示してあげることができるのです。

次のセクションからは、この「引っかける」ことと、より重要なものを「あげる」ことの2つについてそれぞれ見ていきたいと思います。

引っかける

Elasticsearch で引っかけようとおもったら、filter というコンテクストにクエリを書いていくことになります。

論理式の組み立てかた

検索クエリを組み立てるにあたって、最も基本となる and, or, not の作り方まずは覚えておきましょう。 それぞれ、

// A ∧ B
{
"and": [
{ /* A */ },
{ /* B */ }
]
}
// A ∨ B
{
"or": [
{ /* A */ },
{ /* B */ }
]
}
// ¬A
{
"not": { /* A */ }
}

といったクエリで実現出来ます。
またネストをしたり、組み合わせることもできるので、このような複雑な条件も表現できます。

// A ∧ (B ∨ ¬C)
{
"and": [
{ /* A */ },
{
or: [
{ /* B */ },
{ not: { /* C */ } }
]
}
]
}

値による検索

さて、Elasticsearch には色々なクエリ言語が存在しますが、まずは単純に値でフィルタする方法である Term queryを見ていきましょう。

{
"filter": {
"term": {
"i_foo": 123
}
}
}

これは、i_foo という field に対して、123 という値を持つものだけを返すクエリです。
加えて、456 も返したい場合、先ほどの OR のパターンを使うと、

{
"filter": {
"or": [
{
"term": {
"i_foo": 123
}
},
{
"term": {
"i_foo": 456
}
}
]
}
}

となります。
ですが、このパターンは実は Terms query を使うともう少し簡潔に書くことが出来ます。

{
"filter": {
"terms": {
"i_foo": [123, 456]
}
}
}

このように、単値の場合は term を、多値の場合は terms を使って、値が完全に一致したものをフィルターすることが出来ます。

豆知識
Elasticsearch の field は基本的に、単値も多値も区別はありません。
内部的にはすべて配列のように扱われます。
つまり、"i_foo": 123 とインデックスするのも、
"i_foo": [123] とインデックスするのも一緒の意味です。

範囲で絞り込む range など、値で検索するためのクエリは複数あるのでこちらから見てみるといいかもしれません。

文字列による検索

大抵の場合は、Simple query string queryというクエリを使えば、簡単に全文検索が実現できます。

{
"filter": {
"simple_query_string": {
"query": "Wantedly 岩永", // デフォルトでは単語は OR になっている
"fields": ["s_name", "s_company"] // 複数の fields にまたがって検索する事もできる
}
}
}

Query string にユーザの入力をそのまま入れるのは少し危険なので、注意しましょう。

また、全文検索系のクエリは他にもあるので公式サイトを参照しましょう。


正規化

文字列による検索をする場合は Elasticsearch のクエリに入れる前処理として、正規化をしておくことをおすすめします。
例えば、全角・半角を統一したり、余分なスペースを除去したりすることで、表記ブレによる精度の低下を防ぎます。

Ruby では NKF 等でこのような関数を使うと良いかと思います。

# Normalize whitespace and kana
#
# normalized_query(' [ ] a1a1あア')
# => '[ ]a1a1アア'
def normalized_query(query, katakana: false)
return '' unless query
option = %w[-Z1 -w]
option << '--katakana' if katakana
NKF.nkf(option.join(' '), query).gsub(/[[:space:]]+/, ' ').strip.downcase
end

Typo (スペルミス) への対策

Apple を Appel と打ち間違えている場合も救ってあげたいとおもったら、Fuzzy Query を使いましょう。レーベンシュタイン距離にもとづいて、少しの間違えでもマッチするような検索を実現できます。


形態素解析

Elasticsearch では kuromoji という形態素解析エンジンを使って、例えば漢字をよみがなで検索出来たりと、日本語の検索精度を上げることができます。日本語で検索をする上での設定項目が書かれているこの記事や、

Elasticsearch 日本語で全文検索 その2 — Hello! Elasticsearch. — Medium

プラグインの公式サイトは、詳しいパラメタを見たい時に参照してみてください。

人名辞書形態素解析で重要なのが、辞書です。 特に人の名前は読み方が特殊なケースも多いので、フリーで公開されている辞書を組み合わせて、エンジンを鍛えると精度が上がります。

ではたくさんの辞書が公開されています。ライセンスを確認して利用しましょう。


ローマ字の曖昧さ

ローマ字は曖昧です。
私達が普段使うローマ字は、かなり適当で、正確に綴られていないパターンが多くあります。
よく知られたケースで言うと、

- ono
- オノ
- オオノ
- kondo
- コンド
- コンドウ
- yuna
- ユナ
- ユウナ
- ユンア (韓国人的な?)
- koniro
- コニロ
- コンイロ
- konniro
- コンイロ
- コンニロ

など、同じ綴でも、違う読み方に解釈できます。
そこで、Wantedly では Roka というライブラリを使っています。

Roka を使うと、このような曖昧なローマ字を可能性のある全てのカナに変換してくれます。

Roka.convert('kyari-pamyupamyu')
#=> [
"キャリーパミュパミュ"
]

Roka.convert('kondo')
#=> [
"コンド",
"コンドウ"
]

Roka.convert('yuna')
#=> [
"ユンア",
"ユナ",
"ユウンア",
"ユウナ"
]

ユーザの入力をこれを使って展開しておき、Elasticsearch にクエリを投げることで、網羅性の高いローマ字検索が可能になります。

{
filter: {
or: Roka.convert('yuna').map { |kana|
{
simple_query_string: {
query: kana,
fields: ['s_name_phonetic']
}
}
}
}
}

上げる

今回はここまで!

次回は「引っかけた」ものの中からより重要なものを「上げる」ランキングの仕方について書きます。お楽しみに

Wantedly, Inc.'s job postings
Anonymous
929f86c0 2652 4e12 8972 db2d4bebaaba?1507617328
Picture?1522981118
48c7cd7d ecdc 4094 a6c0 aa674f0750fa?1520473766
8b7c0642 b272 40eb b5ff daf2888da0a0?1504184836
7cb1f3d8 e29e 44ac 8322 600b722e4a86
37 Like
Anonymous
929f86c0 2652 4e12 8972 db2d4bebaaba?1507617328
Picture?1522981118
48c7cd7d ecdc 4094 a6c0 aa674f0750fa?1520473766
8b7c0642 b272 40eb b5ff daf2888da0a0?1504184836
7cb1f3d8 e29e 44ac 8322 600b722e4a86
37 Like

Weekly ranking

Show other rankings

Page top icon