RAG 檢索重排實戰:點解你嘅 RAG 搵到料但答錯?加一層 reranker 即刻見效

向量搜尋搵返嚟嘅 top-5 未必係最相關嗰 5 篇——由 bi-encoder 到 cross-encoder,用本地模型實測重排前後嘅答案質素差距

AI 教學AI 生成

重點整理

  • 痛點:向量檢索速度快但「粗」——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):

  1. 召回(recall):向量搜尋快速撈出 top-50 候選——要「廣」,唔怕有雜訊
  2. 重排(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-111/2017/20
正確文件喺 top-516/2020/20
LLM 答對率12/2018/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 收窄範圍,再向量召回,再重排——三層過濾最準。

延伸閱讀

分享畀朋友

相關文章

更多「AI 教學」

睇全部分類 →