萬事查
← 回部落格

地址比對到底多難?拆解「神速.郵遞區號」的搜尋引擎設計

深入「神速.郵遞區號」的搜尋核心:地址正規化規則、縣市/行政區/道路的階層式比對與模糊比對 fallback、門牌區間的 specificity 排序,以及讓查詢在瀏覽器裡幾毫秒完成的分層資料設計與實測數字。

  • 開發筆記
  • 郵遞區號
  • 搜尋演算法
  • 技術文章

「查郵遞區號」聽起來像是一個查表問題:地址對到一個 6 碼數字,結束。實際做下去才發現,難的從來不是查表,是「使用者打的那一串文字,到底對應到表裡的哪一列」。這篇記錄「神速.郵遞區號」的搜尋引擎實際怎麼處理這個問題:從一段文字到一個 6 碼結果,中間發生了什麼事。

第一步:把輸入變成可以比對的樣子

使用者打進來的地址,格式從來不統一。同一個地方,可能被寫成「台北市」也可能是「臺北市」;可能貼了完整地址,也可能只打了「我住在信義路三段」;可能是手機輸入法殘留的注音符號,也可能是政府資料匯出時留下的怪符號。

在真正比對縣市、行政區、道路之前,這些輸入都要先經過一段正規化管線:

  • NFKC 正規化先把全形/半形、組合字這類 Unicode 層級的差異收斂掉。
  • 「請問」「麻煩查詢」「我住在」「寄到」這類口語前綴會被整段辨識掉,只留下真正的地址內容。
  • 輸入法殘留的注音符號(ㄅㄆㄇ⋯)直接視為雜訊移除。
  • 「台」一律收斂成「臺」,避免同一個地方因為兩種寫法各自比對一次。
  • 中文數字(一二三⋯十)在段、巷、弄、號、樓這些會影響比對的位置上轉成阿拉伯數字,包含「二十三」「三十」這種兩位數的組合。

比較有意思的是幾個為了對付真實資料長出來的規則:

多門牌的寫法先統一。「143.145號」「143、145號」「143及145號」這幾種常見寫法,會先被正規化成同一種內部格式,門牌解析階段才不用對每一種標點符號各寫一套邏輯。

**舊式郵遞區號前綴不能無腦砍。**很多貼上來的地址會帶著舊的 3 碼或 5 碼郵遞區號,例如「70054臺南市⋯」。但如果只看到開頭是數字就砍掉,會連正常地址裡的門牌號都一起誤刪。實際的規則是用 lookahead:只有確認數字後面緊接著縣市名稱時,才把前面的數字當成舊郵遞區號移除。

**政府資料匯出格式的怪符號要保留階層,不能只是刪除。**部分匯出資料把巷弄號編碼成「126G-5H-13I-」,如果單純把 G-/H-/I- 刪掉,數字會黏在一起變成無意義的字串;正確做法是把它們分別還原成「巷」「弄」「號」,也就是「126巷5弄13號」,保留原本的地址階層。

正規化跑完之後,才輪到真正的比對邏輯上場。

第二步:縣市、行政區、道路的階層式比對

地址比對的核心是「貪婪最長前綴匹配」:從縣市開始,一層一層往下吃掉輸入的文字,每一層都先試最精確的匹配,失敗才逐步放寬條件。以道路這一層為例,實際的 fallback 順序是:

  1. 完整前綴匹配:輸入文字直接是某條路名稱的前綴或完整符合。
  2. 忽略可選的里/村/鄰:像「桃園市中壢里8鄰中正路」這種地址,「中壢里8鄰」是可以省略的部分,第一輪比對失敗後,會再試著跳過這段文字重新比對。
  3. 子字串包含:路名不是輸入的前綴,但輸入的文字整段出現在某條路名稱裡面。
  4. 模糊比對:前面三種都沒有結果時,才透過字串相似度演算法找出最接近的候選道路。

每往下掉一個層級,比對到的結果分數就會跟著遞減——完整匹配的分數最高,模糊比對墊底。這個分數會一路帶到最後決定「要不要直接給答案」的那一步。

模糊比對用的是 Dice’s Coefficient:把兩個字串拆成一組組相鄰兩字的子字串(bigram),計算兩邊 bigram 集合的重疊程度。門檻設在 0.58——低於這個相似度就不列入候選,避免把明顯不像的路名也塞進建議清單;就算超過門檻,也只取相似度最高的前 8 筆,同分再依筆畫排序。這一層刻意設計成「打字錯誤的最後防線」,只有在完整匹配、忽略里村鄰、子字串包含都找不到結果時才會觸發,不會平常就把不夠精確的模糊結果混進來稀釋正確答案。

還有一個為了處理真實地名長出來的細節:像「隴西二巷」這種輸入,機器沒辦法單靠文字判斷它是「官方路名『隴西二巷』」還是「路名『隴西』加上 2 巷」。引擎的做法是:如果索引裡剛好有一條路,它的完整名稱原封不動地出現在輸入文字裡,就優先採信「這是完整官方路名」,而不是拆開來當成路名加巷弄解讀。

第三步:門牌號不是點,是區間

比對到道路之後,還有最後一關:門牌號落在哪個郵遞區號的範圍裡。中華郵政的門牌規則本質上是一段一段的區間,例如「1 號至 99 號單號」「100 號以上」,同一條路上不同區間會對應不同的郵遞區號。

門牌、巷、弄、樓層在引擎內部都用同一種資料結構表示:一個 [主號, 之號] 的區間端點,例如「10 之 2 號」就是 [10, 2],比較大小時先比主號、再比之號。有了統一的區間表示法,門牌比對就變成純粹的區間包含判斷。

真正決定「哪個結果比較準」的是 specificity(精確度)評分:區間越窄,分數越高。具體公式是拿區間寬度取 log2 再做遞減,寬度為 0(也就是精確指定單一門牌號的規則)直接給最高分;寬度越大,分數越低,但有下限,不會歸零。這個設計背後的邏輯很直接:如果某個門牌號同時符合「1 號至 999 號」這種整條路的大範圍規則,也符合「57 號」這種精確指定的規則,精確指定的規則永遠應該贏,因為它就是郵政資料特別為這個門牌號開的例外。

巷弄之間也有嚴格的互斥規則:查詢裡帶了巷號,就不能落回一條沒有巷弄範圍限制的主幹道規則(除非那條規則明確標示涵蓋整條路);反過來,一個純門牌號查詢,也不該誤配到一條專屬某條巷弄的規則。單雙號同樣要對得上——規則要求單號,查詢卻是雙號,直接判定不符合。

實際的政府資料比這個還要複雜一些:有些規則是「34 巷至 56 號」這種巷弄與門牌號混用單位的區間寫法,有些規則同時限制巷弄與其中的門牌號,還有些規則掛在特定機關或部門名稱下(查詢文字裡出現對應的部門名稱,會直接加大量分數)。這些都是為了貼合中華郵政實際資料長出來的規則,不是憑空設計的複雜度。

第四步:什麼時候該給答案,什麼時候該問清楚

比對完所有候選之後,引擎不會一律回傳分數最高的那一個。判斷邏輯是:只有分數達到 90 分以上、而且所有達標結果都收斂到同一個 6 碼郵遞區號時,才會回傳明確答案;如果同樣達到 90 分的結果對應到不只一個郵遞區號(例如同一棟大樓裡有多個機關各自的專用碼),就回傳候選清單,並標記這是一個有歧義的查詢,交給使用者自己選。

這個門檻是刻意設的:地址查詢跟一般的模糊搜尋不一樣,錯一碼寄件人可能就收不到。與其為了「每次都要有答案」硬選一個看起來最可能的結果,不如在真的不確定的時候誠實地說「有好幾種可能」。

讓查詢快到有感的資料設計

演算法之外,另一半的工程問題是怎麼讓這些比對在瀏覽器裡幾毫秒內跑完,尤其現在還要撐貼上幾十筆地址的整批查詢。

資料整體規模:目前索引涵蓋 24 個縣市、372 個行政區、44,658 筆道路資料,以及 7,816 個公開名稱(地標、學校、機關等)。如果每次進頁面都把這些資料整包下載,光是資料傳輸就會拖慢第一次查詢的速度。

解法是把資料拆成三層,按需要才載入

  • Tier 0:只有縣市與行政區清單,壓縮後約 2.8 KB,頁面第一次跟使用者互動就會載入。
  • Tier 1:各行政區底下的道路資料,壓縮後約 137 KB,只有查詢帶了門牌相關細節時才會載入。
  • Tier 2:地標與機關名稱索引,壓縮後約 83 KB,只有前面兩層都沒查到結果,或查詢本身就是名稱時才會載入。

這個分層設計是從舊版本一路演進來的——上一代索引是一個壓縮後約 250 KB 的單一檔案,不管查什麼都要整包下載。拆成三層之後,大部分只需要縣市/行政區資訊就能解決的查詢,第一次互動的關鍵資料量直接降到原本的百分之一左右。實測數字上,Tier 0 中位數載入時間約 0.12 毫秒,累計到 Tier 1 完成約 4.58 毫秒,全部三層都載完約 8.52 毫秒(數字會隨裝置效能與網路狀況浮動,但量級穩定在毫秒等級)。

要載哪幾層,由查詢內容決定,不是每次都全載。 如果輸入裡已經有門牌相關細節(門牌號、巷、弄、樓層),代表使用者很可能是在查一個具體地址,這時候會先跳過名稱索引、直接載入道路資料;等到地址比對真的失敗了,才退回去載入名稱索引當作最後防線。如果輸入完全沒有門牌細節(單純打了個地名或機關名稱),就會同時平行載入道路與名稱兩層,不特別預設使用者要查的是哪一種。

整批貼上多筆地址時,這些查詢是同時算的,不是排隊算的。 貼上幾十行地址後,畫面會先把每一行的位置鋪出來,接著同時對每一行跑上面整套比對邏輯,算完一筆就把那一列填上,不用等最慢的一筆算完才顯示任何東西。

一個原則貫穿始終:查詢不離開瀏覽器

不管是正規化、地理階層比對,還是門牌區間判斷,整套邏輯都是純函式、跑在使用者自己的瀏覽器裡,沒有任何一步需要把輸入的地址送到伺服器。這不只是隱私考量——它也是「整批查詢能快」的前提:三十筆地址如果要走三十次伺服器往返,速度不可能做到現在這樣;全部留在本機運算,才有機會把「貼上去」跟「看到答案」之間的時間壓到接近零。

這篇著重在演算法跟資料設計本身。如果想知道這套引擎背後的產品決策——為什麼批次查詢會是核心賣點——可以參考〈為什麼我做了「神速.郵遞區號」〉;只是想知道怎麼操作,可以直接看〈神速.郵遞區號怎麼用?從查一筆到貼上一整份清單〉。

← 回部落格看更多文章