Multi-index pipeline
Semantic search 不夠精準時——加 BM25 lexical search、再用 reranker 合併結果。production RAG 的標準長相。
TL;DR
- 純 vector search 對「精確關鍵字」(API name、錯誤碼、incident ID)出乎意料地爛
- Hybrid = BM25(關鍵字)+ vector(語意)平行跑,再用 RRF 或 reranker 合併
- Reranker(Voyage rerank-2 / Cohere rerank)是「retrieve 太多後讓 model 重新排」的便宜後處理
一個情境:vector 找不到的 incident ID
文件裡有 INC-2023-Q4-011 這個事故編號,出現在「軟體工程」跟「資安」兩節。
User 問:「INC-2023-Q4-011 發生什麼事?」
純 vector search 結果:
| Rank | 結果 | 有提到 ID 嗎 |
|---|---|---|
| 1 | 資安事件報告 | ✓ |
| 2 | 財務分析 | ✗(完全沒提到 incident) |
| 3 | 軟體工程 | ✓ |
第 2 名跑出財務分析,是因為 embedding model 看到「事故、發生」這類詞,就把財務的「異常損益」也算成相關了。INC-2023-Q4-011 這種精確字串對 embedding 來說是 noise,但對 user 來說是搜尋的全部重點。
這就是為什麼要加 lexical search。
Semantic vs Lexical:互補
| Semantic(vector) | Lexical(BM25) | |
|---|---|---|
| 強項 | 同義詞、概念相關 | 精確 token、罕見詞 |
| 弱項 | 罕見 ID / 專有名詞 | 同義詞、改寫 |
| 例:「員工修了多少 bug」 | ✓ 找到「engineering 修了 47 個 issue」 | ✗ 字面沒 "bug" 抓不到 |
| 例:「INC-2023-Q4-011」 | ✗ 把概念相近的也撈進來 | ✓ 直接找包含這串的 chunk |
| 計算 | embedding API + vector DB | 純文字統計,便宜很多 |
兩個剛好補對方的弱點。production RAG 預設都該 hybrid,除非你已經量過純 vector recall 夠高。
BM25 一分鐘
BM25(Best Match 25) 是 1990 年代的搜尋演算法,至今仍是 lexical search 的 baseline。原理三句話:
- 把 query 跟 doc 都 tokenize(拆成 word)
- 罕見 token(在所有 doc 裡很少出現)權重高、常見 token("a"、"the"、"的")權重低
- 含越多高權重 token 的 doc 排越前
from rank_bm25 import BM25Okapi
# 把每個 chunk tokenize(簡單做就 split 空白;中文要先斷詞)
tokenized = [chunk.split() for chunk in chunks]
bm25 = BM25Okapi(tokenized)
q = "What happened with INC-2023-Q4-011?".split()
scores = bm25.get_scores(q) # 每個 chunk 一個分數
top_k = sorted(range(len(scores)), key=lambda i: -scores[i])[:3]
跑同一個 query,結果:
| Rank | BM25 結果 | 有提到 ID 嗎 |
|---|---|---|
| 1 | 軟體工程(提到 ID 三次) | ✓ |
| 2 | 資安事件 | ✓ |
| 3 | 方法論 | ✗ |
完全沒漏掉精確匹配。但 BM25 自己也有弱點:「員工修了多少 bug」這種概念查詢,它會錯過沒寫 "bug" 但寫了 "issue" 的 chunk。
Hybrid pipeline 流程
user query
│
┌──────────┴──────────┐
▼ ▼
[Vector index] [BM25 index]
top-10 top-10
│ │
└──────────┬──────────┘
▼
[Reciprocal Rank Fusion]
│
▼
top-5 chunks
│
▼
(optional rerank)
│
▼
final top-3 → Claude
兩個 index 平行跑,各自拿 top-N,再用一個策略合併。
Reciprocal Rank Fusion(RRF)
合併兩種 search 不能直接相加分數——BM25 分數可能 0–20、cosine 是 0–1,尺度根本不一樣。RRF 改用「排名」而不是「分數」:
RRF(d) = Σ 1 / (k + rank_i(d))
i
k 通常 60。對每個 doc:在每個 index 裡查到它排第幾,套公式相加。在兩個 index 都排前面的 doc 自然得分高。
from collections import defaultdict
def rrf(rankings: list[list[str]], k: int = 60) -> dict[str, float]:
"""rankings: [[doc_id 排序好的 list], ...]"""
scores = defaultdict(float)
for ranks in rankings:
for i, doc_id in enumerate(ranks):
scores[doc_id] += 1 / (k + i + 1)
return scores
merged = rrf([vector_top_ids, bm25_top_ids])
final = sorted(merged, key=merged.get, reverse=True)[:5]
無 ML、不用訓練、跑得快,是 production 跑 hybrid 的標配方法。
Rerank:再加一層精準度
RRF 是 rule-based 合併;要再準一點,上 reranker:用專門的小 model 把 (query, chunk) pair 重新打分。
result = vo.rerank(
query=q,
documents=retrieved_chunks, # RRF 後的 top-20
model="rerank-2",
top_k=3,
)
final = [r.document for r in result.results]
順序:vector + BM25 各拿 top-20 → RRF 合併拿 top-10 → rerank 最後挑 top-3。主流選擇:Voyage rerank-2、Cohere rerank-v3,或自架 cross-encoder(bge-reranker)。Rerank 比 embedding 貴,但只跑 top-N,總成本可控。
Multi-index:不只一個 vector store
廣義的 multi-index 是指不同類型內容用不同 retrieval 策略:FAQ 短 QA 用 embedding、規格文件用 hybrid、程式碼以 BM25 為主(symbol 名稱)、客服 transcript 加時間 metadata filter。每個 index 自己一條 pipeline,外層 Retriever 抽象成一個 protocol(add(doc) + search(query, k)),fan-out 後再合併。新加一個 index 不用改外層。
接下來
到這裡 RAG section 完整了——你知道為什麼要 RAG、怎麼切 chunk、怎麼算 similarity、怎麼 hybrid + rerank。下一個 section 換到 Claude 進階能力,第一篇是 Prompt caching——RAG 之後馬上會撞到「同樣 system prompt 重複送很貴」的問題,prompt caching 就是針對這個來的。

