</> 技術筆記Tech Notes

兩份搜尋結果怎麼合?我用 RRF 只看名次

BM25 與向量搜尋各排一份名次,RRF 把兩份結果合成新排名

我在做文件搜尋時,同一個問題會跑兩條路。

一條是關鍵字搜尋。產品代號、錯誤碼和專有名詞對得很準。另一條是向量搜尋。使用者講得很口語,文件裡沒有出現同一組字,它還是有機會找到意思相近的段落。

兩條都留著不難,麻煩的是它們各自交回一份排名。我最後只能給模型一份文件清單,兩份要怎麼合?

我一開始想得很直覺:分數加起來就好了。很快就發現不行。

10.5 不能直接加 0.87

關鍵字搜尋常用 BM25。它的分數可能是 10.5、8.3、5.1,而且沒有一個固定上限。向量搜尋的分數則跟所用的距離算法和產品實作有關,常落在另一個完全不同的範圍。

如果我直接把兩邊相加,數字比較大的那一邊自然會主導結果。那不是融合,只是讓其中一種搜尋換個方式贏兩次。

可以先做 min-max 或 z-score 正規化,但這又帶來新的選擇:用哪一批結果算?遇到離群值怎麼辦?換一個 embedding 模型後門檻要不要重調?

我後來用的 RRF(Reciprocal Rank Fusion)乾脆把原始分數丟掉,只留下名次。

RRF 分數 = Σ 1 / (k + 名次)

這裡的 Σ 代表每一份搜尋排名都算一次再加總。k 是讓名次差距變平緩的常數,常見起點是 60。

真的算一次就懂了

假設 BM25 和向量搜尋各找出 5 篇文件:

BM25:A=1, B=2, C=3, D=4, E=5
向量:B=1, C=2, E=3, D=4, A=5

k = 60 計算後:

五篇文件的兩份名次、1/(60 + 名次) 的算式,以及合併後的總分與最後名次

文件 BM25 名次 向量名次 RRF 總分 最後名次
B 2 1 0.03252 1
C 3 2 0.03200 2
A 1 5 0.03178 3
E 5 3 0.03126 4
D 4 4 0.03125 5

B 在兩邊都排前面,所以贏了。A 雖然拿到一次第 1 名,另一邊只有第 5,最後掉到第 3。

這正是我要的效果:不是獎勵某一邊特別有自信,而是讓兩種不同方法都認為不錯的文件往前走。

k = 60 在控制什麼

如果直接用 1 / 名次,第 1 名是 1,第 2 名是 0.5。一次第 1 名的影響太大,另一份排名很難拉得回來。

加上 60 後,第 1 名是 1 / 61 = 0.01639,第 2 名是 1 / 62 = 0.01613。前幾名還是比較高,但差距沒有大到一票定生死。

60 不是不能動的自然定律。它是一個實務上常用的起點,Microsoft 和 Elasticsearch 的官方說明也使用或預設這個值。數字要不要調,還是要拿自己的查詢和相關性標註去測。

20 行內可以寫完

我用 Python 寫的最小版本是這樣:

def rrf(rankings, k=60):
    """rankings 是多份 {文件 ID: 名次}。"""
    scores = {}

    for ranking in rankings:
        for doc_id, rank in ranking.items():
            scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank)

    return sorted(scores.items(), key=lambda item: item[1], reverse=True)


bm25 = {"A": 1, "B": 2, "C": 3, "D": 4, "E": 5}
vector = {"A": 5, "B": 1, "C": 2, "D": 4, "E": 3}

print(rrf([bm25, vector]))

輸出第一筆會是 B,分數約 0.03252。實際接搜尋引擎時,我不一定會自己寫這段,因為不少產品已經內建 RRF;但自己算過一次後,遇到排名不如預期會比較知道該查哪裡。

出問題的不是公式,是進 RRF 之前

有次融合後的結果一直不理想,我花時間調 k,從 60 試到 30、100,排名有動,但答案沒有真的變好。

症狀是某些完全不相關的段落一直出現在前 5 名。原因是向量搜尋本身撈回來的 50 筆品質就不好,關鍵字搜尋又因為文件切得太碎,把同一篇文章的相似片段佔滿排名。

RRF 只會融合兩份排名,不會治好原本的搜尋。垃圾排第 1,經過公式還是很有影響力。

我後來先做三件事:

  • 檢查文件怎麼切段,避免同一來源洗滿前幾名。

  • 分別量 BM25 和向量搜尋的召回率,不先看融合結果。

  • 用一組人工標過相關文件的問題,比較融合前後的 Recall@K。

兩邊品質差很多時,也可以改成加權 RRF:

加權 RRF 分數 = Σ 權重ᵢ / (k + 名次ᵢ)

不過一加權就要有資料支持。只因為某次 Demo 看起來不順,就把喜歡的那條搜尋乘 2,通常只是把人工偏好寫進公式。

它放在 RAG 的哪裡

我的流程大致是:

  1. 同一個問題送進 BM25 和向量搜尋。

  2. 兩邊各取一批候選文件。

  3. 用 RRF 合成一份排名。

  4. 視需要再做較昂貴的 rerank。

  5. 只把最前面的少量內容交給 LLM。

RRF 解決的是第 3 步,而且只解決這一步。它不負責切段、不負責 embedding,也不保證最後答案正確。

如果只有一種搜尋,我不會多放一層 RRF。有足夠的標註資料、需要追求更高效果時,也可能直接做 Learning to Rank 或模型 reranking。

對我來說,RRF 的價值很樸素:兩份分數單位不同,又沒有一批訓練資料時,它讓我先用一個看得懂、算得出來的方式把排名合起來。先有穩定基準,再決定值不值得做更複雜的東西。

參考資料: