朝5時のバッチが「142 docs updated」のログを出したのに、検索トップ10は前日と同じ顔ぶれのまま——新版のドキュメントが1件も引けない状態が半日続いた事故です。原因は、content-hashをIDに使った更新設計が「新版の追加」だけを行い「旧版の削除」を忘れていたことでした。この記事では差分がインデックスに反映されたかを検算する1行チェックと、削除→追加の順序を固定したupsert完全形(chromadb・コピペで動く)まで書く。
何が起きたか: ログは「更新完了」、検索は「前日のまま」
| 日時 | 起きたこと |
|---|---|
| 11/1 05:00 | 夜間バッチが仕様書142件を再取得し、embedding→upsertまで完了ログ |
| 11/1 09:30 | 検証クエリ「請求書の締め処理」のトップ10が前日と同一(新旧入り混じり) |
| 11/1 14:00 | 「バッチが終わっているのに反映されない=検索引擎のバグ」と誤認し半日溶けた |
| 11/1 20:40 | インデックスを直接走査したら新旧2系統のIDが併存(1文書あたり平均1.8件) |
トップ10が「前日のまま」なのは検索が遅いのではなく、旧版のベクトルが生き続け、検索距離が近い旧版ばかりが返っていたためです。バグは更新処理ではなく、更新のID設計と順序にありました。
原因の切り分け: 更新と反映の間に3つの穴
-
ID設計: ドキュメントIDを
sha256(本文)にしていたため、本文が変わると新しいIDが発行される。upsertは「IDが同じものを上書き」するため、IDが変われば旧IDは触られず両方生存になる - 順序: 旧版を消す前に新版を足す(追加→削除)と、削除側の条件をIDで書いた瞬間に消す対象が新版にすり替わる。削除はIDではなく「安定キー」で行う必要がある
- 検算なし: バッチ完了ログは「API呼び出しの成功」しか保証しない。「インデックス内の最新版の比率」を測る検算が1行もなかった
1行検算: インデックスの「最新度」を測る
原因の特定にも使えるのが、更新後のインデックスの新鮮度検算です。1ドキュメント=1ベクトルになっているか、1つの安定キーに旧版が何件残っているかを数えます。事故版はIDこそcontent-hashでもメタデータに安定キーを持たせていたため、この集計で二重生存が見えます。
from collections import Counter
import chromadb
client = chromadb.PersistentClient(path="./db")
col = client.get_collection("docs")
meta = col.get(include=["metadatas"])["metadatas"]
dup = Counter(m["stable_key"] for m in meta)
stale = sum(c - 1 for c in dup.values() if c > 1)
print(f"total={len(meta)} unique_keys={len(dup)} stale_copies={stale}")
print("OK" if stale == 0 else f"NG: {sorted(d for d in dup.values() if d > 1)[:3]}件のキーに複製あり")
stale_copies=0以外は全て「反映されていない」。この1行をバッチの末尾に入れるだけで、冒頭の事故は「朝5時の時点で検知」できていました。
修正: 安定キー+「削除→追加」の順序を固定したupsert完全形
"""RAG差分更新の完全形: 安定キーで削除→追加の順序固定+反映検算つき"""
import hashlib
from datetime import datetime, timezone
import chromadb
client = chromadb.PersistentClient(path="./db")
col = client.get_or_create_collection("docs")
def stable_key(doc_id: str) -> str:
return f"doc::{doc_id}" # 内容非依存の安定キー(本文を含めない)
def upsert_docs(docs: list[dict]) -> dict:
# 1) 削除が先: 安定キーで旧版(本文が何であれ)を一括除外
keys = [stable_key(d["id"]) for d in docs]
old = col.get(where={"stable_key": {"$in": keys}})
if old["ids"]:
col.delete(ids=old["ids"])
# 2) 追加が後: 新版に限り投入(idは安定キーそのものにする)
col.upsert(
ids=[stable_key(d["id"]) for d in docs],
documents=[d["text"] for d in docs],
metadatas=[{
"stable_key": stable_key(d["id"]),
"source": d["source"],
"fetched_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"content_hash": hashlib.sha256(d["text"].encode()).hexdigest()[:16],
} for d in docs],
)
# 3) 反映検算: 1キー=1ベクトルの一致をその場で確認
meta = col.get(where={"stable_key": {"$in": keys}})["metadatas"]
assert len(meta) == len(keys), f"反映不一致: expected={len(keys)} actual={len(meta)}"
return {"upserted": len(keys), "checked_at": datetime.now(timezone.utc).isoformat(timespec="seconds")}
ポイントは3つです。①ID=安定キー: upsertのidsにstable_keyを直接使うと、2回目の更新が「同ID上書き」になり複製が構造的に生まれない(hashをメタデータに置けば「内容が変わったか」の判定もできる)。②削除→追加の順序: 仮に途中で落ちても、旧版が残るだけで新版の二重投入にはならない。③反映検算をバッチ内蔵: 完了ログと実インデックスの一致を同じトランザクション内でassertします。
実行方法:
$ python3 rag_batch.py
{'upserted': 142, 'checked_at': '2026-11-01T20:40:11+00:00'}
$ python3 freshness_audit.py
total=142 unique_keys=142 stale_copies=0
OK
比較表: ID設計の3方式
| ID方式 | 更新時の挙動 | 複製リスク | 事故時の検知 | 判定 |
|---|---|---|---|---|
| content-hash (事故版) | 本文更新で新ID発行・旧ID生存 | 高(毎更新で複製) | 検算なしでは不可 | ✗ やめる |
| 連番 (auto ID) | 上書きされるが削除はID不明 | 中(削除が書けない) | 差分取得が困難 | △ |
| 安定キー固定 | 同ID上書き+削除はwhereで一括 | なし(構造的に排除) | 1行検算で一致確認 | ✓ 採用 |
失敗した設計
- 失敗1: content-hashをIDにした設計。「改ざん検知も兼ねて一石二鳥」と考えたのが誤りで、IDの役割(安定した削除・上書きの単位)と指紋の役割(内容の変化検知)を混ぜました。指紋はメタデータ、IDは安定キーに分離が正解です
-
失敗2: 削除条件を
content_hashで書いた初版の修正。削除対象が旧hashだと、バッチ再実行でhashが変わった瞬間に削除不能の孤児が残ります。削除は常にstable_key(where句)で行うべきでした - 失敗3: 検算を「次日の検索で気づく」前提にした運用。検索の顔ぶれは変化に乏しいため、反映漏れは人間の目では検知できません(実測: 半日溶けた)。検算はバッチ内蔵で機械的に
まとめ: 次にやること
- IDを安定キーに切り替え、削除→追加の順序でupsertを書き直す。content_hashはメタデータに移す
- freshness検算(
stale_copies==0)をバッチ末尾にassertとして入れる。外だしの運用スクリプトは忘れる - そのとき注意: 既存インデックスにcontent-hashの旧IDが残っている場合、まず1回「全件削除→安定キーで再投入」の初期移行が必要です。移行を省略すると旧ID系が残り続け、検算が永久にNGになります
読者特典(無料)
本記事のような実装パターンを横断的にまとめたチートシートと、Obsidian・Notionのテンプレート集を無料配布しています。8冊の内容から「何をどのツールでやるか」の判断チャートも含みます。
関連記事(同テーマ)
📗 RAG構築入門 — LLMに「知識」を与える検索拡張生成の実践(Kindle・読み放題対象)