來源與去重
兩個看起來像同一件事的問題:
- 我是不是已經匯入過這個了?——靠來源座標回答
- 代理是不是漏了東西?——靠雙向核對回答
第一部分:比對文字抓不到重複匯入
當同一份來源被送第二次——同一頁被重拍、同一個網址被查第二輪、同一份訪談被重新處理 ——文字每一次都會不一樣。LLM 重述同一段話產生的字就是不同。相似度比對在這裡是 一場必輸的仗:門檻設高會漏掉重複匯入,設低會擋掉真正不同的紀錄。
比對它從哪裡來,不要比對它說了什麼。
來源座標
每一張有可能收到同一份來源兩次的表,都需要一個能識別「來源位置」的座標, 以及一個蓋在上面的 UNIQUE 約束:
| 內容型態 | 座標 |
|---|---|
| 某份文件的摘錄 | (document_id, location) |
| 某份文件對某個項目的引用 | (document_id, entity_type, entity_id, location) |
| 某個來源對某個項目的描述 | (entity_type, entity_id, source) |
| 一個具體案例 | (entity_type, entity_id, title) |
| 兩個項目之間的關係 | 正規化後的無向配對 |
這張表教了三件很容易做錯的事:
對稱關係必須正規化。 如果 (a, b) 與 (b, a) 意思相同,存之前先排序,否則同一個
關係會被寫兩次。而查詢既有關係時,兩個方向都要查——約束會正規化,你的
WHERE 不會。
有些座標刻意要含一個區別欄位。 一份食譜在醃料放鹽、在醬汁又放鹽,那不是重複。 如果座標含有步驟,兩列都是合法的。這件事往嚴格的方向做錯,比往寬鬆的方向做錯更糟: 你會安靜地拒絕掉正確的資料。
有時候正確的鍵是內容,不是來源。 某個專案把描述表的鍵設成 (項目, 來源),
結果太嚴格——同一個來源本來就會產生一段主描述與一段補充描述,而且一年後重新查同一個
來源會產生合法地不同的內容。改成「只擋位元組完全相同的內容」修好了,但注意這個交換:
資料庫不再告訴你這個來源是不是已經匯入過了。那件事得你自己查。
約束是第二道防線,永遠不是第一道
就算有約束,也一律先查再寫。
撞到約束時你只會拿到一句 duplicate key。你不會知道既有那筆說了什麼、它比你的好還是
差、正確的做法是略過、更新、還是另外加一列並存。約束買到的只有「損害沒有發生」。
所以流程是:
- 查那個座標。
- 存在的話,停下來。把既有那筆與這次要寫的那筆並排列出。
- 由人決定:略過/更新既有那筆/當成獨立來源另外加一列。
三種在不同情況下都是對的,而這正是代理不能自己決定的原因。
記兩個座標,不是一個
source——誰說的retrieved_at——你哪一天拿到的
第二個不是流水帳。一旦你接受多個來源是並存的,「這幾筆裡哪一筆是現行的」就變成一個 真實的問題,而沒有日期就無法回答。
量測值同理:記下在什麼條件下量的。兩個看起來互相矛盾的來源,可能量的是不同的 東西,而去「解決」那個矛盾會銷毀真實的資訊。
第二部分:雙向核對
一個代理在處理一批資料時,可能在兩個不同的地方掉東西,而只有其中一個好抓。
方向 A——每一項都被看過了嗎? 每個輸入項都出現在代理的分流表裡。這是人們會寫的 那個檢查。
方向 B——被找到的每一項都被寫下來了嗎? 分流表裡的每一個標記,產出裡都有對應項嗎?
方向 B 才是會抓到真實損失的那個。 某一則被標成含有三類內容,產出了兩類,第三類 沒有。分流表是完整的。產出內部是自洽的。任何你想得到要檢查的清單裡都沒有東西缺席。 是有人直接問那一則發生了什麼事,才發現的。
分析時認出來了、寫產出時掉了——這比從頭沒看到更難察覺,因為每一個局部檢查都會通過。
怎麼實作
要求代理在產出旁邊附一份分流表:
{
"routing_table": [
{ "item": "p.58 / 說話者 X", "categories": ["1", "2", "4"] },
{ "item": "p.61 / 說話者 Y", "categories": ["5"] }
]
}
然後在交付前,對每一組 (項目, 類型),用項目身分去產出陣列裡找對應項。找不到的
就是漏了。
整批做完,再核對,再交付。 邊做邊逐項核對,你就沒有辦法發現總數對不上。
數量對不上是偵測這一類錯誤唯一的機械方法。 如果你的流水線不會產生任何一個「可能 對不上」的數字,那它根本無法偵測這一類錯誤。
第三部分:刻意丟棄清單
要求每一個萃取代理回報它丟掉了什麼、為什麼、在哪裡。
"deliberately_discarded": [
{ "content": "<簡短摘要>", "location": "<在哪裡>", "reason": "<為什麼>" }
]
在你看清它的用途之前,這都像多餘的工。它是唯一能讓審查者抓到判斷錯誤的地方。
如果代理把有價值的東西誤判成雜訊,產出看起來會完美無缺——那條原則就只是不在那裡, 而且沒有任何跡象顯示它曾經在。丟棄清單是那個決定唯一留下的紀錄。
兩條讓它真的有用的規則:
- 空的丟棄清單是紅旗,不是乾淨。 一批什麼都沒丟的資料,幾乎可以確定是代理根本 沒考慮過要丟。
- 丟棄要帶位置。 「一些自傳性的內容」不可審查;「p.61,說話者 Y,經歷」才可以。
第四部分:建新筆之前先做項目解析
一份策展資料集裂成兩半最常見的方式:同一個真實事物用兩個名字進來。
- 建新筆之前,走完整條別名鏈:本名、別名,以及——如果你的模型允許——指向其他 項目型別的別名,不是只有主表那一種。
- 主要名稱查不到時,換次要軸再查一次。 翻譯名、學名、正式識別碼。一個以不同顯示名
存在、但底層鍵相同的既有紀錄一定會撞——有時吵鬧地撞在 UNIQUE 上,有時安靜地撞在
JOIN 裡。見
silent-failure-hunting第 1 條。 - 解析不到的名字絕不自動建檔。 寫進
data/staging/unresolved.json讓人決定。 這條規則的來源是:一支「查無就自動建新筆」的解析器造出了八組跨表重複,全是很普通的 東西,事後每一組都要人工查證合併。 - 對話中的臨時匯入也要走同一支解析器。 那個沒有留腳本、在對話中臨時下的匯入, 正是會造出重複的那一個。
相關
curation-pipeline——這套紀律運作所在的 staging 閘門silent-failure-hunting——為什麼這裡的檢查全都繞著數量與座標打轉