Pull to refresh

Неполнотекстовый поиск: специфичные возможности Elasticsearch для сложных задач

Abnormal programming *Search engines *PHP *Designing and refactoring *API *
Sandbox
image

Привет всем, меня зовут Андрей, и я разработчик. Давным-давно — кажется, в прошлую пятницу — у нашей команды был проект, где понадобился поиск по ингредиентам, входящим в состав продуктов. Допустим, в состав колбасы. В самом начале проекта от поиска требовалось не много: показать все рецепты, в которых нужный ингредиент содержится в определенном количестве; повторить для N ингредиентов.

Однако в дальнейшем количество продуктов и ингредиентов планировалось значительно увеличить, а поиск должен был не только справляться с возрастающим объемом данным, но и предоставить дополнительные опции — например, автоматическое составление описания продукта по его превалирующим ингредиентам.

Требования

  • Создать поиск на Elacsticsearch по базе из 50 000 документов минимум.
  • Обеспечить высокую скорость ответа на запросы – менее 300 мс.
  • Добиться того, чтобы запросы имели небольшой объем, и сервис был доступен даже в условиях самого плохого мобильного интернета.
  • Сделать логику поиска максимально интуитивной с точки зрения UX. Речь шла по сути о том, что интерфейс будет отражать логику поиска — и наоборот.
  • Снизить до минимума количество прослоек между элементами системы для более высокой производительности и уменьшения количества зависимостей.
  • Предусмотреть возможность в любой момент дополнить алгоритм новыми условиями (например, автоматической генерацией описания продукта).
  • Сделать дальнейшую поддержку поисковой части проекта максимально простой и удобной.

Мы решили не торопиться и начать с простого.

В первую очередь, мы сохранили все ингредиенты состава продукта в базе данных, получив на первых порах 10 000 записей. К сожалению, уже при таком размере поиск по базе занимал слишком много времени, даже с учетом использования join-ов и индексов. А в ближайшее время количество записей должно было превысить 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, но мы решили не впадать в крайности.

Сначала простейший поиск по рецептам сделали прямо на массивах. Да, это все было вынесено в конфигурационные файлы, но запрос все равно получался слишком большим. Поиск проходил по вложенным документам (те самые ingredients), по булевым выражениям с использованием «should» и «must», также действовала директива обязательного прохода по вложенным документам — в итоге запрос занимал от ста строк, а его объем был от трех килобайт.

Не стоит забывать и про требования к скорости и размеру ответа — к тому моменту ответы в 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);
}

Лирическое отступление: когда речь зашла о nested-полях в документе, оказалось, что мы не можем выполнить запрос вида:

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

по одной простой причине — нельзя выполнять мультипоиск внутри nested-фильтра. Поэтому пришлось делать таким образом:

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

т.е. сначала объявлялся массив условий should, а внутри каждого условия вызывался поиск по nested-полю. С точки зрения Elasticsearch это является более правильным и логичным. В итоге мы и сами увидели, что это логично, когда добавляли дополнительные условия поиска.

И здесь мы открыли для себя гугл шаблоны, встроенные в ES. Выбор пал на Mustache — довольно удобный logic-less шаблонизатор. В него можно было вынести все тело запроса и все передаваемые данные практически без изменений, в результате чего конечный запрос приобрел вид:

{ "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. Время ответа по общему запросу было равным времени обработки самого медленного запроса. Bulk-агрегация позволяла распараллелить их. Подобная логика (просто группировкой по условию в запросе) в 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'];

В итоге мы получили запрос, находящий все необходимые рецепты и их полное количество (оно бралось из 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 мс.

Результаты проекта

Мы реализовали поиск по базе как минимум из 50 000 документов на Elasticsearch, который позволяет искать ингредиенты в составе продуктов и получать описание продукта по содержащимся в нем ингредиентам. Скоро эта база вырастет примерно в шесть раз (данные как раз подготавливаются), поэтому мы вполне довольны и своими результатами, и Elasticsearch как инструментом поиска.

К вопросу о производительности — мы уложились в требования проекта, и сами рады тому, что время ответа на запрос в среднем составляет 250-300 мс.

Через три месяца после начала работы с Elasticsearch он уже не кажется таким запутанным и непривычным. А плюсы шаблонизации очевидны: если мы видим, что запрос снова становится слишком большим, мы просто переносим дополнительную логику в шаблон и снова отправляем серверу исходный запрос практически без изменений.

«Всего хорошего и спасибо за рыбу!» (с)

P.S. В последний момент нам понадобилась еще и сортировка по русским символам в названии. И тут выяснилось, что Elasticsearch не воспринимает русский алфавит адекватно. Условная колбаса «Ультра мега свинина 9000 калорий» превращалась внутри сортировки просто в «9000» и оказывалась в конце списка. Как оказалось, эта проблема довольно просто решается преобразованием русских символов в unicode-нотацию вида u042B.
Tags:
Hubs:
Total votes 16: ↑15 and ↓1 +14
Views 14K
Comments Comments 36