全文搜索:针对复杂任务的Elasticsearch特定功能

图片

大家好,我叫Andrey,我是一名开发人员。 很久以前-似乎是上周五-我们的团队进行了一个项目,他们需要搜索构成产品的成分。 假设香肠的成分。 在项目的开始阶段,搜索就不需要太多:显示所有包含一定量所需成分的配方; 重复N个成分。

但是,将来计划大幅增加产品和成分的数量,并且搜索不仅应应对不断增加的数据量,而且还应提供其他选项-例如,根据产品的主要成分自动编制产品说明。

要求条件

  • 使用至少50,000个文档的数据库在Elacsticsearch上创建搜索。
  • 提供对请求的高速响应-少于300毫秒。
  • 为了确保请求量很小,并且即使在最差的移动Internet情况下也可以使用该服务。
  • 从UX的角度来看,使搜索逻辑尽可能直观。 从本质上讲,该界面将反映搜索逻辑,反之亦然。
  • 最小化系统元素之间的中间层数,以实现更高的性能和更少的依赖性。
  • 随时提供机会以新条件补充算法(例如,自动生成产品说明)。
  • 尽可能简单,方便地为项目的搜索部分提供进一步的支持。

我们决定不着急并从简单开始。

首先,我们将产品组合的所有成分存储在一个数据库中,该数据库最初收到了10,000个条目。 不幸的是,即使以这种大小,即使考虑了join-s和索引的使用,搜索数据库也花费了太多时间。 并且在不久的将来,记录的数量应该会超过50,000,此外,该客户坚持使用Elasticsearch(以下简称ES),因为他遇到了这个工具,显然对他有热情。 我们以前没有使用过ES,但是我们知道它的优点并同意这种选择,因为例如,计划将我们经常有新条目(根据每天50到500的各种估计),这是必要的立即分发给用户。

我们决定放弃驱动程序级别的中间层,只使用REST请求,因为与数据库的同步仅在创建文档时进行,并且不再需要。 这是另一个优势-可以将搜索查询直接从浏览器发送到ES。

我们汇总了第一个原型,在该原型中,我们将结构从数据库(PostgreSQL)传输到ES文档:

{"mappings" : { "recipe" : { "_source" : { "enabled" : true }, "properties" : { "recipe_id" : {"type" : "integer"}, "recipe_name" : {"type" : "text"}, "ingredients" : { "type" : "nested", "properties": { "ingredient_id": "integer", "ingredient_name": "string", "manufacturer_id": "integer", "manufacturer_name": "string", "percent": "float" } } } } }} 

基于此映射,我们大致获得以下文档(由于NDA,我们无法显示项目中的工作人员):

 { "recipe_id": 1, "recipe_name": "AAA & BBB", "ingredients": [ { "ingredient_id": 1, "ingredient_name": "AAA", "manufacturer_id": 3, "manufacturer_name": "Manufacturer 3", "percent": 1 }, { "ingredient_id": 2, "ingredient_name": "BBB", "manufacturer_id": 4, "manufacturer_name": "Manufacturer 4", "percent": 3 } ] } 

所有这些都是使用Elasticsearch PHP软件包完成的。 Laravel的扩展(Elastiquent,Laravel Scout等)决定不使用它是出于一个原因-客户需要高性能,直到上面提到的“请求300毫秒之多”。 而且Laravel的所有软件包都充当了额外的开销,并且速度变慢了。 本可以直接在Guzzle上完成,但我们决定不走极端。

首先,最简单的配方搜索直接在阵列上完成。 是的,所有这些都被取出到配置文件中,但是相同的请求却太大了。 搜索是在附件文件(相同成分)上进行的,使用“应该”和“必须”在布尔表达式上进行搜索,还存在对附件文件进行强制传递的指令-结果,请求从一百行开始,其请求量从三千字节开始。

不要忘记对响应速度和大小的要求-到那时,API中的答案已经格式化,从而增加了有用的信息量:每个json对象中的键都减少为一个字母。 因此,在几千字节的ES中进行查询成为一种无法接受的奢侈。

那时,我们意识到以PHP关联数组的形式构建大型查询是一种沉迷的习惯。 此外,控制器变得完全不可读,请亲自查看:

 public function searchSimilar() { /*...*/ $conditions[] = [ "nested" => [ "path" => "ingredients", "score_mode" => "max", "query" => [ "bool" => [ "must" => [ ["term" => ["ingredients.ingredient_id" => $ingredient_id]], ["range" => ["ingredients.percent"=>[ "lte"=>$percent + 5, "gte"=>$percent - 5 ]]] ] ] ] ] ]; $parameters['body']['query']['bool']['should'][0]['bool']['should'] = $conditions; /*...*/ $equal_conditions[] = [ "nested" => [ "path" => "flavors", "query" => [ "bool" => [ "must" => [ ["term" => ["ingredients.percent" => $percent]] ] ] ] ] ]; $parameters['body']['query']['bool']['should'][1]['bool']['must'] = $equal_conditions; /*...*/ return $this->client->search($parameters); } 

抒情离题:当涉及到文档中的嵌套字段时,事实证明我们无法满足以下形式的查询:

 "query": { "bool": { "nested": { "bool": { "should": [ ... ] } } } } 

出于一个简单的原因-您无法在嵌套过滤器中执行多重搜索。 因此,我必须这样做:

 "query": { "bool": { "should": [ {"nested": { "path": "flavors", "score_mode": "max", "query": { "bool": { ... } } }} ] } } 

即 首先,声明了应满足条件的数组,并在每个条件内通过嵌套字段调用了搜索。 从Elasticsearch的角度来看,这是更正确和合乎逻辑的。 结果,当我们添加其他搜索词时,我们自己认为这是合乎逻辑的。

在这里,我们发现了ES内置的Google模板。 选择落在Mustache上-一个相当方便的无逻辑模板引擎。 实际上可以将整个请求主体和所有传输的数据放入其中,而无需进行更改,因此最终请求采用以下形式:

 { "template": "template1", "params": params{} } 

模板的主体非常适度且易于阅读-仅JSON和Mustache本身的指令。 模板存储在Elasticsearch本身中,并按名称调用。

 /* search_similar.mustache */ { "query": { "bool": { "should": [ {"bool": { "minimum_should_match": {{ minimumShouldMatch }}, "should": [ {{#ingredientsList}} // mustache         ingredientsList {{#ingredients}} //         ingredients {"nested": { "path": "ingredients", "score_mode": "max", "query": { "bool": { "must": [ {"term": {"ingredients.flavor_id": {{ id }} }}, {"range": {"ingredients.percent" : { "lte": {{ lte }}, "gte": {{ gte }} }}} ] } } }} {{^isLast}},{{/isLast}} //    {{/ingredients}} {{/ingredientsList}} ] }} ] } } } /*  */ { "template": "search_similar", "params": { "minimumShouldMatch": 1, "ingredientsList": { "ingredients": [ {"id": 1, "lte": 10, "gte": 5, "isLast": true } ] } } } 

结果,在输出中,我们得到了一个模板,我们只需将一系列必要的成分传递给模板。 从逻辑上讲,该请求与以下条件并没有太大区别:

 SELECT * FROM ingredients LEFT JOIN recipes ON recipes.id = ingredient.recipe_id WHERE ingredients.id in (1,2,3) AND ingredients.id not in (4,5,6) AND ingredients.percent BETWEEN 10.0 AND 20.0 

但是他的工作速度更快,这是进一步提出要求的现成基础。

在这里,除了百分比搜索外,我们还需要其他几种类型的操作:按名称搜索配料,组和配方名称; 考虑到成分在配方中的容忍度,通过成分ID搜索; 相同的查询,但是在四个条件下计算了结果(随后重做了另一个任务),以及最终查询。

该请求需要以下逻辑:对于每种成分,有五个将其与任何组相关联的标签。 按照惯例,猪肉和牛肉是肉,鸡肉和火鸡是家禽。 每个标签都位于其自己的级别。 基于这些标签,我们可以为配方创建条件描述,这使我们能够自动生成搜索树和/或描述。 例如,香肠肉和牛奶加香料,肝脏和大豆,清真鸡肉。 单个配方可以包含具有相同标签的多种成分。 这样一来,我们就无需再用双手塞满标签链了-根据食谱的组成,我们已经可以清楚地描述它了。 随附文档的结构也已更改:

 { "ingredient_id": 1, "ingredient_name": "AAA", "manufacturer_id": 3, "manufacturer_name": "Manufacturer 3", "percent": 1, "level_1": 2, "level_2": 4, "level_3": 6, "level_4": 7, "level_5": 12 } 

还需要根据配方的“纯度”条件来指定搜索。 例如,我们需要一个食谱,其中只有牛肉,盐和胡椒粉。 然后,我们必须淘汰仅在第一层上只有牛肉,在第二层上只有香料的食谱(香料的第一个标签为零)。 在这里,我不得不作弊:由于胡须是没有逻辑的模板,因此不能谈论任何计算。 此处要求以ES脚本语言-Painless在请求中实现脚本的一部分。 它的语法尽可能接近Java,因此没有困难。 结果,我们有了一个用于生成JSON的Mustache模板,其中部分计算(即排序和过滤)是在Painless上实现的:

 "filter": [ {{#levelsList}} {{#levels}} {"script": { "script": " int total=0; for (ingredient in params._source.ingredients){ if ([0,{{tag}}].contains(ingredient.level_{{id}})) total+=1; } return (total==params._source.ingredients.length); " }} {{^isLast}},{{/isLast}} {{/levels}} {{/levelsList}} ] 

在下文中,脚本主体经过格式化以提高可读性,不能在请求中使用换行符。

到那时,我们消除了对成分含量的耐受性,发现了一个瓶颈-我们只能考虑牛肉香肠,因为在那里找到了这种成分。 然后,我们添加了所有内容(在相同的Painless脚本上进行了过滤),条件是该成分应在合成中占主导地位:

 "filter": [ {"script":{ "script": " double nest=0,rest=0; for (ingredient in params._source.ingredients){ if([{{#tags}}{{tagId}}{{^isLast}},{{/isLast}}{{/tags}}].contains(flavor.level_{{tags.0.levelId}})){ nest+= ingredient.percent; }else{ if (ingredient.percent>rest){rest = ingredient.percent} } } return(nest>=rest); " }} ] 

如您所见,Elasticsearch在此项目中缺少很多东西,因此必须通过“可用方式”进行组合。 但这不足为奇-该项目对于用于全文搜索的机器来说已经足够非典型了。

在项目的中间阶段之一,我们需要做以下事情:显示所有可用成分组的列表以及每个组的位置数量。 在这里,与普遍查询中出现的问题相同:在10,000个食谱中,根据内容生成了大约10组。 但是,这些组中总共有大约40,000种配方,根本与现实不符。 然后,我们开始研究并行查询。

在第一个请求中,我们收到了第一级所有组的列表,但没有条目数量。 此后,产生了一个多重请求:对于每个组,根据现行百分比的原则,要求接收实际食谱的数量。 所有这些请求都收集到一个中,然后发送到Elasticsearch。 一般请求的响应时间等于最慢请求的处理时间。 批量聚合可以并行化它们。 SQL中类似的逻辑(仅通过按查询中的条件分组)花费了大约15倍的时间。

 /*   */ $params = config('elastic.params'); $params['body'] = config('elastic.top_list'); return (Elastic::getClient()->search($params))['aggregations']['tags']['buckets']; /*   */ 

之后,我们需要评估:

  1. 当前配方有多少种配方可用;
  2. 我们还可以向组合物中添加哪些其他成分(有时我们添加了成分并得到了一个空样品);
  3. 在此级别中,我们可以将所选成分中的哪些成分标记为唯一。

根据任务,我们结合了收到的针对配方列表的最后一个请求的逻辑和从所有可用组的列表中获取确切编号的逻辑:

 /*  */ "aggs" : { //      "tags" :{ //    "terms" :{ "field" : "ingredients.level_{{ level }}", "order" : {"_term" : "asc"}, "exclude" : [ {{#exclude}}{{ id }},{{/exclude}} 0] }, "aggs": { "reverse_nested": {} } //    ,    } } /*   */ foreach ($not_only as $element) { $parameters['body'][] = config('elastic.params'); $parameters['body'][] = self::getParamsBody( $body, collect($only->all())->push($element), $max_level, 0, 0 ); } /*   */ $parameters['body'][] = config('elastic.params'); $parameters['body'][] = self::getParamsBody( $body, $only, $max_level, $from, $size') ); /*     */ $parameters['max_concurrent_searches'] = 1 + $not_only->count(); return (Elastic::getClient()->msearchTemplate($parameters))['responses']; 

结果,我们收到了一个请求,该请求查找了所有必要的食谱及其总数(取自响应[“ hits”] [“ total”])。 为简单起见,此请求记录在列表的最后位置。

此外,通过汇总,我们收到了下一个级别的所有ID成分。 对于未标记为“唯一”的每种成分,我们创建了一个查询,在其中进行了相应的标记,然后简单地计算了找到的文档数。 如果它大于零,则认为该成分可用于分配键“单”。 我认为您可以在没有我的情况下还原整个模板,我们在输出中得到了:

 { "from": {{ from }}, "size": {{ size }}, "query": { "bool": { "must": [ {{#ingredientTags}} {{#tagList}} {"bool": { "should": [ {"term": {"level_{{ levelId }}": {{ tagId }} }} ] }} {{^isLast}},{{/isLast}} {{/tagList}} {{/ingredientTags}} ], "filter": [ {"script":{ "script": " double nest=0,rest=0; for(ingredient in params._source. ingredients){ if([{{#tags}}{{tagId}}{{^isLast}},{{/isLast}}{{/tags}}].contains(ingredient.level_{{tags.0.levelId}})){ nest+= ingredient.percent; }else{ if (ingredient.percent>rest){ rest= ingredient.percent } } } return(nest>=rest); " }} {{#levelsList}}, {{#levels}} {"script": { "script": " int total=0; for(ingredient in params._source.ingredients){ if ([0,{{tag}}].contains(ingredient.level_{{id}})) total+=1; } return (total==params._source.ingredients.length); " }} {{^isLast}},{{/isLast}} {{/levels}} {{/levelsList}} ] } }, "aggs" : { "tags" :{ "terms" :{ "field" : "ingredients.level_{{ level }}", "order" : {"_term" : "asc"}, "exclude" : [ {{#exclude}}{{ id }},{{/exclude}} 0] }, "aggs": { "reverse_nested": {} } } }, "sort": [ {"_score": {"order": "desc"}} ] } 

当然,我们缓存了一部分模板和查询(例如,所有可用组的页面以及可用配方的数量),这在主页上增加了一些性能。 这一决定使得可以在50毫秒内收集主要数据。

项目成果

我们在Elasticsearch的数据库中进行了至少50,000个文档的搜索,使您可以搜索产品中的成分,并通过其中包含的成分获得产品的描述。 很快该数据库将增长大约六倍(正在准备数据),因此我们对我们的结果以及将Elasticsearch用作搜索工具感到非常满意。

在性能问题上,我们满足了项目的要求,我们自己很高兴对请求的平均响应时间为250-300毫秒。

在开始使用Elasticsearch的三个月后,它似乎不再那么令人困惑和异常。 模板的优势非常明显:如果我们发现请求再次变得太大,我们只需将附加逻辑转移到模板,然后将原始请求再次发送到服务器,几乎没有任何更改。

“万事如意,谢谢你的鱼!” (c)

PS最后,我们还需要按名称中的俄语字符进行排序。 然后事实证明,Elasticsearch无法充分理解俄语字母。 有条件的香肠“超大猪肉9000卡路里”在分类内部变成了“ 9000”,排在最后。 事实证明,通过将俄语字符转换为u042B形式的unicode表示法,可以很容易地解决此问题。

Source: https://habr.com/ru/post/zh-CN413075/


All Articles