繁體中文全文搜尋引擎實戰筆記(三):進階優化與系統評估
繁體中文全文搜尋引擎實戰筆記(三):進階優化與系統評估
系列最後一篇。前兩篇記錄了選型決策和基礎建置,這篇進入進階優化 —— Entity-Aware Scoring 如何消除「主角不在」的假陽性、Multi-scale RRF 如何用段落搜尋補回遺漏文章、七輪測試的完整品質演進,以及一段誠實的投入產出檢討:哪些做對了、哪些做過頭了、哪些至今無法量化。
Entity-Aware Scoring:解決「主角不在」
繁體中文搜尋預處理工具-trad-zh-search-開源
假如有人想試試我的方案,我已經將它打包可以單獨搭配的套件,有興趣的人可以試試:
參考文章: 繁體中文搜尋預處理工具-trad-zh-search-開源
GitHub: notoriouslab/trad-zh-search
問題
搜尋「某人在某組織的事工」時,Meilisearch 的 ranking score 不知道誰是主角——只要文章提到該組織(遍地都是),分數就可能很高,即使文章根本沒提到那個人。
這不是 Meilisearch 的 bug,而是所有基於 TF-IDF / BM25 的搜尋引擎的通病:它們不理解查詢中的實體角色。
理論基礎
參考 DREQ(Edinburgh, ECIR 2024)和 REGENT(Missouri, 2025)兩篇 Entity-Centric Re-ranking 論文。核心觀點:不是所有實體對文件相關性的影響都一樣大,應強調查詢相關的實體,弱化不相關的。
雙信號模型
final_score = α × relevance + (1 - α) × entity_coverage
- relevance:Meilisearch
_rankingScore(0~1) - entity_coverage:實體在文章中的出現程度(0~1)
- α = 0.5(兩信號等權),查詢無實體時 α = 1.0(不介入)
Entity Coverage 計算
用 CKIP NER 字典匹配查詢中的實體,分兩級:
| 角色 | 判定 | 權重 |
|---|---|---|
| 主詞 | 查詢中第一個 PERSON/ORG/LOC | 1.0 |
| 配角 | 其餘實體 | 0.3 |
每個實體的 match signal:出現在標題 = 1.0,出現在內容 = 0.6,完全不在 = 0.0。
entity_coverage = Σ(entity_weight × match_signal) / Σ(entity_weight)
關鍵規則:
- 硬篩:主詞 match_signal = 0 → 直接踢掉(絕不出現主角不在的假陽性)
- 共現加權:主詞 + 配角在同一段落共現 → coverage +0.15
實體別名展開
建立靜態別名表,搜尋時自動展開。從最初的 25 組擴充到 136 組(涵蓋各種暱稱、簡稱、中文譯名變體)。成本極低但對人名搜尋的召回率提升極大。
測試結果
人名搜尋(別名展開):搜尋暱稱時,系統自動展開為全名。10/10 結果全部正確,100% 精準。
雙實體查詢(Entity Scoring 前後對比):
| 改善前 | 改善後 | |
|---|---|---|
| 不含主角的文章 | 74 分排前列 | 硬篩踢掉 |
| 結果品質 | 混雜不相關文章 | 只保留提及主角的文章 |
| 50 篇 raw → filtered | — | 19 篇(去除 62% 假陽性) |
無實體查詢(如「禱告的方法」):Entity scoring 不介入,α = 1.0 純用 Meilisearch 分數,結果和改善前完全相同。不會誤傷。
Chunking 與 Multi-scale RRF
為什麼需要 Chunking
一篇 3000 字的文章如果只有一個 embedding 向量,語意會被「稀釋」——同時提到宣教、財務、禱告的文章,向量變成三個主題的平均值,搜哪個主題都「有點像但不太像」。
把文章切成段落(chunk),每段各自 embedding,搜尋時匹配段落而非整篇文章,精度提升。
我選了 Paragraph-Based chunking:文章有明確的 \n\n 段落分隔、速度快、語意完整性遠高於固定大小切分、不需要額外 NLP 工具。
實作數據
| 指標 | 數值 |
|---|---|
| 文章數 | 7,647 |
| Chunk 數 | 59,409(avg ~303 chars/chunk) |
| Embedder | userProvided(Python 預算,不經 Meilisearch 呼叫 embed-server) |
架構教訓:Meilisearch 的
ollamaembedder source 對大量 chunk 有不可接受的 timeout 風險(633 tasks 全 timeout)。改用userProvided+ Python batch 預算完全消除此問題。如果你要建大量 chunk 的 index,直接用 userProvided。
Multi-scale RRF:文章 + 段落混合搜尋
原本:articles-kw + articles-semantic → merge → rerank → entity scoring
升級:articles-kw + articles-semantic ← 文章層級信號
+ chunks-kw + chunks-semantic ← 段落層級信號(新增)
↓ RRF(k=60) ↓
rerank → entity scoring
RRF(Reciprocal Rank Fusion)把四路信號的排名合併:RRF(d) = 1/(k + rank(d)),k=60 是平滑常數。
實際效果
RRF merge: 34 article-signal + 23 chunk-signal → 17 candidates (chunk-only: 12)
Entity scoring: ['某組織(subject)'] → 10 articles survived
chunk-only: 12 代表有 12 篇文章只透過段落搜尋發現,文章層級搜尋完全遺漏。每次查詢大約有 10-15 篇這樣被補回。
Trade-off
段落命中但文章主題偏離的案例確實存在——某個段落提到「差傳」但整篇文章其實在講別的。緩解方案是調高 RRF_K(60 → 80),降低 chunk-only 信號的權重。
R5 到 R7 品質驗收
R5:Multi-scale RRF 上線
| 查詢 | R4 KW | R5 KW | 變化 |
|---|---|---|---|
| 植堂 | 360 | 484 | +34% |
| 社會關懷 | 557 | 762 | +37% |
窄域主題改善最明顯。段落搜尋命中了文章搜尋遺漏的結果。
R6:跨來源知識庫(5,376 篇)
從 1,848 篇擴充到 5,376 篇。
| 查詢 | R5 KW | R6 KW | 變化 |
|---|---|---|---|
| 植堂 | 484 | 819 | +69% |
| 社會關懷 | 762 | 1000 | 達上限 |
Top-5 相關度維持 ~95%,新來源完全可搜,既有結果未被取代。
R7:7,647 篇 + 80 題 Benchmark
最終版本的知識庫包含 7,647 篇文章、59,409 個 chunks。用 80 題涵蓋 16 個主題分類的標準問題集測試,對比新來源匯入前後:
| 指標 | 數值 |
|---|---|
| Top-1 不變 | 48/80(60%) |
| Top-1 改善 | 32/80(40%) |
教義辨析類問題改善最大 —— 這正是新來源的強項。48/80 題 Top-1 不變,表示既有來源的優勢位置未被取代,新舊來源互補而非互搶。
Silent Fallback:最慘痛的教訓
R7 測試過程中,經過逐一確認六層 pipeline 的實際運行狀態,結果發現一個悲劇 …
R3-R6 期間多個機制存在 silent fallback !!!!
| 機制 | 問題 |
|---|---|
| Cross-encoder Reranking | embed-server 的 /api/rerank endpoint 未就緒,try-except 靜默退回原始排序 |
| Entity Scoring | 模組載入失敗時靜默退回純 relevance 排序 |
| Multi-scale RRF | chunks index 為空時直接退回 article-only 排序 |
| Hybrid Search(R1) | 前端未正確送出 hybrid 參數,只有 keyword search 在跑 |
根本原因:所有進階機制都採用 try-except + fallback 的防禦性設計,錯誤只寫 log.warning,不中斷服務,這在生產環境是正確的(graceful degradation),但開發期間導致「看似啟用但實際未運作」—— 我以為在調 reranking 權重,實際上 reranking 根本沒跑。
經驗教訓:pipeline 的每一層在首次部署後,必須有明確的 smoke test —— 檢查 log 中是否出現特定標記(如 “Reranked N → M articles”),而非依賴「沒有報錯 = 正常運作」的假設。之前有使用 QA 導入開發比較沒有這種問題,這次跑的太快,忘了嚴守自己的紀律,加上這陣子太信任 AI 開發(我主要用 Claude,同時使用 Gemini , Chatgpt 等同步 Debug ),結果就是自己挖坑自己跳 …
如果你也在建多層 pipeline,請在每一層加一個 health check ,Silent fallback 是生產環境的好設計,但一個沒注意,它會讓你浪費大量時間調整一個根本沒在跑的機制。
首頁定案:單一入口設計
前端原本提供三種搜尋模式(關鍵字 / 混合 / 語意)和兩種 AI 模式,但要理解每種模式的差異才能有效搜尋,實際使用後發現太複雜——大多數人只想輸入問題、找到文章。
定案:移除搜尋模式切換(固定 hybrid search)、移除 AI 模式切換、移除多輪對話 UI,保留標籤/年份篩選、收藏功能,先完全採用內建的 rag 語意搜尋,結果出來後再補上 AI LLM 彙整,有點像是 Notebooklm 的呈現方式,自己覺得效果不錯。
單一入口不代表功能縮水,而是把複雜度從使用者端移到後端。
誠實的投入產出檢討
回頭看,三個進階優化的投入產出比各不相同。
1. CKIP 全量跑 content —— 做過頭了
R2 的數據已經清楚顯示:bigram 在召回率上一直贏或持平。但我仍然花了 160 分鐘跑 content_ckip 全量。CKIP 的真正價值在查詢端分詞(人名/機構名不被切壞)和 NER 字典產生,不在 content 欄位的全量分詞,content_bigram 已經夠用。
如果重來,content 可能只跑 bigram 就夠用。
2. Cross-encoder Reranker —— 價值在排序精度,但目前無法量化
R3-R6 reranker 因 silent fallback 沒在跑,Top-5 相關度也沒掉回 91%,但這個推論有盲點 —— 同時期 Entity Scoring 也可能沒跑,三個變因無法分離。
更根本的問題是 Top-5 相關度太粗:它不區分「前 5 篇都相關但順序亂」和「最好的排第一」。Reranker 的價值在排序精度 —— RAG 場景中 LLM 只看 top-5,第一名放對放錯直接影響回答品質,需要 Top-1 精準度或 MRR 才能公平評估,不能從「沒跑也沒掉」就否定它。
2026-03-28 更新:Reranker 問題已解決。BGE-reranker-v2-m3 在 ARM64 上佔用 ~10GB RAM 導致 OOM,替換為 Qwen3-Reranker-0.6B ONNX INT8(YesNo 變體),記憶體降至 960MB,速度持平(~4s/10 docs),排序品質優秀(區分度:最相關 0.594 vs 最不相關 0.0006)。詳見第二篇更新。Cross-encoder reranking 從「理論有價值但實際跑不動」變成「穩定可用」。
3. Multi-scale RRF —— 長尾有價值,噪音有解法
Top-5 相關度跟沒加 RRF 時一樣 ~95%,但 RRF 的價值不在通用查詢的 Top-5,而在窄域主題。「小組化是什麼」直接段落命中、「以色列宣教」精準引用——這些是 article-only 搜尋找不到的。
噪音問題真實存在,但有已知解法(RRF_K 60→80 降低 chunk-only 權重)。建置時間成本不低(59,409 chunks 的 embedding + patch 機制),但對 RAG 問答需要精準引用段落的場景,這是必要的基礎設施。
總結:一個確認做過頭,兩個需要更好的指標來評估。
自建系統 vs NotebookLM
建置完成後回頭看 —— 這麼 ㄍㄟ ㄍㄨㄥ 搞這套系統到底是為什麼?什麼情境用 NotebookLM 反而更好?
| 面向 | 自建 RAG Pipeline | NotebookLM |
|---|---|---|
| 查詢品質 | 高度客製化,六層 pipeline | Google 原生多語言理解,黑箱 |
| 維護成本 | 高:模型、服務、排程全自己管 | 零:Google 維護 |
| 對外開放 | 可做公開搜尋 URL | 不可對外,個人使用限制 或共用模式 |
| 資料隱私 | 資料在自己的伺服器 | 上傳 Google |
| 擴充彈性 | 任意自訂 | source/notebook 上限 可透過 api 控制部份功能 |
自建的核心優勢:充分彈性 是 NotebookLM 無法取代的唯一場景,從介面到每個使用細節都可以自己刻想要的方式。
六層 pipeline、CKIP 繁中分詞、entity scoring —— 市面上 99% 套裝方案不會為你處理這些(可能也不需要就是了)。
NotebookLM 的定位:個人備課、快速問答 —— 上傳幾十個檔案,維護成本趨近於零,Audio Overview 還能轉 Podcast。
建議路徑:兩者並存,自建系統高客製化,NotebookLM 補足更深入的互動對話。
七輪演進總覽
| 項目 | R1 (516) | R2 (1854) | R3 (+RAG) | R4 (全量) | R5 (+RRF) | R6 (5376) | R7 (7647) |
|---|---|---|---|---|---|---|---|
| Bigram | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| CKIP | title 有限 | 追上 bigram | 查詢端 | content 全量 | ✅ | 字典 1208 條 | ✅ |
| Reranker | — | — | 加入(※) | ※ | ※ | ※ | ✅ 確認 |
| Entity | — | — | 加入(※) | ※ | ※ | ※ | ✅ 確認 |
| RRF | — | — | — | — | 加入(※) | ※ | ✅ 確認 |
| Hybrid | ❌ 參數未送 | ✅ 修復 | ✅ | ✅ | ✅ | ✅ | ✅ |
| 總評 | 機制缺失 | 優秀 | 卓越 | 卓越 | 卓越 | 卓越 | 全機制確認 |
※ = 程式碼已部署但有 silent fallback,可能未實際運作。R7 是第一次確認全層級正常運行的 benchmark,還是要說,實在笨慘了 …
關鍵發現
- 語料量 + 機制完整度是搜尋品質的兩大槓桿 — 兩者同時提升時效果最顯著
- ONNX INT8 讓 Cross-encoder 在 ARM64 CPU 上可用 — 從 timeout 到 3.8 秒
- Entity-Aware Scoring 消除了「主角不在」的假陽性 — 雙信號模型 + 硬篩是關鍵
- 實體別名表小成本大效果 — 136 組靜態對照,成本趨近零
- 三供應商 LLM fallback 確保可用性 — 單一免費供應商不夠穩定
- userProvided embedder 是批次建 index 的正確架構 — Meilisearch ollama source 對大量 chunk 不可靠
- Silent fallback 是開發期間的隱形殺手 — 每一層都需要 smoke test
- 做過頭的事誠實承認 — content_ckip 全量確實不見得需要
已知盲點
- 缺乏真實使用者回饋:80 組測試題庫查詢是我透過 AI 設計的,使用者的查詢習慣可能有顯著差異
- Embedding 更新成本未評估:BGE-M3 不會是永遠的最佳選擇,但換模型需全量重跑,時間成本有點高(2026-03-28 更新:實測 Qwen3-Embedding-0.6B 後確認 BGE-M3 在 ARM64 CPU + 量化場景仍然是最佳選擇——Qwen3 的 decoder 架構在 ONNX 量化後區分度嚴重退化,詳見第一篇更新)
- 失敗案例未系統化:缺乏完整的 error analysis
原則沒有變:先用 80 分方案上線,有明確痛點再追 90 分。 但「明確痛點」不一定要等使用者回報——在 RAG 問答場景中,排序精度和段落召回是可以預見的需求。
參考資源
- DREQ (Edinburgh, ECIR 2024): Entity-centric document re-ranking
- REGENT (Missouri, 2025): Relevance-guided attention with entities as “semantic skeleton”
- AI21 Multi-scale chunking (2026-01): 多尺度 index + RRF 合併
- FloTorch benchmark (2026-02): 固定 512 token chunking 打敗語意分塊
- BGE-M3 / BGE-reranker-v2-m3
- ONNX Runtime
- ihower 繁體中文 Embedding 評測