跳至内容

BM25算法与文本排序

BM25 根据词项匹配和语料统计为候选文本排序。

  • BM25
  • 全文检索
  • 相关性排序

BM25 是全文检索中的相关性评分算法。 给定查询,它依据查询词在候选文本中的出现次数、词在整批文本中的常见程度,以及文本长度计算分数。分数决定候选文本的排序。

候选文本可以是一句话、一段话或整篇文章。BM25 按分词后的词项评分,不直接计算两句话的语义相似度。

1. 词项匹配与相关性评分

查询和候选文本经过分词等处理,得到用于检索的词项。倒排索引按查询词项找出候选文档;BM25 根据词频和整批文档的统计量打分。

匹配条件决定候选范围。 要求命中任意词项时,只命中一个词项的文档也能参与排序;要求全部命中时,它就不在候选结果中。

2. BM25 评分公式

公式针对单个检索字段,重复的查询词项只计一次:

score⁡(D,Q)=∑t∈unique⁡(Q)IDF⁡(t)f(t,D)f(t,D)+k1(1−b+b∣D∣L‾)\operatorname{score}(D,Q)=\sum_{t\in\operatorname{unique}(Q)}\operatorname{IDF}(t) \frac{f(t,D)}{f(t,D)+k_1\left(1-b+b\frac{|D|}{\overline L}\right)}IDF⁡(t)=ln⁡(1+N−df⁡(t)+0.5df⁡(t)+0.5)\operatorname{IDF}(t)=\ln\left(1+\frac{N-\operatorname{df}(t)+0.5}{\operatorname{df}(t)+0.5}\right)
  • f(t,D)f(t,D):词项 tt 在文档 DD 中的出现次数;没有出现时,该词项贡献为零。
  • NN 与 df⁡(t)\operatorname{df}(t):文档总数,以及包含 tt 的文档数。
  • ∣D∣|D| 与 L‾\overline L:文档的词项数,以及语料中的平均文档长度。这些统计量应来自同一检索字段和同一批文档。
  • k1k_1 与 bb:分别控制重复词的收益递减速度、文档长度对评分的影响。

一个词在同一篇文档里出现三次,文档频率仍只增加一。

IDF 使用 Lucene 的非负写法。词频项省略了另一种写法中的 (k1+1)(k_1+1);对同一查询和字段,这个系数只会等比例改变分数,不改变排名。

2.1 常见词的贡献较小

在同一批文档中,包含某词项的文档越多,它的 IDF 越低。 IDF 反映词项在这批文档里的常见程度,不能据此判断它的语义重要性。

2.2 重复词的收益递减

固定文档长度和 k1k_1 后,词频部分的形式是 f/(f+K)f/(f+K),其中 K=k1(1−b+b∣D∣/L‾)K=k_1(1-b+b|D|/\overline L)。词频继续增加,得分仍会上升,但每增加一次带来的收益变小。

例如 k1=1k_1=1 且文档长度等于平均长度时,K=1K=1。词频为 11 时,该项是 1/21/2;词频为 33 时是 3/43/4,没有变成三倍。k1k_1 越大,收益下降得越慢。

2.3 文档长度改变词频的作用

当 b>0b>0,查询词出现次数相同的两篇文档中,较长文档的分母更大,单个词项的贡献较低。 若较长文档同时多次命中查询词,总分仍可能更高。b=0b=0 时不做长度调整;b=1b=1 时使用完整的长度比例。

3. 四条商品描述的排序

查询“防水背包”拆成“防水”“背包”两个词项。四条商品描述分别作为文档 D1~D4。至少命中一个查询词项即可参与排序,取 k1=1k_1=1、b=0.5b=0.5。词项由人工指定,标点不计入长度,不代表某个中文分词器的输出。

  • D1:“防水背包适合通勤。” → 防水 / 背包 / 适合 / 通勤(4 个词项)
  • D2:“这款防水背包采用防水面料,配有防水拉链,适合雨天通勤。” → 这款 / 防水 / 背包 / 采用 / 防水 / 面料 / 配有 / 防水 / 拉链 / 适合 / 雨天 / 通勤(12 个词项)
  • D3:“轻便的防水背包有多个收纳隔层,适合周末出行。” → 轻便 / 的 / 防水 / 背包 / 有 / 多个 / 收纳 / 隔层 / 适合 / 周末 / 出行(11 个词项)
  • D4:“这款背包适合日常通勤,设有电脑隔层。” → 这款 / 背包 / 适合 / 日常 / 通勤 / 设有 / 电脑 / 隔层(8 个词项)

平均文档长度为 8.758.75;“防水”出现在三条描述中,IDF 约为 0.3566750.356675;“背包”四条都有,IDF 约为 0.1053610.105361。

D1 的长度调整项为 1×(1−0.5+0.5×4/8.75)≈0.7285711\times(1-0.5+0.5\times4/8.75)\approx0.728571。两个查询词各出现一次,所以分数为 (0.356675+0.105361)/(1+0.728571)≈0.267293(0.356675+0.105361)/(1+0.728571)\approx0.267293。其余文档按同一公式计算:

排名文档BM25 分数
1D20.303841
2D10.267293
3D30.217064
4D40.053834

D1 与 D3 的两个查询词都只出现一次;D3 更长,分数较低。D2 的“防水”出现三次,词频带来的增益超过长度的影响。 D4 只命中较常见的“背包”,排在最后。

4. Python 实现

脚本只用 Python 标准库。BM25 接收人工指定的词项列表,不包含中文分词器;原句只用于展示排序结果。

rank() 遍历所有文档,只返回正分结果,按分数降序排列;同分时保持输入顺序。

bm25.py Python
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
"""单字段 BM25 示例;输入是已分词的文档和查询。"""

from collections import Counter
from math import log


class BM25:
    def __init__(self, documents: list[list[str]], k1: float, b: float):
        self.k1 = k1
        self.b = b
        # 词频按文档分别统计,平均长度由整个语料计算。
        self.term_counts = [Counter(document) for document in documents]
        self.lengths = [len(document) for document in documents]
        self.document_count = len(documents)
        self.average_length = sum(self.lengths) / self.document_count

        document_frequency = Counter()
        for counts in self.term_counts:
            # 同一词项在一篇文档中出现多次,文档频率仍只增加一。
            document_frequency.update(counts.keys())
        # 使用正文给出的 Lucene 非负 IDF 形式。
        self.idf = {term: log(1 + (self.document_count - frequency + 0.5) / (frequency + 0.5))
                    for term, frequency in document_frequency.items()}

    def score(self, query_terms: list[str], document_index: int) -> float:
        counts = self.term_counts[document_index]
        # b 决定文档长度对词频贡献的影响。
        length_factor = self.k1 * (1 - self.b + self.b * self.lengths[document_index] / self.average_length)
        score = 0.0
        # 与正文公式一致,重复的查询词项只计算一次。
        for term in dict.fromkeys(query_terms):
            frequency = counts[term]
            if frequency == 0:
                continue
            score += self.idf[term] * frequency / (frequency + length_factor)
        return score

    def rank(self, query_terms: list[str]) -> list[tuple[int, float]]:
        scores = [(index, self.score(query_terms, index)) for index in range(self.document_count)]
        # 只返回命中结果;分数相同时按原始文档顺序排列。
        return sorted((item for item in scores if item[1] > 0), key=lambda item: (-item[1], item[0]))


if __name__ == "__main__":
    # 原句与人工指定的词项一一对应,这里不调用中文分词器。
    examples = [
        ("防水背包适合通勤。", ["防水", "背包", "适合", "通勤"]),
        ("这款防水背包采用防水面料,配有防水拉链,适合雨天通勤。",
         ["这款", "防水", "背包", "采用", "防水", "面料", "配有", "防水", "拉链",
          "适合", "雨天", "通勤"]),
        ("轻便的防水背包有多个收纳隔层,适合周末出行。",
         ["轻便", "的", "防水", "背包", "有", "多个", "收纳", "隔层", "适合", "周末", "出行"]),
        ("这款背包适合日常通勤,设有电脑隔层。",
         ["这款", "背包", "适合", "日常", "通勤", "设有", "电脑", "隔层"]),
    ]
    bm25 = BM25([terms for _, terms in examples], k1=1.0, b=0.5)
    for index, score in bm25.rank(["防水", "背包"]):
        print(f"D{index + 1}: {score:.6f} {examples[index][0]}")
运行结果 Text
1
2
3
4
# D2: 0.303841 这款防水背包采用防水面料,配有防水拉链,适合雨天通勤。
# D1: 0.267293 防水背包适合通勤。
# D3: 0.217064 轻便的防水背包有多个收纳隔层,适合周末出行。
# D4: 0.053834 这款背包适合日常通勤,设有电脑隔层。

这份实现逐篇计算分数,没有建立倒排索引。搜索系统的分词结果、字段权重或 BM25 参数不同,分数就可能与表中不同。

BM25 分数在同一查询、语料和评分设置下用于排序,不能当作相关概率。

5. BM25 与TF-IDF 区别

TF-IDF 有对数词频、长度归一化等变体。这里比较词频取出现次数、IDF 取 log⁡(N/df⁡(t))\log(N/\operatorname{df}(t)),且不做长度归一化的写法。

比较项TF-IDFBM25
词频同一词项每多出现一次,分数增加相同的量。分数仍上升,但增量逐渐减小。
文档长度不调整文档长度。用 bb 控制长度对词频贡献的影响。
IDF文档频率越高,IDF 越低;词项出现在所有文档中时为零。文档频率越高,IDF 越低;词项出现在所有文档中时仍为正。

评论

使用 GitHub 登录后参与讨论,评论将公开保存在 GitHub。

阅读到这里时加载评论。