🔍

PostgreSQLで実現するエンタープライズ日本語全文検索 - PGroonga + MeCab実践ガイド

に公開

はじめに

データ量が爆発的に増加する現代において、効率的なテキスト検索機能はデータベースシステムの重要な要素となっています。特にブログプラットフォーム、ECサイトの商品検索、社内ドキュメント管理システムなど、多くの実務アプリケーションで日本語全文検索が求められます。

PostgreSQLは世界で最も先進的なオープンソースリレーショナルデータベースの一つですが、標準の全文検索機能は主に欧米言語を対象としており、日本語のような分かち書きをしない言語の検索には課題がありました。

この記事では、PGroonga拡張機能とMeCab形態素解析器を組み合わせることで、PostgreSQL上に高精度な日本語全文検索システムを構築する方法を解説します。Dockerで完全に再現可能な環境構築から、実務で必要となる複合条件検索、パフォーマンスチューニング、運用監視まで、実践的な内容をカバーします。

動作するコンテナはGitHubで公開しています。cloneしてご利用ください。

PGroongaとは何か

PGroongaは、高速全文検索エンジンGroongaをベースにしたPostgreSQL拡張機能です。従来のPostgreSQLの全文検索(GIN/GiST)と比較して、以下の特徴があります。

  • 日本語、中国語などの分かち書きがない言語に対応
  • MeCabなどの形態素解析器と統合可能
  • 高速な検索パフォーマンス
  • 前方一致、後方一致、正規表現検索に対応
  • リアルタイムインデックス更新

全文検索アーキテクチャの選択: PGroonga vs Redis vs Elasticsearch

全文検索を実装する際、アーキテクチャの選択は非常に重要です。ここでは、PGroongaと他の主要な選択肢を公平に比較します。

PGroongaの利点

  • PostgreSQL内での完結

    • データベース内で全文検索が完結するため、アーキテクチャがシンプルです
    • データの一貫性とトランザクション保証がPostgreSQLの機能をそのまま利用可能です
    • 別システムへのデータ同期が不要で、整合性の問題が発生しません
    • バックアップ・リストア・レプリケーションがPostgreSQLの標準機能で実現できます
  • 運用コストの削減

    • 追加のサーバー・サービスが不要で、インフラコストを抑制できます
    • 監視・運用対象がPostgreSQLのみで、チームの学習コストが低いです
    • 中小規模のアプリケーション(数百万〜数千万レコード)では十分な性能です
  • 開発の容易性

    • SQLで全文検索を記述できます
    • ただし、PGroonga固有の演算子(&@~など)を使用するため、ORMの抽象化レイヤーでは直接サポートされず、生SQLや文字列クエリの記述が必要になります
    • 複雑なJOINや集計と全文検索を1つのクエリで実行可能です
    • データ移行やETL処理が不要です

PGroongaの欠点・制約

  • マネージドクラウドサービスでの制限
    • AWS RDS PostgreSQL: PGroonga拡張はサポート対象外(2025年時点)
    • Google Cloud SQL for PostgreSQL: PGroonga拡張はサポート対象外
    • Azure Database for PostgreSQL: PGroonga拡張はサポート対象外
    • Heroku Postgres: 拡張インストールに制限あり

これらのマネージドサービスでは、スーパーユーザー権限やシステムライブラリのインストールが制限されているため、PGroongaを導入できません。セルフホストまたはコンテナベース(Docker/Kubernetes)の運用が必須となります。

  • 性能のスケーラビリティ

    • 数億レコード以上の超大規模データでは、専用検索エンジンと比較して性能が劣ります
    • 検索負荷が高い場合、PostgreSQLのリソースと競合する可能性があります
    • インデックス更新時にPostgreSQLのI/O負荷が増加します
  • PostgreSQL設定の調整が必要

    • shared_bufferswork_memなどのメモリ設定調整を強く推奨します
    • maintenance_work_memの調整でインデックス構築が高速化します
    • クラウド環境では、これらの設定変更に制限がある場合がある

Redisの利点

  • 高速性と軽量性

    • インメモリデータストアで、ミリ秒未満の応答時間です
    • RediSearch/RedisStackモジュールで全文検索をサポートしています
    • キャッシュと検索を統合して運用可能です
  • クラウド対応

    • AWS ElastiCache、GCP Memorystore、Azure Cacheなど、主要クラウドでマネージドサービスが利用可能です
    • スケールアウトが容易で、高トラフィックに対応しています

Redisの欠点

  • 日本語全文検索の制約

    • RedisSearchはデフォルトで日本語の形態素解析をサポートしていません
    • トークナイザーがスペース区切りを前提としているため、日本語では単語単位の検索が困難です
    • アプリケーション側で事前に分かち書きを行うか、N-gram方式での保存が必要になります
    • PGroongaやElasticsearchと比較して、日本語検索の精度や柔軟性が劣ります
  • データ同期の複雑性

    • PostgreSQLからRedisへのデータ同期としてCDC、Debezium、カスタムスクリプトなどが必要です
    • 同期遅延や障害時のデータ不整合のリスクがあります
    • トランザクション保証がなく、更新の原子性を保証できません
  • 運用コストの増加

    • 別システムとして監視・運用が必要です
    • インフラコストが追加で発生します
    • データの永続化設定(RDB/AOF)によってはデータ損失のリスクがあります

Elasticsearchの利点

  • 超大規模データ対応

    • 数十億レコード以上のデータでも高速検索できます
    • 分散アーキテクチャで水平スケールが容易です
    • 強力な集計機能(Aggregations)と可視化ツール(Kibana)があります
  • 高度な検索機能と日本語対応

    • ファジー検索、地理情報検索、複雑なスコアリングといった機能があります
    • Kuromojiプラグインで日本語形態素解析を標準サポートしています
    • N-gramトークナイザーとの併用で検索精度を向上できます
    • 異体字統一や正規化にはICUプラグインが利用可能です

Elasticsearchの欠点

  • 日本語検索の初期設定コスト

    • Kuromojiプラグインのインストールと適切なマッピング設定が必須です
    • 形態素解析用の辞書メンテナンス(カスタム辞書、同義語辞書)が必要になる場合があります
    • N-gramとの併用ではインデックスサイズが大幅に増加します(2~3倍以上)
    • アナライザー設定のミスでインデックス再構築が必要になるリスクがあります
  • 運用の複雑性

    • クラスター構成・シャーディング・レプリカ管理が必要です
    • データサイズの2〜3倍ほどのメモリ・ディスクの大量消費が発生します
    • チューニングに専門知識が必要です
  • データ同期の課題

    • PostgreSQLからElasticsearchへのリアルタイム同期が必要になります
    • Logstash、カスタムスクリプト、Change Data Captureの実装が必要になります
    • 同期遅延や障害時の整合性確保が課題になります

選択基準のガイドライン

  • PGroongaを選ぶべきケース

    • データ規模: 数百万〜数千万レコード程度
    • 運用環境: セルフホストまたはコンテナベース(Docker/Kubernetes)
    • 開発リソース: PostgreSQL管理のみで完結させたい
    • 要件: トランザクション保証とデータ一貫性が重要
  • Redisを選ぶべきケース

    • データ規模: 小〜中規模で、超高速応答が必要
    • 運用環境: クラウドマネージドサービスを活用
    • 要件: キャッシュと検索を統合したい
    • 許容: 若干のデータ同期遅延が許容される
    • 注意: 日本語全文検索が主要機能の場合は他の選択肢を検討すべき
  • Elasticsearchを選ぶべきケース

    • データ規模: 数億レコード以上の大規模データ
    • 要件: 複雑な検索機能・高度な分析が必要(日本語全文検索を含む)
    • 運用体制: 専門チームがある、またはマネージドサービス利用
    • 予算: インフラ・運用コストが確保されている
    • 前提: Kuromojiプラグインの適切な設定と辞書メンテナンスが可能

クラウド環境での実践的な選択

マネージドPostgreSQLを使う場合

主要クラウドのマネージドサービスでは、PGroonga拡張はサポート対象外です。

サービス PGroonga対応 代替案
AWS RDS ❌ 非対応 RDS + ElastiCache (Redis) / OpenSearch Service
Cloud SQL ❌ 非対応 Cloud SQL + Memorystore (Redis) / Elasticsearch
Azure Database ❌ 非対応 Azure Database + Azure Cache / Cognitive Search

→ Redis(ElastiCache等)またはElasticsearch(OpenSearch Service等)を選択するのが現実的です。

PGroonga対応クラウドサービス

PGroongaを含むカスタムPostgreSQLイメージをDockerコンテナとしてデプロイ可能なクラウドサービスです。

サービス 対応方法 特徴 料金目安
Render Dockerデプロイ 本記事のDockerイメージをそのままデプロイ可能 $7/月〜
Fly.io カスタムコンテナ グローバルエッジデプロイ、低レイテンシ $5/月〜
Railway Dockerサポート 簡単なデプロイ、開発者フレンドリー 無料枠あり、$5/月〜
DigitalOcean App Platform Dockerサポート シンプルな構成、自動スケーリング $5/月〜

推奨構成例(Render):

# render.yaml
services:
  - type: web
    name: pgroonga-postgres
    env: docker
    dockerfilePath: ./Dockerfile
    envVars:
      - key: POSTGRES_PASSWORD
        generateValue: true
      - key: POSTGRES_DB
        value: postgres

コンテナオーケストレーション(ECS/EKS/GKE等)を使う場合

→ PGroongaをコンテナでデプロイ可能。本記事の構成が適用可能です。

Kubernetes構成例:

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: pgroonga-postgres
spec:
  serviceName: postgres
  replicas: 1
  selector:
    matchLabels:
      app: postgres
  template:
    metadata:
      labels:
        app: postgres
    spec:
      containers:
      - name: postgres
        image: your-registry/pgroonga-postgres:latest
        ports:
        - containerPort: 5432
        volumeMounts:
        - name: postgres-storage
          mountPath: /var/lib/postgresql/data
  volumeClaimTemplates:
  - metadata:
      name: postgres-storage
    spec:
      accessModes: [ "ReadWriteOnce" ]
      resources:
        requests:
          storage: 20Gi

セルフホストの場合

→ すべての選択肢が利用可能。要件に応じて選択してください。

本記事では、セルフホスト・コンテナ環境でのPGroonga実装に焦点を当て、PostgreSQL内で完結するシンプルで保守性の高い全文検索システムの構築方法を解説します。

転置インデックスの基本概念

全文検索エンジンの核心技術である転置インデックスについて説明します。転置インデックスは、文書内のキーワードを、そのキーワードを含む文書のリストにマッピングするデータ構造です。

例えば、以下のような文書セットがあるとします。

文書1: "PostgreSQL 全文検索入門"
文書2: "転置インデックスの仕組み"
文書3: "MeCab形態素解析の活用"

まず、形態素解析によって各文書をトークンに分割します。

文書1: ["PostgreSQL", "全文", "検索", "入門"]
文書2: ["転置", "インデックス", "の", "仕組み"]
文書3: ["MeCab", "形態素", "解析", "の", "活用"]

これらのトークンから、転置インデックスを構築します。

"PostgreSQL" → [文書1]
"全文" → [文書1]
"検索" → [文書1]
"入門" → [文書1]
"転置" → [文書2]
"インデックス" → [文書2]
"仕組み" → [文書2]
"MeCab" → [文書3]
"形態素" → [文書3]
"解析" → [文書3]
"活用" → [文書3]

検索処理の流れ

ユーザーが「全文検索」と検索すると、以下の処理が実行されます。

  1. トークン化: 検索キーワード「全文検索」を形態素解析で分割 → ["全文", "検索"]
  2. インデックス参照: 各トークンのポスティングリスト(文書リスト)を取得
    • "全文" → [文書1]
    • "検索" → [文書1]
  3. マージ処理: 複数トークンのリストを積集合演算(AND検索の場合)
    • [文書1] ∩ [文書1] = [文書1]
  4. 結果返却: 文書1を検索結果として返す

このように、転置インデックスは複数のトークン検索とマージ処理を経て高速な全文検索を実現します。

MeCabによる日本語形態素解析

日本語テキストを検索するには、文章を単語に分割する必要があります。MeCabは日本語形態素解析器の代表的なツールで、文章を品詞単位で正確に分割できます。

MeCab辞書の選択

MeCabでは複数の辞書が利用可能です。用途に応じて適切な辞書を選択することが重要です。

辞書 特徴 適用用途 辞書サイズ 本記事での採用
IPA辞書 汎用性が高く、標準的な語彙を網羅 一般的なWebアプリケーション、ブログ検索 約13MB ✅ 採用
NEologd 新語・固有名詞に強い(Wikipedia、はてなキーワード等から抽出) ニュースサイト、SNS分析 約200MB ⚠️ 2023年メンテナンス停止
UniDic 学術研究向け、解析精度が高い 自然言語処理研究、高精度な形態素解析 約50MB 高精度だがオーバースペック

本記事でIPA辞書を選択した理由:

  • 汎用的な日本語テキスト検索に十分な精度である
  • 辞書サイズが小さく、Dockerイメージのサイズを抑制できる
  • 長期的に安定したメンテナンスがされている
  • ビジネス文書・技術ブログの検索に最適である

MeCabによる形態素解析の実例

例えば、「日本語の形態素解析を行います」という文章は、MeCabによって以下のように分割されます。

日本語    名詞,一般
の        助詞,連体化
形態素    名詞,一般
解析      名詞,サ変接続
を        助詞,格助詞
行い      動詞,自立
ます      助動詞

この形態素解析により、ユーザーが「形態素」で検索した場合に、「形態素解析」を含む文書を正確にヒットさせることができます。名詞・動詞・形容詞などの意味を持つ単語(自立語)が検索キーワードとして抽出され、助詞などの機能語は検索対象から除外されます。

Docker環境でのPGroonga + MeCabセットアップ

実際にPGroongaとMeCabを組み込んだPostgreSQL環境を構築します。

構成についてはGitHubで公開していますのでcloneしてご利用ください。

公式イメージとの違い

PGroongaプロジェクトは公式Dockerイメージを提供していますが、本記事では独自にDockerfileを作成しています。その理由と利点は以下の通りです。

公式イメージ(ghcr.io/pgroonga/pgroonga)の特徴

  • 提供内容: PostgreSQL + PGroonga + Groonga
  • トークナイザー: デフォルトのTokenBigram(バイグラム方式)
  • MeCab: 含まれていない
  • 用途: シンプルな日本語検索には十分

参考: PGroonga公式Docker - Alpine 17

本記事のカスタムイメージの利点

  1. MeCab形態素解析の統合

    • TokenMecabトークナイザーが利用可能
    • 単語単位の正確な検索が可能(「形態素」で「形態素解析」がヒット)
    • バイグラムよりも高精度な日本語検索
  2. マルチステージビルドによる最適化

    • ビルドツールを含まない軽量な実行イメージ
    • イメージサイズの削減(不要なビルド依存を除外)
  3. カスタマイズ性

    • バージョンの固定(PGroonga 4.0.4、Groonga 15.1.7、MeCab 0.996.12)
    • 辞書やトークナイザーの追加が容易
    • プロジェクト固有の要件に対応可能
  4. 実運用を想定した構成

    • 初期化スクリプトによる自動セットアップ
    • サンプルデータとクエリ例の同梱
    • Docker Composeによる即座の起動

ディレクトリ構成

pgroonga/
├── Dockerfile
├── compose.yaml
├── alpine/
│   └── build.sh
└── init/
    ├── 01-pgroonga-setup.sql
    └── 02-sample-data.sql

実装ファイル

Dockerfile

マルチステージビルドで最小サイズを実現しています

  1. ビルドステージ: PostgreSQL 17 Alpine + 開発ツール群でソースビルド
  2. 実行ステージ: 最小限のランタイム依存のみ + ビルド成果物をコピー
  • 主な特徴
    • PGroonga 4.0.4、Groonga 15.1.7を指定
    • ビルドスクリプト(alpine/build.sh)を実行して3つのコンポーネントをビルド
    • 実行環境には必要なバイナリ・ライブラリのみを転送

alpine/build.sh

3つのコンポーネントを順次ソースビルドします

  1. MeCab 0.996.12: 日本語形態素解析エンジン本体
  2. MeCab IPA辞書: 形態素解析用の辞書データ
  3. Groonga: MeCab連携を有効化してビルド
  4. PGroonga: Groonga連携とメッセージパック対応を有効化

各コンポーネントは/usr/local配下にインストールされ、ビルド後の一時ファイルは削除してイメージサイズを削減します。

初期化スクリプト(init/01-pgroonga-setup.sql)

コンテナ起動時に自動的にPGroonga拡張を有効化し、サンプルデータを作成します。

-- PGroongaエクステンションを有効化
CREATE EXTENSION IF NOT EXISTS pgroonga;

-- 日本語全文検索のサンプルテーブルとインデックスを作成
CREATE TABLE IF NOT EXISTS pgroonga_sample (
  id serial PRIMARY KEY,
  content text
);

-- PGroongaインデックスを作成(MeCab形態素解析を使用)
CREATE INDEX IF NOT EXISTS idx_pgroonga_sample_content
  ON pgroonga_sample
  USING pgroonga (content)
  WITH (tokenizer='TokenMecab',
        normalizer='NormalizerNFKC150');

-- サンプルデータを挿入
INSERT INTO pgroonga_sample (content) VALUES
  ('PGroongaは日本語全文検索に対応しています'),
  ('名詞、動詞、形容詞を適切に認識します'),
  ('高速な検索が可能です')
ON CONFLICT DO NOTHING;

Docker Composeファイル(compose.yaml)

PostgreSQLコンテナの起動と設定を定義:

  • ビルド設定: Dockerfileからカスタムイメージをビルド
  • 環境変数: PostgreSQLユーザー・パスワード・DB名、UTF-8エンコーディング、タイムゾーン設定
  • ポート公開: 5432番ポートをホストに公開
  • ボリューム:
    • データ永続化用の名前付きボリューム
    • init/ディレクトリをマウントして初期化スクリプトを自動実行
  • ヘルスチェック: pg_isreadyで10秒ごとにPostgreSQLの起動状態を監視
  • 共有メモリ: 128MBに設定(複雑なクエリ実行用)

ビルドと起動

# イメージをビルド
docker compose build --no-cache

# コンテナを起動
docker compose up -d

# PostgreSQLに接続
docker exec -it pgroonga-postgres psql -U postgres

実践的な検索クエリの実装

基本的なキーワード検索

PGroongaでは&@~演算子を使用して全文検索を行います。

-- テストテーブルを作成
CREATE TABLE tech_articles (
  id SERIAL PRIMARY KEY,
  title TEXT,
  content TEXT
);

-- データを投入
INSERT INTO tech_articles (title, content) VALUES
  ('PostgreSQL 全文検索入門', 'PGroongaを使用した日本語全文検索の実装方法について説明します'),
  ('転置インデックスの仕組み', 'Groongaエンジンによる高速な転置インデックス検索技術を解説します'),
  ('MeCab形態素解析の活用', '日本語の名詞、動詞、形容詞を正確に認識する方法を紹介します');

-- PGroongaインデックスを作成
CREATE INDEX idx_tech_articles_title ON tech_articles
  USING pgroonga (title)
  WITH (tokenizer='TokenMecab', normalizer='NormalizerNFKC150');

CREATE INDEX idx_tech_articles_content ON tech_articles
  USING pgroonga (content)
  WITH (tokenizer='TokenMecab', normalizer='NormalizerNFKC150');

-- タイトルで検索
SELECT title FROM tech_articles WHERE title &@~ '全文検索';

実行結果

          title
-------------------------
 PostgreSQL 全文検索入門
(1 row)

複数キーワードでの検索

スペース区切りで複数のキーワードを指定すると、AND検索になります。

SELECT title FROM tech_articles
WHERE title &@~ '転置インデックス' OR content &@~ '転置インデックス';

実行結果

           title
--------------------------
 転置インデックスの仕組み
(1 row)

コンテンツ全体での検索

タイトルとコンテンツを結合したインデックスを作成することで、両方のフィールドを対象に検索できます。

-- 複合インデックスを作成
CREATE INDEX idx_tech_articles_all ON tech_articles
  USING pgroonga ((title || ' ' || content))
  WITH (tokenizer='TokenMecab', normalizer='NormalizerNFKC150');

-- タイトルまたはコンテンツに「検索」を含む記事を検索
SELECT title FROM tech_articles
WHERE (title || ' ' || content) &@~ '検索';

実行結果

          title
--------------------------
 PostgreSQL 全文検索入門
 転置インデックスの仕組み
(2 rows)

実務ユースケース: ブログプラットフォームの検索機能

実際の業務アプリケーションを想定して、ブログプラットフォームの検索機能を実装します。

データモデルの設計

CREATE TABLE blog_posts (
  id SERIAL PRIMARY KEY,
  title TEXT NOT NULL,
  content TEXT NOT NULL,
  tags TEXT[],
  author TEXT,
  published_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  view_count INTEGER DEFAULT 0
);

-- サンプルデータを投入
INSERT INTO blog_posts (title, content, tags, author, view_count) VALUES
  ('PostgreSQLパフォーマンスチューニング実践',
   'インデックス設計とクエリ最適化の手法を解説します。EXPLAIN ANALYZEを活用した分析方法も紹介します。',
   ARRAY['PostgreSQL', 'パフォーマンス', 'データベース'],
   '山田太郎', 1500),
  ('Dockerで始めるマイクロサービス開発',
   'Docker ComposeとKubernetesを使用したコンテナオーケストレーションについて説明します。',
   ARRAY['Docker', 'マイクロサービス', 'インフラ'],
   '佐藤花子', 2300),
  ('日本語自然言語処理入門',
   'MeCabとJanomeを用いた形態素解析の実装例を紹介します。感情分析にも触れます。',
   ARRAY['NLP', '機械学習', 'Python'],
   '鈴木一郎', 1800);

インデックス設計

-- タイトル検索用のPGroongaインデックス
CREATE INDEX idx_blog_posts_title ON blog_posts
  USING pgroonga (title)
  WITH (tokenizer='TokenMecab', normalizer='NormalizerNFKC150');

-- コンテンツ検索用のPGroongaインデックス
CREATE INDEX idx_blog_posts_content ON blog_posts
  USING pgroonga (content)
  WITH (tokenizer='TokenMecab', normalizer='NormalizerNFKC150');

-- タグ検索用のGINインデックス(配列検索に最適)
CREATE INDEX idx_blog_posts_tags ON blog_posts
  USING GIN (tags);

-- 閲覧数でのソート用のB-treeインデックス
CREATE INDEX idx_blog_posts_view_count ON blog_posts (view_count DESC);

複合条件検索の実装

実務では、キーワード検索と他の条件を組み合わせるケースが多くあります。

-- キーワード検索 + 閲覧数フィルタ
SELECT title, author, view_count
FROM blog_posts
WHERE (title &@~ 'Docker' OR content &@~ 'Docker')
  AND view_count > 2000
ORDER BY view_count DESC;

実行結果

           title                |  author  | view_count
------------------------------------+----------+------------
 Dockerで始めるマイクロサービス開発 | 佐藤花子 |       2300
(1 row)
-- タグ検索との組み合わせ
SELECT title, tags, view_count
FROM blog_posts
WHERE 'PostgreSQL' = ANY(tags)
  AND (title &@~ 'パフォーマンス' OR content &@~ 'パフォーマンス')
ORDER BY view_count DESC;

実行結果

                  title                   |                   tags                   | view_count
------------------------------------------+------------------------------------------+------------
 PostgreSQLパフォーマンスチューニング実践 | {PostgreSQL,パフォーマンス,データベース} |       1500
(1 row)

ページネーション実装

大量の検索結果を扱う場合、適切なページネーション実装が重要です。

参考: Use The Index, Luke - 次ページの取得

❌ 避けるべき実装:単純なOFFSET

-- アンチパターン:後方ページほど遅くなる
SELECT title, author, published_at
FROM blog_posts
WHERE title &@~ '開発'
ORDER BY published_at DESC
LIMIT 10 OFFSET 10000;  -- 10000件スキップするために全件スキャン

問題点:

  • データベースは指定したOFFSETまでの全行を読み取って捨てる必要がある
  • ページ番号が大きくなるほど応答時間が線形に悪化
  • 新規データ挿入時にページがずれる(重複表示や欠落が発生)

✅ 推奨実装:シーク法(Seek Method)

前ページの最終値を基準に次のページを取得する方法です。

-- 初回(1ページ目)
SELECT title, author, published_at, id
FROM blog_posts
WHERE title &@~ '開発'
ORDER BY published_at DESC, id DESC
LIMIT 10;

-- 2ページ目以降(前ページ最終行: published_at='2024-01-15 10:30:00', id=42)
SELECT title, author, published_at, id
FROM blog_posts
WHERE title &@~ '開発'
  AND (published_at, id) < ('2024-01-15 10:30:00', 42)  -- 行値コンストラクタ
ORDER BY published_at DESC, id DESC
LIMIT 10;

利点:

  • 常に一定の高速パフォーマンス(インデックススキャンのみ)
  • 何ページ目でも応答時間が変わらない
  • データ挿入による表示ずれが発生しない
  • 無限スクロールUIに最適

注意点:

  • ソートキーに一意性が必要(published_atのみでは不十分、idを追加)
  • 任意ページへのジャンプは実装できない
  • アプリケーション側で前ページの最終値を保持する必要がある

複合キーでのシーク法実装例

-- スコアと日付で降順ソート
CREATE INDEX idx_score_date ON blog_posts 
  USING btree (view_count DESC, published_at DESC, id DESC);

-- スコア順ページネーション
SELECT title, view_count, published_at, id
FROM blog_posts
WHERE title &@~ '開発'
  AND (view_count, published_at, id) < (1500, '2024-01-15', 42)
ORDER BY view_count DESC, published_at DESC, id DESC
LIMIT 10;

パフォーマンス比較(実測値):

測定環境

  • データ件数: 10,000,000件(1000万件)
  • 検索条件: title &@~ '開発'(約250万件ヒット)
  • PostgreSQL 17 on Docker (Alpine)
  • 各クエリ5回実行の平均値
ページ番号 OFFSET法 シーク法 改善率 判定
1ページ目 41.9ms 41.9ms - 同等
11ページ目(OFFSET 100) 41.5ms 39.1ms 6% わずかに改善
101ページ目(OFFSET 1000) 2,233ms (2.2秒) 47.1ms 98% 劇的改善
1001ページ目(OFFSET 10000) 2,421ms (2.4秒) 47.1ms 98% 劇的改善

重要な閾値:

OFFSET値 OFFSET法の応答時間 シーク法との性能差 推奨
0〜100 30〜50ms ほぼ同等 どちらでも可
100〜1000 50〜500ms 徐々に悪化 シーク法推奨
1000以上 2秒以上 50倍以上の差 シーク法必須

実測結果の考察:

  1. OFFSET 1000が臨界点

    • OFFSET 100まではPostgreSQLの最適化により大きな差は出ない
    • OFFSET 1000を超えると、OFFSET法の性能が劇的に悪化(40ms → 2秒)
    • シーク法は常に30〜50msの一定範囲を維持
  2. 大規模データでの性能劣化の原因

    • OFFSET法は、指定された件数分のデータを読み捨てる必要がある
    • 検索結果が250万件ヒットする場合、OFFSET 1000でも大量のデータスキャンが発生
    • PostgreSQLのソート処理とOFFSET処理の累積コストが支配的
  3. シーク法の一貫性

    • データ量に関わらず30〜50msの応答時間を維持
    • インデックスの直接参照により、スキャン量を最小化
    • 行値コンストラクタ (published_at, id) < (...) による効率的な範囲検索

実務での推奨指針:

データ規模 検索結果件数 OFFSET閾値 推奨手法
〜10万件 〜1万件 制限なし OFFSET可
10万〜100万件 〜10万件 OFFSET < 500 OFFSET可、それ以上はシーク法
100万件以上 10万件以上 OFFSET < 100 シーク法を標準採用

結論:

OFFSET 1000を超えるページネーションでは、シーク法の採用が必須です。特に以下のケースでは最初からシーク法を選択すべきです。

  • 検索結果が数十万件以上ヒットする可能性がある
  • 無限スクロールUIを実装する
  • ディープページへのアクセスが想定される
  • スケーラビリティを重視する

検索候補(サジェスト)機能

ユーザーの入力に応じて検索候補を表示する機能も実装できます。

-- タイトルの前方一致検索
SELECT DISTINCT title
FROM blog_posts
WHERE title &^ 'Post'  -- 前方一致演算子
LIMIT 5;

パフォーマンス最適化のポイント

インデックスのトークナイザー選択

PGroongaでは複数のトークナイザーが利用できます。

  • TokenMecab: 形態素解析による高精度な分割(推奨)
  • TokenBigram: バイグラムによる分割(MeCab不要)
  • TokenNgram: N-gramによる分割

日本語検索では、TokenMecabを使用することで名詞、動詞、形容詞を正確に認識し、検索精度が向上します。

ノーマライザーの活用

ノーマライザーは、検索時の文字列正規化を行います。

CREATE INDEX idx_with_normalizer ON articles
  USING pgroonga (content)
  WITH (
    tokenizer='TokenMecab',
    normalizer='NormalizerNFKC150'  -- Unicode正規化
  );

NormalizerNFKC150を使用すると、全角・半角、大文字・小文字の違いを吸収できます。

多言語検索の実装

PGroongaは日本語以外の言語にも対応しています。言語別にトークナイザーを使い分けることで、多言語サイトの検索を実装できます。

言語カラムの追加

-- 既存テーブルに言語カラムを追加
ALTER TABLE blog_posts ADD COLUMN lang VARCHAR(5) DEFAULT 'ja';

-- 言語別データの投入例
UPDATE blog_posts SET lang = 'ja' WHERE id IN (1, 3);
UPDATE blog_posts SET lang = 'en' WHERE id = 2;

INSERT INTO blog_posts (title, content, tags, author, view_count, lang) VALUES
  ('Introduction to Full-Text Search', 
   'This article explains how to implement full-text search using PostgreSQL and PGroonga.',
   ARRAY['PostgreSQL', 'Search', 'Database'],
   'John Doe', 1000, 'en'),
  ('全文検索の基礎',
   '日本語の全文検索をPostgreSQLで実装する方法を解説します。',
   ARRAY['PostgreSQL', '検索', 'データベース'],
   '山田太郎', 1200, 'ja');

言語別インデックスの作成

部分インデックス(Partial Index)を使用して、言語ごとに最適なトークナイザーを設定します。

-- 日本語用インデックス(MeCab形態素解析)
CREATE INDEX idx_blog_posts_content_ja ON blog_posts 
  USING pgroonga (content) 
  WITH (tokenizer='TokenMecab', normalizer='NormalizerNFKC150')
  WHERE lang = 'ja';

-- 英語用インデックス(バイグラム)
CREATE INDEX idx_blog_posts_content_en ON blog_posts 
  USING pgroonga (content) 
  WITH (tokenizer='TokenBigram', normalizer='NormalizerNFKC150')
  WHERE lang = 'en';

-- 中国語用インデックス(バイグラム)
CREATE INDEX idx_blog_posts_content_zh ON blog_posts 
  USING pgroonga (content) 
  WITH (tokenizer='TokenBigram', normalizer='NormalizerNFKC150')
  WHERE lang = 'zh';

多言語検索クエリ

-- 言語を指定して検索
SELECT title, content, lang
FROM blog_posts
WHERE lang = 'ja' AND content &@~ '全文検索'
ORDER BY view_count DESC;

-- 複数言語をまたいで検索(OR条件)
SELECT title, content, lang
FROM blog_posts
WHERE (lang = 'ja' AND content &@~ '検索')
   OR (lang = 'en' AND content &@~ 'search')
ORDER BY view_count DESC;

トークナイザー選択のガイドライン

言語 推奨トークナイザー 理由
日本語 (ja) TokenMecab 形態素解析による高精度な分割
英語 (en) TokenBigram 単語区切りが明確、バイグラムで十分
中国語 (zh) TokenBigram 単語境界が不明確、バイグラムが適切
韓国語 (ko) TokenBigram ハングルの特性上、バイグラムが効果的
その他 TokenBigram 汎用的に使用可能

注意点:

  • 言語別インデックスは、それぞれディスク容量を消費します
  • 多言語対応により、インデックスメンテナンスの複雑度が増加します
  • WHERE句で言語を指定することで、適切なインデックスが使用されます

インデックスメンテナンス

定期的にVACUUMを実行してインデックスを最適化します。

-- インデックス統計を更新
VACUUM ANALYZE tech_articles;

-- インデックスのサイズを確認
SELECT
  schemaname,
  relname,
  indexrelname,
  pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
FROM pg_stat_user_indexes
WHERE relname = 'blog_posts'
ORDER BY pg_relation_size(indexrelid) DESC;

クエリパフォーマンスの分析

EXPLAIN ANALYZEを使用して、クエリの実行プランを確認します。

EXPLAIN ANALYZE
SELECT title FROM blog_posts
WHERE title &@~ 'PostgreSQL';

実行結果例

QUERY PLAN
-----------------------------------------------------------------------------------------------------
 Seq Scan on blog_posts  (cost=0.00..3.28 rows=1 width=32) (actual time=1.211..1.586 rows=1 loops=1)
   Filter: (title &@~ 'PostgreSQL'::text)
   Rows Removed by Filter: 2
 Planning Time: 20.695 ms
 Execution Time: 1.643 ms
(5 rows)

データ量が少ない場合、PostgreSQLはインデックスを使用せずシーケンシャルスキャンを選択することがあります。これは正常な動作で、小規模データではシーケンシャルスキャンの方が高速だからです。

監視とアラート設定

本番運用では、インデックスの状態とクエリパフォーマンスを継続的に監視することが重要です。

インデックス肥大化の監視

-- インデックスサイズと使用状況を確認
SELECT 
  schemaname,
  relname AS tablename,
  indexrelname,
  pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,
  idx_scan AS index_scans,
  idx_tup_read AS tuples_read,
  idx_tup_fetch AS tuples_fetched
FROM pg_stat_user_indexes
WHERE schemaname = 'public'
ORDER BY pg_relation_size(indexrelid) DESC;

監視すべき指標:

  • index_size: インデックスサイズが急増していないか
  • idx_scan: インデックスが実際に使用されているか(0の場合は不要インデックス)
  • idx_tup_read vs idx_tup_fetched: 効率的にデータを取得できているか

クエリパフォーマンスの監視

-- 低速クエリの特定(pg_stat_statements拡張が必要)
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

SELECT 
  substring(query, 1, 100) AS query_preview,
  calls,
  total_exec_time / 1000 AS total_time_sec,
  mean_exec_time / 1000 AS avg_time_sec,
  max_exec_time / 1000 AS max_time_sec
FROM pg_stat_statements
WHERE query LIKE '%&@~%'  -- PGroonga検索のみ
ORDER BY mean_exec_time DESC
LIMIT 10;

アラート設定例

PostgreSQL Exporter + Prometheus + Grafanaの構成:

# prometheus.yml
scrape_configs:
  - job_name: 'postgres'
    static_configs:
      - targets: ['postgres-exporter:9187']

監視アラートルール:

# alert.rules.yml
groups:
  - name: pgroonga_alerts
    rules:
      - alert: PGroongaIndexSizeExceeded
        expr: pg_stat_user_indexes_idx_size_bytes{indexrelname=~".*pgroonga.*"} > 1e9
        for: 5m
        annotations:
          summary: "PGroongaインデックスが1GBを超えました"
          
      - alert: PGroongaSlowQuery
        expr: pg_stat_statements_mean_exec_time_seconds{query=~".*&@~.*"} > 1
        for: 5m
        annotations:
          summary: "PGroonga検索の平均実行時間が1秒を超えました"
          
      - alert: PGroongaIndexNotUsed
        expr: pg_stat_user_indexes_idx_scan{indexrelname=~".*pgroonga.*"} == 0
        for: 1h
        annotations:
          summary: "PGroongaインデックスが1時間使用されていません"

定期メンテナンススクリプト

#!/usr/bin/env bash
# pgroonga_maintenance.sh
# cron設定例: 0 2 * * * /path/to/pgroonga_maintenance.sh

PGHOST="localhost"
PGUSER="postgres"
PGDATABASE="postgres"

# VACUUMとANALYZEを実行
psql -h $PGHOST -U $PGUSER -d $PGDATABASE -c "VACUUM ANALYZE blog_posts;"

# インデックスサイズをログ出力
psql -h $PGHOST -U $PGUSER -d $PGDATABASE -t -c "
SELECT 
  indexrelname,
  pg_size_pretty(pg_relation_size(indexrelid))
FROM pg_stat_user_indexes
WHERE indexrelname LIKE '%pgroonga%';
" | logger -t pgroonga_maintenance

# 使用されていないインデックスを警告
UNUSED=$(psql -h $PGHOST -U $PGUSER -d $PGDATABASE -t -c "
SELECT indexrelname 
FROM pg_stat_user_indexes 
WHERE indexrelname LIKE '%pgroonga%' AND idx_scan = 0;
")

if [ ! -z "$UNUSED" ]; then
  echo "Warning: Unused PGroonga indexes found: $UNUSED" | logger -t pgroonga_maintenance
fi

MeCabの動作確認

コンテナ内でMeCabが正しく動作していることを確認できます。

# MeCabのバージョン確認
docker exec pgroonga-postgres mecab --version

# 形態素解析のテスト
docker exec pgroonga-postgres sh -c "echo '日本語の形態素解析を行います' | mecab"

出力例

日本語    名詞,一般,*,*,*,*,日本語,ニホンゴ,ニホンゴ
の        助詞,連体化,*,*,*,*,の,ノ,ノ
形態素    名詞,一般,*,*,*,*,形態素,ケイタイソ,ケイタイソ
解析      名詞,サ変接続,*,*,*,*,解析,カイセキ,カイセキ
を        助詞,格助詞,一般,*,*,*,を,ヲ,ヲ
行い      動詞,自立,*,*,五段・ワ行促音便,連用形,行う,オコナイ,オコナイ
ます      助動詞,*,*,*,特殊・マス,基本形,ます,マス,マス

本番環境への導入時の注意点

リソース設計

PGroongaインデックスは、元のデータサイズの1.5〜2倍程度のディスク容量を必要とします。また、インデックス構築時にはCPUとメモリを消費するため、適切なリソース配分が必要です。

-- 現在のテーブルとインデックスのサイズを確認
SELECT
  pg_size_pretty(pg_total_relation_size('blog_posts')) AS total_size,
  pg_size_pretty(pg_relation_size('blog_posts')) AS table_size,
  pg_size_pretty(pg_total_relation_size('blog_posts') - pg_relation_size('blog_posts')) AS indexes_size;

バックアップ戦略

PGroongaインデックスは通常のPostgreSQLバックアップツール(pg_dump、pg_basebackup)で問題なくバックアップできます。

# 論理バックアップ
docker exec pgroonga-postgres pg_dump -U postgres -Fc postgres > backup.dump

# リストア
docker exec -i pgroonga-postgres pg_restore -U postgres -d postgres < backup.dump

大量データの初期インデックス構築

既存の大量データに対してPGroongaインデックスを作成する場合、maintenance_work_memを増やすことで高速化できます。

-- セッション単位で設定を変更
SET maintenance_work_mem = '1GB';

CREATE INDEX idx_large_table ON large_table
  USING pgroonga (content)
  WITH (tokenizer='TokenMecab');

アプリケーション統合例

Python(FastAPI)実装

実装ファイル: search_api.py

  • 基本検索API (/search): キーワード検索と閲覧数フィルタ
  • ページネーションAPI (/search/paginated): シーク法による高速ページング
  • タグ検索API (/search/by-tags): タグと全文検索の組み合わせ
  • サジェストAPI (/suggest): 前方一致によるオートコンプリート
  • 非同期処理: asyncpgによる高速なデータベースアクセス
  • 型安全性: Pydanticモデルによるレスポンス検証

Node.js(Express)実装

実装ファイル: search_api.js

  • 基本検索API (/search): キーワード検索と閲覧数フィルタ
  • ページネーションAPI (/search/paginated): シーク法による高速ページング
  • タグ検索API (/search/by-tags): タグと全文検索の組み合わせ
  • サジェストAPI (/suggest): 前方一致によるオートコンプリート
  • コネクションプール: pg.Poolによる効率的な接続管理
  • エラーハンドリング: 適切なHTTPステータスコードとエラーメッセージ

トラブルシューティング

よくある問題と対処法

  • 問題1: インデックスが使用されない

対処法: 統計情報を更新し、cost設定を調整します。

ANALYZE blog_posts;

-- PGroongaインデックスのコストを下げる(必要に応じて)
SET enable_seqscan = off;  -- 検証用。本番では推奨しません
  • 問題2: MeCabの辞書が見つからない

対処法: mecabrcの設定を確認します。

docker exec pgroonga-postgres cat /usr/local/etc/mecabrc
docker exec pgroonga-postgres ls -la /usr/local/lib/mecab/dic
  • 問題3: メモリ不足エラー

対処法: shared_buffersとwork_memを調整します。

-- postgresql.confで設定(要再起動)
shared_buffers = 256MB
work_mem = 16MB
maintenance_work_mem = 128MB

まとめ

この記事では、PGroongaとMeCabを組み合わせた日本語全文検索システムの構築方法を、実務で必要な内容を含めて解説しました。

重要なポイント

  • PGroongaはGroongaエンジンをベースにしたPostgreSQL拡張機能で、日本語検索に最適化されています
  • MeCab形態素解析器を使用することで、名詞、動詞、形容詞を正確に認識できます
  • Docker環境で簡単にセットアップでき、docker compose up -dだけで利用可能になります
  • 複合条件検索、ページネーション、タグ検索など、実務で必要な機能をすべて実装できます
  • EXPLAIN ANALYZEを使用したパフォーマンス分析が重要です
  • Python、Node.jsなど、主要なプログラミング言語から簡単に利用できます

次のステップ

PostgreSQLの豊富なSQL機能とPGroongaの高速検索を組み合わせることで、エンタープライズレベルの日本語全文検索システムを構築できます。この環境を基盤として、以下のような応用が可能です。

  • レコメンデーションシステム(検索履歴に基づく記事推薦)
  • 検索ログ分析(人気キーワードの抽出)
  • 類似記事検索(関連記事の自動表示)
  • オートコンプリート機能(検索窓でのリアルタイムサジェスト)

本記事で紹介した構成は、数百万件規模のドキュメント検索にも対応できます。実務での検索システム構築の参考になれば幸いです。

Discussion