3、BM25算法:公式推导、参数调优与实战
聊完了TF-IDF,咱们来看看它的进阶版——BM25。说实话,我在做搜索排序的头两年,一直用的都是TF-IDF。直到有一次做一个文档检索系统,发现TF-IDF对长文档特别不友好,长文档明明相关,得分却被稀释了。后来一查,BM25就是来解决这个问题的。
3.1 BM25公式推导:从直觉到数学
BM25的全称是Okapi BM25,它本质上是对TF-IDF的一种改进。核心思想就两点:
- 词频不能无限增长——一个词出现10次和出现100次,相关度不应该差10倍
- 文档长度要归一化——长文档天然词频高,得给它"降权"
先看BM25的完整公式:
Score(Q, D) = Σ (IDF(qi) * (TF(qi, D) * (k1 + 1)) / (TF(qi, D) + k1 * (1 - b + b * |D| / avgdl)))
别被这一长串吓到。咱们拆开来看:
第一部分:IDF(qi)
BM25里的IDF和传统IDF略有不同:
IDF(qi) = log((N - n(qi) + 0.5) / (n(qi) + 0.5) + 1)
这里N是文档总数,n(qi)是包含词qi的文档数。为什么要加0.5?说白了就是平滑处理,防止分母为0。我在项目中遇到过极端情况——某个生僻词只在1篇文档中出现,如果不加平滑,IDF会飙得特别高,导致搜索结果被这个词主导。
第二部分:词频饱和部分
这是BM25的精髓:
TF_saturated = (TF * (k1 + 1)) / (TF + k1 * (1 - b + b * |D| / avgdl))
你想想看,当TF很小的时候,这个值近似等于TF;当TF很大的时候,它会趋近于(k1+1)。这就实现了"词频饱和"——一个词出现再多,对得分的贡献也有上限。
核心洞察:BM25通过k1控制饱和速度,通过b控制长度归一化的力度。这两个参数,就是调优的关键。
3.2 参数调优:k1和b的实战经验
k1参数:控制词频饱和
- k1 = 0:完全忽略词频,只看IDF
- k1 = 1.2~2.0:常用范围,我一般从1.5开始调
- k1 越大,词频影响越大,饱和越慢
b参数:控制长度归一化
- b = 0:不做长度归一化
- b = 1:完全长度归一化
- b = 0.75:经典值,大部分场景适用
我的调参习惯:先用k1=1.5, b=0.75跑基线。然后固定b,在[1.0, 2.0]区间搜索k1。最后固定k1,在[0.5, 1.0]区间搜索b。一般3~5轮就能找到不错的组合。
我曾经踩过的坑:有一次在短文本搜索场景(比如商品标题搜索),我直接把b设成了0.75。结果长标题的商品永远排前面。后来改成b=0.3,效果立竿见影。短文本场景,长度归一化要轻一些。
3.3 BM25 vs TF-IDF:到底强在哪?
| 对比维度 | TF-IDF | BM25 |
|---|---|---|
| 词频处理 | 线性增长,无上限 | 非线性饱和,有上限 |
| 文档长度 | 不处理,长文档吃亏 | 可调节的长度归一化 |
| 参数数量 | 无参数 | 2个可调参数 |
| 适用场景 | 简单搜索、快速原型 | 生产环境、精度要求高 |
| 计算复杂度 | 低 | 略高(可忽略) |
说白了,TF-IDF是个"老实人"——词频多少就是多少。BM25是个"聪明人"——知道词频到一定程度就没那么重要了。我个人的经验是:如果数据量小于1万条,TF-IDF完全够用;超过10万条,BM25的优势就体现出来了。
3.4 代码实现:从零手写BM25
嗯,这里要注意。很多库都封装了BM25,但自己手写一遍才能真正理解。我当年面试候选人,就喜欢让人手写BM25——能写出来的,搜索基础一定扎实。
import math
from collections import Counter
class BM25:
def __init__(self, documents, k1=1.5, b=0.75):
"""
documents: 分词后的文档列表,每篇文档是词列表
k1: 词频饱和参数
b: 长度归一化参数
"""
self.k1 = k1
self.b = b
self.documents = documents
self.N = len(documents)
self.avgdl = sum(len(doc) for doc in documents) / self.N
self.doc_freqs = self._compute_doc_freqs()
self.idf = self._compute_idf()
def _compute_doc_freqs(self):
"""计算每个词出现在多少篇文档中"""
doc_freqs = Counter()
for doc in self.documents:
# 每篇文档中的词只计一次
unique_words = set(doc)
for word in unique_words:
doc_freqs[word] += 1
return doc_freqs
def _compute_idf(self):
"""计算每个词的IDF值"""
idf = {}
for word, freq in self.doc_freqs.items():
# 使用BM25的IDF公式
idf[word] = math.log((self.N - freq + 0.5) / (freq + 0.5) + 1)
return idf
def score(self, query, doc_index):
"""计算查询和某篇文档的BM25得分"""
doc = self.documents[doc_index]
doc_len = len(doc)
score = 0.0
# 统计文档中的词频
doc_term_freqs = Counter(doc)
for term in query:
if term not in self.idf:
continue
tf = doc_term_freqs.get(term, 0)
if tf == 0:
continue
# BM25核心公式
idf_part = self.idf[term]
tf_part = (tf * (self.k1 + 1)) / (tf + self.k1 * (1 - self.b + self.b * doc_len / self.avgdl))
score += idf_part * tf_part
return score
def search(self, query, top_k=10):
"""搜索最相关的top_k篇文档"""
scores = []
for i in range(self.N):
s = self.score(query, i)
scores.append((i, s))
# 按得分降序排列
scores.sort(key=lambda x: x[1], reverse=True)
return scores[:top_k]
# 使用示例
if __name__ == "__main__":
# 准备数据
docs = [
"自然语言处理是人工智能的重要分支".split(),
"深度学习在自然语言处理中取得了巨大成功".split(),
"搜索引擎的核心技术包括索引和排序".split(),
"BM25算法是信息检索中的经典排序算法".split(),
"机器学习模型需要大量的训练数据".split()
]
# 初始化BM25
bm25 = BM25(docs, k1=1.5, b=0.75)
# 搜索
query = "自然语言处理 算法".split()
results = bm25.search(query, top_k=3)
print("查询:", " ".join(query))
print("搜索结果:")
for idx, score in results:
print(f" 文档{idx}: {' '.join(docs[idx])} (得分: {score:.4f})")
代码优化建议:生产环境中,IDF可以预计算并缓存。另外,如果文档量很大,建议用倒排索引加速——先通过倒排索引找到包含查询词的文档,再计算得分,避免遍历所有文档。
3.5 实战中的注意事项
最后分享几个我在项目中积累的经验:
- 中文分词很重要——BM25对分词质量敏感,建议用jieba或HanLP做精细分词
- 停用词要处理——"的"、"了"这类高频词会拉低IDF,建议建一个停用词表
- 参数不是万能的——如果数据质量差,调参也救不了。先清洗数据,再调参数
- 可以结合业务规则——比如电商搜索,BM25得分乘以一个"商品质量分",效果更好
我曾经在一个新闻推荐项目里,直接用BM25跑出来的结果,用户点击率只有3%。后来加了时效性权重——新闻越新得分越高,点击率直接翻倍。算法是基础,业务理解才是灵魂。