Точные совпадения и фильтры: term, range, exists

Запросы без анализа ввода

В предыдущей статье мы разбирали, как match прогоняет строку через анализатор перед поиском. Запросы этой статьи работают иначе: входное значение передаётся буквально — без токенизации, нормализации и стемминга.

Три сценария, где это нужно:

  • keyword-поля — статусы ("published"), категории, теги, ID
  • Числа и диапазоны — цены, количества, рейтинги
  • Даты и временны́е окна — логи, заказы, события

Эти запросы логично помещать в filter context (внутри bool.filter): ES кеширует результаты и не тратит ресурсы на вычисление _score. О разнице между query и filter context — в Query DSL: структура запроса и контекст query vs filter.

term: точное совпадение одного значения

GET /products/_search
{
  "query": {
    "term": {
      "status": "published"
    }
  }
}

ES ищет документы, где status содержит в точности строку "published". Никакого lower-case, никакой морфологии — значение проверяется «как есть». Поле status должно быть типа keyword.

Расширенный синтаксис, когда нужен буст:

GET /products/_search
{
  "query": {
    "term": {
      "category": {
        "value": "electronics",
        "boost": 1.5
      }
    }
  }
}

#### terms: несколько значений (аналог SQL IN)

GET /products/_search
{
  "query": {
    "terms": {
      "status": ["published", "featured", "archived"]
    }
  }
}

Документ совпадёт, если поле содержит хотя бы одно из перечисленных значений.

Классическая ловушка: term по text-полю

Это самая распространённая ошибка при знакомстве с ES. Разберём на конкретном примере.

В индексе лежит документ:

PUT /articles/_doc/1
{
  "title": "Elasticsearch Полнотекстовый поиск"
}

При индексации анализатор standard разбил строку на токены и привёл их к нижнему регистру. В инвертированном индексе хранится: elasticsearch, полнотекстовый, поиск.

Теперь запрос:

GET /articles/_search
{
  "query": {
    "term": {
      "title": "Elasticsearch"
    }
  }
}

Результат: 0 документов. Запрос term ищет строку "Elasticsearch" (с заглавной E) буквально, а в индексе лежит "elasticsearch" (строчная). У match здесь проблемы бы не было — он сам прогоняет ввод через анализатор и получает токен "elasticsearch".

flowchart TD STORE[("Инвертированный индекс: токены elasticsearch, поиск")] Q1["match: 'Elasticsearch Поиск'"] --> AN["Анализатор standard → elasticsearch, поиск"] AN -->|"✅ найдено"| STORE Q2["term: 'Elasticsearch'"] --> NO["Без анализатора → 'Elasticsearch' буквально"] NO -->|"❌ нет совпадения"| STORE
flowchart TD
    STORE[("Инвертированный индекс: токены elasticsearch, поиск")]
    Q1["match: 'Elasticsearch Поиск'"] --> AN["Анализатор standard → elasticsearch, поиск"]
    AN -->|"✅ найдено"| STORE
    Q2["term: 'Elasticsearch'"] --> NO["Без анализатора → 'Elasticsearch' буквально"]
    NO -->|"❌ нет совпадения"| STORE
match анализирует ввод и находит токен; term ищет буквально — на text-полях это приводит к разным результатам

Правило без исключений: для text-полей — match; для keyword-полей — term. Проверить тип поля: GET /my_index/_mapping. О типах подробнее — в Типы полей: text, keyword, числа и даты.

Check yourself
Поле `title` в индексе — тип `text`, анализатор `standard`. Документ: `{ "title": "Quick Brown Fox" }`. Что вернёт запрос `term: { "title": "Quick" }`? А `term: { "title": "quick" }`?

range: диапазоны по числам и датам

Четыре параметра задают границы интервала:

ПараметрСмысл
gtстрого больше ($>$)
gteбольше или равно ($\geq$)
ltстрого меньше ($<$)
lteменьше или равно ($\leq$)

#### Числовой диапазон

GET /products/_search
{
  "query": {
    "range": {
      "price": {
        "gte": 1000,
        "lte": 5000
      }
    }
  }
}

Найдёт продукты с ценой $1000 \leq \text{price} \leq 5000$.

#### Диапазоны по датам

ES принимает ISO 8601 и удобные относительные выражения:

GET /logs/_search
{
  "query": {
    "range": {
      "timestamp": {
        "gte": "now-7d/d",
        "lte": "now/d"
      }
    }
  }
}

now-7d/d — текущий момент минус 7 дней, округлённый до начала суток. Округление (/d, /h, /M) критично для кеширования: точная метка now меняется каждую секунду и не кешируется. С округлением фильтр стабилен в течение суток и хорошо ложится в кеш.

Фиксированный диапазон с явным форматом:

GET /orders/_search
{
  "query": {
    "range": {
      "created_at": {
        "gte": "2024-01-01",
        "lt": "2024-02-01",
        "format": "yyyy-MM-dd"
      }
    }
  }
}

format нужен только если формат строки в запросе отличается от формата, заданного в маппинге.

Check yourself
Запрос `range` по полю `age` с параметрами `"gte": 18, "lt": 65`. Попадут ли документы с `age: 18`? С `age: 65`? С `age: 64`?

exists: есть ли поле в документе

GET /products/_search
{
  "query": {
    "exists": {
      "field": "description"
    }
  }
}

Вернёт документы, у которых поле description присутствует и не равно null. Для поиска документов без этого поля — оберните в must_not:

GET /products/_search
{
  "query": {
    "bool": {
      "must_not": {
        "exists": {
          "field": "description"
        }
      }
    }
  }
}

Аналог WHERE description IS NULL в SQL. Подробнее о bool-запросе — в Составные запросы: bool и комбинирование условий.

Тонкость: пустая строка "" для keyword-поля — это существующее значение, exists её найдёт. Поле считается отсутствующим, если его нет в JSON-документе совсем, оно явно равно null, или массив целиком состоит из null.

Всё вместе: фильтры в bool.filter

На практике эти запросы редко стоят самостоятельно. Классический паттерн — полнотекстовый поиск в must плюс фильтры в filter:

GET /products/_search
{
  "query": {
    "bool": {
      "must": [
        { "match": { "title": "ноутбук" } }
      ],
      "filter": [
        { "term": { "status": "published" } },
        { "range": { "price": { "gte": 30000, "lte": 150000 } } },
        { "exists": { "field": "image_url" } }
      ]
    }
  }
}

match в must вычисляет _score и ранжирует результаты. Три фильтра в filter работают в filter context — кешируются и не влияют на релевантность. Это стандартная архитектура поискового запроса в Elasticsearch.

Quick recall
Почему запрос `term: {title: 'Elasticsearch'}` не нашёл документ с 'Elasticsearch Полнотекстовый поиск'?
Quick recall
На каком типе поля работает term запрос?
Quick recall
Чем отличается match от term в способе обработки входного значения?

См. также