YASD-TECH
YASD TECH
# Rails

searchkickのレイヤー構造とOpenSearchからActiveRecordへの境界

投稿日:2026/7/12

更新日:2026/7/22

ttitleImage

searchkick のレイヤー構造と「OpenSearch → ActiveRecord」の境界

Rails で OpenSearch を使うとき(searchkick gem 前提)の話。
Model.search はどこで定義され、誰が OpenSearch と通信し、
誰がヒット結果を ActiveRecord レコードに戻しているのか(hydrate)を整理したメモ。
関連: OpenSearchとActiveRecordの利用用途の違い(検索フロー全体)、
インデックスという用語の混同(RDB vs OpenSearch)(用語の整理)


1. 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 が生える(メモリ上にだけ存在)。

Rails でよくある同型パターン

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 でソースを読む。


2. OpenSearch と通信しているのは誰か(2段構成)

# 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 サーバー

例えると:

  • opensearch-ruby = 電話回線(つなぐだけ。何を話すかは知らない)
  • searchkick = 通訳兼秘書(Rails の言葉 ⇄ OpenSearch の言葉、
    かけ先の管理、話した結果を AR レコードにして持ってくる)

searchkick はもともと Elasticsearch 用で、Elasticsearch / OpenSearch 両対応。
どちらに繋ぐかは組み合わせるクライアント gem と接続先 URL(環境変数)で決まる。


3. 「OpenSearch → ActiveRecord」(hydrate)はアプリのコードではやっていない

ヒット結果を 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:) は実行ではなく「予約」

searchSearchkick::Relation(遅延評価オブジェクト)を返すだけで、
その行では OpenSearch に通信しない。実際に HTTP が飛ぶのは
結果を列挙・カウントするなど「中身が必要になった瞬間」に1回だけ。
AR の where が SQL を即発行しないのと同じ設計。


4. この分担の嬉しさ

hydrate を自前でやる場合に必要なもの:
ID 抽出 / where(id:) / 並び順維持 / インデックスと DB のズレ(存在しないID)処理。

searchkick を使っている限り、アプリの責務は2つだけ:

  1. 何を検索するか(Query DSL の組み立て)
  2. いつ実行するか(結果を評価するタイミング)

「どうやって AR に戻すか」は考えなくてよい。
(他言語・他フレームワークで searchkick 相当の糊がないと、この hydrate を手書きする羽目になる、の裏返し。)

なお、hydrate はデフォルト動作。パフォーマンス要件が厳しい場合や
OpenSearch に入っているデータだけで足りる場合は、load: false を指定して
RDB への問い合わせ(③〜⑤)自体をスキップできる。この場合は AR レコードではなく
OpenSearch のドキュメント(Hash 相当)がそのまま返る。


5. まとめ

  • Model.search は searchkick が実行時に生やすメソッド(アプリに定義なし)
  • OpenSearch と HTTP で話すのは opensearch-ruby、その上の通訳が searchkick
  • OpenSearch → ActiveRecord の hydrate はアプリではやっていない。searchkick の内部処理
  • Model.search は遅延評価。実際の通信は結果が必要になった瞬間に1回だけ
  • 見つからないメソッドは method(:名前).source_location で追う

Index

  • searchkick のレイヤー構造と「OpenSearch → ActiveRecord」の境界
  • 1. Model.search はアプリのコードに存在しない(メタプログラミング)
  • Rails でよくある同型パターン
  • 見つからないメソッドの正体を突き止める方法(定石)
  • 2. OpenSearch と通信しているのは誰か(2段構成)
  • 3. 「OpenSearch → ActiveRecord」(hydrate)はアプリのコードではやっていない
  • 補足: Model.search(body:) は実行ではなく「予約」
  • 4. この分担の嬉しさ
  • 5. まとめ