---
title: "繁體中文全文搜尋引擎實戰筆記（二）：建置實戰與測試"
description: "上一篇確認了分詞是繁體中文搜尋的核心痛點，這篇進入實作——四種互補解法的工程細節、ONNX Runtime 在 ARM64 CPU 上的加速實測、六層 RAG Pipeline 的完整架構，以及從 516 篇到 1854 篇的品質演進數據。所有數據都來自實際測試，不是理論推導。"
pubDate: 2026-03-20
author: "jacobmei"
category: "AI與科技"
tags: [資料庫, 教會, 聖經]
canonical: https://jacobmei.com/blog/2026/0320-1kx9dt/
lang: zh-TW
license: CC BY-NC 4.0
---

# 繁體中文全文搜尋引擎實戰筆記（二）：建置實戰與測試

# 繁體中文全文搜尋引擎實戰筆記（二）：建置實戰與測試

> 系列第二篇。上一篇確認了分詞是繁體中文搜尋的核心痛點，這篇進入實作——四種互補解法的工程細節、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-開源](https://jacobmei.com/blog/2026/0323-ygw9rr/)

**GitHub**: [notoriouslab/trad-zh-search](https://github.com/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 當成英文單詞來匹配，小心繞過中文分詞問題。

```python
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` 提到排序規則的最前面。

```json
["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   | Google       | 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](https://github.com/n24q02m/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，這是目前最好的選擇。

---

## 小結

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

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 的誠實投入產出檢討。

---

## 參考資源

- [ONNX Runtime](https://onnxruntime.ai/)
- [BGE-M3 (HuggingFace)](https://huggingface.co/BAAI/bge-m3)
- [BAAI/bge-reranker-v2-m3](https://huggingface.co/BAAI/bge-reranker-v2-m3)
- [CKIP Transformers](https://github.com/ckiplab/ckip-transformers)
- [Meilisearch multi-search API](https://www.meilisearch.com/docs/reference/api/multi_search)
