用 LLM 解析非結構化租屋資料:Zwoo Pipeline 的設計與實作
深入解析如何建立一條結合本地 LLM、Geocoding 多重 Provider 容錯、以及 Prefect 排程的租屋資料管線,從 PTT 和 Facebook 社團蒐集非結構化貼文並轉化為結構化地圖資料。
前言
租屋資訊散落在各種非結構化的來源:PTT 的租屋板、Facebook 的地區租屋社團、甚至 LINE 群組。這些貼文的格式千變萬化——有的人用表格整齊列出資訊,有的人寫一大段文字,有的人只放照片配一句話。
要把這些資訊變成可搜尋、可在地圖上顯示的結構化資料,傳統的正規表達式(regex)方法幾乎不可能做到。這就是我們決定在 pipeline 中導入 LLM 的原因。
這篇文章會完整拆解 Zwoo 租屋平台的資料管線設計,包含架構決策、LLM 整合、Geocoding 策略,以及在生產環境中踩過的坑。
整體架構
[PTT 爬蟲] ──┐
├──→ [MongoDB 原始資料] ──→ [Pipeline] ──→ [MongoDB 結構化資料] ──→ [API/前端]
[FB 爬蟲] ──┘ │
├── LLM 解析
├── Geocoding
└── 去重 / 驗證
Pipeline 分為兩條獨立的 flow:
- geo_pipeline:處理 Facebook 社團貼文,每 12 小時執行(01:00, 13:00)
- ptt_pipeline:處理 PTT 租屋板貼文,每 12 小時執行(02:00, 14:00)
兩者邏輯相似但資料結構不同,所以分開處理。使用 Prefect 作為排程和執行框架。
為什麼選擇本地 LLM?
在設計 pipeline 的初期,我們面臨一個關鍵決策:用雲端 LLM API(如 OpenAI、Claude)還是自架本地 LLM?
成本計算
以我們的使用量為基準:
- 每天處理約 200-500 篇貼文
- 每篇貼文的 prompt + 回應約 800-1500 tokens
- 一個月約 9000-15000 次呼叫
如果使用 GPT-4o-mini:
- 約 $15-30/月(input 0.15/M + output 0.6/M tokens)
如果使用本地 LLM(20B 參數模型):
- 一次性硬體成本(GPU)+ 電費
- 月營運成本趨近於零
最終決策
我們選擇了本地部署,原因:
- 成本可控:長期來看本地部署更便宜
- 延遲更低:不需要網路來回,對並行處理特別有利
- 沒有速率限制:可以開多個 worker 平行處理
- 隱私:使用者的租屋資料不需要傳到第三方伺服器
使用 OpenAI 相容的 API 介面(透過 LM Studio 或 vLLM 部署),這樣如果之後要切換到雲端 API,只需要改 base_url 和 model 參數。
LLM 解析:把自然語言變成結構化 JSON
挑戰
租屋貼文的格式完全不統一。以下是幾個真實範例:
PTT 格式(相對結構化):
[房型] 獨立套房
[租金] 12000/月
[地址] 永和區安樂路
[押金] 兩個月
Facebook 格式(完全非結構化):
板橋雅房出租 三民路一段 公寓合租 只要6500/月
限女生 可養貓 歡迎約看
有意者私訊~
極簡格式:
整層住家出租 三重區大勇街 3房 28000 可租補可寵物
這些需要被統一解析成同一個結構:
class MetaData(BaseModel):
absolute_address: str | None = None # 完整地址
relative_address: list[str] = [] # 相對地標
gender_limit: Gender | None = None # 性別限制
total_rooms: int | None = None # 房間數
room_type: RoomType | None = None # 房型
pet_friendly: bool | None = None # 可否養寵物
price: int | None = None # 租金
geo_location: GeoLocation | None = None # 經緯度
Prompt 設計
我們使用 few-shot learning 的方式,在 system prompt 中提供清楚的分類定義和範例:
system_prompt = """請你將一篇租屋貼文轉成結構化 JSON,
貼文中沒有提到的資訊請不要自己亂填,謝謝!
房型分類說明(room_type 欄位):
- "suite":有獨立衛浴的房間
- "shared_room":沒有獨立衛浴、需共用衛浴的房間
- "other":整層住家、公寓出租等
- 如果完全沒提到房型相關資訊才填 null"""
加上 3 組 few-shot 範例,涵蓋套房、雅房、整層住家三種典型情境。
關鍵設計:JSON Schema 約束
為了確保 LLM 的輸出格式一致,我們使用 JSON Schema 來約束回應格式:
response_format = {
"type": "json_schema",
"json_schema": {
"name": "metadata",
"strict": True,
"schema": MetaData.model_json_schema(),
},
}
直接用 Pydantic Model 的 model_json_schema() 產生 schema,確保 LLM 的輸出可以被 MetaData.model_validate() 直接解析。這避免了手動處理 JSON 格式錯誤的麻煩。
社團名稱作為地區提示
一個重要的 insight:Facebook 租屋社團的名稱通常包含地區資訊(如「板橋租屋」、「中壢租屋社」)。我們把社團名稱作為額外的上下文提供給 LLM:
group_hint = f'這篇貼文來自社團「{group_name}」,
社團名稱通常包含地區資訊,請參考社團名稱來推斷地區。'
這大幅提升了地址解析的準確度——當貼文只寫「中正路 xxx 號」時,社團名稱能幫助 LLM 判斷是哪個城市的中正路。
Geocoding:多 Provider 容錯策略
拿到地址後,下一步是轉換成經緯度座標(Geocoding),才能在地圖上顯示。
為什麼需要多個 Provider?
單一 Geocoding 服務的問題:
- 免費額度限制:LocationIQ 每天 5000 次、Mapbox 每月 100000 次
- 台灣地址支援度不一:某些 provider 對「巷弄」地址解析較差
- 服務可用性:任何單一服務都可能暫時不可用
Waterfall 容錯設計
_PROVIDERS = [
('LocationIQ', _geocode_locationiq, LOCATIONIQ_API_KEY),
('Mapbox', _geocode_mapbox, MAPBOX_API_KEY),
]
def get_geocode(address: str) -> GeoLocation | None:
for name, fn, api_key in _PROVIDERS:
if not api_key:
continue
try:
result = fn(address)
if result:
return result
except Exception as e:
logger.warning(f'[{name}] Error: {e}, trying next')
return None
按優先順序嘗試,前面的失敗了自動 fallback 到下一個。這個設計讓我們:
- 平時用免費額度最多的 provider
- 額度用盡自動切換
- 一個 provider 掛了不影響整體運作
地址前處理:社團名稱補地區
台灣地址的一個常見問題:使用者只寫路名不寫城市。「復興南路一段」在台北和桃園都有。
我們建了一個社團名稱→地區的映射表,在 Geocoding 前自動補上行政區:
_SHORT_TO_FULL = {
'板橋': '新北市板橋區',
'中和': '新北市中和區',
'中壢': '桃園市中壢區',
# ... 100+ 組映射
}
def prepend_region(address: str, region: str) -> str:
"""若地址尚未包含市/縣/區層級資訊,在前面補上地區前綴。"""
if _HAS_ADMIN_REGION.search(address):
return address # 已經有行政區,不需要補
return region + address
這個看似簡單的處理,把 Geocoding 的成功率從約 65% 提升到 90% 以上。
Pipeline 執行流程
整個 pipeline 分為兩個 phase:
Phase 1:過濾與去重(快速、循序)
for post in facebook_posts:
# 1. 空內容過濾
if len(content) == 0:
continue
# 2. 賣屋文過濾(用 regex 快速判斷)
if is_sell_post(content):
continue
# 3. 重複文章過濾(by post_id)
if get_post_by_id(post.post_id):
continue
# 4. 重複圖片過濾(同一張首圖 = 重複貼文)
if get_post_by_image(first_image):
continue
# 5. 內容 hash 比對(相同內容 = 轉貼,重用 metadata)
existing_post = get_post_by_hash(sha256_hash)
if existing_post:
# 直接複用已解析的 metadata,省一次 LLM 呼叫
post_list.append(PostModel(..., metadata=existing_post.metadata))
else:
needs_llm.append((post, content, sha256_hash))
Phase 1 的目標是盡量減少需要 LLM 處理的數量。每次 LLM 呼叫大約需要 2-5 秒,如果能在 Phase 1 就過濾掉 60% 的文章,整體執行時間會大幅縮短。
Phase 2:LLM 解析 + Geocoding(並行)
with ThreadPoolExecutor(max_workers=LLM_WORKERS) as executor:
futures = {
executor.submit(process_single_post, post, content, sha256_hash): post.post_id
for post, content, sha256_hash in needs_llm
}
for future in as_completed(futures):
result = future.result()
if result:
post_list.append(result)
使用 ThreadPoolExecutor 並行處理多篇文章。LLM_WORKERS 預設為 3,這是根據本地 GPU 的算力調整的——開太多 worker 會導致每個請求變慢,反而降低總吞吐量。
為什麼用 Thread 而不是 asyncio?
因為瓶頸在 LLM 推理(CPU/GPU bound),不在 I/O。Thread 在這裡的作用是讓多個 LLM 請求可以同時排隊,而不是等一個完成再送下一個。
如果改用 asyncio,需要把所有的資料庫操作和 HTTP 請求都改成 async 版本,改動成本太高,而效能提升有限。
監控與通知
每次 pipeline 執行完畢,會發送 Discord 通知:
**[Pipeline] geo_pipeline 完成**
- 來源: 324 篇
- 需 LLM 處理: 87 篇
- 成功寫入: 156 篇
- LLM 失敗: 12 篇
- Geocode 失敗: 8 篇
- Geocode 失敗地址: 中正路xxx號, 民族路...
- 執行時間: 4m 32s
這讓我們可以快速掌握 pipeline 的健康狀況:
- LLM 失敗率突然上升 → 可能是 LLM 服務掛了
- Geocode 失敗率上升 → 可能是 API 額度用盡
- 來源數量異常少 → 可能是爬蟲端出問題
踩過的坑
坑 1:LLM 幻覺地址
LLM 偶爾會「發明」不存在的地址。例如貼文只說「近古亭站」,LLM 卻輸出 "absolute_address": "台北市大安區羅斯福路三段 xxx 號"。
解法:在 prompt 中強調「貼文中沒有提到的資訊請不要自己亂填」,並且用 Geocoding 結果間接驗證——如果地址太精確但 Geocoding 找不到,很可能是幻覺。
坑 2:價格解析混亂
「租金 8500 含水電」、「$12,000/月」、「一萬二」、「面議」——各種寫法 LLM 大多能正確解析,但偶爾會把押金誤判為租金。
解法:加入 Facebook 貼文的 meta.price 欄位作為 fallback:
if parsed_data.price is None and post.meta.price:
price_str = re.sub(r'[^\d]', '', post.meta.price)
if price_str:
parsed_data.price = int(price_str)
坑 3:重複貼文的多種形態
同一個房東可能在 5 個社團發相同的文。去重策略:
- post_id 去重:同一篇文不處理兩次
- content hash 去重:相同內容的文章,複用已有的 metadata
- 首圖 URL 去重:不同文字但相同照片的大概率是同一個物件
這三層去重讓最終資料的重複率從約 30% 降到 5% 以下。
坑 4:Geocoding 的「中正路問題」
全台灣有幾十條「中正路」。如果地址只寫「中正路 100 號」,Geocoding 服務可能回傳任何一個城市的結果。
解法:前面提到的「社團名稱→地區映射」機制。我們維護了一個 100+ 組的映射表,涵蓋台灣所有主要城市和行政區的常見簡稱。
效能數據
實際執行的效能數據(處理 300 篇 Facebook 貼文):
| 階段 | 時間 | 說明 | |------|------|------| | Phase 1(過濾去重) | 15 秒 | 主要是 MongoDB 查詢 | | Phase 2(LLM + Geocoding) | 3-5 分鐘 | 瓶頸在 LLM 推理 | | 寫入 MongoDB | 2 秒 | 批次 upsert | | 總計 | 4-6 分鐘 | |
Phase 1 過濾掉約 60-70% 的文章(重複、賣屋文、空內容),只有 80-120 篇需要 LLM 處理。3 個 worker 並行,每篇約 3 秒,總共約 2-3 分鐘。
結語
用 LLM 解析非結構化資料是一個強大但需要謹慎使用的工具。它不是銀彈——你仍然需要:
- 良好的 prompt 設計和 few-shot 範例
- 前處理邏輯來補強 LLM 的輸出(如社團名稱→地區映射)
- 多層驗證和 fallback 機制
- 去重策略避免浪費 LLM 算力
但當你面對的是「格式完全不統一的人類語言」時,LLM 提供了 regex 和規則引擎無法比擬的靈活性。關鍵在於把 LLM 放在正確的位置——用它做它最擅長的事(理解語意),用傳統工具做它們最擅長的事(精確匹配、資料驗證)。
關於作者

GWP4 STUDIO 創辦人,超過 8 年軟體開發經驗,專精資料工程、全端開發與系統架構。 持續在部落格分享專案實作經驗與技術心得。