GINインデックス(Generalized Inverted Index)は、配列・JSONB・全文検索用のtsvectorのように「1つの値の中に複数の要素が入っているデータ」を検索するための、PostgreSQLの転置インデックスです。B-treeが行の値そのものを並べるのに対し、GINは値から要素を取り出してキーにし、そのキーを含む行を逆引きします。この記事では、PostgreSQL 18.6のマニュアルとソースを一次情報にしたうえで、100万行のテーブルで実際に取ったEXPLAIN ANALYZEの結果を使い、仕組み・作成方法・効かないケース・GiSTとの使い分け・更新と構築の運用までを整理します(2026年10月13日時点)。
まとめ:GINインデックスの要点
- GINは(キー, ポスティングリスト)の組を持つ転置索引です。同じキーは一度しか格納されないため、同じ要素が多くの行に現れるデータほど索引がコンパクトになります。
- 組み込みの演算子クラスは array_ops・jsonb_ops・jsonb_path_ops・tsvector_ops の4つで、LIKEの中間一致は拡張の pg_trgm(gin_trgm_ops)で扱います。
- 実行計画は必ず Bitmap Index Scan と Bitmap Heap Scan の組になります。100万行での LIKE ‘%a1b2c%’ は、全件走査の1,105ミリ秒がGINで3.9ミリ秒になりました。
- 索引を張っても効かない書き方があります。jsonb_path_ops に ? 演算子、-> を挟んだ条件、2文字以下のLIKE、LC_CTYPEがCのデータベースでの日本語は、いずれも Seq Scan でした。
- pg_trgmの同じLIKE検索(どちらもBitmap Index Scan)で、索引のページアクセスはGIN 33、GiST 82,880、サイズはGIN 212MB、GiST 654MBでした。両方を張るとプランナーがGiSTを選んだので、距離順の検索が要らないならGINだけを残すのが安全です。
- 更新は fastupdate(既定ON)で軽くなり、30万行の挿入はON 5.2秒、OFF 51.8秒でした。PostgreSQL 18の並列作成では構築が約3分の1に縮みました。
GINインデックスの仕組み:転置索引とBitmap Index Scan
GINは、索引対象の値(マニュアルでいう item)からキーを抽出し、(キー, ポスティングリスト)の組を格納します。ポスティングリストは、そのキーが出現する行ID(TID)の集合です。キー値は一度しか格納されないため、「tag42」というタグが5,000行に付いていても、キー自体は索引内に1つだけ存在します。定義と内部構造はPostgreSQL 18公式マニュアルのGIN Indexesが一次情報です。
ポスティングリストとポスティングツリーの切り替わり
内部はキーに対するB-treeで、リーフページの各タプルは2つの形を取ります。行IDのリストが小さくキー値と一緒に1つの索引タプルに収まるうちは単純なポスティングリストとして持ち、収まらなくなるとヒープポインタのB-tree、すなわちポスティングツリーへのポインタに切り替わります。頻出語のように行数が膨れるキーでも索引が破綻しないのはこの二段構えのためです。複数列のGIN索引は、(列番号, キー値)の複合値に対する単一のB-treeとして実装されます。
実行計画がBitmap Index Scanに固定される理由
GINは索引エントリに元の値の一部しか持たないため、Index Only Scanに対応していません。実装も同じで、PostgreSQL 18系のginutil.cではGINのアクセスメソッドは amgettuple が NULL、amgetbitmap のみが実装され、amcanreturn も NULL です。つまりGINは1行ずつ順に返す索引スキャンを提供できず、候補行のビットマップを作ってからテーブルを読む形にしかなりません。
100万行・各行5タグの配列列にGINを張り、1つのタグで絞った結果が次のとおりです。索引の読み取りは6ページで、5,069行の候補を2.5ミリ秒で返しています。
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT count(*) FROM items WHERE tags @> ARRAY['tag42'];
Aggregate (actual time=92.738..92.739 rows=1.00 loops=1)
-> Bitmap Heap Scan on items (actual time=3.473..74.759 rows=5069.00 loops=1)
Recheck Cond: (tags @> '{tag42}'::text[])
Heap Blocks: exact=4776
-> Bitmap Index Scan on t_tags (actual time=2.547..2.547 rows=5069.00 loops=1)
Index Cond: (tags @> '{tag42}'::text[])
Buffers: shared hit=1 read=5
Execution Time: 92.842 ms
同じクエリで SET enable_bitmapscan = off にすると、計画は Seq Scan(752ミリ秒)に落ちました。Index Scanへの切り替えが起きないことが、amgettuple を持たない実装の裏付けです。また ORDER BY id LIMIT 10 を付けると、GINではなく主キーのB-treeを順に読み、タグ条件をFilterで判定する計画になりました。GINは並び順を返せないため、並べ替えと件数制限を伴うクエリでは別の索引に役割を奪われます。計画の読み方はPostgreSQLの実行計画の読み方|EXPLAIN ANALYZEと見積もり乖離の診断で扱っています。
GINインデックスを作成する:データ型別の演算子クラス
作成構文は通常のCREATE INDEXに USING gin を付けるだけで、対象データ型に応じて演算子クラスを選びます。組み込みは array_ops(&&、@>、<@、=)、jsonb_ops(@>、@?、@@、?、?|、?&)、jsonb_path_ops(@>、@?、@@)、tsvector_ops(@@)の4つです。
-- 前提:items(tags text[], doc jsonb, body text)。必要な索引だけを選んで作る
-- 配列(array_ops が既定)
CREATE INDEX idx_tags ON items USING gin (tags);
-- JSONB(既定は jsonb_ops、? 演算子を使わないなら jsonb_path_ops)
CREATE INDEX idx_doc ON items USING gin (doc);
CREATE INDEX idx_doc_path ON items USING gin (doc jsonb_path_ops);
-- 全文検索(tsvector_ops)
CREATE INDEX idx_body_tsv ON items USING gin (to_tsvector('english', body));
-- LIKE の中間一致(pg_trgm 拡張が必要)
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX idx_body_trgm ON items USING gin (body gin_trgm_ops);
JSONBのjsonb_opsとjsonb_path_opsの選び方
jsonb_path_ops はキー存在演算子(?、?|、?&)を扱えない代わりに、対応する演算子については性能が良いとマニュアルが述べています。差が出るのは ? を使うクエリです。doc ? 'deleted_at' は、jsonb_ops の索引があると0.26ミリ秒で終わり、jsonb_path_ops だけにすると Seq Scan で378ミリ秒かかりました。サイズは今回のような浅い構造のJSONB(キー3つ)で jsonb_ops 64MB、jsonb_path_ops 61MBとほぼ同じだったので、? を使う可能性が少しでもあるなら既定の jsonb_ops で始めるのが無難です。JSONB列を正規化列に切り出す判断はPostgreSQL JSONBの使い方|json型との格納差・GIN索引と正規化列の設計境界に分けています。
btree_ginでスカラー列と組み合わせる複数列GIN
GINは複数列索引を作れますが、整数やテキストのようなスカラー列を混ぜるには btree_gin 拡張の演算子クラスが要ります。テナントIDとタグ配列で絞るクエリに USING gin (tenant_id, tags) の1本を張ると、100万行から156行を2.96ミリ秒で返し、索引は13MBでした。マニュアルは、btree_gin の演算子クラスは通常のB-treeより速くはならず、一意性も強制できないと断ったうえで、GIN向きの列とB-tree向きの列を同時に条件にするクエリでは、2本の索引をBitmapANDさせるより複数列GIN1本のほうが効率的な場合があると述べています。
pg_trgmによるLIKE中間一致の検索
pg_trgm の gin_trgm_ops を使うと、LIKE・ILIKE・正規表現に対して3文字組(トライグラム)単位の索引検索が効きます。前後にワイルドカードを置く中間一致でも索引が使えるのが利点で、100万行(各行はMD5の16進文字列3つを空白でつないだ約100文字)での body LIKE '%a1b2c%' は、全件走査の1,105ミリ秒に対し、GINでは3.9ミリ秒(キャッシュ済みの2回目)でした。索引から127行の候補が返り、そのうち61行は再チェックで外れています。距離演算子(<-> など)による近い順の取得はGiSTなら効率的に実装できますがGINではできない点は、マニュアルが明記しています。
GINインデックスが効かないケースと実行計画(実測)
索引を張っても、書き方やデータの条件しだいでプランナーはGINを使いません。PostgreSQL 18.6で確かめた5つのケースです。
| ケース | 実行計画 | 対処 |
|---|---|---|
| jsonb_path_opsだけで doc ? ‘key’ | Seq Scan(378ms) | キー存在検索にはjsonb_opsを使う |
| doc -> ‘tags’ ? ‘t7’ | Seq Scan | 式インデックス/@>の包含形 |
| LIKE ‘%ff%’(2文字) | Seq Scan(1,036ms) | 3文字以上にする/pg_bigm |
| 全行が持つキーで doc ? ‘status’ | Seq Scan | 正しい判断なので対処不要 |
| 同じ列にGiSTトライグラム索引が併存 | GiSTを選択(4,653ms) | 距離順が不要ならGINだけ残す |
2つ目は見落としやすい形です。索引可能な演算子でも、索引列そのものに直接かかっていなければ使われません。doc列にGINを張っても doc -> 'tags' ? 't7' は Seq Scan のままで、CREATE INDEX ON items USING gin ((doc -> 'tags')) の式インデックスにするか、doc @> '{"tags": ["t7"]}' の包含形に書き換えます。
3つ目の2文字検索は、トライグラムを1つも取り出せないことが原因です。show_trgm('ab') の結果は単語の前後に空白を補った {" a"," ab","ab "} だけで、中間一致では使えません。マニュアルも、トライグラムを抽出できないパターンは索引全体の走査に退化すると書いています。今回は enable_seqscan をOFFにしてもGINは選ばれず、B-tree索引の全件読みで1,196ミリ秒でした。
5つ目は意外な結果でした。同じ body 列に gin_trgm_ops と gist_trgm_ops の両方を張った状態で LIKE を実行すると、プランナーはGiSTを選び、索引だけで82,880ページを読んで4,653ミリ秒かかりました。GiSTを削除するとGINが選ばれ、読み取り33ページ・3.9ミリ秒(2回目の実行)です。GiSTの1回目はキャッシュが冷えた状態なので時間は単純比較できませんが、ページアクセス数(Buffers)はキャッシュの状態に左右されず、索引の読み取り量の差は約2,500倍です。プランナーが常に軽い側を選ぶとは限らないため、GiSTを残すのは <-> による距離順の検索など、GINで代替できない用途がある場合に絞り、削除の前には他のクエリの計画も同じ条件で確かめます。
日本語の全文検索でGINを使う前提
日本語の解説記事には to_tsvector('japanese', 本文) にGINを張る例が見られますが、PostgreSQL 18.6の標準配布で実行すると ERROR: text search configuration "japanese" does not exist になります。標準のテキスト検索設定は arabic から yiddish までの30種類で、日本語はありません。単語を区切らない日本語を simple 設定に通すと、'PostgreSQLのGINインデックスで全文検索する' の全体が1語として登録されるだけです。PGroongaの公式サイトも、PostgreSQL標準の全文検索は日本語や中国語に対応しないと説明しています。
pg_trgm にも落とし穴があります。pg_trgm は英数字以外の文字を無視してトライグラムを作りますが、その判定はデータベースの LC_CTYPE に従います。実測では次のとおりでした。
| データベースのロケール | show_trgm(‘データベース’) | LIKE ‘%データベース%’ |
|---|---|---|
| LOCALE ‘C’(libc) | {}(空) | Seq Scan |
| builtin C.UTF-8 | {}(空) | Seq Scan |
| ja_JP.UTF-8(libc) | 7個 | Bitmap Index Scan(0.57ms) |
原因はソースにあります。ts_locale.c の文字種判定は、データベースのctypeがCのとき、マルチバイト文字でも先頭バイトだけで英数字かどうかを判定するため、日本語は常に英数字ではない扱いになります。builtin プロバイダの C.UTF-8 で作ったデータベースも pg_database の datctype は C のままなので、同じ結果でした。ja_JP.UTF-8 のデータベースでも、2文字の LIKE '%設計%' は Seq Scan です。
日本語の部分一致を索引で捌くなら、2-gramで索引を作る pg_bigm(最新リリース v1.2-20250903、PostgreSQL 9.1〜18でテスト済み、1〜2文字の検索も高速と公式の比較表に記載)か、Groongaを索引に使う PGroonga(最新版4.1.0、2026年10月9日公開)を使います。DBの外に検索エンジンを立てる選択肢との比較はElasticsearchの日本語全文検索|kuromojiのanalyzer構成と辞書追加、N-gram併用の判断が参考になります。
GiST・B-treeとの使い分け
GINと競合するのは主にGiSTで、全文検索とトライグラムでは両方が使えます。GiSTが可逆か非可逆かは演算子クラスによって異なります。全文検索の tsvector_ops とトライグラムの gist_trgm_ops は各値を固定長のシグネチャで表す非可逆な方式で、ビットが衝突すると偽陽性が出るため、テーブルを読んで確かめる処理が増えます。GINも pg_trgm では再チェックが入り、今回の検索でも127行の候補から61行が外れました。シグネチャ長 siglen の既定値は全文検索(tsvector)のGiSTは124バイト、pg_trgm の gist_trgm_ops は12バイトです。
| 観点 | GIN | GiST | B-tree |
|---|---|---|---|
| 対象 | 配列/JSONB/tsvector/トライグラム | tsvector/幾何/範囲型 | スカラー値の比較 |
| 再チェック | 演算子クラス・条件次第 | 演算子クラス次第 | 不要 |
| Index Only Scan | 不可 | 演算子クラス次第で可 | 可 |
| UNIQUE索引 | 不可 | 不可 | 可 |
| 近い順の取得 | 不可 | 可 | 対象外 |
| pg_trgm索引(実測) | 212MB・構築32秒 | 654MB・構築137秒 | 127MB(中間一致は不可) |
「GINの検索はGiSTの約3倍速く、構築は約3倍遅く、更新は約10倍遅い」という数値を引く記事が今も多く見られます。この一覧はPostgreSQL 9.4までのマニュアルに載っていたもので、9.5以降の該当ページからは削除されています。今回のトライグラム索引では、構築もGINのほうが速く(max_parallel_maintenance_workers=4の同条件でGIN 32秒、並列作成に対応しないGiSTは137秒)、サイズはGiSTが約3倍でした。古い比率を選定の根拠にせず、自分のデータで pg_relation_size と EXPLAIN ANALYZE を取り直してください。
fastupdateとペンディングリストの運用
GINは1行の挿入で複数のキーが増えるため、更新が重くなりがちです。これを緩和するのが fastupdate で、既定はONです。新しいエントリはいったん未整列のペンディングリストに入り、VACUUMまたはautoanalyze時、gin_clean_pending_list() の呼び出し時、あるいはリストが gin_pending_list_limit(既定4MB)を超えたときに、本体側へ一括で移されます。
差は大きく、5タグの配列を持つ30万行を空のテーブルへ挿入したところ、fastupdate=on は5.2秒、fastupdate=off は51.8秒でした。挿入直後のONの索引には、pgstatginindex() で97ページ・7,863タプルのペンディングエントリが残っており、gin_clean_pending_list() は処理したペンディングページ数として97を返し、エントリは本体へ統合されました。
代償は検索側にあります。検索はペンディングリストも走査するため、リストが大きいほど遅くなります。さらに、リストが上限を超えた瞬間の更新は前景でクリーンアップを走らせるので、その1回だけ極端に遅くなります。応答時間のばらつきを避けたいシステムでは fastupdate をOFFにします。ALTER INDEXでOFFにしても既存のペンディングエントリは自動で吐き出されない点に注意してください。
-- 溜まり具合を測る(pgstattuple 拡張)
CREATE EXTENSION IF NOT EXISTS pgstattuple;
SELECT pending_pages, pending_tuples FROM pgstatginindex('idx_tags');
-- 索引単位で上限を変える(格納パラメータはキロバイト単位の整数)
ALTER INDEX idx_tags SET (gin_pending_list_limit = 32768);
-- 応答時間を優先して無効化し、残りを明示的に整理する
ALTER INDEX idx_tags SET (fastupdate = off);
SELECT gin_clean_pending_list('idx_tags');
間違えやすいのが単位です。サーバ変数の gin_pending_list_limit は 32MB のように単位付きで書けますが、索引の格納パラメータ側はキロバイト単位の整数しか受け付けず、gin_pending_list_limit = '32MB' は invalid value for integer option "gin_pending_list_limit": 32MB で弾かれました。クリーンアップを前景に出さない現実的な運用は、autovacuumを対象テーブルに十分効かせることです。しきい値の考え方はPostgreSQLのVACUUM運用|autovacuumのしきい値設計とXID周回・肥大の切り分けにまとめています。
GINインデックスの構築時間を縮める:並列作成とmaintenance_work_mem
PostgreSQL 18のリリースノートに、GIN索引の並列作成(Tomas Vondra、Matthias van de Meent)が入りました。ワーカー数の上限は max_parallel_maintenance_workers(既定2)で、実際に起動する数はテーブルの大きさと maintenance_work_mem でも変わります。CREATE INDEXのマニュアルによると、並列ワーカーには maintenance_work_mem のうち1プロセスあたり32MB以上の取り分が要り、さらにリーダープロセス用にも32MBが残っている必要があるので、メモリも一緒に上げます。手元の計測は次のとおりです(並列はワーカー上限を4にした測定)。
| 索引(データ) | 条件 | 構築時間 |
|---|---|---|
| gin_trgm_ops(100万行) | 直列・64MB | 98.2秒 |
| gin_trgm_ops(100万行) | 直列・1GB | 101.9秒 |
| gin_trgm_ops(100万行) | ワーカー上限4・1GB | 32.0秒 |
| tsvector(50万文書・各40語) | 直列・16MB/64MB/1GB | 105.4/99.5/101.3秒 |
| tsvector(50万文書・各40語) | ワーカー上限4・1GB | 30.9秒 |
ワーカー上限を4にすると約3分の1に縮んだ一方、直列のまま maintenance_work_mem を16MBから1GBへ上げても差は数%に収まりました。GINのマニュアルは構築時間が maintenance_work_mem に強く左右されると書いていますが、今回のデータでは効いたのは並列化のほうです。CREATE INDEXのマニュアルにも、同じメモリ増でも並列構築では効き、直列構築ではほとんど効かない場合があるとの記述があります。大きなテーブルの初期構築やREINDEXでは、まず max_parallel_maintenance_workers を上げ、その人数分のメモリを確保するのが先です。
GINの制限とGINを選ばない判断
- UNIQUE索引は作れません。
CREATE UNIQUE INDEX ... USING ginはaccess method "gin" does not support unique indexesで失敗します。一意性が必要な列には別途B-treeの一意索引を張ります。 - 大量投入の前は索引を落とす。1行あたり多数のキーが挿入されるため、マニュアルはバルクロードでは索引を削除してから作り直すことを勧めています。
- 結果が多すぎる全文検索には gin_fuzzy_search_limit。返す行数のソフト上限で既定は0(無制限)、マニュアルは経験則として5000〜20000を挙げています。結果は全体からランダムに選ばれた部分集合になるため、件数の正確さが要る画面には使えません。
- 破損の検査は gin_index_check()。PostgreSQL 18でamcheckに追加されました。
CREATE EXTENSION amcheck;のあとSELECT gin_index_check('t_tags');を実行すると、異常が無ければ戻り値が空欄の1行(void)が返り、破損があればエラーになります。
次のメジャー版の PostgreSQL 19 は、2026年9月24日公開のBeta 4の段階です。19のリリースノートにはストリーミング読み取りによるGIN索引のVACUUM高速化が載っており、更新の多いテーブルの保守時間に効く変更です。その他の変更点はPostgreSQL 19の新機能|Beta 4時点の確定機能と差し戻し・18からの移行で壊れる箇所にまとめています。
逆に、GINを選ぶべきでない条件もはっきりしています。第一に、等価比較や範囲検索しか使わない列です。B-treeのほうが速く、Index Only Scanも一意制約も使えます。値の種類が多いか少ないかで索引の効き方が変わる点はカーディナリティとは?意味・高低の見分け方とインデックス設計で整理しています。第二に、更新が絶え間なく走るうえに応答時間のばらつきも許されない列です。fastupdateをONにすれば検索がペンディングリストの走査で遅くなり、OFFにすれば書き込みが重くなるという交換条件からは逃げられません。第三に、検索スコア順の並べ替えを索引側で解決したい場合です。GINは順序付き出力を返せないため、全文検索のスコア順を索引で扱うなら拡張のRUMを検討します。GiSTで索引から近い順に取り出せるのは、pg_trgm の <-> のように距離演算子を持つ演算子クラスに限られ、標準の tsvector_ops にはありません。既存システムの索引設計をまとめて見直す、あるいはDB移行に合わせて組み直す場合は、データベース設計・移行支援でご相談を受け付けています。
よくある質問
GINインデックスとは何の略で、どう呼びますか?
Generalized Inverted Index(汎用転置インデックス)の略です。PostgreSQLマニュアルの65.4節に定義があり、複合的な値の中の要素を検索するケース向けに設計された索引だと説明されています。公式に定められたカタカナ表記はなく、日本語の文書でも英字のままGINと書かれるのが通例です。
GINインデックスが使われているかはどう確認しますか?
EXPLAINで実行計画を見ます。GINが使われていれば Bitmap Index Scan on 索引名 と、その上位に Bitmap Heap Scan が現れます。Index Scan や Index Only Scan と表示されることはGINでは起こりません。Seq Scan のままなら、演算子が索引列に直接かかっているか、演算子クラスが対応しているかを確認します。
jsonb_ops と jsonb_path_ops はどちらを選ぶべきですか?
キー存在演算子(?、?|、?&)を使うなら既定の jsonb_ops しか選べません。検索が包含(@>)やjsonpath(@?、@@)に限られるなら jsonb_path_ops が候補になります。浅い構造のJSONBではサイズ差が小さかったため、迷ったら jsonb_ops です。
GINとGiSTはどちらを使うべきですか?
LIKEの中間一致や配列・JSONBの包含検索ならGINです。今回のpg_trgm索引では、GINはGiSTの約3分の1のサイズで、同じLIKE検索(どちらもBitmap Index Scan)での索引のページアクセス数はGIN 33、GiST 82,880でした。近い順の取得(<-> と ORDER BY … LIMIT)や範囲型・幾何型ならGiSTを選びます。
btree_gin は何のために使いますか?
整数・テキスト・日付などのスカラー列をGINの複数列索引に含めるための拡張です。テナントIDとタグ配列のように、GIN向きの列とB-tree向きの列を同時に条件にするクエリで1本の索引にまとめられます。単独のスカラー列の検索ではB-treeより速くならず、一意性も強制できません。