pgvectorの使い方ガイド:PostgreSQLでベクトル埋め込みを保存・検索してAIとRAGパイプラインに活用する

Database tutorial - IT technology blog
Database tutorial - IT technology blog

独立したベクトルデータベースではなくpgvectorを選んだ理由

初めて参加したRAGプロジェクトでは、チームがQdrantを別途導入していました——デプロイ、モニタリング、バックアップが必要な、もう一つのサービスです。数ヶ月の運用を経て、こんな疑問が生まれました:PostgreSQLがすでに安定稼働しているなら、ベクトルを保存するためだけにもう一つシステムを維持する必要があるのだろうか?

これまでMySQL、PostgreSQL、MongoDBをさまざまなプロジェクトで使ってきました。PostgreSQLにpgvector拡張機能を組み合わせれば、既存のデータベースにベクトル埋め込みをそのまま保存でき、使い慣れたSQLを活用しながら、トランザクション、バックアップレプリケーションといった既存の仕組みをそのまま利用できます。小規模チームや中規模プロジェクトにとって、独立したシステムをもう一つ維持するよりも、はるかに実用的な選択肢です。

pgvectorのサポート機能:

  • vector(n)型でfloat4[]形式のベクトルを保存
  • 最近傍探索(L2、コサイン、内積)
  • 大規模データセットでの高速検索のためのIVFFlatおよびHNSWインデックス
  • pgbouncerpg_stat_statementsなどの他の拡張機能と同様に直接統合可能

pgvectorのインストール

Ubuntu/Debianへのインストール

公式PGDGリポジトリのPostgreSQLを使用している場合、コマンド一つで完了します:

# PostgreSQL 16
sudo apt install postgresql-16-pgvector

# 拡張機能を読み込むために再起動
sudo systemctl restart postgresql

PGDGリポジトリがない場合は、ソースからビルドします:

sudo apt install -y postgresql-server-dev-16 git build-essential
git clone https://github.com/pgvector/pgvector.git
cd pgvector
make
sudo make install

Dockerを使ったインストール

テスト環境を素早く立ち上げる最も手軽な方法——pgvector/pgvectorイメージには拡張機能がバンドルされており、追加インストール不要です:

docker run -d \
  --name pgvector-dev \
  -e POSTGRES_PASSWORD=secret \
  -e POSTGRES_DB=vectordb \
  -p 5432:5432 \
  pgvector/pgvector:pg16

データベースで拡張機能を有効化する

PostgreSQLの優れた点の一つ:拡張機能はシステムレベルでインストールしますが、データベースごとに個別に有効・無効を切り替えられます。他のデータベースに影響を与える心配がありません:

-- 使用するデータベースに接続
\c vectordb

-- 拡張機能を有効化
CREATE EXTENSION IF NOT EXISTS vector;

-- 確認
SELECT * FROM pg_extension WHERE extname = 'vector';

詳細な設定と使い方

ベクトル埋め込みを保存するテーブルの作成

社内ナレッジベース向けのRAGシステムを構築していると仮定します——ドキュメントを小さなチャンクに分割し、各チャンクにOpenAIのtext-embedding-3-smallが出力する1536次元の埋め込みベクトルを持たせます:

CREATE TABLE documents (
  id          BIGSERIAL PRIMARY KEY,
  content     TEXT NOT NULL,
  source      TEXT,
  metadata    JSONB,
  embedding   vector(1536),
  created_at  TIMESTAMPTZ DEFAULT NOW()
);

次元数は使用している埋め込みモデルと正確に一致させる必要があります——次元数が違うとインサート時にすぐエラーになります:

  • text-embedding-3-small:1536次元
  • text-embedding-3-large:3072次元
  • nomic-embed-text:768次元
  • all-MiniLM-L6-v2:384次元

Pythonからデータを挿入する

import psycopg2
from openai import OpenAI
import json

client = OpenAI()

def get_embedding(text: str) -> list[float]:
    response = client.embeddings.create(
        model="text-embedding-3-small",
        input=text
    )
    return response.data[0].embedding

def insert_document(conn, content: str, source: str, metadata: dict):
    embedding = get_embedding(content)
    with conn.cursor() as cur:
        cur.execute(
            """
            INSERT INTO documents (content, source, metadata, embedding)
            VALUES (%s, %s, %s, %s)
            """,
            (content, source, json.dumps(metadata), embedding)
        )
    conn.commit()

# 接続
conn = psycopg2.connect(
    host="localhost",
    dbname="vectordb",
    user="postgres",
    password="secret"
)

insert_document(
    conn,
    content="pgvectorを使うとPostgreSQLに直接ベクトル埋め込みを保存できます",
    source="docs/pgvector.md",
    metadata={"chapter": 1, "topic": "overview"}
)

セマンティック検索

ユーザーのクエリに最も関連する5件のドキュメントチャンクを検索します——テキスト埋め込みに最適なコサイン距離を使用します:

def semantic_search(conn, query: str, top_k: int = 5) -> list[dict]:
    query_embedding = get_embedding(query)
    
    with conn.cursor() as cur:
        cur.execute(
            """
            SELECT
              id,
              content,
              source,
              metadata,
              1 - (embedding <=> %s::vector) AS similarity
            FROM documents
            ORDER BY embedding <=> %s::vector
            LIMIT %s
            """,
            (query_embedding, query_embedding, top_k)
        )
        rows = cur.fetchall()
    
    return [
        {
            "id": row[0],
            "content": row[1],
            "source": row[2],
            "metadata": row[3],
            "similarity": float(row[4])
        }
        for row in rows
    ]

results = semantic_search(conn, "UbuntuへのpgvectorのインストールMethods")
for r in results:
    print(f"[{r['similarity']:.3f}] {r['content'][:100]}")

pgvectorには距離を計算する演算子が3種類あります——間違った演算子を選ぶと検索結果の品質に直接影響します:

  • <=> — コサイン距離(テキスト埋め込みに使用)
  • <-> — ユークリッド距離 / L2距離
  • <#> — 負の内積(ベクトルが正規化済みの場合に使用)

検索を高速化するインデックスの作成

50,000行未満であれば、シーケンシャルスキャンでも十分高速です——レイテンシは通常10ms以下です。この閾値を超えてインデックスがない状態では、特に同時リクエストが多い場合にクエリが明らかに遅くなります。

HNSWインデックス(本番環境での推奨——IVFFlatよりクエリが高速で、事前トレーニング不要):

-- コサイン距離でHNSWインデックスを作成
CREATE INDEX ON documents
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);

-- クエリ時にef_searchを増やして精度と速度のバランスを調整
SET hnsw.ef_search = 100;

IVFFlatインデックス(データセットが安定していて新規インサートが少ない場合に適する):

-- IVFFlatの作成前にデータが必要
-- lists ≈ sqrt(行数)、最低100
CREATE INDEX ON documents
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);

-- クエリ時にprobesを増やす(デフォルト1、値が大きいほど精度が高い)
SET ivfflat.probes = 10;

本番環境ではHNSWを選ぶことが多いです。事前にデータ量を把握しておく必要がなく、データセットが増えても精度が安定して維持されるためです。IVFFlatは、データセットがほぼ固定されていて初回のインデックス構築速度を優先する場合に向いています。

メタデータと組み合わせたフィルタリング

ここがpgvectorが専用ベクトルデータベースに対して真に輝く部分です——新しいAPIを覚える必要なく、普通のSQLでフィルタリングとJOINができます:

-- chapter 1 のドキュメントから検索
SELECT content, 1 - (embedding <=> '[0.1, 0.2, ...]'::vector) AS sim
FROM documents
WHERE metadata->>'chapter' = '1'
ORDER BY embedding <=> '[0.1, 0.2, ...]'::vector
LIMIT 5;

-- 別テーブルとJOINして検索
SELECT d.content, u.name AS author, sim
FROM (
  SELECT id, content, 1 - (embedding <=> %s::vector) AS sim
  FROM documents
  ORDER BY embedding <=> %s::vector
  LIMIT 10
) d
JOIN document_authors u ON d.id = u.document_id
WHERE sim > 0.7;

確認とモニタリング

インデックスが使われているか確認する

-- クエリプランを確認——「Seq Scan」ではなく「Index Scan」が表示されるべき
EXPLAIN (ANALYZE, BUFFERS)
SELECT content
FROM documents
ORDER BY embedding <=> '[0.1, 0.2, ...]'::vector
LIMIT 5;

-- インデックス情報を確認
SELECT
  indexname,
  pg_size_pretty(pg_relation_size(indexname::regclass)) AS index_size
FROM pg_indexes
WHERE tablename = 'documents';

パフォーマンスのモニタリング

-- テーブルとインデックスのサイズ
SELECT
  pg_size_pretty(pg_table_size('documents')) AS table_size,
  pg_size_pretty(pg_indexes_size('documents')) AS indexes_size,
  pg_size_pretty(pg_total_relation_size('documents')) AS total_size;

-- 遅いクエリの確認(pg_stat_statementsが必要)
SELECT query, calls, mean_exec_time, total_exec_time
FROM pg_stat_statements
WHERE query LIKE '%embedding%'
ORDER BY mean_exec_time DESC
LIMIT 5;

定期的なメンテナンス

HNSWはリビルド不要です。IVFFlatは話が別で——大量のdelete/update後にデッドタプルが蓄積して精度が徐々に低下します。定期的なREINDEXでこの問題を解決できます:

-- インデックスのリビルド(テーブルをロックしない——CONCURRENTLYを使用)
REINDEX INDEX CONCURRENTLY documents_embedding_idx;

-- 大量update後にデッドタプルを回収するためのVacuum
VACUUM ANALYZE documents;

簡易ベンチマーク

import time
import random

def benchmark_search(conn, n_queries=100):
    # テスト用に既存の埋め込みをランダムに取得
    with conn.cursor() as cur:
        cur.execute("SELECT embedding FROM documents ORDER BY random() LIMIT %s", (n_queries,))
        sample_embeddings = [row[0] for row in cur.fetchall()]
    
    start = time.time()
    for emb in sample_embeddings:
        with conn.cursor() as cur:
            cur.execute(
                "SELECT id FROM documents ORDER BY embedding <=> %s::vector LIMIT 5",
                (emb,)
            )
            cur.fetchall()
    elapsed = time.time() - start
    
    print(f"{n_queries}クエリを{elapsed:.2f}秒で処理")
    print(f"平均: {elapsed/n_queries*1000:.1f}ms/クエリ")
    # 約10万行のHNSWはef_searchの設定によって通常5〜20ms/クエリを達成

benchmark_search(conn)

実運用で学んだいくつかのポイント

pgvectorを本番環境で数ヶ月間運用して、もっと早く知っておきたかったことをまとめます:

  • 適切な次元数を選ぶ:小さい埋め込みモデル(384〜768次元)は速くてストレージ消費も少なめです——384次元で10万件のドキュメントを保存しても約150MBに収まります。本当に高い精度が必要な場合にのみ1536次元以上を選びましょう。
  • HNSWとIVFFlat:データセットが継続的に増加するならHNSW一択です。IVFFlatはデータがほぼ固定されていて、初回のインデックス構築を速く終わらせたい場合にのみ適しています。
  • バックアップの心配不要:pgvectorのデータはpg_dumpで通常通りエクスポートされます——専用ツールも追加手順も不要で、既存のバックアップフローにそのまま組み込めます。
  • コネクションプーリング:pgbouncerを前段に置いても、他のPostgreSQLワークロードと同様にpgvectorで問題なく動作します——特別な考慮事項はありません。
  • QdrantやWeaviateが必要になるタイミング:ベクトルデータセットが数千万レコードを超えて、水平シャーディングや専用マルチテナンシーが必要になった時です。500万レコード未満であれば、pgvectorで十分対応でき、しかもはるかにシンプルです。

Share: