繁體中文全文搜尋引擎實戰筆記(二):建置實戰與測試
繁體中文全文搜尋引擎實戰筆記(二):建置實戰與測試
系列第二篇。上一篇確認了分詞是繁體中文搜尋的核心痛點,這篇進入實作——四種互補解法的工程細節、ONNX Runtime 在 ARM64 CPU 上的加速實測、六層 RAG Pipeline 的完整架構,以及從 516 篇到 1854 篇的品質演進數據。所有數據都來自實際測試,不是理論推導。
測試環境:Oracle Cloud ARM64 Ampere A1 (4 OCPU, 24GB RAM), Meilisearch 1.x, ONNX Runtime BGE-M3 INT8
四種解法實作
確認分詞是核心痛點後,依據實測,我最後是全部疊加 —— 每種手段有不同的強項,互補比擇優更有效,但時間也會拖長一點。
繁體中文搜尋預處理工具-trad-zh-search-開源
假如有人想試試我的方案,我已經將它打包可以單獨搭配的套件,有興趣的人可以試試:
參考文章: 繁體中文搜尋預處理工具-trad-zh-search-開源
GitHub: notoriouslab/trad-zh-search
解法一:CKIP 前置分詞
架構很直覺:匯入時先用 CKIP 分詞,存入獨立欄位;搜尋時同步對 query 分詞。
以「劉彤牧師在生命河基金會的宣教事工」為例,jieba 和 CKIP 的差異立刻可見:
| jieba-tw | CKIP | |
|---|---|---|
| 分詞結果 | 劉 / 彤 / 牧師 / 在 / 生命河 / 基金會 | 劉彤 / 牧師 / 在 / 生命河基金會 / 的 / 宣教 / 事工 |
| 搜「劉彤」 | 可能找不到(被拆成「劉」+「彤」) | 精確匹配 |
| 搜「生命河基金會」 | 要看字典有沒有收錄 | 自動辨識為組織名 |
| 需要維護字典 | 要 | 不用 |
我用 CKIP Transformers 處理標題欄位,搭配自訂詞典做後處理合併。內容欄位太長不適合逐篇跑 CKIP,改用下面的 bigram 處理。
解法二:Character Bigram —— 被低估的 CJK 經典解法
不做分詞,直接把文字切成兩兩一組的字元組合:
生命河基金會 → 生命 / 命河 / 河基 / 基金 / 金會
搜尋時也做同樣的切分。因為 index 和 query 的 bigram 高度重疊,匹配率很高。
| 優點 | 缺點 |
|---|---|
| 不需要字典 | 會有假匹配(如「河基」匹配到不相關文章) |
| 不怕新詞 | index 體積較大 |
| 實作極簡單 | 短詞搜尋噪音多 |
| 天然高 recall | precision 較低 |
這不是什麼新發明——Elasticsearch 的 CJK analyzer、Manticore Search、Sphinx、PostgreSQL pg_trgm 底層都是類似的 ngram 思路。只是在 Meilisearch 的生態圈裡很少人提。
做法是匯入時預先算好 bigram,存入 title_bigram 和 content_bigram 欄位,Meilisearch 把空格分隔的 bigram 當成英文單詞來匹配,小心繞過中文分詞問題。
def to_bigrams(text: str) -> str:
chars = [c for c in text if not c.isspace()]
return " ".join(chars[i:i+2] for i in range(len(chars)-1))
後來的實測證明:bigram 是繁中搜尋召回率最強的手段,多數情境下勝過 CKIP。
解法三:Hybrid Search
Hybrid search = keyword search + vector search,用權重參數(semanticRatio)調配比例。
搜尋 "生命河基金會"
↓
├── Keyword Search:jieba 分詞 → 倒排索引(受分詞品質影響)
└── Vector Search:BGE-M3 embedding → 向量空間找語意相近文章(不受分詞影響)
↓
合併結果,依權重排序
向量搜尋完全繞過分詞問題,因為 embedding model 把整段文字映射到高維向量空間,模型本身理解「生命河基金會」是一個完整概念。
解法四:三路合併搜尋(Multi-Search Merge)
最終方案不是擇一,小孩子才做選擇,成熟且幼稚的大人是全部疊加 XD
使用者輸入 "宣教植堂"
↓
前端同時發出三路查詢(Meilisearch /multi-search API):
├── Q1: keyword-only → 搜 title, content, tags
├── Q2: bigram → 搜 title_bigram, content_bigram, title_ckip
└── Q3: hybrid → keyword + vector 混合搜尋
↓
合併:同一篇文章取最高分,依分數排序
再加上同義詞擴展(Meilisearch synonyms API),把專業術語的同義詞互相對應,keyword 搜尋自動擴展。
Ranking Rules 調整
一個小但關鍵的設定:把 exactness 提到排序規則的最前面。
["exactness", "words", "typo", "proximity", "attribute", "sort"]
Meilisearch 預設優先考慮 typo tolerance 和 proximity,但中文搜尋的痛點是分詞造成的「什麼都沾到邊」,完全匹配優先排前面,一行設定就能改善體驗。
ONNX Runtime 加速
為什麼 ONNX 對 Embedding 特別有效
Embedding 模型(BGE-M3) LLM 生成模型(Qwen3, Llama)
├── Encoder-only ├── Decoder-only (autoregressive)
├── 一次前向傳播 ├── 逐 token 生成(數百~數千次)
├── 瓶頸:計算 (compute) ├── 瓶頸:記憶體頻寬 (memory)
└── ONNX 加速:顯著(我們實測 5x) └── ONNX 加速:有限(10-30%)
ONNX Runtime 的核心優化(算子融合、記憶體排程、INT8 量化)對 encoder 模型效果拔群,但對 LLM 的逐 token 生成幫助有限。
實測效能
| 指標 | Ollama (llama.cpp) | ONNX Runtime INT8 |
|---|---|---|
| 516 篇匯入時間 | ~3.5 小時 | 43 分鐘(5x 提速) |
| 1854 篇匯入時間 | — | 2 小時 45 分鐘 |
| 記憶體佔用 | ~1 GB | 288 MB(70% 減少) |
| 每篇平均 | ~25 秒 | ~5 秒 |
線性擴展良好,從 516 篇到 1854 篇,每篇平均時間從 5 秒微增到 5.4 秒。
Embedding Server 架構
寫了一個 Flask embedding server,模擬 Ollama 的 /api/embed API,讓 Meilisearch 只需改 port 就能從 Ollama 切換到 ONNX:
Meilisearch → embedder url: http://127.0.0.1:11435/api/embed
↓
embed_server.py (ONNX BGE-M3 INT8)
→ systemd service, port 11435
→ 288 MB RAM, ARM64 NEON 加速
結論:ONNX 是 embedding / 分類 / NER 的首選推理引擎,LLM 生成仍然靠 llama.cpp 或打 API。
六層 RAG Pipeline 全貌
最終架構演進到六層:
| 層 | 技術 | 作用 |
|---|---|---|
| 1. Sparse | Meilisearch keyword + bigram + CKIP 查詢分詞 | 精確文字匹配 |
| 2. Dense | BGE-M3 ONNX INT8 embedding | 語意理解 |
| 3. Multi-scale RRF | 文章 + 段落雙索引,4 路 multi-search,RRF(k=80) 合併 | 段落級語意召回 |
| 4. Cross-encoder Rerank | BGE-reranker-v2-m3 ONNX INT8 | 精排 |
| 5. Entity Scoring | 雙信號模型(硬篩 + coverage + 共現 + 別名) | 主詞過濾 |
| 6. Chunk-only Recovery | RRF 補回只在段落層級命中的文章 | 窄域主題召回 |
LLM Fallback 鏈
為了控制成本,AI 問答部分先用了三個免費供應商做 fallback,以後若需求量大,再考慮付費模式:
| 順位 | Provider | Model | Timeout |
|---|---|---|---|
| 1 | NVIDIA Build | deepseek-v3.2 | 10s |
| 2 | Groq | llama-4-scout-17b | 15s |
| 3 | gemma-3-27b-it | 30s |
測試數據:R1 到 R4
R1 → R2:516 篇到 1854 篇
13 組標準查詢的命中數對比:
| 查詢 | KW (R1, 516 篇) | KW (R2, 1854 篇) | Bigram (R2) | 合併 (R2) |
|---|---|---|---|---|
| 宣教 | 332 | 1000 | 0 | 50 |
| 青年牧區 | 102 | 365 | 614 | 67 |
| 婚姻家庭 | 81 | 361 | 377 | 56 |
| 聖靈充滿 | 326 | 1000 | 1000 | 55 |
| 植堂 | 43 | 360 | 0 | 44 |
| 敬拜讚美 | 264 | 1000 | 1000 | 56 |
注:KW 命中數上限為 Meilisearch 預設回傳上限 1000,實際命中可能更高。
觀察:
- Bigram 對多字詞持續有效:「青年牧區」bigram 614 > keyword 365
- 2 字詞 bigram 無效:宣教、植堂等 2 字詞只產生 1 個 bigram,等同原詞
- 三路合併數穩定:合併後精選結果維持在 40-80 篇,沒有因文章量暴增失控
CKIP vs Bigram 效果對比
| 查詢 | title_bigram (R2) | title_ckip (R2) | 最佳 |
|---|---|---|---|
| 靈糧季刊 | 791 | 697 | bigram |
| 遇見神營會 | 21 | 3 | bigram |
| 宣教視野 | 118 | 104 | bigram |
| 禱告 | 94 | 94 | 平手 |
Bigram 是召回率冠軍,尤其多字詞場景。CKIP 在語料量大時開始追上,但 bigram 仍然領先。
Top-5 相關度演進
| R1(516 篇) | R2(1854 篇) | |
|---|---|---|
| Top-5 高度相關 | 68% | 91% |
| 部分相關 | 28% | 9% |
| 不相關 | 4% | 0% |
後記:R1 的 68% 事後發現有程式瑕疵——hybrid search 參數未正確送出,三路合併未實作。所以 R1→R2 的提升實際來自三個因素:文章量增加、hybrid search 修復、以及 multi-search merge 的加入,並非純粹是文章量的功勞。
R3:Cross-encoder Reranking
ONNX INT8 Reranker 效能
| 指標 | PyTorch(原始) | ONNX INT8 |
|---|---|---|
| 20 passages 推論 | 40.62s | 0.97s |
| 加速比 | — | ~42x(見注) |
| 模型大小 | ~1.1 GB | 544 MB |
| 實際 10 篇文章 | timeout | ~3.8s |
注:PyTorch 基準測試未控制
torch.no_grad()+model.eval()+ batch processing,ARM64 上 PyTorch 也缺乏 MKL-DNN 等 CPU 優化,這些因素可能放大了差距。ONNX INT8 的速度優勢在 ARM64 CPU 上仍然非常顯著(至少 10-20x)。
這個加速是讓 cross-encoder reranking 從「理論上可行」變成「實際可用」的關鍵——PyTorch 直接 timeout,ONNX INT8 跑完 10 篇只要 3.8 秒。
CKIP 查詢端分詞
用 CKIP NER 自動從 1854 篇文章提取專有名詞,建立自訂字典:
| 來源 | 數量 | 門檻 |
|---|---|---|
| PERSON(NER 自動提取) | 672 | freq ≥ 2 |
| ORG(NER 自動提取) | 208 | freq ≥ 2, len ≥ 3 |
| LOCATION(NER 自動提取) | 191 | freq ≥ 5 |
| 手動專有名詞 | 41 | — |
| 合計 | 1098 | — |
NER 提取跑完 1854 篇文章約 67 分鐘,產出 2344 人名、773 組織、749 地點。自訂字典確保搜尋時人名和機構名不被 jieba 切壞。
R4:全量分詞與整合驗收
content_ckip 全量完成:1,448 篇處理了 160 分鐘,覆蓋率 100%。搜尋上限從 20 篇提升到 80 篇,前端加入分頁(每 15 篇 + 載入更多)。
整合測試結果
| 測試項目 | 結果 |
|---|---|
| 精確人名搜尋 | 80 篇(之前上限 20) |
| 混合搜尋合併 | keyword 50 + semantic 50 = 86 篇(去重有效) |
| Entity 硬篩 | 50 篇 raw → 19 篇 filtered(去除 62% 假陽性) |
系統資源總覽
| 元件 | RAM | 推論速度 |
|---|---|---|
| Embedding server (BGE-M3 ONNX INT8) | 288 MB | ~5s/doc |
| Reranker (BGE-reranker-v2-m3 ONNX INT8) | 544 MB | ~3.8s/10 docs |
| CKIP 分詞 (bert-base-chinese-ws) | ~400 MB | ~0.1s/query |
| Entity scoring(字典匹配) | ~1 MB | <1ms |
| Meilisearch | ~300 MB | <50ms/query |
| 合計 | ~1.5 GB | — |
Oracle ARM64(4 core, 24 GB RAM)上全部同時運行無壓力,還剩 22+ GB 可用。
2026-03-28 更新:Qwen3-Reranker-0.6B 實測——BGE Reranker 的替代方案
BGE-reranker-v2-m3 在加上 --reranker 啟動時佔用 ~10GB RAM(模型本體 544MB + ONNX runtime 記憶體膨脹),在 24GB 的 Oracle 上跑大量 embedding 時會觸發 OOM killer。實測發現 Qwen3-Reranker-0.6B 是可行的替代。
記憶體比較:
| Reranker | 載入 RAM | 模型大小 |
|---|---|---|
| BGE-reranker-v2-m3 ONNX INT8 | ~10 GB(含 runtime) | 544 MB |
| Qwen3-Reranker-0.6B ONNX INT8 (YesNo) | ~960 MB | 570 MB |
Qwen3 版記憶體只需 1/10,根因是 BGE reranker 的 ONNX runtime 在 ARM64 上有嚴重的記憶體膨脹問題,而 Qwen3 的 YesNo 變體(只輸出 yes/no 兩個 logit,不需要完整 vocab)大幅降低了 runtime 記憶體。
速度比較(Oracle ARM64, 4 core, 1 thread):
| 指標 | BGE-reranker ONNX INT8 | Qwen3-Reranker ONNX INT8 (YesNo) |
|---|---|---|
| 10 docs rerank | ~3.8s | ~4.0s |
| 20 docs rerank | timeout/OOM | ~8s(推估) |
| per doc | ~0.38s | ~0.4s |
速度幾乎一樣。但 BGE 版在多 index 搜尋(20+ docs)時因記憶體膨脹觸發 OOM,Qwen3 版穩定。
排序品質(Query: 「產假規定」):
| Score | Document | 判斷 |
|---|---|---|
| 0.5940 | 勞基法第50條:產假八星期 | 正確最相關 |
| 0.3172 | 內部規章:產假相關規定 | 正確 |
| 0.2300 | 性別平等工作法:產假八星期 | 正確 |
| 0.0918 | 育嬰留職停薪實施辦法 | 邊緣相關,合理 |
| 0.0573 | 資產管理辦法 | 正確不相關 |
| 0.0006 | 財務報告 | 正確最不相關 |
區分度極好:最相關 0.594 vs 最不相關 0.0006,差距近 1000 倍。「資產管理辦法」被正確壓到 0.057,不再因為「產」字匹配而排在前面。
使用方式:透過 qwen3-embed 庫,pip install qwen3-embed,TextCrossEncoder("n24q02m/Qwen3-Reranker-0.6B-ONNX-YesNo")。
結論:Qwen3-Reranker-0.6B 是 ARM64 CPU 上 BGE reranker 的上位替代——記憶體降 90%、速度持平、品質優秀。 如果你在資源受限的 CPU 環境跑 cross-encoder reranking,這是目前最好的選擇。
小結
這一輪建置的幾個關鍵收穫:
- Bigram 是繁中搜尋的最強手段 — 簡單、零依賴、高召回,多數情境下勝過 CKIP
- ONNX INT8 讓 Cross-encoder 在 ARM64 CPU 上可用 — 從 timeout 到 3.8 秒,42 倍加速
- 四種解法疊加比擇優更有效 — keyword、bigram、CKIP、vector 各有擅長
- 文章量 + 機制完整度是搜尋品質的兩大槓桿 — 516 到 1854 篇,Top-5 相關度 68% → 91%
- Ranking rules 的 exactness 優先是必做的設定 — 一行 config 改善體驗
下一篇:(三)進階優化與系統評估——Entity-Aware Scoring 的雙信號模型、Multi-scale RRF 段落搜尋、七輪測試的完整品質演進、Silent Fallback 的慘痛教訓,以及自建 RAG Pipeline 的誠實投入產出檢討。