
Hola a todos, mi nombre es Andrey y soy desarrollador. Hace mucho tiempo, al parecer, el viernes pasado, nuestro equipo tenía un proyecto en el que necesitaban una búsqueda de los ingredientes que componen los productos. Digamos la composición de la salchicha. Al comienzo del proyecto, no se requería mucho de la búsqueda: mostrar todas las recetas en las que el ingrediente deseado está contenido en cierta cantidad; repita para N ingredientes.
Sin embargo, en el futuro, se planeó aumentar significativamente la cantidad de productos e ingredientes, y la búsqueda no solo debería hacer frente al creciente volumen de datos, sino también proporcionar opciones adicionales, por ejemplo, la compilación automática de una descripción del producto basada en sus ingredientes predominantes.
Requisitos- Cree una búsqueda en Elacsticsearch utilizando una base de datos de al menos 50,000 documentos.
- Proporcione una respuesta de alta velocidad a las solicitudes: menos de 300 ms.
- Para garantizar que las solicitudes fueran pequeñas y que el servicio estuviera disponible incluso en las condiciones de la peor Internet móvil.
- Haga que la lógica de búsqueda sea lo más intuitiva posible desde una perspectiva UX. Básicamente, la interfaz reflejaría la lógica de búsqueda, y viceversa.
- Minimice el número de capas intermedias entre los elementos del sistema para un mayor rendimiento y menos dependencias.
- Brindar una oportunidad en cualquier momento para complementar el algoritmo con nuevas condiciones (por ejemplo, generación automática de una descripción del producto).
- Haga que el soporte para la parte de búsqueda del proyecto sea lo más simple y conveniente posible.
Decidimos no apurarnos y comenzar de manera simple.
En primer lugar, almacenamos todos los ingredientes de la composición del producto en una base de datos, habiendo recibido al principio 10.000 entradas. Desafortunadamente, incluso a este tamaño, la búsqueda en la base de datos tomó demasiado tiempo, incluso teniendo en cuenta el uso de join-sy índices. Y en un futuro cercano, se suponía que el número de registros superaría los 50,000. Además, el cliente insistió en usar Elasticsearch (en adelante, ES), porque se encontró con esta herramienta y, aparentemente, tenía sentimientos cálidos por él. Antes no trabajábamos con ES, pero conocíamos sus ventajas y estuvimos de acuerdo con esta opción, ya que, por ejemplo, estaba planeado que a menudo tuviéramos nuevas entradas (de acuerdo con varias estimaciones de 50 a 500 por día), que serían necesarias entregar inmediatamente al usuario.
Decidimos abandonar las capas intermedias a nivel del controlador y simplemente usar solicitudes REST, ya que la sincronización con la base de datos se realiza solo al momento de crear el documento y ya no es necesaria. Esta fue otra ventaja: enviar consultas de búsqueda directamente a ES desde un navegador.
Armamos el primer prototipo en el que transferimos la estructura de una base de datos (PostgreSQL) a 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" } } } } }}
En base a esta asignación, obtenemos aproximadamente el siguiente documento (no podemos mostrar al trabajador del proyecto debido a 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 } ] }
Todo esto se hizo usando el paquete Elasticsearch PHP. Las extensiones para Laravel (Elastiquent, Laravel Scout, etc.) decidieron no usarlo por una razón: el cliente requería un alto rendimiento, hasta el punto mencionado anteriormente que "300 ms para una solicitud es mucho". Y todos los paquetes para Laravel actuaron como una sobrecarga adicional y disminuyeron la velocidad. Podría haberse hecho directamente en Guzzle, pero decidimos no ir a los extremos.
Primero, la búsqueda más simple de recetas se realizó directamente en las matrices. Sí, todo esto se llevó a los archivos de configuración, pero la solicitud resultó ser demasiado grande. La búsqueda se llevó a cabo en los documentos adjuntos (los mismos ingredientes), en expresiones booleanas que usan "debería" y "debe", también hubo una directiva para el pasaje obligatorio en los documentos adjuntos; como resultado, la solicitud tomó de cien líneas y su volumen fue de tres kilobytes.
No se olvide de los requisitos de velocidad y tamaño de la respuesta: en ese momento, las respuestas en la API se formatearon de tal manera que aumentaran la cantidad de información útil: las claves en cada objeto json se redujeron a una letra. Por lo tanto, las consultas en ES de unos pocos kilobytes se convirtieron en un lujo inaceptable.
Y en ese momento, nos dimos cuenta de que construir consultas gigantes en forma de matrices asociativas en PHP es una especie de adicción feroz. Además, los controladores se volvieron completamente ilegibles, compruébelo usted mismo:
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); }
Digresión de letras: cuando se trata de campos anidados en el documento, resultó que no podemos completar una consulta del formulario:
"query": { "bool": { "nested": { "bool": { "should": [ ... ] } } } }
por una simple razón: no puede realizar búsquedas múltiples dentro de un filtro anidado. Por lo tanto, tuve que hacer esto:
"query": { "bool": { "should": [ {"nested": { "path": "flavors", "score_mode": "max", "query": { "bool": { ... } } }} ] } }
es decir primero, se declaró una serie de condiciones de deber, y dentro de cada condición se llamó una búsqueda por el campo anidado. Desde el punto de vista de Elasticsearch, esto es más correcto y lógico. Como resultado, nosotros mismos vimos que esto era lógico cuando agregamos términos de búsqueda adicionales.
Y aquí descubrimos las plantillas de
Google integradas en ES. La elección recayó en Moustache, un motor de plantillas sin lógica bastante conveniente. Fue posible colocar todo el cuerpo de la solicitud y todos los datos transmitidos prácticamente sin cambios, como resultado de lo cual la solicitud final tomó la forma:
{ "template": "template1", "params": params{} }
El cuerpo de la plantilla resultó ser bastante modesto y legible, solo JSON y las directivas del propio Bigote. La plantilla se almacena en Elasticsearch y se llama por su nombre.
/* search_similar.mustache */ { : { : { : [ {: { : {{ minimumShouldMatch }}, : [ {{#ingredientsList}} // mustache ingredientsList {{#ingredients}} // ingredients {: { : , : , : { : { : [ {: {: {{ id }} }}, {: { : { : {{ lte }}, : {{ gte }} }}} ] } } }} {{^isLast}},{{/isLast}} // {{/ingredients}} {{/ingredientsList}} ] }} ] } } } /* */ { : , : { : 1, : { : [ {: 1, : 10, : 5, : true } ] } } }
Como resultado, en la salida obtuvimos una plantilla en la que simplemente pasamos una serie de los ingredientes necesarios. Lógicamente, la solicitud no difería mucho de, condicionalmente, lo siguiente:
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
pero trabajó más rápido y fue una base preparada para nuevas solicitudes.
Aquí, además de la búsqueda porcentual, necesitábamos varios tipos más de operaciones: una búsqueda por nombre entre los ingredientes, grupos y nombres de recetas; buscar por ID de ingrediente teniendo en cuenta la tolerancia de su contenido en la receta; la misma consulta, pero con el cálculo de los resultados en cuatro condiciones (posteriormente se rehizo para otra tarea), así como la consulta final.
La solicitud requería la siguiente lógica: para cada ingrediente hay cinco etiquetas que lo relacionan con cualquier grupo. Por convención, el cerdo y la carne son carne, y el pollo y el pavo son aves de corral. Cada una de las etiquetas se encuentra en su propio nivel. En base a estas etiquetas, podríamos crear una descripción condicional para la receta, lo que nos permitió generar un árbol de búsqueda y / o descripción automáticamente. Por ejemplo, salchichas de carne y leche con especias, hígado y soja, pollo halal. Una sola receta puede tener múltiples ingredientes con la misma etiqueta. Esto nos permitió no llenar la cadena de etiquetas con nuestras manos; según la composición de la receta, ya podríamos describirla claramente. La estructura del documento adjunto también ha cambiado:
{ "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 }
También era necesario especificar una búsqueda por la condición de "pureza" de la receta. Por ejemplo, necesitábamos una receta donde no hubiera más que carne de res, sal y pimienta. Luego tuvimos que eliminar las recetas donde solo la carne estaba en el primer nivel y solo las especias en el segundo (la primera etiqueta para las especias era cero). Aquí tuve que hacer trampa: como el bigote es una plantilla sin lógica, no se puede hablar de ningún cálculo; aquí se requería implementar parte del script en la solicitud en el lenguaje de script ES: indoloro. Su sintaxis es lo más cercana posible a Java, por lo que no hubo dificultades. Como resultado, tuvimos una plantilla de bigote que genera JSON, en la que parte de los cálculos, a saber, la clasificación y el filtrado, se implementaron en indoloro:
"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}} ]
En lo sucesivo, el cuerpo del script está formateado para facilitar la lectura, los saltos de línea no se pueden usar en las solicitudes.
En ese momento, eliminamos la tolerancia al contenido del ingrediente y encontramos un cuello de botella: podríamos considerar la salchicha de carne de res solo porque este ingrediente se encuentra allí. Luego agregamos, todo en los mismos guiones indoloros, filtrado con la condición de que este ingrediente prevalezca en la composición:
"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 puede ver, Elasticsearch careció de muchas cosas para este proyecto, por lo que tuvieron que ensamblarse a partir de "medios disponibles". Pero esto no es sorprendente: el proyecto es lo suficientemente atípico para una máquina que se utiliza para la búsqueda de texto completo.
En una de las etapas intermedias del proyecto, necesitábamos lo siguiente: mostrar una lista de todos los grupos de ingredientes disponibles y el número de posiciones en cada uno. Aquí se reveló el mismo problema que en la consulta predominante: de 10,000 recetas, se generaron alrededor de 10 grupos en función del contenido. Sin embargo, un total de aproximadamente 40,000 recetas resultaron estar en estos grupos, lo que no correspondía en absoluto a la realidad. Luego comenzamos a cavar hacia consultas paralelas.
La primera solicitud recibimos una lista de todos los grupos que están en el primer nivel sin el número de entradas. Después de eso, se generó una solicitud múltiple: para cada grupo, se realizó una solicitud para recibir el número real de recetas de acuerdo con el principio del porcentaje prevaleciente. Todas estas solicitudes se recopilaron en una y se enviaron a Elasticsearch. El tiempo de respuesta para la solicitud general fue igual al tiempo de procesamiento de la solicitud más lenta. La agregación masiva hizo posible paralelizarlos. Una lógica similar (simplemente agrupando por condición en una consulta) en SQL tomó aproximadamente 15 veces más tiempo.
/* */ $params = config('elastic.params'); $params['body'] = config('elastic.top_list'); return (Elastic::getClient()->search($params))['aggregations']['tags']['buckets']; /* */
Después de eso, tuvimos que evaluar:
- cuántas recetas hay disponibles para la composición actual;
- qué otros ingredientes podemos agregar a la composición (a veces agregamos el ingrediente y obtenemos una muestra vacía);
- qué ingredientes entre los seleccionados podemos marcar como los únicos en este nivel.
En función de la tarea, combinamos la lógica de la última solicitud recibida para la lista de recetas y la lógica de obtener números exactos de la lista de todos los grupos 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'];
Como resultado, recibimos una solicitud que encuentra todas las recetas necesarias y su número total (se tomó de la respuesta ["hits"] ["total"]). Para simplificar, esta solicitud se registró en el último lugar de la lista.
Además, a través de la agregación, recibimos todos los ingredientes de identificación para el siguiente nivel. Para cada uno de los ingredientes que no estaban marcados como "únicos", creamos una consulta donde lo marcamos en consecuencia, y luego simplemente contamos el número de documentos encontrados. Si fue mayor que cero, el ingrediente se consideró disponible para asignar la clave "individual". Creo que aquí puedes restaurar toda la plantilla sin mí, que obtuvimos en la salida:
{ : {{ from }}, : {{ size }}, : { : { : [ {{#ingredientTags}} {{#tagList}} {: { : [ {: {: {{ tagId }} }} ] }} {{^isLast}},{{/isLast}} {{/tagList}} {{/ingredientTags}} ], : [ {:{ : }} {{#levelsList}}, {{#levels}} {: { : }} {{^isLast}},{{/isLast}} {{/levels}} {{/levelsList}} ] } }, : { :{ :{ : , : { : }, : [ {{#exclude}}{{ id }},{{/exclude}} 0] }, : { : {} } } }, : [ {: {: }} ] }
Por supuesto, almacenamos en caché parte de este montón de plantillas y consultas (como la página de todos los grupos disponibles con el número de recetas disponibles), lo que nos agrega un poco de rendimiento en la página principal. Esta decisión hizo posible que los datos principales se recopilaran en 50 ms.
Resultados del proyectoRealizamos una búsqueda en la base de datos de al menos 50,000 documentos en Elasticsearch, que le permite buscar ingredientes en productos y obtener una descripción del producto por los ingredientes que contiene. Pronto esta base de datos crecerá aproximadamente seis veces (los datos se están preparando), por lo que estamos muy contentos con nuestros resultados y Elasticsearch como herramienta de búsqueda.
En cuanto a la cuestión del rendimiento, cumplimos con los requisitos del proyecto y nos complace que el tiempo de respuesta promedio a una solicitud sea de 250-300 ms.
Tres meses después de comenzar a trabajar con Elasticsearch, ya no parece tan confuso e inusual. Y las ventajas de la creación de plantillas son obvias: si vemos que la solicitud vuelve a ser demasiado grande, simplemente transferimos la lógica adicional a la plantilla y nuevamente enviamos la solicitud original al servidor casi sin cambios.
"Todo lo mejor y gracias por el pescado!" (c)
PD: En el último momento también necesitábamos ordenar por caracteres rusos en el nombre. Y luego resultó que Elasticsearch no percibe el alfabeto ruso adecuadamente. Salchicha condicional "Ultra mega cerdo 9000 calorías" se convirtió dentro de la clasificación simplemente en "9000" y se encontraba al final de la lista. Al final resultó que, este problema se resuelve fácilmente convirtiendo caracteres rusos a notación unicode de la forma u042B.