•16 min read

pgvectorとハイブリッド検索でプロダクションRAGを構築する

pgvectorとハイブリッド検索でプロダクションRAGを構築する

Retrieval-Augmented Generation(RAG)システムを構築する際、検索精度がすべてです。取得されたドキュメントが適切でなければ、LLMの応答も適切ではありません。ここでpgvectorハイブリッド検索が威力を発揮します。従来の全文検索とベクトル埋め込みを組み合わせることで、両方の良いところ取りができます。

このガイドでは、PostgreSQL、pgvector拡張機能、Reciprocal Rank Fusion(RRF)、および完全なPythonクライアントを使用して、本番環境に対応したRAGアーキテクチャを構築する方法を探ります。これには、HNSWチューニング、クエリプラン分析、および本番チェックリストが含まれます。

他のAIスタックコンポーネントを検討している場合は、AIツールの概要もぜひご確認ください。

Audio Briefing
0:00 / 0:00

RAGアーキテクチャ

ハイブリッド検索RAGアーキテクチャがどのように動作するかを見てみましょう。このシステムは、セマンティックな意味のための高密度ベクトル検索と、厳密な一致のための疎なキーワード検索(BM25)という2つの並行検索を実行します。その後、Reciprocal Rank Fusion(RRF)を使用して結果をマージします。

Advertisement

高密度検索 vs. 疎な検索

なぜハイブリッドアプローチが必要なのでしょうか?セマンティックベクトル検索と従来のキーワードマッチングの違いを詳しく見ていきましょう。

セマンティック検索は強力ですが、ユーザーが特定のシリアル番号、頭字語、または厳密な名前を検索する場合に失敗することがあります。PostgreSQLの全文検索と組み合わせることで、厳密な用語と概念的な一致の両方を確実に捉えることができます。

埋め込み次元の選択

スキーマを設定する前に、埋め込みモデルを決定してください。選択した次元は永続的です。変更するには、すべてのドキュメントを再インデックス化する必要があります。

モデル次元注意事項
OpenAI text-embedding-3-small1536コストと品質のバランスが良い
OpenAI text-embedding-3-large3072最高品質、ストレージは2倍
Cohere embed-english-v3.01024強力な多言語オプション
BAAI/bge-m3 (オープンソース)1024最高のオープンソース、セルフホスト型
all-MiniLM-L6-v2384高速、低メモリ、オンデバイスに最適

次元が大きいほど、微妙なセマンティッククエリでの再現率が向上しますが、ストレージと検索レイテンシーの両方でコストがかかります。ほとんどのプロダクションRAGシステムでは、1024または1536がスイートスポットです。

pgvectorのセットアップ

拡張機能の有効化

まず、データベースでpgvectorを有効にします。RDSまたはSupabaseでは、これはデフォルトで利用可能です。

CREATE EXTENSION IF NOT EXISTS vector;

テーブルの作成

ドキュメント、その全文検索ベクトル(BM25用)、および埋め込みを保存するテーブルを作成します。

CREATE TABLE documents (
    id          bigserial PRIMARY KEY,
    content     text      NOT NULL,
    metadata    jsonb     DEFAULT '{}',
    embedding   vector(1536),  -- match your model's dimension
    fts         tsvector  GENERATED ALWAYS AS
                  (to_tsvector('english', content)) STORED,
    created_at  timestamptz DEFAULT now()
);

HNSWインデックスの作成

HNSWは、IVFFlatよりも高速なクエリ時間と優れた再現率を提供します。m(グラフ接続性)とef_construction(構築時検索深度)をワークロードに合わせて調整します。

CREATE INDEX documents_embedding_hnsw_idx
    ON documents
    USING hnsw (embedding vector_cosine_ops)
    WITH (m = 16, ef_construction = 64);
  • m = 16: ノードあたりの接続数 — 高いほど再現率が向上しますが、より多くのメモリを使用します
  • ef_construction = 64: 構築時の検索深度 — 高いほどインデックス品質が向上しますが、構築に時間がかかります

全文インデックスの作成

BM25スタイルのキーワードクエリを高速化するためにGINインデックスを追加します。

CREATE INDEX documents_fts_gin_idx ON documents USING GIN (fts);

クエリ時HNSWパラメータの設定

クエリ時には、hnsw.ef_searchで再現率とレイテンシーのトレードオフを制御します。値が高いほど、レイテンシーを犠牲にして再現率が向上します。

SET hnsw.ef_search = 100; -- default is 40; 100 is good for production

これをセッションごと、または接続プール構成で設定します。

Advertisement

RRFによるハイブリッド検索の実装

Reciprocal Rank Fusionは、両方の検索からのランク付けされた結果を結合します。ベクトル検索で2位、キーワード検索で3位のドキュメントは、どちらか一方でのみ1位のドキュメントよりも高い融合スコアを獲得します。

WITH vector_search AS (
  SELECT id, content, metadata,
         RANK() OVER (ORDER BY embedding <=> $1) AS rank
  FROM documents
  ORDER BY embedding <=> $1
  LIMIT 50
),
keyword_search AS (
  SELECT id, content, metadata,
         RANK() OVER (
           ORDER BY ts_rank_cd(fts, plainto_tsquery('english', $2)) DESC
         ) AS rank
  FROM documents
  WHERE fts @@ plainto_tsquery('english', $2)
  ORDER BY ts_rank_cd(fts, plainto_tsquery('english', $2)) DESC
  LIMIT 50
)
SELECT
  COALESCE(v.id, k.id)               AS id,
  COALESCE(v.content, k.content)     AS content,
  COALESCE(v.metadata, k.metadata)   AS metadata,
  (
    COALESCE(1.0 / (60 + v.rank), 0.0) +
    COALESCE(1.0 / (60 + k.rank), 0.0)
  )                                   AS rrf_score
FROM vector_search v
FULL OUTER JOIN keyword_search k ON v.id = k.id
ORDER BY rrf_score DESC
LIMIT 10;
-- $1: embedding vector, $2: query text string

分母の定数60はRRF定数kです。これは、上位ランクのドキュメントの重みを減らし、単一のリストが支配的になるのを防ぎます。元のRRF論文で使われている標準値は60です。

クエリプランの検証

本番環境にデプロイする前に、EXPLAIN ANALYZEを実行してHNSWインデックスが使用されていることを確認し、実際の行数と推定行数を比較します。

EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT id, content, embedding <=> '[0.1, 0.2, ...]' AS dist
FROM documents
ORDER BY embedding <=> '[0.1, 0.2, ...]'
LIMIT 10;

出力でIndex Scan using documents_embedding_hnsw_idxを探してください。シーケンシャルスキャンが表示される場合、ef_searchが高すぎるか、統計情報が更新されていない可能性があります(ANALYZE documents;)。

asyncpgを使用したPythonクライアント

以下は、asyncpgとOpenAI埋め込みを使用した本番環境対応のPython実装です。

import asyncpg
import openai
from typing import Any

async def embed(text: str) -> list[float]:
    resp = await openai.AsyncOpenAI().embeddings.create(
        model="text-embedding-3-small",
        input=text,
    )
    return resp.data[0].embedding

async def hybrid_search(
    pool: asyncpg.Pool,
    query: str,
    limit: int = 10,
) -> list[dict[str, Any]]:
    embedding = await embed(query)
    # asyncpg expects vectors as lists of floats
    vector_str = "[" + ",".join(str(x) for x in embedding) + "]"

    rows = await pool.fetch(
        """
        WITH vector_search AS (
          SELECT id, content, metadata,
                 RANK() OVER (ORDER BY embedding <=> $1::vector) AS rank
          FROM documents ORDER BY embedding <=> $1::vector LIMIT 50
        ),
        keyword_search AS (
          SELECT id, content, metadata,
                 RANK() OVER (
                   ORDER BY ts_rank_cd(fts, plainto_tsquery('english', $2)) DESC
                 ) AS rank
          FROM documents
          WHERE fts @@ plainto_tsquery('english', $2)
          LIMIT 50
        )
        SELECT
          COALESCE(v.id, k.id)             AS id,
          COALESCE(v.content, k.content)   AS content,
          COALESCE(v.metadata, k.metadata) AS metadata,
          (COALESCE(1.0/(60+v.rank),0) + COALESCE(1.0/(60+k.rank),0)) AS score
        FROM vector_search v
        FULL OUTER JOIN keyword_search k ON v.id = k.id
        ORDER BY score DESC LIMIT $3
        """,
        vector_str,
        query,
        limit,
    )
    return [dict(r) for r in rows]

チャンキング戦略

埋め込み前にドキュメントをどのように分割するかは、インデックス構成と同じくらい重要です。チャンキングが不適切だと、2つの失敗モードが発生します。

  • チャンクが小さすぎる: 埋め込みが概念の一部しか捉えられず、セマンティック検索が文脈を見逃す。
  • チャンクが大きすぎる: 単一のチャンクが関連性を希薄化させる — 2,000トークンの中に埋もれた1つの関連する文。
戦略チャンクサイズオーバーラップ最適な用途
固定トークン512トークン50トークン汎用、実装が簡単
文境界3-5文1文会話型ドキュメント、QAシステム
段落境界1段落—ブログ記事、記事
セマンティックチャンキング可変—論理的なセクションを持つ長い技術文書

ほとんどのRAGアプリケーションでは、50トークンのオーバーラップを持つ512トークンが堅実な出発点となります。オーバーラップにより、チャンク境界をまたいで分割された概念が、少なくとも1つのチャンクのコンテキストウィンドウに確実に表示されます。

オプション: クロスエンコーダによる再ランキング

RRFは優れたフューザーですが、クエリとドキュメントの関係を理解していません。ランキングを結合するだけです。医療、法律、金融などの重要なRAGの場合、検索後にクロスエンコーダによる再ランキングステップを追加します。

from sentence_transformers import CrossEncoder

reranker = CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')

def rerank(query: str, docs: list[dict]) -> list[dict]:
    pairs = [(query, d['content']) for d in docs]
    scores = reranker.predict(pairs)
    ranked = sorted(zip(docs, scores), key=lambda x: x[1], reverse=True)
    return [d for d, _ in ranked]

クロスエンコーダはバイエンコーダよりも遅いため(各クエリとドキュメントのペアを同時に処理するため)、ハイブリッド検索からの上位20〜50件の結果のみを再ランキングし、その後上位5〜10件をLLMに渡します。

FAQ

本番チェックリスト

ハイブリッドRAGパイプラインを稼働させる前に:

  • ☑ 接続プールでhnsw.ef_search = 100(またはそれ以上)を設定する — デフォルトの40では再現率が犠牲になりすぎる
  • ☑ EXPLAIN ANALYZEでHNSWインデックスが使用されていることをクエリプランで確認する
  • ☑ 大量挿入後にVACUUM ANALYZE documentsを実行して統計情報を更新する
  • ☑ 検索後のフィルタリングのためにmetadata(ソースURL、セクション、日付)をjsonbに保存する
  • ☑ ランキング前にソースでフィルタリングするためにWHERE metadata @> '{"source": "docs"}'::jsonbを追加する
  • ☑ オブザーバビリティスタックで、埋め込みAPIのレイテンシーをDBクエリのレイテンシーとは別に監視する
  • ☑ 遅いクエリがLLMパイプラインをブロックしないように、ハイブリッド検索クエリにstatement_timeoutを設定する
  • ☑ asyncpg接続プールを使用する(最小2、最大20) — リクエストごとに新しい接続を作成しない

これらの方法を組み合わせて、一貫性のあるPostgreSQLハイブリッド検索RAGパイプラインを構築することで、AIアプリケーションの精度と信頼性を劇的に向上させることができます。

こちらもおすすめです

Share this article:

Stay Updated

Get the latest posts delivered straight to your inbox.

Free Developer Utilities

Free In-Browser Developer Tools

Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.

Explore Tools
Advertisement