Подсветка, сортировка и пагинация результатов

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

Подсветка совпадений: highlight

highlight — просьба к ES: «покажи фрагменты полей, где нашлись совпадения, и обозначь их тегами». По умолчанию ES оборачивает совпавшие токены в <em>…</em>:

GET /articles/_search
{
  "query": {
    "match": { "body": "elasticsearch поиск" }
  },
  "highlight": {
    "fields": {
      "body": {}
    }
  }
}

В ответе рядом с каждым документом появится секция highlight:

"highlight": {
  "body": [
    "Технология <em>поиска</em> в <em>Elasticsearch</em> позволяет строить..."
  ]
}

ES сам выбирает наиболее релевантный фрагмент — по умолчанию длиной 100 символов; если совпадений несколько, вернёт до 5 фрагментов.

Полезные настройки:

"highlight": {
  "pre_tags":  ["<mark>"],
  "post_tags": ["</mark>"],
  "fields": {
    "body": {
      "number_of_fragments": 2,
      "fragment_size": 200
    }
  }
}
  • pre_tags / post_tags — кастомные теги (например, <mark> для HTML5 вместо <em>)
  • number_of_fragments: 0 — вернуть поле целиком, не разбивая на куски
  • fragment_size — длина одного фрагмента в символах

Highlight работает только с полями типа text. Поле keyword хранится без анализа — подсвечивать нечего.

Проверь себя
Ваш фронтенд ожидает теги `<mark>…</mark>` вокруг совпавших слов, но ES возвращает `<em>…</em>`. Какие параметры в секции `highlight` нужно изменить и как?

Сортировка: sort

По умолчанию ES сортирует по убыванию _score — самые релевантные документы первыми. Поведение меняется через секцию sort.

Сортировка по одному полю:

GET /products/_search
{
  "query": { "match": { "title": "ноутбук" } },
  "sort": [
    { "price": { "order": "asc" } }
  ]
}

Ноутбуки выстроятся от дешёвых к дорогим.

Несколько уровней сортировки:

"sort": [
  { "category": { "order": "asc" } },
  { "price":    { "order": "desc" } },
  "_score"
]

Сначала сортировка по категории, внутри категории — по убыванию цены, при равной цене — по релевантности. Это стандартный паттерн для каталогов.

Важно: сортировать строки можно только по полю типа keyword. Поле text разбито на токены и для сортировки не подходит. Если у вас multi-field (title + title.keyword), используйте .keyword-вариант:

"sort": [{ "title.keyword": { "order": "asc" } }]

О разнице между text и keyword — в статье Типы полей: text, keyword, числа и даты.

Проверь себя
Поле `name` имеет тип `text`. Вы пишете `"sort": [{"name": {"order": "asc"}}]`. ES вернёт ошибку или некорректный результат. Почему и как это исправить?

Пагинация from/size

Самый простой способ постраничной выдачи — параметры from и size:

GET /products/_search
{
  "from": 0,
  "size": 10,
  "query": { "match_all": {} }
}

from — смещение от начала (0-based), size — количество документов. По умолчанию from: 0, size: 10. Вторая страница: from: 10, третья: from: 20.

Жёсткий лимит. ES не позволит заглянуть глубже позиции 10 000:

$$\text{from} + \text{size} \leq 10000$$

Нарушение этого условия возвращает ошибку Result window is too large. Лимит задан настройкой индекса index.max_result_window (по умолчанию 10 000). Поднять его технически можно, но не стоит: чтобы выдать страницу на позиции $N$, ES вынужден поднять и отсортировать $N$ документов на каждом шарде, а потом объединить — расходы растут линейно с глубиной.

Проблема консистентности. Если между двумя запросами в индекс добавили новые документы, следующая страница from/size может содержать дубликаты или пропуски. Для обычного каталога это терпимо; для надёжного обхода всего индекса — нет.

Проверь себя
Запрос: `from: 9500, size: 600`. Что вернёт ES и почему?

Глубокая пагинация: search_after и PIT

Для пагинации за пределами 10 000 документов и надёжного обхода индекса используют search_after в паре с Point In Time (PIT).

Идея: вместо «пропусти первые $N$ документов» ES держит курсор — значения ключей сортировки последнего виденного документа. Следующий запрос получает документы, чьи ключи строго больше курсора. Глубина страницы не влияет на производительность.

Шаг 1 — открыть PIT:

POST /products/_pit?keep_alive=1m
{ "id": "46ToAwMDaWR5BXV1a..." }

PIT фиксирует «снимок» состояния индекса. Все последующие страницы видят одни и те же данные — даже если в индекс что-то добавили или удалили пока шла итерация.

Шаг 2 — первая страница:

GET /_search
{
  "size": 10,
  "query": { "match": { "title": "ноутбук" } },
  "sort": [
    { "price": { "order": "asc" } },
    { "_id":   { "order": "asc" } }
  ],
  "pit": {
    "id":         "46ToAwMDaWR5BXV1a...",
    "keep_alive": "1m"
  }
}

Запрос идёт на /_search без имени индекса — индекс закодирован внутри PIT. В sort обязателен уникальный tiebreaker (_id — стандартный выбор): без него при совпадении ключей порядок нестабилен и страницы будут «съезжать».

Из ответа берём значение поля sort у последнего документа в hits.hits:

"sort": [45990, "doc-abc123"]

Шаг 3 — следующие страницы:

GET /_search
{
  "size": 10,
  "query": { "match": { "title": "ноутбук" } },
  "sort": [
    { "price": { "order": "asc" } },
    { "_id":   { "order": "asc" } }
  ],
  "pit": {
    "id":         "46ToAwMDaWR5BXV1a...",
    "keep_alive": "1m"
  },
  "search_after": [45990, "doc-abc123"]
}

search_after принимает массив значений в том же порядке, что и sort. После каждой страницы обновляйте search_after значениями из последнего документа. Когда ES вернул меньше size документов — страницы закончились.

Шаг 4 — закрыть PIT:

DELETE /_pit
{
  "id": "46ToAwMDaWR5BXV1a..."
}

PIT держит ресурсы на кластере — закрывайте его явно по завершении. Если забыть, он истечёт через keep_alive сам, но явное закрытие — хороший тон.

sequenceDiagram participant C as Клиент participant ES as Elasticsearch C->>ES: POST /products/_pit?keep_alive=1m ES-->>C: {id: "46ToAwMD..."} C->>ES: GET /_search (pit_id, sort, size=10) ES-->>C: Страница 1 — sort последнего: [45990, "abc"] C->>ES: GET /_search (search_after=[45990,"abc"]) ES-->>C: Страница 2 — sort последнего: [67000, "xyz"] Note over C,ES: Повторять, пока hits.length < size C->>ES: DELETE /_pit {id: "46ToAwMD..."} ES-->>C: {succeeded: true}
sequenceDiagram
    participant C as Клиент
    participant ES as Elasticsearch
    C->>ES: POST /products/_pit?keep_alive=1m
    ES-->>C: {id: "46ToAwMD..."}
    C->>ES: GET /_search (pit_id, sort, size=10)
    ES-->>C: Страница 1 — sort последнего: [45990, "abc"]
    C->>ES: GET /_search (search_after=[45990,"abc"])
    ES-->>C: Страница 2 — sort последнего: [67000, "xyz"]
    Note over C,ES: Повторять, пока hits.length < size
    C->>ES: DELETE /_pit {id: "46ToAwMD..."}
    ES-->>C: {succeeded: true}
Полный цикл пагинации через search_after + PIT

Когда что использовать

СценарийМеханизм
Поиск по каталогу, не глубже ~1000 записейfrom / size
Поиск глубже 10 000 записейsearch_after + PIT
Бесконечная прокрутка (infinite scroll)search_after + PIT
Полный экспорт данных из индексаsearch_after + PIT

Scroll API — старый способ глубокой пагинации в ES — официально помечен как устаревший в пользу search_after + PIT. В новых проектах его лучше не начинать.

Быстрое повторение
Какой жёсткий лимит есть для пагинации `from` и `size`?
Быстрое повторение
Какой тип поля нужен для сортировки строк в Elasticsearch?
Быстрое повторение
На какие типы полей работает `highlight`?

См. также