Elasticsearchエンジニア()、RDBを知る
ということで、OSS-DB silverを取ろうと思い、postgreSQLで何かしようってなったら、やっぱ全文検索でしょ!と思い、この記事の執筆にあたりました。
この記事では、PostgreSQLの全文検索がどこまで戦えて、どこからElasticsearchが必要になるのか を、同じ日本語データ(Wikipedia)で実測しました。
対象読者
- PostgreSQLを勉強中で、全文検索はこれから、という人
- Elasticsearchの名前は知っているが、全文検索そのものには詳しくない人
結論
速度は「一律どちらが速い」ではなく、クエリの性質で変わる
| こういう時 | おすすめ | ざっくり理由 |
|---|---|---|
| データが既にPostgreSQLにある/中規模まで/絞り込めるクエリ中心 | PostgreSQL(PGroonga) | 1システムで完結。少数ヒットのクエリは速い |
| 高頻度語の大量ヒット/前方一致 | Elasticsearch | 大量マッチのスコアリングと取り出しが最適化 |
| タイポ許容・ハイライト等の検索機能を多用 | Elasticsearch | 標準機能が手厚い |
| まず日本語で検索できるか試したい | 標準FTSはNG → PGroonga | PostgreSQL標準FTSは日本語を語に割れない(後述) |
前提知識
📖 そもそも「全文検索」とは?
大量の文書から「この単語を含む文書」を高速に探す仕組みです。
シンプルなやり方
SELECT * FROM docs WHERE body LIKE '%東京%' は、全行を先頭から舐めて部分一致を調べます。件数が増えるほど線形に遅くなり、しかも「どれが一番関連が高いか」の順位づけができません。
全文検索のポイント
- 転置インデックス — 「単語 → その単語を含む文書IDの一覧」という対応表を"事前に"作っておく。検索はこの表を引くだけなので速い。本の巻末索引と同じ発想です
-
トークナイズ(語の切り出し) — 転置インデックスを作るには文章を語に区切る必要があります。英語は空白で切れて楽。日本語は語の間に空白がない(例:「東京都に住む」)ので、ここが最大の難所。方式は2つ:
- 形態素解析: 辞書を使い「東京都 / に / 住む」と意味のある語に分ける。精度は高いが辞書にない語(新語・固有名詞)に弱い
- N-gram(bigram): 機械的にN文字ずつ区切る。「東京都」→「東京 / 京都 / 都に…」。取りこぼしは少ないがノイズも増え、インデックスも大きくなりがち
- 関連度スコア — ヒットした文書のうち「どれが良い順か」を計算する。代表格が BM25(単語の出現頻度・希少さ・文書長から算出)
PostgreSQLもElasticsearchも、この3点をやっています。違うのは「どこまで作り込めるか」と「運用の形」だけ!
📖 Elasticsearchとは?
検索に特化した全文検索エンジンです。Apache Luceneという検索ライブラリの上に、分散処理・REST API・JSONドキュメント管理を乗せたもの。
- 転置インデックス・BM25スコアリングを最初から高度に備えている
- 横にスケールしやすく、大量データの検索・集計が得意
- ログ解析基盤(いわゆるELK/Elastic Stack)としても広く使われる
- 日本語は kuromoji(形態素解析)プラグインを入れて対応する
強力な反面、立てて・繋いで・運用する分のコストがかかります。この記事ではそのコストに見合う場面はどこか、も見ます。
📖 PostgreSQLと全文検索の関係は?
PostgreSQLは汎用のリレーショナルDB。多くの場合、アプリのデータはもうここに入っています。だからここで検索まで完結できれば、システムを増やさずに済みます。
PostgreSQLにも標準の全文検索がある
tsvector / tsquery 型とGINインデックスを使う仕組みが標準搭載されています。英語のような空白区切りの言語では、辞書設定を使ってちゃんと動きます。
ただし日本語は標準だと厳しい
PostgreSQL標準には日本語用のトークナイザが入っていません。空白区切り前提の設定(simpleなど)では、空白のない日本語がうまく語に割れず、検索の精度が出ません。※これは後で実際に見せます。
補う拡張が2つ
-
pg_bigm: bigram(N-gram)方式で日本語対応 - PGroonga: Groonga検索エンジンを組み込む拡張。形態素・N-gramの両対応で機能が豊富。Elasticsearchと殴り合わせるならこちらが本命なので、今回はPGroongaを使います。
PGroongaの魅力は、あくまでPostgreSQLの中で完結すること。SQLの WHERE に検索を書け、JOINやトランザクションもそのまま使えます。
この記事で確かめたい「問い」
比較記事はつい「どっちが優秀か」を競わせがちですが、それはあまり役に立ちません。両者は設計思想が違うので、勝ち負けではなく どこで切り替わるか=境界線 を知りたい。
具体的には、次の軸で「PostgreSQL(PGroonga)で十分な範囲」と「Elasticsearchが効き始める範囲」の境目を実測で探します。
- 速度: クエリの種類(単語 / フレーズ / AND・OR / 前方一致 / タイポ許容)×データ規模
- 精度(引き味): 日本語がちゃんと期待どおりに引けるか
- コスト: インデックスの構築時間・サイズ、そして運用の重さ
検証環境
同じ日本語データを、PGroonga入りPostgreSQL と kuromoji入りElasticsearch の両方に投入し、Dockerで並べて計測しました。データは日本語Wikipediaの本文(クリーンなプレーンテキスト)を 1万件 / 10万件 の2スケールで用意し、「規模で挙動がどう変わるか」も見ています。
🔧 バージョン・compose・データの詳細
-
PostgreSQL側:
groonga/pgroonga:4.0.6-alpine-18-slim(PostgreSQL 18ベース)。拡張pgroonga -
Elasticsearch側:
9.4.3+analysis-kuromojiプラグイン - 実行環境: Docker(WSL2上)
-
データ: Hugging Face の
wikimedia/wikipedia(20231101.ja)。wikitext除去済みのクリーンな本文。{id, title, body}のJSONLに整形して両エンジンへ同一投入
# compose.yaml(抜粋)
services:
postgres:
image: groonga/pgroonga:4.0.6-alpine-18-slim
environment:
POSTGRES_USER: bench
POSTGRES_PASSWORD: bench
POSTGRES_DB: bench
ports: ["5432:5432"]
shm_size: "1gb"
elasticsearch:
build: ./elasticsearch # FROM elasticsearch:9.4.3 + kuromojiプラグイン
environment:
- discovery.type=single-node
- xpack.security.enabled=false
- ES_JAVA_OPTS=-Xms2g -Xmx2g
ports: ["9200:9200"]
PostgreSQL側のインデックス定義:
-- 日本語対応の本命
CREATE INDEX idx_body_pgroonga ON docs USING pgroonga (body);
-- 「素のPostgreSQL標準FTS」比較用(日本語分割不可)
ALTER TABLE docs ADD COLUMN body_tsv tsvector
GENERATED ALWAYS AS (to_tsvector('simple', body)) STORED;
CREATE INDEX idx_body_tsv ON docs USING gin (body_tsv);
Elasticsearch側のkuromojiアナライザ:
{
"analyzer": {
"ja_kuromoji": {
"type": "custom",
"tokenizer": "kuromoji_tokenizer",
"filter": ["kuromoji_baseform", "kuromoji_part_of_speech",
"cjk_width", "ja_stop", "kuromoji_stemmer", "lowercase"]
}
}
}
検証手順
測る軸はこの4つです。
- インデックス構築時間(投入後にインデックスを張る時間を計測)
- インデックスサイズ(ディスク消費)
- クエリ速度(p50(中央値) / p95 単語・フレーズ・AND・OR・前方一致・タイポの6種)
- 精度(ヒット件数と中身)
PGroongaは &@(1語)・&@~(クエリ構文)・&^(前方一致)。
Elasticsearchは match / match_phrase / bool / match_phrase_prefix / fuzziness。
⚠️ 計測の注意点
フェアに測るために踏んだ工夫と、残る非対称性。
-
件数は「真の総数」で比較: PostgreSQLは
count(*)、ESはtrack_total_hits: trueで全件数える。 - レイテンシは別計測: 全件カウントは重いので、速度計測はデフォルト設定(上位N件取得)で
- 非対称①(通信): PostgreSQLはローカルソケット直結、ESはHTTP往復込みで計測している。ESには数msのノイズが乗るため、少数ヒットのクエリでPGが圧勝する絶対値は、エンジン差をやや過大に見せている(ESの10ms前後にはHTTP通信分が含まれる)
-
非対称②(ソート): ESはBM25でスコア順ソートする、PGroongaの
&@はマッチを返すだけ。ESの方が仕事量が多い - なので 絶対値より「クエリ種別ごとの傾向・逆転」に注目してください。桁が変わるような差(前方一致など)は非対称性では説明できない本物の差です
結果と比較
PostgreSQL標準FTSは、日本語で「割れない」
まず一番大事な前提から。PostgreSQL標準のFTS(to_tsvector)は、日本語を語に分割できません。
SELECT to_tsvector('simple', '東京は日本の首都で、多くの企業や大学が集まる。');
-- → '多くの企業や大学が集まる':2 '東京は日本の首都で':1
:1 :2 は「文中の何番目のトークンか」を表す位置情報です。つまりこの文は、たった2つのトークンにしか割れていません。句読点(、。)でしか区切れないので、文節が丸ごと1語になり、「東京」という独立したトークンが存在しないのです。
結果、同じ「東京」で検索しても、標準FTSとPGroongaでこれだけ差が出ます(1万件時点)。
SELECT
(SELECT count(*) FROM docs WHERE body_tsv @@ to_tsquery('simple','東京')) AS tsvector,
(SELECT count(*) FROM docs WHERE body &@ '東京') AS pgroonga;
-- → 素のtsvector = 0 / pgroonga = 3395
標準FTSは0件。日本語を語に割れないので「東京」というトークンが索引に無く、何も引けない。一方PGroongaは日本語をちゃんと語に割るので普通に引けます。
つまり、PostgreSQLで日本語の全文検索をやるなら、標準FTSは実質使えず、PGroonga(またはpg_bigm)が前提になります。以降の「PostgreSQL」は基本的にPGroongaを指します。
速度:クエリの性質で勝者が入れ替わる
10万件での計測結果です(各クエリ50回、単位はms)。
| クエリ | PG p50 | PG p95 | PGヒット | ES p50 | ES p95 | ESヒット |
|---|---|---|---|---|---|---|
| 単語1語(東京) | 45.7 | 48.5 | 29705 | 7.8 | 9.1 | 28703 |
| フレーズ(機械学習) | 0.6 | 1.2 | 72 | 9.7 | 10.1 | 72 |
| AND(人工知能 かつ 倫理) | 0.9 | 1.6 | 42 | 11.1 | 12.0 | 233 |
| OR(犬 または 猫) | 3.3 | 5.4 | 8530 | 11.4 | 12.2 | 6092 |
| 前方一致(プログラ*) | 4987.1 | 5088.4 | 19 | 10.8 | 11.7 | 4579 |
| タイポ許容(コミニュケーション) | 4.1 | 5.3 | 0 | 18.0 | 18.9 | 2272 |
「PGが速い/ESが速い」の一言では語れません。クエリの性質で勝者が変わります。
- 少数ヒットのクエリ(フレーズ・AND)はPGが圧倒的に速い。フレーズ検索は PG 0.6ms vs ES 9.7ms。絞り込めるクエリはPGroongaの圧勝
- 高頻度語の大量ヒット(単語「東京」=約3万件)はESが速い。PG 45.7ms vs ES 7.8ms で逆転。大量マッチのスコアリングと上位N取得はESが最適化されている。1万件まではPGが速かったですが、10万件で逆転しました
- 前方一致はESの圧勝(約460倍差)。これは別項で詳しく
- タイポ許容はESの独擅場。これも別項で
ESの下限が10ms前後なのは前述の通り通信分を含みます。少数ヒットでPGが速い絶対値は割り引いて見てください。それでも桁で速いのは本物です。
インデックスのサイズと構築時間:検索特化ほどコンパクト
インデックスサイズを1万件・10万件で並べると、順序が入れ替わる面白い現象が出ました。
| 対象 | 1万件 | 10万件 |
|---|---|---|
| PGroonga | 251.8 MB | 1480 MB |
| Elasticsearch | 155.1 MB | 1807 MB |
| 素のtsvector GIN | 312 MB | 2437 MB |
- 1万件では ES < PGroonga < GIN、10万件では PGroonga < ES < GIN に逆転。ESは固定オーバーヘッド(各種メタ情報)が大きく小規模では有利だが、データが増えるとPGroongaの素の索引の方がコンパクトになりました。
- どの規模でも tsvector GINが最大。汎用インデックスなので検索特化ほど詰められません。
構築時間(10万件)はさらに象徴的でした。
| 対象 | 構築時間 |
|---|---|
| PGroonga(body索引) | 98.6秒 |
| tsvector(列生成+GIN索引) | 146.5秒 + 228.0秒 |
| Elasticsearch(投入込み) | 252.3秒 |
tsvector GINは構築が最も重く(合計6分超)、サイズも最大、しかも日本語では機能しない。 「コストは最大なのに使えない」という結果です。日本語では最初からPGroongaかESを選ぶべき、と数字が言っています。
前方一致:PGroongaの弱点(issue #96)
表で突出していた PGの前方一致 4987ms。これはPGroonga側の既知の制約です。EXPLAIN ANALYZE で正体が分かります(1万件時点)。
EXPLAIN ANALYZE SELECT id FROM docs WHERE body &^ 'プログラ' LIMIT 20;
Limit ...
-> Seq Scan on docs (actual time=15.5..2428.2 rows=6)
Filter: (body &^ 'プログラ'::text)
Rows Removed by Filter: 9994
Execution Time: 2428.352 ms
注目は Seq Scan(全表走査) と Rows Removed by Filter: 9994。前方一致 &^ なのに PGroongaインデックスが使われず、1万行を総当たりで舐めて9994行を捨てている。だからデータ量に比例して激遅になり、10万件では約5秒に膨れました。
原因はPGroonga公式の issue #96 でメンテナが説明しています。
要約すると、前方一致でも内部で「全文検索用(トークナイズ済み)インデックス」を使ってしまい、前方一致に適した索引が選択されない。トークナイズ済みの索引は語に割れているため、前方一致がうまく当たらないのです。
回避策として「トークナイザーを指定しない索引」を作れば前方一致は効きますが、その索引では今度は全文検索ができなくなります。全文検索用(トークナイズあり)と前方一致用(トークナイズなし)は要件が逆で、1つの索引で両立しにくい、という設計上のトレードオフです。
一方 ESの match_phrase_prefix は10.8msで4579件を正しく返します。前方一致を多用するなら、この一点だけでもESを選ぶ理由になります。
issue自体は古いバージョン(2019年頃)のものですが、本検証の現行 PGroonga 4.0.6 でも Seq Scan が再現しており、素直に使う限りこの制約は生きています。索引を作り込めば改善の余地はあるはずですが、「素の &^ はそのままでは効かない」というのが実測の結論です。
タイポ許容:Elasticsearchのfuzzinessが効く領域
同じタイポ「コミニュケーション」(正しくは「コミュニケーション」)を両エンジンに投げました。
| エンジン | クエリ | ヒット |
|---|---|---|
| PostgreSQL | body &@~ 'コミニュケーション' |
0 |
| Elasticsearch |
match + fuzziness: AUTO
|
2272 |
PGroongaの素の演算子は完全一致ベースなのでタイポを一切拾えず0件。ESは fuzziness で綴りのゆらぎを吸収し、正しい方の文書2272件を拾いました。「打ち間違いに強い検索窓」を素直に作りたいなら、ESの fuzziness は一発で効きます。PGroongaでも曖昧検索はできますが別の設定・構文が必要で、この手軽さの差は実務で効いてきます。
補足:素直なクエリでも、大規模だと結果がズレる
小規模(数千件)では単語・フレーズ・AND・ORでPGとESのヒット件数が一致していたのですが、10万件の実データでは ANDで PG 42件 vs ES 233件 と乖離が出ました。PGroonga(MeCab)とES(kuromoji)の形態素解析の辞書・分割の違いが、多様な実語彙で顕在化したものと考えられます。どちらが「正しい」かは用途次第で、ここは今回は深掘りしていません。ただ 「形態素エンジンが違えば、素直なクエリでも拾う範囲は微妙に変わる」 ことは頭に入れておく必要があります。
考察:境界線はどこか
実測をまとめると、境界線は「規模」だけでなく クエリの性質 で引けます。
PostgreSQL(PGroonga)で十分な領域
- データが既にPostgreSQLにあり、検索のためだけに別システムを増やしたくない
- 中規模まで(今回の10万件級なら余裕)
- 検索が 絞り込める(フレーズ・条件ANDなど、少数ヒット中心)。この帯域はPGが桁違いに速い
- SQLのJOINやトランザクションと検索を混ぜたい
Elasticsearchが効き始める領域
- 高頻度語で大量ヒットする検索が多い(大量マッチのスコアリングと取り出しで有利)
- 前方一致を多用する(PGroongaは索引が効かずSeq Scanに落ちる/issue #96)
-
タイポ許容を手軽に入れたい(
fuzziness一発) - ファセット集計・ハイライトなど検索特化機能を使い込む
- データがさらに大規模で、検索が主役のワークロード
運用コストも忘れずに。PGroongaは「1システムで完結」が最大の武器で、DBを1つ運用するだけ。ESは強力ですが、別システムを立てて・同期して・監視する分の重さが乗ります。「検索の要件がまだ軽いうちはPGroongaで十分、要件が検索特化に振れてきたらESへ」 というのが素直な移行ラインです。
まとめ:使い分け早見表
実測を経て
| シーン | 選ぶべき | 決め手(実測) |
|---|---|---|
| 日本語で全文検索したい(PostgreSQL標準FTS) | 不可 → PGroongaへ | 標準FTSは日本語が割れずヒット0 |
| 絞り込めるクエリ(フレーズ・AND)中心・中規模まで | PostgreSQL(PGroonga) | フレーズ 0.6ms vs ES 9.7ms |
| 高頻度語の大量ヒットが多い | Elasticsearch | 単語「東京」PG 45.7ms vs ES 7.8ms |
| 前方一致を多用 | Elasticsearch | PG 4987ms(Seq Scan)vs ES 10.8ms |
| タイポ許容を手軽に | Elasticsearch | PG 0件 vs ES 2272件 |
| とにかく運用を軽くしたい | PostgreSQL(PGroonga) | 1システムで完結 |
「PostgreSQLの全文検索はどこまで戦えるか?」の答えは、「絞り込めるクエリと中規模までなら十分すぎるほど戦える。ただし高頻度語の大量ヒット・前方一致・タイポ許容の3つはElasticsearchの土俵」 でした。
あとがき
OSSDBの勉強だ!と意気込んで環境作って遊んでいましたが、この辺の知識って試験に出るの?
タイトルでvsと銘打ったのに、結局適材適所、に逃げてしまいました。すみません。
てか、全文検索に詳しくなっただけでは。。。
🛠 ハマったところ・構築Tips
検証中に踏んだ地雷をまとめておきます。
-
PGroongaのインデックスサイズが
pg_relation_sizeで0になる: PGroongaは実データをGroonga独自ファイル(pgrn*)に持つため、pg_relation_sizeでは測れない。duで実ファイルを合算する -
Wikipediaデータの入手: 以前使えた abstract ダンプは2025年2月に廃止、旧CirrusSearchダンプも2026年に非推奨化。配置がよく変わるので、安定して入手できる Hugging Faceの
wikimedia/wikipediaを使うのが楽(wikitext除去済み・streaming対応)
参考リンク
- PGroonga 公式ドキュメント(https://pgroonga.github.io/ja/)
- PGroonga issue #96「前方一致検索で期待した行がヒットしない」(https://github.com/pgroonga/pgroonga/issues/96)
- Elasticsearch 日本語(kuromoji)アナライザ(https://www.elastic.co/guide/)
- Hugging Face
wikimedia/wikipediaデータセット(https://huggingface.co/datasets/wikimedia/wikipedia)
計測はあくまで単一ノード・ローカル環境での一例です。PostgreSQL・ES双方ともチューニング余地があり、数字は環境で変わります。ワークロードで実測して判断するのが確実です。