Назад в блог

Маппинг Elasticsearch — это архитектура, а не конфигурация

Три недели мы не могли понять, почему фильтр по цене не работает. Elasticsearch был жив. Запросы приходили. Результаты возвращались. Но диапазон цен от 1000 до 5000 рублей игнорировался полностью: в выдаче появлялись товары за 12 000 и за 300 рублей одновременно.

Нашли в итоге случайно. Поле price было проиндексировано как text.

Не потому что кто-то ошибся. Потому что индекс создавался с dynamic: true, Elasticsearch сам вывел тип по первому документу, где цена пришла строкой из XML-выгрузки 1С. Один import, одна строка вместо числа — и поисковый движок молча решил, что price это текст.

Исправить это без реиндекса нельзя. Маппинг в Elasticsearch — иммутабельный. Не «сложно изменить». Именно нельзя: нужно создать новый индекс с правильным маппингом, залить 28 000 документов заново, переключить alias. У нас это заняло 4 часа работы плюс полтора часа реиндекса.

Вот почему настройка маппинга Elasticsearch — это не конфигурационная задача. Это архитектура.

Что значит «иммутабельный маппинг»

Когда вы меняете схему в реляционной базе, вы пишете ALTER TABLE. Дорого, иногда опасно, но возможно без пересоздания таблицы. В Elasticsearch поменять тип поля невозможно in-place. Можно добавить новое поле. Но изменить существующее — только через полный reindex.

Для индекса с 28 000 SKU это 80–90 минут в prod. Для интернет-магазина с пиковой нагрузкой вечером это риск.

Поэтому решение принимается один раз. До первого PUT /my_index. Потом — только через alias swap.

dynamic: false — первое правило боевого индекса

По умолчанию Elasticsearch включает dynamic: true. Это значит: любое новое поле в документе автоматически добавляется в маппинг, тип выводится по значению.

В tutorial это удобно. В production — нет.

Три последствия dynamic: true, с которыми я лично сталкивался:

Первое — тип выводится по первому документу. Если в первом документе price = "1200" (строка из XML), маппинг закрепит text. Всё, фильтр по диапазону не работает.

Второе — неожиданные поля в индексе. Bitrix иногда добавляет служебные поля в API-ответ. Они попадают в индекс, раздувают маппинг, ломают GET /my_index/_mapping читаемость.

Третье — _doc_count растёт незаметно. Через полгода обнаруживаете 40+ полей, из которых используете 12.

dynamic: false для боевого индекса. Новые поля не попадают в маппинг автоматически — нужно явно добавить их и сделать reindex. Неудобно. Зато через полгода не откроете 40 полей и не будете гадать, откуда они взялись.

text vs keyword: это контракт, а не тип данных

Когда выбираете тип поля, решаете не «как хранить», а «что с этим можно делать».

text — поле идёт через анализатор: токенизация, стемминг, опционально нормализация. По нему работает full-text search. По нему не работает точная фильтрация, агрегации, сортировка.

keyword — поле хранится как есть. По нему работает фильтрация (term, terms), агрегации (facets), сортировка. Full-text search — нет.

Типичная ошибка: поле brand помечают как text. Потом пытаются сделать фасет по бренду и получают неверные bucket'ы, потому что анализатор разбил "Samsung Galaxy" на два токена.

Правило: поля, по которым нужны фасеты и точные фильтры — только keyword. Поля, по которым ищут — text с добавленным keyword-субполем через fields:

"name": {
  "type": "text",
  "analyzer": "russian",
  "fields": {
    "keyword": { "type": "keyword" }
  }
}

Всё, что нужно и для поиска, и для фасетов — делаем так.

nested vs object: когда иерархия стоит денег

Если у товара есть несколько вариантов (цвет + размер → цена), хочется хранить это как массив объектов:

"variants": [
  { "color": "red", "size": "M", "price": 1200 },
  { "color": "blue", "size": "L", "price": 1400 }
]

С типом object Elasticsearch «разворачивает» это в плоский список: variants.color: [red, blue], variants.price: [1200, 1400]. Связь между полями внутри одного варианта теряется. Запрос «найди красный вариант за 1200» может вернуть документ, где красный — за 1400, а за 1200 — синий.

Тип nested сохраняет связи. Но каждый nested-объект — отдельный скрытый документ. На 28 000 товаров с 5 вариантами это 140 000 скрытых документов. Производительность падает, и есть лимит index.max_nested_depth.

nested только там, где нужна фильтрация по сочетанию полей внутри объекта. Для остального — object или отдельный индекс под варианты.

Русская морфология: поле для поиска ≠ поле для фильтра

Для RU-поиска нужен кастомный анализатор с morpheme или russian stemmer. Он позволяет искать «телефоны» по запросу «телефон», «ноутбука» → «ноутбук».

Но тот же анализатор сломает фасеты: "Samsung" превратится в токен "samsung" без заглавной. Точная фильтрация term: { "brand": "Samsung" } не сработает.

Решение: два субполя на одном поле.

"name": {
  "type": "text",
  "analyzer": "ru_morpheme",
  "fields": {
    "raw": { "type": "keyword" }
  }
}

Поиск идёт по name (с морфологией). Фасеты и фильтры — по name.raw (точное значение).

Как менять маппинг, когда уже поздно

Алгоритм минимального простоя:

  1. Создайте новый индекс с правильным маппингом: PUT /my_index_v2.
  2. Запустите POST /_reindex из старого в новый. На 28k SKU — около 80 минут.
  3. Перед переключением проверьте count: GET /my_index_v2/_count == GET /my_index/_count.
  4. Переключите alias: POST /_aliasesremove: my_index_alias → my_index, add: my_index_alias → my_index_v2.
  5. Новые запросы автоматически идут в v2. Старый индекс оставляете как резервный на 24 часа.

Приложение не должно знать о версии индекса — только об alias. Это единственный способ делать reindex без даунтайма.

Чеклист до первого PUT /my_index

  • dynamic: false установлен явно
  • Все поля для фасетов — keyword
  • Все поля для full-text search — text с анализатором
  • Поля, нужные и для поиска, и для фильтра — fields с субполем keyword
  • RU-морфология настроена через custom analyzer, не standard
  • nested только там, где нужна фильтрация по составному объекту
  • Индекс доступен только через alias, не напрямую по имени

Маппинг настраивается один раз. Переделать без реиндекса — нельзя. Поэтому это не конфигурационная задача, которую «потом допилим». Это архитектурное решение, которое определяет, что ваш поиск сможет делать через год.

Про связь индекса с конверсией — Elasticsearch как UX-инструмент. Про fuzzy-поиск поверх правильного маппинга — разбор опечаток на реальном каталоге.