作者:刘晓国
原文: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),因为稍后需要一些映射功能来改进查询,让我们暂时坚持下去。
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 子句给自己打高分。
我特意没有在此处包含此示例,请随意尝试。