VPSでRAG(検索拡張生成)を自作する:pgvector + Embeddingでドキュメント検索

ChatGPTのようなLLMは賢いが、自社のドキュメントや最新情報は当然知らない。この「知らない部分」を検索で補ってから回答させる仕組みがRAG(Retrieval-Augmented Generation、検索拡張生成)だ。社内FAQボットやマニュアル検索チャットを自前のVPSで動かしたい場合、実は専用のベクトルDBサービスを契約しなくても、手持ちのPostgreSQLにpgvector拡張を入れるだけでかなりのところまで作れる。今回はさくらのVPS(Debian 12)上でRAGの検索基盤を組む手順をまとめる。

RAGの全体の流れ

まず仕組みを整理しておく。RAGは大きく分けて「準備フェーズ」と「質問応答フェーズ」の2段階で動く。

  • ドキュメント(PDF、Markdown、社内Wikiなど)を適当な長さの断片(チャンク)に分割する
  • 各チャンクをEmbedding(埋め込みベクトル)に変換する
  • チャンクの本文とベクトルをベクトルDBに保存する
  • ユーザーの質問文も同じ方法でEmbeddingに変換する
  • 質問ベクトルに近いチャンクをDBから近傍検索で取得する
  • 取得したチャンクを「参考情報」としてプロンプトに埋め込み、LLMに回答させる

要は「検索エンジンで関連文章を引っ張ってきて、それをLLMに読ませてから答えさせる」だけ。仕組み自体はシンプルだが、この「近傍検索」の部分に専用のベクトルDBが必要というのが従来の常識だった。ただ小〜中規模なら、実はPostgreSQLで十分まかなえる。

pgvectorのインストール

Debian 12でPostgreSQL 16を使っている前提で進める。Debianのbookwormリポジトリにpgvectorパッケージが用意されているので、素直にaptで入る。

apt update
apt install -y postgresql postgresql-16-pgvector

バージョン番号は環境のPostgreSQLバージョンに合わせて読み替えること(postgresql-15-pgvectorなど)。もしaptにパッケージが無ければ、pgvectorのGitHubからソースを取ってきてmake && make installでビルドする方法もあるが、Debian標準リポジトリに入っていれば素直にそちらを使うのが保守の面で楽だ。

インストールが終わったら、対象のデータベースに接続して拡張を有効化する。

\c mydb
CREATE EXTENSION IF NOT EXISTS vector;

これだけでPostgreSQLがベクトル型(vector(N))と近傍検索演算子を扱えるようになる。専用DBを別途構築する必要が無いのが素直にありがたい。

テーブル設計と近傍検索

チャンクの本文とベクトルを持つテーブルを作る。ここでは次元数1536(OpenAIのtext-embedding-3-small相当)を例にする。

CREATE TABLE docs (
    id serial PRIMARY KEY,
    content text NOT NULL,
    embedding vector(1536)
);

ここで注意したいのが、次元数はテーブル定義と使うEmbeddingモデルで必ず一致させるという点。モデルを途中で変えると次元数がずれてinsertが通らなくなる(例えば1536次元と768次元は混在できない)ので、運用中にモデルを切り替える場合は再Embeddingしてテーブルを作り直す必要がある。

近傍検索は次のように書く。

SELECT content
FROM docs
ORDER BY embedding <=> '[0.012, -0.034, ...]'
LIMIT 5;

<=>はコサイン距離を計算する演算子で、値が小さいほど質問文と意味的に近い。ちなみにpgvectorには他にも演算子があり、用途によって使い分ける。

  • <-> ... ユークリッド距離(L2)
  • <#> ... 負の内積
  • <=> ... コサイン距離(文書検索では大抵これでOK)

チャンク数が数万〜数十万件を超えてくると、単純な線形スキャンでは遅くなってくる。その場合はivfflathnswのインデックスを張る。

CREATE INDEX ON docs USING hnsw (embedding vector_cosine_ops);

hnswは検索精度が高く新しめの手法、ivfflatは構築が速いがクラスタ数のチューニングが要る、というのが体感の違い。数万件規模の社内文書程度なら、インデックス無しでもさほど気にならない速度で返ってくることが多い。

Embeddingの作り方

チャンクや質問文をベクトル化する方法は大きく2択。

  • OpenAIなどのAPIを使う(text-embedding-3-smallなど)。手軽だが文書を外部に送ることになる
  • ローカルモデルを使う(Ollamaのnomic-embed-textなど)。機密文書を外に出したくない場合はこちら

OllamaをVPSに入れて動かす場合はこんな感じになる。

curl -fsSL https://ollama.com/install.sh | sh
ollama pull nomic-embed-text
curl http://localhost:11434/api/embeddings -d '{
  "model": "nomic-embed-text",
  "prompt": "ここに対象のテキスト"
}'

返ってきたベクトル配列をそのままpgvectorのinsert文に流し込む。繰り返しになるが、nomic-embed-textは768次元なので、OpenAIの1536次元と混在させないこと。テーブル定義のvector(N)はモデルを決めてから書く、の順番を守るのが安全だ。

一連の流れをスクリプトでイメージする

実運用ではPythonやPHPでバッチスクリプトを書くことになるが、疑似コードでイメージだけ示す。

# 1. ドキュメントをチャンク分割してファイルに書き出す
split_into_chunks docs/*.md > chunks.jsonl

# 2. 各チャンクをEmbedding APIに投げてベクトルを取得
python embed_and_insert.py chunks.jsonl

# 3. 質問が来たら質問文もEmbeddingしてpgvectorに近傍検索
python ask.py "[ユーザーの質問文]"

ask.pyの中身は「質問をEmbedding → pgvectorで近傍検索 → 上位チャンクをプロンプトに埋め込み → LLM APIに投げる」という流れをそのままコードにするだけ。特別なフレームワークを使わなくても、SQLとAPI呼び出しの組み合わせだけでRAGの最小構成は動く。

専用ベクトルDBとの比較

QdrantやChromaのような専用ベクトルDBは、大規模データやフィルタ検索の柔軟性、メタデータ管理の面で優れている。ただし別プロセス・別ポートで常駐させる必要があり、その分VPSのメモリとバックアップの手間が増える。数万〜数十万件程度のドキュメント規模で、かつ既にPostgreSQLを運用しているなら、わざわざ別のDBを増やすメリットは薄い。pgvectorで小規模〜中規模のRAGは十分に実用的、というのが触ってみた実感だ。将来的にデータ量が跳ね上がってから専用DBへの移行を検討すれば良い。

まとめ

RAGは「Embeddingで意味検索 → LLMに参考情報として渡す」というシンプルな仕組みで、pgvectorを使えばPostgreSQLだけでベクトルDBの役割を代替できる。次元数の一致だけは事故りやすいポイントなので注意しつつ、まずは小規模な社内ドキュメント検索から試してみるのが良い出発点だと思う。

読んで頂いて有り難うございます!