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

作者:刘晓国


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


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

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


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

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


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

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

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

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

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

 

现在这很有趣,因为它与第一次搜索有很大不同。 我一点也不知道,为什么这不会在顶部给你任何非人的结果。 此外,似乎还有一些名为 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),因为稍后需要一些映射功能来改进查询,让我们暂时坚持下去。

1. PUT social-network
2. {
3. "mappings": {
4. "properties": {
5. "name": {
6. "properties": {
7. "first": {
8. "type": "text",
9. "fields": {
10. "search-as-you-type": {
11. "type": "search_as_you_type"
12.               }
13.             }
14.           },
15. "last": {
16. "type": "text",
17. "fields": {
18. "search-as-you-type": {
19. "type": "search_as_you_type"
20.               }
21.             }
22.           },
23. "full": {
24. "type": "text",
25. "fields": {
26. "search-as-you-type": {
27. "type": "search_as_you_type"
28.               }
29.             }
30.           }
31.         }
32.       },
33. "employer": {
34. "type": "text"
35.       },
36. "contacts": {
37. "type": "keyword"
38.       },
39. "title": {
40. "type": "keyword"
41.       }
42.     }
43.   }
44. }

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

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

1. PUT _ingest/pipeline/name-pipeline
2. {
3. "processors": [
4.     {
5. "script": {
6. "source": "ctx.name.full = ctx.name.first + ' ' + ctx.name.last"
7.       }
8.     }
9.   ]
10. }

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

1. PUT social-network/_bulk?pipeline=name-pipeline
2. {"index":{"_id":"alexr"}}
3. {"name":{"first":"Alexander","last":"Reelsen"},"employer":"Elastic","title":"Community Advocate","contacts":["philippk","philipk","philippl"]}
4. {"index":{"_id":"philipk"}}
5. {"name":{"first":"Philip","last":"Kredible"},"employer":"Elastic","title":"Team Lead"}
6. {"index":{"_id":"philippl"}}
7. {"name":{"first":"Philipp","last":"Laughable"},"employer":"FancyWorks","title":"Senior Software Engineer"}
8. {"index":{"_id":"philippi"}}
9. {"name":{"first":"Philipp","last":"Incredible"},"employer":"21st Century Marketing","title":"CEO"}
10. {"index":{"_id":"philippb"}}
11. {"name":{"first":"Philipp Jean","last":"Blatantly"},"employer":"Monsters Inc.","title":"CEO"}
12. {"index":{"_id":"felixp"}}
13. {"name":{"first":"Felix","last":"Philipp"},"employer":"Felixia","title":"VP Engineering"}
14. {"index":{"_id":"philippk"}}
15. {"name":{"first":"Philipp","last":"Krenn"},"employer":"Elastic","title":"Community Advocate"}

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


搜索用户


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

1. GET social-network/_search
2. {
3. "query": {
4. "match": {
5. "name.first": "Philipp"
6.     }
7.   }
8. }

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

1. GET social-network/_search?filter_path=**.name.full,**._score
2. {
3. "query": {
4. "match": {
5. "name.first": "Philipp"
6.     }
7.   }
8. }

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

1. {
2. "hits" : {
3. "hits" : [
4. {
5. "_score" : 0.6063718,
6. "_source" : {
7. "name" : {
8. "full" : "Philipp Laughable"
9. }
10. }
11. },
12. {
13. "_score" : 0.6063718,
14. "_source" : {
15. "name" : {
16. "full" : "Philipp Incredible"
17. }
18. }
19. },
20. {
21. "_score" : 0.6063718,
22. "_source" : {
23. "name" : {
24. "full" : "Philipp Krenn"
25. }
26. }
27. },
28. {
29. "_score" : 0.44027865,
30. "_source" : {
31. "name" : {
32. "full" : "Philipp Jean Blatantly"
33. }
34. }
35. }
36. ]
37. }
38. }

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


给自己的联系人评分更高


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

1. GET social-network/_search?filter_path=**.name.full,**._score
2. {
3. "query": {
4. "bool": {
5. "should": [
6.         {
7. "term": {
8. "_id": {
9. "value": "philippk"
10.             }
11.           }
12.         }
13.       ],
14. "must": [
15.         {
16. "match": {
17. "name.first": "Philipp"
18.           }
19.         }
20.       ]
21.     }
22.   }
23. }

响应如下所示:

1. {
2. "hits" : {
3. "hits" : [
4.       {
5. "_score" : 1.438688,
6. "_source" : {
7. "name" : {
8. "full" : "Philipp Krenn"
9.           }
10.         }
11.       },
12.       {
13. "_score" : 0.43868804,
14. "_source" : {
15. "name" : {
16. "full" : "Philipp Laughable"
17.           }
18.         }
19.       },
20.       ...
21.     ]
22.   }
23. }

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

1. GET social-network/_search?filter_path=**.name.full,**._score
2. {
3. "query": {
4. "bool": {
5. "should": [
6.         {
7. "terms": {
8. "_id": {
9. "index": "social-network",
10. "id": "alexr",
11. "path": "contacts"
12.             }
13.           }
14.         }
15.       ],
16. "must": [
17.         {
18. "match": {
19. "name.first": "Philipp"
20.           }
21.         }
22.       ]
23.     }
24.   }
25. }

响应如下所示:

1. {
2. "hits" : {
3. "hits" : [
4. {
5. "_score" : 1.6063719,
6. "_source" : {
7. "name" : {
8. "full" : "Philipp Laughable"
9. }
10. }
11. },
12. {
13. "_score" : 1.6063719,
14. "_source" : {
15. "name" : {
16. "full" : "Philipp Krenn"
17. }
18. }
19. },
20. {
21. "_score" : 0.6063718,
22. "_source" : {
23. "name" : {
24. "full" : "Philipp Incredible"
25. }
26. }
27. },
28. {
29. "_score" : 0.44027865,
30. "_source" : {
31. "name" : {
32. "full" : "Philipp Jean Blatantly"
33. }
34. }
35. }
36. ]
37. }
38. }

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

然而,还有更多。


完全匹配的姓氏得分更高


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

1. GET social-network/_search?filter_path=**.name.full,**._score,**.employer
2. {
3. "query": {
4. "bool": {
5. "should": [
6.         {
7. "terms": {
8. "_id": {
9. "index": "social-network",
10. "id": "alexr",
11. "path": "contacts"
12.             }
13.           }
14.         }
15.       ],
16. "must": [
17.         {
18. "multi_match": {
19. "query": "Philipp",
20. "fields": [
21. "name.last",
22. "name.first"
23.             ]
24.           }
25.         }
26.       ]
27.     }
28.   }
29. }

让我们看看结果:

1. {
2. "hits" : {
3. "hits" : [
4. {
5. "_score" : 1.6739764,
6. "_source" : {
7. "name" : {
8. "full" : "Felix Philipp"
9. },
10. "employer" : "Felixia"
11. }
12. },
13. {
14. "_score" : 1.6063719,
15. "_source" : {
16. "name" : {
17. "full" : "Philipp Laughable"
18. },
19. "employer" : "FancyWorks"
20. }
21. },
22. {
23. "_score" : 1.6063719,
24. "_source" : {
25. "name" : {
26. "full" : "Philipp Krenn"
27. },
28. "employer" : "Elastic"
29. }
30. },
31. {
32. "_score" : 0.6063718,
33. "_source" : {
34. "name" : {
35. "full" : "Philipp Incredible"
36. },
37. "employer" : "21st Century Marketing"
38. }
39. },
40. {
41. "_score" : 0.44027865,
42. "_source" : {
43. "name" : {
44. "full" : "Philipp Jean Blatantly"
45. },
46. "employer" : "Monsters Inc."
47. }
48. }
49. ]
50. }
51. }

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

1. GET social-network/_search?filter_path=**.name.full,**._score,**.employer
2. {
3. "query": {
4. "bool": {
5. "should": [
6.         {
7. "terms": {
8. "_id": {
9. "index": "social-network",
10. "id": "alexr",
11. "path": "contacts"
12.             }
13.           }
14.         }
15.       ],
16. "must": [
17.         {
18. "multi_match": {
19. "query": "Philipp",
20. "fields": [
21. "name.last^2",
22. "name.first"
23.             ]
24.           }
25.         }
26.       ]
27.     }
28.   }
29. }

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


给同事打分更高


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

1. GET social-network/_search?filter_path=**.name.full,**._score,**.employer
2. {
3. "query": {
4. "bool": {
5. "should": [
6.         {
7. "terms": {
8. "_id": {
9. "index": "social-network",
10. "id": "alexr",
11. "path": "contacts"
12.             }
13.           }
14.         },
15.         {
16. "match": {
17. "employer": "Elastic"
18.           }
19.         }
20.       ],
21. "must": [
22.         {
23. "multi_match": {
24. "query": "Philipp",
25. "fields": [
26. "name.last",
27. "name.first"
28.             ]
29.           }
30.         }
31.       ]
32.     }
33.   }
34. }

结果是这些:

1. {
2. "hits" : {
3. "hits" : [
4. {
5. "_score" : 2.5486999,
6. "_source" : {
7. "name" : {
8. "full" : "Philipp Krenn"
9. },
10. "employer" : "Elastic"
11. }
12. },
13. {
14. "_score" : 1.6739764,
15. "_source" : {
16. "name" : {
17. "full" : "Felix Philipp"
18. },
19. "employer" : "Felixia"
20. }
21. },
22. {
23. "_score" : 1.6063719,
24. "_source" : {
25. "name" : {
26. "full" : "Philipp Laughable"
27. },
28. "employer" : "FancyWorks"
29. }
30. },
31. {
32. "_score" : 0.6063718,
33. "_source" : {
34. "name" : {
35. "full" : "Philipp Incredible"
36. },
37. "employer" : "21st Century Marketing"
38. }
39. },
40. {
41. "_score" : 0.44027865,
42. "_source" : {
43. "name" : {
44. "full" : "Philipp Jean Blatantly"
45. },
46. "employer" : "Monsters Inc."
47. }
48. }
49. ]
50. }
51. }

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

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

1. "must": [
2. {
3. "multi_match": {
4. "query": "Philipp",
5. "boost": 2,
6. "fields": [
7. "name.last",
8. "name.first"
9. ]
10. }
11. }
12. ]

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

还有最后一件事。


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


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

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

1. GET social-network/_search?filter_path=**.name.full,**._score,**.employer
2. {
3. "query": {
4. "bool": {
5. "should": [
6.         {
7. "terms": {
8. "_id": {
9. "index": "social-network",
10. "id": "alexr",
11. "path": "contacts"
12.             }
13.           }
14.         },
15.         {
16. "match": {
17. "employer": "Elastic"
18.           }
19.         }
20.       ],
21. "must": [
22.         {
23. "multi_match": {
24. "query": "Philip",
25. "boost": 2, 
26. "fields": [
27. "name.last",
28. "name.first"
29.             ]
30.           }
31.         }
32.       ]
33.     }
34.   }
35. }

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

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

1. GET social-network/_search?filter_path=**.name.full,**._score,**.employer
2. {
3. "query": {
4. "bool": {
5. "should": [
6.         {
7. "terms": {
8. "_id": {
9. "index": "social-network",
10. "id": "alexr",
11. "path": "contacts"
12.             }
13.           }
14.         },
15.         {
16. "match": {
17. "employer": "Elastic"
18.           }
19.         }
20.       ],
21. "must": [
22.         {
23. "multi_match": {
24. "query": "Philip",
25. "boost": 2, 
26. "type": "phrase_prefix", 
27. "fields": [
28. "name.last.search-as-you-type",
29. "name.first.search-as-you-type"
30.             ]
31.           }
32.         }
33.       ]
34.     }
35.   }
36. }

这将返回:

1. {
2. "hits" : {
3. "hits" : [
4. {
5. "_score" : 5.47071,
6. "_source" : {
7. "name" : {
8. "full" : "Philip Kredible"
9. },
10. "employer" : "Elastic"
11. }
12. },
13. {
14. "_score" : 3.3479528,
15. "_source" : {
16. "name" : {
17. "full" : "Felix Philipp"
18. },
19. "employer" : "Felixia"
20. }
21. },
22. {
23. "_score" : 3.1550717,
24. "_source" : {
25. "name" : {
26. "full" : "Philipp Krenn"
27. },
28. "employer" : "Elastic"
29. }
30. },
31. {
32. "_score" : 2.2127438,
33. "_source" : {
34. "name" : {
35. "full" : "Philipp Laughable"
36. },
37. "employer" : "FancyWorks"
38. }
39. },
40. {
41. "_score" : 1.2127436,
42. "_source" : {
43. "name" : {
44. "full" : "Philipp Incredible"
45. },
46. "employer" : "21st Century Marketing"
47. }
48. },
49. {
50. "_score" : 0.8805573,
51. "_source" : {
52. "name" : {
53. "full" : "Philipp Jean Blatantly"
54. },
55. "employer" : "Monsters Inc."
56. }
57. }
58. ]
59. }
60. }

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

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

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

1. GET social-network/_search?filter_path=**.name.full,**._score,**.employer
2. {
3. "query": {
4. "bool": {
5. "should": [
6.         {
7. "terms": {
8. "_id": {
9. "index": "social-network",
10. "id": "alexr",
11. "path": "contacts"
12.             }
13.           }
14.         },
15.         {
16. "match": {
17. "employer": "Elastic"
18.           }
19.         }
20.       ],
21. "must": [
22.         {
23. "multi_match": {
24. "query": "Philipp K",
25. "boost": 2, 
26. "type": "phrase_prefix", 
27. "fields": [
28. "name.full.search-as-you-type",
29. "name.last.search-as-you-type",
30. "name.first.search-as-you-type"
31.             ]
32.           }
33.         }
34.       ]
35.     }
36.   }
37. }

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

还有一件事……


不关心 term 的顺序


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

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

1. GET social-network/_search?filter_path=**.name.full,**._score,**.employer
2. {
3. "query": {
4. "bool": {
5. "should": [
6.         {
7. "terms": {
8. "_id": {
9. "index": "social-network",
10. "id": "alexr",
11. "path": "contacts"
12.             }
13.           }
14.         },
15.         {
16. "match": {
17. "employer": "Elastic"
18.           }
19.         }
20.       ],
21. "must": [
22.         {
23. "bool": {
24. "should": [
25.               {
26. "multi_match": {
27. "query": "Krenn P",
28. "operator": "and",
29. "boost": 2,
30. "type": "bool_prefix",
31. "fields": [
32. "name.full.search-as-you-type",
33. "name.full.search-as-you-type._2gram",
34. "name.full.search-as-you-type._3gram"
35.                   ]
36.                 }
37.               },
38.               {
39. "multi_match": {
40. "query": "Krenn P",
41. "boost": 2,
42. "type": "phrase_prefix",
43. "fields": [
44. "name.full.search-as-you-type",
45. "name.last.search-as-you-type",
46. "name.first.search-as-you-type"
47.                   ]
48.                 }
49.               }
50.             ]
51.           }
52.         }
53.       ]
54.     }
55.   }
56. }

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

1. {
2. "hits" : {
3. "hits" : [
4. {
5. "_score" : 7.384149,
6. "_source" : {
7. "name" : {
8. "full" : "Philipp Krenn"
9. },
10. "employer" : "Elastic"
11. }
12. }
13. ]
14. }
15. }

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


使用搜索模板


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

让我们存储一个 mustache 脚本:

1. POST _scripts/social-query
2. {
3. "script": {
4. "lang": "mustache",
5. "source": {
6. "query": {
7. "bool": {
8. "should": [
9.             {
10. "terms": {
11. "_id": {
12. "index": "social-network",
13. "id": "{{own_id}}",
14. "path": "contacts"
15.                 }
16.               }
17.             },
18.             {
19. "match": {
20. "employer": "{{employer}}"
21.               }
22.             }
23.           ],
24. "must": [
25.             {
26. "bool": {
27. "should": [
28.                   {
29. "multi_match": {
30. "query": "{{query_string}}",
31. "operator": "and",
32. "boost": 2,
33. "type": "bool_prefix",
34. "fields": [
35. "name.full.search-as-you-type",
36. "name.full.search-as-you-type._2gram",
37. "name.full.search-as-you-type._3gram"
38.                       ]
39.                     }
40.                   },
41.                   {
42. "multi_match": {
43. "query": "{{query_string}}",
44. "boost": 2,
45. "type": "phrase_prefix",
46. "fields": [
47. "name.full.search-as-you-type",
48. "name.last.search-as-you-type",
49. "name.first.search-as-you-type"
50.                       ]
51.                     }
52.                   }
53.                 ]
54.               }
55.             }
56.           ]
57.         }
58.       }
59.     }
60.   }
61. }

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

1. GET social-network/_search/template
2. {
3. "id": "social-query", 
4. "params": {
5. "query_string": "Philipp",
6. "own_id" : "alexr",
7. "employer" : "Elastic"
8.     }
9. }

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


最终优化:排除自己


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

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

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

上一篇:Java 类的方法总结-目前网上最完整9种方法总结


下一篇:没有做不到,只有想不到,在星巴克用笔记本薅电费挖矿,了解一下