Better TF-IDF: BM25
引言
如果你经常翻阅 LLM (RAG) 或者信息检索相关文献的话,想必会经常看到 BM25 算法。BM25 是用来对检索到的东西按照相关性排序的算法。即在给定用户查询(Query)的情况下,计算每一个文档(Document)的相关性(Relevance)并进行排序
它跟经典的 TF-IDF 有着相似的结构,事实上,BM25 是对 TF-IDF 的改进,后面我们会看看为什么这么说
BM25 的原理
记号规定
- $q$:表示用户查询
- $token_i$:表示用户查询里包含的关键词,这是对用户查询 $q$ 的分词结果
- $d_i$ 表示第 $i$ 个文档
那么计算用户查询 $q$ 和文档 $d_i$ 的相关性分数 $\texttt{score}(q,d_i)$ 的公式就是
$$ \texttt{score}(q,d_i)=\sum_i\texttt{IDF}(token_i)\cdot\frac{\texttt{freq}(token_i,d_i) * (k_1+1)}{\texttt{freq}(token_i,d_i)+k_1\cdot(1-b+b\cdot\frac{|d_i|}{avgdl})} $$
其中
- $\texttt{freq}(token_i, d_i)$ 表示 $token_i$ 在文档 $d_i$ 中的频率
- $\texttt{IDF}$ 就是逆文档频率
- $avgdl$ = average of document length,即检索到的文档的平均长度
- *$k_1, b$ 是 BM25 的超参数
BM25 Vs TF-IDF
上面的公式其实和 TF-IDF 的公式很像 :)
改进 1 - TF 增长非线性
首先是对词频 $\texttt{TF}$ 的改进,在 BM25 里我们可以对 $\texttt{TF}$ 相关的部分进行一个近似
$$ \frac{\texttt{freq}(token_i,d_i) * (k_1+1)}{\texttt{freq}(token_i,d_i)+k_1\cdot(1-b+b\cdot\frac{|d_i|}{avgdl})}\approx \frac{\texttt{freq}(token_i,d_i) * (k_1+1)}{\texttt{freq}(token_i,d_i)+k_1} $$
可以看到,现在词频 $\texttt{TF}$ 项存在一个上限,当 $\texttt{freq}(token_i, d_i)\rightarrow\infty$ 的时候,上限值是 $k_1+1$,这是一个非线性函数。而 TF-IDF 的 $\texttt{TF}$ 是线性的。从非线性函数到线性函数蕴含的原理是:相关性不应该随着词频进行无限增长,应该存在一个饱和区域
举例来说,假设文档有 1000 个词,foobar
出现了 10 次或者 500 次
foobar
出现 10 次- TF-IDF:$\texttt{TF}=0.01$
- BM25 ($k_1=2$):$\texttt{TF}=10 * (2 + 1) / (10 + 2) = 2.5$
foobar
出现 500 次- TF-IDF:$\texttt{TF}=0.5$
- BM25 ($k_1=2$):$\texttt{TF}=500*(2+1)/(500+2)\approx3$
对于 TF-IDF 来说,它认为 $\texttt{TF}$ 50 倍(500/10=50),那么相关性应该也提升 50 倍(0.5/0.01=50),但 BM25 因为存在饱和区域,曲线是非线性的,所以相关性只提升了 1.2 倍(3/2.5=1.2)
下面的图清晰展示了不同的 $k_1$ 的取值会如何影响非线性的效果
改进 2 - 考虑文档长度
刚才我们简化了 TF 项,有意忽略了如下的这个式子
$$ 1-b+b\cdot\frac{|d_i|}{avgdl} $$
它背后蕴含的原理是:如果一个文档本身就比较长,那么任何词出现的概率都天然更高,因此需要对长文档进行惩罚,降低它的得分
这个式子就是这么一个作用,$b$ 控制了惩罚长文档的力度
- $b=0$,那么这个式子永远等于 1,等于不惩罚长文档
- $b=1$,那么这个式子永远等于 $|d_i|/avgdl$
相关超参数
$k_1$
$$ k_1\in[1.2, 2.0] $$
$k_1$ 控制了饱和速率,$k_1$ 越大,TF 饱和得越慢(体现在曲线更靠近左上角)
b
$$ b\in[0,1] $$
$b$ 控制惩罚长文档的力度,一般设置为 0.75
Python 实现 - rank-bm25
接下来看看如何在 Python 里面使用 BM25 算法,我这里使用的是 rank-bm25 库,下面是官方提供的一个例子
定义 BM25 模型,输入需要做分词
from rank_bm25 import BM25Okapi
# Make BM25
corpus = [
"Hello there good man!",
"It is quite windy in London",
"How is the weather today?",
]
tokenized_corpus = [doc.split(" ") for doc in corpus]
bm25 = BM25Okapi(tokenized_corpus)
读取用户输入,用户输入也需要做分词
# Handle query
query = "windy London"
tokenized_query = query.split(" ")
获取最相关的文档用 get_top_n
方法
print(bm25.get_top_n(tokenized_query, corpus, 1))
# ['It is quite windy in London']