Маппинг 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 (точное значение).
Как менять маппинг, когда уже поздно
Алгоритм минимального простоя:
- Создайте новый индекс с правильным маппингом:
PUT /my_index_v2. - Запустите
POST /_reindexиз старого в новый. На 28k SKU — около 80 минут. - Перед переключением проверьте count:
GET /my_index_v2/_count==GET /my_index/_count. - Переключите alias:
POST /_aliases→remove: my_index_alias → my_index,add: my_index_alias → my_index_v2. - Новые запросы автоматически идут в 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-поиск поверх правильного маппинга — разбор опечаток на реальном каталоге.