Skip to content
Published on

PostgreSQL + pgvector ベクトル検索 実践ガイド: RAG からハイブリッド検索まで

Authors
  • Name
    Twitter

1. なぜ PostgreSQL + pgvector なのか

専用ベクトル DB(Pinecone、Qdrant、Weaviate)の代わりに PostgreSQL + pgvector を選択する理由:

特性専用ベクトル DBPostgreSQL + pgvector
追加インフラ必要不要(既存 PostgreSQL 活用)
ACID トランザクション限定的完全サポート
JOIN/リレーショナル不可自由に組み合わせ可能
ハイブリッド検索限定的tsvector + vector
運用の複雑さ高い低い(既存 DBA を活用)
スケーラビリティ数十億ベクトル数千万レベル

結論: ベクトル数が数千万以下で、リレーショナルデータと一緒に管理する必要がある場合、pgvector が最適。

2. インストールと設定

2.1 pgvector のインストール

# Ubuntu/Debian
sudo apt install postgresql-17-pgvector

# macOS (Homebrew)
brew install pgvector

# Docker
docker run -d --name pgvector \
  -e POSTGRES_PASSWORD=secret \
  -p 5432:5432 \
  pgvector/pgvector:pg17

2.2 拡張の有効化

-- pgvector 拡張をインストール
CREATE EXTENSION IF NOT EXISTS vector;

-- バージョン確認
SELECT extversion FROM pg_extension WHERE extname = 'vector';
-- 0.8.0

3. 基本的な使い方

3.1 テーブル作成

-- ドキュメントテーブル(1536次元 = OpenAI text-embedding-3-small)
CREATE TABLE documents (
    id BIGSERIAL PRIMARY KEY,
    title TEXT NOT NULL,
    content TEXT NOT NULL,
    embedding vector(1536),  -- ベクトルカラム!
    metadata JSONB DEFAULT '{}',
    created_at TIMESTAMPTZ DEFAULT NOW()
);

-- 384次元(sentence-transformers/all-MiniLM-L6-v2)
CREATE TABLE chunks (
    id BIGSERIAL PRIMARY KEY,
    doc_id BIGINT REFERENCES documents(id),
    chunk_text TEXT NOT NULL,
    embedding vector(384),
    chunk_index INT
);

3.2 データの挿入

-- 単一挿入
INSERT INTO documents (title, content, embedding)
VALUES (
    'Kubernetes RBAC Guide',
    'RBAC is a method of regulating access...',
    '[0.1, 0.2, 0.3, ...]'::vector  -- 1536次元ベクトル
);

-- Python からバッチ挿入
import psycopg2
from pgvector.psycopg2 import register_vector
import numpy as np

conn = psycopg2.connect("dbname=mydb user=postgres password=secret")
register_vector(conn)

cur = conn.cursor()

# OpenAI エンベディング生成
from openai import OpenAI
client = OpenAI()

texts = ["Kubernetes RBAC guide", "Docker networking basics", ...]
response = client.embeddings.create(
    model="text-embedding-3-small",
    input=texts
)

# バッチ挿入
for text, emb_data in zip(texts, response.data):
    embedding = np.array(emb_data.embedding)
    cur.execute(
        "INSERT INTO documents (title, content, embedding) VALUES (%s, %s, %s)",
        (text, text, embedding)
    )

conn.commit()

3.3 類似度検索

-- コサイン類似度(最も一般的)
SELECT id, title,
       1 - (embedding <=> '[0.1, 0.2, ...]'::vector) AS similarity
FROM documents
ORDER BY embedding <=> '[0.1, 0.2, ...]'::vector
LIMIT 10;

-- L2 距離
SELECT id, title,
       embedding <-> '[0.1, 0.2, ...]'::vector AS distance
FROM documents
ORDER BY embedding <-> '[0.1, 0.2, ...]'::vector
LIMIT 10;

-- 内積(Inner Product) — 正規化ベクトルではコサインと同等
SELECT id, title,
       (embedding <#> '[0.1, 0.2, ...]'::vector) * -1 AS similarity
FROM documents
ORDER BY embedding <#> '[0.1, 0.2, ...]'::vector
LIMIT 10;

演算子まとめ:

演算子意味用途
<->L2 距離ユークリッド距離ベース
<=>コサイン距離方向の類似度(最も一般的)
<#>内積(負の値)正規化ベクトルで使用

4. インデックス: HNSW vs IVFFlat

4.1 IVFFlat

-- IVFFlat インデックスの作成
CREATE INDEX ON documents
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);

-- 検索時の probe 数を設定(精度 vs 速度のトレードオフ)
SET ivfflat.probes = 10;
パラメータ説明推奨値
listsクラスター数sqrt(行数) ~ 行数/1000
probes検索クラスター数lists/10 ~ lists/5

4.2 HNSW(推奨)

-- HNSW インデックスの作成(ビルド時間は長いが検索は高速)
CREATE INDEX ON documents
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 200);

-- 検索時の ef_search を設定
SET hnsw.ef_search = 100;
パラメータ説明推奨値
m接続数16~64
ef_constructionビルド品質200+
ef_search検索品質40~200

4.3 HNSW vs IVFFlat の比較

特性IVFFlatHNSW
ビルド速度速い遅い
検索速度普通速い
Recall普通(probe依存)高い
メモリ少ない多い
更新再ビルドが必要リアルタイム可能

推奨: ほとんどの場合 HNSW。データが頻繁に変更される場合やメモリが限られている場合は IVFFlat。

5. ハイブリッド検索: ベクトル + 全文検索

-- tsvector カラムの追加
ALTER TABLE documents ADD COLUMN tsv tsvector
  GENERATED ALWAYS AS (to_tsvector('english', title || ' ' || content)) STORED;

CREATE INDEX ON documents USING gin(tsv);

-- ハイブリッド検索関数
CREATE OR REPLACE FUNCTION hybrid_search(
    query_text TEXT,
    query_embedding vector(1536),
    match_count INT DEFAULT 10,
    vector_weight FLOAT DEFAULT 0.7,
    text_weight FLOAT DEFAULT 0.3
)
RETURNS TABLE (id BIGINT, title TEXT, score FLOAT) AS $$
BEGIN
    RETURN QUERY
    WITH vector_results AS (
        SELECT d.id, d.title,
               1 - (d.embedding <=> query_embedding) AS vector_score
        FROM documents d
        ORDER BY d.embedding <=> query_embedding
        LIMIT match_count * 3
    ),
    text_results AS (
        SELECT d.id, d.title,
               ts_rank(d.tsv, plainto_tsquery('english', query_text)) AS text_score
        FROM documents d
        WHERE d.tsv @@ plainto_tsquery('english', query_text)
        LIMIT match_count * 3
    ),
    combined AS (
        SELECT
            COALESCE(v.id, t.id) AS id,
            COALESCE(v.title, t.title) AS title,
            COALESCE(v.vector_score, 0) * vector_weight +
            COALESCE(t.text_score, 0) * text_weight AS score
        FROM vector_results v
        FULL OUTER JOIN text_results t ON v.id = t.id
    )
    SELECT c.id, c.title, c.score
    FROM combined c
    ORDER BY c.score DESC
    LIMIT match_count;
END;
$$ LANGUAGE plpgsql;

-- 使用例
SELECT * FROM hybrid_search(
    'Kubernetes RBAC security',
    '[0.1, 0.2, ...]'::vector(1536)
);

5.2 Reciprocal Rank Fusion(RRF)

CREATE OR REPLACE FUNCTION rrf_search(
    query_text TEXT,
    query_embedding vector(1536),
    match_count INT DEFAULT 10,
    rrf_k INT DEFAULT 60
)
RETURNS TABLE (id BIGINT, title TEXT, rrf_score FLOAT) AS $$
BEGIN
    RETURN QUERY
    WITH vector_ranked AS (
        SELECT d.id, d.title,
               ROW_NUMBER() OVER (ORDER BY d.embedding <=> query_embedding) AS rank
        FROM documents d
        LIMIT match_count * 5
    ),
    text_ranked AS (
        SELECT d.id, d.title,
               ROW_NUMBER() OVER (
                   ORDER BY ts_rank(d.tsv, plainto_tsquery('english', query_text)) DESC
               ) AS rank
        FROM documents d
        WHERE d.tsv @@ plainto_tsquery('english', query_text)
        LIMIT match_count * 5
    ),
    fused AS (
        SELECT
            COALESCE(v.id, t.id) AS id,
            COALESCE(v.title, t.title) AS title,
            COALESCE(1.0 / (rrf_k + v.rank), 0) +
            COALESCE(1.0 / (rrf_k + t.rank), 0) AS rrf_score
        FROM vector_ranked v
        FULL OUTER JOIN text_ranked t ON v.id = t.id
    )
    SELECT f.id, f.title, f.rrf_score
    FROM fused f
    ORDER BY f.rrf_score DESC
    LIMIT match_count;
END;
$$ LANGUAGE plpgsql;

6. RAG パイプライン統合

6.1 Python 完全な例

import psycopg2
from pgvector.psycopg2 import register_vector
from openai import OpenAI
import numpy as np

client = OpenAI()
conn = psycopg2.connect("dbname=ragdb user=postgres password=secret")
register_vector(conn)

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

def rag_query(question: str, top_k: int = 5) -> str:
    query_vec = embed(question)

    cur = conn.cursor()
    cur.execute("""
        SELECT title, content,
               1 - (embedding <=> %s::vector) AS similarity
        FROM documents
        WHERE 1 - (embedding <=> %s::vector) > 0.7
        ORDER BY embedding <=> %s::vector
        LIMIT %s
    """, (query_vec, query_vec, query_vec, top_k))

    results = cur.fetchall()
    context = "\n\n".join([
        f"[{r[0]}] (similarity: {r[2]:.3f})\n{r[1]}"
        for r in results
    ])

    response = client.chat.completions.create(
        model="gpt-4o",
        messages=[
            {"role": "system", "content": f"Answer based on context:\n{context}"},
            {"role": "user", "content": question}
        ]
    )
    return response.choices[0].message.content

# 使用例
answer = rag_query("Kubernetes RBAC における ClusterRole と Role の違いは?")
print(answer)

7. パフォーマンスチューニング

7.1 主要設定

-- 作業メモリ(インデックスビルド/検索時)
SET maintenance_work_mem = '2GB';  -- HNSW ビルド時
SET work_mem = '256MB';             -- 検索時

-- 並列処理
SET max_parallel_workers_per_gather = 4;
SET max_parallel_maintenance_workers = 4;

-- HNSW ビルド最適化
SET maintenance_work_mem = '4GB';
-- ビルド後に元に戻す

7.2 ベンチマーク

100万ベクトル(1536次元)、PostgreSQL 17 + pgvector 0.8.0:

インデックスビルド時間検索レイテンシ (p50)Recall@10メモリ
なし(brute)-850ms100%0
IVFFlat (lists=1000, probes=50)45s8ms95%1.2GB
HNSW (m=16, ef=200)12min3ms99%2.8GB

8. クイズ

Q1. pgvector の <=> 演算子は何を計算するか?

コサイン距離(1 - cosine_similarity)。値が小さいほど類似している。類似度に変換するには 1 - (a <=> b)

Q2. HNSW と IVFFlat のうち検索速度が速いのは?

HNSW。ビルドは遅いが検索は速く、Recall も高い。ほとんどの本番環境で推奨。

Q3. ハイブリッド検索が純粋なベクトル検索より優れている理由は?

ベクトル検索は意味的類似度に強いがキーワードの正確なマッチングに弱い。全文検索と組み合わせることで、意味的類似度 + キーワードの正確さの両方を確保できる。

Q4. RRF(Reciprocal Rank Fusion)の原理は?

各検索結果の順位の逆数を合算する。スコアスケールが異なる検索結果を正規化なしに結合できるのが利点。

Q5. IVFFlat の lists と probes パラメータの関係は?

lists はクラスター数、probes は検索時に探索するクラスター数。probes が大きいほど正確だが遅い。通常 probes = lists/10 ~ lists/5。

Q6. 専用ベクトル DB の代わりに pgvector を選ぶべき場合は?

(1) リレーショナルデータとの JOIN が必要 (2) ACID トランザクションが必要 (3) ベクトル数が数千万以下 (4) 追加インフラの運用負担を最小化したい場合。