Мне и моему коллеге (следует отдать должное Дмитрию Аптеру, который проделал большую часть реальной материальной работы) недавно была поручена относительно простая задача - создать навигацию с несколькими фильтрами для сайта электронной коммерции. Поскольку эта функция ни в коем случае не уникальна, я был уверен, что это будет 5-минутный поиск в Google и ваш дядя Боб. Я был горько разочарован. Я читал об агрегации фильтров, агрегации терминов, вложенной агрегации, составной агрегации и т. Д. На то, чтобы найти действительно нужный мне ответ, потребовался почти день. Надеюсь, теперь люди, работающие над аналогичными задачами, быстро наткнутся на этот пост и сочтут его полезным.
Сценарий довольно банальный. Предположим, у нас есть магазин по продаже одежды, и наша одежда имеет только 5 атрибутов: категорию, цвет, бренд, стиль и размер, то есть 5 аспектов в терминах Elasticsearch.
Чтобы сделать этот пост исчерпывающим, давайте сначала синтезируем некоторые игрушечные данные.
import pandas as pd import numpy as np df = pd.DataFrame({'Category': np.random.choice(['Dress', 'Pants'], size=50, p=[0.7, 0.3]), 'Color': None, 'Style': None, 'Brand': None, 'Size': None}) dress_styles = ['Maxi', 'Evening', 'Shift', 'Sheath'] dress_brands = ['Hermes', 'Prada', 'Chanel', 'Fendi', 'Armani'] sizes = ['S', 'M', 'L', 'XL'] pants_styles = ['Culottes', 'Tights', 'Dungarees'] pants_brands = ["Levi's", "Wrangler", "Armani", "Calvin Klein", "Diesel"] colors = ['Green', 'Black', 'White', 'Red', 'Blue'] size = df[df.Category == 'Dress'].shape[0] df.loc[df['Category'] == 'Dress', ['Style']] = np.random.choice(dress_styles, size=size).reshape(size,1) df.loc[df['Category'] == 'Dress', ['Brand']] = np.random.choice(dress_brands, size=size).reshape(size,1) size = df[df.Category == 'Pants'].shape[0] df.loc[df['Category'] == 'Pants', ['Style']] = np.random.choice(pants_styles, size=size).reshape(size,1) df.loc[df['Category'] == 'Pants', ['Brand']] = np.random.choice(pants_brands, size=size).reshape(size,1) df['Color'] = np.random.choice(colors, size=50) df['Size'] = np.random.choice(sizes, size=50) df['id'] = list(range(1, len(df) + 1))
Теперь давайте вставим наши данные в индекс Elasticsearch (я использовал ES 7.7).
from elasticsearch import Elasticsearch, helpers def filterKeys(document, df): return {key: document[key] for key in df.columns.values} def doc_generator(df, index_name): df_iter = df.iterrows() for index, document in df_iter: res = { "_index": index_name, "_id": f"{document['id']}", "_source": filterKeys(document, df) } yield res es_client = Elasticsearch('localhost:9200') index_name = 'faceted_navigation' es_client.indices.create(index_name, body={"mappings": {"properties": { "id": { "type": "integer" }, "Category": { "type": "keyword" }, "Color": { "type": "keyword" }, "Brand": { "type": "keyword" }, "Style": { "type": "keyword" }, "Size": { "type": "keyword" } }}}) helpers.bulk(es_client, doc_generator(df, index_name), request_timeout=120)
Предположим, я ищу платье. Мой запрос будет выглядеть примерно так:
{ "size": 0, "query": { "match": { "Category": "Dress" } }, "aggs": { "Color": { "terms": { "field": "Color", "size": 10 } }, "Size": { "terms": { "field": "Size", "size": 10 } }, "Brand": { "terms": { "field": "Brand", "size": 10 } }, "Style": { "terms": { "field": "Style", "size": 10 } } } }
И ответ будет выглядеть примерно так:
{ "took": 33, "timed_out": false, "_shards": { "total": 1, "successful": 1, "skipped": 0, "failed": 0 }, "hits": { "total": { "value": 30, "relation": "eq" }, "max_score": null, "hits": [] }, "aggregations": { "Brand": { "doc_count_error_upper_bound": 0, "sum_other_doc_count": 0, "buckets": [ { "key": "Prada", "doc_count": 8 }, { "key": "Chanel", "doc_count": 6 }, { "key": "Hermes", "doc_count": 6 }, { "key": "Armani", "doc_count": 5 }, { "key": "Fendi", "doc_count": 5 } ] }, "Size": { "doc_count_error_upper_bound": 0, "sum_other_doc_count": 0, "buckets": [ { "key": "L", "doc_count": 9 }, { "key": "XL", "doc_count": 9 }, { "key": "M", "doc_count": 8 }, { "key": "S", "doc_count": 4 } ] }, "Color": { "doc_count_error_upper_bound": 0, "sum_other_doc_count": 0, "buckets": [ { "key": "Black", "doc_count": 8 }, { "key": "Red", "doc_count": 8 }, { "key": "White", "doc_count": 7 }, { "key": "Blue", "doc_count": 4 }, { "key": "Green", "doc_count": 3 } ] }, "Style": { "doc_count_error_upper_bound": 0, "sum_other_doc_count": 0, "buckets": [ { "key": "Maxi", "doc_count": 11 }, { "key": "Sheath", "doc_count": 11 }, { "key": "Evening", "doc_count": 5 }, { "key": "Shift", "doc_count": 3 } ] } } }
Теперь предположим, что я применил несколько фильтров. Я выбрала вечерние платья Prada, то есть Бренд - «Prada», Стиль - «Вечерний». Желаемое поведение:
- Результаты поиска содержат только вечерние платья Prada.
- На счетчики для бренда влияет только фильтр стиля.
- На количество стилей влияет только фильтр бренда.
- Количество всех остальных атрибутов пересчитывается на основе фильтров стиля и бренда.
По-видимому, я искал не расширенные агрегаты, а функциональность post_filter. Таким образом, запрос будет выглядеть так:
{ "query": { "match": { "Category": "Dress" } }, "aggs": { "Color": { "aggs": { "Color": { "terms": { "field": "Color", "size": 10 } } }, "filter": { "bool": { "filter": [ { "terms": { "Brand": [ "Prada" ] } }, { "terms": { "Style": [ "Evening" ] } } ] } } }, "Size": { "aggs": { "Size": { "terms": { "field": "Size", "size": 5 } } }, "filter": { "bool": { "filter": [ { "terms": { "Brand": [ "Prada" ] } }, { "terms": { "Style": [ "Evening" ] } } ] } } }, "Brand": { "aggs": { "Brand": { "terms": { "field": "Brand", "size": 10 } } }, "filter": { "bool": { "filter": [ { "terms": { "Style": [ "Evening" ] } } ] } } }, "Style": { "aggs": { "Style": { "terms": { "field": "Style", "size": 10 } } }, "filter": { "bool": { "filter": [ { "terms": { "Brand": [ "Prada" ] } } ] } } } }, "post_filter": { "bool": { "filter": [ { "terms": { "Brand": [ "Prada" ] } }, { "terms": { "Style": [ "Evening" ] } } ] } } }
Давайте быстро проведем несколько тестов, чтобы понять, каких результатов мы ожидаем. Во-первых, давайте проверим, сколько всего платьев содержится в нашем наборе данных:
curl --location --request GET 'localhost:9200/faceted_navigation/_count' \ --header 'Content-Type: application/json' \ --data-raw '{ "query": { "bool": { "must": [ { "match": { "Category": "Dress" } } ] } } }'
Это вернет (это верно только для данных, которые я сгенерировал, ваши результаты, если вы повторите мой эксперимент, могут быть другими):
{ "count": 30, "_shards": { "total": 1, "successful": 1, "skipped": 0, "failed": 0 } }
Другими словами, мы ожидаем, что значения в сегментах «Бренд» и «Стиль» в сумме будут равны 30.
Теперь давайте проверим, сколько вечерних платьев Prada есть в нашем наборе данных:
curl --location --request GET 'localhost:9200/faceted_navigation/_count' \ --header 'Content-Type: application/json' \ --data-raw '{ "query": { "bool": { "must": [ { "match": { "Category": "Dress" } }, { "match": { "Brand": "Prada" } }, { "match": { "Style": "Evening" } } ] } } }'
Это возвращает:
{ "count": 2, "_shards": { "total": 1, "successful": 1, "skipped": 0, "failed": 0 } }
Отлично, у нас всего два матча, так что проверить наши результаты будет несложно.
И вот окончательные результаты нашего многогранного запроса:
{ "took": 4, "timed_out": false, "_shards": { "total": 1, "successful": 1, "skipped": 0, "failed": 0 }, "hits": { "total": { "value": 2, "relation": "eq" }, "max_score": 0.51409894, "hits": [ { "_index": "faceted_search", "_type": "_doc", "_id": "16", "_score": 0.51409894, "_source": { "Category": "Dress", "Color": "Blue", "Style": "Evening", "Brand": "Prada", "Size": "L", "id": 16 } }, { "_index": "faceted_search", "_type": "_doc", "_id": "36", "_score": 0.51409894, "_source": { "Category": "Dress", "Color": "Green", "Style": "Evening", "Brand": "Prada", "Size": "XL", "id": 36 } } ] }, "aggregations": { "Brand": { "doc_count_error_upper_bound": 0, "sum_other_doc_count": 0, "buckets": [ { "key": "Prada", "doc_count": 8 }, { "key": "Chanel", "doc_count": 6 }, { "key": "Hermes", "doc_count": 6 }, { "key": "Armani", "doc_count": 5 }, { "key": "Fendi", "doc_count": 5 } ] }, "Size": { "doc_count": 2, "Size": { "doc_count_error_upper_bound": 0, "sum_other_doc_count": 0, "buckets": [ { "key": "L", "doc_count": 1 }, { "key": "XL", "doc_count": 1 } ] } }, "Color": { "doc_count": 2, "Color": { "doc_count_error_upper_bound": 0, "sum_other_doc_count": 0, "buckets": [ { "key": "Blue", "doc_count": 1 }, { "key": "Green", "doc_count": 1 } ] } }, "Style": { "doc_count_error_upper_bound": 0, "sum_other_doc_count": 0, "buckets": [ { "key": "Maxi", "doc_count": 11 }, { "key": "Sheath", "doc_count": 11 }, { "key": "Evening", "doc_count": 5 }, { "key": "Shift", "doc_count": 3 } ] } } }
У нас действительно только два хита, и оба они - вечерние платья Prada. Значения в области "Бренд": Prada - 8, Chanel - 6, Hermes - 6, Armani - 5, Fendi - 5. Значения в области "Стиль": Макси - 11, Оболочка - 11, Вечер - 5, Сдвиг - 3. В обоих ведрах всего 30, так что у нас все хорошо. В двух других сегментах значения, как и ожидалось, в сумме составляют 2, что означает, что наши фильтры были применены.
Миссия выполнена.