BM25(Best Matching 25)是一种经典且高效的信息检索算法,用于计算用户查询与文档之间的相关性得分,并根据得分对文档进行排序。它至今仍是搜索引擎、知识库等检索系统的核心组件之一。
简单来说,当你在搜索引擎或文档工具中输入关键词时,BM25 就在幕后工作,决定哪些结果应该排在前面。
核心原理:为什么 BM25 更聪明?
BM25 可以看作是经典算法 TF-IDF 的智能进化版。它不仅考虑关键词是否出现,还通过以下三个关键因素来更合理地判断相关性:
-
关键词稀缺性 (IDF)
- 一个词在所有文档中出现得越频繁,它的重要性就越低。例如,“的”、“是”这类常见词对判断相关性帮助不大。
- 相反,像“区块链”、“量子计算”这类只在少数文档中出现的词,其权重会更高,因为它们更能代表文档的核心内容。
-
词频饱和 (TF Saturation)
- 一个词在文档中出现的次数越多,通常说明相关性越高。但 BM25 认为这种相关性不应该无限增长。
- 例如,一个词出现10次和出现100次,其相关性的提升并不是10倍。BM25 通过一个非线性函数来模拟这种“饱和效应”,有效防止了通过堆砌关键词来作弊的行为。
-
文档长度归一化
- 长文档因为包含更多文字,天然有更高概率包含查询词。但这并不代表它比短文档更相关。
- 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]}")