众所周知,机器学习正在改变许多行业。搜索行业也是如此,公司通过手动调整搜索相关性来压榨潜能。成功的搜索组织希望通过“足够好”的手动调整来构建更智能的自学习搜索系统。
这就是为什么我们很高兴能够发布Elasticsearch排名学习插件。什么是排名学习?通过排名学习训练机器学习模型,来了解用户认为相关的内容。
在实施学习排名时,你需要:
在上述的每个步骤中,都有复杂的技术难题和非技术性问题。直到现在还没有银弹(指能极大的提高软件生产率的东西)。正如我们在相关性搜索中提到的那样,手动调整搜索结果带来了许多相同的挑战,但是其也是一个很好的学习排名解决方案。在以后的博客文章中,我们将会讲述更多的基础设施,技术和非技术的挑战来完善我们的排名学习解决方案。
在这篇博客文章中,我想向你们介绍我们将排名学习集成到Elasticsearch中的工作。我们可以为我们的客户提供一项技术几乎所有相关的咨询服务,不管这项技术是否能帮到他们。然而,尽管Bloomberg让Solr有一个明确的方向,但Elasticsearch却没有。许多客户想要Elasticsearch的现代化功能,但是发现却发现其缺少关键的搜索技术可供选择。
事实上,Elasticsearch的查询DSL(一个帮助书写和运行查询的高级Elasticsearch库)可以对巨大且复杂的结果进行排名。熟练的工程师可以使用查询DSL来计算各种可能表示相关性的查询时间特征,从而给出以下问题的定量答案:
许多这些功能不是搜索引擎中文档的静态属性。相反,它们是依赖查询的,这意味着这些功能度量用户或其查询与文档之间的某种关系。对于《相关搜索》的读者来说,这就是我们在该书中所说的信号。
所以,问题变成了,我们如何能够将机器学习的能力和Elasticsearch Query DSL的现有功能结合起来?这正是我们的插件所做的工作:使用Elasticsearch Query DSL查询作为机器学习模型的特征输入。
该插件集成了RankLib和Elasticsearch。Ranklib以一个判断文件作为输入,并在本地输出一个人类可读的模型。然后,Ranklib可让您以编程方式或通过命令行训练模型。一旦你得到了一个模型,Elasticsearch插件包含以下内容:
由于实施排名学习模型可能代价很大,你可能几乎不希望直接使用ltr查询。相反,你会重新排列前N个结果,例如:
{
"query": { /*这里是一个简单的基础查询*/ },
"rescore": {
"window_size": 100,
"query": {
"rescore_query": {
"ltr": {
"model": {
"stored": "dummy"
},
"features": [{
"match": {
"title": < users keyword search >
}
}...
你可以在项目的scripts目录中发现一个功能完整的示例。这是一个封装好的例子,它使用TMDB的电影人工判断。我使用TMDB的Elasticsearch索引来执行对应于特征的查询,用这些查询和功能的相关性得分来增加判断文件,并且在命令行上训练一个Ranklib模型。我将模型存储在Elasticsearch中,并提供一个脚本来使用该模型进行搜索。
不要被这个例子的简单所迷惑。现实中的排名学习解决方案是一项巨大的工作,包括研究用户,过程分析,数据工程和特征工程。我这么说,不是要阻止你,因为这样做的回报是值得的;只要知道你在做什么。较小的组织仍可能使用手工调整结果后在ROI(投资回报率)方面表现得更好。
先从我提供的手动创建的最小判断列表开始,来展示我们的示例如何训练模型。
Ranklib判断列表的格式相当标准。第一列包含对文档的判断(0-4)。下一列是查询ID,例如“qid:1”。紧跟其后的列包含与该查询-文档对关联的特征的值。冒号左侧是特征从1开始的索引。右侧是该特征的值。Ranklib README中的示例是:
3 qid:1 1:1 2:1 3:0 4:0.2 5:0 # 1A 2 qid:1 1:0 2:0 3:1 4:0.1 5:1 # 1B 1 qid:1 1:0 2:1 3:0 4:0.4 5:0 # 1C 1 qid:1 1:0 2:0 3:1 4:0.3 5:0 # 1D 1 qid:2 1:0 2:0 3:1 4:0.2 5:0 # 2A
还有注释(如#1A)。该注释是这一判断的文件标识符。Ranklib不需要文档标识符,但它对于读者来说非常有用。当我们通过Elasticsearch查询收集特征时,我们会发现它对我们也很有用。
我们的例子从上述文件的最小版本开始(在这里看)。我们需要从经过裁剪的文件开始,裁剪过的文件只包含分数,查询ID和文档ID元组。像这样:
4 qid:1 # 7555 3 qid:1 # 1370 3 qid:1 # 1369 3 qid:1 # 1368 0 qid:1 # 136278 ...
如上所述,我们为分级文档提供Elasticsearch _id作为每行注释。
这个方法需要进一步优化。我们必须将每个查询ID(qid:1)映射到实际的关键字查询(“Rambo”),以便我们可以使用该关键字来生成特征值。当示例代码将被取出时,我们将在其头部加上这种映射。
# Add your keyword strings below, the feature script will
# Use them to populate your query templates # # qid:1: rambo # qid:2: rocky # qid:3: bullwinkle # # https://sourceforge.net/p/lemur/wiki/RankLib%20File%20Format/ # # 4 qid:1 # 7555 3 qid:1 # 1370 3 qid:1 # 1369 3 qid:1 # 1368 0 qid:1 # 136278 ...
为了能够消除一些混淆,我将开始讨论ranklib“查询”(qid:1等)作为“关键字”,来和Elasticsearch Query DSL“查询”相区分,其中Elasticsearch Query DSL“查询”是Elasticsearch用来产生特征值的专用构建器。
以上内容并不是完整的Ranklib判断列表。对于给定关键字搜索的给定文档,这只是相关性等级的最小样本。要成为一个完整的训练集,它还需要包含上面展示的特征值,在展示的第一个判断列表的每一行后面都需要有1:0 2:1 ...
。
为了生成这些特征值,我们还需要提出可能对应于电影相关性的特征。这些正是我们所说的Elasticsearch查询。这些Elasticseach查询的分数将填满上面的判断列表。在上面的例子中,我们使用与每个要素编号对应的jinja模板来执行此操作。例如,文件1.json.jinja就是以下Query DSL查询:
{ "query": { "match": { "title": "" } } }
换句话说,我们已经决定,我们的电影搜索系统的特征1应该是用户的关键字与标题字段匹配时的TF * IDF相关性分数。还有2.jinja.json,它在多个文本字段中执行更复杂的搜索:
{ "query": { "multi_match": { "query": "", "type": "cross_fields", "fields": ["overview", "genres.name", "title", "tagline", "belongs_to_collection.name", "cast.name", "directors.name"], "tie_breaker": 1.0 } } }
排名学习的部分有趣之处来自假设什么功能可能与相关性相关。在该示例中,您可以将特征1和2更改为任何Elasticsearch查询。你还可以通过添加很多其他特征来实验。由于很多问题的特征很多,那么你需要获取足够多的具有代表性的训练样本,来涵盖所有合理的特征值。我们将在以后的博客中讨论更多的培训和测试排名学习的模型。
通过这两个组件,最小判断列表和一组推荐的Query DSL查询/特征,我们需要为Ranklib生成完备的判断列表,并将Ranklib生成的模型加载到Elasticsearch中以供使用。也就是说:
进行这些步骤的代码都捆绑在train.py中,我鼓励你将它们分解开来。你需要通过以下步骤来进行分解:
然后你可以运行:
python train.py
这一个脚本贯穿上述所有步骤。为了引导您阅读代码:
首先,我们使用文件头,关键字查询ID,等级元组,以及文件头中特定的搜索关键字来加载最小判断列表:
judgements = judgmentsByQid(judgmentsFromFile(filename='sample_judgements.txt'))
然后,我们进行批量的Elasticsearch查询来记录每个判断的特征(增加判断中的传递)。
kwDocFeatures(es, index='tmdb', searchType='movie', judgements=judgements)
kwDocFeatures
函数通过N.json.jinja(特性/查询)查找1.json.jinja,并使用Elasticsearch的批量搜索(_msearch)API ,进行批量的Elasticsearch查询来获取每个关键字/文档元组的相关性分数。代码很乏味,你可以在这里看到它。
一旦我们拥有了完备的功能,我们就可以将完整的训练集(判断和特征)输出到一个新文件(sample_judgements_wfeatures.txt)中:
buildFeaturesJudgmentsFile(judgements, filename='sample_judgements_wfeatures.txt')
输出文件将对应一个成熟的Ranklib判断列表,就像下面的:
3 qid:1 1:9.476478 2:25.821222 # 1370 3 qid:1 1:6.822593 2:23.463709 # 1369
特征1是在标题(1.json.jinja)上搜索的“Rambo”的TF * IDF得分;特征2是更复杂的搜索(2.json.jinja)的TF * IDF分数。
接下来,我们开始训练!这行代码通过命令行运行Ranklib.jar并使用保存的这个文件作为判断数据
trainModel(judgmentsWithFeaturesFile='sample_judgements_wfeatures.txt', modelOutput='model.txt')
如下所示,这只是运行java -jar Ranklib.jar来训练
LambdaMART模型:
def trainModel(judgmentsWithFeaturesFile, modelOutput): # java -jar RankLib-2.6.jar -ranker 6 -train sample_judgements_wfeatures.txt -save model.txt cmd = "java -jar RankLib-2.6.jar -ranker 6 -train %s -save %s" % (judgmentsWithFeaturesFile, modelOutput) print("Running %s" % cmd) os.system(cmd)
然后,我们使用简单的Elasticsearch命令将模型存储到Elasticsearch中:
saveModel(es, scriptName='test', modelFname='model.txt')
在这里,saveModel
,像这里所展示的一样,只是读取文件内容作为要存储的ranklib脚本并将其发布到Elasticsearch。
一旦你完成训练,你就可以进行搜索了!你可以在search.py中看到一个例子;这个例子里面的简单的查询非常直白。您可以运行python search.py rambo
,它将使用训练的模型搜索“rambo”,执行以下rescoring查询:
{ "query": { "match": { "_all": "rambo" } }, "rescore": { "window_size": 20, "query": { "rescore_query": { "ltr": { "model": { "stored": "test" }, "features": [{ "match": { "title": "rambo" } }, { "multi_match": { "query": "rambo", "type": "cross_fields", "tie_breaker": 1.0, "fields": ["overview", "genres.name", "title", "tagline", "belongs_to_collection.name", "cast.name", "directors.name"] } }] } } } } }
在这里,我们只是重新排名前20的结果。我们可以直接使用ltr查询。事实上,直接运行模型可以工作得很好。虽然它需要几百毫秒来运行整个集合。对于更大的集合,这是不可行的。总体而言,由于排名学习模型的性能成本,最好重新排名前N个结果。
这就是可以运行的例子。当然,这只是一个愚蠢的例子,旨在让你发掘创造力。你自己的问题可能需要更多的组件来解决。您选择的特征,如何记录特征,如何训练模型以及实现一个基准排名功能取决于你的研究领域。我们在相关搜索中撰写的大部分内容仍然适用。
在未来的博客文章中,我们将有更多关于学习排名的内容,包括:
如果你认为你想讨论你的搜索应用程序如何从排名学习中受益,请告诉我们。我们也一直在寻找合作者或在实际生产系统来比我们做的更好的人。所以,试一下,并给我们发送反馈!