PostgreSQLベクトル検索のスケーリング:本番RAG向けHNSWチューニングとテーブルパーティショニング

AI tutorial - IT technology blog
AI tutorial - IT technology blog

現場の課題:ベクトル検索が限界を迎えた日

6か月前、私たちのチームはPostgreSQLとpgvectorをベースにしたエンタープライズ向けRAGナレッジエンジンをローンチしました。ステージング環境でのパフォーマンスは非常に良好でした。4万件の埋め込みサポートチケットに対するコサイン距離クエリは約18ミリ秒で返答。専用のベクトルデータベースを管理する必要がなく、データ同期の遅延もゼロで、インフラのオーバーヘッドも最小限に抑えられ、私たちは大いに満足していました。

しかし、本番規模のデータが押し寄せたことで状況は一変しました。わずか3か月の間に自動取り込みパイプラインが650万件ものドキュメントを単一のPostgreSQLテーブルに投入したのです。各ドキュメントにはOpenAIのtext-embedding-3-largeによる1536次元の埋め込みベクトルが付与されていました。レイテンシはほぼ一夜にして悪化。P95クエリ時間は20msから使い物にならない2.8秒へと跳ね上がりました。ピーク時間帯のトラフィックでは32-vCPUのデータベースインスタンスの使用率が98%に張り付き、クライアントアプリ全体でコネクションプールのタイムアウトが連鎖し始めました。

ダッシュボードには明らかな障害パターンが現れていました。クエリのスループットは140 QPSからわずか12 QPSへと崩壊。ランダムI/Oが8,000-IOPSのNVMeアレイを圧迫し、標準的なインデックス戦略は完全に機能停止に陥りました。

根本原因の分析:デフォルトの検索が大規模環境で破綻する理由

pgvectorを使い始めるチームの多くは、シーケンシャルスキャンに依存するか、IVFFlatインデックスを構築します。数百万行規模の本番ワークロードにおいて、これら両方のアプローチが破綻した理由は次のとおりです。

1. IVFFlatにおける再現率と速度のジレンマ

IVFFlatは、k-meansクラスタリング(lists = N)によってベクトルをボロノイセルに分割します。クエリ実行時、エンジンはivfflat.probesで定義されたクラスタのサブセットのみをチェックします。

私たちの650万行のデータセットでは、92%以上の許容可能な再現率を達成するために、probesの値を10から64まで引き上げる必要がありました。その結果、激しいディスク読み取りの増幅が発生し、レスポンス時間は素のテーブルスキャン並みにまで落ち込みました。さらに悪いことに、日々のデータ挿入が進むにつれてセントロイドのドリフト(重心のずれ)が生じ、インデックスを完全に再構築しない限り再現率は78%まで低下してしまいました。

2. ワーキングメモリの枯渇

B-Treeインデックスは整然とした予測しやすいメモリ配置を持っています。一方、近似最近傍探索(ANN)グラフはそうではありません。グラフ探索では、非連続なブロックに分散したメモリポインタ間をジャンプします。インデックスサイズがPostgreSQLのshared_buffersを超えると、グラフを1ホップ進むごとにランダムなディスク読み取りが発生します。私たちのベクトルインデックスは42 GBまで膨張していましたが、インスタンスのRAMは64 GB、shared_buffersは16 GBしかありませんでした。ディスクスラッシングの発生は不可避でした。

3. モノリシックテーブルの肥大化

私たちのドキュメントリポジトリには、3会計年度にわたるマルチテナントの企業レコードが混在していました。パーティショニングされていない650万行のテーブルに対してクエリを実行すると、PostgreSQLはメタデータフィルタを適用する前に、まったく無関係なテナントやアーカイブ済みレコードを含むグラフ全体を評価せざるを得ませんでした。

選択肢の評価:3つのアーキテクチャ方針

本番環境でのタイムアウトを解消するため、私たちはAWS RDS上で4週間のベンチマークスプリントを実施し、3つのアプローチを検証しました。

選択肢1: 専用ベクトルDB(Qdrant / Pinecone / Milvus)への移行

外部の専用ベクトルデータベースの採用は一般的な推奨策に思えました。しかし、二重書き込みトランザクションの同期、リレーショナルデータと埋め込みベクトルの間の結果整合性による遅延、そして毎月推定1,800ドルの追加SaaSコストなど、エンジニアリング上の大きな摩擦が生じました。また、ネイティブなトランザクションACID保証が失われることは、エンタープライズの監査コンプライアンスの観点からも受け入れがたいものでした。

選択肢2: ハードウェアの垂直スケーリングとIVFFlatのチューニング

サーバーRAMを64 GBから256 GBに増設し、IVFFlatの週次再クラスタリングジョブをスケジュールしました。この対症療法によりレイテンシは450msまで改善しました。しかし、排他ロックによって毎週末約90分間にわたり取り込みパイプラインが停止し、週半ばには依然として再現率が低下していました。

選択肢3: HNSWインデックスと宣言的テーブルパーティショニングの組み合わせ

pgvectorにはネイティブのHierarchical Navigable Small World(HNSW)グラフが導入されました。HNSWはPostgresのストレージ内に多層スキップグラフを直接構築するため、定期的なクラスタメンテナンスが不要になります。HNSWとネイティブのレンジまたはリストパーティショニングを組み合わせることで、各グラフを小さく隔離した状態に保ち、メモリ内に綺麗にキャッシュさせることができます。

解決策:本番環境でのHNSWとパーティショニングの実装

私たちはこのアーキテクチャを本番環境に導入し、即座に効果を得ることができました。アクティブな800万ベクトルのコーパス全体で、P95レイテンシは2,800msから14msへと急減。読み取りIOPSは85%削減され、ピークトラフィック時でもCPU使用率は30%未満に落ち着きました。

以下に実装手順を解説します。

ステップ1: pgvectorエクステンションの有効化

PostgreSQLインスタンスでpgvector 0.5.0以降が稼働していることを確認し、エクステンションを有効化します。

CREATE EXTENSION IF NOT EXISTS vector;

ステップ2: 宣言的パーティションの作成

作成日時やテナント境界など、クエリパターンに応じたパーティションキーを選択します。私たちの構成では、四半期ごとのレンジパーティションによって個々のインデックスファイルを約8 GBに抑えました。これにより、RAM内に容易に収まるようになります。

-- 日付範囲でパーティショニングされたマスターテーブル
CREATE TABLE enterprise_documents (
    id BIGSERIAL,
    tenant_id UUID NOT NULL,
    created_at TIMESTAMPTZ NOT NULL,
    content TEXT NOT NULL,
    metadata JSONB DEFAULT '{}'::jsonb,
    embedding vector(1536) NOT NULL,
    PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);

-- 四半期ごとのパーティション
CREATE TABLE enterprise_documents_2025_q1 PARTITION OF enterprise_documents
    FOR VALUES FROM ('2025-01-01 00:00:00+00') TO ('2025-04-01 00:00:00+00');

CREATE TABLE enterprise_documents_2025_q2 PARTITION OF enterprise_documents
    FOR VALUES FROM ('2025-04-01 00:00:00+00') TO ('2025-07-01 00:00:00+00');

ステップ3: メモリのチューニングとHNSWインデックスの構築

HNSWグラフの構築には専用の作業用メモリが必要です。構築セッション用にmaintenance_work_memを一時的に増やし、CPUリソースに余裕があればマルチワーカーによる並列構築を有効にします。

-- 構築用メモリと並列度の割り当て
SET maintenance_work_mem = '8GB';
SET max_parallel_maintenance_workers = 4;

-- パーティションごとにHNSWインデックスを作成
CREATE INDEX idx_docs_2025_q1_hnsw_cosine 
ON enterprise_documents_2025_q1 
USING hnsw (embedding vector_cosine_ops) 
WITH (m = 24, ef_construction = 128);

CREATE INDEX idx_docs_2025_q2_hnsw_cosine 
ON enterprise_documents_2025_q2 
USING hnsw (embedding vector_cosine_ops) 
WITH (m = 24, ef_construction = 128);

主要な設定パラメータのポイント:

  • m = 24: ノードあたりの双方向リンクの最大数を設定します(デフォルトは16)。これを24または32に引き上げると、構築時間が約15%増加するものの、1536次元の埋め込みに対する再現率が大幅に向上します。
  • ef_construction = 128: インデックス構築時に探索される動的候補キューのサイズを制御します。128に設定することで、インデックス作成速度と上位10件の最近傍探索精度のバランスが適切に保たれます。

ステップ4: 高再現率な本番クエリの実行

実行時にhnsw.ef_searchを調整し、クエリレイテンシと再現率のトレードオフを最適化します。極めて重要なのは、PostgreSQLがパーティションプルーニングを確実に効かせられるよう、WHERE句に必ずパーティションキーを含めることです。

import psycopg2

def search_knowledge_base(query_vector, tenant_id, start_date, limit=5):
    conn = psycopg2.connect("dbname=rag_db user=rag_user host=localhost")
    with conn.cursor() as cur:
        # このセッションの候補探索幅を設定
        cur.execute("SET LOCAL hnsw.ef_search = 80;")
        
        query = """
            SELECT id, content, metadata, 1 - (embedding <=> %s::vector) AS similarity
            FROM enterprise_documents
            WHERE created_at >= %s
              AND tenant_id = %s
            ORDER BY embedding <=> %s::vector
            LIMIT %s;
        """
        cur.execute(query, (query_vector, start_date, tenant_id, query_vector, limit))
        results = cur.fetchall()
    conn.close()
    return results

運用6か月で得られた教訓

  • パーティションプルーニングは必須:created_atによるフィルタがない場合、プランナはすべてのパーティションインデックスをスキャンしてしまいます。EXPLAIN (ANALYZE, BUFFERS)を実行して、パーティションの除外が行われているか確認してください。
  • 再起動後のキャッシュウォーミング:データベースメンテナンス後は、トラフィックが流入する前にSELECT pg_prewarm('idx_docs_2025_q2_hnsw_cosine');を実行し、アクティブなグラフ層をRAMにあらかじめロードしておきます。
  • WAL生成量への配慮:HNSWインデックスの構築は書き込み負荷が高くなります。8 GBのインデックス構築で12〜16 GBのWALトラフィックが発生します。レプリケーション遅延を監視し、WALアーカイブディスクに十分な空き容量を確保してください。

HNSWインデックスと宣言的パーティショニングを組み合わせることで、専用ベクトルストアに匹敵する15msの検索速度を実現できます。何より最大の利点は、標準的なPostgreSQLのバックアップ、ポイントインタイムリカバリ(PITR)、そして統合されたリレーショナル結合をそのまま一箇所で維持できる点にあります。

Share: