Skip to main content

rank_bm25

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)

逆文档频率是文档频率的倒数,用于衡量词的稀有程度:

词总文档数包含该词的文档数IDF(词)=log⁡(总文档数包含该词的文档数)

例如:

  • 总文档数 = 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 是词频和逆文档频率的乘积:

词文档词文档词TF-IDF(词,文档)=TF(词,文档)×IDF(词)

直觉 :一个词在文档中出现次数多(TF 大),且在整个文档集合中稀有(IDF 大),则该词对该文档的重要性越高。

3.5 文档长度归一化

文档长度归一化是指:对不同长度的文档在相关性计算时进行校正,避免长文档因为包含更多词而在检索评分中“不公平地”占优势。

为什么需要长度归一化?

  • 长文档自然包含更多词,命中查询词的概率更高;
  • 如果不做归一化,长文档会因为词多而被高估相关性。

如何归一化?
BM25 采用如下归一化方式,通过 avgdl(平均文档长度)和一个可调参数 b 来实现:

归一化项归一化项=1−b+b×|d|avgdl

其中:

  • |d|:当前文档的长度(词数)
  • avgdl:集合中所有文档的平均长度
  • b:平衡参数(常用取值 0.75,范围 [0,1]

在 BM25 公式中,长度归一化通过分母体现:

TF(t,d)×(k1+1)TF(t,d)+k1×(1−b+b×|d|avgdl)

直观理解:

  • b=0 时,不做长度归一化,所有文档同等对待。
  • b=1 时,完全按文档长度进行归一化,长文档影响最大。
  • b 一般设为 0.75,即部分归一化,经验表现较好。

举例说明:

  • 平均长度 avgdl=100,某文档 |d|=50,b=0.75
    归一化项 =1−0.75+0.75×50100=0.25+0.375=0.625
    分母变小,短文档加权变大。
  • 如果 |d|=200,
    归一化项 =1−0.75+0.75×200100=0.25+1.5=1.75
    分母变大,长文档加权变小。

这样可以让短文档、长文档在相关性评分时处于公平位置,防止长文档“滥占优势”。

3.6 词频饱和度

词频饱和度指的是:词频的提升对相关性的贡献不是线性的 ,而是逐渐减缓的。即:

  • 一个词在文档中出现 1 次时相关性增加很大,
  • 出现 2 次时继续提升相关性,
  • 但之后每增加一次,它带来的提升会变得越来越小,趋于饱和。

在 BM25 中,采用了如下的词频饱和计算方式:

TF-Sat(t,d)=TF(t,d)×(k1+1)TF(t,d)+k1

其中 k1 为词频饱和参数(典型取值为 1.2-2.0,推荐 1.5),其作用是控制词频饱和的速度。

  • TF(t,d) 很小时,分数随词频增加提升较快;
  • TF(t,d) 很大时,分数增长逐渐趋缓,不会无限增大。

直观理解

  • 当一个词在文档中的出现次数很高时,它对文档的重要性不会无限增加,而是慢慢趋于一个上限,这更符合人类的认知和实际检索需求。

举例说明
假设 k1=1.5,词频 TF 从 1 到 5 时 TF 饱和项计算如下:

词频 TFTF饱和值 TF×2.5TF+1.5
12.52.5=1.0
25.03.5≈1.43
37.54.5≈1.67
512.56.5≈1.92
5012551.5≈2.43
5001250501.5≈2.49
5000125005001.5≈2.5
5000012500050001.5≈2.5

可以看到词频越高,TF饱和值的增长幅度逐渐减小,体现了一种饱和式的增长效果。

4. 核心思想

BM25 的核心思想是:一个词在文档中出现的次数越多,同时在整个文档集合中出现的次数越少,则该词对于该文档的相关性贡献越大

具体来说:

  1. 词频(TF) :词在文档中出现次数越多,相关性越高(但有上限,不会无限增长)
  2. 逆文档频率(IDF) :词在整个文档集合中越稀有,区分度越高,权重越大
  3. 文档长度归一化 :避免长文档因为包含更多词而获得不公平优势

BM25 相比 TF-IDF 的改进:

  • 词频饱和 :词频不会无限增长,达到一定次数后增长变慢(更符合实际情况)
  • 更好的长度归一化 :通过参数 b 灵活控制文档长度的影响
  • 概率模型 :基于概率理论,更符合信息检索的实际需求

5. BM25公式

BM25 的完整公式如下:

score(D,Q)=∑i=1nIDF(qi)×TF(qi,D)×(k1+1)TF(qi,D)+k1×(1−b+b×|D|avgdl)

其中:

  • D:文档
  • Q:查询(由多个词组成:q1,q2,...,qn)
  • qi:查询中的第 i 个词
  • TF(qi,D):词 qi 在文档 D 中出现的次数
  • |D|:文档 D 的长度(词数)
  • avgdl:所有文档的平均长度
  • k1:词频饱和度参数(通常取 1.2-2.0,默认 1.5)
  • b:长度归一化参数(通常取 0.75)

5.1 IDF 部分

IDF(qi)=log⁡(N−n(qi)+0.5n(qi)+0.5)

其中:

  • N:文档集合中的总文档数
  • n(qi):包含词 qi 的文档数
  • 0.5 是平滑参数,防止除零和负值

作用 :衡量词的稀有程度,词越稀有,IDF 值越大,权重越高。

示例

  • 总文档数 N=1000
  • 包含"苹果"的文档数 苹果n(苹果)=10,则 苹果IDF(苹果)=log⁡((1000−10+0.5)/(10+0.5))≈4.6
  • 包含"的"的文档数 的n(的)=900,则 的IDF(的)=log⁡((1000−900+0.5)/(900+0.5))≈−2.7

可以看到,"苹果"的 IDF 值远大于"的",说明"苹果"更能区分文档。

5.2 TF 部分(词频调整)

BM25 对词频进行了非线性调整:

adjusted_TF=TF×(k1+1)TF+k1×normalization

其中 normalization 是文档长度归一化项。

为什么需要调整?

  • 词频为 1 时,调整后 ≈ 2.5(假设 k1=1.5)
  • 词频为 5 时,调整后 ≈ 4.2(增长变慢)
  • 词频为 10 时,调整后 ≈ 5.4(增长更慢)

这符合实际情况:一个词出现 1 次和出现 5 次差异很大,但出现 50 次和出现 55 次差异很小。

5.3 文档长度归一化

normalization=1−b+b×|D|avgdl

其中:

  • |D|:当前文档长度(词数)
  • avgdl:所有文档的平均长度
  • b:长度归一化参数(0 ≤ b ≤ 1)

参数 b 的作用

  • b=0:不进行长度归一化
  • b=1:完全长度归一化
  • b=0.75:默认值,平衡长度影响

示例

  • 平均文档长度 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 = 1

4. IDF 值计算 使用 BM25 的 IDF 公式: IDF(t)=log⁡(N−df(t)+0.5df(t)+0.5)+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.94000.9399

文档2:['机器', '学习', '很', '有趣']

与文档1相同(长度相同,查询词词频相同):

  • score_机器 ≈ 0.4700
  • score_学习 ≈ 0.4700
  • 文档2总分: 0.94000.9399

文档3:['我', '喜欢', '编程']

查询词 '机器''学习' 均未出现:

  • tf_机器 = 0score_机器 = 0
  • tf_学习 = 0score_学习 = 0
  • 文档3总分: 0.0000

第三阶段:获取 Top-N 文档(get_top_n

按分数降序排序:

  1. 文档1:0.9399
  2. 文档2:0.9399
  3. 文档3:0.0000

返回前 2 个:

  1. 我喜欢机器学习
  2. 机器学习很有趣

分数验证

计算过程正确。文档1和文档2分数相同(0.9399)的原因:

  1. 文档长度相同(都是 4 个词)
  2. 查询词 '机器''学习' 的词频相同(都是 1)
  3. 归一化项和调整后词频相同
  4. IDF 值相同(两个词的 df 都是 2)

BM25 公式总结

BM25 分数计算公式: BM25(q,d)=∑t∈qIDF(t)×tf(t,d)×(k1+1)tf(t,d)+k1×(1−b+b×|d|avgdl)

其中:

  • 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-IDFBM25
理论基础向量空间模型概率模型
词频处理线性增长(词频越高,分数越高)非线性饱和(词频达到一定值后增长变慢)
长度归一化余弦归一化更灵活的参数控制(通过 b 参数)
参数数量无参数2 个参数(k1, b)
实际效果基础但有效更鲁棒,效果通常更好
适用场景简单检索任务搜索引擎、大规模检索

为什么 BM25 通常效果更好?

  1. 词频饱和 :BM25 认为词频不会无限增长,更符合实际情况
  2. 更好的长度归一化 :通过参数 b 灵活控制文档长度的影响
  3. 概率模型 :基于概率理论,更符合信息检索的实际需求

什么时候用 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 是无监督方法,不需要训练数据,只需要:

  1. 文档集合(用于计算文档频率和平均文档长度)
  2. 查询(用于计算相关性分数)

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 的主要局限性:

  1. 不考虑词序 :BM25 是词袋模型,不考虑词的顺序

    • 例如:"苹果公司"和"公司苹果"会被视为相同
  2. 不考虑语义 :BM25 只考虑词的字面匹配,不考虑语义

    • 例如:"苹果"和"Apple"会被视为不同
    • 例如:"汽车"和"车辆"会被视为不同
  3. 无法处理未登录词 :如果查询词不在文档集合中出现,无法计算分数

  4. 参数需要调整 :虽然默认值通常效果不错,但在某些场景下可能需要调参

解决方案

  • 结合语义检索(如向量检索)使用
  • 使用同义词扩展
  • 结合深度学习方法(如 DPR)

9.5 BM25 适合多大规模的数据?

BM25 适合:

  • 小规模 :几千到几万文档(单机即可)
  • 中规模 :几十万到几百万文档(需要优化,如倒排索引)
  • 大规模 :千万级以上文档(需要分布式系统,如 Elasticsearch)

性能优化

  • 使用倒排索引加速检索
  • 批量处理查询
  • 分布式计算

10. 总结

BM25 是一个经典而强大 的检索算法,虽然已经有 30 年历史,但仍然是许多生产系统的首选基线方法

核心特点

  1. 无需训练 :无监督方法,直接使用数学公式计算
  2. 效果优秀 :在大多数文本检索任务中表现优异
  3. 计算高效 :适合大规模文档检索
  4. 参数简单 :只需要 2 个参数(k1, b),且默认值通常效果不错

适用场景

  • 搜索引擎(如 Elasticsearch、Lucene)
  • 问答系统(检索相关文档/段落)
  • 文档检索系统
  • 推荐系统(基于内容的推荐)

使用建议

  1. 对于大多数场景,使用默认参数(k1=1.5, b=0.75)即可
  2. 中文文本需要先进行分词
  3. 大规模数据建议使用现成的库(如 rank-bm25)或搜索引擎(如 Elasticsearch)
  4. 可以结合语义检索方法(如向量检索)获得更好的效果

学习路径

  1. 理解 TF-IDF 基础(词频、逆文档频率)
  2. 理解 BM25 的核心思想(词频饱和、长度归一化)
  3. 掌握 BM25 公式的各个组成部分
  4. 实践使用 BM25 进行文档检索
  5. 根据实际需求调整参数或结合其他方法

BM25 的简单性、高效性和良好效果使其在实际应用中经久不衰。对于大多数文本检索任务,BM25 仍然是一个优秀的起点