社内向けRAGのベクトル置き場として、専用のベクトルDBではなく既存のPostgreSQLに pgvector を入れ、Djangoから使う構成で作りました。
動かすまでは簡単でしたが、エラーが出ないまま精度だけ静かに落ちるタイプの罠がいくつもありました。実装順に7つ並べます。
前提の構成
- Django + PostgreSQL + pgvector-python
- 埋め込みモデル:
multilingual-e5-small(384次元) - 規模: チャンク数十万件まで
# migrations/0002_vector.py
from django.db import migrations
from pgvector.django import VectorExtension
class Migration(migrations.Migration):
operations = [VectorExtension()] # CREATE EXTENSION vector をマイグレーションで
# models.py
from django.contrib.postgres.fields import ArrayField
from django.db import models
from pgvector.django import VectorField, HnswIndex
class Chunk(models.Model):
document = models.ForeignKey("Document", on_delete=models.CASCADE)
ordinal = models.IntegerField()
body = models.TextField()
body_hash = models.CharField(max_length=64)
allowed_groups = ArrayField(models.IntegerField(), default=list)
embedding = VectorField(dimensions=384)
class Meta:
indexes = [
HnswIndex(
name="chunk_emb_hnsw",
fields=["embedding"],
m=16,
ef_construction=64,
opclasses=["vector_cosine_ops"],
),
]
落とし穴1:次元数はあとから変えられない
VectorField(dimensions=384) の384は、埋め込みモデルの出力次元そのものです。モデルを変える=全チャンクの作り直しで、カラム定義だけ変えても既存データは使えません。
モデル選定は「どれが一番精度が高いか」より、「後で乗り換えるときのコスト」を見積もる作業だと考えたほうが実態に合います。
落とし穴2:e5系の query: / passage: を付け忘れる
# 正しい
q = model.encode("query: " + question)
d = model.encode("passage: " + chunk_body)
# 付け忘れ → 例外は出ない。それらしい結果も返る。精度だけ落ちる
q = model.encode(question)
e5系は質問側と文書側で別の接頭辞を付ける前提で学習されています。付け忘れても何も起きないので、「なんとなく精度が悪い」の原因がこれ、ということが普通にあります。
落とし穴3:IVFFlatを空のテーブルに作る
pgvectorの近似インデックスはHNSWとIVFFlatの2種類です。
| HNSW | IVFFlat | |
|---|---|---|
| 作成速度 | 遅い | 速い |
| 検索精度 | 高い | 設定次第 |
| メモリ | 多い | 少ない |
| データ追加 | そのまま入れてよい | 偏ると精度が落ち、作り直しが要る |
| 作成タイミング | いつでも | データ投入後でないと駄目 |
IVFFlatは既存データからクラスタの中心を決めるので、空のテーブルに作ると中身のない区画ができて検索結果が目に見えて悪くなります。そしてDjangoのマイグレーションに素直に書くと、まさに空の状態で作られます。
迷ったらHNSWで。構築が遅いときはセッション単位でメモリを上げます。
SET maintenance_work_mem = '2GB';
SET max_parallel_maintenance_workers = 4;
件数が増えて「昨日まで終わっていた構築が終わらない」ときは、インデックスがメモリに乗り切っていないのをまず疑ってください。
落とし穴4:「正規化しているつもり」で内積を選ぶ
正規化済みのベクトルなら、コサイン距離と内積は順位が一致し、内積(vector_ip_ops)のほうが軽いです。
ただ、モデルを差し替えたときに正規化の有無が変わることがあります。確信が持てないうちはコサインが安全です。
落とし穴5:権限フィルタを後ろに書く
from pgvector.django import CosineDistance
def search(question, user, k=5):
vec = model.encode("query: " + question)
return (
Chunk.objects
.filter(allowed_groups__overlap=user.group_ids) # 権限は先に絞る
.annotate(dist=CosineDistance("embedding", vec))
.order_by("dist")[:k]
)
上位k件を取ってから権限で落とすと、件数が減るうえに「見えない文書が存在すること」自体が漏れます。
ここが pgvectorを選ぶ一番の理由 でもあります。ベクトル検索の結果を、権限テーブルや業務テーブルと同じDBでそのまま絞り込めます。ベクトルDBを別サービスに分けると、IDを持ち帰ってアプリ側で照合する処理を自前で書くことになり、そこがバグの温床になります。
落とし穴6:類似度のしきい値を固定値で決める
「距離0.3以下なら採用」のような固定しきい値は、質問の長さや語彙で距離の絶対値がまるごとずれるため、たいてい機能しません。
代わりに、上位から並べて隣との距離の差(落差)が大きく開いたところで切る方式にしました。手元の実測では0.010が目安でした。
GAP = 0.010
def cut(results):
for i in range(1, len(results)):
if results[i].dist - results[i - 1].dist > GAP:
return results[:i]
return results
「似ている文書が3件しかない質問」と「10件ある質問」を、同じ設定で扱えます。
落とし穴7:モデル差し替えでカラムを上書きする
埋め込みモデルを替える日は必ず来ます。そのとき既存カラムを上書きしていくと、変換中は尺度の違う新旧ベクトルが混在し、検索結果が意味をなさなくなります。精度が落ちても戻せません。
止めずにやる手順はこうです。
- 新しい置き場所を作る(
embedding_v2カラム、またはEmbedding(chunk, model_name, vector)の別テーブル) - バッチで裏から埋める(その間、検索は旧ベクトルのまま)
- 埋め終わったら新しい側にインデックスを張る
- 設定値ひとつで参照先を切り替える
- 問題なければ旧側を削除。問題があれば設定を戻すだけ
5で切り戻せること自体が、実質のバックアップになります。モデル差し替えの予定があるなら、最初からベクトルを別テーブルに分けておくと楽です。
おまけ:差分だけ再計算する
import hashlib
h = hashlib.sha256(body.encode("utf-8")).hexdigest()
if chunk.body_hash != h:
chunk.body = body
chunk.body_hash = h
chunk.embedding = model.encode("passage: " + body)
chunk.save()
ただしチャンクの切り方を変えると全チャンクのハッシュが変わるので、分割ロジックを触ったら全件再計算になります。
モデル選定でボツにしたもの
軽量な蒸留モデルも試しましたが、単語ひとつのクエリに弱い傾向がありました。社内検索では「請求書」「有給」のような一語の入力がとても多く、そこで期待した文書が上位に来ませんでした。
ベンチマークの数字ではなく、自社の実際の検索ワードで比べるのが大事です。最終的には384次元で軽く、一語クエリでも崩れなかった multilingual-e5-small に落ち着きました。
まとめ
- チャンク数十万件までなら pgvector で足りる。最大の利点は速度より「権限・業務データと同じDBで絞り込めること」
- 次元数は後から変えられない
- e5系は
query:/passage:を忘れない - 迷ったらHNSW。IVFFlatは空テーブルに作らない
- しきい値は固定値ではなく落差で切る
- モデル差し替えは並存→切替。上書きしない
この構成を本番運用まで含めて書いた詳しい版と、実際に構築を頼んだ場合の費用感は自社ブログにまとめています。