← назад к разделу

Когда вы вводите «шоколадные конфеты» в поиск и видите список товаров — Elasticsearch не просто ищет эти два слова. Он вычисляет, насколько каждый документ подходит к запросу, и сортирует результаты по этой оценке. Разберём, как всё это устроено.

Два режима: «насколько подходит» и «подходит или нет»

Самое важное различие в Elasticsearch — запрос может работать в двух режимах:

Полнотекстовый поиск (query context) — Elasticsearch задаёт вопрос «насколько хорошо этот документ отвечает на запрос?» и вычисляет числовую оценку (_score). Документы сортируются по этой оценке — самые релевантные идут первыми.

Точный фильтр (filter context) — вопрос «подходит документ или нет?» Ответ — да или нет, без оценки. Такие фильтры кэшируются, поэтому работают быстрее.

Пример, где оба режима используются вместе:

{
  "query": {
    "bool": {
      "must": [
        { "match": { "name": "шоколад" } }
      ],
      "filter": [
        { "term":  { "in_stock": true } },
        { "range": { "price": { "gte": 50, "lte": 500 } } }
      ]
    }
  }
}

match в must — полнотекстовый поиск, влияет на оценку. Фильтры в filter — точные условия, кэшируются.

Практическое правило: всё, что не для ранжирования, кладите в filter — это быстрее и не искажает оценку релевантности.

Виды запросов

match — поиск по тексту

Самый частый запрос для текстовых полей:

{ "match": { "name": "шоколадные конфеты" } }

Перед поиском текст проходит через анализатор (тот же, что и при индексации): слова разбиваются на термины, удаляются окончания. В результате ищутся документы, где встречается хотя бы один из полученных терминов.

Чтобы потребовать все термины:

{ "match": { "name": { "query": "шоколадные конфеты", "operator": "and" } } }

match_phrase — точная фраза

{ "match_phrase": { "name": "шоколадные конфеты" } }

Термины должны идти в нужном порядке и стоять рядом. Подходит для точных названий. Параметр slop позволяет разрешить небольшой разрыв между словами.

multi_match — поиск сразу по нескольким полям

{
  "multi_match": {
    "query": "шоколад",
    "fields": ["name^3", "description^1", "tags^2"],
    "type": "best_fields"
  }
}

name^3 означает, что совпадение в поле name ценится в три раза дороже, чем в description. Удобно, когда заголовок важнее тела.

Режимы поиска:

  • best_fields — итоговая оценка = максимум оценок по отдельным полям. Подходит, когда слова чаще всего в одном поле.
  • most_fields — сумма оценок. Подходит для случаев, когда одно и то же слово может быть в разных полях.
  • cross_fields — рассматривает все поля как одно. Удобно для имени и фамилии, разнесённых по разным полям.

term и terms — точное значение

{ "term":  { "category_id": 1 } }
{ "terms": { "category_id": [1, 2, 3] } }

term не анализирует текст, поэтому не подходит для текстовых полей — только для чисел, идентификаторов и keyword-полей. На текстовых полях результат будет неожиданным: Elasticsearch будет искать дословное значение в индексе, где хранятся уже обработанные термины.

range — диапазон

{ "range": { "price": { "gte": 50, "lte": 500 } } }
{ "range": { "created_at": { "gte": "now-7d/d", "lte": "now/d" } } }

Работает с числами и датами. Для дат поддерживается математика: now-7d — семь дней назад, /d — округлить до начала дня.

exists — поле заполнено

{ "exists": { "field": "image_url" } }

Находит документы, где поле присутствует и не равно null.

bool — основа всех сложных запросов

bool позволяет комбинировать запросы:

{
  "bool": {
    "must":     [ ... ],   // обязательно, влияет на оценку
    "filter":   [ ... ],   // обязательно, без оценки
    "should":   [ ... ],   // желательно, повышает оценку при совпадении
    "must_not": [ ... ]    // исключить
  }
}

Пример: найти товары с «шоколадом» в названии, в категориях 1 или 2, по цене 50–500, и желательно в наличии:

{
  "query": {
    "bool": {
      "must": [
        { "match": { "name": "шоколад" } }
      ],
      "filter": [
        { "terms": { "category_id": [1, 2] } },
        { "range": { "price": { "gte": 50, "lte": 500 } } }
      ],
      "should": [
        { "term": { "in_stock": true } }
      ]
    }
  }
}

Товары в наличии получат более высокую оценку, но товары не в наличии тоже попадут в результаты.

Как Elasticsearch решает, кто первый: алгоритм BM25

Когда вы ищете «шоколад», почему один документ оказывается выше другого? Elasticsearch использует формулу BM25 (Best Matching 25) — стандартный алгоритм из теории информационного поиска.

Упрощённо, оценка зависит от трёх вещей:

  • Частота термина — слово «шоколад» встречается в документе 5 раз? Это лучше, чем один раз. Но не в 5 раз лучше — отдача убывает.
  • Редкость термина — «шоколад» встречается в 10% всех документов, а «и» — в 99%. Редкое слово несёт больше информации, значит его совпадение ценится дороже.
  • Длина документа — короткое название «Шоколадные конфеты» с совпадением ценится выше, чем длинное описание с тем же словом.

В большинстве случаев настройки BM25 менять не нужно — алгоритм работает хорошо из коробки.

Управление оценкой: поднять важные документы

Иногда нужно, чтобы определённые документы оказывались выше не за счёт текстового совпадения, а по бизнес-логике: «новые товары важнее», «рекомендованные товары в топ».

Простой способ — boost для отдельных запросов:

"must": [
  { "match": { "name":        { "query": "шоколад", "boost": 3 } } },
  { "match": { "description": { "query": "шоколад", "boost": 1 } } }
]

function_score — комбинированное ранжирование

Для более сложной логики есть function_score:

{
  "query": {
    "function_score": {
      "query": { "match": { "name": "шоколад" } },
      "functions": [
        {
          "filter": { "term": { "is_featured": true } },
          "weight": 2.0
        },
        {
          "field_value_factor": {
            "field": "popularity",
            "modifier": "log1p",
            "factor": 0.5
          }
        },
        {
          "gauss": {
            "created_at": {
              "origin": "now",
              "scale": "30d",
              "decay": 0.5
            }
          }
        }
      ],
      "score_mode": "sum",
      "boost_mode": "multiply"
    }
  }
}

Здесь базовая оценка от текстового совпадения умножается на сумму трёх факторов: рекомендованный товар получает вес 2, популярные товары поднимаются через логарифм числа продаж, а свежие товары оцениваются выше через гауссово затухание (через 30 дней оценка падает вдвое).

Начинайте с одного-двух факторов — с function_score легко перемудрить.

Aggregations: фасеты и аналитика

Aggregations (агрегации) — это подсчёт статистики по найденным документам. Именно они строят фасеты каталога: «в каждой категории X товаров», «цена от Y до Z».

Запрос с агрегациями не требует возвращать сами документы — можно поставить size: 0 и получить только статистику:

{
  "query": { "match": { "name": "шоколад" } },
  "size": 0,
  "aggs": {
    "by_category": {
      "terms": { "field": "category_id", "size": 10 }
    },
    "price_ranges": {
      "range": {
        "field": "price",
        "ranges": [
          { "to": 100 },
          { "from": 100, "to": 500 },
          { "from": 500 }
        ]
      }
    }
  }
}

Результат:

{
  "aggregations": {
    "by_category": {
      "buckets": [
        { "key": 1, "doc_count": 50 },
        { "key": 2, "doc_count": 12 }
      ]
    },
    "price_ranges": {
      "buckets": [
        { "key": "*-100",   "doc_count": 30 },
        { "key": "100-500", "doc_count": 25 },
        { "key": "500-*",   "doc_count": 7  }
      ]
    }
  }
}

Агрегации можно вкладывать друг в друга — например, посчитать среднюю цену в каждой категории:

{
  "aggs": {
    "by_category": {
      "terms": { "field": "category_id" },
      "aggs": {
        "avg_price": { "avg": { "field": "price" } }
      }
    }
  }
}

Это один HTTP-запрос вместо нескольких запросов в базу данных.

Собираем всё вместе: запрос каталога

Реальный запрос для каталога товаров с поиском, фильтрами и фасетами:

{
  "from": 0,
  "size": 20,
  "query": {
    "bool": {
      "must": [
        {
          "multi_match": {
            "query": "шоколадные конфеты",
            "fields": ["name^3", "description"],
            "type": "best_fields",
            "fuzziness": "AUTO"
          }
        }
      ],
      "filter": [
        { "terms": { "category_id": [1, 2] } },
        { "range": { "price": { "gte": 50, "lte": 500 } } },
        { "term":  { "in_stock": true } }
      ]
    }
  },
  "sort": [
    { "_score": "desc" },
    { "created_at": "desc" }
  ],
  "aggs": {
    "categories":   { "terms":     { "field": "category_id", "size": 20 } },
    "price_ranges": { "histogram": { "field": "price", "interval": 100 } }
  }
}

fuzziness: AUTO позволяет находить документы даже при опечатках: для длинных слов допускаются отклонения до двух символов.

Пагинация: почему from плохо работает на глубоких страницах

Стандартная пагинация через from и size имеет ограничение. Запрос from: 10000, size: 20 вынуждает Elasticsearch обработать и отсортировать первые 10020 документов на каждом осколке, а потом выбросить 10000 из них. На больших отступах это заметно замедляется.

Для постраничного листания (особенно бесконечной прокрутки) лучше использовать search_after:

{
  "size": 20,
  "query": { "match": { "name": "шоколад" } },
  "sort": [ { "_score": "desc" }, { "_id": "desc" } ],
  "search_after": [0.78, "product-12345"]
}

search_after принимает значения из последнего документа предыдущей страницы и продолжает список с этого места. Прыгнуть сразу на страницу 50 нельзя — зато нет проблем с производительностью.

Коротко

  • Запросы работают в двух режимах: query context вычисляет оценку релевантности, filter context просто фильтрует без оценки. Фильтры кэшируются и быстрее.
  • Основные типы запросов: match для текста, term/terms для точных значений, range для диапазонов.
  • bool объединяет запросы через must, filter, should, must_not.
  • Оценку релевантности считает BM25 — учитывает частоту слова в документе, редкость слова в индексе и длину документа.
  • Поднять нужные документы можно через boost или function_score (для сложной логики).
  • Aggregations считают статистику по найденным документам — используются для фасетов каталога.
  • Для глубокой пагинации используйте search_after вместо большого from.

Что почитать дальше

  • Fundamentals — как устроен индекс и как документы попадают в поиск.
  • Клиенты и интеграция — как отправлять эти запросы из Java-приложения.
  • Operations — производительность и управление индексами.