繁體中文全文搜尋引擎實戰筆記(二):建置實戰與測試

閱讀偏好
繁體中文全文搜尋引擎實戰筆記(二):建置實戰與測試

繁體中文全文搜尋引擎實戰筆記(二):建置實戰與測試

系列第二篇。上一篇確認了分詞是繁體中文搜尋的核心痛點,這篇進入實作——四種互補解法的工程細節、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-twCKIP
分詞結果劉 / 彤 / 牧師 / 在 / 生命河 / 基金會劉彤 / 牧師 / 在 / 生命河基金會 / 的 / 宣教 / 事工
搜「劉彤」可能找不到(被拆成「劉」+「彤」)精確匹配
搜「生命河基金會」要看字典有沒有收錄自動辨識為組織名
需要維護字典不用

我用 CKIP Transformers 處理標題欄位,搭配自訂詞典做後處理合併。內容欄位太長不適合逐篇跑 CKIP,改用下面的 bigram 處理。

解法二:Character Bigram —— 被低估的 CJK 經典解法

不做分詞,直接把文字切成兩兩一組的字元組合:

生命河基金會 → 生命 / 命河 / 河基 / 基金 / 金會

搜尋時也做同樣的切分。因為 index 和 query 的 bigram 高度重疊,匹配率很高。

優點缺點
不需要字典會有假匹配(如「河基」匹配到不相關文章)
不怕新詞index 體積較大
實作極簡單短詞搜尋噪音多
天然高 recallprecision 較低

這不是什麼新發明——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 = 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 GB288 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. SparseMeilisearch keyword + bigram + CKIP 查詢分詞精確文字匹配
2. DenseBGE-M3 ONNX INT8 embedding語意理解
3. Multi-scale RRF文章 + 段落雙索引,4 路 multi-search,RRF(k=80) 合併段落級語意召回
4. Cross-encoder RerankBGE-reranker-v2-m3 ONNX INT8精排
5. Entity Scoring雙信號模型(硬篩 + coverage + 共現 + 別名)主詞過濾
6. Chunk-only RecoveryRRF 補回只在段落層級命中的文章窄域主題召回

LLM Fallback 鏈

為了控制成本,AI 問答部分先用了三個免費供應商做 fallback,以後若需求量大,再考慮付費模式:

順位ProviderModelTimeout
1NVIDIA Builddeepseek-v3.210s
2Groqllama-4-scout-17b15s
3Googlegemma-3-27b-it30s

測試數據:R1 到 R4

R1 → R2:516 篇到 1854 篇

13 組標準查詢的命中數對比:

查詢KW (R1, 516 篇)KW (R2, 1854 篇)Bigram (R2)合併 (R2)
宣教3321000050
青年牧區10236561467
婚姻家庭8136137756
聖靈充滿3261000100055
植堂43360044
敬拜讚美2641000100056

注:KW 命中數上限為 Meilisearch 預設回傳上限 1000,實際命中可能更高。

觀察:

  • Bigram 對多字詞持續有效:「青年牧區」bigram 614 > keyword 365
  • 2 字詞 bigram 無效:宣教、植堂等 2 字詞只產生 1 個 bigram,等同原詞
  • 三路合併數穩定:合併後精選結果維持在 40-80 篇,沒有因文章量暴增失控

CKIP vs Bigram 效果對比

查詢title_bigram (R2)title_ckip (R2)最佳
靈糧季刊791697bigram
遇見神營會213bigram
宣教視野118104bigram
禱告9494平手

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.62s0.97s
加速比~42x(見注)
模型大小~1.1 GB544 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 自動提取)672freq ≥ 2
ORG(NER 自動提取)208freq ≥ 2, len ≥ 3
LOCATION(NER 自動提取)191freq ≥ 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 MB570 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 INT8Qwen3-Reranker ONNX INT8 (YesNo)
10 docs rerank~3.8s~4.0s
20 docs reranktimeout/OOM~8s(推估)
per doc~0.38s~0.4s

速度幾乎一樣。但 BGE 版在多 index 搜尋(20+ docs)時因記憶體膨脹觸發 OOM,Qwen3 版穩定。

排序品質(Query: 「產假規定」)

ScoreDocument判斷
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-embedTextCrossEncoder("n24q02m/Qwen3-Reranker-0.6B-ONNX-YesNo")

結論:Qwen3-Reranker-0.6B 是 ARM64 CPU 上 BGE reranker 的上位替代——記憶體降 90%、速度持平、品質優秀。 如果你在資源受限的 CPU 環境跑 cross-encoder reranking,這是目前最好的選擇。


小結

這一輪建置的幾個關鍵收穫:

  1. Bigram 是繁中搜尋的最強手段 — 簡單、零依賴、高召回,多數情境下勝過 CKIP
  2. ONNX INT8 讓 Cross-encoder 在 ARM64 CPU 上可用 — 從 timeout 到 3.8 秒,42 倍加速
  3. 四種解法疊加比擇優更有效 — keyword、bigram、CKIP、vector 各有擅長
  4. 文章量 + 機制完整度是搜尋品質的兩大槓桿 — 516 到 1854 篇,Top-5 相關度 68% → 91%
  5. Ranking rules 的 exactness 優先是必做的設定 — 一行 config 改善體驗

下一篇:(三)進階優化與系統評估——Entity-Aware Scoring 的雙信號模型、Multi-scale RRF 段落搜尋、七輪測試的完整品質演進、Silent Fallback 的慘痛教訓,以及自建 RAG Pipeline 的誠實投入產出檢討。


參考資源

作者 Jacobmei:帶領街口支付對接國際巨頭 Apple,推動台灣金融科技國際化實踐。

← 回文章列表