mybook

Kata: 検索エンジン — 全文検索の実装

課題の提示

「タクミさん、SQLで検索は書いたことある?」

「はい。LIKE '%keyword%' で。」

ナオミは静かに笑った。「データが100万件になったらどうなる?」


Kata 7: コンテンツ検索プラットフォーム

ドキュメント管理システムの検索機能を強化したい。

  • ドキュメント数: 100万件
  • 検索レスポンスタイム: 100ms以下
  • 全文検索(タイトル・本文・メタデータ)
  • 日本語検索(形態素解析)
  • ファセット検索(カテゴリ・日付・作成者でフィルタ)
  • 検索ランキング(関連度、更新日時、閲覧数で重み付け)
  • オートコンプリート(入力途中の候補表示)
  • 同義語・表記揺れの対応

LIKE '%キーワード%' の何が問題ですか?」とタクミが聞いた。

「3つある。1つ目はパフォーマンス。インデックスが使えない前方一致でなければ。2つ目は日本語。単語境界がない。3つ目はランキング。全部同じ扱いになる」

「PostgreSQLの全文検索では?」

「限界がある。本格的な検索ならElasticsearchかOpenSearch。判断の基準を学ぼう」


設計判断

検索技術の選択

Loading diagram...
機能LIKEpg_trgmElasticsearch
パフォーマンス
日本語形態素解析
ファセット検索
関連度スコアリング
オートコンプリート

判断: Elasticsearch / OpenSearch を採用。AWS では OpenSearch Service を使う。

INFO

OpenSearch は Elasticsearch 7.x のオープンソースフォーク。AWS OpenSearch Service として提供され、Kibana相当の OpenSearch Dashboards が付属する。API互換性が高く、elasticsearch-rails gemが使える。


実装

インデックスの設計

# app/models/document.rb
class Document < ApplicationRecord
  include Elasticsearch::Model
  include Elasticsearch::Model::Callbacks  # DB変更を自動同期
 
  belongs_to :user
  belongs_to :category
 
  # インデックスのマッピング定義
  settings do
    mappings dynamic: false do
      indexes :title, type: :text, analyzer: :kuromoji_analyzer do
        indexes :keyword, type: :keyword  # 完全一致・ソート用
        indexes :suggest, type: :completion  # オートコンプリート用
      end
      indexes :body, type: :text, analyzer: :kuromoji_analyzer
      indexes :category_id, type: :keyword
      indexes :author_id, type: :keyword
      indexes :tags, type: :keyword
      indexes :view_count, type: :integer
      indexes :created_at, type: :date
      indexes :updated_at, type: :date
      indexes :published, type: :boolean
    end
  end
 
  # Elasticsearchに送るデータ
  def as_indexed_json(options = {})
    {
      title: title,
      body: body,
      category_id: category_id,
      author_id: user_id,
      tags: tags.map(&:name),
      view_count: view_count,
      created_at: created_at,
      updated_at: updated_at,
      published: published?
    }
  end
end

日本語アナライザーの設定

# config/initializers/elasticsearch.rb
Elasticsearch::Model.client = Elasticsearch::Client.new(
  host: ENV["ELASTICSEARCH_URL"],
  log: Rails.env.development?
)
 
# インデックス設定(日本語形態素解析)
DOCUMENT_INDEX_SETTINGS = {
  analysis: {
    analyzer: {
      kuromoji_analyzer: {
        type: :custom,
        tokenizer: :kuromoji_tokenizer,
        filter: [
          :kuromoji_baseform,      # 基本形に変換(走る→走る)
          :kuromoji_part_of_speech, # 品詞でフィルタ
          :kuromoji_stemmer,       # 語幹抽出
          :lowercase,
          :stop
        ]
      }
    }
  }
}

検索クエリの構築

# app/services/document_search_service.rb
class DocumentSearchService
  def initialize(query:, filters: {}, page: 1, per_page: 20)
    @query = query
    @filters = filters
    @page = page
    @per_page = per_page
  end
 
  def search
    Document.search(build_query)
  end
 
  private
 
  def build_query
    {
      query: {
        bool: {
          must: [
            build_full_text_query,
            { term: { published: true } }
          ],
          filter: build_filters
        }
      },
      sort: build_sort,
      aggs: build_aggregations,
      highlight: {
        fields: {
          title: { number_of_fragments: 0 },
          body: { fragment_size: 200, number_of_fragments: 3 }
        },
        pre_tags: ["<mark>"],
        post_tags: ["</mark>"]
      },
      from: (@page - 1) * @per_page,
      size: @per_page
    }
  end
 
  def build_full_text_query
    return { match_all: {} } if @query.blank?
 
    {
      multi_match: {
        query: @query,
        fields: [
          "title^3",   # タイトルに3倍の重み
          "body^1",
          "tags^2"
        ],
        type: :best_fields,
        operator: :and
      }
    }
  end
 
  def build_filters
    filters = []
    filters << { term: { category_id: @filters[:category_id] } } if @filters[:category_id]
    filters << { term: { author_id: @filters[:author_id] } } if @filters[:author_id]
    if @filters[:date_from] || @filters[:date_to]
      filters << {
        range: {
          created_at: {
            gte: @filters[:date_from],
            lte: @filters[:date_to]
          }.compact
        }
      }
    end
    filters
  end
 
  def build_sort
    case @filters[:sort]
    when "updated_at"
      [{ updated_at: :desc }]
    when "view_count"
      [{ view_count: :desc }, "_score"]
    else
      ["_score", { updated_at: :desc }]
    end
  end
 
  def build_aggregations
    {
      categories: { terms: { field: :category_id, size: 20 } },
      tags: { terms: { field: :tags, size: 50 } },
      date_histogram: {
        date_histogram: {
          field: :created_at,
          calendar_interval: :month
        }
      }
    }
  end
end

オートコンプリート

# app/controllers/search_suggestions_controller.rb
class SearchSuggestionsController < ApplicationController
  def index
    prefix = params[:q].to_s.strip
    return render json: [] if prefix.length < 2
 
    suggestions = fetch_suggestions(prefix)
    render json: suggestions
  end
 
  private
 
  def fetch_suggestions(prefix)
    result = Document.search(
      suggest: {
        title_suggest: {
          prefix: prefix,
          completion: {
            field: "title.suggest",
            size: 5,
            skip_duplicates: true
          }
        }
      }
    )
 
    result.response["suggest"]["title_suggest"][0]["options"]
          .map { |opt| { text: opt["text"], score: opt["_score"] } }
  end
end

同義語・表記揺れの対応

# カスタムアナライザーに同義語フィルタを追加
SYNONYM_FILTER = {
  type: :synonym,
  synonyms: [
    "機械学習, ML, Machine Learning",
    "人工知能, AI, Artificial Intelligence",
    "クラウド, Cloud",
    "コンテナ, Container, Docker",
    "マイクロサービス, Microservices, MSA"
  ]
}

DBとの同期戦略

Elasticsearchのデータは常にDBと同期させる必要がある。

Loading diagram...
# app/models/document.rb
# Callback でキューに積む(直接同期はしない)
class Document < ApplicationRecord
  after_commit :schedule_index_update, on: [:create, :update]
  after_destroy :schedule_index_delete
 
  private
 
  def schedule_index_update
    DocumentIndexJob.perform_later(id, :index)
  end
 
  def schedule_index_delete
    DocumentIndexJob.perform_later(id, :delete)
  end
end
 
# app/jobs/document_index_job.rb
class DocumentIndexJob < ApplicationJob
  queue_as :elasticsearch
 
  sidekiq_options retry: 5, backtrace: true
 
  def perform(document_id, operation)
    case operation.to_sym
    when :index
      document = Document.find_by(id: document_id)
      return unless document
      document.__elasticsearch__.index_document
    when :delete
      Document.__elasticsearch__.client.delete(
        index: Document.index_name,
        id: document_id,
        ignore: [404]
      )
    end
  end
end

WARNING

after_save ではなく after_commit を使う。after_save はトランザクション内で呼ばれるため、ロールバックが発生したときにインデックスとDBが乖離する。


AWSインフラ構成

Loading diagram...

OpenSearch Service の設定

{
  "DomainName": "document-search",
  "EngineVersion": "OpenSearch_2.9",
  "ClusterConfig": {
    "InstanceType": "r6g.large.search",
    "InstanceCount": 3,
    "ZoneAwarenessEnabled": true,
    "ZoneAwarenessConfig": {
      "AvailabilityZoneCount": 3
    }
  },
  "EBSOptions": {
    "EBSEnabled": true,
    "VolumeType": "gp3",
    "VolumeSize": 100
  }
}

INFO

OpenSearch は Multi-AZ の3ノード構成が推奨。1ノードが落ちてもサービスを継続できる。データは3レプリカに分散されるため、ノード障害でデータ損失はない。


検索品質のモニタリング

# 検索クリック率の追跡
class SearchAnalyticsService
  def self.record_search(query, result_count, user_id)
    SearchEvent.create!(
      query: query,
      result_count: result_count,
      user_id: user_id,
      searched_at: Time.current
    )
  end
 
  def self.record_click(search_event_id, document_id, position)
    SearchClick.create!(
      search_event_id: search_event_id,
      document_id: document_id,
      position: position,
      clicked_at: Time.current
    )
  end
 
  # CTR が低いクエリを検出
  def self.low_ctr_queries(threshold: 0.1, since: 7.days.ago)
    SearchEvent
      .where("searched_at > ?", since)
      .left_joins(:clicks)
      .group(:query)
      .having("COUNT(search_clicks.id)::float / COUNT(search_events.id) < ?", threshold)
      .count
  end
end

振り返り

「検索の品質はどう測る?」とナオミが聞いた。

「クリック率…ですか? CTR」

「そう。ユーザーが検索して結果を見て、クリックするかどうか。クリックしないということは、関連性が低い検索結果を返しているということ。技術だけではなく、ユーザーの行動から設計を改善し続けるのがエンジニアの仕事よ」

INFO

Kata 7 の学び: 全文検索は「探せること」だけが目標ではない。「正しい結果を上位に返すこと」が本当の目標。そのためのランキング設計とモニタリングが不可欠。

トレードオフの記録

決定メリットデメリット
Elasticsearch採用全要件を満たすDB + ES の二重管理
非同期インデックス更新DB保護、スケーラブル数秒の遅延
3ノード構成高可用性コスト(シングルより3倍)

「さあ、最後の本格的なKata。マルチテナントSaaS。これが一番設計の奥深い問題よ」