VPSだけでRAGパイプラインを構築する方法
VPS上でRAGのチャンク分割、埋め込み、保存、検索を実行します。pgvectorのスキーマ、HNSWのサイズ設計、Ollamaのローカル埋め込みモデル、検索を検証するSQLを紹介します。
セルフホスト型 RAG パイプラインの構成
RAG パイプライン(retrieval augmented generation)には、5 つの段階があります。ドキュメントをチャンクに分割し、各チャンクを埋め込み、ベクトルを保存し、質問に最も近いチャンクを検索し、そのチャンクを言語モデルに渡して回答を生成します。すでに借りている VPS では、最初の4段階をそのサーバー上で実行できます。PostgreSQL と pgvector 拡張機能でベクトルを保持し、Ollama で提供する小規模な埋め込みモデルでテキストをベクトルに変換します。サーバー外へ出す必要があるのは最後の段階だけです。
この分離が、このガイドの主張です。チャンク分割は通常の CPU 処理です。埋め込みでは、1 億 3700 万パラメータのモデルを使用しますが、必要な RAM は数百 MB です。保存先は PostgreSQL のテーブルであり、1 行も書き込む前に算術計算でサイズを見積もれます。チャンク数が数十万規模のコーパスであれば、これらはすべて一般的な VPS で実行できます。生成は異なります。質問のたびに、今後も継続してコストが発生するためです。
RAG パイプラインのどの部分に実際の費用がかかるか
DigitalOcean のエンドツーエンドの RAG チュートリアルでは、マネージドベクトルデータベースとホスト型の埋め込みモデルを使用しています。費用に関する説明は定性的で、同じクエリをキャッシュすること、取得するチャンク数を少なく保つこと、生成前に再ランク付けすることを勧めています。この方針は正しいものです。ただし、計算方法を変える選択肢である、すでに料金を支払っているサーバーで埋め込みモデルを実行する方法には触れていません。
料金表が変わっても古くならないように、金額ではなくトークン数で考えます。ここでは、400 トークンのチャンクを 100,000 個含むコーパスに対して 10,000 件の質問を行い、各回答でモデルに 8 個のチャンク、100 トークンの質問と指示ブロック、400 トークンの回答を送るものとします。
The data behind this chart
[
{
"label": "Embed the corpus (once)",
"tokens_millions": 40,
"tokens_per_question": "4,000"
},
{
"label": "Embed each question",
"tokens_millions": 0.2,
"tokens_per_question": "20"
},
{
"label": "Generation input",
"tokens_millions": 33,
"tokens_per_question": "3,300"
},
{
"label": "Generation output",
"tokens_millions": 4,
"tokens_per_question": "400"
}
]コーパス全体の埋め込みには 40 百万トークンが必要で、これは 1 回だけ発生します。これを 10,000 件の質問に分散すると、1 件あたり 4,000 トークンです。質問が 100,000 件になると、1 件あたり 400 トークンまで下がります。生成のコストは下がりません。回答するすべての質問で、入力に 3,300 トークン、出力に 400 トークンが必要です。
つまり、費用は繰り返し実行される段階に集中します。埋め込み処理は 1 回だけで済み、VPS はいずれにしても稼働しているため、これを自前で実行します。生成処理は、より優れたモデルに実際の価値があるため、外部サービスを利用します。キャッシュが重要なのも同じ理由です。キャッシュヒットが発生すると、コストを償却できない唯一の段階を省略できます。KV キャッシュとプロンプトキャッシュの違いによって、どちらの部分を再利用できるかが決まります。RAG のプロンプトは、固定された指示ブロックの後に変化するチャンクブロックが続く構成です。この形式は、最も大きな効果を得られる構成です。
チャンク分割: 固定サイズとオーバーラップを標準にする理由
チャンクは検索する単位なので、そのサイズが後続の処理全体を左右します。埋め込みは空間上の単一の点であるため、1 つの事柄を中心にした埋め込みになるよう、チャンクは十分に小さくする必要があります。4 つのトピックを含むチャンクは、それらの中間に配置され、どのトピックにも近くならないためです。一方で、チャンク単独で質問に答えられるよう、十分な大きさも必要です。言語モデルが参照するのは、ドキュメント全体ではなくチャンクだからです。
まずは 300 words、オーバーラップ 50 words から始めます。英語は 1 word あたりおよそ 1.3 tokens なので、300 words は約 400 tokens です。境界上にある文は、オーバーラップがないと途中で分割され、どちらの断片も質問に答えられなくなるため、オーバーラップを設けます。
ドキュメントに構造がある場合は、まず構造に沿って分割します。見出しで分割し、次に段落で分割します。それでも長すぎるセクションの内部でのみ、固定サイズのルールを適用します。文の途中から始まるチャンクは、最終的な回答で読みにくくなります。モデルは渡された内容をそのまま引用するためです。
測定できるようになる前に、チャンク分割を調整しないでください。オーバーラップ付きの固定サイズ分割は決定的で、再実行のコストも低いため、改善対象にできるベースラインになります。まずは後述のスコアリング用クエリを構築し、その後で一度に 1 つだけ変更してください。
同一ホストでの埋め込みと、RAMおよびレイテンシーへの影響
curl -fsSL https://ollama.com/install.sh | sh
ollama pull nomic-embed-textnomic-embed-text は、2026年8月時点で1億3700万パラメーター、274 MBのダウンロードサイズです。これを前提に表を設計する前に、実際の返り値を確認してください。
curl -s http://127.0.0.1:11434/api/embed \
-d '{"model": "nomic-embed-text", "input": "search_document: hello"}' |
python3 -c 'import json,sys; print(len(json.load(sys.stdin)["embeddings"][0]))'これは 768 を出力します。列の型は、この数値に正確に合わせる必要があります。
このモデルには、注意が必要な設定が2つあります。
タスクプレフィックスは省略できません。 Nomic のモデルカードには、入力に「タスク指示プレフィックスを含める必要がある」と記載されています。ドキュメントの前には search_document: 、質問の前には search_query: を付けます。省略しても処理は失敗しません。ベクトルは返されますが、検索品質が低下し、その理由を示すログもどこにも出ません。
長い入力は通知なく切り詰められます。 /api/embed エンドポイントには truncate フィールドがあり、デフォルトは true です。また、Ollama がパッケージ化しているこのモデルは、2K のコンテキストを公称しています。この長さを超えるチャンクは上限位置で切り詰められたうえで埋め込まれるため、末尾部分は検索できません。テスト中は "truncate": false を送信してください。サイズ超過したチャンクが通過せず、失敗するようにします。
リクエストをバッチ化し、モデルをメモリに常駐させてください。
curl -s http://127.0.0.1:11434/api/embed -d '{
"model": "nomic-embed-text",
"input": ["search_document: first chunk", "search_document: second chunk"],
"keep_alive": "30m"
}' > /dev/nullinput はリストを受け取ります。32チャンクを含む1つのリクエストは、32個のリクエストより高速です。HTTP のラウンドトリップとモデルの検索が32回ではなく1回で済むためです。keep_alive は、リクエスト後にモデルをメモリへ保持する時間を制御します。デフォルトは5分です。期限が切れると、次のリクエストで再び読み込み時間が発生します。
自分のホストで、重要な2つの数値を測定してください。これらは vCPU 数に依存するため、公表値と一致するとは限りません。
ollama ps
time curl -s http://127.0.0.1:11434/api/embed \
-d '{"model":"nomic-embed-text","input":"search_document: ... one real chunk ..."}' > /dev/nullollama ps は、読み込まれたモデルの常駐サイズを出力します。これは、keep_alive がモデルを保持する間、確保され続ける RAM です。time の出力をバッチサイズで割ると、1チャンクあたりの秒数になります。これにチャンク数を掛けると、インデックス作成に必要な合計時間を求められます。CPU のみで運用する場合、10万チャンクのコーパスは数分ではなく数時間かかると考えてください。これは問題ありません。処理は1回だけで、nice -n 19 の下で夜間に実行できるためです。数時間かけられない場合に検討すべきなのは、GPU のレンタル費用を回収できるかどうかです。これは好みの問題ではなく、API トークンとの損益分岐点を計算する問題です。
そのホストですでにチャットモデルを提供している場合、埋め込みモデルは2つ目の常駐モデルになります。RAM 使用量は合算されます。VPS 上で Ollama を実行する方法では生成側のサイジングを、複数のユーザーが同時に質問した場合の自己ホストモデルの動作では同時実行時の挙動を説明しています。埋め込みモデルは小さいため、どちらのモデルとも同居させられます。
インデックス作成スクリプトの全体
Ubuntu 24.04 では、仮想環境の外で単純な pip install を実行すると error: externally-managed-environment で停止します。システムの Python は apt が管理しているためです。
python3 -m venv ~/rag
~/rag/bin/pip install "psycopg[binary]" pgvectorimport json, urllib.request
import psycopg
from pgvector.psycopg import register_vector
from pgvector import Vector
OLLAMA = "http://127.0.0.1:11434/api/embed"
MODEL = "nomic-embed-text"
def embed(texts, prefix="search_document: "):
payload = {"model": MODEL,
"input": [prefix + t for t in texts],
"truncate": False,
"keep_alive": "30m"}
req = urllib.request.Request(OLLAMA, data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req) as resp:
return json.load(resp)["embeddings"]
def split(text, size=300, overlap=50):
words = text.split()
step = size - overlap
return [" ".join(words[i:i + size]) for i in range(0, len(words), step)]
with psycopg.connect("dbname=rag user=rag") as conn:
register_vector(conn)
for doc_id, text in documents(): # your loader
pieces = split(text)
for start in range(0, len(pieces), 32):
batch = pieces[start:start + 32]
vectors = embed(batch)
with conn.cursor() as cur:
cur.executemany(
"INSERT INTO chunks (doc_id, seq, body, embedding)"
" VALUES (%s, %s, %s, %s)",
[(doc_id, start + i, body, Vector(vec))
for i, (body, vec) in enumerate(zip(batch, vectors))])
conn.commit()documents() は利用者が実装する部分です。ファイルまたは行を走査し、ドキュメント ID とそのテキストを生成する処理を指定してください。それ以外はすべてパイプラインです。
ストレージ: pgvector のスキーマとそのサイズ
Ubuntu 24.04 に含まれる postgresql-16-pgvector はバージョン 0.6.0 で、halfvec 型より古いバージョンです。最新のビルドを使用するには、PostgreSQL プロジェクトの公式リポジトリを使用します。
sudo apt update && sudo apt install -y postgresql-common
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh
sudo apt install -y postgresql-17 postgresql-17-pgvectorパッケージ名の数字は、サーバーのメジャーバージョンと一致している必要があります。次に、ロール、データベース、拡張機能を作成します。
sudo -u postgres createuser --pwprompt rag
sudo -u postgres createdb --owner rag rag
sudo -u postgres psql -d rag -c 'CREATE EXTENSION vector;'CREATE TABLE chunks (
id bigserial PRIMARY KEY,
doc_id text NOT NULL,
seq int NOT NULL,
body text NOT NULL,
embedding vector(768) NOT NULL,
fts tsvector GENERATED ALWAYS AS (to_tsvector('english', body)) STORED
);
CREATE INDEX chunks_fts ON chunks USING gin (fts);vector(768) は、モデルの出力と一致している必要があります。この列に 1024 次元のベクトルを挿入すると、Postgres は expected 768 dimensions, not 1024 を返して拒否します。これは、このパイプライン全体で最も明確なエラーメッセージです。生成される fts 列は維持コストがかからず、後からキーワード検索を追加できます。
ストレージ容量は算術で求められます。pgvector のドキュメントでは、vector を 4 * dimensions + 8 バイト、halfvec を 2 * dimensions + 8 としています。以下の次元数は、それぞれのモデルが公開している出力サイズです。
The data behind this chart
[
{
"label": "384 (all-minilm)",
"bytes_per_vector": "1,544",
"vector_mib_per_100k": 147,
"halfvec_mib_per_100k": 74
},
{
"label": "768 (nomic-embed-text)",
"bytes_per_vector": "3,080",
"vector_mib_per_100k": 294,
"halfvec_mib_per_100k": 147
},
{
"label": "1024 (mxbai-embed-large)",
"bytes_per_vector": "4,104",
"vector_mib_per_100k": 391,
"halfvec_mib_per_100k": 196
},
{
"label": "1536 (hosted API model)",
"bytes_per_vector": "6,152",
"vector_mib_per_100k": 587,
"halfvec_mib_per_100k": 294
}
]768 次元では、各ベクトルは 3,080 バイトです。そのため、100,000 個のチャンクには、ベクトルデータとして 294 MiB が必要です。同じコーパスを 1536 次元のホステッドモデルで埋め込む場合は 587 MiB が必要で、インデックスもそれに比例して大きくなります。半精度では、両方が半分になります。halfvec(768) を使用すると、そのコーパスを 147 MiB に収められます。再現率に影響するかどうかは、以下のスコアリングクエリを 1 回実行すれば確認できます。
これらの数値は、ベクトル列だけを対象としています。テキスト、行のオーバーヘッド、インデックスは別に必要になるため、実際のテーブルを測定してください。
SELECT pg_size_pretty(pg_total_relation_size('chunks')) AS total,
pg_size_pretty(pg_relation_size('chunks')) AS heap,
count(*) AS n_rows
FROM chunks;API とユーザーアカウントも備えた同じ拡張機能を使用したい場合は、セルフホストの Supabase スタックを利用できます。これは pgvector が有効化された Postgres であり、このガイドのすべてのクエリを変更せずに実行できます。
インデックス作成: 重要な HNSW 設定
数千行未満であれば、インデックスは作成しないでください。完全一致検索はすべての行を読み取りますが、この規模なら十分に高速で、再現率も完全です。逐次スキャンが十分に高速でなくなった時点でインデックスを追加し、近似インデックスはおおむね正しい近傍を返すというトレードオフを理解してください。
SET maintenance_work_mem = '2GB';
SET max_parallel_maintenance_workers = 3;
CREATE INDEX chunks_embedding ON chunks
USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);m = 16 と ef_construction = 64 は pgvector のデフォルト値です。値を大きくすると再現率は向上しますが、構築時間とインデックスサイズが増加します。モデルが単位長ベクトルを出力すると確信できない場合は、<=> 演算子とともに vector_cosine_ops を使用してください。コサイン距離はベクトルの長さを無視しますが、内積は無視しないためです。
構築処理を監視してください。グラフが maintenance_work_mem を超えると、pgvector はそのことを表示します。
NOTICE: hnsw graph no longer fits into maintenance_work_mem after 100000 tuples
DETAIL: Building will take significantly more time.これはエラーではなく、構築処理も完了します。ただし、はるかに遅い経路に切り替わります。インデックスを構築するセッションで maintenance_work_mem を大きくしてください。サーバーのデフォルト値は変更しないでください。この設定はメンテナンス操作ごとに適用されるため、グローバル値を大きくするとサーバーのメモリが枯渇するおそれがあります。長時間かかる構築処理は、別のセッションから追跡してください。
SELECT phase, round(100.0 * blocks_done / nullif(blocks_total, 0), 1) AS "%"
FROM pg_stat_progress_create_index;その後、完成したインデックスとサーバーのメモリ容量を比較してください。
SELECT pg_size_pretty(pg_relation_size('chunks_embedding'));
SHOW shared_buffers;HNSW の検索ではグラフをたどるため、範囲を読み取るのではなく、インデックス内の分散したページにアクセスします。メモリに収まらないインデックスでは、すべてのクエリでディスク読み取りが発生し、ユーザーが気付くのは遅延の大きい末尾部分です。サーバーのサイズを決めるルールはこれだけです。インデックスと実際に提供する行の合計が RAM に収まるようにしてください。比較する数値は free -m と上記のサイズです。
クエリ実行時は、hnsw.ef_search が再現率を調整する設定で、デフォルト値は 40 です。
BEGIN;
SET LOCAL hnsw.ef_search = 100;
SELECT id, body FROM chunks ORDER BY embedding <=> $1 LIMIT 8;
COMMIT;値を大きくすると、グラフのより広い範囲を検索するため、より適切な近傍を見つけられますが、レイテンシーが増加します。これはセッション設定なので、インデックスに変更を加えずに 1 つのクエリだけ値を大きくできます。
クエリがインデックスをまったく使用していない場合は、実行計画に表示されます。
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM chunks ORDER BY embedding <=> $1 LIMIT 8;ここで逐次スキャンになる原因は、ストレージにあることがよくあります。768 次元のベクトルは 3,080 バイトで、Postgres が行内に保持するサイズを超えています。そのため、値は TOAST テーブル(サイズの大きい値を行外に格納する領域)へ移動します。pgvector 自身の説明によると、プランナーはコスト見積もりで行外ストレージを考慮しません。そのため、実際よりも逐次スキャンが安価に見えることがあります。ALTER TABLE chunks ALTER COLUMN embedding SET STORAGE PLAIN; を使用すると、ベクトルを行内に保持できます。この設定は変更後に書き込まれた行に適用されるため、既存の行にはテーブルの書き換えが必要です。
検索: 1 つのクエリで 2 つのシグナル
ベクトル検索は、質問と同じ意味を持つテキストを見つけます。一方で、部品番号、エラーコード、姓など、文字列の完全一致には弱い性質があります。キーワード検索はその逆で、Postgres にはすでにこの機能があります。別のシステムを動かすのではなく、1 つのクエリで両方を組み合わせます。
相互順位融合は、実用的な結合方法の中で最も単純です。各結果について、出現した各リストから 1 / (60 + rank) を加算し、2 つのスコアを合計します。距離ではなく順位を使うため、スコアの正規化は必要ありません。
WITH semantic AS (
SELECT id, row_number() OVER (ORDER BY distance) AS rank
FROM (SELECT id, embedding <=> $1 AS distance
FROM chunks ORDER BY embedding <=> $1 LIMIT 40) s
),
keyword AS (
SELECT id, row_number() OVER (ORDER BY score DESC) AS rank
FROM (SELECT c.id, ts_rank_cd(c.fts, q) AS score
FROM chunks c, websearch_to_tsquery('english', $2) q
WHERE c.fts @@ q
ORDER BY score DESC LIMIT 40) k
)
SELECT c.id, c.body,
coalesce(1.0 / (60 + s.rank), 0) + coalesce(1.0 / (60 + k.rank), 0) AS rrf
FROM (SELECT id FROM semantic UNION SELECT id FROM keyword) u
JOIN chunks c ON c.id = u.id
LEFT JOIN semantic s ON s.id = u.id
LEFT JOIN keyword k ON k.id = u.id
ORDER BY rrf DESC
LIMIT 8;$1 は、同じモデルで search_query: プレフィックスを付けて生成した質問の埋め込みです。$2 は質問のテキストです。どちらもアプリケーションからバインドします。websearch_to_tsquery は句読点を含む実際のユーザー質問でも問題なく処理できますが、to_tsquery はできません。もう 1 つ注意点があります。HNSW スキャンに WHERE フィルターを追加すると、要求した行数より少ない結果しか返らないことがあります。先にインデックスを検索し、その後でフィルターを適用するためです。SET hnsw.iterative_scan = relaxed_order; を指定すると、必要な行数に達するまで pgvector がスキャンを続けます。
検索結果が十分かどうかを確認するには
これは、ほぼすべての RAG ガイドが省略している手順です。ほかの選択が有効だったかどうかを判断できる唯一の手順でもあります。評価フレームワークは必要ありません。必要なのは、30 個の質問と、それぞれに回答するチャンクの id です。
質問は手作業で作成します。このコーパスについて実際に尋ねられる質問を選び、1 つずつ実行します。返された内容を読み、正解であるべきチャンクの id を記録します。30 個では小さな差までは判断できません。しかし、重要な差は検出できます。重要な差は大きいからです。
CREATE TABLE gold (
id bigserial PRIMARY KEY,
question text NOT NULL,
chunk_id bigint NOT NULL REFERENCES chunks(id),
embedding vector(768) NOT NULL
);各質問を search_query: プレフィックス付きで埋め込み、保存します。その後、セット全体を 1 回のクエリでスコアリングします。
WITH hits AS (
SELECT g.id,
min(r.rank) FILTER (WHERE r.id = g.chunk_id) AS hit_rank
FROM gold g
CROSS JOIN LATERAL (
SELECT top.id, row_number() OVER (ORDER BY top.distance) AS rank
FROM (SELECT c.id, c.embedding <=> g.embedding AS distance
FROM chunks c
ORDER BY c.embedding <=> g.embedding
LIMIT 10) top
) r
GROUP BY g.id
)
SELECT count(*) AS questions,
count(hit_rank) AS found_in_top_10,
round(avg(coalesce(1.0 / hit_rank, 0)), 3) AS mrr
FROM hits;found_in_top_10 を questions で割った値が recall at 10 です。これは、モデルに送るウィンドウ内に回答が含まれていた割合を示します。MRR (mean reciprocal rank) は、正しいチャンクの順位の逆数を平均した値です。見つからなかった場合は 0 として扱います。そのため、回答を 8 番目ではなく 1 番目に順位付けした場合に、より高く評価します。チャンクサイズを変更した場合、埋め込みモデルを切り替えた場合、またはキーワード検索を追加した場合、どちらの数値も変化します。これで、どの方向に変化したかを確認できます。
recall at 10 を最優先してください。生成モデルは、受け取っていないチャンクを利用できないためです。recall at 10 が 0.9 で、それでも回答が誤っている場合、原因は検索ではなく、プロンプトまたはモデルにあります。この切り分けだけで、推測に費やす日数を減らせます。
インデックスは別に確認します。近似検索では recall が低下します。pgvector では、その程度を確認できます。同じクエリを exact search でも実行し、id を比較してください。
BEGIN;
SET LOCAL enable_indexscan = off; -- use exact search
SELECT id FROM chunks ORDER BY embedding <=> $1 LIMIT 10;
COMMIT;10 個中 9 個の id が共通していれば、ef_search は問題ありません。10 個中 4 個であれば、値を上げてください。
再ランキングと生成: API の価値が発揮される場所
再ランカーは、別の種類のモデルです。質問と 1 つのチャンクを組み合わせて読み、そのペアをスコアリングします。これは、独立して計算した 2 つの埋め込みを比較するよりも精度が高くなります。一方で、コーパス全体に適用するには遅すぎます。だからこそ、この段階で使います。再ランカーが処理するのは、検索で返された 40 件の候補です。テーブル内の 100,000 個のチャンクではありません。そのため、ホスト型の再ランキング API では、質問ごとに 40 個の短いペアに対して課金され、コストの高い段階に進む前に、質の低い誤検出を除外できます。
生成が継続的なコストになります。コストを左右する要素は 2 つあります。まず、送信するチャンクを減らします。recall at 10 を使って、回答を失わずに送信できるチャンク数の下限を確認します。次に、プロンプトの先頭部分をバイト単位で常に同じにします。これにより、プロバイダーの prompt cache が利用されやすくなります。検索したチャンクは、その固定部分の後に配置します。質問ごとに生成済みの回答もキャッシュしてください。最も安価な生成トークンは、先週すでに生成したトークンです。
サーバーのサイジングと、この構成で不足するタイミング
ここで示すサイジングの基準は、すべて推測ではなく測定によって決めます。
- RAM が制約になります。
ollama psの常駐モデルサイズ、HNSW インデックスのサイズ、shared_buffersを合算し、接続とページキャッシュのための余裕を残します。 - ディスク容量は
pg_total_relation_size('chunks')の 2 倍が必要です。インデックスの再構築中は、両方のコピーを同時に保持するためです。 - CPU によって再インデックス時間が決まります。測定したチャンクあたりの秒数に、チャンク数を掛けて見積もります。
- 再インデックスは想定より頻繁に発生します。埋め込みモデルを変更すると、保存済みのすべてのベクトルが無効になるためです。
この構成では、いずれ不足する時点が明確に見えてきます。HNSW インデックスが購入可能な RAM に収まらなくなると、クエリの遅延はディスクシークによるものになり、設定を変更しても改善できません。1 つのテーブルを多数のテナントで共有し、すべてのクエリでテナントによるフィルタリングが必要になった場合は、テーブルのパーティショニングが解決策になりますが、実際の作業が必要です。インデックス作成の書き込みとユーザークエリが同じサーバー上で競合する場合は、データベースを移行する前に、埋め込みワーカーを 2 台目のサーバーへ移します。これらのいずれかが発生するまでは、すでに借りている VPS 上で Postgres と pgvector を運用する構成で本番利用できます。上記の数値から、限界までの余裕も判断できます。
FAQ
1 台の VPS で RAG パイプラインを実行できますか。それともベクトルデータベースが必要ですか?
数十万チャンク規模のコーパスであれば、1 台の VPS で十分です。768 次元の場合、100,000 チャンクでは 294 MiB のベクトルデータに加えてテキストと HNSW インデックスが必要ですが、一般的なプランの RAM に収まります。制限要因は行数ではなくメモリです。HNSW 検索はインデックス内を不規則に参照するため、インデックスが RAM に収まらなくなるとレイテンシが悪化します。インデックスに対する pg_relation_size と free -m を比較すれば、現在の状態を確認できます。
ドキュメントの埋め込みに GPU は必要ですか?
一度埋め込みを生成し、その後にクエリを実行するだけなら必要ありません。nomic-embed-text のような 137 million パラメータのモデルは CPU で動作し、大規模なコーパス全体の処理には数時間かかるため、夜間に実行できます。ドキュメントが継続的に到着する場合や、同じサーバーで生成処理も実行したい場合は、GPU の効果が出ます。自分のサーバーで /api/embed に対する 1 バッチの処理時間を計測し、チャンク数を掛けてください。vCPU の数は環境による差が大きく、公開された数値は参考になりにくいためです。
ベクトルクエリが HNSW インデックスではなくシーケンシャルスキャンを使用するのはなぜですか?
EXPLAIN (ANALYZE, BUFFERS) で実行計画を確認してください。よくある原因はストレージです。pgvector の説明では、プランナーはコスト見積もりで行外ストレージを計上しません。そのため、シーケンシャルスキャンの方が実際より安価に見えます。また、768 次元のベクトルは 3,080 バイトなので、デフォルトでは TOAST テーブルに保存されます。ALTER TABLE chunks ALTER COLUMN embedding SET STORAGE PLAIN; を使用すると、新しい行をインラインに保持できます。ほかの原因は2つあります。1つ目は、インデックスと一致しない演算子を使用していることです。vector_cosine_ops で構築したインデックスは <=> でのみ使用されます。2つ目は、ORDER BY ... LIMIT のないクエリです。近似インデックスは、近傍を順序付けして取得するクエリにのみ使用されます。
検索結果の品質が十分かどうかを確認するにはどうすればよいですか?
30 個の質問から成る正解データセットを作成します。各質問には、その質問に回答するチャンクの id を対応付け、質問の埋め込みも併せて保存します。次に、recall at 10 と MRR を測定します。recall at 10 は、正しいチャンクが上位 10 件に含まれる頻度です。MRR は、正しいチャンクが1位になるほど高く評価します。この2つの数値により、チャンクサイズ、埋め込みモデル、ランク融合の変更が効果を上げたかどうかを判断できます。これらの指標がないと、設定を変更し、少数の回答に対する印象だけで判断することになります。