机器学习利用 Elasticsearch 进行更智能搜索

众所周知,机器学习正在引领许多行业的变革。对于曾疲于用人工调整搜索相关性来捕捉细微差别的搜索行业就更是如此。人工调整已经实现了其能达到的最好效果,成熟的搜索公司不满足于此,试图建立更加智能和自动化的搜索系统。

因此,Elasticsearch 学习排序插件发布后我们非常激动。那么,学习排序是什么?用学习排序方法,一个团队就可以训练机器学习模型来判断用户行为的相关性。

在实现排名学习的时候,你需要:

不要被假象欺骗。上述这些步骤,其中的每一个底层都是复杂的、技术上实现起来比较困难的,以及非技术性的问题。实施起来仍然不会有捷径可循。如我们在相关度搜索中所提过的, 对搜索结果的手工调整会遇到跟应用一个好的排名学习解决方案相比许多相同的挑战。以后我们会在博文中就成熟的排名学习解决方案中关于基础设施、技术以及非技术性的挑战发表更多的意见。

在本篇博文中,我想告诉你关于我们将机器学习集成到 Elasticsearch 中的排名系统中去,所做的一些工作。客户在几乎每一次的相关咨询会议中都要问我们这项技术是否能帮助到他们。然而,虽然在 Solr 中 Bloomberg 使其有了一个应用此技术的明确途径,但在 Elasticsearch 仍然没有。许多的客户想要的是 Elasticsearch 能满足更加与时俱进的可用性需求, 却发现在为他们的搜索技术栈选择该项技术方案时存在关键性的缺失。

诚然, Elasticsearch 的 DSL 查询可以利用强大的能力和复杂的架构来实现对结果的排名。一名熟练的相关领域的工程师可以利用查询 DSL 来计算出各种也许会标识出相关性的查询时间特性, 然后给出如下一些定量问题的回答:

  1. 标题中提到的搜索字条有多少?

  2. 文章、影片等等是多久之前发布的?

  3. 文档是如何同用户的浏览习惯相关联的?

  4. 该产品的价格相对于买房的预期是贵了还是便宜?

  5. 用户在搜索是使用的词条与文章主题之间的概念性相关度几何?

这些特性大部分都不是搜索引擎文档的静态属性。相反,它们是依赖于查询的,即计量用户、用户查询和某个文档的相关度。请关联搜索的读者知悉,这就是我们在书中提到的标记。

因此问题就变成了,如何将机器学习和 elasticsearch 提供的 Query DSL 查询相结合?这就是我们的插件要完成的工作:在用户向一个机器学习模型输入特征值的时候使用 Elasticsearch Query DSL 查询。

如何工作?

这个插件集成了 RankLib 和 Elasticsearch。Ranklib 的输入是一个评分文件,它以易读格式输出一个内置的模型。我们需要通过编程或者命令行训练这个模型。有了模型以后,Elasticsearch 插件会包含如下内容:

鉴于排名学习模型的实现成本极高,你可能永远不会想要使用 ltr query,而是会像下面这样给前 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 >
        }
       }...

你可以到项目的 scripts 目录仔细研究这个功能完整的样例。这个例子封装严密,使用了 TMDB 的电影的手工评分。我用 Elasticsearch 索引了 TMDB 来检索相应特征,然后用这些检索和特征的相应分扩大了评分文件,并在命令行训练了一个 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

注意一下注释(用井号开头的),是评分的文档标识。这个标识对于 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") 相匹配,以便用关键字生成特征值。我们在例子代码的开头提供匹配关系:

# 在下面加入你的关键字, 特征脚本将会它们安置于查询模板 
# # 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 查询。Elasticsearch 查询的分数将填充上述的判断列表。上述例子中,我们用 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 } } }

学习排名的乐趣之一是假设特征的相关性。举例来说,在任意 Elasticsearch 查询中,你可以改变特征 1 和 2,你也可以通过增加额外的特征来实验。特征太多就会出现问题,你需要充分的训练样本来覆盖所有合理的特征值。在后续文章中,我们将讨论更多关于训练与测试学习来排名模型。

通过精简版判断列表和 Query DSL 查询/特征 集这两个因素,我们要为 Ranklib 生成更多的判断列表,将 Ranklib 生成模型加载进 Elasticsearch。这意味着:

  1. 为特征和关键字/文档对获取相关分数。又称,向 Elasticsearch 发出查询,记录相关分数。

  2. 输出全量的判断文件,不仅关于级别以及关键字查询ids,而且有步骤 1 中的特征值:

执行下面命令:

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 查询获取相关分数,为每一个关键字/文档元组使用 Elasticsearch 批量查询 (_msearch) API。代码在这里可以看。

一旦我们有全量的特征,我们就在新文件输出全量的训练集(判断附加特征)(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 是 "Rambo" 的 TF*IDF 分数查询(1.json.jinja),特征 2 是更复杂的 TF*IDF 分数查询(2.json.jinja)

接下来,我们训练!以下行使用保存的文件作为判断数据,通过命令行来执行 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 脚本以 POST 方式存储到 Elasticsearch 中。

使用学习排序模型进行搜索

一旦你完成了训练,你就可以进行搜索了!在 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个结果。 我们直接使用学习排序查询。 事实上,直接运行模型就工作得很好。 虽然它需要几百毫秒来搜索整个集合。 对于更大的集合,它就不能工作了。 一般来说,由于学习排序模型的性能成本,最好重新排序前 N 个结果。

这个例子只是为了抛砖引玉。 你需要根据实际问题的不同选择功能,如特征,记录特征的方式,训练模型的方式以及实施基准排名功能等。 这些在我们写的相关搜索中仍然适用。