「查不到」不是「不存在」:不可逆刪除前的 8,818 次查證
盤點腳本掃了 8,818 筆,回報 193 筆有缺口:130 筆「內容不同」、63 筆「對面完全沒有」。
它因此拒絕刪除。
那 193 筆全部都在對面,內容 sha256 完全相同。零缺口,193/193 是假陽性。
上一篇問的是「它真的做了嗎」——系統回報成功,實際什麼都沒做。這篇問的是另一個問題:你憑什麼說這兩份是同一份?
為什麼這次不能猜
背景一句話:兩台機器共用一個自架知識庫,約 10,000 筆。這天要把其中一台改成純 client,然後刪掉它上面所有本機副本。
刪除不可逆,而且遺失完全無聲——不會有錯誤訊息,不會有告警。你只會在幾個月後想找某筆東西時發現它不在了,那時已經無從追查它曾經在哪。
所以刪除之前必須逐筆證明:我要刪的每一份內容,對面都有一份等價物。
那個「等價」怎麼判定,就是這篇的全部。
腳本的判定邏輯,和它藏著的假設
# 先用 id 查
if remote.get(namespace, item.id):
ok()
# 查不到就用完全相同的標題搜,命中後比 sha256
elif (found := search(title=item.title)):
compare_sha(found, item)
讀起來很合理。但它至少假設了四件事:
- id 跨機器穩定
- 標題近似唯一
- 搜尋會回傳所有候選
- 「搜不到」等於「不存在」
第一條先破了——檔案同步重新匯入時會換新 id,所以光靠 id 一定會大量誤報,腳本才加了標題這條退路。
問題是第二條和第三條也不成立,而且不是偶爾,是結構上必然。
標題在這個資料集裡,結構上不可能是鍵
知識庫的切片是依 Markdown 標題切的。而進度型文件每個 session 都會生出一組同樣的小標。
實查同一個 namespace 裡:
29× 進度記錄.md § 摘要
22× 進度記錄.md § 下一步
16× 進度記錄.md § 派工留痕
21× 文件/看板/進度記錄.md § 完成什麼
看到這四行,那 130 筆「內容不同」就不需要解釋了。
腳本拿標題去找,找到的是 29 個同名兄弟裡的某一個——然後理直氣壯地比對 sha256,回報「內容不同」。它每次都比對到了東西,只是比對到的不是它以為的那一筆。
第二種失效是另一個機制。進度記錄.md § 下一步 這種標題在好幾個 namespace 裡都有。搜尋沒有限定 namespace,又只取前 10 名,正解根本沒進候選集合——於是回報「對面完全沒有」。那是 63 筆的來源。
兩個是不同的 bug:一個是選錯候選,一個是候選集合根本沒包含正解。合併成「標題比對不準」會弱化問題的形狀。
正確的做法是把標題降級:
# 標題只負責召回,內容決定身分
candidates = search(title=item.title, namespace=item.namespace, limit=50)
match = next((c for c in candidates if sha256(c.content) == sha256(item.content)), None)
改完重驗,193/193 全數命中。
但 sha256 也不是萬用身分證
這裡要誠實劃一條界線:內容雜湊只有在「你要保住的就是內容」時才是充分判準。
如果 namespace 歸屬、標題、時間戳、附件關聯或排序也是不可失去的語意,那麼「內容相同」不足以證明可刪。這次的目標明確是保住內容本身,所以 sha256 夠用。換一個場景就要重新想。
而且雜湊前兩端必須對同一種位元組表示計算——換行、編碼、前後空白的正規化規則不能含糊,否則你會得到一批新的假陽性。
一個能解釋全部 193 筆的錯誤根因
盤點腳本拒絕刪除之後,它寫了一份交接文件,把問題丟給知識庫那端排查。
那端的排查由另一個 AI 助手做。它查到對面的標題帶相對路徑前綴(文件/看板/稽核/xxx.md § 安全性),而交接文件的附錄表格為了排版,把路徑放在表頭、資料列只有檔名。
於是它推論:對方比對時漏了路徑前綴。
那個推論能解釋全部 193 筆。它聽起來合理,內部自洽,而且如果成立的話,整件事就結案了。
它是錯的。盤點腳本實際用的是完整標題。
而且只有盤點那端知道自己餵進比對函式的字串是什麼。排查那端手上只有一份渲染過的交接文件——它拿排版當程式行為的代理,這正是前面那個錯誤換了一件衣服:把給人看的呈現,當成事實本身。
這裡有兩件事值得單獨拿出來。
第一,完整的解釋力是危險訊號,不是驗收標準。 那個根因能解釋 193/193,也正因為如此沒有人再去查證它。一個假說解釋得太乾淨的時候,該問的是「它憑什麼知道這件事」,而不是「還有哪一筆解釋不了」。
第二,排查的裁決權屬於持有一手事實的那一方,不屬於推理最流暢的那一方。 更正這個根因的方法不是換一顆更聰明的腦袋,是回到能觀察實際輸入的那一端重跑一次。
我知道有人會想把這段讀成 AI 話題。有個簡單的檢查:把「另一個 AI 助手」換成「另一個同事」,這段還成立嗎?
完全成立。一個同事看到附錄表格的排版,推論出一個能解釋所有現象的根因——這在 code review 和事故檢討裡每週都在發生。AI 只是這次的執行者。
順序也要講清楚:是我的腳本先用標題當鍵、先產出 193 筆假陽性、先寫了一份排版會誤導人的交接文件。 那個錯誤的根因是這個錯的回音,不是它的起因。
假陽性不是免費的
到這裡很容易寫成「幸好機制擋下來了」。但那會讓讀者學到錯的東西。
先把事實擺正:沒有任何一筆資料真的只存在於待刪的那台機器上。 也沒有任何驗證器放行過危險的刪除。這不是一次死裡逃生。
而 193 筆假陽性的代價是真的:一個正確的操作停擺、一份會誤導人的交接文件、一整輪跨機排查、以及一個錯的根因差點被寫進結論。
更值得警惕的是另一條路。當時如果選擇「保險起見,把那 193 筆全部補寫到對面」,結果會是:193 筆重複條目進入知識庫,而且是沒有 file_key 的孤兒版本——之後每次檔案同步都會與它們並存,標題還一模一樣。
假陽性導致的「保險起見補上去」,會製造出下一次假陽性的材料。
最後,這次錯在安全的一邊,是因為設計時就把不可逆動作的預設值選成「證明不了就拒絕」。那是預設值的功勞,不是判斷力的功勞。同一個 bug 反過來——用不可靠的鍵判定「都在」而其實沒有——會直接刪掉真資料,而且一樣不會有任何跡象。
刪除,然後親眼看一次
確認零缺口後,刪掉了兩份副本(189 檔與 315 檔,共約 103 MB)。知識從此只剩一份,加上 git 這一層異地備份。
而上一篇的教訓正好在這裡發作:那個 git 備份,開頭抓到過它 commit 失敗卻回報成功。修好了,但「修好了」跟「我親眼看過它成功」是兩件事。
所以做了端到端驗證,而且不用測試檔——用當天真實寫入的 23 筆知識。方法是比對兩個雜湊:
# 伺服器上活檔的實際位元組
git hash-object data/vault/.../某筆知識.md
# GitHub 上那個檔案的位元組(fetch 過來的,不是本機快取)
git rev-parse origin/main:data/vault/.../某筆知識.md
第一輪:21/23 相同。兩筆不同。
那兩筆的寫入時間都晚於最後一次備份。觸發一次備份之後,23/23。
那 2 筆不符,比 23/23 更有價值。 如果一開始就全部相符,我反而該懷疑自己是不是在比對兩份同樣的快照。一個有鑑別力的驗證必須有能力說「不」——而你得看它說過一次,才知道它會說。
那是一個自然發生的對照組,不是我設計出來的。
收尾
同一個錯誤,那天出現了三次,一次比一次高層:
| 拿什麼當「同一份」的證據 | 結果 | |
|---|---|---|
| 盤點腳本 | 標題字串 | 193 筆假陽性 |
| 跨機排查 | 交接文件的排版 | 一個能解釋全部的錯根因 |
| 刪除後驗證 | 內容的位元組雜湊 | 21/23 → 23/23 |
三次都是同一句話:人看得懂的名字——標題、檔名、表格排版——是召回手段,不是身分。
它們會被改寫、加前綴、重複使用。用可變的鍵去證明「東西還在」,得到的假陰性會讓人白做工,假陽性則會讓人誤刪。
而在不可逆的動作前面,授權你按下去的不是你的信心,是那個比對函式。它拿什麼當證據,決定了你的資料活不活得下來。