# Provenance And Dedup

> 怎麼記錄策展資料的來源、以及怎麼偵測一份來源已經被匯入過——用來源座標而非文字比對、對代理萃取結果做雙向核對、用刻意丟棄清單讓代理的判斷可被審查。當同一份來源可能被送兩次、代理的產出需要檢查有沒有漏、要決定 schema 需要哪些來源欄位、或一份策展資料集長出了重複時使用。

- Skill: `jerryliu0103/provenance-and-dedup-2` (Agent Skill)
- Install (CLI): `npx skillmds@latest add jerryliu0103/provenance-and-dedup-2`
- Raw SKILL.md: https://api.skillmd.com/api/skills/jerryliu0103/provenance-and-dedup-2/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: jerryliu0103 (https://skillmd.com/u/jerryliu0103)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/jerryliu0103/provenance-and-dedup-2

---


# 來源與去重

兩個看起來像同一件事的問題：

- **我是不是已經匯入過這個了？**——靠來源座標回答
- **代理是不是漏了東西？**——靠雙向核對回答

## 第一部分：比對文字抓不到重複匯入

當同一份來源被送第二次——同一頁被重拍、同一個網址被查第二輪、同一份訪談被重新處理
——**文字每一次都會不一樣**。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`。你不會知道既有那筆說了什麼、它比你的好還是
差、正確的做法是略過、更新、還是另外加一列並存。**約束買到的只有「損害沒有發生」。**

所以流程是：

1. 查那個座標。
2. 存在的話，**停下來**。把既有那筆與這次要寫的那筆並排列出。
3. **由人**決定：略過／更新既有那筆／當成獨立來源另外加一列。

三種在不同情況下都是對的，**而這正是代理不能自己決定的原因**。

### 記兩個座標，不是一個

- `source`——誰說的
- `retrieved_at`——你哪一天拿到的

第二個不是流水帳。一旦你接受多個來源是並存的，「這幾筆裡哪一筆是現行的」就變成一個
真實的問題，而沒有日期就無法回答。

量測值同理：記下**在什麼條件下量的**。**兩個看起來互相矛盾的來源，可能量的是不同的
東西**，而去「解決」那個矛盾會銷毀真實的資訊。

## 第二部分：雙向核對

一個代理在處理一批資料時，可能在兩個不同的地方掉東西，**而只有其中一個好抓**。

**方向 A——每一項都被看過了嗎？** 每個輸入項都出現在代理的分流表裡。這是人們會寫的
那個檢查。

**方向 B——被找到的每一項都被寫下來了嗎？** 分流表裡的每一個標記，產出裡都有對應項嗎？

**方向 B 才是會抓到真實損失的那個。** 某一則被標成含有三類內容，產出了兩類，第三類
沒有。分流表是完整的。產出內部是自洽的。**任何你想得到要檢查的清單裡都沒有東西缺席。**
是有人直接問那一則發生了什麼事，才發現的。

**分析時認出來了、寫產出時掉了**——這比從頭沒看到更難察覺，因為每一個局部檢查都會通過。

### 怎麼實作

要求代理在產出旁邊附一份分流表：

```json
{
  "routing_table": [
    { "item": "p.58 / 說話者 X", "categories": ["1", "2", "4"] },
    { "item": "p.61 / 說話者 Y", "categories": ["5"] }
  ]
}
```

然後在交付前，對**每一組** `(項目, 類型)`，用項目身分去產出陣列裡找對應項。找不到的
就是漏了。

**整批做完，再核對，再交付。** 邊做邊逐項核對，你就沒有辦法發現總數對不上。

**數量對不上是偵測這一類錯誤唯一的機械方法。** 如果你的流水線不會產生任何一個「可能
對不上」的數字，那它根本無法偵測這一類錯誤。

## 第三部分：刻意丟棄清單

要求每一個萃取代理回報它**丟掉了什麼**、為什麼、在哪裡。

```json
"deliberately_discarded": [
  { "content": "<簡短摘要>", "location": "<在哪裡>", "reason": "<為什麼>" }
]
```

在你看清它的用途之前，這都像多餘的工。**它是唯一能讓審查者抓到判斷錯誤的地方。**

如果代理把有價值的東西誤判成雜訊，產出看起來會完美無缺——那條原則就只是不在那裡，
而且沒有任何跡象顯示它曾經在。**丟棄清單是那個決定唯一留下的紀錄。**

兩條讓它真的有用的規則：

- **空的丟棄清單是紅旗，不是乾淨。** 一批什麼都沒丟的資料，幾乎可以確定是代理根本
  沒考慮過要丟。
- **丟棄要帶位置。** 「一些自傳性的內容」不可審查；「p.61，說話者 Y，經歷」才可以。

## 第四部分：建新筆之前先做項目解析

一份策展資料集裂成兩半最常見的方式：**同一個真實事物用兩個名字進來。**

- **建新筆之前，走完整條別名鏈**：本名、別名，以及——如果你的模型允許——指向其他
  項目型別的別名，不是只有主表那一種。
- **主要名稱查不到時，換次要軸再查一次。** 翻譯名、學名、正式識別碼。一個以不同顯示名
  存在、但底層鍵相同的既有紀錄一定會撞——**有時吵鬧地撞在 UNIQUE 上，有時安靜地撞在
  JOIN 裡**。見 `silent-failure-hunting` 第 1 條。
- **解析不到的名字絕不自動建檔。** 寫進 `data/staging/unresolved.json` 讓人決定。
  這條規則的來源是：一支「查無就自動建新筆」的解析器造出了八組跨表重複，全是很普通的
  東西，事後每一組都要人工查證合併。
- **對話中的臨時匯入也要走同一支解析器。** 那個沒有留腳本、在對話中臨時下的匯入，
  **正是會造出重複的那一個**。

## 相關

- `curation-pipeline`——這套紀律運作所在的 staging 閘門
- `silent-failure-hunting`——為什麼這裡的檢查全都繞著數量與座標打轉

