
有一種軟體,你每天使喚它數千次,卻幾乎不會意識到它的存在──直到它壞掉為止。輸入法就是這種東西。
Yahoo! 奇摩輸入法是一個傳奇。它在 2013 年停止維護,到今天已經十三個年頭,卻還有人(對,包括我)在網路上翻找它的安裝檔;網友一路傳承、修補它最後一次釋出的版本,硬是讓它在 macOS 上多撐了十幾年。但天下無不散的筵席。某天早晨,剛更新完的 Mac 右上角跳出一則來自蘋果的最後通牒:Intel 版 app 的支援即將結束,x86 程式將在 macOS 27 停止執行。
大家其實都心裡有數,這個早該安息的輸入法終將離開。抱著不捨的心情,我想,既然 Yahoo 都大方的開源了,那不如重拾多年沒碰的 C++,看看能不能讓它再活一次。
這篇文章記錄的,就是這幾個月發生的事。前半段是一場數位考古,後半段則慢慢變成一個語言學問題。因為我很快就發現,讓輸入法「跑起來」只是入場券,真正決定一個注音輸入法好不好用的,是它背後那份詞庫。
第一部分:先讓它活過來
一座「廢件處理廠」
打開 GitHub 一查,才發現早就有人動過手了。一個 vChewing/KeyKey-Boneyard 的封存庫,裡面對 Yahoo 奇摩輸入法在 2012 年開源出來的原始碼已經進行過大量的整理。資料夾名稱像 Loaders/OSX-TSM、Loaders/Windows-IMM、Frameworks/OpenVanilla,每一個都充滿年代感。中間有唯音輸入法的開發者,孫志貴前輩修過偏好設定與詞庫編輯器,但輸入法主體從來沒有現代化過。README 甚至寫著這是一座「廢件處理廠」,並且聲明沒有人保證這東西會動。
我心一橫,決定接手。第一天(2026 年 6 月 22 日)就是不折不扣的震撼教育。
專案的入口根本不存在,.xcworkspace 打開來是空的。發送按鍵的程式碼還在使用 Carbon 時代的 Str255 這類早已成為化石的型別。而最讓我倒抽一口氣的是,這個輸入法在沒有辭典資料庫的情況下會直接崩潰。程式碼寫死認定一定存在一個叫 SmartMandarin 的輸入法,那段「找不到就換下一個」的容錯迴圈整段被註解掉,不知道封印了多久。
修完這一輪,程式終於願意在沒有資料庫的情況下體面地「什麼都不做」,而不是當場倒下。同一天下午,我用 Ruby 從頭寫了一支資料庫產生器,直接從注音表組出一份最小可用的詞庫,不再依賴 Yahoo 當年的搜尋語料、中研院語料庫,也不需要商用的加密 SQLite 擴充。「詞庫」這件事,第一次徹底與原廠脫鉤。
跟 2012 年打一架
接下來的一週像是考古挖掘現場。輸入法當年有一個 Yahoo 快捷鍵,可以直接搜尋或者查東西的「OneKey」功能,它的設定檔列出的服務清單,活脫脫是一份 2012 年台灣網路生態的切片:Yahoo 奇摩搜尋、股票查詢、無名小站、Yahoo 拍賣、Yahoo 地圖。再往下挖,有一整套 Windows 安裝程式工具鏈,甚至還有 Borland C++Builder 5 的專案檔;有替 Mac OS X 10.4 Tiger 寫的 Text Services Manager 實作;有「上傳資料庫到 iDisk」的按鈕,而 iDisk 是蘋果 iCloud 的前身,MobileMe 時代的雲端服務,2012 年就熄燈了。
把行李箱裡的石頭一塊塊倒出來之後,這個專案改名為 ChiaKey(千秋輸入法),並且把專案架構與路線圖定出來。詞庫獨立成姊妹庫 ChiaKey-Lexicon,透過 GitHub Releases 發佈,附上簽章與校驗碼;輸入法本體只負責執行期的契約、驗證與安裝。
中間還有一天一口氣修了十五個資安漏洞。沒有檢查 macOS 的「安全輸入」狀態;把使用者字串直接丟給 system() 執行的 shell injection 破口;差一個位元組的 stack buffer overflow;還有一條透過古老的 Distributed Objects 暴露出來、任何行程都能遠端塞按鍵進去的 IPC 通道。也順手替核心引擎裡兩個負責展開所有切法的遞迴函式加上記憶化。原本句子每多一個字,重複展開的工作量便呈指數增長,因此偏好設定裡建議大家組字區域不要開太大,但改完之後,長句與短句一樣流暢。
這些工程細節可以再寫好幾篇,但我想把篇幅留給後面更有意思的事。當程式不再崩潰、修好了 Bug、打起來也不卡了,我開始認真用它打字,然後很快遇到所有注音使用者都熟悉的那種懊惱:
打「天意難測」,出來的是「天意南側」。
第二部分:輸入法真正的難題,是詞
開源詞庫很多,但都少了一種東西
注音輸入法的核心,是把一串讀音變成一串字。這件事需要三種資料:注音對字的對照表、每個詞有多常用(術語叫 unigram),以及詞與詞怎麼銜接(bigram)。
繁體中文的開源詞庫資源其實不少。新酷音(libchewing)的 tsi.csv 是「詞組、頻率、注音」,Rime 中州韻共享的 essay.txt 是「詞、頻率」。但仔細看會發現,它們幾乎都是同一種形式:一個詞、一個數字。這種資料能告訴你「哪個詞比較常用」,卻無法描述「打完 A 之後,接 C 是不是比接 B 更合理」。
而同音歧義恰恰全靠這件事。「難測」與「南側」單獨看都是正常的詞,誰比較常用其實差不了多少;真正能分出勝負的,是前面那個「天意」。台灣的開源注音生態,長期缺少這種可用的接續關係資料。原因也很現實:n-gram 權重表需要語料清洗、機率計算、模型編譯,不像傳統詞庫那樣改改文字檔就能持續更新。
Yahoo 當年是有這份資料的,用他們的搜尋語料與中研院語料庫算出來。但那是 2012 年的語言,而且授權不明,沒辦法跟著開源版一起走。所以要嘛沒有 bigram,要嘛自己做一份。
先弄懂引擎怎麼「選」
在動手做資料之前,得先搞清楚引擎究竟怎麼使用這些數字。ChiaKey 的核心引擎(Manjusri/Gramambular 系)把一串注音切成一格一格的候選詞,然後尋找一條總分最高的路徑。這裡有幾條規則,決定了後面所有的研究方向。
分數是以 10 為底的 log 機率,全部是負值,越接近 0 越常用。相加就等於機率相乘。
每個詞節點會多拿一份「詞長加分」:每多一個音節加 1.0。這個數字相當可觀。四字詞當一個節點,比拆成兩個二字詞多出整整 1.0,遠大於多數 bigram 能提供的差距。換句話說,bigram 只有在競爭路徑的節點數相同時,才真的有影響力。
bigram 與 unigram 之間是「取最高分者」,不是相加。而且理論上應該打折的退避權重(Katz back-off),在整個資料庫裡全是 0。所以一筆 bigram 要生效,必須硬贏過那個讀音本來排第一的候選。
還有一個很容易被忽略的細節:同一組「讀音+前文」底下如果掛了許多筆 bigram,引擎只看第一筆。第二名以後的資料永遠不會被讀到。
弄懂這幾條,就能反推出一個判準:一筆 bigram 要真的有用,它的後文這個詞,必須在使用者實際會打的那個讀音上,原本是輸給同音對手的。如果它本來就是第一名,這筆資料不會改變任何結果;如果它綁在一個罕用讀音上,使用者永遠不會觸發它。這個判準後來成為整個研究的骨幹,不過我是繞了兩條錯路才走到這裡。
第三部分:兩條死路
死路一:請 AI 寫一堆台灣文章,再從裡面數
第一個想法很直覺。既然缺語料,就請本機的語言模型(gemma-4-26B-A4B,跑在 MLX 上)大量生成台灣繁體中文短文,斷詞之後統計哪些詞常接在哪些詞後面。
跑了一陣子,帳算出來相當難看。30.5 萬個漢字的生成文本,只能產出約 2,000 個真正落在「同音撞碼」位置的配對,長尾頻率的估計雜訊極大。按實測速率,要達到可用規模得連續生成好幾週。更麻煩的是,模型有自己的口癖:同一個 prompt 下,「相關單位」這個詞在 381 篇文章裡出現了 62 篇。直接計數,會把模型的偏好誤認成語言的頻率。
以這條路做出來的合成語料層(後來稱作 chiaki-synthetic-overlay)有 46,822 列 bigram。拿前面那個判準回頭檢視,只有 11.6% 落在能改變結果的位置。其餘 88.4%,要嘛那個讀音只有一個候選,要嘛那個詞本來就是第一名。
死路二:既然撞碼詞可以列出來,就請模型替每個詞補前文
第二個想法看起來更聰明。哪些詞會撞碼,是可以從詞庫直接列舉的封閉集合:335,427 個詞條裡,在自己常用讀音上會輸給同音對手的有 55,170 個(16.4%)。那何不對每一個撞碼詞,批次請模型回答「最常出現在它前面的是哪些詞」,再用模型的 logprob 做判別式驗證,只留下真的能把它推過對手的配對?
工程效率高得驚人:12 個詞 9 秒,一個晚上就把全部 29,221 個高頻撞碼詞跑完,產出 20,092 筆通過驗證的配對。
隔天早上把評估工具建好一測,這條路宣告失敗。那批資料在 32.6 萬句真實測試語料裡,只命中了約 200 次。
原因我猜測一來是本地模型太小,對詞彙的創意度很低,二來是模型其實也並不熟悉台灣人寫出來的句子。模型可以列舉撞碼詞,卻不曉得真實用法。判別式驗證能確認「給定這個前文,這個詞是否勝過同音對手」,卻無法確認「這個前文到底會不會出現」。
好在這幾天的產物並非白費,為了幫模型打分做的評估工具,對於之後的品質驗證起到了非常大的作用。
第四部分:回到真實的台灣文本
4.6 億字
放棄讓模型憑空發明之後,路線變得樸實許多:尋找真實的、台灣人寫的、授權允許使用的文本,直接抽取。
最後湊出來的語料有五種來源,合計超過 4.6 億字、約 3.3 億個漢字、六百七十多萬行。政府機關新聞發布(行政院、陸委會、中研院、客委會、新北市政府,依政府資料開放授權條款釋出);立法院公報的詢答逐字稿(這是少數同時具備「真實即席口語」「台灣本土語境」「授權可再散布」三個條件的語料,甚至幾乎沒有錯字);PTT 與 Plurk 的公開貼文;以及正體中文轉換後的維基百科條文。
我設計了一套系統,萃取出文本的詞庫列與權重機率,原始文本與中間語料完全不落地。
只收「會改變結果」的配對
從語料到詞庫列,中間有十一個步驟。大多是可以想見的清洗:切句、斷詞、統計相鄰詞對、把「前文加後文本身就是一個詞」的配對挪走(那屬於 unigram 層)、設定文件頻率門檻。但有幾步值得單獨說說。
斷詞要與引擎一模一樣。 抽取用的斷詞器採 Viterbi 最大分數法,詞長加成刻意設成與引擎的 lengthPrior() 相同。這件事後來還出過一次頗為難堪的 bug,容後再談。
撞碼過濾。 也就是前面那個判準:後文在它權重最高的讀音上,必須落後於同音對手。這一步篩掉了五百多萬筆配對。最後產出的 394,267 列,全數落在撞碼位置,對照舊合成層的 11.6%。
語料證據檢查。 同一個前文之後,如果語料裡另一個同音對手出現得更頻繁,這一列就是在與證據作對,直接移除。這是防止「搶錯」的主要防線,效果隨語域差異極大。在書面語域它帶來約三成五的淨值差異;在朋友之間的聊天訊息語域,它是「有效」與「幾乎無效」的分界線:不做這項檢查,修對 5,267 個位置、同時搶錯 5,233 個,淨值僅 +34;做了之後,修對 4,679、搶錯 618,淨值 +4,061。
的/地/得必須額外處理。 這三個字共用 ㄉㄜ˙ 的讀音,在 bigram 層處理不但多餘,實測還會彼此搶位。於是決定改由輸入法本體以聲調區分:打 ㄉㄜ˙ 出「的」、ㄉㄜˊ 出「得」、ㄉㄧˋ 出「地」,把判斷交還給使用者。
整詞的假象。 這是一個相當漂亮的陷阱題。像「電影裡」「成就」這種標準寫法,本身就是詞庫裡的複合詞,斷詞會把它併成單一 token,於是「電影→裡」這個配對在語料裡的計數恆為 0;而「電影裏」「成舊」這類非標準寫法不是詞典詞,會被切成兩個 token 並累積計數。原本的證據檢查一比,13 對 0,放行。結果就是系統性地偏好錯字。隨機抽 22 筆被這條規則揪出來的列,內容全是錯字、日文字形(聖霊、新緑)與簡體字形(蝴蝶结)。補上判準之後,這一類移除了 10,641 列。
立法委員的姓氏。 公報整篇都是「立法委員 ×」「立委 ×」,那個單字是委員的姓。這種配對的頻率反映的是哪幾位委員發言次數多,不是語言裡的搭配關係,需要排除。
第五部分:怎麼知道有沒有變好
不能只看筆數
詞庫工作最容易犯的錯,是拿「加了幾萬筆資料」當成進度。所以整個專案最重要的產物,其實不是那 39 萬列 bigram,而是量測方法。
做法是這樣:在真實文本上斷詞,逐個相鄰詞對判定「沒有 bigram 的時候,引擎會不會選錯」。這些位置叫可修位置。然後看一個 bigram 層修對了幾個位置,又把幾個原本正確的位置搶錯。評估會重播正式版的校準公式,只計入確定會生效的資料列。
測試集分四個語域:新北市政府新聞(書面)、立法院公報一個保留會期(正式口語)、PTT 語料的 5%(涵蓋 169 個看板),以及我和朋友的群組對話(訊息)。
為什麼一定要多語域
因為單一語域的評估會很有系統地哄騙你。
這一層的前一版只用政府新聞與立法院公報訓練,在書面語域淨值 +85,128,看起來非常漂亮,這個數字也已經寫進來源文件了。加入真實訊息語域之後才發現,同一份資料在朋友聊天的語域淨值只有 +65:修對 1,603、搶錯 1,538,幾乎完全抵銷。加入 PTT 語料重做之後,訊息語域提升了大約 65 倍。
最有價值的是評估工具
評估工具在開發過程中碰到了三個問題,每一次都讓我高估了成果。
第一次,資料列被綁到罕用讀音。初版的邏輯是「選權重落差最大的讀音當綁定目標」,理由是落差越大越需要幫助。結果它很有系統地選中每個詞最罕用的讀音:「的」被綁到 ㄉㄧˋ(權重 −3.09),而不是 ㄉㄜ˙(−0.58)。初版 93,952 列裡有 68.5% 綁在使用者永遠不會打的讀音上。
第二次,評估只比對文字,不管讀音也不管校準。等於假設一筆 bigram 在該詞的任何讀音下都生效,而且必定勝過第一名。修正成以「前文、後文、綁定讀音」三元組比對並重播校準之後,書面語域的可修位置基數從 184 萬降到 95 萬。
第三次,就是前面說的單一語域。
還有一個低級的 bug:斷詞的詞長加成。抽取工具原本寫的是「每個字 +1.0」,聽起來與引擎的「每多一個音節 +1.0」差不多,但加總之後前者等於「Σ權重 + 1.0 × 總字數」,而總字數與你怎麼切完全無關。也就是說,這個加成對斷詞選擇毫無作用,實際行為是純粹取權重和最大,與偏好長詞的引擎完全不一致。實測 55.1% 的句子兩種規則切出來不同;當時出貨的 29 萬列裡,在語料中可觀測的有 76,407 列,其中 5,558 列(7.3%)落在引擎根本不會產生的詞界上,永遠不會觸發。修正之後重建,以引擎端的 gold set 量測逐句正確率:口語 +2.07、論壇 +0.88、Plurk +0.75,維基持平。
被丟掉的,比留下的還多
還有一條規則一直讓我很在意,為了方便,一開始我設定「前文如果是多音字,就整筆棄用」,理由是它的讀音會變成猜測。這條規則棄掉的量比留下的還多,一百多萬筆,而且棄掉的正是最有價值的那批。被丟掉的配對裡最常見的前文,是「的」(2,756 對)、「在」(1,119)、「和」(1,026)、「與」、「有」這些高頻虛詞。
我嘗試保留,並且把多音字綁到它自己權重最高的讀音,與後文採用同一個定義。意外的效果並不差,四個語域全部提升,列數從 293,853 增到 360,016,訊息語域的修對率從 13.5% 升到 18.4%。我也嘗試過人工審核,但發現靠人力審核讀音,一個晚上最多也只能看個 1000 條左右,也不是個長久的辦法。
第六部分:有些事 bigram 管不了
做到這裡,我漸漸明白 bigram 只是整個系統的一層,許多問題根本不在這一層。
立法院的行話
早期有一層專門從立法院公報做的 bigram(tw-ly-transcript),經過人工複核。它有個頗為反直覺的性質:訊號最強的搭配,恰好是最沒價值的部分。公報裡文件頻率最高的配對是「條文+通過」「所以+本席」「本席+要」,全是議場專用語。更危險的是「異議/意義」「議事/意識」這類:議場偏好前者,日常打字幾乎全是後者,語料的偏好會直接傷到高頻日常詞。而且這種判斷無法從權重落差導出。「議事/意識」落差 0.759 應該移除。差別在語義語域,不在頻率結構,只能逐一點名。
「啊」與「阿」
引擎端的 gold set 基準線揭露了一件事:幾乎沒有斷詞層級的錯誤(輸出與期望長度不同的只有 1 句),問題全在同讀音候選選錯。最大的一群是句末助詞輸給同音實詞:啊→阿、啦→拉、嘛→嗎,合計 7,204 次。
「啊」在 ㄚ 這個讀音上的權重是 −1.673,輸給「阿」的 −1.015。翻過來之後,啊→阿 的錯誤從 3,100 次降到 3 次,代價是阿→啊 多了 12 次。因為「阿」幾乎總是出現在「阿姨」「阿公」「阿拉伯」裡,那些以整詞路徑勝出,不受單字排頭影響。口語語域逐句正確率因此 +2.37。
但這件事有方法論上的教訓。翻轉單字排頭是雙向的,現在的排頭永遠不會錯,翻過去之後換它每次都錯。同一份稽核清單裡翻轉成本最低的兩組(嘛/嗎 只差 0.000008),淨值其實是負的,因為「嗎」在語料裡比「嘛」常用四倍以上。所以判準不是「翻轉多便宜」,而是「哪一邊才是使用者想要的」。
一個由檔案位置決定的輸入法
還有一個更奇妙的發現。引擎撈候選時用的是穩定排序,權重完全相同的候選會維持輸入順序,而輸入順序就是資料列在 SQLite 檔案裡的實體位置。實測同讀音相鄰候選對 68,684 組中,有 12,003 組權重一模一樣。典型案例是「啦/拉」(同為 −1.198788)。順序一翻,「就好了啦」變成「就好了拉」,上千句受影響。
這代表兩份內容完全等價、只差資料列順序的資料庫,口語語域逐句正確率可以差 1.02 個百分點,比這個專案多數改動的真實效果還大。這也意味著,用 UPDATE 改資料庫做 A/B 測試會產生假訊號。
解法是找一份外部、客觀的頻率依據,在權重相同時充當第二排序鍵。國家教育研究院的《通用詞頻表》正好合適。專案去函詢問授權,國教院竟然爽快的以公文書面確認採 CC BY 4.0,可以商業利用。套用之後不改任何權重、只改排序,五個語域全部提升(+0.22 到 +1.36)。
「吃不下」為什麼打不出來
最近的一批修正處理的是「不」的變調。「不」在四聲前變調成 ㄅㄨˊ、在正反問句裡弱化成 ㄅㄨ˙,語料統計忠實地記下了變調後的讀音;但使用者打字有可能會打本調 ㄅㄨˋ。於是「吃不下」在詞庫裡只有變調讀音,打 ㄔ ㄅㄨˋ ㄒㄧㄚˋ 只能走「吃+部下」。這批補了五千多個含「不」的詞的本調讀音,原讀音不動,兩種都能打。
這類問題沒有一個是 bigram 能解的。它們是讀音層、排序層、政策層的問題。詞庫是一疊層,每一層有自己的邊界。
第七部分:現在的成績,以及該坦白的事
主力層 chiaki-tw-homophone-bigram 目前有 394,267 列,覆蓋 61,675 個不同的前文、17,049 個不同的後文,平均每個後文有 23.1 個前文。出貨版的量測如下:
| 語域 |
可修位置 |
修對 |
搶錯 |
淨值 |
修復率 |
| 書面(新北市新聞) |
900,169 |
400,851 |
19,300 |
+381,551 |
44.5% |
| 正式口語(立法院公報) |
257,232 |
98,788 |
7,623 |
+91,165 |
38.4% |
| 論壇(PTT) |
441,425 |
124,526 |
14,423 |
+110,103 |
28.2% |
| 訊息(朋友群組) |
29,485 |
5,524 |
1,086 |
+4,438 |
18.7% |
與其他 bigram 來源在同一天、同一份詞庫、同一個訊息語域測試集上比較(7 月 30 日的快照,當時這一層還只有 29 萬列):人工審查過的網路用語層淨值 +476,舊立法院層 +39,舊合成層 +5,這一層 +4,259。
不過這張表有幾件事必須說清楚。
出貨版是演算法凍結之後用全部語料重建的,四個語域的測試集也在訓練裡,所以上表不是 held-out 的。演算法的每個採用決定,都是在測試集完全排除在訓練外的乾淨版本上做的;凍結驗證時,dev 半與凍結半的修復率(14.8% 對 14.5%)與搶錯率幾乎相同,沒有過擬合的跡象。
評估只計入「任何學習狀態下都會生效」的那一類資料列,佔 41.8%。另外有 49.9% 是「使用者選過較弱的候選之後才會生效」的救援路徑,這部分的貢獻取決於個別使用者,靜態語料量不出來,所以表上的數字是下限。
394,267 列沒有逐列人工複核,由自動判準產生、以 held-out 評估把關。
訊息語域的測試集只有我們的群組,而且帶有人名偏誤:群組成員的名字造成了最大宗的搶錯,單一配對佔全部搶錯的 16%。訓練出來的成果,也相當程度的跟隨我本人的使用習慣,難以涵蓋所有人的使用情境。
引擎端的 gold set 基準線(約 19.9 萬句,五個語域)逐句正確率大約在 64% 到 77% 之間,逐字正確率 95% 到 97%。這是 in-sample 的數字,只適合比較改動前後,不能當作對外宣稱的準確率。
授權這件事
整個詞庫沒有單一授權。每個資料層都有自己的來源、授權與整合紀錄,公開 release 之前必須先宣告。主力 bigram 層因為包含 PTT、Plurk 與維基百科(CC BY-SA)衍生的資料,採 CC BY-NC 4.0,不提供商業例外;需要明確資料授權的使用者,另有一份只用政府新聞與立法院公報重建、採 ODbL 的 clean 版本,56,048 列,可以商業使用,但公開衍生資料庫時要以 ODbL 回饋。
這個專案建立在許多人的成果之上:新酷音的詞庫、Rime 的詞頻、vChewing 整理保存的 KeyKey 原始碼封存與資料、g0v 維護的立法院 API、國教院的詞頻表、教育部《重編國語辭典修訂本》的讀音。我做的事情,主要是把這些前人的積累整合成可重現、可追蹤來源的現代輸入法詞庫。
結語:所以,都 2026 年了
回到標題的問題。
我原本以為這是一個「把老程式修到能跑」的懷舊專案。結果最耗時間、也最有滋味的部分,是慢慢弄清楚一件事:一個注音輸入法之所以「懂你」,不是因為它有多聰明,而是因為有人把台灣人實際怎麼打字的證據,一筆一筆的整理起來。
在語言模型的時代,這件事反而更值得做。實驗很明白地告訴我們,模型可以幫忙判斷,卻不能代替真實文本告訴你台灣人怎麼說話。「相關單位」出現 62 次的那份語料,與立法院公報裡的「本席」,本質上是同一種東西,都是某個特定說話者的口癖,不是語言本身。
它還在成長,我也還在改。詞庫每週會自動吸收新的熱門詞,bigram 層會隨詞庫變動重新產生,每一次改動都得先過四個語域的關。故事還沒寫完,但至少現在,打「天意難測」,出來的會是「天意難測」。
ChiaKey(千秋輸入法)與 ChiaKey-Lexicon(千秋輸入法綜合詞庫)皆為開源專案。本文提到的所有數字都來自專案文件中的實測紀錄;各層的完整方法、限制與授權,見詞庫專案的來源說明與研究附錄。
有一種軟體,你每天使喚它數千次,卻幾乎不會意識到它的存在──直到它壞掉為止。輸入法就是這種東西。
Yahoo! 奇摩輸入法是一個傳奇。它在 2013 年停止維護,到今天已經十三個年頭,卻還有人(對,包括我)在網路上翻找它的安裝檔;網友一路傳承、修補它最後一次釋出的版本,硬是讓它在 macOS 上多撐了十幾年。但天下無不散的筵席。某天早晨,剛更新完的 Mac 右上角跳出一則來自蘋果的最後通牒:Intel 版 app 的支援即將結束,x86 程式將在 macOS 27 停止執行。
大家其實都心裡有數,這個早該安息的輸入法終將離開。抱著不捨的心情,我想,既然 Yahoo 都大方的開源了,那不如重拾多年沒碰的 C++,看看能不能讓它再活一次。
這篇文章記錄的,就是這幾個月發生的事。前半段是一場數位考古,後半段則慢慢變成一個語言學問題。因為我很快就發現,讓輸入法「跑起來」只是入場券,真正決定一個注音輸入法好不好用的,是它背後那份詞庫。
第一部分:先讓它活過來
一座「廢件處理廠」
打開 GitHub 一查,才發現早就有人動過手了。一個
vChewing/KeyKey-Boneyard的封存庫,裡面對 Yahoo 奇摩輸入法在 2012 年開源出來的原始碼已經進行過大量的整理。資料夾名稱像Loaders/OSX-TSM、Loaders/Windows-IMM、Frameworks/OpenVanilla,每一個都充滿年代感。中間有唯音輸入法的開發者,孫志貴前輩修過偏好設定與詞庫編輯器,但輸入法主體從來沒有現代化過。README 甚至寫著這是一座「廢件處理廠」,並且聲明沒有人保證這東西會動。我心一橫,決定接手。第一天(2026 年 6 月 22 日)就是不折不扣的震撼教育。
專案的入口根本不存在,
.xcworkspace打開來是空的。發送按鍵的程式碼還在使用 Carbon 時代的Str255這類早已成為化石的型別。而最讓我倒抽一口氣的是,這個輸入法在沒有辭典資料庫的情況下會直接崩潰。程式碼寫死認定一定存在一個叫SmartMandarin的輸入法,那段「找不到就換下一個」的容錯迴圈整段被註解掉,不知道封印了多久。修完這一輪,程式終於願意在沒有資料庫的情況下體面地「什麼都不做」,而不是當場倒下。同一天下午,我用 Ruby 從頭寫了一支資料庫產生器,直接從注音表組出一份最小可用的詞庫,不再依賴 Yahoo 當年的搜尋語料、中研院語料庫,也不需要商用的加密 SQLite 擴充。「詞庫」這件事,第一次徹底與原廠脫鉤。
跟 2012 年打一架
接下來的一週像是考古挖掘現場。輸入法當年有一個 Yahoo 快捷鍵,可以直接搜尋或者查東西的「OneKey」功能,它的設定檔列出的服務清單,活脫脫是一份 2012 年台灣網路生態的切片:Yahoo 奇摩搜尋、股票查詢、無名小站、Yahoo 拍賣、Yahoo 地圖。再往下挖,有一整套 Windows 安裝程式工具鏈,甚至還有 Borland C++Builder 5 的專案檔;有替 Mac OS X 10.4 Tiger 寫的 Text Services Manager 實作;有「上傳資料庫到 iDisk」的按鈕,而 iDisk 是蘋果 iCloud 的前身,MobileMe 時代的雲端服務,2012 年就熄燈了。
把行李箱裡的石頭一塊塊倒出來之後,這個專案改名為 ChiaKey(千秋輸入法),並且把專案架構與路線圖定出來。詞庫獨立成姊妹庫 ChiaKey-Lexicon,透過 GitHub Releases 發佈,附上簽章與校驗碼;輸入法本體只負責執行期的契約、驗證與安裝。
中間還有一天一口氣修了十五個資安漏洞。沒有檢查 macOS 的「安全輸入」狀態;把使用者字串直接丟給
system()執行的 shell injection 破口;差一個位元組的 stack buffer overflow;還有一條透過古老的 Distributed Objects 暴露出來、任何行程都能遠端塞按鍵進去的 IPC 通道。也順手替核心引擎裡兩個負責展開所有切法的遞迴函式加上記憶化。原本句子每多一個字,重複展開的工作量便呈指數增長,因此偏好設定裡建議大家組字區域不要開太大,但改完之後,長句與短句一樣流暢。這些工程細節可以再寫好幾篇,但我想把篇幅留給後面更有意思的事。當程式不再崩潰、修好了 Bug、打起來也不卡了,我開始認真用它打字,然後很快遇到所有注音使用者都熟悉的那種懊惱:
打「天意難測」,出來的是「天意南側」。
第二部分:輸入法真正的難題,是詞
開源詞庫很多,但都少了一種東西
注音輸入法的核心,是把一串讀音變成一串字。這件事需要三種資料:注音對字的對照表、每個詞有多常用(術語叫 unigram),以及詞與詞怎麼銜接(bigram)。
繁體中文的開源詞庫資源其實不少。新酷音(libchewing)的
tsi.csv是「詞組、頻率、注音」,Rime 中州韻共享的essay.txt是「詞、頻率」。但仔細看會發現,它們幾乎都是同一種形式:一個詞、一個數字。這種資料能告訴你「哪個詞比較常用」,卻無法描述「打完 A 之後,接 C 是不是比接 B 更合理」。而同音歧義恰恰全靠這件事。「難測」與「南側」單獨看都是正常的詞,誰比較常用其實差不了多少;真正能分出勝負的,是前面那個「天意」。台灣的開源注音生態,長期缺少這種可用的接續關係資料。原因也很現實:n-gram 權重表需要語料清洗、機率計算、模型編譯,不像傳統詞庫那樣改改文字檔就能持續更新。
Yahoo 當年是有這份資料的,用他們的搜尋語料與中研院語料庫算出來。但那是 2012 年的語言,而且授權不明,沒辦法跟著開源版一起走。所以要嘛沒有 bigram,要嘛自己做一份。
先弄懂引擎怎麼「選」
在動手做資料之前,得先搞清楚引擎究竟怎麼使用這些數字。ChiaKey 的核心引擎(Manjusri/Gramambular 系)把一串注音切成一格一格的候選詞,然後尋找一條總分最高的路徑。這裡有幾條規則,決定了後面所有的研究方向。
分數是以 10 為底的 log 機率,全部是負值,越接近 0 越常用。相加就等於機率相乘。
每個詞節點會多拿一份「詞長加分」:每多一個音節加 1.0。這個數字相當可觀。四字詞當一個節點,比拆成兩個二字詞多出整整 1.0,遠大於多數 bigram 能提供的差距。換句話說,bigram 只有在競爭路徑的節點數相同時,才真的有影響力。
bigram 與 unigram 之間是「取最高分者」,不是相加。而且理論上應該打折的退避權重(Katz back-off),在整個資料庫裡全是 0。所以一筆 bigram 要生效,必須硬贏過那個讀音本來排第一的候選。
還有一個很容易被忽略的細節:同一組「讀音+前文」底下如果掛了許多筆 bigram,引擎只看第一筆。第二名以後的資料永遠不會被讀到。
弄懂這幾條,就能反推出一個判準:一筆 bigram 要真的有用,它的後文這個詞,必須在使用者實際會打的那個讀音上,原本是輸給同音對手的。如果它本來就是第一名,這筆資料不會改變任何結果;如果它綁在一個罕用讀音上,使用者永遠不會觸發它。這個判準後來成為整個研究的骨幹,不過我是繞了兩條錯路才走到這裡。
第三部分:兩條死路
死路一:請 AI 寫一堆台灣文章,再從裡面數
第一個想法很直覺。既然缺語料,就請本機的語言模型(gemma-4-26B-A4B,跑在 MLX 上)大量生成台灣繁體中文短文,斷詞之後統計哪些詞常接在哪些詞後面。
跑了一陣子,帳算出來相當難看。30.5 萬個漢字的生成文本,只能產出約 2,000 個真正落在「同音撞碼」位置的配對,長尾頻率的估計雜訊極大。按實測速率,要達到可用規模得連續生成好幾週。更麻煩的是,模型有自己的口癖:同一個 prompt 下,「相關單位」這個詞在 381 篇文章裡出現了 62 篇。直接計數,會把模型的偏好誤認成語言的頻率。
以這條路做出來的合成語料層(後來稱作
chiaki-synthetic-overlay)有 46,822 列 bigram。拿前面那個判準回頭檢視,只有 11.6% 落在能改變結果的位置。其餘 88.4%,要嘛那個讀音只有一個候選,要嘛那個詞本來就是第一名。死路二:既然撞碼詞可以列出來,就請模型替每個詞補前文
第二個想法看起來更聰明。哪些詞會撞碼,是可以從詞庫直接列舉的封閉集合:335,427 個詞條裡,在自己常用讀音上會輸給同音對手的有 55,170 個(16.4%)。那何不對每一個撞碼詞,批次請模型回答「最常出現在它前面的是哪些詞」,再用模型的 logprob 做判別式驗證,只留下真的能把它推過對手的配對?
工程效率高得驚人:12 個詞 9 秒,一個晚上就把全部 29,221 個高頻撞碼詞跑完,產出 20,092 筆通過驗證的配對。
隔天早上把評估工具建好一測,這條路宣告失敗。那批資料在 32.6 萬句真實測試語料裡,只命中了約 200 次。
原因我猜測一來是本地模型太小,對詞彙的創意度很低,二來是模型其實也並不熟悉台灣人寫出來的句子。模型可以列舉撞碼詞,卻不曉得真實用法。判別式驗證能確認「給定這個前文,這個詞是否勝過同音對手」,卻無法確認「這個前文到底會不會出現」。
好在這幾天的產物並非白費,為了幫模型打分做的評估工具,對於之後的品質驗證起到了非常大的作用。
第四部分:回到真實的台灣文本
4.6 億字
放棄讓模型憑空發明之後,路線變得樸實許多:尋找真實的、台灣人寫的、授權允許使用的文本,直接抽取。
最後湊出來的語料有五種來源,合計超過 4.6 億字、約 3.3 億個漢字、六百七十多萬行。政府機關新聞發布(行政院、陸委會、中研院、客委會、新北市政府,依政府資料開放授權條款釋出);立法院公報的詢答逐字稿(這是少數同時具備「真實即席口語」「台灣本土語境」「授權可再散布」三個條件的語料,甚至幾乎沒有錯字);PTT 與 Plurk 的公開貼文;以及正體中文轉換後的維基百科條文。
我設計了一套系統,萃取出文本的詞庫列與權重機率,原始文本與中間語料完全不落地。
只收「會改變結果」的配對
從語料到詞庫列,中間有十一個步驟。大多是可以想見的清洗:切句、斷詞、統計相鄰詞對、把「前文加後文本身就是一個詞」的配對挪走(那屬於 unigram 層)、設定文件頻率門檻。但有幾步值得單獨說說。
斷詞要與引擎一模一樣。 抽取用的斷詞器採 Viterbi 最大分數法,詞長加成刻意設成與引擎的
lengthPrior()相同。這件事後來還出過一次頗為難堪的 bug,容後再談。撞碼過濾。 也就是前面那個判準:後文在它權重最高的讀音上,必須落後於同音對手。這一步篩掉了五百多萬筆配對。最後產出的 394,267 列,全數落在撞碼位置,對照舊合成層的 11.6%。
語料證據檢查。 同一個前文之後,如果語料裡另一個同音對手出現得更頻繁,這一列就是在與證據作對,直接移除。這是防止「搶錯」的主要防線,效果隨語域差異極大。在書面語域它帶來約三成五的淨值差異;在朋友之間的聊天訊息語域,它是「有效」與「幾乎無效」的分界線:不做這項檢查,修對 5,267 個位置、同時搶錯 5,233 個,淨值僅 +34;做了之後,修對 4,679、搶錯 618,淨值 +4,061。
的/地/得必須額外處理。 這三個字共用 ㄉㄜ˙ 的讀音,在 bigram 層處理不但多餘,實測還會彼此搶位。於是決定改由輸入法本體以聲調區分:打 ㄉㄜ˙ 出「的」、ㄉㄜˊ 出「得」、ㄉㄧˋ 出「地」,把判斷交還給使用者。
整詞的假象。 這是一個相當漂亮的陷阱題。像「電影裡」「成就」這種標準寫法,本身就是詞庫裡的複合詞,斷詞會把它併成單一 token,於是「電影→裡」這個配對在語料裡的計數恆為 0;而「電影裏」「成舊」這類非標準寫法不是詞典詞,會被切成兩個 token 並累積計數。原本的證據檢查一比,13 對 0,放行。結果就是系統性地偏好錯字。隨機抽 22 筆被這條規則揪出來的列,內容全是錯字、日文字形(聖霊、新緑)與簡體字形(蝴蝶结)。補上判準之後,這一類移除了 10,641 列。
立法委員的姓氏。 公報整篇都是「立法委員 ×」「立委 ×」,那個單字是委員的姓。這種配對的頻率反映的是哪幾位委員發言次數多,不是語言裡的搭配關係,需要排除。
第五部分:怎麼知道有沒有變好
不能只看筆數
詞庫工作最容易犯的錯,是拿「加了幾萬筆資料」當成進度。所以整個專案最重要的產物,其實不是那 39 萬列 bigram,而是量測方法。
做法是這樣:在真實文本上斷詞,逐個相鄰詞對判定「沒有 bigram 的時候,引擎會不會選錯」。這些位置叫可修位置。然後看一個 bigram 層修對了幾個位置,又把幾個原本正確的位置搶錯。評估會重播正式版的校準公式,只計入確定會生效的資料列。
測試集分四個語域:新北市政府新聞(書面)、立法院公報一個保留會期(正式口語)、PTT 語料的 5%(涵蓋 169 個看板),以及我和朋友的群組對話(訊息)。
為什麼一定要多語域
因為單一語域的評估會很有系統地哄騙你。
這一層的前一版只用政府新聞與立法院公報訓練,在書面語域淨值 +85,128,看起來非常漂亮,這個數字也已經寫進來源文件了。加入真實訊息語域之後才發現,同一份資料在朋友聊天的語域淨值只有 +65:修對 1,603、搶錯 1,538,幾乎完全抵銷。加入 PTT 語料重做之後,訊息語域提升了大約 65 倍。
最有價值的是評估工具
評估工具在開發過程中碰到了三個問題,每一次都讓我高估了成果。
第一次,資料列被綁到罕用讀音。初版的邏輯是「選權重落差最大的讀音當綁定目標」,理由是落差越大越需要幫助。結果它很有系統地選中每個詞最罕用的讀音:「的」被綁到 ㄉㄧˋ(權重 −3.09),而不是 ㄉㄜ˙(−0.58)。初版 93,952 列裡有 68.5% 綁在使用者永遠不會打的讀音上。
第二次,評估只比對文字,不管讀音也不管校準。等於假設一筆 bigram 在該詞的任何讀音下都生效,而且必定勝過第一名。修正成以「前文、後文、綁定讀音」三元組比對並重播校準之後,書面語域的可修位置基數從 184 萬降到 95 萬。
第三次,就是前面說的單一語域。
還有一個低級的 bug:斷詞的詞長加成。抽取工具原本寫的是「每個字 +1.0」,聽起來與引擎的「每多一個音節 +1.0」差不多,但加總之後前者等於「Σ權重 + 1.0 × 總字數」,而總字數與你怎麼切完全無關。也就是說,這個加成對斷詞選擇毫無作用,實際行為是純粹取權重和最大,與偏好長詞的引擎完全不一致。實測 55.1% 的句子兩種規則切出來不同;當時出貨的 29 萬列裡,在語料中可觀測的有 76,407 列,其中 5,558 列(7.3%)落在引擎根本不會產生的詞界上,永遠不會觸發。修正之後重建,以引擎端的 gold set 量測逐句正確率:口語 +2.07、論壇 +0.88、Plurk +0.75,維基持平。
被丟掉的,比留下的還多
還有一條規則一直讓我很在意,為了方便,一開始我設定「前文如果是多音字,就整筆棄用」,理由是它的讀音會變成猜測。這條規則棄掉的量比留下的還多,一百多萬筆,而且棄掉的正是最有價值的那批。被丟掉的配對裡最常見的前文,是「的」(2,756 對)、「在」(1,119)、「和」(1,026)、「與」、「有」這些高頻虛詞。
我嘗試保留,並且把多音字綁到它自己權重最高的讀音,與後文採用同一個定義。意外的效果並不差,四個語域全部提升,列數從 293,853 增到 360,016,訊息語域的修對率從 13.5% 升到 18.4%。我也嘗試過人工審核,但發現靠人力審核讀音,一個晚上最多也只能看個 1000 條左右,也不是個長久的辦法。
第六部分:有些事 bigram 管不了
做到這裡,我漸漸明白 bigram 只是整個系統的一層,許多問題根本不在這一層。
立法院的行話
早期有一層專門從立法院公報做的 bigram(
tw-ly-transcript),經過人工複核。它有個頗為反直覺的性質:訊號最強的搭配,恰好是最沒價值的部分。公報裡文件頻率最高的配對是「條文+通過」「所以+本席」「本席+要」,全是議場專用語。更危險的是「異議/意義」「議事/意識」這類:議場偏好前者,日常打字幾乎全是後者,語料的偏好會直接傷到高頻日常詞。而且這種判斷無法從權重落差導出。「議事/意識」落差 0.759 應該移除。差別在語義語域,不在頻率結構,只能逐一點名。「啊」與「阿」
引擎端的 gold set 基準線揭露了一件事:幾乎沒有斷詞層級的錯誤(輸出與期望長度不同的只有 1 句),問題全在同讀音候選選錯。最大的一群是句末助詞輸給同音實詞:啊→阿、啦→拉、嘛→嗎,合計 7,204 次。
「啊」在 ㄚ 這個讀音上的權重是 −1.673,輸給「阿」的 −1.015。翻過來之後,啊→阿 的錯誤從 3,100 次降到 3 次,代價是阿→啊 多了 12 次。因為「阿」幾乎總是出現在「阿姨」「阿公」「阿拉伯」裡,那些以整詞路徑勝出,不受單字排頭影響。口語語域逐句正確率因此 +2.37。
但這件事有方法論上的教訓。翻轉單字排頭是雙向的,現在的排頭永遠不會錯,翻過去之後換它每次都錯。同一份稽核清單裡翻轉成本最低的兩組(嘛/嗎 只差 0.000008),淨值其實是負的,因為「嗎」在語料裡比「嘛」常用四倍以上。所以判準不是「翻轉多便宜」,而是「哪一邊才是使用者想要的」。
一個由檔案位置決定的輸入法
還有一個更奇妙的發現。引擎撈候選時用的是穩定排序,權重完全相同的候選會維持輸入順序,而輸入順序就是資料列在 SQLite 檔案裡的實體位置。實測同讀音相鄰候選對 68,684 組中,有 12,003 組權重一模一樣。典型案例是「啦/拉」(同為 −1.198788)。順序一翻,「就好了啦」變成「就好了拉」,上千句受影響。
這代表兩份內容完全等價、只差資料列順序的資料庫,口語語域逐句正確率可以差 1.02 個百分點,比這個專案多數改動的真實效果還大。這也意味著,用
UPDATE改資料庫做 A/B 測試會產生假訊號。解法是找一份外部、客觀的頻率依據,在權重相同時充當第二排序鍵。國家教育研究院的《通用詞頻表》正好合適。專案去函詢問授權,國教院竟然爽快的以公文書面確認採 CC BY 4.0,可以商業利用。套用之後不改任何權重、只改排序,五個語域全部提升(+0.22 到 +1.36)。
「吃不下」為什麼打不出來
最近的一批修正處理的是「不」的變調。「不」在四聲前變調成 ㄅㄨˊ、在正反問句裡弱化成 ㄅㄨ˙,語料統計忠實地記下了變調後的讀音;但使用者打字有可能會打本調 ㄅㄨˋ。於是「吃不下」在詞庫裡只有變調讀音,打 ㄔ ㄅㄨˋ ㄒㄧㄚˋ 只能走「吃+部下」。這批補了五千多個含「不」的詞的本調讀音,原讀音不動,兩種都能打。
這類問題沒有一個是 bigram 能解的。它們是讀音層、排序層、政策層的問題。詞庫是一疊層,每一層有自己的邊界。
第七部分:現在的成績,以及該坦白的事
主力層
chiaki-tw-homophone-bigram目前有 394,267 列,覆蓋 61,675 個不同的前文、17,049 個不同的後文,平均每個後文有 23.1 個前文。出貨版的量測如下:與其他 bigram 來源在同一天、同一份詞庫、同一個訊息語域測試集上比較(7 月 30 日的快照,當時這一層還只有 29 萬列):人工審查過的網路用語層淨值 +476,舊立法院層 +39,舊合成層 +5,這一層 +4,259。
不過這張表有幾件事必須說清楚。
出貨版是演算法凍結之後用全部語料重建的,四個語域的測試集也在訓練裡,所以上表不是 held-out 的。演算法的每個採用決定,都是在測試集完全排除在訓練外的乾淨版本上做的;凍結驗證時,dev 半與凍結半的修復率(14.8% 對 14.5%)與搶錯率幾乎相同,沒有過擬合的跡象。
評估只計入「任何學習狀態下都會生效」的那一類資料列,佔 41.8%。另外有 49.9% 是「使用者選過較弱的候選之後才會生效」的救援路徑,這部分的貢獻取決於個別使用者,靜態語料量不出來,所以表上的數字是下限。
394,267 列沒有逐列人工複核,由自動判準產生、以 held-out 評估把關。
訊息語域的測試集只有我們的群組,而且帶有人名偏誤:群組成員的名字造成了最大宗的搶錯,單一配對佔全部搶錯的 16%。訓練出來的成果,也相當程度的跟隨我本人的使用習慣,難以涵蓋所有人的使用情境。
引擎端的 gold set 基準線(約 19.9 萬句,五個語域)逐句正確率大約在 64% 到 77% 之間,逐字正確率 95% 到 97%。這是 in-sample 的數字,只適合比較改動前後,不能當作對外宣稱的準確率。
授權這件事
整個詞庫沒有單一授權。每個資料層都有自己的來源、授權與整合紀錄,公開 release 之前必須先宣告。主力 bigram 層因為包含 PTT、Plurk 與維基百科(CC BY-SA)衍生的資料,採 CC BY-NC 4.0,不提供商業例外;需要明確資料授權的使用者,另有一份只用政府新聞與立法院公報重建、採 ODbL 的 clean 版本,56,048 列,可以商業使用,但公開衍生資料庫時要以 ODbL 回饋。
這個專案建立在許多人的成果之上:新酷音的詞庫、Rime 的詞頻、vChewing 整理保存的 KeyKey 原始碼封存與資料、g0v 維護的立法院 API、國教院的詞頻表、教育部《重編國語辭典修訂本》的讀音。我做的事情,主要是把這些前人的積累整合成可重現、可追蹤來源的現代輸入法詞庫。
結語:所以,都 2026 年了
回到標題的問題。
我原本以為這是一個「把老程式修到能跑」的懷舊專案。結果最耗時間、也最有滋味的部分,是慢慢弄清楚一件事:一個注音輸入法之所以「懂你」,不是因為它有多聰明,而是因為有人把台灣人實際怎麼打字的證據,一筆一筆的整理起來。
在語言模型的時代,這件事反而更值得做。實驗很明白地告訴我們,模型可以幫忙判斷,卻不能代替真實文本告訴你台灣人怎麼說話。「相關單位」出現 62 次的那份語料,與立法院公報裡的「本席」,本質上是同一種東西,都是某個特定說話者的口癖,不是語言本身。
它還在成長,我也還在改。詞庫每週會自動吸收新的熱門詞,bigram 層會隨詞庫變動重新產生,每一次改動都得先過四個語域的關。故事還沒寫完,但至少現在,打「天意難測」,出來的會是「天意難測」。
ChiaKey(千秋輸入法)與 ChiaKey-Lexicon(千秋輸入法綜合詞庫)皆為開源專案。本文提到的所有數字都來自專案文件中的實測紀錄;各層的完整方法、限制與授權,見詞庫專案的來源說明與研究附錄。