VPSでベクトルデータベースを運用する方法
アプリとインデックスを同じVPSに置けば、ネットワーク遅延は問題になりません。pgvector、Qdrant、Chroma、総当たり検索を比較し、ベクトル数×次元数×4 bytesで必要なRAMを見積もります。
VPS 上のベクトルデータベースに実際にかかるコスト
VPS(仮想専用サーバー)でベクトルデータベースを運用すると、マネージドサービス各社が解決策として提供している問題を解消できます。アプリケーションとインデックスが同じマシン上にあるため、検索要求はネットワークを経由せず、ループバックソケットを通ります。残るのは、常に本質的だったコストです。テキストをベクトルに変換するコストです。さらに、インデックスの構築時間と、サービス提供中にインデックスを保持し続けるための RAM という、2 つのコストがあります。
これにより、重要な判断が変わります。リージョンやエンドポイントまでの往復時間は、考慮しなくてよくなります。ベクトル数、次元数、4 bytes の積が重要になります。この値によって、毎月レンタルするメモリにインデックスが収まるかどうかが決まるためです。
1 台のマシンでミリ秒が実際に消費される場所
セルフホスト構成で、類似検索を 1 回実行したときの処理を追います。
- クエリ文字列を embedding model がベクトルに変換します。CPU では、短い文字列でも数十〜数百ミリ秒かかります。GPU では 1 桁のミリ秒です。
- ベクトルを store に送信します。loopback TCP または Unix domain socket では、1 ミリ秒未満です。
- store がインデックスを走査し、最も近い行を返します。
- コードが一致したテキストを読み取り、プロンプトを組み立てます。
通常、この一覧で最も時間がかかるのは Step 1 です。Step 2 はホスト型ベンダーが競争する部分ですが、1 台のマシン上ではほとんど時間がかかりません。処理時間の内訳を推測しないでください。自分のサーバーで両端の時間を計測してください。
curl http://127.0.0.1:11434/api/embed -s -o /dev/null \
-w 'embed: %{time_total}s\n' \
-d '{"model": "nomic-embed-text", "input": "how do I rotate the api key"}'続いて、検索クエリの前に \timing on を psql で実行します。最初のコマンドが embed: 0.184312s を出力し、psql が Time: 4.201 ms に回答する場合、インデックスのチューニングは誤った作業です。遅延の原因は embedding model です。Ollama で embedding model をローカル実行すると、Step 1 も Step 2 と Step 3 と同じ CPU 上で実行されるため、両方の処理が同じコアと同じ RAM を奪い合います。この store を中心とした取り込み・検索ループについては、セルフホスト RAG パイプラインガイドで説明しています。RAG は retrieval augmented generation の略です。自分のドキュメントを検索し、最適な一致結果をプロンプトに貼り付けます。
約 10 万ベクトル未満なら、すべてをスキャンします
全件スキャンでは、クエリと保存済みのすべてのベクトルを比較します。定義上、再現率は完全です。インデックスもビルド処理も不要で、データに対して古くなることもありません。
どの時点で実用的でなくなるかは、計算量から判断できます。1 クエリのスキャンで読み取るデータ量は n * d * 4 バイトです。n はベクトル数、d は次元数です。768 次元のベクトルが 100,000 個ある場合、1 クエリあたり 307 MB になります。最新の CPU なら、数十ミリ秒でストリーミングできます。500 万個になると、1 クエリあたり 15 GB です。これはもはやクエリとはいえません。
そこで、ベクトルを SQLite に保存し、比較処理を NumPy で実行します。
import sqlite3, numpy as np
db = sqlite3.connect("docs.db")
db.execute("CREATE TABLE IF NOT EXISTS docs (id INTEGER PRIMARY KEY, body TEXT, vec BLOB)")
def add(body, vec):
v = np.asarray(vec, dtype=np.float32)
v /= np.linalg.norm(v)
db.execute("INSERT INTO docs (body, vec) VALUES (?, ?)", (body, v.tobytes()))
db.commit()
def search(query_vec, k=5):
rows = db.execute("SELECT id, body, vec FROM docs").fetchall()
mat = np.frombuffer(b"".join(r[2] for r in rows), dtype=np.float32).reshape(len(rows), -1)
q = np.asarray(query_vec, dtype=np.float32)
q /= np.linalg.norm(q)
scores = mat @ q
return [(rows[i][0], rows[i][1], float(scores[i])) for i in np.argsort(-scores)[:k]]両方を単位長にスケーリングしているため、内積 は コサイン類似度です。スコアが高いほど、近い一致です。mat はクエリごとではなく起動時に 1 回だけ読み込んでください。これにより、SQLite の読み取りを完全にホットパスから外せます。
採用を見送る前に、実際の環境で計測してください。
import time
t = time.perf_counter()
search(q)
print(f"{(time.perf_counter() - t) * 1000:.1f} ms")制約を正直に述べると、これは行列全体を 1 プロセスの RAM に保持する方式です。また、メタデータによるフィルタリングや、同時書き込みへの対応方針もありません。これらのいずれかが問題になるなら、別の方式へ移行してください。SQLite 自体はサーバー側の保存先として十分に実用的です。詳しくは 本番環境での SQLite ガイド を参照してください。実際のワークロードが行を提供することではなく列のスキャンである場合は、DuckDB と SQLite の比較 のほうが役立ちます。
Postgres をすでに運用している場合の pgvector
アプリケーションにすでに Postgres データベースがある場合、pgvector によって新たに追加される構成要素を最小限に抑えられます。これはサービスではなく、拡張機能です。ベクトルは、それが表す行の隣にある通常のテーブルに保存されます。そのため、条件付き検索は、同期を維持する別システムではなく、WHERE 句で実行できます。
Ubuntu 24.04 では、universe コンポーネントに含まれています。
sudo apt update
sudo apt install -y postgresql-16-pgvector
sudo -u postgres psql -d yourdb -c 'CREATE EXTENSION vector;'2026 年 8 月時点で、このパッケージは pgvector 0.6.0 です。upstream より大幅に古いバージョンです。特に反復型インデックススキャンには 0.8 が必要なため、PostgreSQL プロジェクト独自のリポジトリから取得してください。
sudo apt install -y postgresql-common
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh
sudo apt install -y postgresql-17-pgvector17 をサーバーのメジャーバージョンに置き換えます。メジャーバージョンは sudo -u postgres psql -tAc 'SHOW server_version' で確認できます。誤ったメジャーバージョン向けにビルドされた拡張機能パッケージをインストールすると、CREATE EXTENSION は失敗します。Postgres は、実行中のバージョンの share ディレクトリだけを検索するためです。
ERROR: could not open extension control file "/usr/share/postgresql/16/extension/vector.control": No such file or directoryスキーマは、新しい型を 1 つ追加した通常の SQL です。
CREATE TABLE chunks (
id bigserial PRIMARY KEY,
doc_id bigint NOT NULL,
body text NOT NULL,
embedding vector(768)
);
SELECT id, body FROM chunks
ORDER BY embedding <=> '[0.013, -0.021, 0.004]'
LIMIT 5;<=> はコサイン距離、<-> は L2(ユークリッド)距離、<#> は負の内積です。埋め込みモデルの学習に使用された距離を選択してください。誤った距離を選んでもエラーは発生しません。検索結果が静かに悪化するだけです。
インデックスがない場合、このクエリはすべての行を対象とする完全検索です。これは前述のブルートフォース検索に相当する Postgres 版で、同じく完全な再現率になります。max_parallel_workers_per_gather を増やすと、より多くのコアを検索に割り当てられます。最初にこの検索を実行し、インデックスの作成は後に行ってください。これで、インデックスと比較する再現率の基準値を取得できます。
インデックスがデータベースを超える場合の Qdrant
Qdrant は Rust で実装された専用のベクトルストアです。インデックスが十分に大きく、インデックスの構築処理をアプリケーションの Postgres と競合させたくない場合や、pgvector にはないペイロードフィルタリングと量子化が必要な場合に適しています。
docker run -d --name qdrant \
-p 127.0.0.1:6333:6333 -p 127.0.0.1:6334:6334 \
-e QDRANT__SERVICE__API_KEY="$(openssl rand -hex 32)" \
-v "$(pwd)/qdrant_storage:/qdrant/storage:z" \
qdrant/qdrant6333 番ポートは REST(representational state transfer)API と /dashboard のダッシュボードを提供し、6334 番ポートは gRPC を提供します。公開 VPS では、2 つの点が重要です。Qdrant の公式ドキュメントによると、このサービスはデフォルトで「暗号化も認証もない」状態で動作します。また、クイックスタートの -p 6333:6333 はすべてのインターフェースにバインドされます。Docker は独自に転送ルールを書き込むため、ufw のルールを越えてポートを公開します。127.0.0.1 にバインドし、API キーを設定してください。公開 IP から到達可能でキーのない Qdrant インスタンスは、ドキュメントの公開コピーと同じです。
curl -s http://127.0.0.1:6333/collections -H "api-key: $QDRANT_API_KEY"正常な応答は {"result":{"collections":[]},"status":"ok","time":0.00002} のようになります。{"status":{"error":"Unauthorized"}} が返る場合は、ヘッダー名またはキーが誤っています。何も返らない場合は、コンテナが起動していないか、別の場所にバインドされています。このコンテナが自分のサーバーに適した構成かどうかは、通常のステートフルサービスに関する問題です。そのため、Docker とホスト上のデータベースの比較をそのまま適用できます。
Chroma と用途
Chroma は、何もない状態から動作する検索デモまでの最短経路です。
pip install chromadb
chroma run --path /srv/chromaこれは port 8000 でサービスを提供し、chromadb.HttpClient(host="localhost", port=8000) が接続します。Chroma にはデフォルトの embedding function が付属しているため、最初のプロトタイプで別の model server を用意する必要はありません。
このトレードオフは正直に考える必要があります。Chroma が扱いやすいのは、このガイドで扱う判断、つまりどの distance metric を使うか、結果を保持するためにどれだけ RAM が必要かを隠してくれるためです。これはプロトタイプには適していますが、障害対応で呼び出される本番システムには適していません。データがすでに Postgres にある場合、Chroma に移行すると、pgvector に存在しない問題を解決するために、プロセスと同期の問題が追加されます。
インデックスに必要な RAM
チューニングで変えられない下限であるため、まず生ベクトルから計算します。
bytes = number_of_vectors * dimensions * 44 bytes は、各次元に 1 つの 32-bit float を使うことを意味します。Qdrant のキャパシティプランニング用ドキュメントでは、メタデータと最適化中に作成される一時セグメント分として 1.5 倍を加えています。
memory_size = number_of_vectors * vector_dimension * 4 bytes * 1.5次は、実際の埋め込みモデルで使われる次元数について、この式を 1 million 個のベクトルに適用した結果です。
The data behind this chart
[
{
"label": "384 dims",
"raw_gib": 1.43,
"with_overhead_gib": 2.15
},
{
"label": "768 dims",
"raw_gib": 2.86,
"with_overhead_gib": 4.29
},
{
"label": "1024 dims",
"raw_gib": 3.81,
"with_overhead_gib": 5.72
},
{
"label": "1536 dims",
"raw_gib": 5.72,
"with_overhead_gib": 8.58
},
{
"label": "3072 dims",
"raw_gib": 11.44,
"with_overhead_gib": 17.17
}
]これは式から算出した gibibytes (GiB) 単位の値であり、実測値ではありません。メモリ上に確保しておく必要がある容量として読み取ってください。nomic-embed-text のような 768 次元モデルを 1 million 個のチャンクに適用すると、約 4.29 GiB が必要です。これは Postgres 用の余裕を残した 8 GB プランに収まります。同じデータを 3072 次元で扱うと 17.17 GiB が必要になり、収まりません。
調整すべきなのは、表の最後の列ではなく最初の列です。次元数を半分にすると、その後の処理で必要なすべての byte 数が恒久的に半分になります。公開ベンチマークで少し低いスコアになる 768 次元モデルでも、VPS ではより適切な技術的選択であることがよくあります。次に、pgvector の halfvec type を使うと 16-bit float で保存できるため、byte 数をさらに半分にできます。インデックスは expression を通じて作成します。
CREATE INDEX ON chunks USING hnsw ((embedding::halfvec(768)) halfvec_cosine_ops);モデルを選ぶ前に、1 つ制限を確認してください。pgvector の vector type は最大 16,000 次元に対応しますが、HNSW と IVFFlat のインデックスが対応するのは 2,000 次元までです。それを超える場合は halfvec cast を使って 4,000 次元まで対応させるか、インデックスを作成しません。
ビルド時の m と ef_construction のコスト
HNSW(hierarchical navigable small world)は、pgvector と Qdrant の両方で使われるインデックスです。これは階層型グラフです。各ベクトルがノードになり、近いノードへのリンクを持ちます。検索では全件を読む代わりに、クエリに近づくようにリンクをたどります。
m は、各ノードが保持するリンク数です。Faiss のドキュメントでは、HNSW のメモリ使用量をベクトルあたり (d * 4 + m * 2 * 4) バイトとしています。また、m は 4 から 64 の範囲に保つことを推奨しています。768 次元、1000000 ベクトルの場合に計算してみます。
The data behind this chart
[
{
"label": "m = 8",
"link_bytes_per_vector": 64,
"total_gib": 2.92
},
{
"label": "m = 16 (default)",
"link_bytes_per_vector": 128,
"total_gib": 2.98
},
{
"label": "m = 32",
"link_bytes_per_vector": 256,
"total_gib": 3.1
},
{
"label": "m = 64",
"link_bytes_per_vector": 512,
"total_gib": 3.34
}
]この差の大きさは重要です。デフォルトの m = 16 から m = 64 に変更すると、3072 バイトのベクトルデータに対して、ベクトルあたり 512 バイトのリンクが追加されます。その結果、合計サイズは 2.98 GiB から 3.34 GiB になります。差は約 12 パーセントです。この次元数では、メモリを大きく消費するのは m ではありません。ベクトルです。
m が実際に消費するのは、ビルド時間と挿入時間です。ノードを配置する際に、その数の近傍を検索してリンクする必要があるためです。ef_construction は、各ノードの配置時にビルダーが検討する候補リストのサイズです。これを増やすとグラフの品質は向上しますが、ビルドは遅くなります。完成したインデックスのサイズは変わりません。
SET maintenance_work_mem = '4GB';
SET max_parallel_maintenance_workers = 7;
CREATE INDEX ON chunks USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);maintenance_work_mem は、ビルドが数分で終わるか数時間かかるかを左右する設定です。pgvector は、グラフがメモリに収まる場合、メモリ上でグラフを構築するためです。収まらなくなると、pgvector は次のように通知します。
NOTICE: hnsw graph no longer fits into maintenance_work_mem after 100000 tuples
DETAIL: Building will take significantly more time.
HINT: Increase maintenance_work_mem to speed up builds.この通知は、pgvector が出力する中で最も有用な行です。ビルドが大幅に遅い経路へ移行したことを示します。そのため、処理をキャンセルし、先ほど計算した RAM 容量を超える値まで設定を増やしてから、再実行します。別のセッションから進行状況を確認します。
SELECT phase, round(100.0 * blocks_done / nullif(blocks_total, 0), 1) AS pct
FROM pg_stat_progress_create_index;HNSW は initializing を報告し、その後 loading tuples を報告します。ディスクが混雑していないのに、低いパーセントのまま長時間進まない場合は、クエリが停止しているのではなく、maintenance_work_mem の問題です。
計画時に考慮すべき事実が 2 つあります。pgvector の README では、HNSW は IVFFlat より「ビルド時間が長く、より多くのメモリを使用する」と説明されています。その代わり、速度と再現率のトレードオフが改善されます。また、HNSW は空のテーブルにも作成できます。一方、IVFFlat はまず代表データに対して k-means を実行する必要があるため、空のテーブルに作成すると再現率が低くなります。新しいスキーマでは、事前に作成できるのは HNSW です。
ef_search: 構築後に調整するパラメーター
m と ef_construction はインデックスに固定されます。ef_search は固定されません。グラフを探索する際に保持する候補数を設定し、セッション単位またはクエリ単位で変更できます。
SET hnsw.ef_search = 100;
SELECT id, body FROM chunks ORDER BY embedding <=> $1 LIMIT 5;デフォルト値は 40 です。値を上げると再現率が向上しますが、レイテンシーも増加します。値を下げると、どちらも低下します。再構築せずに変更できる再現率の制御項目はこれだけです。そのため、正解が既知の固定クエリセットで調整し、再現率が向上しなくなった時点で止めます。
注意点が 1 つあります。ef_search は、選択性の高い WHERE 句と組み合わせると問題が発生します。インデックスは固定数の候補を返し、その後でフィルターが適用されるためです。大半の行を除外するフィルターでは、テーブル内に一致する行が残っていても、LIMIT 件未満の結果しか得られないことがあります。pgvector 0.8 では、これに iterative scans で対応します。
SET hnsw.iterative_scan = relaxed_order;その後、制限数を満たすまでインデックスを再スキャンして候補を追加します。上限は hnsw.max_scan_tuples で、デフォルト値は 20000 です。strict_order は正確な距離順を維持しますが、コストが増加します。Ubuntu 0.6.0 パッケージにはこの機能がなく、フィルター適用時に結果が欠落することで、その違いを確認できます。
RAM に収まる必要がある理由
HNSW の検索は、グラフ上をたどる処理です。各ホップでは、直前のノードとは無関係な場所に格納されたノードを読み取るため、アクセスパターンはほぼランダムになり、先読みは役に立ちません。グラフが RAM 上にある間は、各ホップがメモリ参照になります。RAM に収まらなくなると、1 ホップがディスク読み取りになる可能性があり、数百ノードを参照する検索は、数百回の読み取りを伴う処理になります。
Qdrant のドキュメントには、これが次のように示されています。「RAM に格納するベクトル数を半分にすると、検索レイテンシはおおむね 2 倍になります」。この説明を前提に計画してください。
インデックスが実際に RAM に収まらない場合は、どの選択肢にもトレードオフがあります。意図を持って選択してください。
- ベクトルをメモリーマップし、ホットなページをオペレーティングシステムにキャッシュさせ、コールドなページをディスクに残します。許容できる性能にするには、基盤ストレージとして高速な NVMe(non-volatile memory express)が必要です。
- 量子化し、各次元を 4 バイトではなく 1 バイトで格納します。ベクトルのバイト数を 4 分の 1 に減らせますが、再現率がわずかに低下します。
- pgvector で
halfvecにキャストします。1 バイト量子化よりも再現率の低下を抑えながら、必要なバイト数を半分にできます。 - より小さいモデルで埋め込みを生成します。最も安価な対策ですが、コーパスを再度埋め込む必要があるため、実施されないことが多い方法です。
障害パターンと表示されるメッセージ
could not open extension control file。 実行中の Postgres のメジャーバージョンに対応する pgvector パッケージがインストールされていません。sudo -u postgres psql -tAc 'SHOW server_version' でバージョンを表示し、対応する postgresql-NN-pgvector をインストールします。
ERROR: expected 768 dimensions, not 1536。 カラムの型とモデルが一致していません。埋め込みモデルを変更した後、再度埋め込みを生成していません。この場合、部分的な修正はできません。異なるモデルのベクトルはまったく比較できないため、すべての行を再生成する必要があります。
クエリが遅く、EXPLAIN に sequential scan と表示される。 インデックスの operator class とクエリの operator が一致していません。vector_cosine_ops は <=> にのみ対応します。クエリに対して EXPLAIN ANALYZE を実行し、Index Scan using ... on chunks を確認します。代わりに Seq Scan on chunks が表示される場合は、実際にクエリで使用している operator に対応する operator class でインデックスを再構築します。
LIMIT より行数が少なく、WHERE 句が存在する。 これは前述のフィルタリングによる問題です。hnsw.ef_search を増やすか、pgvector 0.8 に移行して hnsw.iterative_scan を設定します。
インデックスの構築が、エラーを psql に出力しないままプロセスの消失で終了する。 maintenance_work_mem をマシンの大部分に設定すると、shared_buffers とアプリケーションもメモリを必要とするため、最終的に kernel の out-of-memory killer がプロセスを終了させます。sudo dmesg -T | grep -i 'killed process' に postgres を示す行が表示されます。設定値を下げるか、より大きなプランでインデックスを構築してから、ダンプをリストアします。
選定
すでに Postgres を運用していて、ベクトル数が数百万未満であれば、pgvector を使用します。インデックスをデータの隣に配置でき、フィルタリングは WHERE 句で実行でき、既存のバックアップでインデックスも保護できます。インデックスに専用のメモリ上限が必要な規模である場合や、ペイロードに対する複雑なフィルタリングが必要な場合は、Qdrant を併設します。ただし、運用対象のサービスが 1 つ増えます。
ベクトル数が概ね 100000 未満であれば、何かをインストールする前に総当たり検索を測定します。完全な再現率を得られ、構築手順も不要な全件検索は、その規模では妥協ではありません。これが適切な選択です。近似インデックスを導入すると、検索に使っていなかった数ミリ秒のために、チューニングと RAM 圧迫を引き受けることになります。
FAQ
専用のベクトルデータベースは必要ですか。それとも Postgres で十分ですか?
データがすでに Postgres にある場合、一般的な比較記事で示されるよりも長い期間、pgvector で十分です。ベクトルは通常のカラムに保存されるため、フィルタリング付き検索は WHERE 句で実行でき、既存のバックアップでインデックスも保護できます。ベクトル処理に専用のメモリ上限が必要になった場合や、pgvector にないペイロードフィルタリングと量子化が必要になった場合は、Qdrant のような専用ストアへ移行します。
1 台の VPS に保存できるベクトル数はどのくらいですか?
推測せず、number_of_vectors * dimensions * 4 bytes * 1.5 を使って計算してください。768 次元のベクトル 1000000 個は約 4.3 GiB になるため、8 GB プランでも Postgres 用の余裕を残して保存できます。3072 次元のベクトル 1000000 個では約 17 GiB になり、はるかに大きいプランが必要です。この値に最も大きく影響するのは埋め込みモデルの次元数です。そのため、メモリコストを考慮してモデルを選択してください。
インデックスを同じマシンに置いているのに、ベクトル検索が遅いのはなぜですか?
1 台のマシン上では、ネットワークが原因ではありません。原因になりやすい 2 点を確認してください。まず、埋め込みの生成処理だけを計測します。CPU でクエリベクトルを生成する処理は、検索自体より大幅に時間がかかることがあるためです。次に、インデックスが RAM に収まっているか確認します。HNSW 検索はグラフ内をランダムに移動するため、グラフがディスクにあふれると、各移動がディスク読み取りになる可能性があります。Qdrant のガイダンスでも、RAM に保持するベクトル数を半分にすると、検索レイテンシはおおむね 2 倍になるとされています。
HNSW インデックスは構築すべきですか?
ベクトル数が概ね 100000 個未満なら、構築する必要はありません。全件走査では、クエリごとに n * d * 4 バイトを読み取ります。768 次元のベクトル 100000 個では 307 MB です。最新の CPU なら、再現率を完全に維持し、構築処理なしで、数十ミリ秒以内にこのデータをストリーミング処理できます。まずは使用しているハードウェアで全件走査の時間を計測してください。ベンチマーク記事に書かれているからではなく、計測した走査時間が実際に遅すぎる場合にインデックスを構築します。
m を増やすと、実際に何が増えますか?
メモリ使用量よりも、構築時間と挿入時間への影響が大きくなります。768 次元の場合、既定値の m = 16 から m = 64 に変更すると、ベクトルデータ 3072 バイトに対して、ベクトルごとのグラフリンクが 512 バイト増加します。そのため、メモリ使用量は合計で約 12 パーセント増加します。一方、挿入のたびに、4 倍の数の近傍を検索してリンクする必要があります。まずは ef_search を調整してください。変更にコストがかからず、再構築も不要だからです。