[Из песочницы] Неполнотекстовый поиск: специфичные возможности Elasticsearch для сложных задач
Давным-давно — кажется, в прошлую пятницу — у нашей команды был проект, где понадобился поиск по ингредиентам, входящим в состав продуктов. Привет всем, меня зовут Андрей, и я разработчик. В самом начале проекта от поиска требовалось не много: показать все рецепты, в которых нужный ингредиент содержится в определенном количестве; повторить для N ингредиентов.
Однако в дальнейшем количество продуктов и ингредиентов планировалось значительно увеличить, а поиск должен был не только справляться с возрастающим объемом данным, но и предоставить дополнительные опции — например, автоматическое составление описания продукта по его превалирующим ингредиентам. Допустим, в состав колбасы.
Требования
- Создать поиск на Elacsticsearch по базе из 50 000 документов минимум.
- Обеспечить высокую скорость ответа на запросы – менее 300 мс.
- Добиться того, чтобы запросы имели небольшой объем, и сервис был доступен даже в условиях самого плохого мобильного интернета.
- Сделать логику поиска максимально интуитивной с точки зрения UX. Речь шла по сути о том, что интерфейс будет отражать логику поиска — и наоборот.
- Снизить до минимума количество прослоек между элементами системы для более высокой производительности и уменьшения количества зависимостей.
- Предусмотреть возможность в любой момент дополнить алгоритм новыми условиями (например, автоматической генерацией описания продукта).
- Сделать дальнейшую поддержку поисковой части проекта максимально простой и удобной.
Мы решили не торопиться и начать с простого.
К сожалению, уже при таком размере поиск по базе занимал слишком много времени, даже с учетом использования join-ов и индексов. В первую очередь, мы сохранили все ингредиенты состава продукта в базе данных, получив на первых порах 10 000 записей. К тому же заказчик настаивал на использовании Elasticsearch (далее — ES), поскольку сталкивался с этим инструментом и, судя по всему, испытывал к нему теплые чувства. А в ближайшее время количество записей должно было превысить 50 000. Мы до этого не работали с ES, но знали о его преимуществах и были согласны с этим выбором, так как, например, планировалось, что у нас будут часто появляться новые записи (по разным оценкам от 50 до 500 в день), которые нужно будет сразу же выдавать пользователю.
Это было еще одно преимущество — вплоть до отправки поисковых запросов напрямую в ES из браузера. От прослоек на уровне драйверов мы решили отказаться и просто использовать REST-запросы, поскольку синхронизация с базой делается только в момент создания документа и больше не нужна.
Мы собрали первый прототип, в котором перенесли структуру из базы данных (PostgreSQL) в документы ES:
, "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, но мы решили не впадать в крайности.
Да, это все было вынесено в конфигурационные файлы, но запрос все равно получался слишком большим. Сначала простейший поиск по рецептам сделали прямо на массивах. Поиск проходил по вложенным документам (те самые ingredients), по булевым выражениям с использованием «should» и «must», также действовала директива обязательного прохода по вложенным документам — в итоге запрос занимал от ста строк, а его объем был от трех килобайт.
Поэтому запросы в ES размером в несколько килобайт становились непозволительной роскошью. Не стоит забывать и про требования к скорости и размеру ответа — к тому моменту ответы в API были отформатированы таким образом, чтобы увеличить объем полезной информации: ключи в каждом json-объекте были сокращены до одной буквы.
К тому же контроллеры становились абсолютно нечитаемыми, смотрите сами: И еще мы на тот момент поняли, что строить гигантские запросы в виде ассоциативных массивов на 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);
}
Лирическое отступление: когда речь зашла о nested-полях в документе, оказалось, что мы не можем выполнить запрос вида:
"query": { "bool": { "nested": { "bool": { "should": [ ... ] } } }
}
по одной простой причине — нельзя выполнять мультипоиск внутри nested-фильтра. Поэтому пришлось делать таким образом:
"query": { "bool": { "should": [ {"nested": { "path": "flavors", "score_mode": "max", "query": { "bool": { ... } } }} ] }
}
т.е. сначала объявлялся массив условий should, а внутри каждого условия вызывался поиск по nested-полю. С точки зрения Elasticsearch это является более правильным и логичным. В итоге мы и сами увидели, что это логично, когда добавляли дополнительные условия поиска.
Выбор пал на Mustache — довольно удобный logic-less шаблонизатор. И здесь мы открыли для себя гугл шаблоны, встроенные в ES. В него можно было вынести все тело запроса и все передаваемые данные практически без изменений, в результате чего конечный запрос приобрел вид:
{ "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
}
Также была необходимость задать поиск по условию «чистоты» рецепта. Например, нам требовался рецепт, где не было бы ничего кроме говядины, соли и перца. Тогда мы должны были отсеивать рецепты, где на первом уровне была бы только говядина, а на втором — только специи (первый тэг у специй был нулевым). Здесь пришлось схитрить: поскольку mustache является шаблоном без логики, ни о каких расчетах речи идти не могло; здесь требовалось внедрить в запрос часть скрипта на встроенном скриптовом языке ES — Painless. Его синтаксис максимально приближен к Java, так что трудностей не возникло. В итоге у нас был Mustache-шаблон, генерирующий JSON, в котором часть расчетов, а именно сортировка и фильтрация были реализованы на 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 раз больше времени. Bulk-агрегация позволяла распараллелить их.
/* первый запрос */
$params = config('elastic.params');
$params['body'] = config('elastic.top_list'); return (Elastic::getClient()->search($params))['aggregations']['tags']['buckets']; /* второй запрос */
После этого нам понадобилось в рамках одного запроса оценить:
- сколько для текущего состава доступно рецептов;
- какие еще ингредиенты мы можем добавить в состав (иногда мы добавляли ингредиент и получали пустую выборку);
- какие ингредиенты среди выбранных мы можем пометить как единственные на данном уровне.
Исходя из задачи, мы объединили логику последнего полученного запроса на список рецепта и логику получения точных чисел из списка всех доступных групп:
/* аггрегация */ "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'];
В итоге мы получили запрос, находящий все необходимые рецепты и их полное количество (оно бралось из response[«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 он уже не кажется таким запутанным и непривычным.
«Всего хорошего и спасибо за рыбу!» (с)
S. В последний момент нам понадобилась еще и сортировка по русским символам в названии. P. Условная колбаса «Ультра мега свинина 9000 калорий» превращалась внутри сортировки просто в «9000» и оказывалась в конце списка. И тут выяснилось, что Elasticsearch не воспринимает русский алфавит адекватно. Как оказалось, эта проблема довольно просто решается преобразованием русских символов в unicode-нотацию вида u042B.