Pesquisa de texto completo: recursos específicos do Elasticsearch para tarefas complexas

imagem

Olá pessoal, meu nome é Andrey e sou desenvolvedor. Há muito tempo - ao que parece, na última sexta-feira -, nossa equipe teve um projeto em que precisavam pesquisar os ingredientes que compõem os produtos. Digamos a composição da linguiça. No início do projeto, pouco foi exigido na pesquisa: mostrar todas as receitas nas quais o ingrediente desejado está contido em uma certa quantidade; repita para N ingredientes.

No entanto, no futuro, o número de produtos e ingredientes foi planejado para aumentar significativamente, e a pesquisa não deve apenas lidar com o crescente volume de dados, mas também fornecer opções adicionais - por exemplo, compilação automática de uma descrição de produto com base em seus ingredientes predominantes.

Exigências

  • Crie uma pesquisa no Elacsticsearch usando um banco de dados de pelo menos 50.000 documentos.
  • Forneça resposta de alta velocidade a solicitações - menos de 300 ms.
  • Garantir que os pedidos fossem pequenos e que o serviço estivesse disponível mesmo nas condições da pior Internet móvel.
  • Torne a lógica de pesquisa o mais intuitiva possível da perspectiva do UX. Era essencialmente que a interface refletisse a lógica de pesquisa - e vice-versa.
  • Minimize o número de intercaladas entre os elementos do sistema para obter melhor desempenho e menos dependências.
  • Fornecer uma oportunidade a qualquer momento para complementar o algoritmo com novas condições (por exemplo, geração automática de uma descrição do produto).
  • Suporte adicional para a parte de pesquisa do projeto o mais simples e conveniente possível.

Decidimos não nos apressar e começar do zero.

Primeiro, armazenamos todos os ingredientes da composição do produto em um banco de dados, tendo recebido as primeiras 10.000 entradas. Infelizmente, mesmo nesse tamanho, a pesquisa no banco de dados levou muito tempo, mesmo levando em consideração o uso de junções e índices. E em um futuro próximo, o número de registros deveria ter excedido 50.000. Além disso, o cliente insistiu em usar o Elasticsearch (doravante - ES), porque encontrou essa ferramenta e, aparentemente, tinha sentimentos calorosos por ele. Nós não trabalhamos com a ES antes, mas sabíamos das vantagens e concordamos com essa escolha, pois, por exemplo, foi planejado que teríamos frequentemente novas entradas (de acordo com várias estimativas de 50 a 500 por dia), o que seria necessário imediatamente entregue ao usuário.

Decidimos abandonar os interlayers no nível do driver e simplesmente usar solicitações REST, pois a sincronização com o banco de dados é feita apenas no momento da criação do documento e não é mais necessária. Essa foi outra vantagem: enviar consultas de pesquisa diretamente para o ES a partir de um navegador.

Reunimos o primeiro protótipo no qual transferimos a estrutura de um banco de dados (PostgreSQL) para documentos 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" } } } } }} 

Com base nesse mapeamento, obtemos aproximadamente o seguinte documento (não podemos mostrar o trabalhador do projeto devido ao 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 } ] } 

Tudo isso foi feito usando o pacote PHP do Elasticsearch. As extensões do Laravel (Elastiquent, Laravel Scout, etc.) decidiram não usá-lo por um motivo - o cliente exigia alto desempenho, até o ponto mencionado acima, que “300 ms para uma solicitação são muitos”. E todos os pacotes para o Laravel agiram como uma sobrecarga extra e diminuíram a velocidade. Poderia ter sido feito diretamente no Guzzle, mas decidimos não ir ao extremo.

Primeiro, a pesquisa mais simples de receitas foi feita diretamente nas matrizes. Sim, tudo isso foi retirado dos arquivos de configuração, mas a solicitação da mesma forma ficou muito grande. A pesquisa foi realizada nos documentos anexados (os mesmos ingredientes), nas expressões booleanas usando "deveria" e "deve", havia também uma diretiva para passagem obrigatória nos documentos anexados - como resultado, a solicitação demorou cem linhas e seu volume era de três kilobytes.

Não se esqueça dos requisitos de velocidade e tamanho da resposta - nesse momento, as respostas na API foram formatadas de forma a aumentar a quantidade de informações úteis: as chaves em cada objeto json foram reduzidas a uma letra. Portanto, consultas em ESs de alguns kilobytes se tornaram um luxo inaceitável.

E, naquele momento, percebemos que a criação de consultas gigantes na forma de matrizes associativas no PHP é algum tipo de dependência feroz. Além disso, os controladores tornaram-se completamente ilegíveis, veja você mesmo:

 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); } 

Digressão lírica: quando se trata de campos aninhados no documento, descobriu-se que não podemos atender a uma consulta no formulário:

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

por um motivo simples - você não pode realizar várias pesquisas dentro de um filtro aninhado. Portanto, eu tive que fazer isso:

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

isto é a princípio, uma matriz de condições deveria ser declarada e, dentro de cada condição, uma pesquisa foi chamada pelo campo aninhado. Do ponto de vista da Elasticsearch, isso é mais correto e lógico. Como resultado, vimos que isso era lógico quando adicionamos termos de pesquisa adicionais.

E aqui descobrimos modelos do Google incorporados ao ES. A escolha recaiu sobre o Bigode - um mecanismo de modelo sem lógica bastante conveniente. Foi possível colocar todo o corpo da solicitação e todos os dados transmitidos nele praticamente sem alterações, como resultado da solicitação final:

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

O corpo do modelo acabou sendo bastante modesto e legível - apenas JSON e as diretrizes do próprio Bigode. O modelo é armazenado no próprio Elasticsearch e é chamado pelo nome.

 /* 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 } ] } } } 

Como resultado, na saída, obtivemos um modelo no qual simplesmente passamos uma matriz dos ingredientes necessários. Logicamente, a solicitação não difere muito, condicionalmente, do seguinte:

 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 

mas ele trabalhou mais rápido, e era uma base pronta para solicitações adicionais.

Aqui, além da pesquisa percentual, precisávamos de vários outros tipos de operações: uma pesquisa por nome entre os ingredientes, grupos e nomes de receitas; pesquise por ID do ingrediente, levando em consideração a tolerância do seu conteúdo na receita; a mesma consulta, mas com o cálculo dos resultados em quatro condições (posteriormente refeitas para outra tarefa), bem como a consulta final.

A solicitação exigia a seguinte lógica: para cada ingrediente, há cinco tags que o relacionam a qualquer grupo. Por convenção, porco e carne bovina são carne e frango e peru são aves. Cada uma das tags está localizada em seu próprio nível. Com base nessas tags, podemos criar uma descrição condicional para a receita, o que nos permitiu gerar uma árvore de pesquisa e / ou descrição automaticamente. Por exemplo, carne de lingüiça e leite com especiarias, fígado e soja, frango halal. Uma única receita pode ter vários ingredientes com a mesma tag. Isso nos permitiu não encher a cadeia de tags com as mãos - com base na composição da receita, já poderíamos descrevê-la claramente. A estrutura do documento anexo também foi alterada:

 { "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 } 

Havia também a necessidade de especificar uma pesquisa pela condição de "pureza" da receita. Por exemplo, precisávamos de uma receita onde não houvesse nada além de carne, sal e pimenta. Depois tivemos que eliminar as receitas em que apenas a carne estava no primeiro nível e apenas as especiarias no segundo (a primeira etiqueta para especiarias era zero). Aqui eu tive que trapacear: como o bigode é um modelo sem lógica, não se pode falar em nenhum cálculo; aqui foi necessário implementar parte do script na solicitação na linguagem de script ES - Indolor. Sua sintaxe é o mais próxima possível do Java, portanto não houve dificuldades. Como resultado, tivemos um modelo de bigode gerando JSON, no qual parte dos cálculos, como classificação e filtragem, foram implementados no 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}} ] 

A seguir, o corpo do script é formatado para facilitar a leitura, as quebras de linha não podem ser usadas em solicitações.

Naquela época, removemos a tolerância para o conteúdo do ingrediente e encontramos um gargalo - poderíamos considerar salsicha de carne apenas porque esse ingrediente é encontrado lá. Em seguida, adicionamos - todos nos mesmos scripts Painless - filtrando a condição de que esse ingrediente prevaleça na composição:

 "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); " }} ] 

Como você pode ver, o Elasticsearch não possuía muitas coisas para este projeto, então eles tiveram que ser montados a partir de "meios disponíveis". Mas isso não é surpreendente - o projeto é atípico o suficiente para uma máquina usada para pesquisa de texto completo.

Em um dos estágios intermediários do projeto, precisávamos do seguinte: exiba uma lista de todos os grupos de ingredientes disponíveis e o número de posições em cada um. O mesmo problema foi revelado aqui como na consulta predominante: de 10.000 receitas, cerca de 10 grupos foram gerados com base no conteúdo. No entanto, um total de cerca de 40.000 receitas acabou por estar nesses grupos, o que não correspondia à realidade. Em seguida, começamos a procurar consultas paralelas.

Na primeira solicitação, recebemos uma lista de todos os grupos que estão no primeiro nível sem o número de entradas. Depois disso, uma solicitação múltipla foi gerada: para cada grupo, uma solicitação foi feita para receber o número real de receitas de acordo com o princípio da porcentagem prevalecente. Todos esses pedidos foram coletados em um e enviados ao Elasticsearch. O tempo de resposta para a solicitação geral foi igual ao tempo de processamento da solicitação mais lenta. A agregação em massa tornou possível paralelizá-los. Lógica semelhante (apenas agrupando por condição em uma consulta) no SQL levou cerca de 15 vezes mais tempo.

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

Depois disso, precisamos avaliar:

  1. quantas receitas estão disponíveis para a composição atual;
  2. que outros ingredientes podemos adicionar à composição (às vezes adicionamos o ingrediente e obtivemos uma amostra vazia);
  3. quais ingredientes dentre os selecionados podemos marcar como os únicos nesse nível.

Com base na tarefa, combinamos a lógica da última solicitação recebida para a lista de receitas e a lógica de obter números exatos da lista de todos os grupos disponíveis:

 /*  */ "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']; 

Como resultado, recebemos uma solicitação que encontra todas as receitas necessárias e seu número total (foi extraído da resposta ["hits"] ["total"]). Por simplicidade, essa solicitação foi registrada em último lugar na lista.

Além disso, por meio da agregação, recebemos todos os ingredientes de identificação para o próximo nível. Para cada um dos ingredientes que não foram marcados como "exclusivos", criamos uma consulta na qual a marcamos de acordo e, em seguida, contamos o número de documentos encontrados. Se fosse maior que zero, o ingrediente era considerado disponível para atribuir a chave "único". Eu acho que aqui você pode restaurar o modelo inteiro sem mim, o que obtivemos na saída:

 { "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"}} ] } 

Obviamente, armazenamos em cache parte desse monte de modelos e consultas (como a página de todos os grupos disponíveis com o número de receitas disponíveis), o que nos agrega um pouco de desempenho na página principal. Essa decisão possibilitou a coleta dos principais dados em 50 ms.

Resultados do projeto

Realizamos uma pesquisa no banco de dados de pelo menos 50.000 documentos no Elasticsearch, que permite pesquisar ingredientes em produtos e obter uma descrição do produto pelos ingredientes contidos nele. Em breve, esse banco de dados crescerá cerca de seis vezes (os dados estão sendo preparados), por isso estamos muito felizes com nossos resultados e com o Elasticsearch como uma ferramenta de pesquisa.

Na questão do desempenho, atendemos aos requisitos do projeto, e nós mesmos estamos satisfeitos por o tempo médio de resposta a uma solicitação ser de 250 a 300 ms.

Três meses após o início do trabalho com o Elasticsearch, ele não parece mais tão confuso e incomum. E as vantagens do modelo são óbvias: se percebermos que a solicitação se tornará muito grande novamente, simplesmente transferiremos a lógica adicional para o modelo e enviaremos novamente a solicitação original para o servidor quase sem alterações.

“Tudo de bom e obrigado pelo peixe!” c)

PS No último momento, também precisamos classificar por caracteres russos no nome. E então o Elasticsearch não percebe o alfabeto russo adequadamente. A lingüiça condicional “Ultra mega pork 9000 calorias” transformou a seleção simplesmente em “9000” e estava no final da lista. Como se viu, esse problema é facilmente resolvido pela conversão de caracteres russos em notação unicode do formato u042B.

Source: https://habr.com/ru/post/pt413075/


All Articles