SoSoraEndo2026年5月9日1 min555 字
検索を入れる前提
個人ブログ規模で「日本語検索が動く」ところまで到達する道は概ね 3 つ:
- MySQL
LIKE— 単純、一致率は低い - MySQL FULLTEXT + ngram parser — 日本語に対応した built-in 全文検索
- Meilisearch / OpenSearch — 専用エンジン
最初の 1 本目は (1)、本気を出すなら (3)。中間で「悪くない検索」を実装するなら (2)。
私は (2) を雑に動かす 方針を取った。50〜500 記事規模の個人サイトなら、これで十分体感できる検索になる。
ngram parser を入れる
MySQL 8.x は ngram parser が built-in。インデックス作成時に指定する:
ALTER TABLE posts
ADD FULLTEXT INDEX idx_posts_fulltext (title, excerpt, body_markdown)
WITH PARSER ngram;
ngram parser はデフォルト 2-gram。日本語の検索ではこれでだいたい問題なく動く。
クエリ
SELECT *,
MATCH(title, excerpt, body_markdown)
AGAINST('Rails 8 移行' IN BOOLEAN MODE) AS score
FROM posts
WHERE MATCH(title, excerpt, body_markdown)
AGAINST('Rails 8 移行' IN BOOLEAN MODE)
AND status = 2
ORDER BY score DESC
LIMIT 20;
IN BOOLEAN MODE でないと、+keyword -keyword のような演算子が効かない。空白区切りの語は OR で結合される(natural language mode は AND だが、ngram の挙動が独特)。
AND 検索したい場合
複数語の AND は手動で組む:
def search(query, scope: Post.published)
terms = query.strip.split(/\s+/).reject(&:empty?).first(5)
return scope if terms.empty?
match_clauses = terms.map do |t|
sanitized = ActiveRecord::Base.connection.quote(t)
"MATCH(title, excerpt, body_markdown) AGAINST(#{sanitized} IN BOOLEAN MODE)"
end
scope.where(match_clauses.join(' AND '))
end
語ごとに MATCH ... AGAINST ... を作って AND で結合。これで「複数キーワードの AND 検索」が動く。
落とし穴
実運用で気づいた点 3 つ:
- 2 文字以下の検索語は ngram parser で扱えない(
innodb_ft_min_token_sizeのデフォルトが 2)。「Go」「Rails」のような 2 文字以下を検索したい場合はLIKEでフォールバック。 - score ベースのソート は記事数が少ないと安定しない。50 記事規模だと「relevance を期待した結果」と「最新順」がほぼ同じになる。50 を超えると初めて relevance が効く。
- 空白の正規化: 全角スペースを半角に変換。これを忘れると「Rails 8 移行」(全角)が hit しない。
卒業のタイミング
500 記事を超えるか、ファセット検索が欲しくなったら Meilisearch に移行。1000 記事 / 10ms 以内が体感できる。それ以下では MySQL ngram で十分。
検索は「無いと不便、あるとそこそこ」の機能。最初は MySQL で雑に動かして、書きながら必要性を実測するのがよい。