· liyu · algorithm · 11 min read

搜索算法基石:从 TF-IDF 到 BM25 的演进之路

在搜广推系统中,搜索是最核心的能力之一。本文从信息检索的经典算法 TF-IDF 出发,深入剖析其原理与局限,再引出工业界广泛使用的 BM25 算法,探讨它如何优雅地解决 TF-IDF 的不足。

在搜广推系统中,搜索是最核心的能力之一。本文从信息检索的经典算法 TF-IDF 出发,深入剖析其原理与局限,再引出工业界广泛使用的 BM25 算法,探讨它如何优雅地解决 TF-IDF 的不足。

搜索、广告、推荐(搜广推)是互联网产品的三大核心引擎。其中,搜索是用户主动表达意图的场景,如何从海量文档中快速找到与查询最相关的结果,是信息检索(Information Retrieval)领域数十年来持续研究的课题。

本文聚焦两个经典且至今仍在工业界广泛使用的文本相关性算法:TF-IDFBM25

为什么需要相关性算法?

最朴素的搜索方式是关键词精确匹配——用户搜”机器学习”,就把包含这四个字的文档全部返回。但这样做有两个致命问题:

  1. 无法排序:100 篇都包含”机器学习”的文档,哪篇更相关?
  2. 噪声太多:一篇文档提到了一次”机器学习”和一篇通篇讨论”机器学习”的论文,重要性完全不同。

相关性算法的使命,就是给每篇文档打一个相关性分数,让最相关的结果排在最前面。

TF-IDF:词频与逆文档频率

TF-IDF(Term Frequency - Inverse Document Frequency)是信息检索领域最经典的加权方案,核心思想可以用一句话概括:一个词在当前文档中出现得越多、在整个语料库中出现得越少,它就越能代表这篇文档。

TF:词频

词频衡量一个词在文档中出现的频率:

TF(t, d) = f(t, d) / Σf(t', d)

其中 f(t, d) 是词 t 在文档 d 中出现的次数,分母是文档中所有词的总数。

直觉上很好理解:一篇文章中”神经网络”出现了 20 次,而”的”出现了 50 次,单看词频,“的”反而更重要——这显然不对。所以我们需要 IDF 来修正。

IDF:逆文档频率

IDF 衡量一个词的区分度

IDF(t, D) = log(|D| / |{d ∈ D : t ∈ d}|)

其中 |D| 是语料库总文档数,分母是包含词 t 的文档数。

“的”、“是”、“在”这类停用词几乎出现在每篇文档中,IDF 值趋近于 0;而”BM25”、“信息检索”这样的专业术语只出现在少数文档中,IDF 值很高。

TF-IDF 最终得分

将两者相乘,就得到了一个词对文档的重要性权重:

TF-IDF(t, d, D) = TF(t, d) × IDF(t, D)

对于一个查询 Q = {q₁, q₂, ..., qₙ},文档 d 的相关性得分就是所有查询词的 TF-IDF 之和:

Score(Q, d) = Σ TF-IDF(qᵢ, d, D)    (i = 1 到 n)

Python 实现

import math
from collections import Counter

def compute_tf(doc: list[str]) -> dict[str, float]:
    """计算单篇文档的词频"""
    word_count = Counter(doc)
    total = len(doc)
    return {word: count / total for word, count in word_count.items()}

def compute_idf(corpus: list[list[str]]) -> dict[str, float]:
    """计算语料库的逆文档频率"""
    n = len(corpus)
    idf = {}
    # 统计每个词出现在多少篇文档中
    all_words = set(word for doc in corpus for word in doc)
    for word in all_words:
        doc_count = sum(1 for doc in corpus if word in doc)
        idf[word] = math.log(n / doc_count)
    return idf

def tf_idf_score(query: list[str], doc: list[str], idf: dict[str, float]) -> float:
    """计算查询与文档的 TF-IDF 相关性得分"""
    tf = compute_tf(doc)
    return sum(tf.get(q, 0) * idf.get(q, 0) for q in query)

TF-IDF 的局限

尽管 TF-IDF 简单优雅,但在实际搜索场景中暴露了几个明显不足:

  1. 词频饱和问题:一个词出现 100 次和出现 200 次,相关性真的翻倍吗?TF-IDF 中 TF 与得分是线性关系,没有上限。
  2. 文档长度偏差:长文档天然包含更多词,TF 值更高,导致长文档容易获得更高分数,但长文档未必更相关。
  3. 缺乏参数调优空间:公式是固定的,无法根据不同业务场景灵活调整。

这些问题催生了 BM25 的诞生。

BM25:工业界的搜索利器

BM25(Best Matching 25)由 Stephen Robertson 等人在 1990 年代提出,是 Okapi BM25 的简称。它可以看作 TF-IDF 的”升级版”——继承了词频和逆文档频率的核心思想,但通过引入词频饱和文档长度归一化两个机制,巧妙地解决了 TF-IDF 的局限。

至今,BM25 仍然是 Elasticsearch、Lucene、Solr 等主流搜索引擎的默认排序算法

BM25 公式

Score(Q, d) = Σ IDF(qᵢ) × f(qᵢ,d) × (k₁+1) / (f(qᵢ,d) + k₁ × (1 - b + b × |d|/avgdl))

别被公式吓到,我们逐项拆解:

核心改进一:词频饱和

看 TF 部分的变化:

f(qᵢ, d) × (k₁ + 1) / (f(qᵢ, d) + k₁ × (...))

f(qᵢ, d) 很大时,这个分数趋近于 k₁ + 1,不再无限增长。参数 k₁(通常取 1.2 ~ 2.0)控制饱和速度:

  • k₁ = 0 时退化为 0/1 二值(只看有没有,不看出现几次)
  • k₁ 越大,词频的影响越持续

这完美解决了 TF-IDF 的线性增长问题——一个词出现 5 次和 50 次的差距被合理压缩。

核心改进二:文档长度归一化

公式中的这部分处理文档长度:

1 - b + b × |d| / avgdl

其中 |d| 是当前文档长度,avgdl 是语料库平均文档长度。参数 b(通常取 0.75)控制长度归一化的强度:

  • b = 0 时完全不考虑文档长度
  • b = 1 时完全按文档长度做归一化

直觉理解:如果一篇文档比平均长度短,分母变小,得分被提升;如果比平均长度长,分母变大,得分被压制。这消除了长文档的不公平优势。

IDF 的改进

IDF(qᵢ) = log((N - n(qᵢ) + 0.5) / (n(qᵢ) + 0.5))

其中 N 是总文档数,n(qᵢ) 是包含 qᵢ 的文档数。加 0.5 是为了平滑处理,避免极端情况下的数值问题。

Python 实现

import math
from collections import Counter

def bm25_score(
    query: list[str],
    doc: list[str],
    corpus: list[list[str]],
    k1: float = 1.5,
    b: float = 0.75,
) -> float:
    """计算查询与文档的 BM25 相关性得分"""
    N = len(corpus)
    avgdl = sum(len(d) for d in corpus) / N
    doc_len = len(doc)
    freq = Counter(doc)

    score = 0.0
    for term in query:
        # 包含该词的文档数
        n_qi = sum(1 for d in corpus if term in d)
        # IDF
        idf = math.log((N - n_qi + 0.5) / (n_qi + 0.5) + 1)
        # 词频
        f_qi = freq.get(term, 0)
        # TF 饱和 + 长度归一化
        tf_norm = (f_qi * (k1 + 1)) / (f_qi + k1 * (1 - b + b * doc_len / avgdl))

        score += idf * tf_norm
    return score


# 示例
corpus = [
    "信息 检索 是 搜索引擎 的 核心 技术".split(),
    "机器 学习 在 搜索 排序 中 的 应用".split(),
    "BM25 算法 是 信息 检索 中 经典 的 相关性 算法".split(),
]

query = "信息 检索 算法".split()

for i, doc in enumerate(corpus):
    print(f"文档 {i}: {bm25_score(query, doc, corpus):.4f}{''.join(doc)}")

TF-IDF vs BM25 对比

维度TF-IDFBM25
词频处理线性增长,无上限饱和函数,有上界
文档长度不处理,长文档占优通过 b 参数归一化
可调参数k₁b 可针对场景调优
计算复杂度略高,但仍为 O(n)
工业应用特征工程、文本向量化Elasticsearch 等搜索引擎默认算法
适用场景文本分类、关键词提取文档检索、搜索排序

在搜广推体系中的定位

在现代搜广推系统中,TF-IDF 和 BM25 通常处于召回层粗排层

用户查询

[倒排索引 + BM25 召回] ← 从百万级文档中快速筛出 Top-K 候选

[粗排模型] ← 轻量级模型初步排序

[精排模型] ← BERT / 交叉注意力等深度模型精细打分

[重排 + 业务策略] ← 多样性、时效性、商业化等

最终结果展示

虽然深度学习模型(如 BERT、ColBERT)在语义理解上远超 BM25,但 BM25 有两个不可替代的优势:

  1. 速度极快:配合倒排索引,毫秒级完成百万文档的检索
  2. 无需训练数据:开箱即用,不依赖标注数据

因此在实际系统中,BM25 召回 + 深度模型精排是最常见的混合架构。近年来流行的 RAG(检索增强生成)方案中,BM25 同样是召回环节的重要一环。

总结

TF-IDF 和 BM25 虽然诞生于上个世纪,但它们所体现的思想——词频饱和、逆文档频率、文档长度归一化——至今仍是信息检索的基石。理解这些经典算法,不仅能帮助我们更好地使用 Elasticsearch 等工具,也能为深入理解现代神经检索模型打下坚实基础。

下一篇我计划聊聊搜广推体系中推荐算法的经典方法,从协同过滤到 Embedding 召回,敬请期待。

Share:
Back to Blog

Related Posts

View All Posts »
当双塔模型遇见 RAG:召回与检索,殊途同归

当双塔模型遇见 RAG:召回与检索,殊途同归

搜广推系统中的双塔模型和 RAG 中的向量检索,一个诞生于推荐系统,一个兴起于大模型时代。当我把两者的架构图放在一起时,突然发现它们竟如此相似——本质上都是在向量空间中寻找最近的"灵魂伴侣"。