0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

RAGのバッチ更新が完了したのに検索トップ10が前日のままだった — 差分反映の検算とupsert順序を直す完全実装

0
Posted at

朝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つの穴

  1. ID設計: ドキュメントIDをsha256(本文)にしていたため、本文が変わると新しいIDが発行される。upsertは「IDが同じものを上書き」するため、IDが変われば旧IDは触られず両方生存になる
  2. 順序: 旧版を消す前に新版を足す(追加→削除)と、削除側の条件をIDで書いた瞬間に消す対象が新版にすり替わる。削除はIDではなく「安定キー」で行う必要がある
  3. 検算なし: バッチ完了ログは「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・読み放題対象)

著者: 葉山悠希 — 書籍シリーズは Zenn / Amazon で公開中

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?