searchkickのコードリーディング

普段、ElasticsearchとDBの関連付け兼Elasticsearchのクライアントとして searchkickをよく使っている。

searchkickは便利で、

Model.search('term', options).results

のようにメソッドを呼ぶだけで、Elasticsearchに検索をかけて、取得したドキュメントの_idフィールドを元にDBに検索をかける。その結果をModel オブジェクトの配列として受け取ることができる。

searchkickはoptionに並び並び順を指定することができる。 例えば

Model.search('term', order: { hoge: :desc }).results

といった感じに指定すれば、最終的に得られるModelオブジェクトの配列は hoge の降順に整列されている。

そこで少し疑問に思ったことがあった。searchkickが発行するsqlにはorder byなどは指定されていなかった。

Elasticsearchに検索でパフォーマンスを出すために非正規化されたデータが入っているので order by column はできないにせよ、 order by field(id, ids)のようなsqlが発行されるのだろうと思っていた。

少し気になったのでどうやってソートしているのかsearchkickのコードを読んでみた。

searchkickがelasticsearchへの検索結果を元にDBにクエリを投げるのは resultsメソッドなのでそのあたり読んだ。 resultsメソッドが定義されているのはこの辺

まず

hits.group_by { |hit, _| hit["_type"] }.each do |type, grouped_hits|
  results[type] = results_query(type.camelize.constantize, grouped_hits).to_a.index_by { |r| r.id.to_s }
end

でDBに問い合わせを行い、その結果をresults変数に以下のような形式で格納している

{
  type: {
    id1: model_object1,
    id2: model_object2
  }
}

(id*は対になっているmodel_objectが保持しているrecordのidです。)

ここではまだソートは行っていない。コードを追っていくとModel.where(id: ids) を呼んでいるだけだ。

その下の方にご丁寧に sortとと書かれたコメントとともに以下のような処理が書かれている

hits.map do |hit|
  result = results[hit["_type"]][hit["_id"].to_s]
  if result && !(options[:load].is_a?(Hash) && options[:load][:dumpable])
    unless result.respond_to?(:search_hit)
      result.define_singleton_method(:search_hit) do
        hit
      end
     end

     if hit["highlight"] && !result.respond_to?(:search_highlights)
       highlights = Hash[hit["highlight"].map { |k, v| [(options[:json] ? k : k.sub(/\.#{@options[:match_suffix]}\z/, "")).to_sym, v.first] }]
         result.define_singleton_method(:search_highlights) do
           highlights
         end
     end
  end
  result
end.compact

なんか長々と書かれているが、大半はoptionによってmethodを定義するみたいなやつなので純粋にソートに関する部分を切り出したら以下のようになる

hits.map do |hit|
  results[hit["_type"]][hit["_id"].to_s]
end.compact

めちゃくちゃシンプルで、Elasticsearchへの検索の結果からどの順番でソートすれば良いかは分かっているので、DBへの問い合わせ結果をその順序で抜き出して配列にしているだけ。 下手にorder by field(id, ids) みたいなクエリでもindexでソートを解決できずにfilesortが発生することもあるが、この方法だとO(n)のコストでソートできるので、不特定多数の人に使われるgemと考えると、DBでソートせずrubyでソートするのが正しいのだろうと思った。

また、order by field(id, ids) をしているクエリでfilesortが発生したらsearchkickのようにruby側でソートするのも手だと思った。 gem のコードたまにしか読まないけど、読むと毎回何かしらの学びがある。