众所周知,机器学习正在重构很多行业。搜索领域同样如此,很多公司竭尽全力通过手动调优搜索相关度来实现非常微小的改进;更成熟的搜索团队则希望进一步超越已经“足够好”的人工优化,从而构建更加智能的、自学习的搜索系统。
因此,我们很激动地发布了 Elasticsearch 的 LTR(Learning to Rank,机器学习排序)插件。什么是 LTR?一个团队可以通过 LTR 训练一个机器学习模型,来学习用户认为什么是最相关的。
实现 LTR 时需要做到:
- 通过分析来衡量用户所反馈的相关度,并构建一个评价列表,将文档分级为精确相关的、模糊相关的和无关的,并用于查询。
- 猜想哪些特征可能有助于相关度预测,比如特定属性匹配程度的 TF-IDF 相关度、新颖性,以及搜索用户的个性化特征等。
- 训练一个模型,将这些特征准确无误地映射到一个相关性得分上。
- 部署该模型到你的搜索基础设施中,在线上环境中使用它来对搜索结果排序。
不要自欺欺人:这些步骤里面的每一个都是非常复杂和困难的技术或非技术问题,并没有什么所谓的银弹。就像我们在《相关性搜索》一书中提到的,搜索结果的人工调优与一个好的机器学习排序算法面对着许多相同的挑战。我们会在今后的博客文章中探讨更多关于成熟的机器学习排序解决方案会面临的许多基础设施、技术性及非技术性挑战的内容。
本文中介绍的是我们如何把 LTR 集成到了 Elasticsearch。几乎在每一个关于相关性的咨询案例中客户都会问我们,这项技术是否可以帮助到他们。在 Solr 中要感谢 Bloomberg 给出了一个明确的路径来达到目的,而 Elasticsearch 中并没有。在为搜索技术栈做技术选型时,很多客户看重 Elasticsearch 与时俱进的高效特性,但同时也发现缺少了这个关键性的特性。
实际上 Elasticsearch 查询语句能够通过其强大的能力和复杂的结构来对结果进行排序。一个技能娴熟的相关度工程师能使用查询语句来计算多种可能涉及信号相关度的查询时特征,并定量地回答以下问题:
- 搜索的词项在标题中被提及多少次?
- 文章 / 电影 / 其他已经发布多久了?
- 文档是如何与用户的浏览行为关联的?
- 产品比买家的预期昂贵多少?
- 用户的检索词项与文章的标题在概念上有多相关?
在搜索引擎中这些特征很多都不是文档的静态属性,相反地它们依赖于查询——它们度量用户或用户的查询与一个文档之间的关联关系。对于《相关性搜索》的读者,这便是我们在该书中所说的信号。
所以现在问题变成了,如何才能把机器学习的能力与已有的 Elasticsearch Query DSL 查询语言的能力结合起来?这恰恰就是我们的插件所做的事情:把 Elasticsearch 查询语言所构造的查询作为特征输入到一个机器学习模型中。
工作原理
长话短说
该插件集成了 RankLib 和 Elasticsearch。RankLib 有一个输入文件作为评价依据,并输出一个模型,该模型是内置的可阅读格式。接下来 RankLib 可通过编程或命令行来训练模型。一旦有了模型,Elasticsearch 插件就会包含以下内容:
- 一个自定义的 Elasticsearch 脚本语言,叫做 ranklib,它把 RankLib 生成的模型作为一个 Elasticsearch 脚本
- 一个自定义的 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 中待用。这表示:
- 获取特征的每一个关键字 / 文档对的相关度得分。即发布查询到 Elasticsearch 来记录相关度得分
- 输出一个完整的评分文件,同时包含等级和关键字查询 id,以及第一步中的特征值
- 运行 Ranklib 来训练模型
- 加载模型到 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/
感谢冬雨对本文的审校。
给InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家通过新浪微博( @InfoQ , @丁晓昀),微信(微信号: InfoQChina )关注我们。
评论