iT邦幫忙

2026 iThome 鐵人賽

DAY 7
0
AI Engineering

從 LLM 到 Harness: 打造隱私與可信任的繁中進階 OCR Agent系列 第 7

Day 7 - 繁簡混排與異體字:正規化前先搞懂差異

  • 分享至 

  • xImage
  •  

昨天結尾我留了一個問題:錯誤分類表裡,簡體字、異體字、形近字三個類別的計數全是 0

而我明明在第 24 頁親眼看到「綠」被讀成「線」

今天這篇要講一個「沒炸」的實驗,以及為什麼我還是決定把這層先做掉。


先把那個 0 攤開

把九頁的錯誤分類表拉出來,只看前三欄:

頁面 來源 simplified_char variant_char visual_confusion other diff 塊數
page_01_pure_text Day 2 0 0 0 1 3
page_09_mixed Day 2 0 0 0 16 34
page_11_table Day 2 0 0 0 2 7
page_12_table Day 2 0 0 0 3 9
page_14_mixed Day 2 0 0 0 0 7
page_02_dense_table Day 6 0 0 0 2 4
page_05_mixed_cjk_ascii Day 6 0 0 0 4 8
page_19_footnote Day 6 0 0 0 4 6
page_24_rare_char Day 6 0 0 0 5 9
合計 0 0 0 37

(實測,出處 step4_zh_error_taxonomy.jsonstep4_zh_error_summary.md。)

九頁、87 個 diff 塊,簡體字 0 次、異體字 0 次、形近字 0 次

我原本預期這三欄會是這篇文章的主菜。結果它們是三個 0

這種時候你有兩條路可以走。

第一條:把標題改成「繁簡混排其實沒那麼可怕」,貼這張表,收工。看起來很有反差、很有觀點

第二條:先問「這個 0 到底在說什麼」

我走第二條。因為我很清楚昨天親眼看到「綠→線」,那明明就是形近字

所以問題不是「沒有這類錯誤」,問題是「為什麼我的分類器沒認出來


那個 0 的兩個原因

原因一:對照表是手工小表,不完備

分類腳本裡的三張表,長這樣:

規模 怎麼來的
簡繁對照 SIMP_TO_TRAD 約 90 對 手工列舉常見簡體字
異體字 VARIANT_PAIRS 14 對 手工列舉(台/臺、裡/裏、着/著、爲/為、羣/群……)
形近字 VISUAL_CONFUSION_GROUPS 19 組 手工列舉(己已巳、末未、土士、日曰、戊戍戌戉……)

19 組形近字裡,沒有「綠/線」這一組

所以昨天那個 [replace] gt='綠' hyp='線' 走到分類器的時候,三個規則全部不匹配,最後落進 other

同理,「阻燃→防燃」「載具→製具」「補強→補償」也全部落進 otheromission

37 個 other 裡面,有多少其實是形近字誤判?我不知道。

我只知道至少有一個(綠/線),而且用肉眼掃過一遍 diff,我估計還有三到四個。但「用肉眼估計」不是數據,所以我不寫成表

💡Tip: 規則式分類器的統計數字有個內建陷阱:它的「0」永遠是雙義的——可能是真的沒有,也可能是規則沒涵蓋到。所以任何規則式分類的產出,都必須有一個 other 的兜底桶,而且 other 的比例要跟主類別一起看。37/87 ≈ 43% 落進 other,這個比例本身就在對我大喊「你的規則不夠用」。

原因二:這份 PDF 本身就是乾淨的繁中

第二個原因更根本:簡體字那欄的 0,很可能是真的 0

這是一份台灣上市公司(永光化學,1717)給台灣投資人看的法說會簡報。它由台灣的簡報團隊製作、用台灣的字型排版、在台灣的公開資訊觀測站發布

它沒有理由出現簡體字。

異體字也類似。這種文件會統一用一套用字慣例,不太會在同一份檔案裡混用「台」和「臺」

所以這兩欄的 0,我的判斷是:

  • simplified_char 的 0:大機率是真的——這份文件裡本來就沒有簡體字
  • variant_char 的 0:可能是真的,也可能是表太小——14 對實在太少
  • visual_confusion 的 0:確定是假的——綠/線就在那裡,只是表沒收錄

還有第三個技術性原因,一併講清楚。simplified_char 的判定規則要求「該 replace 塊的 GT 與模型輸出等長,且逐字有簡繁對應」。如果模型把一個簡體字混在一段較長的替換段落裡(長度不相等),這條規則直接不成立,那塊會落到別的類別去

所以就算文件裡真的有簡體字誤判,我的規則也只抓得到最乾淨的那種形式。


那為什麼還是要做這層?

一個實測 0 次的問題,值得花一整天講嗎?

值得。而且理由跟「模型準不準」沒關係

理由一:這份文件乾淨,不代表下一份乾淨

Day 6 我已經講過一次適用範圍的問題:這份法說會簡報的 dense_table 最高分只有 0.1429,根本不是真正的密集表格

繁簡混排也一樣。我的目標文件不只有法說會簡報。

年報的「重要子公司資料」「轉投資事業」章節會列大量中國子公司;重大契約摘要的交易對象可能是中國廠商;技術授權合約的原文可能就是簡體版;供應鏈揭露會出現簡體的地名與廠區名

這些文件一旦進來,繁簡混排不是可能發生,是一定發生

具體一點,我預期會在這幾個地方撞上:

文件位置 會出現什麼 為什麼難
年報「重要子公司資料」 中國子公司的登記全名 官方登記是簡體,中文版年報可能繁簡並列
年報「主要股東」「關係人交易」 境外法人名稱 同一家公司在不同章節可能一繁一簡
重大契約摘要「交易對象」 對方公司全名與地址 地名簡繁混用(廣州/广州)
技術授權/採購合約附件 合約原文摘錄 原文可能整段是簡體
附註的法規引用 大陸法規名稱與字號 法規名不能改字,一改就對不上

最後一列是最硬的約束:法規名稱與公告字號是識別碼,不是文字。你把裡面任何一個字正規化掉,它就不再指向原本那份法規了

而我如果等到那時候才發現問題,我的整條 pipeline 已經蓋好了——在既有架構上補正規化層,比一開始就留好位置貴太多

理由二:真正會炸的不是 OCR,是下游比對

這才是關鍵論證

繁簡與異體字對「辨識」這件事的傷害其實有限——模型多半認得出「臺」和「台」都是那個字

但我的系統後面有兩個地方是拿字串直接比對的

第一個是 Day 25 的 diff-driven flywheel。 整套飛輪的核心是「把模型輸出跟參考答案做字元級 diff,把 diff 變成校準燃料」。如果模型輸出「臺灣」而參考答案是「台灣」,diff 會報一個錯

那是錯誤嗎?不是。但飛輪不知道。

它會把這筆假陽性收進錯誤池,然後我會拿一個根本不存在的問題去校準系統。更糟的是,這種假陽性會穩定地大量出現——只要兩邊用字慣例不同,每一頁都會中

第二個是 Day 17 的 MOPS XBRL 校驗。 MOPS 提供的 XBRL 結構化財報是官方版本,我要拿它當 ground truth 去驗 OCR 出來的公司名、科目名、金額

XBRL 裡的公司全名是法定登記名稱。而法定登記名稱裡的「臺」字,在台灣的公司登記實務上是真的會出現的——「臺灣」「臺北」在正式文件裡用「臺」的比例不低

如果 PDF 封面印的是「台灣永光化學」而 XBRL 登記的是「臺灣永光化學」,我的一致性檢查會判定不符

一個純粹的用字慣例差異,會觸發一次「公司名不一致」的告警。

Day 16 講關鍵欄位語意驗證的時候,這種假陽性是最要命的——因為告警一旦太吵,人就會開始忽略告警,而忽略告警的系統等於沒有告警

💡Tip: 這是我想在 Phase 2 傳達的最重要的一個觀念:正規化不是為了讓 OCR 更準,是為了讓「比對」這件事有意義。任何要做字串比對的系統,都必須先定義「什麼叫相同」。而中文的「相同」比英文複雜得多——英文你做個 lower() 大概就解決八成了。


第一個誤解:以為 Unicode 正規化能處理繁簡

好,那就正規化嘛。Python 標準庫就有 unicodedata.normalize,NFC、NFD、NFKC、NFKD 四種模式,一行就搞定

不行。而且完全不行。

先講 Unicode 正規化到底在做什麼。它處理的是兩件事:

  1. 組合序列與預組字的等價——e + 組合用尖音符(U+0301)應該等於 é(U+00E9)。這是 NFC/NFD 在管的
  2. 相容字元與標準字元的等價——全形的 (U+FF11)應該等於半形的 1(U+0031);連字 應該等於 fi。這是 NFKC/NFKD 多做的那層「K」(compatibility)

注意這兩件事的共同點:它們處理的都是「同一個字的不同編碼方式」

而繁簡字與異體字不是同一個字的不同編碼方式,它們是不同碼位的不同字

「臺」是 U+81FA,「台」是 U+53F0。這是兩個獨立的漢字,各自有自己的部首、筆畫數、歷史。Unicode 從來沒有宣稱它們等價——因為在字義層面它們本來就不完全等價(「台」還有「台州」「兄台」這些跟「臺」無關的用法)

不信?我直接跑給你看:

import unicodedata

PAIRS = [
    ("臺", "台", "異體字"),
    ("裏", "裡", "異體字"),
    ("爲", "為", "異體字"),
    ("國", "国", "繁 / 簡"),
    ("後", "后", "一簡對多繁"),
    ("㇐", "一", "CJK 筆畫 vs 漢字"),
    ("1", "1",  "全形 / 半形數字"),
    ("﹪", "%",  "小符號變體"),
]

for a, b, why in PAIRS:
    nfc  = unicodedata.normalize("NFC",  a) == unicodedata.normalize("NFC",  b)
    nfkc = unicodedata.normalize("NFKC", a) == unicodedata.normalize("NFKC", b)
    print(f"{a} (U+{ord(a):04X}) vs {b} (U+{ord(b):04X})"
          f"  NFC相等={nfc}  NFKC相等={nfkc}   # {why}")

輸出:

臺 (U+81FA) vs 台 (U+53F0)  NFC相等=False  NFKC相等=False   # 異體字
裏 (U+88CF) vs 裡 (U+88E1)  NFC相等=False  NFKC相等=False   # 異體字
爲 (U+7232) vs 為 (U+70BA)  NFC相等=False  NFKC相等=False   # 異體字
國 (U+570B) vs 国 (U+56FD)  NFC相等=False  NFKC相等=False   # 繁 / 簡
後 (U+5F8C) vs 后 (U+540E)  NFC相等=False  NFKC相等=False   # 一簡對多繁
㇐ (U+31D0) vs 一 (U+4E00)  NFC相等=False  NFKC相等=False   # CJK 筆畫 vs 漢字
1 (U+FF11) vs 1 (U+0031)  NFC相等=False  NFKC相等=True   # 全形 / 半形數字
﹪ (U+FE6A) vs % (U+0025)  NFC相等=False  NFKC相等=True   # 小符號變體

前六組,NFC 跟 NFKC 全部束手無策。

只有最後兩組——全形數字、符號變體——NFKC 搞定了

那第六組是什麼?(U+31D0)是 Unicode 的「CJK 筆畫」區塊裡的橫畫,(U+4E00)是漢字「一」。它們看起來一模一樣,但是完全不同的東西

而這個案例是真的在我的實測資料裡出現過的——Day 2 的 page_01_pure_text,代表案例表裡那筆 other 就是 GT='㇐'模型輸出='一'

模型是對的。是 PDF 文字層用了筆畫碼位。 而 NFKC 救不了這個

NFC 唯一會動到的漢字

講句公道話,Unicode 正規化在漢字上不是完全沒作用。有一個區塊它會處理:CJK 相容漢字區(U+F900–U+FAFF)

這個區塊是為了跟早期的韓國、日本、台灣字集往返轉換而保留的重複碼位,裡面的字大多有標準區的對應字。NFC 會把它們折回去:

import unicodedata
for cp in (0xF900, 0xF95E, 0xFA10):
    ch = chr(cp)
    nfc = unicodedata.normalize("NFC", ch)
    print(f"U+{cp:04X} {ch} -> NFC -> U+{ord(nfc):04X} {nfc}  相等={ch==nfc}")

輸出:

U+F900 豈 -> NFC -> U+8C48 豈  相等=False
U+F95E 丹 -> NFC -> U+4E39 丹  相等=False
U+FA10 塚 -> NFC -> U+585A 塚  相等=False

三個字在螢幕上長得一模一樣,碼位不同,NFC 把它們折到了標準區

所以 NFC 該做,但不要期待它做繁簡。 它處理的是編碼層的重複,不是字形層的變體

💡Tip: 我建議 pipeline 的第一步無腦做一次 NFC,成本幾乎是零,而且能擋掉相容漢字區與組合序列這兩種很陰的問題。但千萬不要用 NFKC——NFKC 會把全形標點也一起折掉,而中文的全形冒號、全形括號在版面結構判讀上是有意義的資訊(例如「註1:」的冒號型態可以用來判斷這是不是註腳),折掉就沒了。要折全形英數,自己寫一個只處理 ASCII 範圍的映射,別動標點。


第二個誤解:以為繁簡是一對一映射

假設你決定不靠 Unicode,改用一張簡繁對照表。字典查一下,一行 str.translate 換掉

問題來了:往哪個方向換?

一簡對多繁:資訊在簡化時就被刪掉了

簡體字的設計本來就包含合併——好幾個不同的繁體字被合併成同一個簡體字。這個過程是不可逆的資訊壓縮

ONE_SIMP_MANY_TRAD = {
    "干": ["干", "乾", "幹"], "面": ["面", "麵"], "后": ["后", "後"],
    "里": ["里", "裡", "裏"], "只": ["只", "隻"], "发": ["發", "髮"],
}
for simp, trads in ONE_SIMP_MANY_TRAD.items():
    print(f"{simp} -> {'/'.join(trads)}  ({len(trads)} 種可能,無上下文無法決定)")

輸出:

干 -> 干/乾/幹  (3 種可能,無上下文無法決定)
面 -> 面/麵  (2 種可能,無上下文無法決定)
后 -> 后/後  (2 種可能,無上下文無法決定)
里 -> 里/裡/裏  (3 種可能,無上下文無法決定)
只 -> 只/隻  (2 種可能,無上下文無法決定)
发 -> 發/髮  (2 種可能,無上下文無法決定)

看「干」那組。簡體的「干」對應到繁體可能是:

  • (干擾、干涉、天干)
  • (乾燥、乾淨、餅乾)
  • (幹部、主幹、能幹)

在財報裡這三個都會出現。「干擾」是技術章節、「乾燥」是製程描述、「幹部」是人事章節

一張純字元對照表,在這裡必然出錯。 它只能選一個,而選任何一個都會在另外兩個場景錯

再看「后」。「后」在繁體裡是皇后的后,「後」是前後的後。簡體全部寫成「后」

而財報裡「後」的出現頻率極高——「期後事項」「後續處理」「事後審查」。如果你用一張表把簡體「后」無條件轉成繁體「後」,那「董事長夫人王后女士」也會被轉錯

所以正確的做法是什麼?

方向一:不轉換,只做「等價比對」。

這是我在這個專案裡選的路。我不試圖把文字改成某個標準寫法,我只在比對的時候把雙方都折到一個共同的比較鍵(comparison key)上

差別在哪?

  • 轉換會改變資料本身。萬一轉錯了,原始資訊就沒了,而且錯誤會一路傳到下游
  • 折成比較鍵只影響「相等判定」,原始字串完整保留。萬一折錯了,最壞的結果是一次誤判相等,資料本身不受污染

對一個要求可追溯的系統來說,這個差別很關鍵。Day 29 講 Router 的可追溯性時是同一個原則:任何不可逆的變換,都要在流程越後面越好。

方向二:如果真的必須轉換,要有上下文。

也就是說你需要的不是字元對照表,是詞彙層的轉換,而且要有詞頻或語言模型輔助決定歧義。這類工具是存在的(OpenCC 是最常被提到的一個,它有詞彙層的轉換配置),但那已經超出「正規化」的範疇,變成一個獨立的 NLP 任務

我在這個專案裡沒有安裝、也沒有實測過 OpenCC。 我把它列為可選方案,等到真的遇到大量簡體文件時再評估——而評估的時候還要過 Day 12 的供應鏈審查這一關,不會因為它好用就直接裝


異體字:比繁簡更安靜的坑

繁簡至少很顯眼——你看到「国」就知道那是簡體

異體字不顯眼。「臺灣」跟「台灣」放在兩份文件裡,你翻閱的時候根本不會覺得有問題

異體字對 常見場景 為什麼會炸
台 / 臺 公司名、地址、地名 公司登記名常用「臺」,簡報封面常用「台」
裡 / 裏 敘述文字 兩種寫法都合法,同一份文件可能混用
著 / 着 敘述文字 「著」在台灣用法較廣,港式文本用「着」
為 / 爲 敘述、法條引用 「爲」多見於舊式排版與部分法規原文
線 / 綫 產品名、規格 兩者在不同字集裡都是正字
群 / 羣 人名、公司名 人名用字最不能亂改

注意最後一列。人名用字是絕對不能正規化的。

如果某位董事的登記姓名就是「羣」,你把它折成「群」,那在法律文件上你就改了一個人的名字。所以任何正規化策略都必須有欄位級的例外清單——人名欄、公司登記名欄、證號欄不折,只在做「等價比對」的時候用折過的鍵當輔助判斷,而輸出永遠是原始字串


我的做法:比較鍵,不是轉換

把上面的討論收成一個可以直接跑的小函式:

import unicodedata

# 只收「同義且方向明確」的異體字對,折向專案統一的比較形。
# 刻意不收有歧義的繁簡對(干/乾/幹、后/後、里/裡/裏 等),那些留給人工或詞彙層處理。
VARIANT_FOLD = {"臺": "台", "裏": "裡", "爲": "為", "着": "著", "綫": "線"}

def fold(s: str) -> str:
    """回傳比較鍵:先做 NFC(處理相容漢字區),再折已知異體字。
    原始字串不變,這個回傳值只用於相等判定,不寫回資料。"""
    s = unicodedata.normalize("NFC", s)
    return "".join(VARIANT_FOLD.get(c, c) for c in s)

A, B = "臺灣永光化學股份有限公司", "台灣永光化學股份有限公司"
print("原始相等:", A == B)
print("fold 後相等:", fold(A) == fold(B))

輸出:

原始相等: False
fold 後相等: True

程式碼本身很簡單,真正的設計決定有三個:

第一,函式名叫 fold,不叫 normalizeconvert 命名是契約。normalize 會讓下一個人(包括三個月後的我)以為這是一個可以安心套用在資料上的清洗函式。fold 明確說了:這是給比對用的折疊,不是資料變換

第二,VARIANT_FOLD 刻意不收有歧義的繁簡對。 表裡只有五對,全部是「兩個寫法在現代繁中語境下同義」的情況。干/乾/幹那種一簡對多繁的,我一個都沒放——因為放了就會錯,而錯在一個叫 fold 的函式裡,是最難被發現的

第三,回傳值不寫回資料。 這句話我寫在 docstring 裡,而且我打算在 code review 時把它當硬規則。原始 OCR 輸出、原始 GT,兩邊都原樣落檔,fold 只在計算相等時被呼叫

💡Tip: 折疊表要小、要保守、要有 owner。我看過太多專案的「中文清洗工具函式」長成一個三百行的怪物,裡面混雜了繁簡轉換、全形半形、標點統一、空白處理、錯字修正,然後沒有人敢動它,也沒有人說得出它到底會不會改壞資料。寧可有五個各自只做一件事的小函式,也不要一個什麼都做的 clean_text()


另一種混排:全形、半形與中英之間的那個空白

講完漢字,還有一組會踩到同一套地雷的東西:標點與數字的形態

這組問題在 Day 6 的 page_05 已經露臉過了——NT$ 的位置、5% 的百分號、2025年12月31日 的日期格式。它們不是漢字,但它們跟漢字一樣有「多種寫法都合法」的性質:

情況 寫法 A 寫法 B 該不該折
數字 407(半形) 407(全形) 該折,折成半形
百分號 5% 5% 該折,折成半形
逗號 1,234(半形,千分位) 1,234(全形,是標點不是千分位) 不能無腦折,語意不同
冒號 註1: 註1: 不該折,全形冒號帶版面資訊
括號 (聚酯樹脂事業部) (聚酯樹脂事業部) 看場景,通常保留
破折號 —— - 不該折,模型輸出的 —— 是它自己加的排版符號

第三列是最陰的。半形逗號在數字中間是千分位分隔符,全形逗號是中文標點。如果你寫了一個「全形折半形」的通用函式,把 1,234 折成 1,234,那你就把一個標點變成了一個數字分隔符——金額會從「一和二三四」變成「一千二百三十四」

這不是理論。中文排版裡數字前後接全形標點是常態,而 OCR 在全形/半形逗號上的判別本來就不穩

所以我的處理原則跟漢字那層一致:

  • 數字與 ASCII 英數的全形/半形,折——這層沒有歧義,而且 Day 6 的 mixed_cjk_ascii 分數告訴我這種交界處很多
  • 標點,不折——只在比對時忽略,不改資料
  • 金額欄位,單獨處理——不走通用折疊,走一個專門的金額解析器,明確處理千分位、貨幣符號、單位(千元/百萬元)、正負號與括號負數

最後那條特別重要。財報裡的負數常寫成 (1,234) 而不是 -1,234,而括號在 OCR 裡又很容易跟中文的全形括號混淆。這個坑我留到 Day 16 講欄位一致性檢查時再攤開,今天先標記它存在

💡Tip: 「寫一個通用的中文文字清洗函式」是個聽起來很合理、實際上很危險的需求。危險在於不同欄位需要的清洗強度完全不同:敘述文字可以折得很兇,金額欄位幾乎不能折,人名與登記名一個字都不能動。所以正確的設計不是一個函式,是一組按欄位型別分派的折疊策略——這也是為什麼 Day 22 的 Checksum 驗證要建立在 Pydantic model 上,因為型別就是分派的依據。


這層該放在 pipeline 的哪個位置

最後一個設計問題:正規化這層要插在哪裡?

有三個候選位置,我的選擇跟直覺相反:

位置 做法 我的判斷
進模型之前 把 prompt 或圖片先正規化 不可行——輸入是圖片,沒有字串可正規化
模型輸出之後、落檔之前 把模型輸出洗乾淨再存 不採用——這是不可逆變換,原始輸出就沒了
落檔之後、比對之時 原始輸出原樣落檔,比對時雙方各自 fold 採用

為什麼堅持原始輸出原樣落檔?

因為 Day 6 已經給過答案了。page_02 那段化學元素底紋、page_19 那段重複上百次的「碳纖維/鋁複合材料」——這些東西如果在落檔前就被某個清洗函式處理掉,我今天根本看不到 finish_reason=length 的證據,也寫不出那節分析

原始輸出是證據,不是中間結果。

而且這條規則有一個很實際的好處:分析邏輯可以無限次重跑。Day 6 講重現方式時提過,我的第二支腳本完全不重新呼叫模型,只讀已落檔的輸出算 diff。如果輸出在落檔前就被洗過,那我每次改折疊規則都得重跑一次模型——不但貴,而且模型輸出的非決定性會讓前後兩批數據不可比

所以折疊發生在比對函式內部,而且是雙向的:GT 折一次、模型輸出折一次,比的是兩個鍵,存的是兩份原文

def equal_after_fold(gt: str, hyp: str) -> bool:
    """比對時雙方各自折疊,兩邊原始字串都不改。"""
    return fold(gt) == fold(hyp)

一行。但這一行的位置,決定了整個系統的可追溯性

💡Tip: 有一個判斷準則我覺得很好用:問這個變換「可不可逆」。可逆的(排序、加索引、算 hash)放哪裡都行;不可逆的(正規化、截斷、四捨五入、去重)一律往流程後面推,而且推得越後面越好。Day 29 講 Router 的資料敏感分級也是同一套邏輯——脫敏是不可逆的,所以脫敏點的位置要非常慎重。


回到那個 0:我怎麼把它寫進文件

實驗結果是 0,但我不能讓下一個看到這份數據的人(或三個月後的我)以為「繁簡混排在這個專案不是問題」

所以我在 step4_zh_error_summary.md 的「方法限制」那節寫了這幾條,逐字:

  • 分類器是規則式的,簡繁對照約 90 對、異體字約 14 對、形近字約 19 組,皆為手工列舉,非完備詞典
  • 沒被表列到的字對會落入 other不代表「沒有這類錯誤」,只代表現有規則沒認出來
  • simplified_char 的判定要求 replace 塊 GT/輸出等長且逐字對應,混在長段落裡的簡體字抓不到

這三句話比那張統計表重要。因為統計表是「這次測到什麼」,而限制欄是「這次測不到什麼

我在 Day 3 就吃過一次虧:一個沒記錄條件的「150 tok/s」差點讓我寫出一整段錯誤分析。從那之後我的習慣是——數據落檔時,限制跟數字寫在同一份檔案裡,不要分開,因為分開的結果就是引用的人只複製數字

💡Tip: 如果你的錯誤分類是規則式的,我建議額外落一個欄位:other 佔全部 diff 塊的比例。這個比例就是你分類器的「未知率」,而未知率的變化趨勢比任何單一類別的數字都有價值——它會告訴你什麼時候該擴充規則。這次是 37/87 ≈ 43%,這個數字高得足以讓我明天在設計 verifier 的時候,不敢只靠規則。


今天的結論

  • 九頁實測裡,簡體字 0 次、異體字 0 次、形近字 0 次。兩個原因:樣本本身是乾淨的台灣繁中簡報,以及分類用的對照表是手工小表、不完備(90 對/14 對/19 組)
  • 其中 visual_confusion 的 0 確定是假的——Day 6 那個「綠→線」就是形近字,只是表裡沒收,落進了 other。87 個 diff 塊有 37 個落進 other,未知率 43%
  • Unicode 正規化在繁簡與異體字上幫不上忙,實跑驗證:臺/台、裏/裡、爲/為、國/国、後/后,NFC 與 NFKC 全部判為不相等——因為它們是不同碼位的不同字,不是同一個字的不同編碼
  • NFC 唯一會動到的漢字是相容漢字區 U+F900–U+FAFF;NFKC 能處理全形/半形,但會連全形標點一起折掉,不建議整段用
  • 繁簡不是一對一映射:干→干/乾/幹、里→里/裡/裏,資訊在簡化時就被刪掉了,純字元對照表必然出錯
  • 我的選擇是做比較鍵(fold),不做資料轉換。原始字串永遠保留,折疊只影響相等判定;折疊表保守到只有五對,人名與公司登記名欄位不折
  • 即使實測 0 次,這層還是要先建——因為真正會炸的不是 OCR,是 Day 25 的 diff 飛輪(假陽性會被當成校準燃料)跟 Day 17 的 MOPS XBRL 校驗(台/臺會觸發公司名不一致告警)

實驗沒炸,不等於問題不存在。

有時候「測不到」只是說明你的量測工具不夠好。

明天是 Phase 2 的最後一天

我要把 Day 6 跟 Day 7 攤開的所有難點,一條一條對應到後面真的會出現的設計決策。而且我會回答一個你可能從第一天就想問的問題:

這些問題,換一顆更大的模型不就好了嗎?

明天見 👋


上一篇
Day 6 - 繁體中文字型與版面難點:為什麼「進階」不是行銷詞
系列文
從 LLM 到 Harness: 打造隱私與可信任的繁中進階 OCR Agent7
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
justin_log
iT邦新手 5 級 ‧ 2026-09-21 14:19:02

期待明天的 Phase 2 最後一天!!!!!

我要留言

立即登入留言