1. 什么是 BM25? #
BM25(Best Match 25)是 Okapi BM25 的简称,是 1994 年提出的一种用于文档检索的概率相关性评分算法。它基于 TF-IDF(词频-逆文档频率)框架改进而来,主要用于搜索引擎中计算查询与文档的相关性得分。
简单来说,BM25 是一个数学公式,用来计算一个查询(比如"苹果公司")和一篇文档(比如"苹果公司发布了新款iPhone手机")的相关性有多高。分数越高,表示文档与查询越相关。
BM25 的特点:
- 无需训练:不需要机器学习模型,直接使用数学公式计算
- 经典算法:虽然已有 30 年历史,但仍然是许多搜索引擎的默认算法
- 效果优秀:在大多数文本检索任务中表现优异
- 计算高效:适合大规模文档检索
2. 为什么需要 BM25? #
在实际应用中,我们经常需要从大量文档中找到与查询最相关的文档。例如:
- 搜索引擎:用户输入"Python教程",需要从百万网页中找到最相关的
- 问答系统:用户提问"什么是机器学习?",需要从知识库中找到最相关的答案
- 文档检索:在大量文档中搜索特定主题的内容
如果只是简单地看文档中是否包含查询词,会有很多问题:
- 无法区分重要词和普通词(如"的"、"是"等停用词)
- 无法处理词频的影响(一个词出现 10 次和出现 1 次应该不同)
- 无法处理文档长度的影响(长文档更容易包含查询词,但不一定更相关)
BM25 通过数学公式巧妙地解决了这些问题,能够更准确地评估文档与查询的相关性。
3. 前置知识 #
在学习 BM25 之前,我们需要了解一些基础概念:
3.1 词频(Term Frequency, TF) #
词频是指一个词在文档中出现的次数。例如:
- 文档:"苹果公司发布了新款iPhone手机,苹果公司市值再创新高"
- "苹果"出现 2 次,TF("苹果") = 2
- "公司"出现 2 次,TF("公司") = 2
- "手机"出现 1 次,TF("手机") = 1
直觉:一个词在文档中出现次数越多,该文档可能与该词越相关。
3.2 文档频率(Document Frequency, DF) #
文档频率是指包含某个词的文档数量。例如:
- 文档集合中有 100 篇文档
- 其中 5 篇包含"苹果",则 DF("苹果") = 5
- 其中 80 篇包含"的",则 DF("的") = 80
直觉:一个词在越少的文档中出现,它越能区分文档(如"苹果"比"的"更有区分度)。
3.3 逆文档频率(Inverse Document Frequency, IDF) #
逆文档频率是文档频率的倒数,用于衡量词的稀有程度:
$$ \text{IDF}(词) = \log\left(\frac{\text{总文档数}}{\text{包含该词的文档数}}\right) $$
例如:
- 总文档数 = 100
- DF("苹果") = 5,则 IDF("苹果") = log(100/5) = log(20) ≈ 3.0
- DF("的") = 80,则 IDF("的") = log(100/80) = log(1.25) ≈ 0.22
- log 是 以 e 为底的自然对数,e 是一个数学常数,称为自然常数,其近似值为2.7
- 直觉:IDF 越大,表示词越稀有,越能区分文档。
3.4 TF-IDF #
TF-IDF 是词频和逆文档频率的乘积:
$$ \text{TF-IDF}(词, 文档) = \text{TF}(词, 文档) \times \text{IDF}(词) $$
直觉:一个词在文档中出现次数多(TF 大),且在整个文档集合中稀有(IDF 大),则该词对该文档的重要性越高。
3.5 文档长度归一化 #
文档长度归一化是指:对不同长度的文档在相关性计算时进行校正,避免长文档因为包含更多词而在检索评分中“不公平地”占优势。
为什么需要长度归一化?
- 长文档自然包含更多词,命中查询词的概率更高;
- 如果不做归一化,长文档会因为词多而被高估相关性。
如何归一化?
BM25 采用如下归一化方式,通过 $\text{avgdl}$(平均文档长度)和一个可调参数 $b$ 来实现:
$$ \text{归一化项} = 1 - b + b \times \frac{|d|}{\text{avgdl}} $$
其中:
- $|d|$:当前文档的长度(词数)
- $\text{avgdl}$:集合中所有文档的平均长度
- $b$:平衡参数(常用取值 $0.75$,范围 $[0,1]$)
在 BM25 公式中,长度归一化通过分母体现:
$$ \frac{\text{TF}(t, d) \times (k_1 + 1)}{\text{TF}(t, d) + k_1 \times \left(1 - b + b \times \frac{|d|}{\text{avgdl}}\right)} $$
直观理解:
- $b=0$ 时,不做长度归一化,所有文档同等对待。
- $b=1$ 时,完全按文档长度进行归一化,长文档影响最大。
- $b$ 一般设为 $0.75$,即部分归一化,经验表现较好。
举例说明:
- 平均长度 $\text{avgdl}=100$,某文档 $|d|=50$,$b=0.75$
归一化项 $=1-0.75+0.75\times \frac{50}{100}=0.25+0.375=0.625$
分母变小,短文档加权变大。 - 如果 $|d|=200$,
归一化项 $=1-0.75+0.75\times \frac{200}{100}=0.25+1.5=1.75$
分母变大,长文档加权变小。
这样可以让短文档、长文档在相关性评分时处于公平位置,防止长文档“滥占优势”。
3.6 词频饱和度 #
词频饱和度指的是:词频的提升对相关性的贡献不是线性的,而是逐渐减缓的。即:
- 一个词在文档中出现 1 次时相关性增加很大,
- 出现 2 次时继续提升相关性,
- 但之后每增加一次,它带来的提升会变得越来越小,趋于饱和。
在 BM25 中,采用了如下的词频饱和计算方式:
$$ \text{TF-Sat}(t, d) = \frac{\text{TF}(t, d) \times (k_1 + 1)}{\text{TF}(t, d) + k_1} $$
其中 $ k_1 $ 为词频饱和参数(典型取值为 1.2-2.0,推荐 1.5),其作用是控制词频饱和的速度。
- $ \text{TF}(t, d) $ 很小时,分数随词频增加提升较快;
- $ \text{TF}(t, d) $ 很大时,分数增长逐渐趋缓,不会无限增大。
直观理解:
- 当一个词在文档中的出现次数很高时,它对文档的重要性不会无限增加,而是慢慢趋于一个上限,这更符合人类的认知和实际检索需求。
举例说明:
假设 $k_1 = 1.5$,词频 TF 从 1 到 5 时 TF 饱和项计算如下:
| 词频 TF | TF饱和值 $\frac{\text{TF} \times 2.5}{\text{TF} + 1.5}$ |
|---|---|
| 1 | $ \frac{2.5}{2.5} = 1.0 $ |
| 2 | $ \frac{5.0}{3.5} \approx 1.43 $ |
| 3 | $ \frac{7.5}{4.5} \approx 1.67 $ |
| 5 | $ \frac{12.5}{6.5} \approx 1.92 $ |
| 50 | $ \frac{125}{51.5} \approx 2.43 $ |
| 500 | $ \frac{1250}{501.5} \approx 2.49 $ |
| 5000 | $ \frac{12500}{5001.5} \approx 2.5 $ |
| 50000 | $ \frac{125000}{50001.5} \approx 2.5 $ |
可以看到词频越高,TF饱和值的增长幅度逐渐减小,体现了一种饱和式的增长效果。
4. 核心思想 #
BM25 的核心思想是:一个词在文档中出现的次数越多,同时在整个文档集合中出现的次数越少,则该词对于该文档的相关性贡献越大。
具体来说:
- 词频(TF):词在文档中出现次数越多,相关性越高(但有上限,不会无限增长)
- 逆文档频率(IDF):词在整个文档集合中越稀有,区分度越高,权重越大
- 文档长度归一化:避免长文档因为包含更多词而获得不公平优势
BM25 相比 TF-IDF 的改进:
- 词频饱和:词频不会无限增长,达到一定次数后增长变慢(更符合实际情况)
- 更好的长度归一化:通过参数 b 灵活控制文档长度的影响
- 概率模型:基于概率理论,更符合信息检索的实际需求
5. BM25公式 #
BM25 的完整公式如下:
$$ \text{score}(D, Q) = \sum_{i=1}^{n} \text{IDF}(q_i) \times \frac{\text{TF}(q_i, D) \times (k_1 + 1)}{\text{TF}(q_i, D) + k_1 \times \left(1 - b + b \times \frac{|D|}{\text{avgdl}}\right)} $$
其中:
- $D$:文档
- $Q$:查询(由多个词组成:$q_1, q_2, ..., q_n$)
- $q_i$:查询中的第 $i$ 个词
- $\text{TF}(q_i, D)$:词 $q_i$ 在文档 $D$ 中出现的次数
- $|D|$:文档 $D$ 的长度(词数)
- $\text{avgdl}$:所有文档的平均长度
- $k_1$:词频饱和度参数(通常取 1.2-2.0,默认 1.5)
- $b$:长度归一化参数(通常取 0.75)
5.1 IDF 部分 #
$$ \text{IDF}(q_i) = \log\left(\frac{N - n(q_i) + 0.5}{n(q_i) + 0.5}\right) $$
其中:
- $N$:文档集合中的总文档数
- $n(q_i)$:包含词 $q_i$ 的文档数
- 0.5 是平滑参数,防止除零和负值
作用:衡量词的稀有程度,词越稀有,IDF 值越大,权重越高。
示例:
- 总文档数 $N = 1000$
- 包含"苹果"的文档数 $n(\text{苹果}) = 10$,则 $\text{IDF}(\text{苹果}) = \log((1000-10+0.5)/(10+0.5)) \approx 4.6$
- 包含"的"的文档数 $n(\text{的}) = 900$,则 $\text{IDF}(\text{的}) = \log((1000-900+0.5)/(900+0.5)) \approx -2.7$
可以看到,"苹果"的 IDF 值远大于"的",说明"苹果"更能区分文档。
5.2 TF 部分(词频调整) #
BM25 对词频进行了非线性调整:
$$ \text{adjusted_TF} = \frac{\text{TF} \times (k_1 + 1)}{\text{TF} + k_1 \times \text{normalization}} $$
其中 normalization 是文档长度归一化项。
为什么需要调整?
- 词频为 1 时,调整后 ≈ 2.5(假设 $k_1 = 1.5$)
- 词频为 5 时,调整后 ≈ 4.2(增长变慢)
- 词频为 10 时,调整后 ≈ 5.4(增长更慢)
这符合实际情况:一个词出现 1 次和出现 5 次差异很大,但出现 50 次和出现 55 次差异很小。
5.3 文档长度归一化 #
$$ \text{normalization} = 1 - b + b \times \frac{|D|}{\text{avgdl}} $$
其中:
- $|D|$:当前文档长度(词数)
- $\text{avgdl}$:所有文档的平均长度
- $b$:长度归一化参数(0 ≤ b ≤ 1)
参数 b 的作用:
- $b = 0$:不进行长度归一化
- $b = 1$:完全长度归一化
- $b = 0.75$:默认值,平衡长度影响
示例:
- 平均文档长度 $\text{avgdl} = 100$
- 短文档长度 $|D| = 50$,则 normalization = 1 - 0.75 + 0.75 × (50/100) = 0.625
- 长文档长度 $|D| = 200$,则 normalization = 1 - 0.75 + 0.75 × (200/100) = 1.5
长文档的 normalization 更大,会降低其 TF 调整值,从而降低最终分数,避免长文档获得不公平优势。
6. BM25实现 #
6.1 代码实现 #
# 导入必要的库
import math
from collections import Counter
import jieba
class BM25:
def __init__(self, documents, k1=1.5, b=0.75):
"""
初始化 BM25 模型
参数:
documents: 文档列表,每个文档是一个词列表
k1: 词频饱和度参数,默认 1.5
b: 长度归一化参数,默认 0.75
"""
# 存储文档集合(每个文档是词的列表)
self.documents = documents
# 文档总数
self.N = len(documents)
# BM25 参数
self.k1 = k1
self.b = b
# 计算平均文档长度
# 所有文档的长度之和除以文档数
self.avgdl = sum(len(doc) for doc in documents) / self.N if self.N > 0 else 0
# 计算每个词的文档频率(包含该词的文档数)
# 使用字典存储:词 -> 包含该词的文档数
self.df = {}
# 遍历每个文档
for doc in documents:
# 获取文档中唯一的词(去重)
unique_words = set(doc)
# 对于文档中的每个唯一词,增加文档频率计数
for word in unique_words:
# 如果词不在字典中,初始化为 0,然后加 1
# 如果词已在字典中,直接加 1
self.df[word] = self.df.get(word, 0) + 1
# 计算每个词的 IDF 值
# 使用字典存储:词 -> IDF 值
self.idf = {}
# 遍历每个词
for word, df_value in self.df.items():
# BM25 的 IDF 公式:log((N - df + 0.5) / (df + 0.5)) + 1
# 加1是为了确保IDF始终为正数
# 0.5 是平滑参数,防止除零和负值
self.idf[word] = math.log((self.N - df_value + 0.5) / (df_value + 0.5)) + 1
def _calculate_tf(self, word, doc):
"""
计算词在文档中的词频
参数:
word: 词
doc: 文档(词的列表)
返回:
词在文档中出现的次数
"""
# 使用 Counter 统计词频
word_counts = Counter(doc)
# 返回词在文档中的出现次数(如果不存在则返回 0)
return word_counts.get(word, 0)
def _calculate_score_for_word(self, word, doc):
"""
计算单个词对文档的 BM25 分数贡献
参数:
word: 词
doc: 文档(词的列表)
返回:
该词对文档的分数贡献
"""
# 如果词不在 IDF 字典中(即词不在任何文档中出现),返回 0
if word not in self.idf:
return 0
# 计算词在文档中的词频
tf = self._calculate_tf(word, doc)
# 如果词频为 0,返回 0
if tf == 0:
return 0
# 计算文档长度归一化项
# normalization = 1 - b + b * (文档长度 / 平均文档长度)
doc_length = len(doc)
normalization = 1 - self.b + self.b * (doc_length / self.avgdl)
# 计算调整后的词频
# adjusted_tf = (tf * (k1 + 1)) / (tf + k1 * normalization)
numerator = tf * (self.k1 + 1)
denominator = tf + self.k1 * normalization
adjusted_tf = numerator / denominator
# 计算最终分数:IDF * adjusted_tf
score = self.idf[word] * adjusted_tf
return score
def get_scores(self, query):
"""
计算查询与所有文档的相关性分数
参数:
query: 查询(词的列表)
返回:
所有文档的分数列表
"""
# 初始化分数列表
scores = []
# 遍历每个文档
for doc in self.documents:
# 初始化文档分数
doc_score = 0
# 遍历查询中的每个词
for word in query:
# 计算该词对文档的分数贡献
word_score = self._calculate_score_for_word(word, doc)
# 累加到文档总分
doc_score += word_score
# 将文档分数添加到分数列表
scores.append(doc_score)
# 返回所有文档的分数
return scores
def get_top_n(self, query, documents, n=5):
"""
获取与查询最相关的前 n 个文档
参数:
query: 查询(词的列表)
documents: 原始文档列表(字符串列表)
n: 返回的文档数量
返回:
前 n 个最相关的文档列表
"""
# 计算所有文档的分数
scores = self.get_scores(query)
# 创建 (分数, 索引) 的元组列表
# 使用 enumerate 同时获取索引和分数
score_index_pairs = [(score, idx) for idx, score in enumerate(scores)]
# 按分数降序排序(分数高的在前)
score_index_pairs.sort(key=lambda x: x[0], reverse=True)
# 获取前 n 个文档的索引
top_indices = [idx for _, idx in score_index_pairs[:n]]
# 根据索引获取对应的原始文档
top_documents = [documents[idx] for idx in top_indices]
# 返回前 n 个文档
return top_documents
# 测试 BM25 算法
print("=" * 60)
print("BM25 算法测试")
print("=" * 60)
# 定义原始文档(字符串)
original_docs = [
"我喜欢机器学习",
"机器学习很有趣",
"我喜欢编程"
]
# 使用 jieba 对文档进行分词
documents = [list(jieba.cut(doc)) for doc in original_docs]
# 创建 BM25 模型
bm25 = BM25(documents)
# 定义查询字符串(使用较少见的词,能产生非零分数)
query_text = "机器学习"
# 使用 jieba 对查询进行分词
query = list(jieba.cut(query_text))
# 计算所有文档的分数
scores = bm25.get_scores(query)
print(f"\n查询: {query_text}")
print(f"查询分词: {' '.join(query)}")
# 显示所有文档的分数
print(f"\n各文档的 BM25 分数:")
for i, (doc, score) in enumerate(zip(original_docs, scores), 1):
print(f" 文档{i}: {doc}")
print(f" 分数: {score:.4f}")
# 获取前 2 个最相关的文档
print(f"\n前 2 个最相关的文档:")
top_docs = bm25.get_top_n(query, original_docs, n=2)
for i, doc in enumerate(top_docs, 1):
print(f" {i}. {doc}")
6.2 执行过程 #
第一阶段:初始化 BM25 模型(__init__)
1. 文档集合统计
doc1: ['我', '喜欢', '机器', '学习'] (长度 = 4)
doc2: ['机器', '学习', '很', '有趣'] (长度 = 4)
doc3: ['我', '喜欢', '编程'] (长度 = 3)2. 基础参数计算
- 文档总数:
N = 3 - 平均文档长度:
avgdl = (4 + 4 + 3) / 3 = 11/3 ≈ 3.667 - BM25 参数:
k1 = 1.5,b = 0.75
3. 文档频率(DF)计算 遍历每个文档的唯一词,统计每个词出现在多少个文档中:
'我': 出现在 doc1, doc3 → df = 2
'喜欢': 出现在 doc1, doc3 → df = 2
'机器': 出现在 doc1, doc2 → df = 2
'学习': 出现在 doc1, doc2 → df = 2
'很': 出现在 doc2 → df = 1
'有趣': 出现在 doc2 → df = 1
'编程': 出现在 doc3 → df = 14. IDF 值计算 使用 BM25 的 IDF 公式: $$\text{IDF}(t) = \log\left(\frac{N - \text{df}(t) + 0.5}{\text{df}(t) + 0.5}\right) + 1$$
对于查询词 '机器' 和 '学习'(df = 2):
IDF = log((3 - 2 + 0.5) / (2 + 0.5)) + 1
= log(1.5 / 2.5) + 1
= log(0.6) + 1
≈ -0.5108 + 1
= 0.4892第二阶段:计算文档分数(get_scores)
查询:['机器', '学习']
对每个文档,累加查询中每个词的分数贡献。
文档1:['我', '喜欢', '机器', '学习']
词 "机器" 的贡献:
- 词频:
tf = 1 - 文档长度:
doc_length = 4 - 归一化项:
normalization = 1 - 0.75 + 0.75 × (4 / 3.667) = 0.25 + 0.75 × 1.091 = 1.068 - 调整后词频:
adjusted_tf = (1 × 2.5) / (1 + 1.5 × 1.068) = 2.5 / 2.602 = 0.961 - 分数贡献:
score_机器 = 0.4892 × 0.961 ≈ 0.4700
词 "学习" 的贡献:
- 词频:
tf = 1 - 归一化项:
normalization = 1.068(与"机器"相同) - 调整后词频:
adjusted_tf = 0.961(与"机器"相同) - 分数贡献:
score_学习 = 0.4892 × 0.961 ≈ 0.4700
文档1总分: 0.4700 + 0.4700 = 0.9400 ≈ 0.9399 ✓
文档2:['机器', '学习', '很', '有趣']
与文档1相同(长度相同,查询词词频相同):
score_机器 ≈ 0.4700score_学习 ≈ 0.4700- 文档2总分:
0.9400≈ 0.9399 ✓
文档3:['我', '喜欢', '编程']
查询词 '机器' 和 '学习' 均未出现:
tf_机器 = 0→score_机器 = 0tf_学习 = 0→score_学习 = 0- 文档3总分:
0.0000✓
第三阶段:获取 Top-N 文档(get_top_n)
按分数降序排序:
- 文档1:0.9399
- 文档2:0.9399
- 文档3:0.0000
返回前 2 个:
- 我喜欢机器学习
- 机器学习很有趣
分数验证
计算过程正确。文档1和文档2分数相同(0.9399)的原因:
- 文档长度相同(都是 4 个词)
- 查询词
'机器'和'学习'的词频相同(都是 1) - 归一化项和调整后词频相同
- IDF 值相同(两个词的 df 都是 2)
BM25 公式总结
BM25 分数计算公式: $$\text{BM25}(q, d) = \sum_{t \in q} \text{IDF}(t) \times \frac{\text{tf}(t, d) \times (k_1 + 1)}{\text{tf}(t, d) + k_1 \times \left(1 - b + b \times \frac{|d|}{\text{avgdl}}\right)}$$
其中:
k1 = 1.5:控制词频饱和度b = 0.75:控制长度归一化强度|d|:文档长度avgdl:平均文档长度
7. 实际应用示例 #
7.1 简单文档检索系统 #
下面是一个完整的文档检索系统示例:
# 导入 rank_bm25 包中的 BM25Okapi 类
from rank_bm25 import BM25Okapi
# 导入结巴分词库
import jieba
# 定义一个简单的文档检索系统类
class SimpleSearchEngine:
"""
基于 BM25 的简单搜索引擎
"""
# 构造函数,初始化搜索引擎
def __init__(self, documents):
"""
初始化搜索引擎
参数:
documents: 文档列表(字符串列表)
"""
# 保存原始文档列表
self.original_docs = documents
# 定义中文分词函数,使用结巴分词
def chinese_tokenize(text):
"""使用 jieba 进行中文分词"""
# 对文本进行结巴分词并返回结果列表
return list(jieba.cut(text))
# 对所有文档进行分词处理,得到分词后的文档列表
self.tokenized_docs = [chinese_tokenize(doc) for doc in documents]
# 使用分词后的文档列表构建 BM25 模型
self.bm25 = BM25Okapi(self.tokenized_docs)
# 定义检索方法,输入查询和返回文档数 top_k
def search(self, query, top_k=5):
"""
搜索相关文档
参数:
query: 查询字符串
top_k: 返回的文档数量
返回:
相关文档列表
"""
# 对查询字符串进行结巴分词
query_tokens = list(jieba.cut(query))
# 计算查询和所有文档之间的 BM25 分数
scores = self.bm25.get_scores(query_tokens)
# 获取得分最高的前 top_k 个文档
top_docs = self.bm25.get_top_n(query_tokens, self.original_docs, n=top_k)
# 返回搜索结果(文档列表和分数)
return top_docs, scores
# 定义原始文档库(字符串列表形式)
documents = [
"深度学习通过神经网络取得突破。",
"机器人学结合机械工程与人工智能。",
"美食点评:这家餐厅的川菜很正宗。",
"旅游攻略:云南大理的风景非常美丽。",
"大数据分析推动了智慧城市的发展。"
]
# 创建 BM25 搜索引擎实例
search_engine = SimpleSearchEngine(documents)
# 定义查询字符串(中文)
query = "人工智能与机器人"
# 打印查询内容
print(f"查询: {query}")
# 打印分隔信息
print("\n搜索结果:")
# 执行搜索方法,返回前 2 个相关文档和分数
top_docs, scores = search_engine.search(query, top_k=2)
# 遍历搜索结果文档,逐条打印文档内容
for i, doc in enumerate(top_docs, 1):
print(f"{i}. {doc}")7.2 使用 rank-bm25 库 #
在实际项目中,建议使用现成的 rank-bm25 库,它已经优化过,使用更方便:
# 注意:需要先安装 rank-bm25 库
# 安装命令:pip install rank-bm25
# 导入库
from rank_bm25 import BM25Okapi
# 定义文档集合(每个文档需要先分词)
documents = [
["苹果", "公司", "发布", "新", "手机"],
["今天", "市场", "苹果", "价格", "上涨"],
["我", "喜欢", "吃", "红色", "苹果"],
["苹果", "公司", "市值", "达到", "新高"],
["水果", "市场", "苹果", "供应", "充足"]
]
# 创建 BM25 模型
bm25 = BM25Okapi(documents)
# 查询(也需要分词)
query = ["苹果", "公司"]
# 计算所有文档的分数
scores = bm25.get_scores(query)
print("各文档分数:", scores)
# 获取前 3 个最相关的文档
top_3 = bm25.get_top_n(query, documents, n=3)
print("\n前 3 个最相关的文档:")
for i, doc in enumerate(top_3, 1):
print(f"{i}. {' '.join(doc)}")8. BM25 vs TF-IDF #
BM25 和 TF-IDF 都是经典的文本检索算法,它们的主要区别如下:
| 特性 | TF-IDF | BM25 |
|---|---|---|
| 理论基础 | 向量空间模型 | 概率模型 |
| 词频处理 | 线性增长(词频越高,分数越高) | 非线性饱和(词频达到一定值后增长变慢) |
| 长度归一化 | 余弦归一化 | 更灵活的参数控制(通过 b 参数) |
| 参数数量 | 无参数 | 2 个参数(k1, b) |
| 实际效果 | 基础但有效 | 更鲁棒,效果通常更好 |
| 适用场景 | 简单检索任务 | 搜索引擎、大规模检索 |
为什么 BM25 通常效果更好?
- 词频饱和:BM25 认为词频不会无限增长,更符合实际情况
- 更好的长度归一化:通过参数 b 灵活控制文档长度的影响
- 概率模型:基于概率理论,更符合信息检索的实际需求
什么时候用 TF-IDF?
- 简单快速的检索任务
- 不需要调参的场景
- 作为基线方法
什么时候用 BM25?
- 需要更好的检索效果
- 大规模文档检索
- 搜索引擎等生产环境
9. 常见问题 #
9.1 k1 和 b 参数应该设置为多少? #
k1(词频饱和度参数):
- 默认值:1.5
- 推荐范围:1.2 - 2.0
- 作用:控制词频的影响程度
- k1 越小:词频影响越小
- k1 越大:词频影响越大
- 建议:大多数情况下使用默认值 1.5 即可
b(长度归一化参数):
- 默认值:0.75
- 推荐范围:0.5 - 1.0
- 作用:控制文档长度的影响
- b = 0:不进行长度归一化
- b = 1:完全长度归一化
- 建议:大多数情况下使用默认值 0.75 即可
调参建议:
- 对于大多数场景,使用默认值(k1=1.5, b=0.75)即可
- 如果文档长度差异很大,可以适当增大 b
- 如果词频差异很大,可以适当调整 k1
9.2 BM25 需要训练吗? #
不需要。BM25 是无监督方法,不需要训练数据,只需要:
- 文档集合(用于计算文档频率和平均文档长度)
- 查询(用于计算相关性分数)
BM25 直接使用数学公式计算,不需要像机器学习模型那样训练。
9.3 BM25 能处理中文吗? #
可以,但需要先进行分词。BM25 本身不关心语言,只要将文本分词成词列表即可。
中文分词工具:
jieba:最常用的中文分词库pkuseg:北大开源的分词工具HanLP:功能更强大的自然语言处理工具
示例:
import jieba
# 中文文档
documents = [
"苹果公司发布了新款iPhone手机",
"今天市场苹果价格有所上涨",
"我喜欢吃红苹果和青苹果"
]
# 分词处理
tokenized_docs = [list(jieba.cut(doc)) for doc in documents]
# 创建 BM25 模型
bm25 = BM25Okapi(tokenized_docs)
# 查询
query = list(jieba.cut("苹果公司"))
scores = bm25.get_scores(query)9.4 BM25 的局限性是什么? #
BM25 的主要局限性:
不考虑词序:BM25 是词袋模型,不考虑词的顺序
- 例如:"苹果公司"和"公司苹果"会被视为相同
不考虑语义:BM25 只考虑词的字面匹配,不考虑语义
- 例如:"苹果"和"Apple"会被视为不同
- 例如:"汽车"和"车辆"会被视为不同
无法处理未登录词:如果查询词不在文档集合中出现,无法计算分数
参数需要调整:虽然默认值通常效果不错,但在某些场景下可能需要调参
解决方案:
- 结合语义检索(如向量检索)使用
- 使用同义词扩展
- 结合深度学习方法(如 DPR)
9.5 BM25 适合多大规模的数据? #
BM25 适合:
- 小规模:几千到几万文档(单机即可)
- 中规模:几十万到几百万文档(需要优化,如倒排索引)
- 大规模:千万级以上文档(需要分布式系统,如 Elasticsearch)
性能优化:
- 使用倒排索引加速检索
- 批量处理查询
- 分布式计算
10. 总结 #
BM25 是一个经典而强大的检索算法,虽然已经有 30 年历史,但仍然是许多生产系统的首选基线方法。
核心特点:
- 无需训练:无监督方法,直接使用数学公式计算
- 效果优秀:在大多数文本检索任务中表现优异
- 计算高效:适合大规模文档检索
- 参数简单:只需要 2 个参数(k1, b),且默认值通常效果不错
适用场景:
- 搜索引擎(如 Elasticsearch、Lucene)
- 问答系统(检索相关文档/段落)
- 文档检索系统
- 推荐系统(基于内容的推荐)
使用建议:
- 对于大多数场景,使用默认参数(k1=1.5, b=0.75)即可
- 中文文本需要先进行分词
- 大规模数据建议使用现成的库(如
rank-bm25)或搜索引擎(如 Elasticsearch) - 可以结合语义检索方法(如向量检索)获得更好的效果
学习路径:
- 理解 TF-IDF 基础(词频、逆文档频率)
- 理解 BM25 的核心思想(词频饱和、长度归一化)
- 掌握 BM25 公式的各个组成部分
- 实践使用 BM25 进行文档检索
- 根据实际需求调整参数或结合其他方法
BM25 的简单性、高效性和良好效果使其在实际应用中经久不衰。对于大多数文本检索任务,BM25 仍然是一个优秀的起点。