《Practical BM25》系列文章来自于 elastic 官方博客,共分为三部分,讲解了 Elasticsearch 的默认相似度算法 BM25 的原理。本篇为第三部分的中文翻译,原文链接 Practical BM25 - Part 3: Considerations for Picking b and k1 in Elasticsearch
值得注意的是,当你的用户不能很快搜索到他们要的东西的时候,调整b和k1值通常不是首先要考虑的办法。默认值b = 0.75和k1 = 1.2在大多数情况下都工作得很完美,所以你使用默认值时应该也没什么问题,下面这些优化可能更适合你:
Elasticsearch能够如此强大部分是因为你可以基于这些基础,构建出非常健壮的搜索体验。假设你已经把能做的都做了,就是想追求一下极致,那么如何选取b和k1?
已经有很充分的研究表明,对于所有数据/查询,并不存在“最优”的b和k1的值。修改了参数b和k1的用户通常对于每个增量进行执行查询来进行调试。Elasticseach中的Rank Eval API可以在执行阶段给你提供帮助。
在尝试b和k1时,你首先要考虑的是范围。我也会介绍一下过去这方面的实验,给你来点对于实验粗略的印象——那些你可能会感兴趣去做的那种实验——特别适合第一次干这个的同学:
b需要在0到1之间。有些实验测试了增量为0.1左右时的各个值,大部分实验得出的结论是b在0.3-0.9这个范围内可以获得最优的效果(Lipani, Lupu, Hanbury, Aizawa (2015); Taylor, Zaragoza, Craswell, Robertson, Burges (2006); Trotman, Puurula, Burgess (2014); etc.)
k1通常在0到3这个范围内,尽管没人阻止你把它设置得更高。有些实验以0.1到0.2为增量对k1的值进行测试,得出结论是k1在0.5-2.0这个范围内效果最优。
对于k1这个参数,你应该问“我们什么时候认为一个term达到饱和?”,对于书这种很长的文档——特别是虚构类或是多主题书籍——在一部著作中很可能有大量不同的term出现若干次,甚至这些term与整本著作都没太大关系。例如“eye”或“eyes”可能在一本虚构类书籍中出现几百次,然而“eyes”并不是这本书的主题。提到几千次“eyes”的书,看起来更与眼睛有关系。你可能不想让一个term在这种情况下很快饱和,因此当文本很长并且更多样化时,建议k1的值应设得更大一些。而在相反的情况下,建议把k1设得小一些。在一批短新闻中出现“eyes”几十上百次,不大可能与眼睛这个主题无关。
对于b这个参数,你应该问“我们什么时候认为一篇文档太长,什么时候它阻碍了对term的关联?”那些高度特殊的文档,比如工程规范或专利,它们很冗长,为了更特定于某个主题。它们的长度不应该削弱关联性,b的值设得更低一些比较合适。在相反的情况下,文档涉及了许多不同主题——新闻(一篇政治文章可能涉及到经济、国际事务以及某些特定的公司)、用户评论等——通常在b较大的时候结果更好,那些与用户搜索无关的主题,如垃圾信息等,会被过滤掉。
这是些常见的出发点,但最终你应该测试你设置的所有参数。这也会揭示在同一索引下,相关性性是如何与相似文档(相似语言,相似的结构等)紧密联系的。
现在你知道了BM25算法如何工作,以及那些参数如何工作,我想简单介绍一下Elasticsearch工具箱中易用的工具之一,它能在解释“为什么”的问题时能给你提供更多信息。如果你回答过“为什么文档x要比文档y评分更高”的问题,Explain API能够帮上大忙。我们看people3的文档4,这次使用两个term的查询:
GET /people3/_doc/4/_explain
{
"query": {
"match": {
"title": "shane connelly"
}
}
}
返回了丰富的信息,解释了本次查询中文档4的评分是如何得出的:
{
"_index": "people3",
"_type": "_doc",
"_id": "4",
"matched": true,
"explanation": {
"value": 0.71437943,
"description": "sum of:",
"details": [
{
"value": 0.102611035,
"description": "weight(title:shane in 3) [PerFieldSimilarity], result of:",
"details": [
{
"value": 0.102611035,
"description": "score(doc=3,freq=1.0 = termFreq=1.0\n), product of:",
"details": [
{
"value": 0.074107975,
"description": "idf, computed as log(1 + (docCount - docFreq + 0.5) / (docFreq + 0.5)) from:",
"details": [
{
"value": 6,
"description": "docFreq",
"details": []
},
{
"value": 6,
"description": "docCount",
"details": []
}
]
},
{
"value": 1.3846153,
"description": "tfNorm, computed as (freq * (k1 + 1)) / (freq + k1 * (1 - b + b * fieldLength / avgFieldLength)) from:",
"details": [
{
"value": 1,
"description": "termFreq=1.0",
"details": []
},
{
"value": 5,
"description": "parameter k1",
"details": []
},
{
"value": 1,
"description": "parameter b",
"details": []
},
{
"value": 3,
"description": "avgFieldLength",
"details": []
},
{
"value": 2,
"description": "fieldLength",
"details": []
}
]
}
]
}
]
},
{
"value": 0.61176836,
"description": "weight(title:connelly in 3) [PerFieldSimilarity], result of:",
"details": [
{
"value": 0.61176836,
"description": "score(doc=3,freq=1.0 = termFreq=1.0\n), product of:",
"details": [
{
"value": 0.44183275,
"description": "idf, computed as log(1 + (docCount - docFreq + 0.5) / (docFreq + 0.5)) from:",
"details": [
{
"value": 4,
"description": "docFreq",
"details": []
},
{
"value": 6,
"description": "docCount",
"details": []
}
]
},
{
"value": 1.3846153,
"description": "tfNorm, computed as (freq * (k1 + 1)) / (freq + k1 * (1 - b + b * fieldLength / avgFieldLength)) from:",
"details": [
{
"value": 1,
"description": "termFreq=1.0",
"details": []
},
{
"value": 5,
"description": "parameter k1",
"details": []
},
{
"value": 1,
"description": "parameter b",
"details": []
},
{
"value": 3,
"description": "avgFieldLength",
"details": []
},
{
"value": 2,
"description": "fieldLength",
"details": []
}
]
}
]
}
]
}
]
}
}
我们能看到对于每个term的k1和b的值各是多少,也能看到fieldLength和avgFieldLength以及公式其他组成部分的值。最终得分是0.71437943,我们发现有0.102611035分来自“shane”,0.61176836分来自“connelly”。在我们的数据中,Connelly更稀有一些,因此它有更高的IDF,它成为了对最终得分的主要影响。但我们也能看到文档的长度(2 terms vs 均值 3)提高了分数的“tfNorm”部分。如果我们觉得这不公平,可以通过降低b的值来进行补偿。当然对于b和k1的任何改动也会影响这个查询之外的查询,因此如果你调整过它们,一定要记得重新测试过多种查询和多种文档才行。
请记住Explain API是个调试工具,并只在调试时使用它。就像你通常不会把你的生产应用运行在debug模式,在正常情况下,你不应该在你的生产环境的Elasticseach上调用_explain。
BM25并不是唯一的评分算法!还有传统的TF/IDF, 随机性差异模型(divergence from randomness)(https://en.wikipedia.org/wiki/Divergence-from-randomness_model) 等等——甚至还有基于超链接的变种pagerank——你更可以将这些算法中的一些组合起来使用!多年来,已经出现了许多对于核心BM25算法的变种。例如,其中有一些BM25变种在学术上尝试自动选取/建议/估计k1和b值。事实上,有原因/证据表明,至少以term-by-term为基础,k1是可以有最优解的(Lv, ChengXiang (2011))。在这样的情况下,问一句“为什么用BM25?”或者“为什么BM25选了k1 = 1.2和b = 0.75这两个值?”
简单的说,选取k1或b值的一把梭算法还不存在,但用k1 = 1.2和b = 0.75能够在大多数情况下获得较好的结果。在“Improvements to BM25 and Language Models Examined” (Trotman, Puurula, Burgess (2014))中,Trotman等人研究了取值范围b=0-1和k1=0-3,还应用了许多不同的相关算法,包括那些视图自动调节BM25参数的算法。我觉得他们在结论中说得很清楚了:
"本研究中检验了9种排序函数,2种相关性反馈方法,5种词干提取算法,和2个停止词列表。我们发现停止词是无效的,词干提取是有效的,相关性反馈是有效的,不使用停止词,词干提取和使用反馈能够优化任意排序函数的效果。但没有明确的证据表明某个排序函数要系统性地比其他排序函数优秀。"
总结一下,如同开篇所说,你的大多数调优精力应该花在使用更合适的Elasticsearch查询语句,调整索引/linguistic controls,吸取用户反馈,这些事情都能够通过Elasticsearch API完成。在做完了上述努力,并想再进一步时,再考虑调整k1和b参数。对于想搞个大新闻的同学,Elasticsearch支持pluggable similarity algorithms,里面也包含了一些常用的相似度算法。但无论你干了什么,记得对你的修改进行测试!
Lipani, A., Lupu, M., Hanbury, A., Aizawa, A. (2015). Verboseness Fission for BM25 Document Length Normalization. Association for Computing Machinery
Taylor, M., Zaragoza, H., Craswell, N., Robertson, S., Burges, C. (2006). Optimisation methods for ranking functions with multiple parameters. Association for Computing Machinery
Trotman, A., Puurula, A., Burgess, B. (2014). Improvements to BM25 and Language Models Examined. Association for Computing Machinery
Lv, Y., ChengXiang, Z. (2011). Adaptive term frequency normalization for BM25. Association for Computing Machinery