全文検索エンジンの導入|Elasticsearch・Meilisearch・PostgreSQL全文検索の選び方と運用
目次
- LIKE検索の限界はどこにあるのか
- 前方一致以外はインデックスが効かない
- 「関連度」と「表記ゆれ」に対応できない
- 転置インデックスと日本語の形態素解析
- 転置インデックス:「単語→文書」の逆引き表
- 日本語は「単語の区切り」を決めるところから始まる
- 主要な検索エンジンの比較:Elasticsearch・Meilisearch・Typesense・PostgreSQL
- Elasticsearch / OpenSearch:機能は最強、運用は最重
- Meilisearch / Typesense:ECサイトやサービス内検索の現実解
- PostgreSQL拡張:ミドルウェアを増やさない選択
- インデックス更新の同期設計:DBと検索エンジンをどう揃えるか
- 同期の3パターン
- 運用負荷とコストで最終判断する
- 判断の目安
- トラブル事例:「京都」で検索すると東京都の店舗が出てくる
- 症状
- 原因
- 対処
- まとめ
「商品検索がLIKE文で、データが増えたら数秒返ってこなくなった」「『東京都』で検索すると『京都』もヒットする」「Elasticsearchを入れるべきだと言われたが、運用できる自信がない」――検索機能は最初は簡単に作れるのに、データ量とユーザーの期待が育つと一気に難しくなる領域です。
この記事では、LIKE検索がなぜ限界を迎えるのかを仕組みから説明し、全文検索エンジンの中核である転置インデックスと、日本語特有の形態素解析・N-gramの考え方を解説します。そのうえでElasticsearch/OpenSearch・Meilisearch・Typesense・PostgreSQL拡張(pg_bigm/PGroonga)を運用負荷とコストを含めて比較し、DBと検索インデックスをどう同期させるかという設計まで扱います。なお、意味的な類似検索を行うベクトルDBはベクトルデータベースとはで解説しているので、本記事はキーワード検索に絞ります。
LIKE検索の限界はどこにあるのか
前方一致以外はインデックスが効かない
RDBのWHERE name LIKE '%キーワード%'という検索は、実装が簡単で数万件程度なら十分に速く動きます。しかし通常のB-treeインデックスは「先頭から一致する」場合にしか使えません。'キーワード%'なら索引を引けますが、'%キーワード%'は全行を先頭から読んで文字列比較する「フルスキャン」になります。数十万件を超え、対象カラムが長文(商品説明や記事本文)になると、1クエリで数秒かかるのは珍しくありません。
「関連度」と「表記ゆれ」に対応できない
速度だけでなく、検索品質の問題もあります。LIKEは「含むか含まないか」しか判定できず、「タイトルに含まれる方を上に出す」「複数語のうち多く一致した方を上に出す」といった関連度の順位付けができません。また「サーバ」と「サーバー」、「iPhone」と「iphone」のような表記ゆれ、複数キーワードのAND/OR、タイプミスへの寛容さも、SQLだけで実装しようとすると急速に複雑になります。全文検索エンジンは、この「速度」と「品質」の両方を解決するために存在します。
転置インデックスと日本語の形態素解析
転置インデックス:「単語→文書」の逆引き表
全文検索エンジンの中核は転置インデックスです。文書を単語(トークン)に分割し、「この単語はどの文書の何番目に出てくるか」という逆引き表を事前に作っておきます。本の巻末索引と同じ発想で、検索時は「キーワードが載っている文書のリスト」を索引から直接引くだけなので、文書数が増えても検索時間はほとんど増えません。ついでに「文書内での出現回数」や「全文書中でのその単語の希少さ」から関連度スコア(BM25など)を計算できるため、順位付けも自然に手に入ります。
日本語は「単語の区切り」を決めるところから始まる
英語は空白で単語が区切れますが、日本語は「東京都に住む」をどう区切るかを機械が決めなければなりません。方式は2つあります。
| 方式 | 仕組み | 長所 | 短所 |
|---|---|---|---|
| 形態素解析 | 辞書を使って「東京都/に/住む」と単語単位に分割(kuromoji、MeCabなど) | 検索精度が高い。「京都」で「東京都」がヒットしない | 辞書にない新語・固有名詞に弱い。辞書更新の運用が必要 |
| N-gram | 「東京」「京都」「都に」…と機械的にN文字ずつ切る(bigramなら2文字) | 辞書不要で漏れがない。新語にも強い | 「京都」で「東京都」がヒットする。インデックスが大きくなる |
実務では、精度重視の本文検索は形態素解析、漏れが許されない型番・名称の検索はN-gram、と使い分けたり、両方のフィールドを用意して組み合わせたりします。Elasticsearchではkuromojiプラグイン、PostgreSQLではpg_bigm(bigram)とPGroonga(形態素解析とN-gramの両方に対応)が代表的な選択肢です。
主要な検索エンジンの比較:Elasticsearch・Meilisearch・Typesense・PostgreSQL
| 項目 | Elasticsearch / OpenSearch | Meilisearch | Typesense | PostgreSQL(pg_bigm / PGroonga) |
|---|---|---|---|---|
| 性格 | 全部入りの分散検索・分析基盤 | 「そのまま使える」ことに特化した検索サーバー | Meilisearchに近い。タイポ耐性と速度重視 | 既存DBに検索用インデックスを追加 |
| 日本語対応 | kuromoji / Sudachiプラグイン。設定は自由度が高い | 組み込みの日本語トークナイザーあり(Lindera) | 標準はN-gram的な分割。日本語向けの設定が必要 | pg_bigmはbigram、PGroongaはMeCab対応 |
| タイポ耐性・入力補完 | 実装可能だが設定が必要 | デフォルトで有効。検索しながら結果が出るUIが得意 | デフォルトで有効 | 基本的になし(別途実装) |
| 集計・ログ分析 | 非常に強い(Kibana / OpenSearch Dashboards) | ファセット程度 | ファセット程度 | SQLで集計 |
| 運用負荷 | 高い。JVMのメモリ、クラスタ、シャード設計が必要 | 低い。単一バイナリで起動。クラウド版あり | 低い。クラスタ構成も可能 | DB運用に含まれる。追加ミドルウェアなし |
| データ同期 | アプリまたはパイプラインで別途同期 | 同左 | 同左 | 不要(同じDB内) |
Elasticsearch / OpenSearch:機能は最強、運用は最重
全文検索の代名詞で、複雑なクエリDSL、集計、ログ分析、地理検索、ベクトル検索まで一通り揃っています。OpenSearchはAWSがフォークした互換実装で、Amazon OpenSearch Serviceとしてマネージド提供されています。反面、JVMのヒープ設計、シャードとレプリカ数の決定、バージョンアップ、ディスク逼迫の監視など、運用項目が多く、小さなチームが片手間で持つには重いのが正直なところです。ログ分析やBIも同じ基盤に載せたい、検索要件が複雑、という場合に選ぶ道具です。
Meilisearch / Typesense:ECサイトやサービス内検索の現実解
どちらも「開発者がすぐ使える検索」を目標にしたエンジンで、単一のバイナリで起動し、REST APIでドキュメントを投入すれば、タイポ耐性・前方一致・ファセット・関連度順が最初から有効です。数百万件程度のドキュメントまでなら1台で十分に速く、Meilisearchは日本語トークナイザーを内蔵しているため、辞書の設定なしに実用的な精度が出ます。商品検索・記事検索・社内ドキュメント検索など、「検索ボックスにキーワードを入れたら即座に結果が出る」体験を作りたいならまずここから検討するのが現実的です。
PostgreSQL拡張:ミドルウェアを増やさない選択
PostgreSQLを使っているなら、pg_bigmまたはPGroongaを入れるだけで、LIKE検索がインデックスで高速化されます。新しいサーバーも同期処理も不要で、トランザクションの中で更新されるため、DBと検索結果がずれる問題も起きません。Amazon RDS for PostgreSQLやAurora PostgreSQLではpg_bigmがサポートされています。関連度順やタイポ耐性は限定的なので、「まず遅いのを直したい」「検索UIはシンプルで十分」という場合の第一候補です。
-- pg_bigmで部分一致検索を高速化する(RDS/Auroraでも利用可)
CREATE EXTENSION IF NOT EXISTS pg_bigm;
CREATE INDEX idx_products_description_bigm
ON products USING gin (description gin_bigm_ops);
-- インデックスが使われる(2文字以上のキーワード)
EXPLAIN ANALYZE
SELECT id, name FROM products
WHERE description LIKE '%防水スピーカー%';
PostgreSQL自体の基本についてはPostgreSQL入門を、MySQLとの選択についてはMySQLとPostgreSQLの違いと選び方を参照してください。
インデックス更新の同期設計:DBと検索エンジンをどう揃えるか
外部の検索エンジンを使う場合、避けて通れないのが「DBを更新したら検索インデックスも更新する」同期の設計です。ここを雑にすると、「商品を削除したのに検索に出る」「価格を変えたのに古い価格で出る」という問い合わせが確実に来ます。
同期の3パターン
- アプリから直接更新:保存処理の中で検索エンジンのAPIも呼ぶ。実装は簡単だが、検索エンジンが落ちていると保存自体が失敗するか、失敗を握りつぶして不整合が残る。
- 非同期ジョブで更新:DBのコミット後に「ID=123を再インデックス」というジョブをキューに入れ、ワーカーが最新の状態を読んで検索エンジンに送る。検索エンジンの障害がアプリに波及せず、リトライも効く。もっとも一般的な構成。
- 定期的な全件・差分再構築:
updated_atが直近のものだけを夜間や数分おきに再投入する。即時性は下がるが、実装と復旧が最も単純。上記1・2の「ずれの保険」として併用するのが定石。
非同期ジョブでの更新は、ジョブが2回動いても同じドキュメントを上書きするだけなので冪等になりやすく、この用途に向いています。ジョブの設計はバックグラウンドジョブの設計を参考にしてください。
// Meilisearchへ非同期ジョブから同期する例(Node.js)
import { MeiliSearch } from "meilisearch";
const client = new MeiliSearch({
host: process.env.MEILI_HOST,
apiKey: process.env.MEILI_ADMIN_KEY,
});
const index = client.index("products");
// ワーカー側:IDを受け取り、DBの最新状態で上書き(冪等)
export async function reindexProduct(productId) {
const p = await db.products.findById(productId);
if (!p || p.deletedAt) {
await index.deleteDocument(String(productId));
return;
}
await index.addDocuments([{
id: String(p.id),
name: p.name,
description: p.description,
price: p.price,
categories: p.categoryNames,
updatedAt: p.updatedAt.getTime(),
}], { primaryKey: "id" });
}
Meilisearchの初期設定として、検索対象と絞り込み対象のフィールドを宣言しておくと、不要なフィールドがインデックスに入らず精度と速度が上がります。
curl -X PATCH "$MEILI_HOST/indexes/products/settings" \
-H "Authorization: Bearer $MEILI_ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{
"searchableAttributes": ["name", "description"],
"filterableAttributes": ["categories", "price"],
"sortableAttributes": ["price", "updatedAt"],
"rankingRules": ["words", "typo", "proximity", "attribute", "sort", "exactness"]
}'
運用負荷とコストで最終判断する
判断の目安
- まず遅いLIKEを直したい・PostgreSQL利用中:
pg_bigm/PGroonga。追加コストほぼゼロ、同期設計不要。 - 検索UXを上げたい(タイポ耐性、即時サジェスト、ファセット):MeilisearchかTypesense。小さなインスタンス1台かクラウド版で始められる。
- ログ分析・集計・複雑なクエリも同じ基盤で:Elasticsearch/OpenSearch。マネージドサービスを前提に、運用担当を決めてから導入する。
コストは、検索エンジンのサーバー費用そのものより、「同期の実装と保守」「インデックス再構築の手順整備」「障害時の切り戻し」といった人的コストが大きくなりがちです。検索エンジンが落ちたときにDBのLIKE検索へフォールバックする、あるいは検索機能だけ一時停止する、という縮退運転を最初から設計しておくと安心です。
トラブル事例:「京都」で検索すると東京都の店舗が出てくる
症状
店舗検索をpg_bigmで高速化した直後、「京都」で検索した結果の上位に「東京都新宿区」の店舗が大量に並ぶという指摘がありました。速度は改善したものの、検索結果の品質に対する苦情が増えました。
原因
bigramは「東京都」を「東京」「京都」の2文字ずつに分割してインデックスするため、「京都」というキーワードは「東京都」の一部に一致します。これはN-gram方式の構造的な性質であり、バグではありません。移行前のLIKE検索でも同じ結果は出ていましたが、以前は遅くて結果を最後まで見る人が少なかったため、顕在化していなかったのです。
対処
- 住所フィールドは、都道府県・市区町村を構造化した別カラムに分け、完全一致で絞り込むようにした。
- 自由テキストの検索については、形態素解析に対応した
PGroongaへ切り替え、「東京都」を1語として扱うようにした。 - それでも拾いきれない固有名詞は、ユーザー辞書に登録して運用で補うルールにした。
-- PGroongaでMeCabベースのトークナイザーを使う
CREATE EXTENSION IF NOT EXISTS pgroonga;
CREATE INDEX idx_shops_address_pgroonga
ON shops USING pgroonga (address)
WITH (tokenizer = 'TokenMecab');
SELECT name, address FROM shops WHERE address &@~ '京都';
まとめ
LIKE検索は前方一致以外でインデックスが効かず、関連度や表記ゆれにも対応できないため、データが育つと必ず限界を迎えます。全文検索エンジンは転置インデックスでこの問題を解決しますが、日本語では「形態素解析かN-gramか」というトークン分割の選択が精度を左右します。
製品は、PostgreSQL拡張(追加運用なし)、Meilisearch/Typesense(検索UX重視で軽い運用)、Elasticsearch/OpenSearch(分析まで含む重厚な基盤)という3段階で考えると選びやすくなります。外部エンジンを使うなら、DBとの同期を非同期ジョブと定期再構築の組み合わせで設計し、検索エンジンが落ちたときの縮退運転も決めておきましょう。
検索機能の改善や検索基盤の選定・同期設計でお困りの場合は、Harmonic Societyのシステム開発・インフラ支援にご相談ください。
Harmonic Society
この記事の内容、自社の業務でも活かせそうですか?
ローカルLLM・AI・クラウドなどの技術導入を、要件整理からPoC・社内展開まで代表エンジニアが伴走します。オンライン対応・全国OK。まずは30分の無料相談から。売り込みはしません。
関連記事
Related / 9 articles
Notes & Insights
- プログラミング
DDoS攻撃の仕組みと対策入門|レイヤー別の防御とCDN・クラウドの活用
DDoS攻撃をボリューム型・プロトコル型・アプリ層に分けて仕組みを解説し、自前サーバーで防げない理由、CloudflareやAWS Shieldの標準防御、オリジンIPの隠し方、レートリミットとBot対策、攻撃を受けたときの初動、費用が跳ね上がるDenial of Walletへの備えまでわかります。
- プログラミング
WAFとは?仕組み・導入パターン・誤検知対策|Webアプリを攻撃から守る実践ガイド
WAFがファイアウォールやIDSと何が違うのか、シグネチャとマネージドルールの仕組み、Cloudflare WAF・AWS WAF・ModSecurityの比較、フォーム送信がブロックされる誤検知の調査と例外設定、ログ監視、WAFが代替できないことまで実践的に解説します。
- プログラミング
セキュリティヘッダー入門|CSP・HSTS・X-Frame-Optionsの設定と効果を実践解説
CSP・HSTS・X-Frame-Options・X-Content-Type-Optionsなど主要セキュリティヘッダーが防ぐ攻撃と、CSPのReport-Onlyからの段階導入、nonce/hash、HSTS preloadの不可逆リスク、Nginx・Next.js・Astroでの設定例、確認方法を解説します。
- プログラミング
クラウドの通信費(Egress)入門|データ転送量課金の仕組みと転送コストを抑える設計
クラウドの「受信無料・送信有料」の原則、AZ間・リージョン間・インターネット向けの単価差、NATゲートウェイ処理料の罠、CDNで転送量を減らす方法、バックアップやログ転送の見落とし、請求書で転送料を特定する手順を解説。想定外の請求を防げます。
- プログラミング
秘密情報をGitに入れない仕組み|.gitignore・git-secrets・履歴から漏れた鍵の削除
APIキーや.envをGitにコミットしてしまう典型経路と、.gitignore・.env.exampleの運用、pre-commitでのgitleaks検知、GitHub secret scanningの活用、漏れた鍵の無効化と履歴書き換え(git filter-repo)の手順を解説。仕組みで再発を防げます。
- プログラミング
開発・ステージング・本番環境の分離設計|環境差分をなくす構成とアクセス制御
開発・ステージング・本番それぞれの目的と、構成をコードで揃える方法、環境別の設定注入、本番データを使わないテストデータ戦略、ステージングの保護(Basic認証・IP制限・noindex)、コストを抑える運用までを解説。環境差分による本番障害を防げます。
- プログラミング
ngrok・Cloudflare Tunnelでローカルを公開|Webhook開発とデモ環境の作り方
NAT内のローカル環境にStripeやLINEのWebhookを届けるトンネリングの仕組みを解説。ngrok・Cloudflare Tunnel・localtunnelの比較、固定ドメインと認証、リクエスト検査、公開時のセキュリティ、自宅サーバー公開への応用までわかります。
- プログラミング
ローカル開発環境のHTTPS化|mkcert・hostsファイル・自己署名証明書の正しい使い方
ローカル開発をHTTPS前提にすべき理由(Secure Cookie・Service Worker・OAuth)と、mkcertでローカルCAを作りhostsで独自ドメインを割り当ててVite・Next.js・Dockerで使う手順を解説。証明書警告を無視する癖の危険も理解できます。
- プログラミング
localhost・0.0.0.0・127.0.0.1の違い|ポートとUnixソケットを理解して「つながらない」を解決
localhost・127.0.0.1・0.0.0.0の意味の違い、Dockerで外から接続できない原因、host.docker.internal、ポート競合の調べ方、Unixソケットの利点と権限、1024未満ポートの制約を解説。「つながらない」を仕組みから解決できます。
Harmonic Society
「読んで終わり」にせず、自社の業務で試してみませんか?
AI・ローカルLLM・クラウドの導入を、要件整理からPoC・社内展開まで代表エンジニアが伴走します。オンライン対応・全国OK・売り込みなし。
無料・30分・オンラインOK|1営業日以内に返信します