Feature
Sorting & pagination
By default results come back ranked by score. sort replaces that
order with your own, from pages through it by offset, and
search_after pages through it by cursor for depths where an offset
stops being affordable.
Default order
With no sort, hits are ordered by BM25 score descending, ties
broken by document id ascending. The tie-break is not cosmetic — BM25 scores
repeat often, and a deterministic tie-break is what makes paging stable across
requests.
Sorting
sort is an array of single-key objects, evaluated in order, in the
same shape OpenSearch uses.
{
"query_text": "breach",
"sort": [
{"status": {"order": "asc"}},
{"_score": {"order": "desc"}}
]
}
Two field names are special:
- _score numeric
- The relevance score, compared numerically. Use it as a tie-break after a field sort to keep relevance meaningful within each group.
- _id string
- The document id, compared as a string. Always fully ordered, which makes it the natural final tie-break.
Any other name is read from the segment's columnar store — the same store filters use — so sorting adds no extra disk reads. Whichever clauses you list, the final tie-break is always document id ascending, so the total order is deterministic.
Custom field sorts compare as strings
Values from Integer and Float columns are converted
to text before comparison, so 10 sorts before 9 and
-1 sorts before 0. Sort numerically only on
zero-padded values you control ("0009", "0010"), or
use _score. ISO-8601 dates sort correctly as text, which is what
makes them safe here.
Offset paging
- max_results int · default 10
- Page size.
0returns no documents, which is what you want when only aggregations ortotal_hitsmatter. - from int · default 0
- How many leading results to skip.
{"query_text": "breach", "from": 40, "max_results": 20}
Each segment keeps its best from + max_results candidates and the
rest are discarded before merging, so a deep offset makes every segment carry a
proportionally larger candidate list. Offsets in the low thousands are fine.
Past that, use a cursor.
Cursor paging with search_after
search_after takes the sort values of the last result you saw and
returns what comes strictly after it. There is no offset to grow, so page 500
costs what page 2 costs.
{
"query_text": "breach",
"max_results": 20,
"sort": [{"_id": {"order": "asc"}}]
}
{
"query_text": "breach",
"max_results": 20,
"sort": [{"_id": {"order": "asc"}}],
"search_after": ["doc-01840"]
}
The array's values line up positionally with your sort clauses.
With no sort, the cursor is interpreted against document id.
Values are always strings, and they are compared as strings — which is why a
cursor over _id or a Keyword field is reliable, and a
cursor over _score is not: scores are stringified for the cursor
comparison and do not order lexicographically.
Choosing a cursor field
_id— always works, always unique, always fully ordered. Start here.- A
Keywordor ISO-8601Datefield, with_idas a second clause to break ties. - Avoid
_scoreas the cursor field, for the string-comparison reason above.
What paging costs
A plain score-ordered query can stop early: each segment keeps a bounded top-k
and prunes postings whose best possible score cannot reach it. Adding a
sort or a search_after turns that off — the page
order no longer follows score, so no candidate can be discarded early without
risking the one that belongs on the page.
Sorted and cursored queries are therefore materially more expensive than the default ranking over the same matches. The lever is the same as everywhere else in Kosha: narrow the candidate set with a filter, which prunes whole segments before any of this work starts.
Paging and consistency
Each search resolves the namespace's manifest once and executes entirely against that version, so a single request always sees a coherent snapshot. Separate requests do not: a flush between page 1 and page 2 publishes a new manifest, and page 2 runs against it.
A cursor degrades more gracefully than an offset here. Newly indexed documents that sort before your cursor simply do not appear, whereas an offset shifts every subsequent page. For a paging session that must be strictly stable, page against a namespace you are not concurrently writing to.