Elasticsearch:使用 Elasticsearch 在键入时实现类似 Linkedin 的搜索

Posted Elastic 中国社区官方博客

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Elasticsearch:使用 Elasticsearch 在键入时实现类似 Linkedin 的搜索相关的知识,希望对你有一定的参考价值。

原文:Implementing a Linkedin like search as you type with Elasticsearch

在大多数社交网络中搜索时,你的直接联系人的排名将高于其他用户。 让我们看一下 Linkedin 的搜索,看看我们是否可以用 Elasticsearch 复制类似的东西。在这里也告诉大家一个小秘密:Linkedin 上面的搜索也是使用 Elasticsearch 完成的哦!

请注意,这篇文章仅在你输入建议时处理自动完成/搜索,并且在发送搜索后不会深入搜索搜索结果,从而产生搜索结果页面。

让我们看看 Linkedin 的搜索界面:

 

所以让我们看看这个搜索响应。 输入是 Philip。 我们将忽略任何非人的搜索结果或建议 - 前 6 条建议(非人)只是向你展示你可能还在搜索什么。

关注人员结果,列表中的最后五个。 前四个命中是在我的直接联系人中(也即是我的朋友或者同事)。 前两位也在 Elastic 工作。 第三个命中有 Philip 作为他的名字的一部分。 只有最后一个命中不是直接联系人 - 但也在我现在的雇主 Elastic 工作。

另一个需要注意的有趣的事情是,这显然是一个前缀(prefix)搜索,因为 Philipp 末尾有两个 p 也是一个有效匹配。

在收集需求之前,让我们尝试第二次搜索。

 

现在这很有趣,因为它与第一次搜索有很大不同。 我一点也不知道,为什么这不会在顶部给你任何非人的结果。 此外,似乎还有一些名为 Felix 的公司。 但是让我们看看人员搜索结果。

这次的第一个命中不是来自我的直接联系人,尽管我的直接联系人中有很多 Felix(这是复数,对吗?)。

显然,姓氏的完全匹配得分很高。

接下来是直接联系,首先是同事,然后是其他公司。 最后一个命中是 2 级命中,但也在 Elastic 工作。

计分规则

让我们尝试从两个结果中得出一些评分规则:

  • 搜索的名字和姓氏(first name 及 last name 这是两个不同的字段)
  • 姓氏精确匹配会使得排名靠前(还记得 TF/IDF 吗?与名字相比,Felix 很可能是一个罕见的姓氏,所以这可能是在没有调整的情况下发生的)。
  • 前缀匹配是可以的(见 Philip vs. Philipp)
  • 自己的联系人排名更高
  • 你自己雇主的第二级联系人排名更高

Data Model

接下来,让我们提出一个数据模型。

一、全文检索所需字段:名(first name)、姓(last name)、全名(full name)。

二,排名除了搜索字段外还需要的字段:雇主(employer)、直接联系人(direct contacts)。

三、显示必填字段:职称(title)、雇主(employer)。

映射数据模型

现在我先不解释映射(mapping),因为稍后需要一些映射功能来改进查询,让我们暂时坚持下去。

PUT social-network

  "mappings": 
    "properties": 
      "name": 
        "properties": 
          "first": 
            "type": "text",
            "fields": 
              "search-as-you-type": 
                "type": "search_as_you_type"
              
            
          ,
          "last": 
            "type": "text",
            "fields": 
              "search-as-you-type": 
                "type": "search_as_you_type"
              
            
          ,
          "full": 
            "type": "text",
            "fields": 
              "search-as-you-type": 
                "type": "search_as_you_type"
              
            
          
        
      ,
      "employer": 
        "type": "text"
      ,
      "contacts": 
        "type": "keyword"
      ,
      "title": 
        "type": "keyword"
      
    
  

在上面,我使用了 search_as_you_type 数据类型。如果你对这个还是不很熟悉的话,请参阅我之前的文章 “Elasticsearch:Search-as-you-type 字段类型”。

接下来,让我们创建一个索引 pipelien 来自动创建全名(full name):

PUT _ingest/pipeline/name-pipeline

  "processors": [
    
      "script": 
        "source": "ctx.name.full = ctx.name.first + ' ' + ctx.name.last"
      
    
  ]

再接下来,让我们索引一些人,一些直接联系人,一些同事和一些根本没有联系人的人:

PUT social-network/_bulk?pipeline=name-pipeline
"index":"_id":"alexr"
"name":"first":"Alexander","last":"Reelsen","employer":"Elastic","title":"Community Advocate","contacts":["philippk","philipk","philippl"]
"index":"_id":"philipk"
"name":"first":"Philip","last":"Kredible","employer":"Elastic","title":"Team Lead"
"index":"_id":"philippl"
"name":"first":"Philipp","last":"Laughable","employer":"FancyWorks","title":"Senior Software Engineer"
"index":"_id":"philippi"
"name":"first":"Philipp","last":"Incredible","employer":"21st Century Marketing","title":"CEO"
"index":"_id":"philippb"
"name":"first":"Philipp Jean","last":"Blatantly","employer":"Monsters Inc.","title":"CEO"
"index":"_id":"felixp"
"name":"first":"Felix","last":"Philipp","employer":"Felixia","title":"VP Engineering"
"index":"_id":"philippk"
"name":"first":"Philipp","last":"Krenn","employer":"Elastic","title":"Community Advocate"

为简单起见,我只为自己添加了直接联系人列表,在实际应用程序中,每个用户都会有自己的联系人列表。

搜索用户

好的,最简单的搜索优先展示 :),任意搜索 Philipp,这次只在 first name 字段中。

GET social-network/_search

  "query": 
    "match": 
      "name.first": "Philipp"
    
  

如果要减少结果字段,请将 filter_path=**.name.full,**._score 附加到 URL 以仅包含 full name 和 score。

GET social-network/_search?filter_path=**.name.full,**._score

  "query": 
    "match": 
      "name.first": "Philipp"
    
  

你会看到,所有文档的评分都相同(因为大多数字段仅在名字中包含 Philipp,但最后评分的 Philipp Jean 除外)。


  "hits" : 
    "hits" : [
      
        "_score" : 0.6063718,
        "_source" : 
          "name" : 
            "full" : "Philipp Laughable"
          
        
      ,
      
        "_score" : 0.6063718,
        "_source" : 
          "name" : 
            "full" : "Philipp Incredible"
          
        
      ,
      
        "_score" : 0.6063718,
        "_source" : 
          "name" : 
            "full" : "Philipp Krenn"
          
        
      ,
      
        "_score" : 0.44027865,
        "_source" : 
          "name" : 
            "full" : "Philipp Jean Blatantly"
          
        
      
    ]
  

没有具体的顺序,因为分数相同并且没有定义 tie breaker。最后一个文档的得分较低是因为 full name 和其它的文章相比较长一些。你可以参阅文章 TF/IDF

给自己的联系人评分更高

好的,所以我的用户(first: Alexander)有一个联系人列表。 他们的影响力如何得分。 我们可以在 bool 查询中使用 should。 假设只有 Philipp Krenn 是我的同事。 我可以查看他的 id (philippk) 并像这样添加:

GET social-network/_search?filter_path=**.name.full,**._score

  "query": 
    "bool": 
      "should": [
        
          "term": 
            "_id": 
              "value": "philippk"
            
          
        
      ],
      "must": [
        
          "match": 
            "name.first": "Philipp"
          
        
      ]
    
  

响应如下所示:


  "hits" : 
    "hits" : [
      
        "_score" : 1.438688,
        "_source" : 
          "name" : 
            "full" : "Philipp Krenn"
          
        
      ,
      
        "_score" : 0.43868804,
        "_source" : 
          "name" : 
            "full" : "Philipp Laughable"
          
        
      ,
      ...
    ]
  

在我看来不错! Philipp 现在得分更高。 但是在每次查询之前手动查找 id 太乏味了(想象一下为成千上万的联系人这样做)。 Elasticsearch 已经可以为我们做到这一点了! 有一个内置的术语查找(terms lookup)功能。 使用它,我们可以像这样自动查找我的用户的联系人列表。

GET social-network/_search?filter_path=**.name.full,**._score

  "query": 
    "bool": 
      "should": [
        
          "terms": 
            "_id": 
              "index": "social-network",
              "id": "alexr",
              "path": "contacts"
            
          
        
      ],
      "must": [
        
          "match": 
            "name.first": "Philipp"
          
        
      ]
    
  

响应如下所示:


  "hits" : 
    "hits" : [
      
        "_score" : 1.6063719,
        "_source" : 
          "name" : 
            "full" : "Philipp Laughable"
          
        
      ,
      
        "_score" : 1.6063719,
        "_source" : 
          "name" : 
            "full" : "Philipp Krenn"
          
        
      ,
      
        "_score" : 0.6063718,
        "_source" : 
          "name" : 
            "full" : "Philipp Incredible"
          
        
      ,
      
        "_score" : 0.44027865,
        "_source" : 
          "name" : 
            "full" : "Philipp Jean Blatantly"
          
        
      
    ]
  

好吧,前两个命中是直接联系人中的,所以这对我来说听起来是一个很好的实现。 每当你添加新联系人时,请确保联系人数组已更新并且一切顺利。

然而,还有更多。

完全匹配的姓氏得分更高

我们看到姓氏匹配得更高。 让我们尝试一下,到目前为止,我们只搜索了名字,但也许我们可以使用 multi match 查询来搜索名字和姓氏。

GET social-network/_search?filter_path=**.name.full,**._score,**.employer

  "query": 
    "bool": 
      "should": [
        
          "terms": 
            "_id": 
              "index": "social-network",
              "id": "alexr",
              "path": "contacts"
            
          
        
      ],
      "must": [
        
          "multi_match": 
            "query": "Philipp",
            "fields": [
              "name.last",
              "name.first"
            ]
          
        
      ]
    
  

让我们看看结果:


  "hits" : 
    "hits" : [
      
        "_score" : 1.6739764,
        "_source" : 
          "name" : 
            "full" : "Felix Philipp"
          ,
          "employer" : "Felixia"
        
      ,
      
        "_score" : 1.6063719,
        "_source" : 
          "name" : 
            "full" : "Philipp Laughable"
          ,
          "employer" : "FancyWorks"
        
      ,
      
        "_score" : 1.6063719,
        "_source" : 
          "name" : 
            "full" : "Philipp Krenn"
          ,
          "employer" : "Elastic"
        
      ,
      
        "_score" : 0.6063718,
        "_source" : 
          "name" : 
            "full" : "Philipp Incredible"
          ,
          "employer" : "21st Century Marketing"
        
      ,
      
        "_score" : 0.44027865,
        "_source" : 
          "name" : 
            "full" : "Philipp Jean Blatantly"
          ,
          "employer" : "Monsters Inc."
        
      
    ]
  

谢谢标准评分算法(best_fields)和我们非常小的数据集匹配 last name 得分最高。我们甚至可以使用加权的办法确保 last time 的得分较高:

GET social-network/_search?filter_path=**.name.full,**._score,**.employer

  "query": 
    "bool": 
      "should": [
        
          "terms": 
            "_id": 
              "index": "social-network",
              "id": "alexr",
              "path": "contacts"
            
          
        
      ],
      "must": [
        
          "multi_match": 
            "query": "Philipp",
            "fields": [
              "name.last^2",
              "name.first"
            ]
          
        
      ]
    
  

在上面,我们使用 name.last^2 使得 last name 在计算分数时进行加权。

给同事打分更高

如果我们找到两个直接联系人,但一个用户为你的雇主(比如 Elastic)工作,那么如何给他们更高的评价? 幸运的是,我们可以添加一个 should 子句。

GET social-network/_search?filter_path=**.name.full,**._score,**.employer

  "query": 
    "bool": 
      "should": [
        
          "terms": 
            "_id": 
              "index": "social-network",
              "id": "alexr",
              "path": "contacts"
            
          
        ,
        
          "match": 
            "employer": "Elastic"
          
        
      ],
      "must": [
        
          "multi_match": 
            "query": "Philipp",
            "fields": [
              "name.last",
              "name.first"
            ]
          
        
      ]
    
  

结果是这些:


  "hits" : 
    "hits" : [
      
        "_score" : 2.5486999,
        "_source" : 
          "name" : 
            "full" : "Philipp Krenn"
          ,
          "employer" : "Elastic"
        
      ,
      
        "_score" : 1.6739764,
        "_source" : 
          "name" : 
            "full" : "Felix Philipp"
          ,
          "employer" : "Felixia"
        
      ,
      
        "_score" : 1.6063719,
        "_source" : 
          "name" : 
            "full" : "Philipp Laughable"
          ,
          "employer" : "FancyWorks"
        
      ,
      
        "_score" : 0.6063718,
        "_source" : 
          "name" : 
            "full" : "Philipp Incredible"
          ,
          "employer" : "21st Century Marketing"
        
      ,
      
        "_score" : 0.44027865,
        "_source" : 
          "name" : 
            "full" : "Philipp Jean Blatantly"
          ,
          "employer" : "Monsters Inc."
        
      
    ]
  

现在有了两个 should 子句,你可以看到得分发生了变化,并且 Philipp 作为姓氏不再得分最高。 这可能是期望的行为,也可能不是。 我们能做些什么来再次增加姓氏得分? 或者可能减少两个 should 从句? 另一个解决方案是给联系人打分更高,但员工只有在他们还没有联系人的情况下 - 因为这个查询变得更加复杂,这对你来说是一个练习。

另一种解决方案是通过将查询的必须部分更改为

"must": [
  
    "multi_match": 
      "query": "Philipp",
      "boost": 2, 
      "fields": [
        "name.last",
        "name.first"
      ]
    
  
]

这样,must 部分变得更加重要。 如你所见,有很多方法可以调整和尝试使用你自己的数据。

还有最后一件事。

使用 “search-as-you-type” 数据类型

我们还没有涉及的一件事是部分匹配。 搜索 Philip 还应该返回我们数据集中的所有 Philipps。

现在下面的查询只返回 Philip Jan Kredible,我们唯一的只含有一个 p 字母的 Philip。

GET social-network/_search?filter_path=**.name.full,**._score,**.employer

  "query": 
    "bool": 
      "should": [
        
          "terms": 
            "_id": 
              "index": "social-network",
              "id": "alexr",
              "path": "contacts"
            
          
        ,
        
          "match": 
            "employer": "Elastic"
          
        
      ],
      "must": [
        
          "multi_match": 
            "query": "Philip",
            "boost": 2, 
            "fields": [
              "name.last",
              "name.first"
            ]
          
        
      ]
    
  

还记得一开始的映射吗? name 字段包含我们现在利用的 search-as-you-type  类型映射。 该字段针对搜索进行了优化,因为你通过存储字段 shingle 和 edge ngram 标记过滤器来开箱即用地键入用例,以确保查询尽可能快 - 以需要更多磁盘空间为代价。

让我们切换 multi match 查询的类型:

GET social-network/_search?filter_path=**.name.full,**._score,**.employer

  "query": 
    "bool": 
      "should": [
        
          "terms": 
            "_id": 
              "index": "social-network",
              "id": "alexr",
              "path": "contacts"
            
          
        ,
        
          "match": 
            "employer": "Elastic"
          
        
      ],
      "must": [
        
          "multi_match": 
            "query": "Philip",
            "boost": 2, 
            "type": "phrase_prefix", 
            "fields": [
              "name.last.search-as-you-type",
              "name.first.search-as-you-type"
            ]
          
        
      ]
    
  

这将返回:


  "hits" : 
    "hits" : [
      
        "_score" : 5.47071,
        "_source" : 
          "name" : 
            "full" : "Philip Kredible"
          ,
          "employer" : "Elastic"
        
      ,
      
        "_score" : 3.3479528,
        "_source" : 
          "name" : 
            "full" : "Felix Philipp"
          ,
          "employer" : "Felixia"
        
      ,
      
        "_score" : 3.1550717,
        "_source" : 
          "name" : 
            "full" : "Philipp Krenn"
          ,
          "employer" : "Elastic"
        
      ,
      
        "_score" : 2.2127438,
        "_source" : 
          "name" : 
            "full" : "Philipp Laughable"
          ,
          "employer" : "FancyWorks"
        
      ,
      
        "_score" : 1.2127436,
        "_source" : 
          "name" : 
            "full" : "Philipp Incredible"
          ,
          "employer" : "21st Century Marketing"
        
      ,
      
        "_score" : 0.8805573,
        "_source" : 
          "name" : 
            "full" : "Philipp Jean Blatantly"
          ,
          "employer" : "Monsters Inc."
        
      
    ]
  

首先是完全匹配(philip),第二是得分最高的姓氏(Philipp),然后是我的同事 Philipp Krenn。 看起来不错!

现在我们得到了完美的搜索? 好吧……尝试搜索 Philipp K - 我们没有得到任何结果。 那很糟!

然而,由于我们的摄入管道,我们也获得了全名索引,让我们将其添加到正在搜索的字段中:

GET social-network/_search?filter_path=**.name.full,**._score,**.employer

  "query": 
    "bool": 
      "should": [
        
          "terms": 
            "_id": 
              "index": "social-network",
              "id": "alexr",
              "path": "contacts"
            
          
        ,
        
          "match": 
            "employer": "Elastic"
          
        
      ],
      "must": [
        
          "multi_match": 
            "query": "Philipp K",
            "boost": 2, 
            "type": "phrase_prefix", 
            "fields": [
              "name.full.search-as-you-type",
              "name.last.search-as-you-type",
              "name.first.search-as-you-type"
            ]
          
        
      ]
    
  

现在搜索 Philip、Philipp 和 Philipp K 会返回正确的结果。

还有一件事……

不关心 term 的顺序

不是每个人都知道他正在搜索的人的全名,所以有时你可能只输入姓氏。 搜索 Krenn 按预期工作,但是搜索 Krenn P 不会产生任何结果!

那么,我们能做些什么呢? 让我们的查询更大一点:

GET social-network/_search?filter_path=**.name.full,**._score,**.employer

  "query": 
    "bool": 
      "should": [
        
          "terms": 
            "_id": 
              "index": "social-network",
              "id": "alexr",
              "path": "contacts"
            
          
        ,
        
          "match": 
            "employer": "Elastic"
          
        
      ],
      "must": [
        
          "bool": 
            "should": [
              
                "multi_match": 
                  "query": "Krenn P",
                  "operator": "and",
                  "boost": 2,
                  "type": "bool_prefix",
                  "fields": [
                    "name.full.search-as-you-type",
                    "name.full.search-as-you-type._2gram",
                    "name.full.search-as-you-type._3gram"
                  ]
                
              ,
              
                "multi_match": 
                  "query": "Krenn P",
                  "boost": 2,
                  "type": "phrase_prefix",
                  "fields": [
                    "name.full.search-as-you-type",
                    "name.last.search-as-you-type",
                    "name.first.search-as-you-type"
                  ]
                
              
            ]
          
        
      ]
    
  

此查询在所有先前情况下的行为相似,但还支持以任意顺序搜索术语(如姓氏在前),同时仍提供补全支持。上面的搜索结果为:


  "hits" : 
    "hits" : [
      
        "_score" : 7.384149,
        "_source" : 
          "name" : 
            "full" : "Philipp Krenn"
          ,
          "employer" : "Elastic"
        
      
    ]
  

现在作为最后一步,让我们在搜索端使它更易于维护。

使用搜索模板

最后一步是存储此搜索,以便搜索客户端只需提供一次输入查询。

让我们存储一个 mustache 脚本:

POST _scripts/social-query

  "script": 
    "lang": "mustache",
    "source": 
      "query": 
        "bool": 
          "should": [
            
              "terms": 
                "_id": 
                  "index": "social-network",
                  "id": "own_id",
                  "path": "contacts"
                
              
            ,
            
              "match": 
                "employer": "employer"
              
            
          ],
          "must": [
            
              "bool": 
                "should": [
                  
                    "multi_match": 
                      "query": "query_string",
                      "operator": "and",
                      "boost": 2,
                      "type": "bool_prefix",
                      "fields": [
                        "name.full.search-as-you-type",
                        "name.full.search-as-you-type._2gram",
                        "name.full.search-as-you-type._3gram"
                      ]
                    
                  ,
                  
                    "multi_match": 
                      "query": "query_string",
                      "boost": 2,
                      "type": "phrase_prefix",
                      "fields": [
                        "name.full.search-as-you-type",
                        "name.last.search-as-you-type",
                        "name.first.search-as-you-type"
                      ]
                    
                  
                ]
              
            
          ]
        
      
    
  

现在查询超短,我们只需要提供一些查询信息:

GET social-network/_search/template

    "id": "social-query", 
    "params": 
        "query_string": "Philipp",
        "own_id" : "alexr",
        "employer" : "Elastic"
    

这种方法的另一个优点是,你现在可以在不更改应用程序的情况下切换查询的底层实现。 你甚至可以做一些花哨的事情,比如 a/b 测试。

最终优化:排除自己

尽管这在开始时听起来很有用,但我敢打赌,每个人都会时不时地在每个社交网络上搜索自己。 关闭自恋很难 :-)

你可以在 bool 查询中添加另一个过滤  own_id 的 must_not 子句,并确保你在搜索内容时永远不会看到自己,但我认为这可能是一种不错的感觉。 此外,如果你继续包括自己,你可能希望使用 should 子句给自己打高分。

我特意没有在此处包含此示例,请随意尝试。

以上是关于Elasticsearch:使用 Elasticsearch 在键入时实现类似 Linkedin 的搜索的主要内容,如果未能解决你的问题,请参考以下文章

spring整合Elasticsearch

ElasticSearch 基本使用

ElasticSearch:分析器

ElasticSearch安装与启动

EnableReactiveMongoRepositories和ElasticSearch

Elasticsearch 和 Hive 协同工作