Qiitaがquery-stringっぽい構文を自前実装した理由

これは [Elasticsearch Advent Calendar 2014](http://qiita.com/advent-calendar/2014/elasticsearch) 15日目の記事です。

今秋、Qiitaの検索システムが刷新されました。

- [Qiita/Qiita:Teamの検索システムがパワーアップしました - Qiita Blog](http://blog.qiita.com/post/101162528979/new-qiita-search)

ブログ記事の中でも簡単に紹介していますが、例えば [title:"elasticsearch 入門"](http://qiita.com/search?utf8=%E2%9C%93&sort=rel&q=title%3A%22elasticsearch+%E5%85%A5%E9%96%80%22) と検索すると、タイトルに "elasticsearch" と "入門" を単語を含んだ記事を検索できたり、他にも OR が使えたり、マイナス検索ができたりします。

一見すると query string query でも使ってるみたいですが実際はそんなことはなく、泥臭く検索文字列をその都度解析し、生成したクエリをElasticsearchに投げています。この記事では、なぜ query string query を使わずに自分で書いたのかという話と、公開しているgemの紹介をしたいと思います。

# ユーザ入力からクエリを作るのは大変

Elasticsarchで検索するにはJSONでクエリを作らなければいけません。特定のfieldに対する文字列検索をするだけのような単純なクエリであれば動的に作ることも容易ですが、複数のfieldにまたがるような複雑なクエリを作ろうと思うとなかなか大変です。例えば「yuku_tが書いたelasticsearchタグのつけられた記事でタイトルにrubyを含まないもの」というクエリはこのように表現できます（もちろんインデックスの形式次第です）が、動的に組み立てるのはそれなりに大変です。

```json
{
    "filtered": {
        "filter": {
            "term": { "tag": "elasticsearch" }
        },
        "query": {
            "bool": {
                "must": [
                    { "match": { "user": "yuku_t" } }
                ],
                "must_not": [
                    { "match": { "title": "ruby" } }
                ]
            }
        }
    }
}
```

# (simple) query string query

こういう場面で便利なのが[query string query](http://www.elasticsearch.org/guide/en/elasticsearch/reference/current/query-dsl-query-string-query.html)（と[simple query string query](http://www.elasticsearch.org/guide/en/elasticsearch/reference/current/query-dsl-simple-query-string-query.html)）です。これを使うと先ほどのクエリはこのように書けます。

```json
{
    "query_string": {
        "query": "user:yuku_t -title:ruby tag:elasticsearch"
    }
}
```

プログラムから作るのも実に簡単です。Rubyであればこんな具合です。

```rb
query = { query_string: { query: params[:query] } }
```

しかしquery string queryを使う場合はいくつか気をつけなければならないことがあります。

## query string queryを使う際に注意すべきこと

### 全てのfieldが検索対象になる

システムが使うためのメタデータがインデックスに含まれていることがあります。例えば、Qiitaには限定共有という機能があり、限定共有が有効になっている記事は通常の検索結果には出てきません。しかし、検索しているのがログインユーザだった場合は、そのユーザが投稿した限定共有記事も検索対象に含めるようになっています。この機能を実現するために、インデックスには限定共有かどうかのフラグが格納されています。

先ほどのRubyコードのように何も考えずにquery string queryを使ってしまうと、ユーザがこのような隠しfieldにアクセスできてしまいます。query string query にはデフォルトで検索対象になるフィールドを指定する機能がありますが、隠しfieldを直接指定されたらそれを防ぐ手立てはありません。そのため事前に許可していないfieldへのクエリが含まれていないかを確かめる必要があります。

### どこまでも複雑なクエリを作れてしまう

もう一つ見逃せない問題として、ユーザの入力を馬鹿正直にquery stringに渡してしまうと、おそろしく重たいクエリを投げられてElasticsearchが落ちる危険がある、ということです。どの程度のクエリを投げられると厳しいのか、そもそもElasticsearchのレスポンスはクエリの複雑さにどの程度影響されるのか、などの点について真剣な検証は行っていませんがanonymous userからの検索クエリをそのまま渡すのは危険だろうと判断しました。

## query string queryは社内システム向け

以上の事情を踏まえて考えるとquery string queryは隠しfieldが無く、利用者の中に異常なクエリを投げる人がいない（と期待できる）システム、つまり社内システムの検索インタフェースとして用いるのがよいのだろうと思います。もしくは中の人だけが使えるインタフェースの内部で使うのはいいかも知れません。

# 要件

というわけで、長々とquery string queryの話をしてきましたが、Qiitaではそれは採用せず、自前でユーザの検索文字列を解析してElasticsearchの検索クエリを組み立てることにしました。要件としてはこのようなものです。

-   許可したfieldだけを指定できるようにしたい

    上で書いたようにインデックスに含まれている隠しfieldへの検索ができないようにしなければいけません。

-   スコアリングに使うfieldとフィルタリングに使うfieldを区別したい

    本文への検索は必ずしもマッチしている必要がない場面もありますが、タグによる検索は必ずそのタグがつけられた記事だけが返ってくるべきです。

-   ORやマイナス検索はサポートしたいが、必要以上に複雑なものはいらない

    99.9%の検索をサポートできるだけの機能が必要で、極一部の極端な検索意図には我慢してもらいましょう。

-   デフォルトの検索対象を指定できる

    mappingで_allを指定するのを忘れると、意図せず隠しfieldで検索ができてしまいます。mappingは滅多に操作しないですが、その分暗黙的な了解を忘れてしまいそうだったので、アプリケーションの側で指定しておきたいです。

## es-query-builder

- [increments/es-query-builder · GitHub](https://github.com/increments/es-query-builder)

そんなこんなで検索システム入れ替え時に実装したものは、GitHub上で公開されています。これを作ったモチベーションなどは上で書いた通りなので、あとは簡単な使い方を紹介します。

```rb
builder = EsQueryBuilder.new(
  query_fields: ['title', 'user'],
  filter_fields: ['tag'],
  all_query_fields: ['title', 'body']
)
query = builder.build(params[:query])
```

こんな感じで使います。 `query_fields` はスコアリングに関係するfieldを指定します。 `filter_fields` はフィルタリングに指定するものです。

```rb
builder.build('user:yuku_t -title:ruby tag:elasticsearch')
#=> {:filtered=>
#   {:query=>
#     {:bool=>
#       {:must=>[{:match=>{"user"=>"yuku_t"}}],
#        :must_not=>[{:match=>{"title"=>"ruby"}}]}},
#      :filter=>{:term=>{"tag"=>"elasticsearch"}}}}
```

fieldが指定されていない場合は `all_query_fields` で指定されたものが使われます（デフォルトで `_all`）

```rb
builder.build('query')
#=> {:multi_match=>{:fields=>["title", "body"], :query=>"query"}}
```

クエリはquery string queryのようにfieldを受け取ることができますが、 `query_fields` と `filter_fields` で指定されたもの以外だった場合は、通常の文字列クエリとして扱われます。

```rb
builder.build("unknown:query") # builder.build("query") として扱われる
#=> {:multi_match=>{:fields=>["title", "body"], :query=>"query"}}
```

最低限の機能としてORが実装されています。

```rb
builder.build('title:hello OR world')
#=> {:bool=>
#  {:should=>
#    [{:match=>{"title"=>"hello"}},
#     {:multi_match=>{:fields=>["title", "body"], :query=>"world"}}]}}
```

### アプリ固有のフィルタを追加する

es-query-builderはユーザの入力した文字列をクエリに変換するためのものです。限定共有のフィルタリングはどうするかというと、作られたクエリの外側に `filtered` を追加します。

```rb
query = {
  filtered: {
    query: builder.build(params[:param]),
    filter: {
      # 限定共有だけになるようにフィルタリングする
    }
  }
}
```

# おわりに

とまぁこんな感じのツールを使ってQiitaではユーザが入力した検索文字列をクエリに変換していますよ、というお話でした。この他にも工夫した点は色々ありますが、それについてはまたの機会に書こうと思います。明日は @yuzuki さんです。お楽しみに