Маппинг: явный, динамический и шаблоны индексов

Когда вы кладёте документ в Elasticsearch, он должен знать, как именно хранить каждое поле — в инвертированный индекс, числовую структуру или поле для сортировки. Эту схему называют маппингом (mapping). Маппинг — аналог CREATE TABLE в SQL, только для JSON-документов.

Маппинг бывает динамическим (ES угадывает типы сам по первым данным) и явным (вы задаёте схему заранее). Понять разницу — значит избежать большей части классических ошибок при работе с ES в продакшне.

Динамический маппинг: ES учится на лету

Если вы создаёте индекс и сразу кладёте документ без предварительных настроек, ES проанализирует каждое поле и автоматически выведет его тип. Это и есть dynamic mapping.

Положим первый документ в индекс catalog:

POST /catalog/_doc/1
{
  "title": "Клавиатура механическая",
  "price": 7500,
  "in_stock": true,
  "created_at": "2024-01-15T10:00:00Z"
}

ES посмотрит на значения и создаст такой маппинг:

ПолеЗначениеВыведенный тип
titleстрокаtext + keyword (multi-field)
priceцелое числоlong
in_stockбулевboolean
created_atстрока ISO-8601date

Посмотреть, что получилось:

GET /catalog/_mapping

Строковое поле title автоматически получило multi-field — сразу text (для полнотекстового поиска) и keyword (для точных совпадений и сортировки). Это умолчание ES для строк, и оно весьма удобно.

flowchart TD A["Новый документ"] --> B{"Поле уже есть в маппинге?"} B -- Да --> C["Используем существующий тип"] B -- Нет --> D{"dynamic: true?"} D -- Нет --> E["Поле игнорируется"] D -- Да --> F{"Определяем тип по значению"} F --> G["строка → text + keyword"] F --> H["целое число → long"] F --> I["дробное → float"] F --> J["true/false → boolean"] F --> K["ISO-строка → date"] G --> L["Добавляем поле в маппинг, индексируем документ"] H --> L I --> L J --> L K --> L C --> M["Документ проиндексирован"] L --> M
flowchart TD
    A["Новый документ"] --> B{"Поле уже есть в маппинге?"}
    B -- Да --> C["Используем существующий тип"]
    B -- Нет --> D{"dynamic: true?"}
    D -- Нет --> E["Поле игнорируется"]
    D -- Да --> F{"Определяем тип по значению"}
    F --> G["строка → text + keyword"]
    F --> H["целое число → long"]
    F --> I["дробное → float"]
    F --> J["true/false → boolean"]
    F --> K["ISO-строка → date"]
    G --> L["Добавляем поле в маппинг, индексируем документ"]
    H --> L
    I --> L
    J --> L
    K --> L
    C --> M["Документ проиндексирован"]
    L --> M
Как ES выводит тип поля при динамическом маппинге
Check yourself
ES получает документ с полем `rating: "5"` — строка в кавычках. Какой тип он присвоит этому полю? Что произойдёт, когда следующий документ придёт с `rating: 4.5` (число)?

Где динамический маппинг ломается

Динамика удобна для старта, но в продакшне приносит неприятности.

Неправильный тип при первой записи. Если первый документ содержит "rating": "5" (строка), ES создаст rating как text. Следующий документ с "rating": 4.5 (число) вызовет mapper_parsing_exception — ES не кладёт число в текстовое поле.

Взрывное число полей. Если документы содержат непредсказуемые ключи (динамические атрибуты, произвольные теги), маппинг разрастается до тысяч полей, что тормозит кластер. Ограничение по умолчанию — index.mapping.total_fields.limit: 1000.

Строка вместо даты. Если первый документ содержит "created_at": "вчера", поле станет text. Следующий документ с ISO-датой упадёт с ошибкой разбора.

Явный маппинг: задаём схему заранее

Явный маппинг создаётся при создании индекса командой PUT /<index> с секцией "mappings":

PUT /catalog
{
  "mappings": {
    "properties": {
      "title": {
        "type": "text",
        "analyzer": "russian",
        "fields": {
          "keyword": { "type": "keyword" }
        }
      },
      "price":       { "type": "integer" },
      "in_stock":    { "type": "boolean" },
      "created_at":  { "type": "date" },
      "description": { "type": "text", "analyzer": "russian" },
      "category":    { "type": "keyword" }
    }
  }
}

Что здесь важно:

  • title анализируется анализатором russian для полнотекстового поиска и параллельно хранится как keyword для сортировки и агрегаций;
  • price — тип integer, а не long (достаточно, если цены не превышают $2 \times 10^9$);
  • categorykeyword, потому что по нему будем фильтровать и группировать, а не искать по словам.
Check yourself
Зачем мы объявили поле `category` типа `keyword`, а не `text`? В каких конкретных операциях это принципиально важно?

Почему маппинг нельзя изменить задним числом

Это один из самых важных фактов про ES: изменить тип существующего поля невозможно. Если поле создано как text, сделать его keyword без пересоздания индекса нельзя.

Причина — Lucene физически строит разные структуры для разных типов. Уже проиндексированные документы записаны под одну структуру; изменить её без повторной записи данных не получится.

Что можно сделать с существующим маппингом:

  • Добавлять новые поля в индекс.
  • Добавлять новые fields (multi-fields) к уже существующему полю.
  • Изменять некоторые параметры, например ignore_above для keyword.

Что нельзя:

  • Менять type существующего поля.
  • Удалять поле из маппинга.
  • Менять анализатор для уже существующего text-поля (исторические документы уже проиндексированы по-старому).

Когда всё-таки нужно изменить маппинг, единственный путь — reindex:

POST /_reindex
{
  "source": { "index": "catalog" },
  "dest":   { "index": "catalog_v2" }
}

Создаёте catalog_v2 с правильным маппингом, переносите туда данные через _reindex, затем переключаете алиас. Это штатная практика, не исключение.

Check yourself
Индекс `products` уже существует, в нём есть поле `price` типа `long`. Вы хотите добавить совершенно новое поле `discount` типа `float`. Нужен ли reindex?

Multi-fields: одно поле — несколько представлений

ES позволяет индексировать одно поле сразу в нескольких форматах через секцию fields. Это не хранит дублирующую копию исходного текста — просто строит несколько индексных структур из одного значения.

"title": {
  "type": "text",
  "analyzer": "russian",
  "fields": {
    "keyword": { "type": "keyword", "ignore_above": 256 },
    "english": { "type": "text",    "analyzer": "english" }
  }
}

Теперь одно поле можно использовать тремя способами:

  • match по title — полнотекстовый поиск с русской морфологией;
  • sort по title.keyword — лексикографическая сортировка;
  • match по title.english — поиск с английским стеммингом (для двуязычного каталога).

Index templates: единообразие без повторения

Если вы создаёте много похожих индексов — например, логи по дням: logs-2024-01-01, logs-2024-01-02… — прописывать маппинг для каждого вручную не стоит. Для этого есть index templates — шаблоны, которые ES автоматически применяет при создании нового индекса, если его имя совпадает с паттерном.

В ES 8 используются composable index templates (рекомендованный подход начиная с ES 7.8):

PUT /_index_template/logs_template
{
  "index_patterns": ["logs-*"],
  "priority": 100,
  "template": {
    "settings": {
      "number_of_shards": 1,
      "number_of_replicas": 1
    },
    "mappings": {
      "properties": {
        "timestamp": { "type": "date" },
        "level":     { "type": "keyword" },
        "message":   { "type": "text" },
        "service":   { "type": "keyword" }
      }
    }
  }
}

Теперь при создании любого индекса, чьё имя начинается с logs-, ES автоматически применит этот маппинг и настройки.

Посмотреть список всех шаблонов:

GET /_index_template

Проверить, что именно применится к конкретному имени (полезно при отладке):

POST /_index_template/_simulate_index/logs-2024-01-01

Component templates — дополнительный слой для переиспользования. Выносите общие поля в отдельный компонент и ссылаетесь на него из нескольких index templates:

PUT /_component_template/common_fields
{
  "template": {
    "mappings": {
      "properties": {
        "timestamp": { "type": "date" },
        "service":   { "type": "keyword" }
      }
    }
  }
}

PUT /_index_template/logs_template
{
  "index_patterns": ["logs-*"],
  "composed_of": ["common_fields"],
  "template": {
    "mappings": {
      "properties": {
        "level":   { "type": "keyword" },
        "message": { "type": "text" }
      }
    }
  }
}

Итоговый маппинг индекса — слияние всех компонентов и секции template. Общие поля (временные метки, сервис, хост) держите в одном компоненте и переиспользуйте в шаблонах для разных типов индексов.

Практический порядок работы

Хороший workflow при создании нового индекса:

1. Набросайте структуру документа — какие поля нужны, какие типы логичны.

2. Создайте явный маппинг через PUT /<index> — не надейтесь на динамику в продакшне.

3. Проверьте через _analyze (для текстовых полей), что анализатор токенизирует строки так, как ожидаете.

4. Оберните в index template, если таких индексов будет несколько.

5. Тестируйте на малом объёме перед массовой загрузкой через Bulk API.

Если в процессе нашли ошибку в маппинге — создайте <index>_v2 с исправленным маппингом и перенесите данные через _reindex. Это штатная практика.

Quick recall
Почему при динамическом маппинге строковое поле автоматически получает как text, так и keyword?
Quick recall
Что происходит, если при динамическом маппинге первый документ содержит поле как строку, а второй — как число?
Quick recall
Что такое маппинг в контексте Elasticsearch?

См. также