精華筆記

· @aihub.tw

RAG 系統實作

檢索品質:hybrid search 與 rerank

檢索品質: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 就是補這個洞的標準做法。

這堂課適合誰 適合:完成前 4 課、已有一個能跑的 RAG 系統、想提升實際命中率的工程師。需要基礎:Python 基本語法、會用 pip 安裝套件、看得懂第 3 課的向量庫程式碼。前置課:第 3 課(向量庫實作)與第 4 課(切塊策略)。

這堂學什麼

  • 純向量檢索的失敗模式:哪些場景 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 表示往往不準確,或者因為上下文切片方式讓語意被稀釋。

向量 vs BM25 失敗案例對照:同一個查詢,向量距離遠導致排名差,BM25 卻能精確命中

典型失敗場景:

  • 使用者查「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 融合流程圖:BM25 與向量兩路召回清單經 1/(60+rank) 計算合成一份 top-5 排序

不需要費心調整「兩條路相對重要性」的超參數,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。

二階段檢索管道架構:query 經 BM25 與向量各召回 top-20,RRF 融合成 top-30 候選,再由 Reranker 精排 top-5 送入 LLM,各節點標注延遲

為什麼分兩階段而不是一開始就跑 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 就好。

四個版本命中率對比長條圖:純向量、Hybrid、Hybrid+Rerank-lite、Hybrid+Rerank-full 的 Hit Rate@5 與 MRR@10,標示目標線 80% 與 0.65

常見坑

坑 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 通常是最直接有效的提升手段。

把改善流程標準化

持續改善循環流程圖:建測試集→量基準→加 BM25 hybrid→加 Reranker→找失敗案例→加入測試集,循環收斂到 Hit Rate@5 ≥ 80%、MRR@10 ≥ 0.65

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 課的切塊策略,很可能是切塊造成的問題,不是檢索算法的問題——算法的上限取決於切塊品質

作業

  1. 對你的文件庫建 30 筆以上的測試集(包含口語版和縮寫版問題),量出純向量的 Hit Rate@5 和 MRR@10,記下數字。
  2. 加上 BM25 EnsembleRetriever(中文文件記得加 jieba tokenizer),再量一次,看數字有沒有提升超過 5 個百分點。
  3. 選做:接上 Voyage rerank-2(前 200M tokens 免費),量 Hybrid+Rerank 的數字,與步驟 1 的基準對比,算出改善幅度。

下一課預告

這堂課讓你的系統找得到正確的文件。但找得到,不代表生成的答案品質好——LLM 還是可能忽略你給的來源、自己腦補出不在文件裡的內容,或者引用了文件但引用的段落對不上。第 6 課《生成端:引用來源與防幻覺》,教你如何設計 system prompt 讓 LLM 強制只用提供的文件作答,如何在後處理層驗證「模型說的有沒有在文件裡」,以及如何在產品介面顯示可點擊的來源,讓使用者能自己驗證答案——這是 RAG 系統從「玩具」變成「可信賴產品」的最後一哩路。

#RAG#hybrid search#BM25#rerank#檢索評估#向量搜尋

← 回所有文章