导航菜单

  • 1.vector
  • 2.milvus
  • 3.pymilvus
  • 4.rag
  • 5.rag_measure
  • ragflow
  • heapq
  • HNSW
  • cosine_similarity
  • math
  • typing
  • etcd
  • minio
  • collections
  • jieba
  • random
  • beautifulsoup4
  • chromadb
  • sentence_transformers
  • numpy
  • lxml
  • openpyxl
  • PyMuPDF
  • python-docx
  • requests
  • python-pptx
  • text_splitter
  • all-MiniLM-L6-v2
  • openai
  • llm
  • BPETokenizer
  • Flask
  • RAGAS
  • BagofWords
  • langchain
  • Pydantic
  • abc
  • faiss
  • MMR
  • scikit-learn
  • Runnable
  • PromptEngineering
  • dataclasses
  • LaTeX
  • rank_bm25
  • TF-IDF
  • asyncio
  • sqlalchemy
  • fastapi
  • Starlette
  • uvicorn
  • argparse
  • Generic
  • ssl
  • urllib
  • python-dotenv
  • RRF
  • CrossEncoder
  • Lost-in-the-middle
  • Jinja2
  • logger
  • io
  • venv
  • concurrent
  • parameter
  • SSE
  • 1. 什么是 BM25?
  • 2. 为什么需要 BM25?
  • 3. 前置知识
    • 3.1 词频(Term Frequency, TF)
    • 3.2 文档频率(Document Frequency, DF)
    • 3.3 逆文档频率(Inverse Document Frequency, IDF)
    • 3.4 TF-IDF
    • 3.5 文档长度归一化
    • 3.6 词频饱和度
  • 4. 核心思想
  • 5. BM25公式
    • 5.1 IDF 部分
    • 5.2 TF 部分(词频调整)
    • 5.3 文档长度归一化
  • 6. BM25实现
    • 6.1 代码实现
    • 6.2 执行过程
  • 7. 实际应用示例
    • 7.1 简单文档检索系统
    • 7.2 使用 rank-bm25 库
  • 8. BM25 vs TF-IDF
  • 9. 常见问题
    • 9.1 k1 和 b 参数应该设置为多少?
    • 9.2 BM25 需要训练吗?
    • 9.3 BM25 能处理中文吗?
    • 9.4 BM25 的局限性是什么?
    • 9.5 BM25 适合多大规模的数据?
  • 10. 总结

1. 什么是 BM25? #

  • 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 的核心思想是:一个词在文档中出现的次数越多,同时在整个文档集合中出现的次数越少,则该词对于该文档的相关性贡献越大。

具体来说:

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

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实现 #

  • 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 公式: $$\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.4700
  • score_学习 ≈ 0.4700
  • 文档2总分: 0.9400 ≈ 0.9399 ✓

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

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

  • tf_机器 = 0 → score_机器 = 0
  • tf_学习 = 0 → score_学习 = 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 分数计算公式: $$\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 通常效果更好?

  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 仍然是一个优秀的起点。

← 上一节 random 下一节 requests →

访问验证

请输入访问令牌

Token不正确,请重新输入