整理 Obsidian 筆記庫時,需要把 60 個新匯入的筆記跟現有 4,836 個檔案做相似度比對——找出內容相近(90% 以上)但可能檔名不同的筆記。
Claude 暴力做法:60 × 4,836 = 290,160 次 SequenceMatcher,每次都是 O(n²) 字元運算。實際跑起來風扇狂轉,等超過 30 分鐘還沒結果。
最後用三層過濾架構,把實際執行的 SequenceMatcher 降到 103 次,5 秒跑完。效能提升 99.96%。
為什麼不用排序演算法?
找完全相同的文件,按檔案大小排序就夠了——大小相同的相鄰比對,O(n log n) 搞定,連 hash 都不需要。
但這次要找的是內容相近,不是完全相同。相近文件的特徵:
- 不同檔名,但內容幾乎一樣
- 加了 YAML frontmatter 或標題略有改動
- 排版差異、空白行不同
這類情況兩個檔案大小可能差了幾十 bytes,排序演算法和 hash 都抓不到,卻有 95% 的內容相同。這才是需要特別處理的模糊地帶。
SPV Pipeline:三層過濾架構
命名為 SPV Pipeline,三個字母各對應一層:
- S:Size Filter(大小過濾)
- P:Position-sampled Fingerprint(固定位置指紋)
- V:Verification(精確驗證,SequenceMatcher)
資料流向:
所有候選對 → [Layer 1: 大小過濾] → [Layer 2: 指紋過濾] → [Layer 3: SequenceMatcher]
290,160 ↓ 跳過 272,955 ↓ 跳過 17,042 ↓ 實際執行 103 次
每一層都是前一層的前置條件,候選集單調收縮:290,160 → 17,205 → 103 → 39 對相似文件。
Layer 1:大小過濾
內容相似的文件,清洗後的字元數應在彼此 ±20% 以內。一篇 100 字、另一篇 500 字,相似度不可能達到 90%。
規則很直白:若兩篇文件大小差異超過 20%,直接跳過,不做任何比對。
這一層直接跳過了 272,955 對,跳過率 94%,幾乎零計算成本。
Layer 2:固定位置指紋
對清洗後的文字,每隔 10 個字元取 1 個字元,組成固定位置指紋:
fingerprint = cleaned_text[::10] # 每 10 個字取 1 個,位置固定
兩份指紋做逐位比對,若匹配率低於 80% 則跳過,不進入 SequenceMatcher。
為什麼固定位置而非隨機取樣? 隨機每次結果不同,無法預先建 index。固定步長確保同一文件永遠產生相同指紋,可快取、可 hash,適合大規模場景。
這層又跳過 17,042 對,通過 Layer 1 的部分跳過率 99%。
Layer 3:SequenceMatcher 精確比對
Python 標準庫的 SequenceMatcher 找出兩段文字所有最長公共子序列區塊,計算相似度:
- 90% 意味兩篇文章有 9 成的字元序列互相對應
前處理先去掉 frontmatter 和多餘空白,避免排版差異影響判斷:
def clean(text):
lines = text.splitlines()
if lines and lines[0].strip() == "---":
try:
end = lines.index("---", 1)
lines = lines[end+1:]
except ValueError:
pass
return " ".join(" ".join(lines).split()).lower()
實際執行:103 次(從 290,160 降至 103)。
實驗結果
- 原始候選對:290,160
- Layer 1 大小過濾後:17,205(跳過 272,955)
- Layer 2 指紋過濾後:103(跳過 17,102)
- Layer 3 最終相似對:39 pairs
39 個新檔被標記為相似(不同檔名、相同內容),全數移除。
閾值是可以調整的
90% 不是固定值,依需求調整:
- 90%:抓出「幾乎一樣」的文件(本次使用值)
- 70%:抓出「同一主題不同版本」
- 50%:抓出「內容大量重疊」的文件
閾值越低找到的對越多,但誤判率也越高。
實作重點
# 一次性把所有文件讀入記憶體
vault = [(path, len(cleaned), cleaned, fingerprint(cleaned)) for ...]
for new_file in new_files:
for v_path, v_sz, v_clean, v_fp in vault:
# Layer 1
if size_diff > 0.20: continue
# Layer 2
if fp_similarity(fp_new, v_fp) < 0.80: continue
# Layer 3
s = SequenceMatcher(None, c_new, v_clean).ratio()
if s >= 0.90: report_similar()
記憶體策略:全部讀入、一次掃描。現代機器記憶體充裕,避免反覆 I/O 是正確取捨。
核心原則
最快的計算,是根本不做那次計算。
越早、越便宜的過濾放越前面;SequenceMatcher 是最貴的,放最後、用最少。排序演算法處理完全相同;SPV Pipeline 處理內容相近——兩者解決不同層級的問題。
SPV Pipeline 與學術界的 Filter-and-Verify 正規化一致,相關研究可參考 Broder (2000) 的 shingling 指紋演算法奠基之作,以及 Google Research 的 Web 規模實際系統(Manku et al., 2007)。
本文原載於 blog.stanwu.org,歡迎至原文閱讀完整版本與後續更新:
https://blog.stanwu.org/posts/spv-pipeline-near-duplicate-detection/





















