目录

Better TF-IDF: BM25

如果你经常翻阅 LLM (RAG) 或者信息检索相关文献的话,想必会经常看到 BM25 算法。BM25 是用来对检索到的东西按照相关性排序的算法。即在给定用户查询(Query)的情况下,计算每一个文档(Document)的相关性(Relevance)并进行排序

它跟经典的 TF-IDF 有着相似的结构,事实上,BM25 是对 TF-IDF 的改进,后面我们会看看为什么这么说

记号规定

  • $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 的超参数

上面的公式其实和 TF-IDF 的公式很像 :)

首先是对词频 $\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$ 的取值会如何影响非线性的效果

刚才我们简化了 TF 项,有意忽略了如下的这个式子

$$ 1-b+b\cdot\frac{|d_i|}{avgdl} $$

它背后蕴含的原理是:如果一个文档本身就比较长,那么任何词出现的概率都天然更高,因此需要对长文档进行惩罚,降低它的得分

这个式子就是这么一个作用,$b$ 控制了惩罚长文档的力度

  • $b=0$,那么这个式子永远等于 1,等于不惩罚长文档
  • $b=1$,那么这个式子永远等于 $|d_i|/avgdl$

$$ k_1\in[1.2, 2.0] $$

$k_1$ 控制了饱和速率,$k_1$ 越大,TF 饱和得越慢(体现在曲线更靠近左上角)

$$ b\in[0,1] $$

$b$ 控制惩罚长文档的力度,一般设置为 0.75

接下来看看如何在 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']