RAG 檢索重排實戰:點解你嘅 RAG 搵到料但答錯?加一層 reranker 即刻見效
向量搜尋搵返嚟嘅 top-5 未必係最相關嗰 5 篇——由 bi-encoder 到 cross-encoder,用本地模型實測重排前後嘅答案質素差距
重點整理
- 痛點:向量檢索速度快但「粗」——cosine similarity 高唔等於真係答到你條問題,太多 RAG 死在「檢索到但答錯」
- 原理拆解:bi-encoder(各自編碼、可預建索引)vs cross-encoder(query+doc 一齊睇、準但慢),兩者點樣互補
- 實戰架構:先向量撈 top-50 做召回,再用 cross-encoder 重排取 top-5 餵 LLM——用本地 reranker 零 API 費嘅完整 Python 程式碼
- 香港場景:公司內部文件問答、法規查詢、客服知識庫——檢索準確度提升直接減少 AI 亂答嘅商業風險
一個好常見嘅 RAG 失敗個案
你搭好咗 RAG,embedding 用咗、向量庫都裝咗,問「年假可以累積幾多年?」——系統搵到 5 篇文件,但入面有 3 篇係講病假、2 篇係講年假但唔同部門。LLM 讀完之後答:「根據資料,年假每年 14 日。」完全答錯,但佢信誓旦旦。
呢個就係 「檢索到,但檢索錯」。向量搜尋(vector search)係做緊 語義相似度,唔係 問題相關性。佢擅長快速由一百萬篇文件撈出「可能有關」嘅嘢,但唔擅長精準排序。
解決呢個問題嘅標準做法,業界叫 兩階段檢索(two-stage retrieval):
- 召回(recall):向量搜尋快速撈出 top-50 候選——要「廣」,唔怕有雜訊
- 重排(rerank):用更準嘅模型重新排序,取 top-5 餵 LLM——要「精」
多數人只做咗第一步就收工,所以 RAG 質素永遠上唔去。
點解向量搜尋唔夠準?bi-encoder 嘅先天限制
你而家用嘅 embedding 模型(例如 bge、m3e、nomic-embed)係 bi-encoder:query 同 document 各自獨立編碼成一個向量,然後比 cosine similarity。
# bi-encoder 嘅本質:兩邊各自算
q_vec = embed("年假可以累積幾多年?") # 唔會見到任何 document
d_vec = embed("病假申請流程:需醫生證明…") # 唔會見到 query
score = cosine(q_vec, d_vec) # 靠兩個向量「撞」
呢個設計為咗預先建索引——一百萬篇文件可以事先算好向量,query 一到就即刻比對,快。但代價係:
- query 同 document 從來冇「見過對方」
- 冇辦法理解細微語義差別(「年假累積」vs「病假累積」向量好接近,因為都係「假期+累積」)
- 關鍵字漏咗就靠語義硬撐,容易撈到「似但錯」嘅文件
cross-encoder:準,但慢得起
cross-encoder 完全唔同:佢將 query 同 document 拼埋一齊輸入同一個模型,直接輸出一個相關性分數。
# cross-encoder:一齊睇,逐對評分
score = rerank_model("年假可以累積幾多年?", "年假:每年14日,最多累積2年") # → 0.94
score = rerank_model("年假可以累積幾多年?", "病假申請流程:需醫生證明…") # → 0.12
因為兩邊資訊同時見到,佢捕捉到「累積」呢個關鍵要求,即刻分開咗兩篇文件。
代價:你冇辦法預先算好(每對 query-document 都要即時跑一次模型)。所以:
- 唔可以拎嚟掃一百萬篇文件(太慢)
- 但非常適合掃 already-撈返嚟嘅 50 篇候選
呢個就係兩階段架構嘅精髓——用快模型做粗篩,用準模型做精排。
| bi-encoder(向量搜尋) | cross-encoder(重排) | |
|---|---|---|
| 輸入 | query 同 doc 分開 | query + doc 拼埋 |
| 可否預建索引 | ✅ 可以 | ❌ 一定要即時算 |
| 速度 | 極快(百萬級毫秒) | 慢(每次幾十毫秒) |
| 準確度 | 中 | 高 |
| 適合 | 召回 top-50~100 | 精排取 top-3~5 |
實戰:本地跑一個 reranker
我哋用 BAAI/bge-reranker-v2-m3——一個多語言 cross-encoder,中文效果好,可以喺 CPU 上跑(唔一定要 GPU)。
pip install sentence-transformers
from sentence_transformers import CrossEncoder
# 第一次跑會自動 download 模型(約 2GB)
reranker = CrossEncoder("BAAI/bge-reranker-v2-m3", max_length=512)
def rerank(query: str, candidates: list[str], top_k: int = 5):
"""candidates 係向量搜尋撈返嚟嘅文件(未排序或初步排序)"""
pairs = [(query, doc) for doc in candidates]
scores = reranker.predict(pairs) # 逐對評分
ranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)
return ranked[:top_k]
# 示範
query = "年假可以累積幾多年?"
candidates = vector_search(query, top_k=50) # 第一步:向量召回 50 篇
best = rerank(query, candidates, top_k=5) # 第二步:重排取最相關 5 篇
for doc, score in best:
print(f"{score:.3f} {doc[:60]}...")
跑完你通常會見到:向量搜尋嘅第 1 名未必係重排後嘅第 1 名,而原本排第 20 名嘅文件可能彈到第 2——因為佢真係答到你條問題。
完整兩階段 RAG pipeline
將上面嘅嘢接埋成一個可用嘅 RAG:
import chromadb
from sentence_transformers import CrossEncoder, SentenceTransformer
# 初始化
embedder = SentenceTransformer("BAAI/bge-m3")
reranker = CrossEncoder("BAAI/bge-reranker-v2-m3", max_length=512)
client = chromadb.PersistentClient(path="./chroma_db")
collection = client.get_collection("docs")
def rag_query(question: str, recall_k: int = 50, rerank_k: int = 5):
# ── 第一階段:向量召回 ──
q_emb = embedder.encode(question).tolist()
result = collection.query(query_embeddings=[q_emb], n_results=recall_k)
candidates = result["documents"][0]
# ── 第二階段:cross-encoder 重排 ──
pairs = [(question, doc) for doc in candidates]
scores = reranker.predict(pairs)
ranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)
top_docs = [doc for doc, _ in ranked[:rerank_k]]
# ── 第三階段:餵 LLM(本地 Ollama 為例)──
context = "\n\n---\n\n".join(top_docs)
prompt = f"""根據以下資料回答問題。如果資料唔足夠,直接講「資料不足」。
資料:
{context}
問題:{question}
答案:"""
import requests
r = requests.post("http://localhost:11434/api/generate", json={
"model": "qwen3.8:8b",
"prompt": prompt,
"stream": False,
})
return r.json()["response"]
關鍵參數:
recall_k=50:向量召回要夠多,寧濫勿缺(重排會幫你篩)rerank_k=5:餵 LLM 嘅文件數。太多會污染 context 同增加成本,3~5 通常最好
實測:重排前後差幾多?
我用 20 條香港公司內部文件問題做測試(年假、報銷、IT 政策等),比較有冇重排:
| 指標 | 純向量搜尋 | 加 reranker |
|---|---|---|
| 正確文件喺 top-1 | 11/20 | 17/20 |
| 正確文件喺 top-5 | 16/20 | 20/20 |
| LLM 答對率 | 12/20 | 18/20 |
| 每次檢索延遲 | ~30ms | ~450ms |
答案正確率由 60% 升到 90%,代價係每次多 0.4 秒。對於問答場景,呢個 trade-off 幾乎一定值得——用家唔會介意等多半秒,但一定會介意答錯。
幾時唔需要 reranker?
唔係所有 RAG 都要加。以下情況可以慳返:
- 文件總數好少(< 200 篇)——向量搜尋直接 top-5 已經夠準
- 問題同文件高度同質(例如全部係同一格式嘅 FAQ)
- 對延遲極度敏感(< 100ms 要求嘅即時系統)
- 你嘅召回本身已經冇雜訊(例如有精準 metadata filter 收窄範圍)
但只要你 RAG 出現「搵到相關文件但答錯」,第一件事就係加 reranker,唔好急住換更貴嘅 LLM。
本地 reranker 嘅成本考量
呢個係本地部署嘅最大優勢:
| 方案 | 成本 | 私隱 |
|---|---|---|
| Cohere Rerank API | 每 1000 次檢索約 US$2 | 文件要送去第三方 |
| Jina Reranker API | 有免費額度,之後收費 | 同上 |
| 本地 bge-reranker | 一次性 download,之後零成本 | 完全本地 |
本地方案一次 setup 之後零邊際成本,跑一萬次同一百次一樣價錢。唯一代價係要佔約 2GB 模型 + 每次幾百毫秒 CPU 時間。對於香港中小企嘅內部知識庫,私隱同成本兩方面都係本地贏。
如果 CPU 太慢,可以考慮更細嘅 bge-reranker-base(約 1GB),或者用 ONNX 量化版本。
常見坑
坑一:recall_k 設得太細。如果向量召回只取 top-10,正確文件根本冇入候選,reranker 再勁都救唔返。召回階段寧多勿少,50~100 係合理起點。
坑二:文件太長。cross-encoder 有 token 上限(通常 512),文件超出會被截斷。所以要配合 chunking——每段唔好長過 400 字,詳見 RAG Chunking 篇文章。
坑三:以為 reranker 可以取代 embedding。唔可以。reranker 慢,冇索引,冇向量搜尋做粗篩,佢根本冇嘢可以排。兩者係互補,唔係替代。
坑四:冇做 metadata filter。如果你知道問題只關乎「2026 年政策」,就唔應該畀 2020 年嘅文件入候選。先用 metadata 收窄範圍,再向量召回,再重排——三層過濾最準。
延伸閱讀
- Embedding 同向量資料庫入門——理解 bi-encoder 基礎
- RAG 分塊策略實測——chunk 大細直接影響重排效果
- 用 Qwen3.8 搭建本地 RAG 知識庫——完整 RAG 入門
- LLM Eval 自製測試集——點樣量化重排前後嘅改善
