API 開發
檢索品質不夠好?用 BM25 與混合檢索補上語意搜尋的盲點
檢索品質不夠好?用 BM25 與混合檢索補上語意搜尋的盲點
系列前幾篇把 RAG 的基本管線搭起來了:切塊、embedding、向量檢索。但實際跑起來你很快會發現:光靠語意搜尋,結果不一定最好。
這篇要補上最後一塊拼圖:什麼情境語意搜尋會失手、老牌的 BM25 關鍵字搜尋怎麼救場,以及怎麼把兩路結果合併成一份更可靠的排名。
你將學到什麼
語意搜尋的盲點
查特定編號時,語意搜尋為什麼會失手。
BM25 的原理
四個步驟看懂這個經典的關鍵字搜尋演算法。
混合檢索架構
兩路搜尋並行,各自發揮擅長的事。
RRF 合併排名
用 reciprocal rank fusion 公平地融合兩份排名。
語意搜尋的盲點:查編號查不到
先回顧一下:前一篇的語意搜尋把提問與切塊都轉成 embedding,再用相似度找出意思最接近的段落。大多數時候這招很好用,但有個情境會點出它的罩門:你要在文件裡查一個特定的事件編號「INC-2023-Q4-011」。
語意搜尋很會理解上下文與概念,但它回傳的結果裡,除了真的含有這個編號的資安章節,還混進了一段完全沒提到這個編號的財務分析。因為語意搜尋比的是概念相似,不是字面有沒有出現。
對「幫我解釋這個概念」這類提問,語意搜尋是強項;但對編號、代碼、專有名詞這種必須精確命中的查詢,它會失手。這就是要引入字面搜尋(lexical search)的原因。
BM25:給稀有詞更高權重的關鍵字搜尋
BM25(Best Match 25)是 RAG 系統裡最常用的字面搜尋演算法。它處理一個查詢的過程可以拆成四步:
- 斷詞:把提問拆成一個個詞,例如「a INC-2023-Q4-011」拆成兩個詞。
- 統計詞頻:計算每個詞在所有文件裡出現的次數。
- 依稀有度加權:越少出現的詞越重要。常見的「a」權重很低,罕見的「INC-2023-Q4-011」權重很高。
- 找最佳匹配:含有較多高權重詞的文件,排名越前面。
用起來跟向量檢索幾乎一樣,只是把索引換成 BM25:
chunks = chunk_by_section(text)
store = BM25Index()
for chunk in chunks:
store.add_document({"content": chunk})
results = store.search("What happened with INC-2023-Q4-011?", 3)
for doc, distance in results:
print(distance, "\n", doc["content"][:200], "\n----\n")
同一個查詢改跑 BM25,回傳的就是真正含有那個編號的章節。它特別擅長技術名詞、編號與特定片語,因為它做的正是語意搜尋不做的事:認字,不猜意思。
對照一下兩者的個性就很清楚:語意搜尋把「車子壞了」與「汽車故障」視為近親,BM25 則把它們當成兩組不同的字;反過來,一長串沒有語意的事件編號,在 embedding 眼裡面目模糊,在 BM25 眼裡卻是全場最醒目的稀有詞。
混合檢索:兩條路並行,再合併
既然兩種方法各有擅場,最好的策略就是都用:同一個提問同時送進向量索引與 BM25 索引,兩邊各自回傳一份排名,再把兩份排名融合成一份。語意搜尋負責理解概念,字面搜尋確保精確詞不漏接。
兩路搜尋彼此獨立、可以並行執行,不會互相拖慢,合併只是最後一步輕量的計算。
reciprocal rank fusion:排名怎麼公平合併
合併不能只是把兩份清單接在一起,因為兩種搜尋的計分方式完全不同。reciprocal rank fusion(RRF)的做法是不看分數、只看名次:每份排名裡,文件的名次越前面得分越高,再把各份排名的得分加總。
這個設計的好處是完全不需要理解各索引的分數怎麼算,只要每個索引吐得出一份排序,就能參與融合。公式是:
RRF_score(d) = sum( 1 / (k + rank_i(d)) )
# k 是常數,實務上常用 60;這裡為了讓數字好讀,示範時用 k = 1
# rank_i(d) 是文件 d 在第 i 份排名裡的名次
實際跑一次:查詢那個事件編號後,向量索引回傳的排名是章節二、章節七、章節六;BM25 回傳的是章節六、章節二、章節七。用 k 等於 1 帶進公式:
| 文件 | 向量索引名次 | BM25 名次 | RRF 得分 |
|---|---|---|---|
| 章節二 | 1 | 2 | 0.833 |
| 章節六 | 3 | 1 | 0.75 |
| 章節七 | 2 | 3 | 0.583 |
最終排名變成章節二、章節六、章節七。這很符合直覺:在兩邊都表現不錯的文件,自然浮到最上面。實測也印證了效果:原本向量檢索把不相干的財務分析排到第二名,混合檢索之後,回傳的前兩名變成真正相關的資安事件章節與軟體工程章節。
同一個介面,隨時加新的索引
實作上有個漂亮的設計:VectorIndex 與 BM25Index 的 API 幾乎一模一樣,都有加入文件與搜尋兩個方法。所以可以再包一層 Retriever,把提問轉發給所有索引、收集結果、做 RRF 融合:
class Retriever:
def __init__(self, *indexes: SearchIndex):
if len(indexes) == 0:
raise ValueError("At least one index must be provided")
self._indexes = list(indexes)
def add_document(self, document: Dict[str, Any]):
for index in self._indexes:
index.add_document(document)
def search(self, query_text: str, k: int = 1, k_rrf: int = 60):
# 1. 把查詢送進每一個索引
# 2. 記錄每份文件在各索引的名次
# 3. 套 RRF 公式加總得分,排序後回傳
...
到這裡,RAG 系列的核心拼圖就齊了:切塊、embedding、向量檢索,加上這篇的 BM25 與混合融合。兩種搜尋是互補的,語意搜尋懂脈絡,字面搜尋不漏字,合起來才是一套經得起實戰的檢索系統。
