RAG常用到的BM25算法详解

2 阅读5分钟

BM25(Best Matching 25)是一种经典且高效的信息检索算法,用于计算用户查询与文档之间的相关性得分,并根据得分对文档进行排序。它至今仍是搜索引擎、知识库等检索系统的核心组件之一。

简单来说,当你在搜索引擎或文档工具中输入关键词时,BM25 就在幕后工作,决定哪些结果应该排在前面。

核心原理:为什么 BM25 更聪明?

BM25 可以看作是经典算法 TF-IDF 的智能进化版。它不仅考虑关键词是否出现,还通过以下三个关键因素来更合理地判断相关性:

  1. 关键词稀缺性 (IDF)

    • 一个词在所有文档中出现得越频繁,它的重要性就越低。例如,“的”、“是”这类常见词对判断相关性帮助不大。
    • 相反,像“区块链”、“量子计算”这类只在少数文档中出现的词,其权重会更高,因为它们更能代表文档的核心内容。
  2. 词频饱和 (TF Saturation)

    • 一个词在文档中出现的次数越多,通常说明相关性越高。但 BM25 认为这种相关性不应该无限增长。
    • 例如,一个词出现10次和出现100次,其相关性的提升并不是10倍。BM25 通过一个非线性函数来模拟这种“饱和效应”,有效防止了通过堆砌关键词来作弊的行为。
  3. 文档长度归一化

    • 长文档因为包含更多文字,天然有更高概率包含查询词。但这并不代表它比短文档更相关。
    • BM25 会惩罚过长的文档,让长短不同的文档能在更公平的基础上进行比较。一个在短文档中出现的关键词,其权重通常会高于在长文档中出现同样次数的关键词。

⚙️ 关键参数:k1 和 b

BM25 的灵活性体现在它有两个可调节的参数,用于适应不同的检索场景:

参数作用典型取值
k1词频饱和度调节因子。控制词频对得分的影响程度。k1 越大,词频的影响越大,饱和得越慢。1.2 ~ 2.0
b文档长度归一化因子。控制文档长度对得分的影响。b 越大,对长文档的惩罚力度越大。0.75

算法对比:BM25 vs. TF-IDF vs. 深度学习

算法核心特点优势劣势
BM25基于词频、IDF和文档长度的加权评分计算快、效果稳定、抗关键词堆砌无法理解语义(如“手机”和“移动电话”)
TF-IDF仅基于词频和IDF原理简单、计算极快不考虑文档长度,易受长文档干扰
深度学习模型基于语义理解(如 BERT)语义匹配精准,能识别同义词计算量大、对硬件要求高

主要应用场景

凭借其“快、稳、准”的优势,BM25 被广泛应用于:

  • 搜索引擎:网页搜索、电商商品搜索等。
  • 文档检索:企业知识库、文献数据库、Notion 等工具的站内搜索。
  • 对话系统:智能客服机器人通过匹配 FAQ 来回答用户问题。
  • 混合检索:在现代 RAG(检索增强生成)系统中,BM25 常与向量检索(负责语义理解)结合使用,形成混合检索,以同时保证关键词匹配的精确性和语义理解的广度。

下面是一个简单的python实现,用于理解BM25算法的概念

import math
from collections import Counter

class BM25:
    def __init__(self, k1=1.5, b=0.75):
        """
        初始化 BM25 参数
        :param k1: 词频饱和度参数 (通常 1.2 ~ 2.0)
        :param b: 文档长度归一化参数 (通常 0.75)
        """
        self.k1 = k1
        self.b = b
        self.doc_freqs = []  # 每个文档的词频统计
        self.doc_len = []    # 每个文档的长度
        self.idf = {}        # 词的 IDF 值
        self.avg_doc_len = 0 # 平均文档长度

    def fit(self, corpus):
        """
        训练/索引阶段:计算 IDF 和文档统计信息
        :param corpus: 二维列表,例如 [['我', '爱', '中国'], ['我', '爱', '编程']]
        """
        N = len(corpus)
        self.doc_freqs = []
        self.doc_len = []
        
        # 1. 统计每个文档的词频和长度
        for doc in corpus:
            self.doc_freqs.append(Counter(doc))
            self.doc_len.append(len(doc))
        
        # 2. 计算平均文档长度
        self.avg_doc_len = sum(self.doc_len) / N

        # 3. 计算 IDF (Inverse Document Frequency)
        # 这里的 IDF 公式采用标准的 Robertson 变体: log((N - n + 0.5) / (n + 0.5) + 1)
        vocab = set()
        for doc in corpus:
            vocab.update(doc)
            
        for term in vocab:
            n = sum(1 for doc_freq in self.doc_freqs if term in doc_freq) # 包含该词的文档数
            if n == 0:
                self.idf[term] = 0
            else:
                # 经典的 BM25 IDF 公式
                self.idf[term] = math.log((N - n + 0.5) / (n + 0.5) + 1)

    def score(self, query, doc_id):
        """
        计算单个查询与单个文档的相关性得分
        :param query: 查询词列表,例如 ['爱', '编程']
        :param doc_id: 文档索引
        :return: 得分
        """
        score = 0.0
        doc_len = self.doc_len[doc_id]
        doc_freq = self.doc_freqs[doc_id]

        for term in query:
            if term not in self.idf:
                continue
            
            # 获取词频 (TF)
            tf = doc_freq.get(term, 0)
            idf = self.idf[term]
            
            # BM25 核心公式
            # 分子: idf * (k1 + 1) * tf
            # 分母: tf + k1 * (1 - b + b * doc_len / avg_doc_len)
            numerator = idf * (self.k1 + 1) * tf
            denominator = tf + self.k1 * (1 - self.b + self.b * doc_len / self.avg_doc_len)
            
            score += numerator / denominator
            
        return score

    def search(self, query, top_k=5):
        """
        搜索接口:对所有文档打分并排序
        """
        scores = []
        for i in range(len(self.doc_freqs)):
            score = self.score(query, i)
            scores.append((i, score))
        
        # 按得分降序排序
        scores.sort(key=lambda x: x[1], reverse=True)
        return scores[:top_k]

# --- 简单的中文分词辅助函数 (实际项目中建议使用 jieba) ---
def simple_tokenize(text):
    # 这里仅按字符切分用于演示,实际应用请用 jieba.analyse 或 jieba.lcut
    return list(text) 

# --- 测试代码 ---
if __name__ == "__main__":
    # 1. 准备语料库
    documents = [
        "谷歌搜索引擎非常强大",
        "百度也是中国的搜索引擎巨头",
        "Python 是一门编程语言",
        "自然语言处理是人工智能的核心",
        "BM25 算法常用于搜索引擎排序"
    ]
    
    # 分词
    corpus = [simple_tokenize(doc) for doc in documents]
    query_text = "百度"
    query = simple_tokenize(query_text)

    # 2. 初始化并训练
    bm25 = BM25(k1=1.5, b=0.75)
    bm25.fit(corpus)

    # 3. 搜索
    results = bm25.search(query, top_k=3)

    print(f"查询: '{query_text}'")
    print("-" * 30)
    for rank, (doc_id, score) in enumerate(results):
        print(f"排名 {rank+1}: [得分: {score:.4f}] -> {documents[doc_id]}")