投稿日:2026/7/12
更新日:2026/7/22

Rails で OpenSearch を使うとき(searchkick gem 前提)の話。Model.search はどこで定義され、誰が OpenSearch と通信し、
誰がヒット結果を ActiveRecord レコードに戻しているのか(hydrate)を整理したメモ。
関連: OpenSearchとActiveRecordの利用用途の違い(検索フロー全体)、
インデックスという用語の混同(RDB vs OpenSearch)(用語の整理)
Model.search はアプリのコードに存在しない(メタプログラミング)search メソッドは grep しても見つからないことがある。アプリに定義がなく、
searchkick gem が実行時に動的に定義しているため。
出発点はモデルの searchkick 宣言:
class Product < ApplicationRecord
searchkick language: 'japanese', callbacks: :async
end
この searchkick ... は設定に見えるが実体は gem のクラスメソッド呼び出しで、
実行されるとそのモデルに search(実体は searchkick_search の alias)を定義する:
# searchkick gem 内部(lib/searchkick/model.rb, 簡略化)
def searchkick(**options)
class << self
def searchkick_search(term = '*', **options, &block)
Searchkick.search(term, model: self, **options, &block)
end
alias_method Searchkick.search_method_name, :searchkick_search # ← :search
end
end
つまり: Rails 起動 → モデル読み込み → searchkick ... 実行 →
その場で Product.search が生える(メモリ上にだけ存在)。
| grep で見つからないメソッド | 生やしている宣言 |
|---|---|
Product.search |
searchkick ... |
product.reviews |
has_many :reviews |
product.status_published? |
enum :status, ... |
# rails console で
Product.method(:search).source_location
# => ["/usr/local/bundle/gems/searchkick-6.0.3/lib/searchkick/model.rb", ...]
Product.method(:search).owner
source_location が gem のパスを返したら「メタプログラミングで生えたやつ」確定。Gemfile.lock でバージョンを確認して GitHub でソースを読む。
# Gemfile
gem 'searchkick', '~> 6.0' # 高レベル: Rails モデルとの統合
gem 'opensearch-ruby', '~> 3.4' # 低レベル: OpenSearch への HTTP クライアント
Product.search(body:) / product.reindex
↓
searchkick(Rails との通訳層)
search を生やす / 保存時の同期 / index名管理 / ヒットID→AR の hydrate
↓
opensearch-ruby(純粋な HTTP クライアント。内部は faraday)
POST /products_xxx/_search を実際に送る係
↓
OpenSearch サーバー
例えると:
searchkick はもともと Elasticsearch 用で、Elasticsearch / OpenSearch 両対応。
どちらに繋ぐかは組み合わせるクライアント gem と接続先 URL(環境変数)で決まる。
ヒット結果を AR レコードへ戻す処理は、アプリに1行も書かれていない。全部 searchkick の内部。
【アプリのコード(自分たちが書いた)】
クエリ組み立て … Query DSL を組む
呼び出し … Model.search(body:) を呼ぶ(予約票を作る。まだ通信しない)
│
▼ ここから先は gem の世界
【searchkick(gem)の中】
① opensearch-ruby 経由で HTTP POST(検索実行)
② レスポンスから hit の ID を抜き出す
③ Model.where(id: [...]) を発行 ← ★OpenSearch→AR はここ(gem内)
④ OpenSearch の並び順に整列し直す
⑤ AR レコードの配列として返す
│
▼
【アプリのコード】
返ってきた AR レコードを使う(表示・シリアライズ・GraphQL フィールド解決など)
Model.search(body:) は実行ではなく「予約」search は Searchkick::Relation(遅延評価オブジェクト)を返すだけで、
その行では OpenSearch に通信しない。実際に HTTP が飛ぶのは
結果を列挙・カウントするなど「中身が必要になった瞬間」に1回だけ。
AR の where が SQL を即発行しないのと同じ設計。
hydrate を自前でやる場合に必要なもの:
ID 抽出 / where(id:) / 並び順維持 / インデックスと DB のズレ(存在しないID)処理。
searchkick を使っている限り、アプリの責務は2つだけ:
「どうやって AR に戻すか」は考えなくてよい。
(他言語・他フレームワークで searchkick 相当の糊がないと、この hydrate を手書きする羽目になる、の裏返し。)
なお、hydrate はデフォルト動作。パフォーマンス要件が厳しい場合や
OpenSearch に入っているデータだけで足りる場合は、load: false を指定して
RDB への問い合わせ(③〜⑤)自体をスキップできる。この場合は AR レコードではなく
OpenSearch のドキュメント(Hash 相当)がそのまま返る。
Model.search は searchkick が実行時に生やすメソッド(アプリに定義なし)Model.search は遅延評価。実際の通信は結果が必要になった瞬間に1回だけmethod(:名前).source_location で追う