1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Rails + Elasticsearchを使って日本語全文検索のサンプルアプリを実装してみた

1
Posted at

はじめに

RailsアプリにElasticsearchを導入し、日本語全文検索を実装してみました。

本記事では、elasticsearch-model gemを使ったモデルの統合から、Kuromojiアナライザーの設定、multi_matchクエリによる全文検索の実装まで、実際に動くサンプルコードを参照しながら解説します。

サンプルコードはGitHubで公開しています。

Elasticsearch とは

Elasticsearchは、全文検索に特化した分散型検索エンジンです。REST APIで操作でき、JSONドキュメントを「インデックス」という単位で管理します。
RailsアプリとElasticsearchの関係はシンプルです。DBに保存したデータをElasticsearchにも登録(インデックス)しておき、検索時はDBではなくElasticsearchに問い合わせます。

全文検索にElasticsearchを使う理由

RailsアプリでLIKE検索の代わりにElasticsearchを使う理由は主に2つあると考えます。

1. 形態素解析による検索精度の向上

「機械学習」と入力すると「機械」「学習」に分解されるため、どちらかの単語を含む記事もヒットします。また「走る」と検索すれば「走った」「走り」を含む記事も対象になります。活用形や複合語の揺れを意識せずに検索できるのがKuromojiの強みです。

2. スコアリング

マッチするかどうかの判定だけでなく、検索語との関連度をスコアとして算出し、関連性の高い結果を上位に表示できます。検索キーワードが多く含まれる記事や、タイトルに含まれる記事が上位に来やすくなるため、ユーザーが求める記事に辿り着きやすくなります。

また、検索処理を Elasticsearch に委ねることでDBへの負荷を分散できる点も利点のひとつです。

Elasticsearch の挙動

実際にcurlで操作するとイメージが掴みやすいです。

ドキュメントの登録

curl -X PUT "localhost:9200/articles/_doc/3" -H "Content-Type: application/json" -d '{
  "title": "Elasticsearchの全文検索入門",
  "body": "Elasticsearchは分散型の全文検索エンジンです。",
  "published_at": "2026-01-20"
}'

全文検索

curl -X GET "localhost:9200/articles/_search" -H "Content-Type: application/json" -d '{
  "query": {
    "multi_match": {
      "query": "検索",
      "fields": ["title", "body"]
    }
  }
}'

multi_matchクエリは、複数フィールドを横断して検索できるクエリです。ここではtitlebodyの両方を対象に「検索」を検索しています。

レスポンスの読み方

{
  "took": 13,
  "hits": {
    "total": { "value": 2, "relation": "eq" },
    "max_score": 2.5739589,
    "hits": [
      {
        "_index": "articles",
        "_id": "3",
        "_score": 2.5739589,
        "_source": {
          "title": "Elasticsearchの全文検索入門",
          "body": "Elasticsearchは分散型の全文検索エンジンです。...",
          "published_at": "2026-01-20T14:00:00.000Z"
        }
      },
      {
        "_index": "articles",
        "_id": "10",
        "_score": 1.4485857,
        "_source": {
          "title": "PostgreSQLとMySQLの違いを比較する",
          "body": "PostgreSQLとMySQLはどちらも人気のオープンソースRDBMSですが、...",
          "published_at": "2026-03-10T15:00:00.000Z"
        }
      }
    ]
  }
}

主要なフィールドは以下の通りです。

  • took: 検索にかかった時間(ミリ秒)
  • hits.total.value: ヒット件数
  • hits.max_score: 最高スコア
  • hits.hits[]._score: 各ドキュメントの関連度スコア
  • hits.hits[]._source: 登録したドキュメントの内容

_scoreに注目すると、「検索」というキーワードがタイトルに含まれる1件目が2.5739589、本文にのみ含まれる2件目が1.4485857となっており、タイトル一致のほうが高いスコアになっているのが分かります。Elasticsearchはこのスコアの降順で結果を返すため、関連度の高い記事が自然に上位に表示されます。

実装してみた環境の構成

本記事のサンプルアプリの技術スタックは以下の通りです。

カテゴリ バージョン
Ruby 3.3.11
Rails 8.1.3
MySQL 8.0
Elasticsearch 8.17.4

Docker Composeで3つのサービス(rails / mysql / elasticsearch)を起動します。Elasticsearch部分を抜粋すると以下のようになっています。

services:
  rails:
    depends_on:
      mysql:
        condition: service_healthy
      elasticsearch:
        condition: service_healthy
    environment:
      ELASTICSEARCH_URL: http://elasticsearch:9200

  elasticsearch:
    build:
      context: .
      dockerfile: Dockerfile.elasticsearch
    ports:
      - '9200:9200'
    environment:
      - discovery.type=single-node
      - xpack.security.enabled=false
    healthcheck:
      test:
        [
          'CMD-SHELL',
          "curl -sf http://localhost:9200/_cluster/health | grep -q '\"status\":\"green\"\\|\"status\":\"yellow\"'",
        ]
      start_period: 60s

railsサービスはmysqlelasticsearchの両方が起動してから立ち上がるよう、depends_onでヘルスチェックを設定しています。Elasticsearchは起動に時間がかかるため、start_period: 60sを設けています。また、サンプル環境のためxpack.security.enabled=falseで認証を無効化し、discovery.type=single-nodeで単一ノード構成にしています。

KuromojiのインストールはDockerfileで自動化

日本語形態素解析にはanalysis-kuromojiプラグインが必要です。

英語は単語がスペースで区切られているため、そのままトークン(検索の最小単位)に分割できます。一方、日本語にはスペースがないため、「機械学習入門」を「機械」「学習」「入門」のように単語単位に分割するには形態素解析が必要です。Kuromojiはこの分割を行うElasticsearch用のプラグインです。

Elasticsearchの公式イメージをベースに、ビルド時に自動インストールするDockerfileを用意しました。

FROM docker.elastic.co/elasticsearch/elasticsearch:8.17.4

RUN elasticsearch-plugin install --batch analysis-kuromoji

--batchオプションを付けることで、インタラクティブな確認プロンプトをスキップしてインストールできます。

Elasticsearchとの接続設定

elasticsearch-modelelasticsearch-railsをGemfileに追加します。

gem "elasticsearch-model"
gem "elasticsearch-rails"

elasticsearch-modelがモデルへのElasticsearch統合機能を、elasticsearch-railsがRailsとの連携(Railtieなど)を提供します。

クライアントの初期化

config/initializers/elasticsearch.rbでクライアントを設定します。

Elasticsearch::Client.new(
  url: ENV.fetch("ELASTICSEARCH_URL") { "http://localhost:9200" },
  log: Rails.env.development?
).tap do |client|
  Elasticsearch::Model.client = client
end

ENV.fetch("ELASTICSEARCH_URL")で接続先URLを環境変数から取得します。環境変数が未設定の場合はhttp://localhost:9200をデフォルト値として使用します。

Docker Compose環境ではELASTICSEARCH_URL: http://elasticsearch:9200を設定しているため、コンテナ名でElasticsearchに接続します。log: Rails.env.development?を指定することで、開発環境ではElasticsearchへのリクエストログをコンソールに出力します。

モデルにElasticsearchを統合する

Articleモデルにinclude Elasticsearch::Modelを追加することで、インデックスの作成・更新・削除や検索といった機能が使えるようになります。

マッピングの定義

インデックスのフィールド定義(マッピング)を設定します。

settings index: { number_of_shards: 1 } do
  mappings dynamic: false do
    indexes :title, type: :text, analyzer: "kuromoji"
    indexes :body,  type: :text, analyzer: "kuromoji"
    indexes :published_at, type: :date
  end
end

number_of_shards: 1はインデックスを1つのシャードで構成する指定です。サンプルアプリでは単一ノード構成なので最小値の1にしていますが、本番環境ではデータ量やノード数に応じて調整します。

dynamic: falseを指定すると、マッピングに定義していないフィールドを自動的にインデックスしません。意図しないフィールドが登録されるのを防ぐために設定しています。

titlebodyにはanalyzer: "kuromoji"を指定しています。これにより、ドキュメント登録時と検索時の両方でKuromojiによる形態素解析が適用されます。

DBとElasticsearch自動同期

after_commitコールバックを使って、DBへの変更をElasticsearchに自動反映します。

after_commit on: [:create, :update] do
  __elasticsearch__.index_document
rescue StandardError => e
  Rails.logger.error "Elasticsearch index_document failed: #{e.message}"
end

after_commit on: :destroy do
  __elasticsearch__.delete_document
rescue StandardError => e
  Rails.logger.error "Elasticsearch delete_document failed: #{e.message}"
end

after_saveではなくafter_commitを使うのは、トランザクションがコミットされた後に実行することで、DBとElasticsearchの不整合を防ぐためです。Elasticsearchへの操作が失敗した場合はログに記録し、例外をキャッチすることでアプリの動作に影響を与えないようにしています。

サンプルコードでは簡略化のためafter_commit内で直接更新していますが、Elasticsearchへの通信が遅延・失敗した場合でもユーザーの操作を止めないよう、本番環境ではSidekiqやSolid Queueなどを用いてActiveJob経由で非同期に実行することを推奨します。

インデックス対象フィールドの絞り込み

as_indexed_jsonでElasticsearchに登録するフィールドを明示します。

def as_indexed_json(_options = {})
  as_json(only: %i[title body published_at])
end

このメソッドを定義しない場合、モデルのすべての属性がインデックスされます。onlyで必要なフィールドだけを指定することで、不要なデータの登録を防いでいます。

検索機能の実装

検索処理はArticleモデルのsearch_by_keywordクラスメソッドに集約しています。

def search_by_keyword(keyword)
  keyword = keyword.to_s.strip
  return { total: 0, articles: [] } if keyword.empty?

  response = search(multi_match_query(keyword))
  total = response.response["hits"]["total"]["value"]
  articles = response.results.map { |hit| build_search_result(hit._source) }
  { total: total, articles: articles }
end

キーワードが空の場合は早期リターンし、Elasticsearchへのリクエストを発生させません。

multi_matchクエリ

def multi_match_query(keyword)
  {
    query: {
      multi_match: {
        query: keyword,
        fields: ["title", "body"]
      }
    }
  }
end

multi_matchクエリは複数フィールドを横断して全文検索するクエリです。titlebodyの両方にKuromojiアナライザーが適用されているため、「機械学習」と入力すると「機械」「学習」に分解されて検索されます。

コントローラでのHTML/JSON対応

コントローラの正常系の処理は以下の通りです。

def search
  @keyword = params[:q].to_s.strip
  result = Article.search_by_keyword(@keyword)
  @total_count = result[:total]
  @results = result[:articles]

  respond_to do |format|
    format.html
    format.json { render json: { articles: @results } }
  end
end

respond_toでHTML・JSONの両形式に対応しています。Rails標準の仕組みなので、同じエンドポイントで画面表示とAPIレスポンスを両立できます。

エラーハンドリング

Elasticsearchへの接続が失敗した場合に備えてrescueを追加しています。

rescue Faraday::ConnectionFailed, Faraday::TimeoutError, Elastic::Transport::Transport::Error => e
  Rails.logger.error "Elasticsearch search failed: #{e.class} - #{e.message}"
  respond_to do |format|
    format.html do
      @results = []
      @total_count = 0
      @error_message = "検索サービスが現在利用できません。しばらくしてからお試しください。"
      render :search, status: :service_unavailable
    end
    format.json { render json: { error: "Search service is currently unavailable" }, status: :service_unavailable }
  end
end

Elasticsearch側のダウンやネットワーク障害など、外部サービス由来の例外をキャッチし、HTTPステータス503 Service Unavailableを返します。HTMLでは画面にエラーメッセージを、JSONではエラーレスポンスを返すことで、画面利用者とAPI利用者の両方に適切なレスポンスを返せます。

動作確認・インデックス再構築

以下の手順でアプリを起動できます。

# コンテナ起動
docker compose up -d

# データベース作成・マイグレーション
docker compose exec rails bin/rails db:create db:migrate

# シードデータ投入
docker compose exec rails bin/rails db:seed

# Elasticsearchインデックス作成・データ投入
docker compose exec rails bin/rails elasticsearch:reindex

reindexタスクの役割

rails elasticsearch:reindexは以下の処理を行います。

task reindex: :environment do
  Article.__elasticsearch__.create_index!(force: true)
  Article.__elasticsearch__.import
end

create_index!(force: true)で既存のインデックスを削除して再作成します。importでDBの全レコードをElasticsearchに一括登録します。

初回セットアップ時の初期データ投入だけでなく、マッピングを変更したときの再構築にも使えます。

セットアップ完了後、http://localhost:3000/articles/searchにアクセスして検索UIから動作を確認できます。

おわりに

今回はRails + Elasticsearchを使って日本語全文検索を実装しました。

elasticsearch-model gemを使うことで、マッピング定義・自動同期・検索クエリの実行をモデルに集約でき、Railsらしい書き方でElasticsearchを扱えることが実感できました。Kuromojiを組み合わせることで、日本語の検索精度を高める基盤が比較的少ない設定で整えられました。

参考リンク

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?