檢索品質:hybrid search 與 rerank
你問「我們的 k8s 叢集 OOM 問題怎麼排查」,RAG 系統把這句話 embedding 之後在向量空間搜尋,回來的全是 CPU 監控、Pod 健康檢查的段落。偏偏那篇真正有用的「記憶體不足排查手冊」,標題寫的是 Kubernetes,內容描述的是 OOM Killer——向量模型把「k8s」和「Kubernetes」的距離算得比你想的遠,那份文件排到第 15 名,top-5 看不到它。
這不是孤立案例。第 4 課切塊策略講的是「黃金答案別被切斷」,但就算切塊完美,檢索層本身還有一個洞:向量模型擅長語意,但對縮寫、版本號(v2.1.3)、error code(ERR_HTTP2_PROTOCOL_ERROR)、產品代號這類精確字串,召回率天生比傳統關鍵字搜尋差。Hybrid search 和 rerank 就是補這個洞的標準做法。
這堂學什麼
- 純向量檢索的失敗模式:哪些場景 BM25 比向量強、為什麼
- Hybrid search 原理:BM25 + 向量雙路召回,用 RRF 融合排序
- 用 LangChain
EnsembleRetriever五分鐘實作 hybrid retriever(含中文 jieba 分詞) - 二階段 rerank:Voyage rerank-2 與 Cohere Rerank v3.5 的接法與費用比較
- 從零建黃金測試集、量命中率(Hit Rate)和 MRR,有數字依據地迭代改善
觀念一:向量為什麼找不到「精確字串」
Embedding 模型在大量通用語料上訓練,學的是語意相似度。問題在於:當你的文件庫充滿領域專有名詞——縮寫、product code、API 路徑——模型對這些詞的 embedding 表示往往不準確,或者因為上下文切片方式讓語意被稀釋。

典型失敗場景:
- 使用者查「crashloopbackoff」,文件寫「Pod 反覆重啟」——語意相關但字串不同,向量距離大
- 查「/api/v2/invoices rate limit」,文件有完整說明但這個 endpoint 路徑是罕見字串,embedding 空間位置偏
- 中英文混查:使用者打「2025 Q4 revenue 多少」,報告用「第四季度營收」
BM25(Best Match 25)直接補這個洞。Elasticsearch 和 Solr 預設都用它,邏輯就是:你查什麼字,它找包含這些字的文件。縮寫、error code、版本號,只要文件裡有一樣的字串就能命中。BM25 並不「理解」語意,它只做詞頻統計——但正因為如此,它對精確字串的召回率非常穩定,不受 embedding 訓練資料分布的影響。
兩種方法的互補性:
| 向量搜尋 | BM25 | |
|---|---|---|
| 優勢 | 同義詞替換、語意理解、多語混用 | 精確字串、專有名詞、稀有詞 |
| 弱點 | 罕見詞、縮寫、專有名詞 | 語意相似但用詞不同 |
| 典型場景 | 「如何提升系統穩定性」 | 「ERR_HTTP2_PROTOCOL_ERROR」 |
觀念二:RRF 融合——讓兩條路不打架
兩路召回各自回傳一份排序清單,怎麼合?最常見的方法叫 RRF(Reciprocal Rank Fusion)。公式:
score(d) = Σ 1 / (k + rank_i(d))
k 一般設 60,rank_i(d) 是文件 d 在第 i 條召回路的排名。邏輯是:任何一路排名很前面的文件都得到高分,兩路都排前面的分數更高,最後按合併分數取 top-k。

不需要費心調整「兩條路相對重要性」的超參數,RRF 工程上用起來很穩定。LangChain 的 EnsembleRetriever 內部就是 RRF,也提供 weights 參數讓你手動偏向某一路。
另一種融合方式是加權分數融合(Weighted Score Fusion):把兩路的相似度分數直接加權平均。這種方式對分數的尺度很敏感——向量相似度通常是 0~1,BM25 分數可以是任意正數——容易因為尺度不一致產生奇怪的結果。RRF 只看排名、不看分數絕對值,因此對兩種方法的分數尺度完全不敏感,是更推薦的預設選擇。
觀念三:rerank 是第二道精篩
Hybrid search 之後你拿到 top-20 到 top-30 的候選文件——比純向量更完整,但數量多、排序還不夠準。Reranker 的角色:把 query 和每份候選文件一起丟進 cross-encoder 模型,讓它給每對打一個 0~1 的相關分,重排後只留 top-5。

為什麼分兩階段而不是一開始就跑 reranker?因為 cross-encoder 的計算量和候選數量成正比,對 10,000 個 chunk 全跑一遍慢到不實用(甚至費用爆炸)。先用向量+BM25 快速召回 top-30,再 rerank 這 30 筆,延遲和費用都在合理範圍。
2026 年主流 reranker 服務費用:
| 服務 | 模型 | 費用 | 免費額度 |
|---|---|---|---|
| Voyage AI | rerank-2 | $0.05/MTok(按 token) | 前 200M tokens 免費 |
| Voyage AI | rerank-2.5-lite | $0.02/MTok(按 token) | 前 200M tokens 免費 |
| Cohere | Rerank v3.5 | $2.00 / 1,000 次搜尋(按次) | 試用 key 有限額 |
注意兩家計費模型不同:Voyage 按處理的 token 數計費,Cohere 按「搜尋次數」計費(一次搜尋最多 100 份文件、每份最多 500 tokens,超過會被拆成多份計)。所以不能直接把單價相除比較,實際成本取決於你每次 rerank 幾份文件、每份多長。
開發測試用 Voyage rerank-2:前 200M tokens 完全免費,夠跑幾百次實驗。上線後再決定要不要換 Cohere Rerank v3.5(多語言覆蓋更好,但按次計費,在多數 RAG 場景整體成本明顯較高)。
手把手實戰
以下假設你有一批文件已存進 Chroma(或其他向量庫),現在逐步加上 hybrid search 和 rerank,並且用測試集量化每一步的改善幅度。
先建黃金測試集,有數字才能比
在動任何程式碼之前先量「現在的系統有多差」。你需要一份「問題 → 期待命中的 chunk_id」對照表:
[
{
"query": "k8s pod crashloopbackoff 怎麼排查",
"expected_chunk_id": "doc-42-chunk-3"
},
{
"query": "invoice API /v2/invoices 的 rate limit 是多少",
"expected_chunk_id": "doc-17-chunk-1"
},
{
"query": "2025 Q4 revenue 成長率",
"expected_chunk_id": "doc-88-chunk-0"
}
]
測試集最快的來源:從你的文件中隨機抽 30~50 個 chunk,請 Claude 根據每個 chunk 的內容生成多樣化的問題。注意 prompt 要求生成口語版、縮寫版,不然測試集會太理想化:
import anthropic, json
client = anthropic.Anthropic()
def generate_test_questions(chunk_text: str, chunk_id: str, n: int = 2) -> list:
prompt = f"""根據以下文件段落,生成 {n} 個真實使用者可能詢問的問題。
問題必須能從這個段落直接回答。生成多樣化的問法,包含:
- 正式完整問句
- 口語簡短版(像在 Slack 裡問同事)
- 只有關鍵字的搜尋式查詢
只輸出 JSON 陣列,格式如下:
段落內容:
{chunk_text}
[{{"query": "問題", "expected_chunk_id": "{chunk_id}"}}]"""
msg = client.messages.create(
model="claude-sonnet-4-5",
max_tokens=512,
messages=[{"role": "user", "content": prompt}]
)
return json.loads(msg.content[0].text)
# 對所有 chunk 跑完後合成測試集
all_cases = []
for chunk in your_chunks: # 你的 Document 物件清單
cases = generate_test_questions(
chunk.page_content,
chunk.metadata["chunk_id"]
)
all_cases.extend(cases)
json.dump(all_cases, open("test_set.json", "w"), ensure_ascii=False, indent=2)
print(f"測試集共 {len(all_cases)} 筆")
30 個 chunk × 每個 2 題 = 60 筆,已經夠量出有意義的命中率了。
量基準:純向量的命中率
先定義兩個指標函數,之後每個版本的 retriever 都用同一套量:
from langchain_voyageai import VoyageAIEmbeddings
from langchain_chroma import Chroma
import json
embedding = VoyageAIEmbeddings(
model="voyage-4-lite", # $0.02/MTok,前 200M tokens 免費
voyage_api_key="va-..."
)
vectorstore = Chroma(
persist_directory="./chroma_db",
embedding_function=embedding
)
vector_retriever = vectorstore.as_retriever(search_kwargs={"k": 5})
test_set = json.load(open("test_set.json"))
def hit_rate(retriever, test_cases: list, k: int = 5) -> float:
"""k 筆結果中命中期待 chunk 的比例"""
hits = 0
for case in test_cases:
results = retriever.invoke(case["query"])
retrieved_ids = [r.metadata.get("chunk_id") for r in results[:k]]
if case["expected_chunk_id"] in retrieved_ids:
hits += 1
return hits / len(test_cases)
def mrr(retriever, test_cases: list, k: int = 10) -> float:
"""Mean Reciprocal Rank:命中排在越前面分數越高"""
rr_sum = 0.0
for case in test_cases:
results = retriever.invoke(case["query"])
for rank, doc in enumerate(results[:k], start=1):
if doc.metadata.get("chunk_id") == case["expected_chunk_id"]:
rr_sum += 1 / rank
break
return rr_sum / len(test_cases)
baseline_hr = hit_rate(vector_retriever, test_set)
baseline_mrr = mrr(vector_retriever, test_set)
print(f"純向量 Hit Rate@5: {baseline_hr:.2%}")
print(f"純向量 MRR@10: {baseline_mrr:.3f}")
把這兩個數字截圖存下來,這是後續所有改善的對照基準。
加 BM25,建 Hybrid Retriever
pip install langchain-community rank_bm25 jieba
from langchain_community.retrievers import BM25Retriever
from langchain.retrievers import EnsembleRetriever
import jieba
# 中文必須自訂分詞函數,英文可以跳過這段
def chinese_tokenizer(text: str) -> list[str]:
return list(jieba.cut(text))
# BM25 需要原始的 Document 物件清單(就是你 Chroma 裡的 docs)
bm25_retriever = BM25Retriever.from_documents(
docs,
preprocess_func=chinese_tokenizer # 英文文件可省略
)
bm25_retriever.k = 10 # 召回先取多一點
# 向量那邊也放寬,k=10
vector_retriever_wide = vectorstore.as_retriever(search_kwargs={"k": 10})
# EnsembleRetriever 內部使用 RRF 融合
ensemble_retriever = EnsembleRetriever(
retrievers=[bm25_retriever, vector_retriever_wide],
weights=[0.5, 0.5] # 兩邊各半;若你的文件專有名詞多,可試 [0.6, 0.4]
)
print(f"Hybrid Hit Rate@5: {hit_rate(ensemble_retriever, test_set):.2%}")
print(f"Hybrid MRR@10: {mrr(ensemble_retriever, test_set):.3f}")
正常情況下,hybrid 比純向量的 Hit Rate 提升 5~20 個百分點,尤其在你的文件有大量技術術語、型號、縮寫的時候效果最明顯。
加 Reranker:二階段精排
用 Voyage rerank-2 接法(前 200M tokens 完全免費,推薦開發測試期間使用):
pip install voyageai
import voyageai
vc = voyageai.Client(api_key="pa-...")
def rerank_with_voyage(
query: str,
candidates: list,
model: str = "rerank-2",
top_k: int = 5
) -> list:
doc_texts = [d.page_content for d in candidates]
result = vc.rerank(
query=query,
documents=doc_texts,
model=model,
top_k=top_k
)
# result.results 是 RerankingObject list,含 index 和 relevance_score
return [candidates[r.index] for r in result.results]
def hybrid_rerank_retriever(query: str, k_final: int = 5) -> list:
# 第一階段:hybrid 召回 top-20
candidates = ensemble_retriever.invoke(query)[:20]
# 第二階段:rerank 精排
return rerank_with_voyage(query, candidates, top_k=k_final)
# 量測
hits = sum(
1 for case in test_set
if case["expected_chunk_id"] in [
d.metadata.get("chunk_id")
for d in hybrid_rerank_retriever(case["query"])
]
)
print(f"Hybrid+Rerank Hit Rate@5: {hits/len(test_set):.2%}")
如果你已經有 Cohere API key,可以用 LangChain 整合,接法更簡潔:
pip install langchain-cohere
from langchain_cohere import CohereRerank
from langchain.retrievers.contextual_compression import ContextualCompressionRetriever
compressor = CohereRerank(
model="rerank-v3.5",
top_n=5,
cohere_api_key="..."
)
final_retriever = ContextualCompressionRetriever(
base_compressor=compressor,
base_retriever=ensemble_retriever
)
# 這個 retriever 就可以直接 .invoke(query),跟其他 retriever 一樣用
print(f"Hybrid+Cohere Hit Rate@5: {hit_rate(final_retriever, test_set):.2%}")
Cohere Rerank v3.5 是按「搜尋次數」計費($2.00 / 1,000 次搜尋),Voyage rerank-2 則按 token 計費($0.05/MTok);兩者計費單位不同,實際成本取決於每次 rerank 的文件數與長度,但多數 RAG 場景 Cohere 會貴一截。開發測試先用 Voyage,上線後再評估是否需要 Cohere 的更強多語言能力。
找 top-k 的最佳值
reranker 的 top_n 決定最終送進 LLM 的文件數量。量 Hit Rate 在不同 k 下的分布,找邊際效益拐點:
for k in [3, 5, 8, 10]:
hr = sum(
1 for case in test_set
if case["expected_chunk_id"] in [
d.metadata.get("chunk_id")
for d in hybrid_rerank_retriever(case["query"], k_final=k)
]
) / len(test_set)
print(f"k={k:2d}: Hit Rate = {hr:.2%}")
k= 3: Hit Rate = 72.0%
k= 5: Hit Rate = 84.0%
k= 8: Hit Rate = 87.0%
k=10: Hit Rate = 88.0%
k=5 到 k=8 之間通常有一個明顯的「邊際效益拐點」。超過這個點再加文件,命中率提升不多,但每次生成都要讓 LLM 讀更多 context、增加 token 費用和延遲。找到拐點之後固定 top_k 就好。

常見坑
坑 1:中文 BM25 命中率幾乎沒改善,也沒有報錯
症狀:加了 BM25 之後命中率幾乎不動,有時甚至比純向量低一點點。
原因:BM25Retriever 預設用 str.lower().split() 分詞,中文字詞之間沒有空格,整句話被視為一個 token。每次查詢只有「完全一樣的完整句子」才能命中,命中率接近零,而且不報錯——只是靜靜地找不到。
解法:傳入 jieba tokenizer(如前面步驟所示)。英文文件不需要,中文或中英混合文件必須要。確認有沒有正確分詞:
import jieba
print(list(jieba.cut("k8s pod 反覆重啟怎麼排查")))
# 預期輸出:['k8s', ' ', 'pod', ' ', '反覆', '重啟', '怎麼', '排查']
坑 2:加了 reranker 之後命中率反而下降
症狀:Hybrid+Rerank Hit Rate@5 = 68%,但 Hybrid Hit Rate@5 = 79%。
原因:十之八九是召回層太窄。如果 ensemble_retriever 只設 k=5,reranker 的 top_n 也設 5,它只是對同樣 5 份文件重排,沒機會「撿回」排在第 6~20 名但其實正確的答案。
解法:召回層要「寬進嚴出」。召回層的 k 要至少是 reranker top_n 的 3~4 倍:
BM25: k=15 ──┐
向量: k=15 ──┤ RRF → top30 → Reranker top_n=5
坑 3:測試集問題太「標準」,上線後命中率假高
症狀:測試集命中率 85%,上線後使用者回報「找不到答案」。
原因:用 Claude 根據 chunk 內容生成的問題語氣和結構太標準,跟真實使用者的輸入差太多——使用者可能打錯字、省略主詞、夾雜英文、只丟幾個關鍵字。
解法:生成測試集時明確要求多樣化問法(如前面步驟的 prompt 所示);另外,上線之後一定要收集真實使用者的查詢 log,把其中找不到答案的案例加進測試集——這些才是你系統的真實弱點。30 筆手工標注的真實失敗案例,比 100 筆 AI 生成的完美問題更有診斷價值。
坑 4:BM25 索引在記憶體,服務重啟後要重建
症狀:服務重啟後第一個請求很慢,或出現 AttributeError: BM25Retriever object has no attribute 'vectorizer'。
原因:BM25Retriever 把索引存在記憶體中,沒有預設的落地機制。
解法一(最簡單):把所有 docs 存成 docs.json,每次服務啟動讀取後重建 BM25 索引。10 萬個 chunk 以下,重建時間通常在 30 秒內。
解法二(推薦上線後採用):改用支援原生 BM25 落地的方案——ParadeDB(PostgreSQL 擴充,提供完整 BM25 全文索引)或 Qdrant(用 SPLADE sparse vector 存入,效果通常比純 BM25 更好,且可以和 dense vector 一起落地持久化)。
什麼情況下可以不加 reranker
Reranker 帶來兩個成本:延遲和費用。每次查詢要多一個 API round-trip,延遲通常增加 150~400ms;Cohere Rerank v3.5 的費用也比向量 embedding 貴一到兩個數量級。如果你的場景符合以下條件,可以暫時跳過 reranker:
- 文件庫以長篇論述為主(技術部落格、說明文件、新聞),語意相似度已經夠準,BM25 只是小幅補強
- 使用者的查詢很口語,很少出現精確字串查詢
- 對 API 延遲要求很嚴(< 500ms 總響應時間),無法承受額外的 reranker round-trip
反過來說,你的場景如果有以下特徵,reranker 幾乎是必加的:
- 文件庫是技術手冊、法律條文、醫療文件——領域術語密集
- 使用者查詢常帶 error code、型號、條款編號等精確字串
- 你的文件中文英文混雜,或有多種語言混存
- 命中率要求很高(比如客服 RAG:答錯比沒答更糟糕)
一個簡單決策原則:先量 hybrid 的命中率。如果 Hit Rate@5 已經 ≥ 82%,可以考慮不加 reranker 先上線,收集真實使用者的反饋再決定。如果還在 70% 以下,加 reranker 通常是最直接有效的提升手段。
把改善流程標準化

1. 建測試集(30~100 筆 QA 對)
2. 量基準:純向量 Hit Rate@5 和 MRR@10
3. 加 BM25 + RRF → 再量
4. 加 Reranker → 再量
5. 調 top-k、BM25 weights → 再量
6. 找仍然失敗的案例 → 加進測試集 → 回步驟 3
目標數字參考:
- Hit Rate@5 ≥ 80%:可以進入生成端測試
- MRR@10 ≥ 0.65:使用者看到的第一個結果是對的機率夠高
- 如果 Hit Rate 卡在 60% 以下:先回去看第 4 課的切塊策略,很可能是切塊造成的問題,不是檢索算法的問題——算法的上限取決於切塊品質
作業
- 對你的文件庫建 30 筆以上的測試集(包含口語版和縮寫版問題),量出純向量的 Hit Rate@5 和 MRR@10,記下數字。
- 加上 BM25
EnsembleRetriever(中文文件記得加 jieba tokenizer),再量一次,看數字有沒有提升超過 5 個百分點。 - 選做:接上 Voyage rerank-2(前 200M tokens 免費),量 Hybrid+Rerank 的數字,與步驟 1 的基準對比,算出改善幅度。
下一課預告
這堂課讓你的系統找得到正確的文件。但找得到,不代表生成的答案品質好——LLM 還是可能忽略你給的來源、自己腦補出不在文件裡的內容,或者引用了文件但引用的段落對不上。第 6 課《生成端:引用來源與防幻覺》,教你如何設計 system prompt 讓 LLM 強制只用提供的文件作答,如何在後處理層驗證「模型說的有沒有在文件裡」,以及如何在產品介面顯示可點擊的來源,讓使用者能自己驗證答案——這是 RAG 系統從「玩具」變成「可信賴產品」的最後一哩路。