「バックアップを取る」と決めただけで実行せず、記憶用のベクトルDBを二度壊した話
はじめに
個人用に運用しているベクトルDBがある。日々の作業ログや判断の記録を放り込むと、AIアシスタントが意味検索で引き出してくれる、外付けの長期記憶のような仕組みだ。設計の思想は 5年連れ添うRAGの設計 に書いた。この記事はその設計の話ではなく、その運用で起こした事故の話だ。
先に二つの前提を置く。これが無いと、事故の意味も、そのときの焦りも伝わらない。
前提1:記録の中身は第三者に預けず、手元で運用している。 中身は個人的な記録なので、そのまま外へ出さない方針だ。コード(仕組み)はバージョン管理しているが、記録そのもの=データは外部と同期していない。この方針は正しい。ただし当時はそれを「すべて手元だけ」で実装していたので、クラウドやGitの履歴で”自動的に前の状態へ戻せる”安全網が、データには無かった。戻せるかどうかは、自分が手元にバックアップの実体を作ったかどうかだけで決まる。大げさに聞こえるかもしれないが、このデータは外部化した自分の記憶そのもので、代えがきかない。
前提2:このDBは、AIコーディングアシスタントと一緒に運用している。 日々の移行や整理も、アシスタントに手を動かしてもらうことが多い。だから「アシスタントがどう動くか」をこちらが設計・調整(チューニング)しておく必要がある。今回の事故は、その調整がまだ足りていなかったために起きた。
そのDBを、二度壊した。二度とも同じ壊し方で、しかも防げた壊し方だった。
何が起きたか
作業はデータを別の置き場所へ移す移行だった。途中で失敗すれば元のデータが壊れうる、典型的な破壊的操作だ。
工程としては「破壊的操作の前にバックアップを取る」と決めてあった。判断はそこまで正しい。ただしこの時点では、それは**運用ルールとして書いてあるだけ(助言)**で、守られたかを機械的に確かめる関門は無かった。アシスタントは移行に入る前に「バックアップを取ります」と述べたが、実体を作らないまま移行を進めた。決めごとはあったが、それを強制する仕組みが無かった。
移行を回した直後、検索が返らなくなった。何を投げても結果がゼロ、やがてDBそのものがエラーで応答しなくなる。**そのとき血の気が引いた。**前提1のとおり、この記憶はクラウドに無い。戻せる実体も作っていない。「数か月ぶんの記録が、たった今まるごと消えたのか」——その可能性が頭をよぎった数分間は、正直かなり焦った。
工程を図にすると、事故がどこで起きたかがはっきりする。
破壊的操作(データ移行)の直前
│
├─▶ バックアップの実体を作る ─▶ 置き場所を確認 ─▶ 移行を実行
│ │
│ ├─ 成功 ─▶ 完了
│ └─ 失敗 ─▶ 実体から復元 ─▶ 復旧(短時間)
│
└─▶「取るつもり」で実体を作らない ─▶ 移行を実行 ─▶ 検索が全滅
│
└─▶ 戻せる実体が無い ─▶ 手作業で復旧(数時間)
▲
(二度目も同じ下の経路を通った)┘
どう壊れて、どう戻したか
焦りながら中身を確認して、少し息をついた。壊れていたのは検索用の索引だけで、記録の本体は無事だった。
このDBは、意味検索を速くするために HNSW という索引を持っている。近いベクトルを辿って目的地に近づくグラフ構造だ。並行して走った処理が同じDBを別々に掴んだせいで、このグラフがディスク上で不整合になり、検索が総崩れになっていた。一方、記録の本体(メタデータ付きの文書)は別の場所に無傷で残っていた。壊れたのは「引き出す仕組み」で、「記憶そのもの」ではなかった。
分かってしまえば、復旧は落ち着いてやれる。手順はこうだ。
検索が全滅(索引だけ破損/本体は無事)
│
├─▶ ① まずDBディレクトリを丸ごとバックアップ(これ以上失わないため)
├─▶ ② 生のSQLで文書とメタデータを全件エクスポート ─▶ 件数照合(欠損ゼロを確認)
├─▶ ③ 壊れた索引(コレクション)を削除して作り直す
└─▶ ④ 全件を埋め込み直して再投入 ─▶ 件数一致・検索が復活
一度目は、全記録(当時 3,707 件)を1件も欠かさずに戻せた。ただし埋め込みの作り直しに約2時間19分かかった。データは無事でも、時間は返ってこない。
そして数日後、二度目が来た。別の破壊的な移行で、また同じ壊し方をした。前回と同じ手順で全件(4,732 件に増えていた)を戻したが、「またか」という気分と、もう一度数時間を溶かす徒労感は、一度目より重かった。二度あることは、たまたまではない。構造の問題だと認めるしかなかった。
なぜ二度目が起きたのか
原因を二つの層に分けた。表に出ていた引き金と、その下にあった本当の原因だ。
表の原因:DBを複数のプロセスが同時に直接触っていた
このベクトルDBは当初、「プログラムから直接DBファイルを開く」方式で動かしていた。手軽だが落とし穴がある。複数のプロセスが同じDBファイルを別々に開いて書き込むと、内部の索引が矛盾した状態になりうる。移行の処理と、裏で回っていた自動取り込みの処理が、同じ実体を別々に掴んでいた。索引の破損は、この競合が引き金を引いていた。
一度目のとき、私はここで止めてしまった。索引を作り直して検索が戻った時点で「直った」と扱い、「そもそも何が索引を壊したのか」を詰めなかった。だから同じ構造が残り、数日後に同じ場所で足をすくわれた。
裏の原因:決めごとを「強制する仕組み」が無かった
もう一つの、より深い原因はこちらだ。「バックアップを取る」という判断が「取った」という事実と区別されず、しかもその区別を強制する関門が無かった。ルールを助言として書いても、実行の現場で守られたかを確かめる関門が無ければ、決めごとは素通りしうる。
特に、AIアシスタントに手を動かしてもらう運用では、「述べたこと」と「やったこと」の隙間が広がりやすい。「バックアップを取ります」という宣言と、実体が在るという事実は、別のものだ。自分の手で作業するときは頭の中で何となく繋がっているその二つが、作業を任せる相手が変わると、繋がっていないことが表に出る。今回はそこを突かれた。
作り直した運用
1. DBへの入口を一つに絞った
「プログラムから直接ファイルを開く」方式をやめ、一つの常駐プロセスがDBを占有し、他の処理はそこへ接続して読み書きする方式に変えた。書き込みの入口が物理的に一つになるので、複数のプロセスが同じ実体を別々に掴んで索引を矛盾させる余地が消える。
【before】各プロセスがDBファイルを直接開く(競合が起きうる)
移行の処理 ─┐
├─▶ 同一のDBファイル ─▶ 索引が矛盾しうる
自動取り込み処理 ─┘ ▲
└─ 別プロセスが同時に書き込み
【after】常駐プロセスがDBを占有し、他は接続する(入口を一本化)
移行の処理 ─┐
├─▶ 常駐プロセス(DBを占有)─▶ DB
自動取り込み処理 ─┘ │
排他制御を多層に重ね
同時書き込みを直列化
2. 書き込みが競合しないよう多層で守った
入口を一つにしたうえで、同時に走らせたくない処理が重ならないよう、排他制御を複数の段に重ねた。片方の段をすり抜けても、別の段で止まる。安全側の設計は、一枚の完璧な壁より、多層で受け止めるほうが効く。
3. 「決めごと」を助言から強制へ引き上げた
一番効いたのはこれだ。「実体が在り、置き場所を確認してから破壊的操作に進む」というルールは決めた。だが今回の教訓は「ルールを書くだけでは足りない」だった。守られたかを確かめる関門が無ければ、助言はいつか素通りする。
そこで、破壊的操作をしようとした瞬間にバックアップの実体があるかを機械的に確かめ、無ければそこで止める関門を、アシスタントの運用に組み込んだ。助言を、強制に引き上げたわけだ。
破壊的操作の要求
│
├─[助言だけの頃]─▶ ルールは"あった"が確認は無い ─▶ 実体なしでも素通り ─▶ 破損
│
└─[関門を置いた後]─▶ バックアップの実体を機械的に確認
│
├─ 実体あり ─▶ 破壊的操作を許可
└─ 実体なし ─▶ ここで停止(先へ進ませない)
これは、AIアシスタントを安全に働かせるための調整(チューニング)そのものだ。人でもAIでも、「気をつける」に頼る設計は、忙しい現場のどこかで必ず漏れる。漏れてはいけない一線は、注意ではなく関門で守る。
直したあとは、正常に動くことだけでなく「壊れうる操作をしても耐えるか」を、正常系と異常系の両方から確かめた。正常に動くことは「正しく動く」を示すだけで、「壊れない」を示さないからだ。
残った穴:手元のバックアップは、手元ごと失われうる
三つの手当てで、壊れ方の再発は止まった。だが、まだ塞げていない穴がある。バックアップの実体を手元に作っても、その手元(ディスクや機体)ごと失われれば道連れになる。 前提1のとおり、この記憶はクラウドに無い。ローカルにしか無いということは、故障・盗難・災害の一発で、バックアップごと消えうるということだ。
ここで最初の悩みが戻ってくる。「中身は第三者に預けたくない。でも、手元だけでは耐久性が足りない。」——この二つは、両立できる。手元で暗号化してから、暗号化された塊だけを遠隔に預ければいい。 預け先には中身が読めず(鍵は手元だけ)、それでいてディスクが死んでも遠隔から戻せる。
手元(自分だけ) 遠隔(クラウドストレージ)
平文のDB ─▶ 手元で暗号化 ─▶ 暗号化された塊だけを送る ─▶ 保管
│ │
鍵は手元だけ 預け先からは中身が読めない
│
├─ 目的1:ディスクが死んでも遠隔から戻せる(耐久性)
└─ 目的2:中身は第三者に渡さない(前提1を維持)
設計はこう決めた。クライアント側で暗号化する定番ツール restic で暗号化しながら増分バックアップを取り、預け先を自分のアカウントの AWS S3 にする。要点は三つだ。
- 暗号化は手元で完結する。 restic がローカルで暗号化してから送るので、預け先に載るのは暗号化された塊だけだ。鍵(強いパスフレーズ)は手元とオフラインの控えで二重に持ち、預け先には平文も鍵も渡さない。前提1(中身を外に出さない)は、これで保たれる。
- 預け先は最小権限で隔離する。 バックアップ専用の認証情報に、その保存先への読み書きだけを許可し、公開アクセスは全面的に塞ぐ。鍵や権限が漏れたときの被害を、この一箇所に閉じ込める。
- 重複排除で肥大を畳む。 これまでの素朴なやり方は、バックアップのたびに丸ごとコピーしていて、数百MBのデータに対してバックアップだけで数GBまで膨らんでいた。restic は変わっていない部分を使い回すので、履歴を何世代持っても、総量は実データの規模に近いところで頭打ちになる。
まだ実装はしていない——ここは次の宿題だ。ただ、何を使ってどう守るかはもう決めた。手元・遠隔・暗号化・最小権限、この四つを組み合わせれば、中身を渡さないことと遠隔で耐久性を持つことは両立する。
二度目を防ぐために変えたこと
「取る」は完了ではない。 完了の条件を、決定ではなく成果物の実在で定義する。バックアップなら、戻せる物がそこに在ること。遠隔に退避先を用意するにせよ、破壊的操作の直前に手元へ戻せる実体を作ることが、第一の防波堤になる。
壊れたら、まず「本体」と「索引」を切り分ける。 今回、本当に失われたものは何も無く、壊れたのは作り直せる索引だけだった。パニックの最中でも、失ったものと再生成できるものを分ければ、復旧は落ち着いて進む。だから本体と索引を分けて持つ構成を、最後の保険として維持する。
守りたい一線は、助言ではなく強制で守る。 「気をつける」で守れるのは平常時だけだった。AIアシスタントに実作業を任せる以上、「述べた」と「やった」の隙間は、注意ではなく関門で塞ぐ。それがアシスタントを安全に働かせる調整になる。
二度溶かした数時間は返ってこない。それでも代えのきかない記憶が一度も失われなかったのは、本体が索引と分かれて残っていたからだ。破壊的操作の前に「戻せる物は本当にそこに在るか」を確かめる関門は、気合ではなく仕組みとして持つ。次に同じ場所で足をすくわれないために、そう決めた。
関連する記事
- そもそも、なぜこのベクトルDBが二度壊れたのか(複数プロセスでファイルを直接開いていた)と、その構造的な直し方は 「ファイルを直接開くDB」を複数プロセスで触って、二度壊した に書いた。この記事はその予防(バックアップ)側にあたる。
- このベクトルDBの検索精度を、感覚ではなく数値で改善していった話は 測定駆動でRAGの検索精度を上げる にまとめている。
- この事故の教訓「助言を強制に引き上げる」を、AIアシスタント側の仕組みとして実際に作った話は AIアシスタントに「やらせない」仕組みを作る にまとめた。
- 同じ埋め込みでも、検索・推薦の設計側で踏んだ別の落とし穴(種類の違うカタログの混線)は ベクトル検索に別カテゴリが紛れ込む罠 にまとめた。
- 設計の思想そのものは 5年連れ添うRAGの設計 に書いた。
- 別の一括操作でやらかした文字化け事故と復旧の記録は 一括テキスト編集で起きたエンコーディング事故 にある。