Типы полей: text, keyword, числа и даты

Почему тип поля — это не просто «формат»

В реляционной базе данных тип колонки влияет на хранение и валидацию. В Elasticsearch тип поля определяет, как именно данные индексируются и какие запросы к ним допустимы. Неправильный тип — и поиск молча вернёт ноль документов, а причина не очевидна.

Всё это прописывается в маппинге — схеме индекса. Задать маппинг можно явно при создании индекса или положиться на dynamic mapping: ES угадает тип по первому вставленному значению. Подробнее — в статье Маппинг: явный, динамический и шаблоны индексов. Здесь сосредоточимся на самих типах.

text и keyword: главное различие

Это важнейшая пара типов — оба хранят строки, но устроены принципиально по-разному.

text — строка проходит через анализатор: делится на токены, приводится к нижнему регистру, отсеиваются стоп-слова. В инвертированном индексе оказываются именно токены, а не исходная строка. Именно это позволяет полнотекстовому поиску находить «поисковый движок» по запросу «поиск».

keyword — строка хранится как есть, без анализа. В индексе лежит точная строка "Quick Brown Fox", а не токены quick, brown, fox. Этот тип нужен для точных совпадений, сортировки и агрегаций.

flowchart TD A["Строка: Quick Brown Fox"] --> B["Поле text"] A --> C["Поле keyword"] B --> D["Анализатор: токенизация + lowercase"] D --> E1["quick"] D --> E2["brown"] D --> E3["fox"] C --> F["Строка без изменений: Quick Brown Fox"] style E1 fill:#d4edda,stroke:#28a745 style E2 fill:#d4edda,stroke:#28a745 style E3 fill:#d4edda,stroke:#28a745 style F fill:#cce5ff,stroke:#004085
flowchart TD
    A["Строка: Quick Brown Fox"] --> B["Поле text"]
    A --> C["Поле keyword"]
    B --> D["Анализатор: токенизация + lowercase"]
    D --> E1["quick"]
    D --> E2["brown"]
    D --> E3["fox"]
    C --> F["Строка без изменений: Quick Brown Fox"]
    style E1 fill:#d4edda,stroke:#28a745
    style E2 fill:#d4edda,stroke:#28a745
    style E3 fill:#d4edda,stroke:#28a745
    style F fill:#cce5ff,stroke:#004085
Одна строка — два способа индексации: поле `text` разбивает на токены, `keyword` хранит строку целиком

Практическое следствие: term-запрос не работает по text-полю так, как кажется. Если поле title имеет тип text и хранит строку "Elasticsearch 8", то запрос term: { "title": "Elasticsearch 8" } не найдёт ничего — в индексе лежат токены elasticsearch и 8, а не исходная строка. Правило простое:

ЗадачаНужный типЗапрос
Полнотекстовый поискtextmatch, match_phrase
Точное совпадение (статус, тег)keywordterm, terms
Сортировка по алфавитуkeywordsort
Агрегация по значениюkeywordterms aggregation
Check yourself
У вас поле `category` объявлено с типом `text`. Вы отправляете запрос `term: { "category": "Электроника" }` — возвращается 0 результатов, хотя документы с такой категорией точно есть. В чём причина и как это исправить?

Multi-field: одно поле — два индекса

На практике часто нужно и то и другое: искать полнотекстово и при этом агрегировать точные значения. Для этого в ES есть multi-field — один источник данных индексируется несколькими способами через параметр fields.

Пример: поле title одновременно как text (для поиска) и как title.keyword (для сортировки и агрегаций):

PUT /products
{
  "mappings": {
    "properties": {
      "title": {
        "type": "text",
        "fields": {
          "keyword": {
            "type": "keyword",
            "ignore_above": 256
          }
        }
      }
    }
  }
}

После этого title работает как text, а title.keyword — как keyword. Запрашивают их независимо:

GET /products/_search
{
  "query": {
    "match": { "title": "поиск по тексту" }
  },
  "sort": [{ "title.keyword": "asc" }]
}

ignore_above: 256 означает: если строка длиннее 256 символов, keyword-вариант её игнорирует — защита от случайного индексирования длинных строк.

Dynamic mapping по умолчанию создаёт multi-field для любой строки: поле автоматически получает text + .keyword. Это удобно, но при явном маппинге лучше добавлять keyword только туда, где он реально нужен — экономит место.

Check yourself
Поле `title` настроено как multi-field: основной тип `text`, вложенный — `keyword`. Как правильно указать путь к keyword-версии в параметре `sort`?

Числовые типы

ES поддерживает несколько числовых типов. Выбирайте наименьший достаточный — это экономит RAM и ускоряет агрегации:

ТипДиапазон / точность
byte$-128 \ldots 127$
short$-32\,768 \ldots 32\,767$
integer$\approx \pm 2.1 \times 10^{9}$
long$\approx \pm 9.2 \times 10^{18}$
float32-битное IEEE 754
double64-битное IEEE 754
scaled_floatхранится как long, делится на scaling_factor

scaled_float удобен для денег: цену в рублях с копейками лучше хранить как целое, умноженное на 100 (scaling_factor: 100), — точнее и компактнее, чем float.

PUT /orders
{
  "mappings": {
    "properties": {
      "price":    { "type": "scaled_float", "scaling_factor": 100 },
      "quantity": { "type": "integer" },
      "user_id":  { "type": "long" }
    }
  }
}

Числовые поля работают с запросами range, term/terms и со всеми метрическими агрегациями: avg, sum, min, max.

Даты

Тип date принимает строки в формате ISO 8601 ("2024-06-15T10:30:00Z"), миллисекунды с эпохи UNIX и кастомные форматы. Внутри ES всегда хранит дату как long — миллисекунды с 01.01.1970 UTC; формат нужен только для парсинга при записи.

PUT /events
{
  "mappings": {
    "properties": {
      "created_at": {
        "type": "date",
        "format": "strict_date_optional_time||epoch_millis"
      }
    }
  }
}

Два формата через || означают: принять любой из них. strict_date_optional_time — ISO 8601 с обязательной датой и опциональным временем.

По датам работают range-запросы с date math — встроенными выражениями вроде now-7d/d (семь дней назад, округлить до начала дня):

GET /events/_search
{
  "query": {
    "range": {
      "created_at": {
        "gte": "now-30d/d",
        "lte": "now/d"
      }
    }
  }
}

Объекты и вложенные структуры

ES автоматически «уплощает» вложенные JSON-объекты:

{
  "author": {
    "first_name": "Иван",
    "last_name":  "Петров"
  }
}

В маппинге появятся поля author.first_name и author.last_name — тип object. Для большинства задач этого достаточно. Но если у вас массив объектов и нужно сохранить связи внутри каждого элемента — например, гарантировать, что запрос first_name: Иван AND last_name: Сидоров не найдёт документ с Иваном Петровым и Алексеем Сидоровым, — понадобится тип nested. Это отдельный сценарий, разобранный в Маппинг: явный, динамический и шаблоны индексов.

Как посмотреть маппинг индекса

Чтобы узнать, какие типы назначены полям:

GET /products/_mapping

Ответ — дерево полей с типами. Проверяйте его перед написанием запросов: он сразу объяснит, почему term не находит то, что ожидаешь.

Check yourself
Вам нужно выбрать все заказы, оформленные за последние 30 дней. Какой тип назначить полю `created_at` и что написать в `gte` внутри `range`-запроса?

Quick recall
Какой тип поля выбрать для цены в рублях с копейками и почему?
Quick recall
Зачем нужна multi-field в Elasticsearch?
Quick recall
Почему запрос `term: { "title": "Elasticsearch 8" }` по text-полю не найдёт документ с `title: "Elasticsearch 8"`?

См. также