Подсветка, сортировка и пагинация результатов
Запрос нашёл нужные документы и ранжировал их — теперь нужно отдать пользователю правильный срез выдачи и показать, почему именно эти документы совпали. За это отвечают три механизма: 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 хранится без анализа — подсвечивать нечего.
Сортировка: 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, числа и даты.
Пагинация 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:
Нарушение этого условия возвращает ошибку Result window is too large. Лимит задан настройкой индекса index.max_result_window (по умолчанию 10 000). Поднять его технически можно, но не стоит: чтобы выдать страницу на позиции $N$, ES вынужден поднять и отсортировать $N$ документов на каждом шарде, а потом объединить — расходы растут линейно с глубиной.
Проблема консистентности. Если между двумя запросами в индекс добавили новые документы, следующая страница from/size может содержать дубликаты или пропуски. Для обычного каталога это терпимо; для надёжного обхода всего индекса — нет.
Глубокая пагинация: 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}Когда что использовать
| Сценарий | Механизм |
|---|---|
| Поиск по каталогу, не глубже ~1000 записей | from / size |
| Поиск глубже 10 000 записей | search_after + PIT |
| Бесконечная прокрутка (infinite scroll) | search_after + PIT |
| Полный экспорт данных из индекса | search_after + PIT |
Scroll API — старый способ глубокой пагинации в ES — официально помечен как устаревший в пользу search_after + PIT. В новых проектах его лучше не начинать.