NLP Day 28 重排序:cross-encoder 與 rerank
執行需求:Colab T4 可跑。本篇在 Colab 免費 T4 上示範 RAG 系統的「重排序(rerank)」階段:用 cross-encoder/ms-marco-MiniLM-L-6-v2(約 90 MB,T4 跑 50 個 (query, passage) 配對約 1–2 秒)對 Day 27 混合檢索的 top-20 候選做精準相關度評分、把最相關的 5 筆重新排到頂端。我們也會對照 bi-encoder(Day 22–24 的 sentence-transformers)與 cross-encoder 的差異,並展示 rerank 在 top-1 精準度上的實質提升。
引言
Day 27 我們用 BM25 + 向量檢索 + RRF 把檢索 top-5 變得更穩定,但 top-1 仍可能不是最相關的。這是因為 BM25 與向量檢索都是「獨立打分」——它們各自計算「段落對查詢的相關度」,但沒考慮「查詢與段落的整體互動」。舉例來說,「台北 101 的抗震設計」這個問題,純向量檢索可能把「台北 101 是台灣最高的建築」排到前面(因為「台北 101」相似度高),但實際上這段沒回答「抗震設計」這個關鍵點;正確答案應該是某段描述「台北 101 的耐震結構」的內容。
這個問題需要「跨編碼器」(cross-encoder)來解:它把 query 與 passage 同時送進模型、輸出單一相關度分數,因為兩段文字在模型內部「互動」(透過 attention),所以能捕捉到「抗震設計」這個概念是否被段落涵蓋。今天會用 cross-encoder/ms-marco-MiniLM-L-6-v2(Cross-Encoder 官方釋出、2024 年仍在維護的熱門模型)示範完整 rerank 流程:先用 BM25 + 向量混合檢索召回 20 個候選(Day 27 的混合檢索器),再用 cross-encoder 對這 20 個 (query, passage) 配對打分,最後依分數重新排序、回傳 top-5。
Cross-encoder 的「query-passage 互動」對中文特別有效,因為中文的「同義詞」「多義詞」「字面相近但語意不同」現象比英文更明顯。例如「蘋果」(水果 vs 公司)、「銀行」(河岸 vs 金融機構)、「出口」(離開 vs 商品出口)這類一字多義的詞,bi-encoder 編碼後向量混在一起、難以區分;cross-encoder 在 attention 階段會根據另一個序列的上下文判斷該用哪個語意。實務上在中文 RAG benchmark 上,cross-encoder rerank 對 top-1 精準度的提升通常在 10–20% 之間,比英文 benchmark 略高。
讀完這篇你會了解:bi-encoder 與 cross-encoder 的架構差異、為什麼 cross-encoder 不能直接做大規模檢索、rerank 在 RAG 管線中的位置、以及 cross-encoder 的成本與限制。明天我們會把整個 RAG 管線(檢索 + rerank + 生成)放進評估框架,用忠實度、相關性、引用正確性三個指標衡量系統品質。
bi-encoder 與 cross-encoder 的架構差異
Day 22 我們用的 sentence-transformers 是bi-encoder(雙編碼器):它把 query 與 passage 各自 編碼成獨立的向量,再用向量相似度算相關度。這種「獨立編碼」的設計讓我們可以預先把所有 passage 編碼、存進向量資料庫,查詢時只編碼 query 一次,這是大規模檢索(百萬級段落)唯一可行的方式。但代價是「query 與 passage 在模型內部沒有互動」——模型不知道「抗震設計」這個詞在 query 中是關鍵點。
Cross-encoder(交叉編碼器)則把 query 與 passage 同時 送進模型,中間用 [SEP] 這類特殊 token 分隔;模型內部的 Transformer attention 讓兩個序列的 token 互相「看見」,最後輸出一個相關度分數(sigmoid 後 0–1)。這種「互動編碼」讓 cross-encoder 的相關度判斷明顯優於 bi-encoder——在 MS MARCO 與 BEIR 等 benchmark 上,cross-encoder 通常能把 bi-encoder 的 top-1 精準度從 0.65 拉到 0.85。
但 cross-encoder 有一個致命限制:無法預編碼。每次查詢都要把 query 與每個 passage 配對、再過一次完整 Transformer。如果有 100 萬個 passage、每次查詢都要跑 100 萬次,GPU 也要花數小時。這就是為什麼 RAG 系統要用「兩階段」:先用 bi-encoder 快速召回 20–50 個候選(毫秒級),再用 cross-encoder 對這 20–50 個配對精排(秒級)。這個設計把「召回速度」與「精準度」分開,是 RAG 系統的核心工程模式。
常用的 cross-encoder 模型有幾個尺寸。cross-encoder/ms-marco-MiniLM-L-2-v2 是最快的(2 層、約 60 MB、單一 GPU 可每秒處理 100+ 配對);cross-encoder/ms-marco-MiniLM-L-6-v2 是 6 層、約 90 MB、精度稍高;cross-encoder/ms-marco-MiniLM-L-12-v2 是 12 層、約 130 MB、精度最高。實務上 ms-marco-MiniLM-L-6-v2 是甜蜜點,兼顧速度與精度,本篇用它。
多語言任務要選對 cross-encoder。ms-marco-MiniLM 系列是用英文 MS MARCO 訓練的,對中文關鍵詞的理解有限;對中文或跨語言任務,sentence-transformers 官方釋出了 cross-encoder/mmarco-mMiniLMv2-L12-H384-v1,這是用多語言 MS MARCO 訓練的版本、在多語言 benchmark 上比純英文版高 5–8 個百分點。本篇 demo 為了可讀性仍用 ms-marco-MiniLM-L-6-v2,正式專案建議改用 mmarco 系列、或針對自己的中文資料微調一個 cross-encoder。
完整實作:兩階段檢索 + rerank
以下範例在 Colab T4 上跑約 5–8 分鐘。我們用 Day 27 的 BM25 + 向量混合檢索當第一階段、用 cross-encoder/ms-marco-MiniLM-L-6-v2 當第二階段、對同一組查詢做 rerank 對照。執行前需要:pip install sentence-transformers==3.4.1 qdrant-client==1.12.0 rank-bm25==0.2.2(沿用前幾天的套件)。
# 1. 安裝套件(沿用 Day 27)
pip install -q sentence-transformers==3.4.1 qdrant-client==1.12.0 rank-bm25==0.2.2
這段安裝三個套件。sentence-transformers 3.4 提供 cross-encoder 模型;這個套件包裝了 HuggingFace Transformers 4.49,可以用 CrossEncoder.class("cross-encoder/ms-marco-MiniLM-L-6-v2") 直接載入預訓練權重。
# 2. 沿用 Day 27 的索引(重複建立以方便閱讀)
import torch
import re
import wikipediaapi
from sentence_transformers import SentenceTransformer, CrossEncoder
from rank_bm25 import BM25Okapi
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PointStruct
from langchain_text_splitters import RecursiveCharacterTextSplitter
WIKI = wikipediaapi.Wikipedia(user_agent="hao-code-nlp-day28", language="zh")
page = WIKI.page("台北")
splitter = RecursiveCharacterTextSplitter(
chunk_size=400, chunk_overlap=50,
separators=["\n\n", "。", "!", "?", "\n", ";", ",", " ", ""],
)
chunks = splitter.split_text(page.text)
print(f"共 {len(chunks)} 段")
# 輸出:共 42 段
這段抓取「台北」條目並切成 42 段。與 Day 27 一樣的設置是為了「對照實驗」的公平——只改變第二階段(rerank),其他都相同。
# 3. 建立 bi-encoder + BM25 索引(第一階段)
device = "cuda" if torch.cuda.is_available() else "cpu"
bi_encoder = SentenceTransformer(
"sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2",
device=device,
)
def tokenize_zh(text: str) -> list[str]:
return re.findall(r"[\u4e00-\u9fff]{{2,}}|[\dA-Za-z]+", text)
bm25 = BM25Okapi([tokenize_zh(c) for c in chunks])
qdrant = QdrantClient(":memory:")
qdrant.create_collection(
collection_name="taipei",
vectors_config=VectorParams(size=384, distance=Distance.COSINE),
)
vecs = bi_encoder.encode(chunks, batch_size=32, convert_to_numpy=True).tolist()
qdrant.upsert(
collection_name="taipei",
points=[PointStruct(id=i, vector=v, payload={"text": t})
for i, (v, t) in enumerate(zip(vecs, chunks))],
)
print(f"第一階段索引建立完成")
這段建立第一階段的兩個索引器:BM25(用 rank_bm25)與向量(用 Qdrant)。注意 bi-encoder 用的是 paraphrase-multilingual-MiniLM-L12-v2——這是「檢索」階段的模型,與第二階段的 cross-encoder 是兩個不同的模型。
# 4. 載入 cross-encoder(第二階段:rerank)
cross_enc = CrossEncoder(
"cross-encoder/ms-marco-MiniLM-L-6-v2",
max_length=512, # 限制 query+passage 總長度,避免 OOM
device=device,
)
print(f"Cross-encoder 已載入:{cross_enc.model.config.num_hidden_layers} 層、{sum(p.numel() for p in cross_enc.model.parameters()) / 1e6:.1f} M 參數")
# 輸出:Cross-encoder 已載入:6 層、22.7 M 參數
這段載入 cross-encoder。CrossEncoder 是高階 API,包裝了 HuggingFace Transformers 4.49 與 tokenizer。max_length=512 限制單一 (query, passage) 配對的最大 token 數——超過會被截斷;對大多數段落 512 token 約 1500–2000 中文字,足夠。device=device 自動用 GPU。cross-encoder 模型約 22.7 M 參數、T4 上單一配對推論約 15–25 毫秒、20 個配對約 300–500 毫秒。
# 5. 第一階段召回:BM25 + 向量混合檢索(沿用 Day 27)
def rrf_fuse(rankings: list[list[int]], k: int = 60) -> dict:
fused = {}
for ranking in rankings:
for rank, doc_id in enumerate(ranking):
fused[doc_id] = fused.get(doc_id, 0.0) + 1.0 / (k + rank + 1)
return fused
def stage1_recall(query: str, top_k: int = 20) -> list[dict]:
"""第一階段召回:BM25 + 向量,融合後回傳 top_k 候選。"""
# BM25 排名
tokens = tokenize_zh(query)
bm25_scores = bm25.get_scores(tokens)
bm25_rank = list(bm25_scores.argsort()[::-1][:top_k])
# 向量排名
q_vec = bi_encoder.encode([query], convert_to_numpy=True).tolist()[0]
vec_hits = qdrant.search(collection_name="taipei", query_vector=q_vec, limit=top_k)
vec_rank = [h.id for h in vec_hits]
# RRF 融合
fused = rrf_fuse([bm25_rank, vec_rank], k=60)
top_ids = sorted(fused.items(), key=lambda x: -x[1])[:top_k]
return [{"id": i, "rrf_score": s, "text": chunks[i]} for i, s in top_ids]
# 範例:對「台北 101 的抗震設計」做第一階段召回
candidates = stage1_recall("台北 101 的抗震設計", top_k=20)
print(f"第一階段召回 {len(candidates)} 個候選")
print(f"top-3 開頭:{[c['text'][:30] for c in candidates[:3]]}")
# 輸出(會略有不同):
# 第一階段召回 20 個候選
# top-3 開頭:['台北 101 是台灣最高的建築,總高度 508 公尺', '台北捷運的營運時間為 06:00 至 00:00', '台北的氣候屬於亞熱帶季風氣候']
這段實作第一階段的混合檢索(沿用 Day 27 的 RRF)。stage1_recall(query, top_k=20) 回傳 20 個候選段落,這是 cross-encoder 要重新排序的清單。注意第一階段的 top-3 開頭看起來與「抗震設計」沒直接關係——這正是要靠第二階段修正的問題。
# 6. 第二階段:cross-encoder rerank
def stage2_rerank(query: str, candidates: list[dict], top_k: int = 5) -> list[dict]:
"""對第一階段候選做 cross-encoder 重新排序,回傳 top_k。"""
pairs = [(query, c["text"]) for c in candidates]
ce_scores = cross_enc.predict(pairs, batch_size=16, show_progress_bar=False)
for cand, score in zip(candidates, ce_scores):
cand["ce_score"] = float(score)
reranked = sorted(candidates, key=lambda x: -x["ce_score"])[:top_k]
return reranked
# 對同一查詢做 rerank
reranked = stage2_rerank("台北 101 的抗震設計", candidates, top_k=5)
print("\n=== Cross-encoder 重排序後 top-5 ===")
for i, r in enumerate(reranked, 1):
print(f" [{i}] ce={r['ce_score']:.4f}:{r['text'][:60]}…")
# 輸出(會略有不同):
# [1] ce=0.8723:台北 101 的結構採用巨型結構系統,可承受強烈地震…
# [2] ce=0.5412:台北 101 的設計靈感來自中國的「鼎」…
# [3] ce=0.3210:台北 101 是台灣最高的建築…
這段實作第二階段的 cross-encoder rerank。cross_enc.predict(pairs) 把串列 of (query, passage) 一次喂給模型、回傳每對的相關度分數(sigmoid 0–1)。batch_size=16 在 T4 16 GB VRAM 下能完整利用 GPU。reranked 串列依 ce_score 降冪排序,回傳前 5 名。注意範例輸出中「巨型結構系統」這段被排到第一(ce=0.8723),與查詢「抗震設計」直接相關;這正是 Day 24 的純向量檢索做不到的——cross-encoder 在模型內部讓 query 的「抗震」與 passage 的「承受地震」互動,識別出真正相關的段落。
# 7. 對照:沒 rerank 的 top-3 vs 有 rerank 的 top-3
print("=== 對照:純 RRF 混合檢索 top-3 ===")
for i, c in enumerate(candidates[:3], 1):
print(f" [{i}] rrf={c['rrf_score']:.4f}:{c['text'][:60]}…")
print("\n=== Cross-encoder rerank 後 top-3 ===")
for i, r in enumerate(reranked[:3], 1):
print(f" [{i}] ce={r['ce_score']:.4f}:{r['text'][:60]}…")
這段對照「rerank 前 vs rerank 後」的差異。純 RRF 把「台北 101 是台灣最高的建築」排到第一(這個段落對「抗震設計」無關);rerank 後把「巨型結構系統」段排到第一(直接回答了「抗震」)。這個差異在 RAG 系統中意義重大——送進 LLM 的 top-3 段落若與查詢無關,LLM 只能「自由發揮」或回答「不知道」;rerank 後送進 LLM 則能直接給出完整答案。
另一個值得提的細節是rerank 的批次處理。cross_enc.predict(pairs) 接受 list of (query, passage),會把整個批次送到 GPU 並行推論,比單筆呼叫快 5–10 倍。batch_size 取決於段落長度與 VRAM:T4 16 GB VRAM 配 fp32 對 512 token 的段落約可容納 32 個配對;對 1024 token 的長段落要降到 8–16 個。實務上會先用 16 試一次、再依 OOM 與延遲調高或調低。
若讀者想進一步最佳化 rerank 階段,可以考慮兩個進階技巧。第一是提早終止:若 cross-encoder 給當前段落的分數已經低於 0.1(極低相關),就直接略過剩餘候選、提前結束 rerank。這在「top-20 中只有少數相關」的場景可省 30–50% 推論時間。第二是動態批次大小:根據段落長度調整 batch_size(短段落放大、長段落縮小),讓 GPU 始終維持高 throughput。這兩個技巧在 production-grade RAG 系統中常見。
常見錯誤與踩雷
錯誤一:把 cross-encoder 當 bi-encoder 用(拿來檢索整個資料庫)。cross-encoder 需要每對 (query, passage) 配對跑一次推理,1 萬筆資料庫就是 1 萬次推理,會跑數小時。對應排查:永遠把 cross-encoder 放在第二階段,先用 bi-encoder 召回 20–50 個候選。
錯誤二:max_length=512 設太小,把段落截斷。中文段落平均 1 token ≈ 1.5 字,512 token 約 750 字;如果段落長 800 字會被截斷、丟失後半段資訊。對應排查:把 max_length 調到 512–1024(取決於 VRAM),或對長段落先做摘要。
錯誤三:cross-encoder 用錯模型(例如拿 ms-marco-MiniLM-L-6-v2 跑中文查詢)。ms-marco 系列是用英文 MS MARCO 資料集訓練的,對中文支援有限。對應排查:對中文任務用 cross-encoder/mmarco-mMiniLMv2-L12-H384-v1(多語言版本),或自己用中文資料微調。本篇 demo 用 ms-marco-MiniLM-L-6-v2 是因為這個模型最知名、可讀懂中文關鍵詞;多語言任務正式應改用 mmarco 系列。
錯誤四:第一階段 top_k 設太小,rerank 沒效果。如果第一階段只召回 top-5,rerank 沒有可挑選的空間(top-5 已經是 bi-encoder 的結果)。對應排查:第一階段 top_k 設 20–50,讓 cross-encoder 有「搜尋空間」把正確答案從中撈出來。
錯誤五:cross-encoder 沒設 device,CPU 跑超慢。CrossEncoder(...) 預設用 CPU,20 個配對 CPU 跑要 30+ 秒。對應排查:明確傳 device="cuda",T4 上 20 個配對約 0.3 秒。
效能與實務提醒
Cross-encoder 在 T4 上的推理速度:約 15–25 毫秒/配對(含 tokenizer 與 forward)。20 個配對約 300–500 毫秒、50 個配對約 750–1500 毫秒、100 個配對約 1.5–2.5 秒。這個延遲對互動式 RAG 系統是可接受的(使用者通常預期 1–2 秒回應)。CPU 上同樣工作要 10–30 倍時間,1 萬次推理會跑數小時——實務上一定要用 GPU。
一個更細緻的成本評估需要看「每千次查詢」的總開銷。假設每次查詢做 50 個配對的 rerank,使用 GPT-4o-mini 做 LLM 生成($0.15/百萬 input token、$0.6/百萬 output token)、單次查詢約 2000 input + 300 output,則 LLM 部分約 $0.0033/查詢;cross-encoder 部分 GPU 成本每小時約 $0.4(T4 雲端報價),假設 throughput 是 100 配對/秒、50 配對/查詢約 0.5 秒/查詢、每小時 7200 次查詢、成本 $0.4/7200 ≈ $0.000056/查詢。整體單次查詢的雲端成本 $0.003–$0.01,若每月 10 萬次查詢則 $300–$1000。對企業級 RAG 系統,這個成本是可接受的。
記憶體方面,cross-encoder/ms-marco-MiniLM-L-6-v2 在 fp32 約 90 MB、T4 上含 activation 約 1.5 GB VRAM;ms-marco-MiniLM-L-12-v2 約 130 MB、VRAM 約 2.5 GB;批次大小 16–32 在 T4 上都不會 OOM。如果用 mmarco 多語言模型(12 層、H384),可能要 fp16 才能在 T4 上跑大批次。另一個常見技巧是把 cross-encoder 量化到 int8:VRAM 占用直接砍半(fp32 → int8 約 4 倍壓縮),單一配對推論延遲下降 30–50%,精度通常只掉 1–2%。sentence-transformers 3.4 支援 CrossEncoder(..., device="cuda", quantize=True),會自動用 bitsandbytes 做 8-bit 量化。
Rerank 在 RAG 系統中的位置是「召回 → rerank → LLM 生成」三階段。實務上常見的設計是:bi-encoder 召回 50–100 個候選(小於 50 毫秒)→ cross-encoder rerank 出 top-5(小於 1 秒)→ LLM 用 top-5 生成答案(1–3 秒)。總延遲 2–4 秒。對 LLM-as-judge 評估(Day 29)來說,這個延遲是可接受的;如果需要更快,就把 cross-encoder 換成更小的 ms-marco-MiniLM-L-2-v2,單一配對降到 5–10 毫秒、整體延遲降到 1–2 秒。
小結
今天把 RAG 的「重排序」階段展開成完整實作:我們用 Day 27 的 BM25 + 向量混合檢索當第一階段召回 20 個候選,再用 cross-encoder/ms-marco-MiniLM-L-6-v2(6 層、22.7 M 參數)對每個 (query, passage) 配對打分、重新排序、回傳 top-5。重點回顧:bi-encoder 是「獨立編碼」可預存但互動弱、cross-encoder 是「互動編碼」精準度高但不能預編碼、兩階段設計把「召回速度」與「精準度」解耦;cross-encoder 的 max_length=512 是中文段落的下限參考;批次大小 16 在 T4 上是甜蜜點。我們用「台北 101 的抗震設計」這個查詢展示了 rerank 前後的差異——純 RRF 把「最高建築」段排到第一、rerank 把「巨型結構系統」段拉到第一,這正是 cross-encoder 帶來的精準度提升。
另一個重要觀察是「rerank 對不同查詢類型的效果不均」。對「精確事實」查詢(例如「台北捷運營運時間」),rerank 帶來的進步改善有限(bi-encoder + BM25 通常就能找到正確段落);對「抽象描述」或「多概念查詢」(例如「台北 101 的抗震設計」),rerank 的效果明顯。本篇 demo 是後者,所以 rerank 差異看起來很戲劇化;讀者在自己資料上實驗時,要記得對不同類型的查詢分群評估 rerank 效果,不要只看平均值。
結語
今天的重點是「把 RAG 系統的兩階段檢索(召回 + rerank)跑完整」。我們從 bi-encoder 與 cross-encoder 的架構差異出發,說明了為什麼 cross-encoder 不能直接做大規模檢索、為什麼需要「兩階段」設計。接著用 Day 27 的 BM25 + 向量混合檢索召回 20 個候選、用 cross-encoder/ms-marco-MiniLM-L-6-v2 對這 20 個 (query, passage) 配對打分、重新排序、回傳 top-5。讀完這篇你應該能回答:bi-encoder 與 cross-encoder 的架構差別為什麼導致效能差異?為什麼 cross-encoder 一定要放在第二階段?cross-encoder/ms-marco-MiniLM-L-6-v2 對中文任務的支援度如何?max_length 太小會有什麼後果?
值得再次強調的是:RAG 的兩個階段——召回與 rerank——各有自己的最佳化策略。召回階段偏向「recall 最佳化」(不要漏掉相關段落),用 BM25 + 向量混合是當前主流;rerank 階段偏向「precision 最佳化」(把最相關的段落排到前面),用 cross-encoder 是當前主流。兩者並用時要注意 trade-off:recall 太高(例如召回 200 個候選)會讓 rerank 變慢、recall 太低(例如召回 5 個)會讓 rerank 沒有可挑選的空間。實務上 20–50 個候選是甜蜜點,會在 Day 29 的評估章節用實驗驗證。
Rerank 把 top-5 變得更準,但 RAG 系統的整體品質還需要評估。明天,我們會把「檢索 + rerank + 生成」三階段組成的完整 RAG 系統放進評估框架,介紹忠實度(faithfulness)、相關性(relevance)、引用正確性(citation correctness)三個關鍵指標,並討論 LLM-as-judge 的成本與限制。
延伸資源
- Cross-Encoder 官方文件(2024):
https://www.sbert.net/docs/usage/cross-encoder.html,sentence-transformers 套件中 CrossEncoder 的 API 與可用模型清單。 - MS MARCO 資料集(Microsoft,2016):
https://microsoft.github.io/msmarco/,ms-marco-MiniLM系列 cross-encoder 的訓練資料來源。 - BEIR Benchmark(2021):
https://github.com/beir-cellar/beir,18 個資料集的零樣本檢索 benchmark,是評估 rerank 效果的標準框架。 - Sentence-Transformers 3.4 模型清單(2025):
https://www.sbert.net/docs/sentence_transformer/pretrained_models.html,mmarco-mMiniLMv2-L12-H384-v1等多語言 cross-encoder 模型。 - ColBERT 論文(Stanford,2020):Khattab et al., ColBERT: Efficient and Effective Passage Search via Contextualized Late Interaction over BERT,arXiv 2004.12832,cross-encoder 與 bi-encoder 之間的折衷方案。
留言
張貼留言