Сортировка и пагинация: from/size и search_after

Учимся листать результаты поиска и сортировать их, а главное — обходить проблему глубокой пагинации.

Пагинация — постраничный вывод результатов; в ES базовый способ — параметры from и size, но у него есть предел.

from и size

По умолчанию _search возвращает 10 самых релевантных документов. Чтобы получить другую страницу, задают from (сколько пропустить) и size (сколько вернуть).

{
  "from": 20,
  "size": 10,
  "query": { "match": { "title": "ноутбук" } }
}

Это третья страница по 10 элементов (пропустить 20, взять 10). Просто и удобно — для первых страниц.

Сортировка

По умолчанию документы идут по _score (релевантность). Можно отсортировать по полю:

{
  "sort": [ { "price": "asc" } ],
  "query": { "match": { "title": "ноутбук" } }
}

Внимание: при сортировке по полю релевантность не считается, и _score будет null. Сортировать можно по keyword, числам, датам — но не по анализируемому text.

Проблема глубокой пагинации

Запрос «дай страницу 1000 по 10» (from: 9990) выглядит безобидно, но дорог. Чтобы вернуть документы с 9991 по 10000, координатор должен собрать с каждого шарда топ-10000, всё пересортировать и выбросить первые 9990. Чем глубже страница, тем больше данных гоняется и сортируется впустую. Поэтому ES по умолчанию запрещает from + size > 10000 — это защита от тяжёлых запросов.

  from=9990, size=10  -->  каждый шард шлёт топ-10000
                           координатор сортирует 10000*N
                           отдаёт 10 штук, остальное в мусор

Решение: search_after

Для глубокого листания (выгрузки, бесконечной ленты) используют search_after. Идея: вместо «пропусти N» — «дай то, что идёт после вот этого документа». Сортируем по уникальному ключу и передаём значения сортировки последнего показанного документа.

{
  "size": 10,
  "sort": [ { "price": "asc" }, { "_id": "asc" } ],
  "search_after": [ 24990, "product-42" ],
  "query": { "match_all": {} }
}

Так каждая «страница» — это лёгкий запрос с курсором, без перебора всего, что до неё. Цена не растёт с глубиной.

Как работает под капотом

from/size заставляет каждый шард отдавать from + size кандидатов, чтобы координатор гарантированно собрал правильный топ после слияния — отсюда квадратичный рост стоимости с глубиной. search_after же сразу говорит шардам «начни после этого ключа сортировки», и каждый шард отдаёт только следующие size документов. Для стабильности обязательно добавлять в сортировку уникальный тай-брейкер (например, _id), иначе документы с одинаковым значением ключа могут потеряться или продублироваться.

Частые ошибки

  • Глубокий from для выгрузок. Листать тысячами страниц через from — путь к таймаутам. Для полной выгрузки или глубоких страниц — search_after.
  • search_after без уникального ключа. Без тай-брейкера в сортировке курсор неустойчив — добавляйте _id последним полем сортировки.
  • Сортировка по text-полю. Анализируемое поле не сортируется по значению; используйте keyword-версию.

Итоги

  • from/size — простая пагинация для первых страниц; from + size ограничен (по умолчанию 10000).
  • Глубокая пагинация через from дорога: координатор собирает и сортирует всё до нужной страницы.
  • Для глубокого листания и выгрузок — search_after с уникальным тай-брейкером в сортировке.
Проверьте себя
1. Почему запрос с большим from (глубокая пагинация) дорог?
AПотому что ES не умеет пропускать документы
BПотому что каждый шард должен отдать from+size кандидатов, а координатор — всё пересортировать и выбросить лишнее
CПотому что from блокирует индекс
DПотому что size не может быть больше 10
2. Что нужно добавить в сортировку при использовании search_after для устойчивости?
AТолько поле _score
BУникальный тай-брейкер, например _id, последним полем сортировки
CНичего не нужно
DПараметр from