
Salut tout le monde, je m'appelle Andrey et je suis développeur. Il y a longtemps - il semble, vendredi dernier - notre équipe avait un projet où elle avait besoin d'une recherche des ingrédients qui composent les produits. Disons la composition de la saucisse. Au tout début du projet, la recherche ne demandait pas grand-chose: montrer toutes les recettes dans lesquelles l'ingrédient souhaité est contenu en une certaine quantité; répéter pour N ingrédients.
Cependant, à l'avenir, le nombre de produits et d'ingrédients devait être considérablement augmenté, et la recherche devrait non seulement faire face à l'augmentation du volume de données, mais également fournir des options supplémentaires - par exemple, la compilation automatique d'une description de produit basée sur ses ingrédients dominants.
Prérequis- Créez une recherche sur Elacsticsearch en utilisant une base de données d'au moins 50 000 documents.
- Fournit une réponse rapide aux demandes - moins de 300 ms.
- Pour s'assurer que les demandes étaient petites et que le service était disponible même dans les pires conditions de l'Internet mobile.
- Rendez la logique de recherche aussi intuitive que possible dans une perspective UX. C'était essentiellement que l'interface refléterait la logique de recherche - et vice versa.
- Minimisez le nombre d'intercouches entre les éléments du système pour de meilleures performances et moins de dépendances.
- Offrir à tout moment la possibilité de compléter l'algorithme par de nouvelles conditions (par exemple, génération automatique d'une description de produit).
- Rendre le support pour la partie recherche du projet aussi simple et pratique que possible.
Nous avons décidé de ne pas nous précipiter et de commencer simplement.
Tout d'abord, nous avons stocké tous les ingrédients de la composition du produit dans une base de données, après avoir reçu au début 10 000 entrées. Malheureusement, même à cette taille, la recherche dans la base de données a pris trop de temps, même en tenant compte de l'utilisation des jointures et des index. Et dans un avenir proche, le nombre d'enregistrements devait dépasser 50 000. En outre, le client a insisté pour utiliser Elasticsearch (ci-après - ES), car il est tombé sur cet outil et, apparemment, avait des sentiments chaleureux pour lui. Nous ne travaillions pas avec ES auparavant, mais nous connaissions ses avantages et étions d'accord avec ce choix, car, par exemple, il était prévu que nous aurions souvent de nouvelles entrées (selon diverses estimations de 50 à 500 par jour), ce qui serait nécessaire donner immédiatement à l'utilisateur.
Nous avons décidé d'abandonner les intercouches au niveau du pilote et d'utiliser simplement les requêtes REST, car la synchronisation avec la base de données se fait uniquement au moment de la création du document et n'est plus nécessaire. C'était un autre avantage - jusqu'à l'envoi de requêtes de recherche directement à ES à partir d'un navigateur.
Nous avons monté le premier prototype dans lequel nous avons transféré la structure d'une base de données (PostgreSQL) vers des documents 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" } } } } }}
Sur la base de ce mappage, nous obtenons approximativement le document suivant (nous ne pouvons pas montrer le travailleur du projet en raison de 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 } ] }
Tout cela a été fait en utilisant le package PHP Elasticsearch. Les extensions pour Laravel (Elastiquent, Laravel Scout, etc.) ont décidé de ne pas l'utiliser pour une raison - le client exigeait des performances élevées, jusqu'au point mentionné ci-dessus que «300 ms pour une demande, c'est beaucoup». Et tous les packages pour Laravel ont agi comme un surcoût supplémentaire et ont ralenti. Cela aurait pu être fait directement sur Guzzle, mais nous avons décidé de ne pas aller aux extrêmes.
Tout d'abord, la recherche la plus simple de recettes a été effectuée directement sur les tableaux. Oui, tout cela a été retiré des fichiers de configuration, mais la demande s'est quand même avérée trop importante. La recherche a eu lieu sur les documents joints (les mêmes ingrédients), sur les expressions booléennes utilisant `` devrait '' et `` doit '', il y avait aussi une directive pour le passage obligatoire sur les documents joints - en conséquence, la demande a pris cent lignes et son volume était de trois kilo-octets.
N'oubliez pas les exigences de vitesse et de taille de la réponse - à ce moment-là, les réponses dans l'API étaient formatées de manière à augmenter la quantité d'informations utiles: les clés de chaque objet json étaient réduites à une lettre. Par conséquent, les requêtes dans les ES de quelques kilo-octets sont devenues un luxe inacceptable.
Et à ce moment, nous avons réalisé que la construction de requêtes géantes sous la forme de tableaux associatifs en PHP est une sorte de dépendance féroce. De plus, les contrôleurs sont devenus complètement illisibles, voyez par vous-même:
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); }
Digression lyrique: en ce qui concerne les champs imbriqués dans le document, il s'est avéré que nous ne pouvons pas répondre à une requête du formulaire:
"query": { "bool": { "nested": { "bool": { "should": [ ... ] } } } }
pour une raison simple: vous ne pouvez pas effectuer de recherche multiple dans un filtre imbriqué. Par conséquent, je devais faire ceci:
"query": { "bool": { "should": [ {"nested": { "path": "flavors", "score_mode": "max", "query": { "bool": { ... } } }} ] } }
c'est-à-dire tout d'abord, un tableau de conditions devrait être déclaré, et à l'intérieur de chaque condition une recherche a été appelée par le champ imbriqué. Du point de vue d'Elasticsearch, c'est plus correct et logique. En conséquence, nous avons nous-mêmes vu que cela était logique lorsque nous avons ajouté des termes de recherche supplémentaires.
Et ici, nous avons découvert les modèles
Google intégrés à ES. Le choix s'est porté sur Moustache - un moteur de modèle sans logique assez pratique. Il a été possible d'y mettre pratiquement sans modification tout le corps de la demande et toutes les données transmises, à la suite de quoi la demande finale a pris la forme:
{ "template": "template1", "params": params{} }
Le corps du modèle s'est avéré plutôt modeste et lisible - uniquement JSON et les directives de Moustache elle-même. Le modèle est stocké dans Elasticsearch lui-même et est appelé par son nom.
/* search_similar.mustache */ { : { : { : [ {: { : {{ minimumShouldMatch }}, : [ {{#ingredientsList}} // mustache ingredientsList {{#ingredients}} // ingredients {: { : , : , : { : { : [ {: {: {{ id }} }}, {: { : { : {{ lte }}, : {{ gte }} }}} ] } } }} {{^isLast}},{{/isLast}} // {{/ingredients}} {{/ingredientsList}} ] }} ] } } } /* */ { : , : { : 1, : { : [ {: 1, : 10, : 5, : true } ] } } }
En conséquence, à la sortie, nous avons obtenu un modèle dans lequel nous avons simplement passé un tableau des ingrédients nécessaires. Logiquement, la demande ne diffère pas beaucoup, conditionnellement, de ce qui suit:
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
mais il a travaillé plus vite, et c'était une base toute faite pour de nouvelles demandes.
Ici, en plus de la recherche en pourcentage, nous avions besoin de plusieurs autres types d'opérations: une recherche par nom parmi les ingrédients, les groupes et les noms des recettes; recherche par identifiant d'ingrédient en tenant compte de la tolérance de son contenu dans la recette; la même requête, mais avec le calcul des résultats sous quatre conditions (par la suite a été refait pour une autre tâche), ainsi que la requête finale.
La demande exigeait la logique suivante: pour chaque ingrédient, il y a cinq étiquettes qui le relient à n'importe quel groupe. Par convention, le porc et le bœuf sont de la viande, et le poulet et la dinde sont de la volaille. Chacune des balises est située à son propre niveau. Sur la base de ces balises, nous avons pu créer une description conditionnelle de la recette, ce qui nous a permis de générer automatiquement un arbre de recherche et / ou une description. Par exemple, la viande de saucisse et le lait aux épices, le foie et le soja, le poulet halal. Une même recette peut avoir plusieurs ingrédients avec la même étiquette. Cela nous a permis de ne pas remplir la chaîne d'étiquettes avec nos mains - en fonction de la composition de la recette, nous pouvions déjà la décrire clairement. La structure du document joint a également changé:
{ "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 }
Il était également nécessaire de spécifier une recherche par la condition de «pureté» de la recette. Par exemple, nous avions besoin d'une recette où il n'y aurait que du bœuf, du sel et du poivre. Ensuite, nous avons dû éliminer les recettes où seul le bœuf était au premier niveau et uniquement les épices au second (la première étiquette pour les épices était zéro). Ici, je devais tricher: puisque la moustache est un modèle sans logique, il ne pouvait être question de calculs; ici, il était nécessaire d'implémenter une partie du script dans la requête dans le langage de script ES - Indolore. Sa syntaxe est aussi proche que possible de Java, il n'y a donc pas eu de difficultés. En conséquence, nous avions un modèle Moustache générant du JSON, dans lequel une partie des calculs, à savoir le tri et le filtrage, étaient implémentés sur Indolore:
"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}} ]
Ci-après, le corps du script est formaté pour plus de lisibilité, les sauts de ligne ne peuvent pas être utilisés dans les requêtes.
À ce moment-là, nous avons supprimé la tolérance pour le contenu de l'ingrédient et trouvé un goulot d'étranglement - nous ne pouvions considérer la saucisse de boeuf que parce que cet ingrédient s'y trouve. Ensuite, nous avons ajouté - tous sur les mêmes scripts indolores - un filtrage à condition que cet ingrédient prévale dans la composition:
"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); " }} ]
Comme vous pouvez le voir, Elasticsearch manquait de beaucoup de choses pour ce projet, donc elles devaient être assemblées à partir des «moyens disponibles». Mais cela n'est pas surprenant - le projet est suffisamment atypique pour une machine utilisée pour la recherche en texte intégral.
À l'une des étapes intermédiaires du projet, nous avions besoin de la chose suivante: afficher une liste de tous les groupes d'ingrédients disponibles et le nombre de positions dans chacun. Le même problème a été révélé ici que dans la requête dominante: sur 10 000 recettes, environ 10 groupes ont été générés en fonction du contenu. Cependant, environ 40 000 recettes au total se sont révélées appartenir à ces groupes, ce qui ne correspondait pas du tout à la réalité. Ensuite, nous avons commencé à creuser vers des requêtes parallèles.
La première demande, nous avons reçu une liste de tous les groupes qui sont au premier niveau sans le nombre d'entrées. Après cela, une multi-requête a été générée: pour chaque groupe, une requête a été faite pour recevoir le nombre réel de recettes selon le principe du pourcentage en vigueur. Toutes ces demandes ont été rassemblées en une seule et envoyées à Elasticsearch. Le temps de réponse pour la demande générale était égal au temps de traitement de la demande la plus lente. L'agrégation en masse a permis de les paralléliser. Une logique similaire (simplement en regroupant par condition dans une requête) en SQL prenait environ 15 fois plus de temps.
/* */ $params = config('elastic.params'); $params['body'] = config('elastic.top_list'); return (Elastic::getClient()->search($params))['aggregations']['tags']['buckets']; /* */
Après cela, nous devions évaluer:
- combien de recettes sont disponibles pour la composition actuelle;
- quels autres ingrédients pouvons-nous ajouter à la composition (parfois nous avons ajouté l'ingrédient et obtenu un échantillon vide);
- quels ingrédients parmi les sélectionnés, nous pouvons marquer comme les seuls à ce niveau.
Sur la base de la tâche, nous avons combiné la logique de la dernière demande reçue pour la liste de recettes et la logique d'obtention de nombres exacts à partir de la liste de tous les groupes disponibles:
/* */ : { // :{ // :{ : , : { : }, : [ {{#exclude}}{{ id }},{{/exclude}} 0] }, : { : {} } // , } } /* */ 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'];
En conséquence, nous avons reçu une demande qui trouve toutes les recettes nécessaires et leur nombre total (il a été extrait de la réponse ["hits"] ["total"]). Par souci de simplicité, cette demande a été enregistrée à la dernière place de la liste.
De plus, grâce à l'agrégation, nous avons reçu tous les ingrédients d'identification pour le niveau suivant. Pour chacun des ingrédients qui n'étaient pas marqués comme «uniques», nous avons créé une requête où nous l'avons marqué en conséquence, puis nous avons simplement compté le nombre de documents trouvés. S'il était supérieur à zéro, l'ingrédient était alors considéré comme disponible pour l'attribution de la clé "unique". Je pense qu'ici, vous pouvez restaurer le modèle entier sans moi, que nous avons obtenu à la sortie:
{ : {{ from }}, : {{ size }}, : { : { : [ {{#ingredientTags}} {{#tagList}} {: { : [ {: {: {{ tagId }} }} ] }} {{^isLast}},{{/isLast}} {{/tagList}} {{/ingredientTags}} ], : [ {:{ : }} {{#levelsList}}, {{#levels}} {: { : }} {{^isLast}},{{/isLast}} {{/levels}} {{/levelsList}} ] } }, : { :{ :{ : , : { : }, : [ {{#exclude}}{{ id }},{{/exclude}} 0] }, : { : {} } } }, : [ {: {: }} ] }
Bien sûr, nous mettons en cache une partie de ce tas de modèles et de requêtes (comme la page de tous les groupes disponibles avec le nombre de recettes disponibles), ce qui nous ajoute un peu de performance sur la page principale. Cette décision a permis de collecter les données principales en 50 ms.
Résultats du projetNous avons effectué une recherche dans la base de données d'au moins 50 000 documents sur Elasticsearch, ce qui vous permet de rechercher des ingrédients dans les produits et d'obtenir une description du produit par les ingrédients qu'il contient. Bientôt, cette base de données augmentera d'environ six fois (les données sont en cours de préparation), nous sommes donc très satisfaits de nos résultats et d'Elasticsearch en tant qu'outil de recherche.
Sur la question des performances, nous avons répondu aux exigences du projet, et nous nous réjouissons nous-mêmes que le temps de réponse moyen à une demande soit de 250-300 ms.
Trois mois après avoir commencé à travailler avec Elasticsearch, cela ne semble plus si déroutant et inhabituel. Et les avantages du modèle sont évidents: si nous constatons que la demande redevient trop importante, nous transférons simplement la logique supplémentaire au modèle et envoyons à nouveau la demande d'origine au serveur sans presque aucun changement.
"Tout le meilleur et merci pour le poisson!" (c)
PS Au dernier moment, nous avions également besoin d'un tri par caractères russes dans le nom. Et il s'est avéré qu'Elasticsearch ne percevait pas correctement l'alphabet russe. La saucisse conditionnelle «Ultra méga porc 9000 calories» a été transformée à l'intérieur du tri simplement en «9000» et était à la fin de la liste. Il s'est avéré que ce problème est assez facilement résolu en convertissant les caractères russes en notation unicode de la forme u042B.