リリース直後はよく当たっていた検索が、半年ほど経つと「なんとなく鈍い」と感じる瞬間があります。エラーは出ていません。レイテンシも変わっていません。ただ、以前なら一番上に来ていた記事が3番目に落ち、ユーザーからの「探しても出てこない」という問い合わせが少しずつ増えていく。私自身、個人開発で複数のサイト横断検索を pgvector で回していて、この「静かな劣化」に何度かつかまりました。
やっかいなのは、これがコードのバグとして現れないことです。SELECT は通り、結果も返ってくる。崩れているのは結果の順位であって、可用性ではありません。ここでは Gemini Embedding と PostgreSQL pgvector で組んだセマンティック検索が運用のうちに再現率を落とす典型的な経路と、本番で実際に手を入れた対処を、順を追ってまとめます。
まず「鈍さ」を数値にする — 再現率を測れないと直せない
劣化の議論を始める前に、再現率(Recall@k)を測る仕組みがないと、すべてが体感の言い争いになります。最初にやるべきは、正解が分かっている評価セットを少量でいいので固定することです。
評価用には総当たり(インデックスを使わない厳密検索)を「真の近傍」とみなし、HNSW など近似インデックス経由の結果がそれをどれだけ取りこぼすかを測ります。pgvector では検索時に enable_indexscan を切ると総当たりに落とせます。
# recall_probe.py — HNSW の Recall@k を総当たりと突き合わせて測る
import psycopg2
DB = {"host": "localhost", "database": "semantic_search",
"user": "postgres", "password": "your_password"}
def topk_ids(cur, qvec, k, exact: bool):
# exact=True のときだけインデックスを無効化して総当たりにする
cur.execute("SET LOCAL enable_indexscan = %s", ("off" if exact else "on",))
cur.execute(
"""
SELECT id
FROM documents
ORDER BY embedding <=> %s::vector
LIMIT %s
""",
(str(qvec), k),
)
return [r[0] for r in cur.fetchall()]
def measure_recall(query_vectors, k=10):
conn = psycopg2.connect(**DB)
hits, total = 0, 0
for qv in query_vectors:
with conn.cursor() as cur:
truth = set(topk_ids(cur, qv, k, exact=True))
approx = set(topk_ids(cur, qv, k, exact=False))
hits += len(truth & approx)
total += k
conn.close()
return hits / total # Recall@k
# 例: 200 件の代表クエリで Recall@10 を継続的に記録する
# print(round(measure_recall(sample_query_vecs, k=10), 4)) # 0.991 などこの値を週次で記録しておくと、後述する原因のどれが効いているかを切り分けられます。私はこの Recall@10 が 97% を下回ったらアラートにする、という運用に落ち着きました。順位がずれてからではなく、ずれ始めで気づけるのが利点です。
原因1: 格納時と検索時で「ベクトルの作り方」がずれている
最も多く、そして最も見落とされるのがこれです。エンベディングは「同じモデル・同じ次元・同じ正規化・同じ用途指定」で作られたベクトル同士でないと、距離が意味を持ちません。運用が長くなると、ここが少しずつずれていきます。
典型的なずれ方は3つあります。
| ずれの種類 | 起きる経緯 | 結果 |
|---|---|---|
| モデルの暗黙更新 | コードが latest エイリアスを参照し、裏でモデルが入れ替わった | 新規格納分だけ別空間のベクトルになり、既存と混ざる |
| task_type の不一致 | 格納は RETRIEVAL_DOCUMENT、検索クエリも同じものを使い回した | クエリ側の最適化が効かず再現率が静かに低下 |
| 次元の取り違え | output_dimensionality を後から変えた/正規化を忘れた | 距離スケールが変わり閾値が無意味化 |
対策は単純で、「ベクトル生成の設定を一箇所に固定し、モデル ID を明示的にピン留めする」ことに尽きます。latest のようなエイリアスを本番の格納・検索パスで使わないのが肝心です。
# embedding_config.py — 生成設定を1箇所に固定する
from google import genai
client = genai.Client(api_key="YOUR_GEMINI_API_KEY")
# モデルIDはエイリアスではなく固定版を明示する。次元も固定し、
# このモジュール以外からは embed を呼ばせない運用にする。
EMBED_MODEL = "gemini-embedding-001" # ← latest/exp を本番で使わない
EMBED_DIM = 768
def embed(text: str, *, is_query: bool) -> list[float]:
res = client.models.embed_content(
model=EMBED_MODEL,
contents=text,
config={
# 格納とクエリで task_type を必ず出し分ける
"task_type": "RETRIEVAL_QUERY" if is_query else "RETRIEVAL_DOCUMENT",
"output_dimensionality": EMBED_DIM,
},
)
v = res.embeddings[0].values
# 768次元など 3072 未満を指定した場合、Gemini 側で正規化されない
# ことがあるため、コサイン前提なら自前で L2 正規化して揃える。
norm = sum(x * x for x in v) ** 0.5
return [x / norm for x in v] if norm else vさらに、どの設定で作ったベクトルかを行に刻んでおくと、後から監査できます。embedding 列の隣に embed_model と embed_dim を持たせ、検索時に現行設定と一致しない行を検知できるようにしておくと、混在事故をその場で見つけられます。
ALTER TABLE documents ADD COLUMN embed_model TEXT;
ALTER TABLE documents ADD COLUMN embed_dim INT;
-- 現行設定と食い違うベクトルが紛れていないかを点検する
SELECT embed_model, embed_dim, count(*)
FROM documents
GROUP BY 1, 2
ORDER BY 3 DESC;
-- 行が2種類以上に割れていたら、それが「鈍さ」の正体である可能性が高い