文字列からElasticsearchに渡すクエリをつくる

More than 3 years have passed since last update.

検索ボックスなどから渡される文字列をもとに、Elasticsearch用の適当な検索クエリをつくる方法について説明します。題材として increments/qiita-elasticsearch を使います。


クエリ文字列

ここで言っている文字列というのは、先述した通り検索ボックスなどから渡される文字列のことです。例えばQiitaでElasticsearchに関する記事を探したければ「Elasticsearch」と入力すると思いますが、そのような文字列のことを指しています。プログラムの中では query_string と表現しているので、ここではそのような文字列のことをクエリ文字列と呼ぶことにします。


検索クエリ

Elasticsearchを利用して検索を行うときは、GETリクエストで検索クエリを渡し、レスポンスボディ経由でJSON形式の検索結果を受け取ると思いますが、今回クエリ文字列から生成したいのはそのGETリクエストで渡す検索クエリのことです。Elasticsearchに渡す検索クエリは、?q=... のようにURLクエリパラメータ経由で渡したり、リクエストボディにJSONを含めて渡したりできますが、この記事では後者のJSONで表現できる形式の検索クエリを文字列から生成します。


パターン

例えばQiitaの検索ボックスでは、いくつかの特殊なパターンの検索クエリが利用できます。title:Ruby では記事のタイトルにRubyを含むものが一致し、-Rails ではRailsを含まない記事が、"Ruby on Rails" ではRuby on Railsに完全一致する記事のみが一致します。また、tag:Ruby -tag:Rails user:r7kamura のように、いくつかのパターンが組み合わせられることもあり得ます。渡されたクエリ文字列を解析し、これらのパターンの定義に従ってElasticsearch用の適切な検索クエリを生成するプログラムをつくる、というのがこの記事の目的です。


  • title:...

  • tag:...

  • user:...

  • -...

  • ... OR ...


Tokenizer

クエリ文字列を検索クエリに変換するために、まず受け取ったクエリ文字列をトークンという単位に分割し、トークンの列を種類ごとに幾つかのグループに分け、最終的にそのグループ達を木構造に並び替えて検索クエリに変換する、ということをやります。例えば「a tag:b OR c -d」というクエリ文字列を受け取ったときは、以下のように5つのトークンに分割します。

a tag:b OR c -d

^ ^^^^^ ^^ ^ ^^
| | | | |
| | | | `-- dに一致しない
| | | `---- cに一致する
| | `------- 左辺または右辺の条件が満たされている
| `----------- bというタグに一致する
`--------------- aに一致する

qiita-elasticsearchでは String#scan と正規表現を使って文字列からトークンの配列に変換しています。単純化すると以下のようなコードになります。

class Tokenizer

DEFAULT_FILTERABLE_FIELDS = []

TOKEN_PATTERN = /
(?<token_string>
(?<minus>-)?
(?:(?<field_name>\w+):)?
(?:
(?:"(?<quoted_term>.*?)(?<!\\)")
|
(?<term>\S+)
)
)
/x

def tokenize(query_string)
query_string.scan(TOKEN_PATTERN).map do |token_string, minus, field_name, quoted_term, term|
Token.new(
field_name: field_name,
minus: minus,
quoted_term: quoted_term,
term: term,
token_string: token_string,
)
end
end
end

使うときはこんな感じで使います。

tokenizer = Tokenizer.new

tokens = tokenizer.tokenize("a tag:b OR c -d")


OrSeparatableNode

Tokenizerを利用してトークンの配列を生成したあと、これらをグループに分け検索クエリに変換します。まずORトークンに着目して、ORトークンの前後でトークンを分けていきます。「a tag:b OR c -d」というクエリ文字列であれば、「aとtag:b」「cと-d」というグループに分け、Bool Queryのshould clauseを利用し、それぞれのグループのいずれかが満たされるものが一致するようなクエリをつくります。

tokenizer = Tokenizer.new

tokens = tokenizer.tokenize("a tag:b OR c -d")
OrSeparatableNode.new(tokens).to_hash
#=> {
# "bool" => {
# "should" => [
# ..., // 「aとtag:b」用のクエリ
# ... // 「cと-d」用のクエリ
# ]
# }
# }

細かい調整として、もしORトークンで分割したグループが1つしかなければ、Bool Queryではなく単純な1つのクエリを出力するようにします。また「a OR」のようなクエリ文字列が与えられた場合にORの部分を無視するために、ORトークンで分割してトークンが1つも存在しないグループは無視します。Bool Queryになるかもしれないし、ならないかもしれない、という意味を込めてOrSeparatableという名前にしました。qiita-elasticsearchでは or_separatable_node.rb がこの部分の処理を担当しています。


FilterableNode

OrSeparatableNodeでトークンをグループに分割したあと、今度はそれぞれのグループを適切なクエリに変換する必要があります。個々のグループ内のトークンは、必須の条件を表すトークンと、そうではない (=一致するとより嬉しい) 条件を表すトークンに分けられます。そこで、Elasticsearchの Filtered Query を利用してこれらの条件を表現することにします。必須の条件というのは指定されているときには必ず満たしてほしい種類のもので、「-a」や「tag:a」のようなトークンがこれに該当します。一方「a b」のようなクエリ文字列で検索したときは、aとbの両方に一致すればより嬉しいが、片方に一致するだけでもまあ良しという扱いにし、必須ではない条件として扱うことにします。

FilterableNode.new(tokens).to_hash

#=> {
# "filtered" => {
# "filter" => {
# "bool" => {
# "must" => ... // 「tag:b」などのトークン用のクエリ
# "must_not" => ... // 「-b」などのトークン用のクエリ
# }
# },
# "query" => ... // 「c」などのトークン用のクエリ
# }
# }

OrSeparatableNodeと同様、フィルター用のトークンが1つも含まれていない場合はFiltered Queryではなく、より単純なクエリを生成します。qiita-elasticsearchでは、この部分の処理は filterable_node.rb が担当しています。


QueryBuilder

後は個々のトークンを適切な形に変換していく処理が続くので省略しますが、上記のような流れでトークン列をクエリに変換していくと、最終的に検索クエリが得られます。実際には、説明した以外にも Match QueryMulti Match QueryTerm QueryPrefix Query などを組み合わせて検索クエリを表現しています。qiita-elasticsearchでは、クエリ文字列をトークンに分割する処理と検索クエリを組み立てる処理をまとめてQueryBuilderというクラスで担当しています。単純な例では、以下のような使い方になります。コード上では query_builder.rb がこの部分の処理を担当しています。

query_builder = QueryBuilder.new

query_builder.build("a")
#=> {"match"=>{"_all"=>"a"}}


おわり

以上、increments/qiita-elasticsearch を題材にしながら、クエリ文字列をElasticsearch用の検索クエリに変換する方法について簡単に説明しました。qiita-elasticsearchはまだまだ発展途上ではありますが、単純な仕組みで実現されているので、Elasticsearchの学習に役立てていければ幸いです。