一個知識點、兩個來源:避免章節與課堂逐字稿產生重複卡片

教科書說,外力矩為零時,角動量守恆。稍後的一堂課用不同說法講了同一件事。如果每次上傳都產生一張全新的記憶卡片,學習者就會得到兩個幾乎相同的問題、兩套複習紀錄,卻無法清楚看出同一項主張其實有兩個來源。

一個看似直覺的修補方式,是拿新卡片與本次上傳所產生的卡片比較。但只要下一章或下一份逐字稿進來,這個辦法就失效了。更持久的做法,是不再把最後呈現的卡片當成唯一的標準記錄。

保留一項已接受的事實,把所有支持它的來源片段都連到這項事實,再從這項事實渲染或更新學習卡片,而不改變它的身分。

卡片、事實與來源是三種不同的東西

Anki 本來就把筆記與由範本產生的卡片分開。不過,它一般的重複提示主要是在同一筆記類型內比較第一個欄位。這在編輯時很有用,卻不是一套涵蓋整個集合的語義識別模型。設計匯入器之前,值得先閱讀 Anki 手冊的核心概念重複檢查

若要跨來源產生內容,可以使用三個層次:

  1. 事實: 已接受的主張,或一個問答單元。
  2. 證據片段: 來源中的精確位置,例如教科書第 74 頁或逐字稿時間戳 00:31:20。
  3. 渲染後的筆記: 匯出至 Anki 或其他複習系統、供學習者閱讀的欄位。

一項事實可以有多個證據片段,也可以用不只一種範本渲染。這兩種情況都不需要再建立第二項核心事實。

textbook p.74 ─┐
               ├─> accepted fact ─> stable Anki note ─> review history
lecture 31:20 ─┘

這種分離也讓刪除變得安全。移除一份已上傳的逐字稿時,應該只解除它的證據關係,而不應暗中刪除那項事實、教科書引文或學習者的排程紀錄。

保留原文,只為比對進行正規化

不要為了方便去重而改寫顯示在卡片正面和背面的文字。應保留原文,並在旁邊建立一份有版本的比對投影。

第一版保守的投影可以:

  • 套用 Unicode NFC 正規化;
  • 在語言允許時進行大小寫摺疊;
  • 合併重複的空白字元;
  • 保留否定詞、數字、單位、公式與數學符號。

不要不加判斷地移除標點、停用詞或重音符號。「增加」與「不增加」使用的詞彙很接近,意思卻正好相反。同樣地,5 mg50 mg 絕不能變成同一個鍵。Unicode 的正規化形式規範說明了為什麼 NFKC 等相容性正規化可能抹除某些應用需要保留的差異。

為投影取一個像 match_text_v1 這樣的名稱。日後即使規則改變,因為每項判定都帶有比對器版本,舊決策仍可重現。

若要建立精確簽章,可以對範圍與兩個正規化欄位一起做雜湊:

SHA-256(owner_id || language || note_type || normalized_front || normalized_back)

範圍很重要。不要跨帳號比較私人筆記。在許多系統裡,語言、筆記類型、課程,以及使用者自行選擇的集合邊界,也應成為鍵的一部分。

用模糊搜尋找候選項,而不是授權合併

精確簽章能抓到重試與只有輕微格式差異的重複內容,卻抓不到以下這類改寫:

  • 「角動量在什麼情況下守恆?」
  • 「請說明角動量守恆的條件。」

只有在精確比對未命中後,才進入第二階段:

new draft
  -> exact signature
       -> hit: attach the new evidence span
       -> miss: retrieve a small candidate set
                  -> score exact resemblance and containment
                  -> duplicate / review / distinct

對小型本機集合,可以使用 SQLite FTS5 文件中的 trigram tokenizer 篩選候選項。PostgreSQL 使用者可以用 `pg_trgm` 建立相同類型的候選清單。規模更大時,固定種子的 MinHash 或局部敏感雜湊可以縮小搜尋空間;Broder 關於文件相似度與包含度的研究提供了底層的集合模型。

候選清單本身不是決策。應為每個候選項重新計算決定性分數,保留各個組成分數,並把模稜兩可的情況送去審核。在有標註的測試集證明可以安全自動化之前,模糊比對絕不應自動合併兩項事實。漏掉一項重複內容固然麻煩,但錯誤合併可能破壞兩張卡片的意思。

以交易方式處理精確鍵衝突

一個最小的 SQLite 資料結構就能清楚保留這些邊界:

CREATE TABLE facts (
  fact_id     TEXT PRIMARY KEY,
  owner_id    TEXT NOT NULL,
  language    TEXT NOT NULL,
  note_type   TEXT NOT NULL,
  exact_key   TEXT NOT NULL,
  front       TEXT NOT NULL,
  back        TEXT NOT NULL,
  status      TEXT NOT NULL DEFAULT 'accepted',
  UNIQUE (owner_id, language, note_type, exact_key)
);

CREATE TABLE evidence_spans (
  span_id       TEXT PRIMARY KEY,
  document_id   TEXT NOT NULL,
  locator       TEXT NOT NULL,
  source_hash   TEXT NOT NULL,
  excerpt       TEXT NOT NULL,
  UNIQUE (document_id, locator, source_hash)
);

CREATE TABLE fact_evidence (
  fact_id  TEXT NOT NULL REFERENCES facts(fact_id),
  span_id  TEXT NOT NULL REFERENCES evidence_spans(span_id),
  PRIMARY KEY (fact_id, span_id)
);

唯一約束可以防止兩次同時進行的重試建立兩筆核心事實。在同一個交易中:

  1. 插入擬議的事實;
  2. 如果精確鍵已存在,選取既有的 fact_id
  3. 插入新的證據片段;
  4. 使用 INSERT ... ON CONFLICT DO NOTHING 建立關聯;
  5. 一起提交這兩項決策。

SQLite 直接提供了唯一約束交易UPSERT 的文件。應使用這些保證,而不是在應用程式碼裡依賴脆弱的「先搜尋,再插入」流程。

讓複習紀錄始終連在同一項事實上

如果每次匯出仍會建立新的 Anki 筆記,去重就還沒有完成。應為每項已接受的事實保留穩定的外部身分,並更新既有筆記的欄位或證據清單。

Anki 的文字匯入文件說明了如何透過第一個欄位或 GUID 處理重複內容,也解釋了更新既有筆記如何保留排程。請測試實際使用的匯入路徑;不要因為顯示出的問題看起來相似,就假設重新產生的套件會保留身分。

如果一項事實增加了第二個來源,學習者看到的內容可能會從:

Source: Chapter 4, p.74

變成:

Sources: Chapter 4, p.74 · Lecture 6, 00:31:20

筆記身分與複習紀錄保持不變。

測試那些相似度分數會掩蓋的情況

在調整門檻以前,先為無法接受的失敗建立測試資料:

  1. 同一次上傳被同時重試時,只產生一項事實與一條證據關係。
  2. 章節與逐字稿的文字完全相同時,產生一項事實與兩個定位資訊。
  3. 大小寫、空白與 Unicode 規範等價的變體能精確匹配。
  4. 改寫內容成為待審核候選項,而不是自動合併。
  5. 否定、不同數量、單位、群體或條件維持彼此獨立。
  6. 互相衝突的答案會建立一項待審核衝突;任何一個答案都不會覆寫另一個。
  7. 教科書改版時,保留新版的定位資訊與來源雜湊。
  8. 不同語言的陳述保持獨立,除非有經過審核的等價關係把它們連起來。
  9. 刪除一個來源後,另一份證據與複習紀錄仍然存在。
  10. 重新執行相同的測試資料時,得到相同的鍵、候選順序、分數與決策。

候選項召回率應與最終合併精確率分開衡量。候選項產生器可以刻意寬鬆,因為最後的決策階段仍然保守。

已在公開專案中派上用場的模式

Video2Book 保留帶時間戳的逐字稿結構與來源路徑。Susskind archive 展示了字幕、逐字稿與生成筆記之間穩定的課程、執行批次和講次階層。Local Knowledge Terminal 把卡片視為已接受實體的呈現方式,並讓證據保留來源識別碼、雜湊與定位資訊。

這些儲存庫都還不是完成的跨上傳記憶卡片去重器。不過,它們合在一起示範了有用的邊界:來源保有自己的身分,已接受的知識有另一個獨立身分,而卡片只是可以替換的呈現方式,不是資料庫本身。

如果你正在判斷一套混合書籍與逐字稿的資料是否適合採用這種設計,LKT 範例報告列出了我會在動工前檢查的來源、隱私、引用與可行性問題。

Leave a Reply