在Elasticsearch中应用机器学习排序LTR

众所周知,机器学习正在重构很多行业。搜索领域同样如此,很多公司竭尽全力通过手动调优搜索相关度来实现非常微小的改进;更成熟的搜索团队则希望进一步超越已经“足够好”的人工优化,从而构建更加智能的、自学习的搜索系统。

因此,我们很激动地发布了Elasticsearch的LTR(Learning to Rank,机器学习排序)插件。什么是LTR?一个团队可以通过LTR训练一个机器学习模型,来学习用户认为什么是最相关的。

实现LTR时需要做到:

  1. 通过分析来衡量用户所反馈的相关度,并构建一个评价列表,将文档分级为精确相关的、模糊相关的和无关的,并用于查询。
  2. 猜想哪些特征可能有助于相关度预测,比如特定属性匹配程度的TF-IDF相关度、新颖性,以及搜索用户的个性化特征等。
  3. 训练一个模型,将这些特征准确无误地映射到一个相关性得分上。
  4. 部署该模型到你的搜索基础设施中,在线上环境中使用它来对搜索结果排序。

不要自欺欺人:这些步骤里面的每一个都是非常复杂和困难的技术或非技术问题,并没有什么所谓的银弹。就像我们在《相关性搜索》一书中提到的,搜索结果的人工调优与一个好的机器学习排序算法面对着许多相同的挑战。我们会在今后的博客文章中探讨更多关于成熟的机器学习排序解决方案会面临的许多基础设施、技术性及非技术性挑战的内容。

本文中介绍的是我们如何把LTR集成到了Elasticsearch。几乎在每一个关于相关性的咨询案例中客户都会问我们,这项技术是否可以帮助到他们。在Solr中要感谢Bloomberg给出了一个明确的路径来达到目的,而Elasticsearch中并没有。在为搜索技术栈做技术选型时,很多客户看重Elasticsearch与时俱进的高效特性,但同时也发现缺少了这个关键性的特性。

实际上Elasticsearch查询语句能够通过其强大的能力和复杂的结构来对结果进行排序。一个技能娴熟的相关度工程师能使用查询语句来计算多种可能涉及信号相关度的查询时特征,并定量地回答以下问题:

  1. 搜索的词项在标题中被提及多少次?
  2. 文章/电影/其他已经发布多久了?
  3. 文档是如何与用户的浏览行为关联的?
  4. 产品比买家的预期昂贵多少?
  5. 用户的检索词项与文章的标题在概念上有多相关?
 

在搜索引擎中这些特征很多都不是文档的静态属性,相反地它们依赖于查询——它们度量用户或用户的查询与一个文档之间的关联关系。对于《相关性搜索》的读者,这便是我们在该书中所说的信号。

所以现在问题变成了,如何才能把机器学习的能力与已有的Elasticsearch Query DSL查询语言的能力结合起来?这恰恰就是我们的插件所做的事情:把Elasticsearch查询语言所构造的查询作为特征输入到一个机器学习模型中。

工作原理

长话短说

该插件集成了RankLib和Elasticsearch。RankLib有一个输入文件作为评价依据,并输出一个模型,该模型是内置的可阅读格式。接下来RankLib可通过编程或命令行来训练模型。一旦有了模型,Elasticsearch插件就会包含以下内容:

  1. 一个自定义的Elasticsearch脚本语言,叫做ranklib,它把RankLib生成的模型作为一个Elasticsearch脚本
  2. 一个自定义的LTR查询,它输入一个包含Query DSL查询(那些特征)、一个模型名称(就是第一步中上传的模型)和打分结果的列表

由于LTR模型的实现成本很高,人们几乎不会直接使用LTR查询,而是对结果的Top N重新打分,比如:

{
"query": {/*a simple base query goes here*/},
"rescore": {
    "window_size": 100,
    "query": {
       "rescore_query": {
          "ltr": {
              "model": {
                 "stored": "dummy"
              },
              "features": [{
                   "match": {
                       "title": <users keyword search>
                   }
               }
           ...

更多重要细节:来看看这个完整的LTR函数示例的强大之处吧!

你可以在项目脚本目录里仔细研究下这个功能完整的示例。这是一个严谨的示例,使用了TMDB数据库中的电影人工评分数据。我创建了一个Elasticsearch索引叫TMDB,用来执行查询,并通过命令行训练了一个RankLib模型。接下来把模型保存在Elasticsearch中,并提供一个脚本通过改模型来实现检索。

千万别被这个简单的例子给误导了。实践中一个真正的LTR解决方案有着非常大量的工作要做,包括用户研究、分析处理、数据工程以及特征工程等。这样说并不是为了吓唬你,因为付出是值得的,想想你的投入回报吧。小规模团队使用手工调优的ROI可能会获得更好的结果。

训练并加载LTR模型

现在使用我手工创建的迷你评价列表,来演示下如何训练一个模型。

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“查询”,区别于Elasticsearch Query DSL“查询”,后者是符合Elasticsearch规范的,用于生成特征值。

上面并不是一个文章的RangLib评价列表,仅仅是一个给定文档对于一个给定关键字搜索的迷你示例。要成为一个完备的训练集,需要包含上述特征值,并在每行后面的第一个评分列表后显示1:0 2:1 。。。等。

为了生成这些特征值,还需要提出可能跟电影相关性对应的特征。正如前文所述,这些就是Elasticsearch查询。这些Elasticsearch查询的得分将会填充上述评分列表。在上述例子中,我们通过一个对应到每个特征数字的jinja模板来实现。比如文件1.json.jinja就是下列查询:

{
"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
        }
    }
}

LTR的一个有趣之处是猜测哪些特征与相关度相关联。比如,你可以改变特征1和2为任意Elasticsearch查询,也可以多次试验增加额外的特征3。多个特征的问题是,你想获得足够典型的训练样本来覆盖所有候选特征值。后面的文章我们会探讨更多关于训练和测试LTR模型的话题。

基于这两点,最小评分列表和建议的Query DSL查询/特征集合,我们需要为RankLib生成一个全量的评价列表,并加载RankLib生成的模型到Elasticsearch中待用。这表示:

  1. 获取特征的每一个关键字/文档对的相关度得分。即发布查询到Elasticsearch来记录相关度得分
  2. 输出一个完整的评分文件,同时包含等级和关键字查询id,以及第一步中的特征值
  3. 运行Ranklib来训练模型
  4. 加载模型到Elasticsearch待搜索时调用

完成这一切的代码都在train.py中了,建议分步执行:

  • 下载RankLib.jar到scripts目录
  • 安装安装Python包elasticsearch和jinja2(如果你熟悉的话会有一个Python的requirements.txt文件)

然后只要运行:

python train.py

这个单一脚本执行了上面提到的所有步骤。现在来过一遍代码:

首先加载最小评判列表,仅包含文档、关键词查询ID、等级元组,以及文件头部的特定搜索关键词:

judgements = judgmentsByQid(judgmentsFromFile(filename='sample_judgements.txt'))

然后我们发起批量的Elasticsearch查询并记录每个评价的特征(在评价中扩大通过率)。

kwDocFeatures(es, index='tmdb', searchType='movie', judgements=judgements)

函数kwDocFeatures遍历1.json.jinja到N.json.jinja(特征/查询对),策略上使用Elasticsearch的批量搜索API(_search)来批量执行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是在属性title(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')

这里的savaModel跟看起来一样,只是读取文件内容并POST到Elasticsearch中,作为一个ranklib脚本存储。

使用LTR模型进行搜索

一旦完成训练,就准备好了发起检索!在search.py中有一个非常简明直观的例子,里面只有一个简单查询。执行命令python search.py rambo,将会使用训练好的模型检索“rambo”,并执行以下重打分查询:

{
    "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个结果做重排序,因为机器学习排序模型的性能成本的原因。

这就是一个刚好能工作的完整例子了。当然只是一个入门级的小规模示例,刚刚能达到目的;对于特定问题可能会有更多不同的状况,所选的特征、如何记录特征、训练模型,以及实现一个排序基线函数,基本上依赖于你的领域。但我们在《相关性检索》中提到的很多内容仍然适用。

后续内容

后续的博客文章中我们会更多地介绍LTR,包括:

  • 基础:更多地介绍LTR到底是什么
  • 应用:将LTR应用于搜索、推荐系统、个性化及更多场景
  • 模型:流行的模型是什么?模型选择时如何考量?
  • 思考:使用LTR时有哪些技术和非技术因素需要考量?

查看英文原文:http://opensourceconnections.com/blog/2017/02/14/elasticsearch-learning-to-rank/

相关推荐