· liyu · algorithm · 11 min read
搜索算法基石:从 TF-IDF 到 BM25 的演进之路
在搜广推系统中,搜索是最核心的能力之一。本文从信息检索的经典算法 TF-IDF 出发,深入剖析其原理与局限,再引出工业界广泛使用的 BM25 算法,探讨它如何优雅地解决 TF-IDF 的不足。
搜索、广告、推荐(搜广推)是互联网产品的三大核心引擎。其中,搜索是用户主动表达意图的场景,如何从海量文档中快速找到与查询最相关的结果,是信息检索(Information Retrieval)领域数十年来持续研究的课题。
本文聚焦两个经典且至今仍在工业界广泛使用的文本相关性算法:TF-IDF 和 BM25。
为什么需要相关性算法?
最朴素的搜索方式是关键词精确匹配——用户搜”机器学习”,就把包含这四个字的文档全部返回。但这样做有两个致命问题:
- 无法排序:100 篇都包含”机器学习”的文档,哪篇更相关?
- 噪声太多:一篇文档提到了一次”机器学习”和一篇通篇讨论”机器学习”的论文,重要性完全不同。
相关性算法的使命,就是给每篇文档打一个相关性分数,让最相关的结果排在最前面。
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 简单优雅,但在实际搜索场景中暴露了几个明显不足:
- 词频饱和问题:一个词出现 100 次和出现 200 次,相关性真的翻倍吗?TF-IDF 中 TF 与得分是线性关系,没有上限。
- 文档长度偏差:长文档天然包含更多词,TF 值更高,导致长文档容易获得更高分数,但长文档未必更相关。
- 缺乏参数调优空间:公式是固定的,无法根据不同业务场景灵活调整。
这些问题催生了 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-IDF | BM25 |
|---|---|---|
| 词频处理 | 线性增长,无上限 | 饱和函数,有上界 |
| 文档长度 | 不处理,长文档占优 | 通过 b 参数归一化 |
| 可调参数 | 无 | k₁、b 可针对场景调优 |
| 计算复杂度 | 低 | 略高,但仍为 O(n) |
| 工业应用 | 特征工程、文本向量化 | Elasticsearch 等搜索引擎默认算法 |
| 适用场景 | 文本分类、关键词提取 | 文档检索、搜索排序 |
在搜广推体系中的定位
在现代搜广推系统中,TF-IDF 和 BM25 通常处于召回层或粗排层:
用户查询
↓
[倒排索引 + BM25 召回] ← 从百万级文档中快速筛出 Top-K 候选
↓
[粗排模型] ← 轻量级模型初步排序
↓
[精排模型] ← BERT / 交叉注意力等深度模型精细打分
↓
[重排 + 业务策略] ← 多样性、时效性、商业化等
↓
最终结果展示虽然深度学习模型(如 BERT、ColBERT)在语义理解上远超 BM25,但 BM25 有两个不可替代的优势:
- 速度极快:配合倒排索引,毫秒级完成百万文档的检索
- 无需训练数据:开箱即用,不依赖标注数据
因此在实际系统中,BM25 召回 + 深度模型精排是最常见的混合架构。近年来流行的 RAG(检索增强生成)方案中,BM25 同样是召回环节的重要一环。
总结
TF-IDF 和 BM25 虽然诞生于上个世纪,但它们所体现的思想——词频饱和、逆文档频率、文档长度归一化——至今仍是信息检索的基石。理解这些经典算法,不仅能帮助我们更好地使用 Elasticsearch 等工具,也能为深入理解现代神经检索模型打下坚实基础。
下一篇我计划聊聊搜广推体系中推荐算法的经典方法,从协同过滤到 Embedding 召回,敬请期待。