В этой статье я опущу такие подробности работы с Elasticsearch (далее по тексту просто ES), таких как:

  1. Как установить

  2. Как подключаться

  3. Раскрывать полную схему mapping для товара интернет-магазина

  4. Подробное описание всей структуры и всех запросов для получения страницы товаров с результатами поиска и фильтром.

И что-либо еще.

Здесь, как и написано в заголовке, лишь постараюсь описать схему только для полей характеристик товара и как для них делать запросы агрегации и фильтрации.

Предисловие

К написанию статьи пришел после неудачного опыта разработки интернет-магазина на фреймворке и MySQL с десятками тысяч товаров, которые в свою очередь имели несколько десятков характеристик и множество значений для них. Из-за множества запросов для получения значений фильтра товаров и, возможно, абсолютно неправильного структурирования таблиц или по какой-либо другой причине, сайт ужасно тормозил и долго загружался. Дошло до того, что в вебмастере яндекса получал подобную ошибку:

Скрин из интернета.
Скрин из интернета.

Сайт разрабатывал не я. Разбираться с ним в дальнейшем не было ни знаний, ни желания. Решил, что впоследствии буду самостоятельно разрабатывать интернет-магазин, но используя другое и нереляционное хранилище данных, а не Mysql. Выбор пал на ES и при изучении понимание структурирования характеристик товаров и получение для них значений, которые впоследствии безболезненно и не затрагивая код можно было бы менять, отняло много времени. Лично мне очень не хватало в русскоязычном интернете абсолютно простых примеров, какие есть, например, для PHP+Mysql.

Все описанное, лишь основано на моем личном опыте и понимании схемы и структуры документов, ориентированной на использовании для построения фасетного поиска в интернет-магазине к которому я пришел при изучении и разработке. Т.е статья рассчитана больше на новичков, которые начали изучать ES.

По сути дела

Elasticsearch – это распределенный поисковый и аналитический движок на базе Apache Lucene.  Полностью с описанием можно ознакомиться на официальном сайте.

Фасетный поиск (фасетная навигация) – поиск товара в разделе, категории или же на странице полнотекстового поиска по характеристикам: цвет, материал, цена, производитель и т.д. Для конечного пользователя – набор фильтров. Каждый фильтр – характеристика. Значения этого фильтра – все возможные значения характеристики. Для интернет-магазина это основная функция поиска, и пользователи ожидают, что она будет работать достаточно быстро.

В приведенном ниже примере пользователь находится в категории "люстры" и отфильтровал дополнительно товары в диапазоне цен от 1394 до 42207 руб. и с цветом черный. Было найдено 198 товаров, а на панели фильтров слева перечислены те характеристики, которые содержатся в результатах поиска, а также количество доступных значений, имеющих этот атрибут (количество фасетов):

Здесь можно лично опробовать фильтр и повторить действия, описанные выше (на сайте используется ES).
Здесь можно лично опробовать фильтр и повторить действия, описанные выше (на сайте используется ES).

Для создания фасетного поиска в ES достаточно мощный инструмент агрегирования. Одной из приятных особенностей агрегации является то, что они могут быть вложенными — другими словами, можно определить агрегации верхнего уровня, которые создают «корзины» (buckets) документов и другие агрегации, которые выполняются внутри этих корзин. Для упрощения понимания, это в целом похоже на команду SQL GROUP_BY. На основе фильтров обобщаются, группируются документы по какому-то определенному признаку.

Индексирование значений фасета

Перед созданием агрегатов атрибуты документа, которые могут служить фасетами, необходимо проиндексировать в ES. Один из способов индексировать их — перечислить все атрибуты и их значения в одном поле, как в следующем примере:

"facets": {
  "color": "Черный",
  "style": "Лофт",
  "room": "Гостиная",
}

Mapping ES при этом должен выглядеть так:

"facets": {
  "type": "nested",
  "properties": {
      "color": {
          "type": "keyword",    
      },
      "style": {
        "type": "keyword",
      }
      "room": {
        "type": "keyword",
      }
  }
}

Такой подход может подойти, но для фасетирования в таком случае в запросах придется явно перечислять все имена полей, для которых мы хотим создать агрегацию.

"aggs": {
  "facets": {
    "nested": {
      "path": "facets"
    },
    "aggs": {
      "color": {
        "terms": {
          "field": "facets.color"
        }
      },
      "style": {
        "terms": {
          "field": "facets.style"
        }
      },
      "room": {
        "terms": {
          "field": "facets.room"
          }
      },
    }
  }
}

Очевидно, что это не очень практично и эффективно при большом количестве характеристик товаров, которые со временем могут изменяться, дополняться. И, например, при удалении, изменении или добавлении новой характеристики товара придется вручную изменить mapping, переиндексировать и изменить запрос, добавив в него новое имя поля.

Вместо этого я пришел к следующему

Разделил имена и значения фасетов, отправляемых в индекс эластика, следующим образом:

"string_facets": {
  {
    "name": "color",
    "value": "Черный"
  },
  {
    "name": "color",
    "value": "Белый"
  },
  {
    "name": "style",
    "value": "Лофт"
  },
  {
    "name": "style",
    "value": "Техно"
  },
  {
    "name": "room",
    "value": "Гостиная"
  },
  {
    "name": "room",
    "value": "Спальня"
  }
}

Mapping:

"string_facets": {
  "type": "nested",
  "properties": {
    "name": {
      "type": "keyword",    
   },
    "value": {
      "type": "keyword",
    }
  }

Для фильтрации и агрегирования такой структуры требуются вложенные фильтры и вложенные агрегации в запросах.

Агрегация:

"aggs": {
  "aggs_text_facets": {
    "nested": {
      "path": "string_facets"
    },
    "aggs": {
      "name": {
        "terms": {
          "field": "string_facets.name"
        },
        "aggs": {
          "value": {
            "terms": {
              "field": "string_facets.value"
            }
          }
        }
      }
    }
  }
}

Фильтрация:

"filter": {
  "nested": {
    "path": "string_facets",
    "filter": {
      "bool": {
        "must": {
          {
            "term": {
              "string_facets.name": "color"
            }
          },
          {
            "terms": {
              "string_facets.value": {
                "Черный"
              }
            }
          }
        }
      }
    }
  }
}

Это касается характеристик, у которых значения текстовые. Характеристики с числовыми значениями необходимо хранить и анализировать отдельно. Это связано с тем, что числовые характеристики (например, размеры: ширина, длина) иногда имеют огромное количество различных значений. И вместо того, чтобы перечислять все возможные значения, достаточно просто получить минимальное и максимальное значения и отобразить их в виде селектора диапазона или ползунка. Это возможно, только если значения хранятся в виде чисел.

В mapping это будет выглядеть следующим образом:

"number_facets": {
  "type": "nested",
  "properties": {
    "name": {
      "type": "keyword",    
   },
    "value": {
      "type": "double",
    }
  }

Агрегация:

"aggs_number_facet": {
  "nested": {
    "path": "number_facets"
  },
  "aggs": {
    "name": {
      "terms": {
        "field": "number_facets.name"
      },
      "aggs": {
        "value": {
          "stats": {
            "field": "number_facets.value"
          }
        }
      }
    }
  }
}

При таком подходе отпадает необходимость знать список доступных характеристик во время запроса. Также в любой момент можно просто изменить данные в индексе, удалив или изменив нужные характеристики, не затрагивая маппинг и запросы.

P.S. Организовав таким образом схему документов и прописав все необходимые запросы, я столкнулся с еще одной проблемой. При фильтрации оставлялись только товары с выбранным значением в фильтре товаров, соответственно нельзя было выбрать несколько значений одного и того же фильтра, что в моем случае влияло на удобство для пользователей. Для описания решения проблемы требуется отдельная статья.

Комментарии (1)


  1. Helldar
    00.00.0000 00:00

    Для тех, кто не в теме, стоит указать что в данном случае со стороны бэка передаётся массив объектов и эластик сам его положит как надо:

    $facets = [
        [
            'name' => 'color',
            'value' => 'Чёрный',
        ],
        [
            'name' => 'style',
            'value' => 'Лофт',
        ],
    ];