使用Elasticsearch就像玩乐高一样
介绍
在过去的22个月中,我一直担任站点搜索工程师,使用Elasticsearch来帮助提高我们餐厅平台的相关性。我总共部署了83个版本,其中包括3个主要版本。
每周大约发布一个版本,我可以说,不仅我们的搜索引擎比2年前要好得多,而且我也学到了很多东西。虽然离一个伟大的搜索引擎还有很远的路要走,但我认为以下一些值得分享的东西。更重要的是,我真的想得到他们的反馈。
这篇博客文章是为了提供一种设计Elasticsearch查询模板的方法,以解决常见站点搜索问题,包括在不同字段中搜索匹配项、提高结果和测试。我们将一起识别默认方法的问题,然后逐步提出一个新方法来解决这些问题。
这个Github repo: https://github.com/dvquy13/elasticsearch-sharing 包含了本文中讨论的例子和代码。
主要内容
现在我们扮演餐厅平台的搜索工程师的角色,这个平台允许用餐者发现并预订他们下一餐的餐厅。我们没有太多的经验,但幸运的是,应用程序并不需要在开始时具有Google的精度水平。关键是要逐步取得可见的进步!
好的,让我们开始吧。首先,我们确保用户可以按名称搜索餐厅。在这里,我们可以依靠简单的默认query-match
来完成工作。
# Index our first two restaurantsPOST _bulk{ "index" : { "_index" : "restaurant", "_id" : "001sabichuong" } }{ "restaurant_name": "Sa Bi Chuong", "cuisine": "Vietnamese", "rating": 5.0 }{ "index" : { "_index" : "restaurant", "_id" : "002vietnamesephonoodle" } }{ "restaurant_name": "Vietnamese Pho Noodle", "cuisine": "Vietnamese", "rating": 4.0 }# Test searching for one# Should return Vietnamese Pho NoodleGET restaurant/_search{ "query" : { "match" : { "restaurant_name": "vietnamese" } }}
上面的片段可以在Kibana的Dev Tools > Console中运行,如果您遵循该存储库,它将在localhost: 5601
上可用。
代码是自解释的。我们要求Elasticsearch返回餐厅名称包含vietnamese
的餐厅。我们得到了一个名为Vietnamese Pho Noodle
的结果。没有问题。
但我们很快发现,名称不是我们希望在用户提交查询时查找的唯一位置。给定关键字vietnamese
,我们应该还返回标记为cuisine
中的越南餐厅Sa Bi Chuong
。一个multi_match
查询允许我们做到这一点。
# Matching multiple fields# Should return all 2 Vietnamese restaurant with the Vietnamese Pho Noodle on topGET restaurant/_search{ "query" : { "multi_match" : { "query": "vietnamese", "fields": [ "restaurant_name", "cuisine" ] } }}
# Result"hits": { ... "hits": [ { "_index": "restaurant", "_id": "002vietnamesephonoodle", "_score": 0.6931471, "_source": { "restaurant_name": "Vietnamese Pho Noodle", "cuisine": "Vietnamese", "rating": 4 } }, { "_index": "restaurant", "_id": "001sabichuong", "_score": 0.18232156, "_source": { "restaurant_name": "Sa Bi Chuong", "cuisine": "Vietnamese", "rating": 5 } } ] }
默认TFIDF的问题
注意上面的分数。第一个分数比第二个高4倍,说明在查询vietnamese
时更相关。人们可能会假设,因为在多个字段上匹配会使分数更高。
每当我们有疑问时,我们可以使用Elasticsearch的explain
功能来获取其得分组件的详细分解。
# 让我们使用explain=true来查看底层发生了什么# 越南河粉面在顶部,因为默认的TFIDF实现惩罚了在菜肴字段上的匹配,因为有多个菜肴为越南菜肴的餐厅,而只有一个餐厅的名字为越南# 问题:但是为什么在其名称中有越南人的餐厅比其他餐厅更具越南特色?GET restaurant/_search{ "query" : { "multi_match" : { "query": "vietnamese", "fields": [ "restaurant_name", "cuisine" ] } }, "explain": true}
# 结果"hits": { "hits": [ { "_id": "002vietnamesephonoodle", "_score": 0.6931471, "_source": { "restaurant_name": "Vietnamese Pho Noodle", "cuisine": "Vietnamese", "rating": 4 }, "_explanation": { "value": 0.6931471, "description": "max of:", "details": [ # 在字段`cuisine`中匹配得分为0.18 # 请注意,默认情况下,分数是通过TFIDF计算的 # 有关Elasticsearch TFIDF的更多信息:https://www.elastic.co/guide/en/elasticsearch/reference/8.6/index-modules-similarity.html#bm25 { "value": 0.18232156, "description": "weight(cuisine:vietnamese in 1) [PerFieldSimilarity], result of:", "details": [...] }, # 在字段`restaurant_name`中匹配得分为0.69 { "value": 0.6931471, "description": "weight(restaurant_name:vietnamese in 1) [PerFieldSimilarity], result of:", "details": [...] } # 因为最终得分是上述两个得分的“最大值”, # 所以它等于`restaurant_name`的匹配得分 ] } }, { "_id": "001sabichuong", "_score": 0.18232156, "_source": { "restaurant_name": "Sa Bi Chuong", "cuisine": "Vietnamese", "rating": 5 }, # 类似地,因为没有与`restaurant_name`匹配, # 所以这里的最终得分等于`cuisine`的匹配得分 "_explanation": { "value": 0.18232156, "description": "max of:", "details": [ { "value": 0.18232156, "description": "weight(cuisine:vietnamese in 0) [PerFieldSimilarity], result of:", "details": [...] } ] } } ] }
在上面,我们可以看到,越南河粉面之所以在顶部,是因为默认的TFIDF实现惩罚了在菜肴字段上的匹配,因为有多个菜肴为Vietnamese
的餐厅,而只有一个餐厅的名字为Vietnamese
。
深入_explanation
块,我们意识到得分差异源于restaurant_name
的TFIDF匹配输出。这是预期的,因为算法假设如果一个关键词不常见并且通常在很多文档中找到(一种自动处理停用词的解决方案),则该关键词是更好的信号。在我们的例子中,两个餐厅都有菜肴Vietnamese
,因此根据TFIDF,该匹配并没有关于文档相关性的太多信息。
我们是否应该鼓励这种行为是一个问题。有越南人的名字会让一个餐厅比另一个餐厅更具有“越南特色”吗?
TFIDF的另一个问题是它考虑了字段的长度。
# 让我们添加另一个餐厅POST _bulk{ "index" : { "_index" : "restaurant", "_id" : "003vietnamesepho" } }{ "restaurant_name": "Vietnamese Pho", "cuisine": "Vietnamese", "rating": 3.0 }# 在下面的例子中,我们看到新的越南河粉面餐厅排名更高...GET restaurant/_search{ "query" : { "multi_match" : { "query": "vietnamese pho", "fields": [ "restaurant_name", "cuisine" ] } }, "explain": true}
您可以在帖子末尾的附录#1中找到详细而冗长的结果。简而言之,我们看到结果将越南饭店Pho排名第一,然后是越南Pho Noodle。分析组件分数表明,关键差异在于越南Pho的长度为2
(单词),而越南Pho Noodle的长度为3
。这感觉不直观,因为我们知道第二家餐厅的评分更高,因为在实践中,两者都与用户的关键字相匹配。
使用function_score重新排名(提升)
当我们谈论评分
时,我们可以使用function_score
包装我们的查询,以将该信息合并到我们的匹配得分中进行修改,从而更好地控制我们的排名。
GET restaurant/_search{ "query": { "function_score": { # 我们的主查询被包装在function_score子句中 "query": { "multi_match" : { "query": "vietnamese", "fields": [ "restaurant_name", "cuisine" ] } }, # 我们定义将应用于我们的主查询返回的匹配得分之上的函数 "functions": [ { "field_value_factor": { "field": "rating", "modifier": "none", "missing": 1 } } ], # 检索在'functions'中定义的最大提升 # 上面只有一个提升,因此默认情况下应用 "score_mode": "max", # 将匹配得分乘以从函数计算出的提升 "boost_mode": "multiply" } }}
# 结果{ "hits": { "hits": [ { "_index": "restaurant", "_id": "002vietnamesephonoodle", "_score": 1.7885544, "_source": { "restaurant_name": "Vietnamese Pho Noodle", "cuisine": "Vietnamese", "rating": 4 } }, { "_index": "restaurant", "_id": "003vietnamesepho", "_score": 1.5706451, "_source": { "restaurant_name": "Vietnamese Pho", "cuisine": "Vietnamese", "rating": 3 } }, { "_index": "restaurant", "_id": "001sabichuong", "_score": 0.66765696, "_source": { "restaurant_name": "Sa Bi Chuong", "cuisine": "Vietnamese", "rating": 5 } } ] }}
评分更高的餐厅现在排在前面了。但是,拥有评分=5
的餐厅Sa Bi Chuong
怎么样?它作为最后一个结果似乎我们还没有足够的提升。
我们可以开始更多地使用function_score
来实现这一点。这是其中一种实现方式,它以非线性方式建模提升,以有效地在评分=5
的文档上应用强力提升。
GET restaurant/_search{ "query": { "function_score": { "query": { "multi_match" : { "query": "vietnamese", "fields": [ "restaurant_name", "cuisine" ] } }, "functions": [ # 应用非线性函数来模拟 # 评分为5的权重要比评分为4的权重大得多(不仅仅是多25%) { "filter": { "range": { "rating": { "gte": 5, "lte": 5 } } }, "weight": 10 }, { "filter": { "range": { "rating": { "gte": 4, "lt": 5 } } }, "weight": 2 } ], "score_mode": "max", "boost_mode": "multiply" } }}
# 结果{ "hits": { "hits": [ { "_index": "restaurant", "_id": "001sabichuong", "_score": 1.3353139, "_source": { "restaurant_name": "Sa Bi Chuong", "cuisine": "Vietnamese", "rating": 5 } }, { "_index": "restaurant", "_id": "002vietnamesephonoodle", "_score": 0.8942772, "_source": { "restaurant_name": "Vietnamese Pho Noodle", "cuisine": "Vietnamese", "rating": 4 } }, { "_index": "restaurant", "_id": "003vietnamesepho", "_score": 0.52354836, "_source": { "restaurant_name": "Vietnamese Pho", "cuisine": "Vietnamese", "rating": 3 } } ] }}
我们可能会思考:“现在的函数增强不是看起来太武断了吗?它是否适用于其他情况?”确实,这是我们应该问自己的问题。随着越来越多的要求,我们的查询模板将变得越来越复杂,导致我们所做的修改之间产生冲突。
让我们转到下一个示例,以说明我所说的“冲突”是什么意思。
模糊匹配带来的复杂性
虽然不是必要的,但处理用户的拼写错误的能力始终是一个不错的功能,特别是当他们现在熟悉像 Google 这样的智能搜索引擎时。Elasticsearch 有一个内置的机制叫做 fuzzy matching
,可以用选项 fuzziness
进行配置。
# 使用下面的 `bool` 查询实现逻辑:至少有一个条件应该匹配PUT _scripts/01-default-fuzzy-search-template{ "script": { "lang": "mustache", "source": { "query": { "function_score": { "query": { "bool": { "must": [ { "bool": { "should": [ { "multi_match" : { "query": "{{query_string}}", "fields": [ "restaurant_name", "cuisine" ] } }, { "multi_match" : { "query": "{{query_string}}", "fields": [ "restaurant_name", "cuisine" ], # 为了演示目的,默认行为已足够良好 "fuzziness": "AUTO" } } ] } } ] } }, "functions": [ { "filter": { "range": { "rating": { "gte": 5, "lte": 5 } } }, "weight": 10 }, { "filter": { "range": { "rating": { "gte": 4, "lt": 5 } } }, "weight": 2 } ], "score_mode": "max", "boost_mode": "multiply" } } }, "params": { "query_string": "我的查询字符串" } }}
请注意,我们只创建了一个查询模板而没有运行查询。现在,我们可以使用参数调用查询,这是 Elasticsearch 引入的一个很好的功能,使我们的代码看起来不那么令人难以承受。像这样:
GET /_search/template{ "id": "01-default-fuzzy-search-template", "params": { "query_string": "vietnames" }}
上面的查询返回我们期望的越南餐厅,给出了一个错别字关键字 vietnames
。在内部,模糊匹配使用 Levenshtein 编辑距离,通过使一个字符串变成另一个字符串所需的修改次数来衡量字符串之间的相似性。在我们的例子中,我们只需要在末尾添加一个字母e
,就可以使vietnames
变成vietnamese
。对于算法来说,这是一个相当容易的任务。有人可能还会争论说,对于我们的开发人员来说,这也是很容易的。2行代码和一个新的美丽功能。
嗯,有趣的部分在别处。有一天,我们的销售团队突然向我们投诉搜索结果是错误的。即使他们明确搜索了kbbq
(这是korean bbq
的常用缩写),人们仍然会得到日式烧烤餐厅而不是韩国烧烤餐厅。
这是餐厅列表:
POST _bulk{ "index" : { "_index" : "restaurant", "_id" : "004parkhangseokbbq" } }{ "restaurant_name": "Park Hang-seo's KBBQ", "cuisine": "Korean", "rating": 2.0 }{ "index" : { "_index" : "restaurant", "_id" : "005bestbbqintown" } }{ "restaurant_name": "Best BBQ in town", "cuisine": "Japanese", "rating": 5.0 }
查询:
{ "id": "01-default-fuzzy-search-template", "params": { "query_string": "kbbq" }}
结果:
{ "hits": { "hits": [ { "_index": "restaurant", "_id": "005bestbbqintown", "_score": 8.384459, "_source": { "restaurant_name": "Best BBQ in town", "cuisine": "Japanese", "rating": 5 } }, { "_index": "restaurant", "_id": "004parkhangseokbbq", "_score": 2.5153382, "_source": { "restaurant_name": "Park Hang-seo's KBBQ", "cuisine": "Korean", "rating": 2 } } ] }}
为了理解发生了什么,我们需要启用explain=true
来查看对最终得分的贡献。由于这次输出过于详细,以下是发现:
- 在
Best BBQ in town
餐厅之前进行提升的关键字匹配分数为0.8,小于Park Hang-seo's KBBQ
的1.2 - 因此,如果没有应用提升,则会看到
Park Hang-seo's KBBQ
餐厅排名第一 - 但是,来自
rating
的提升修改了得分,导致我们可以看到的排序
解决问题的一种方法是我们拥有不完美的提升。假设我们有一个更好的公式来平衡权衡,那么问题应该得到解决。但是,几乎不可能保证新公式不会引起任何其他问题。我们不希望这些问题在没有任何通知的情况下潜入系统,然后有一天被利益相关者标记出来。我们希望在讨论潜在解决方案之前,我们都同意下一个非常重要的事情是(是的,您可能正在考虑与我相同的事情)建立一个测试/评估机制。
我们应该如何为这个搜索应用程序创建测试用例?
在我的看法中,第一个挑战是关于移动数据的。查询和文档都可以随时间增长而增长,因此静态模拟数据集可能不再是搜索相关性的非常好的代表。下一步与我们的心态有关。有时,我们可能需要考虑是否需要百分之百的通过测试用例来解决这个新的非常紧急的问题。例如,有些情况下,如果您修复了一些问题,那么其他测试用例的搜索结果排序可能会有所改变。如果我们硬编码排名,那么我们可能会尝试调整查询模板。但是在实践中,在很多时候,我们既不需要将排名完全预定义,也不确定哪种排序实际上是最优的。我们应该考虑使用软机制,其中我们量化系统的相关性并使用阈值。
在这里,我们看一下如何使用Elasticsearch Ranking Evaluation API来实现这样的评估方案:
GET restaurant/_rank_eval{ # 与_rank_eval一起使用时,查询模板非常方便 "templates": [ { "id": "01-default-fuzzy-search-template", "template": { "id": "01-default-fuzzy-search-template" } } ], "requests": [ { "id": "kbbq_query", # 在此,我们手动定义了真正的正面案例,其中评分>= 1.0 # 实际评分数字有助于在使用考虑到搜索结果排名的指标时 "ratings": [ { "_index": "restaurant", "_id": "004parkhangseokbbq", "rating": 3 }, { "_index": "restaurant", "_id": "005bestbbqintown", "rating": 1 } ], "template_id": "01-default-fuzzy-search-template", "params": { "query_string": "kbbq" } }, { "id": "vietnamese_query", "ratings": [ { "_index": "restaurant", "_id": "001sabichuong", "rating": 3 }, { "_index": "restaurant", "_id": "002vietnamesephonoodle", "rating": 3 }, { "_index": "restaurant", "_id": "003vietnamesepho", "rating": 3 } ], "template_id": "01-default-fuzzy-search-template", "params": { "query_string": "vietnamese" } } ], "metric": { "dcg": { "k": 5, "normalize": true } }}
结果:
{ "metric_score": 0.8549048706984328, # 这是总体指标得分,最佳为1.0,最差为0.0 "details": { "kbbq_query": { # 该kbbq_query具有不完美的分数,因为它将更相关的结果排名较低 "metric_score": 0.7098097413968655, "unrated_docs": [], "hits": [ { "hit": { "_index": "restaurant", "_id": "005bestbbqintown", "_score": 8.384459 }, "rating": 1 }, { "hit": { "_index": "restaurant", "_id": "004parkhangseokbbq", "_score": 2.5153382 }, "rating": 3 } ], "metric_details": { ... } }, "vietnamese_query": { "metric_score": 1, "unrated_docs": [], "hits": [ ... ], "metric_details": { ... } } }, "failures": {}}
让我们通过引入更改,将评估分数更接近完美的1.0来改进我们的搜索。
我们的修订搜索模型
在开始设计新的查询模板之前,我们可以退后一步,真正思考如何建模搜索引擎。以下是要点:
- 精确匹配始终会出现在模糊匹配之上;
- 精确匹配不考虑字段长度或单词/文档频率。如果两个文档在一个字段中具有相同的精确匹配,则它们应具有相同的关键字匹配分数;
- 在相同的匹配级别(无论是精确还是模糊),虽然初始关键字匹配分数应该相同,但它们可以通过某些修饰符(如距离、流行度等)重新排序。然而,修改后的分数不应使最终得分超过上一级的基础分数,例如修改后的模糊分数不应大于精确基础分数。这是为了确保要点#1。
如果你看足球比赛,这就像英超等联赛如何排名他们的团队。无论L队比M队进球多少,或者他们的头对头结果如何,如果M队得分更高,则M队排名更高。其他措施仅用于打破平局。
然后,可以将此理解转移到我们如何使用Elasticsearch表达我们的模型。
一种方法是使用dis_max
查询结合constant_score
查询。想法是将每种类型的匹配分类为不同的得分级别,其中一个级别将比下面的级别高两倍。落入匹配级别(平局)的文档将通过修饰符重新排序,但最终的新分数不会超过上层基础分数。这是新的查询模板:
PUT _scripts/02-constant-score-search-template{ "script": { "lang": "mustache", "source": { "query": { "function_score": { "query": { "bool": { "must": [ { "bool": { "should": [ { # `dis_max` query gets the max score of an array of clauses "dis_max": { "queries": [ { # `constant_score` says that if matches, return a constant score "constant_score": { "filter": { "multi_match" : { "query": "{{query_string}}", "fields": [ "restaurant_name", "cuisine" ] } }, # This is the constant that is returned as score # Note that the exact number is chosen intentionally # Here the upper level will be twice the lower level # and we will restrict the modifiers to be only # able to boost by at most 100% the base score # so that the lower level can not exceed the upper "boost": 2 } }, { "constant_score": { "filter": { "multi_match" : { "query": "{{query_string}}", "fields": [ "restaurant_name", "cuisine" ], "fuzziness": "AUTO" } }, "boost": 1 } } ] } } ] } } ] } }, "functions": [ # Design the modifiers to be multiplier of maximum 1.9999 the base score { "weight": 1 }, { "field_value_factor": { "field": "rating", "modifier": "ln", "missing": 1 }, "weight": 0.1 } ], "score_mode": "sum", "boost_mode": "multiply" } } }, "params": { "query_string": "My query string" } }}
当我们重新运行评估时,我们可以观察到归一化的DCG指标现在得分为1.0,表示完美的准确性!
总结
本博客文章专注于让您成为一个必须得出适合站点搜索引擎需求的查询模板的Elasticsearch工程师。我们简要涵盖了以下主题:
- 多字段关键字匹配
- 理解默认的Elasticsearch评分
- 默认TFIDF的问题
- 按属性提高搜索结果
- 模糊匹配
- Elasticsearch查询模板评估与排名评估API
- 使用
dis_max
和constant_score
构建查询
尽管肯定不是最优的,但我希望博客文章的某些部分能帮助您更接近利用 Elasticsearch 来解决自己的问题。
我也非常感谢任何评论或反馈。如果您想进行更多讨论,请在此文章中发表评论或在 Github 存储库中打开问题:https://github.com/dvquy13/elasticsearch-sharing。
谢谢大家!
附录
#1:默认 TFIDF 匹配详细分解,其中字段值的长度影响整体匹配得分
# 结果{ "hits": { "hits": [ { "_id": "003vietnamesepho", "_score": 1.0470967, "_source": { "restaurant_name": "越南河粉", "cuisine": "越南菜", "rating": 3 }, "_explanation": { "value": 1.0470967, "description": "max of:", "details": [ { "value": 0.13353139, "description": "sum of:", "details": [ { "value": 0.13353139, "description": "weight(cuisine:vietnamese in 0) [PerFieldSimilarity], result of:", "details": [...] } ] }, { "value": 1.0470967, "description": "sum of:", "details": [ # 匹配得分为 "vietnamese" { "value": 0.52354836, "description": "weight(restaurant_name:vietnamese in 0) [PerFieldSimilarity], result of:", "details": [ { "value": 0.52354836, "description": "score(freq=1.0), computed as boost * idf * tf from:", "details": [ { "value": 2.2, "description": "boost", "details": [] }, { "value": 0.47000363, "description": "idf, computed as log(1 + (N - n + 0.5) / (n + 0.5)) from:", ... }, { "value": 0.50632906, "description": "tf, computed as freq / (freq + k1 * (1 - b + b * dl / avgdl)) from:", "details": [ { "value": 1, "description": "freq, occurrences of term within document", "details": [] }, { "value": 1.2, "description": "k1, term saturation parameter", "details": [] }, { "value": 0.75, "description": "b, length normalization parameter", "details": [] }, # 注意这里的长度=2在分母中, # 这意味着长度越高,得分越低 { "value": 2, "description": "dl, length of field", "details": [] }, { "value": 2.6666667, "description": "avgdl, average length of field", "details": [] } ] } ] } ] }, # 匹配得分为 "pho" { "value": 0.52354836, "description": "weight(restaurant_name:pho in 0) [PerFieldSimilarity], result of:", "details": [...] } ] } ] } }, { "_id": "002vietnamesephonoodle", "_score": 0.8942772, "_source": { "restaurant_name": "越南河粉面", "cuisine": "越南菜", "rating": 4 }, "_explanation": { "value": 0.8942772, "description": "max of:", "details": [ { "value": 0.13353139, "description": "sum of:", "details": [...] }, { "value": 0.8942772, "description": "sum of:", "details": [ { "value": 0.4471386, "description": "weight(restaurant_name:vietnamese in 1) [PerFieldSimilarity], result of:", "details": [ { "value": 0.4471386, "description": "score(freq=1.0), computed as boost * idf * tf from:", "details": [ ..., { "value": 0.4324324, "description": "tf, computed as freq / (freq + k1 * (1 - b + b * dl / avgdl)) from:", "details": [ ..., # 这里的长度=3(大于上面餐厅的长度=2) { "value": 3, "description": "dl, length of field", "details": [] }, ... ] } ] } ] }, { "value": 0.4471386, "description": "weight(restaurant_name:pho in 1) [PerFieldSimilarity], result of:", "details": [...] } ] } ] } } ] }}