Мне и моему коллеге (следует отдать должное Дмитрию Аптеру, который проделал большую часть реальной материальной работы) недавно была поручена относительно простая задача - создать навигацию с несколькими фильтрами для сайта электронной коммерции. Поскольку эта функция ни в коем случае не уникальна, я был уверен, что это будет 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, что означает, что наши фильтры были применены.

Миссия выполнена.