Kata: 検索エンジン — 全文検索の実装
課題の提示
「タクミさん、SQLで検索は書いたことある?」
「はい。LIKE '%keyword%' で。」
ナオミは静かに笑った。「データが100万件になったらどうなる?」
Kata 7: コンテンツ検索プラットフォーム
ドキュメント管理システムの検索機能を強化したい。
- ドキュメント数: 100万件
- 検索レスポンスタイム: 100ms以下
- 全文検索(タイトル・本文・メタデータ)
- 日本語検索(形態素解析)
- ファセット検索(カテゴリ・日付・作成者でフィルタ)
- 検索ランキング(関連度、更新日時、閲覧数で重み付け)
- オートコンプリート(入力途中の候補表示)
- 同義語・表記揺れの対応
「LIKE '%キーワード%' の何が問題ですか?」とタクミが聞いた。
「3つある。1つ目はパフォーマンス。インデックスが使えない前方一致でなければ。2つ目は日本語。単語境界がない。3つ目はランキング。全部同じ扱いになる」
「PostgreSQLの全文検索では?」
「限界がある。本格的な検索ならElasticsearchかOpenSearch。判断の基準を学ぼう」
設計判断
検索技術の選択
| 機能 | LIKE | pg_trgm | Elasticsearch |
|---|---|---|---|
| パフォーマンス | ✗ | △ | ✓ |
| 日本語形態素解析 | ✗ | ✗ | ✓ |
| ファセット検索 | ✗ | ✗ | ✓ |
| 関連度スコアリング | ✗ | △ | ✓ |
| オートコンプリート | ✗ | ✗ | ✓ |
判断: 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と同期させる必要がある。
# 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
endWARNING
after_save ではなく after_commit を使う。after_save はトランザクション内で呼ばれるため、ロールバックが発生したときにインデックスとDBが乖離する。
AWSインフラ構成
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。これが一番設計の奥深い問題よ」