Kosha docs
Documentation menu

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.

POST /v1/namespaces/{namespace}/search
{
  "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. 0 returns no documents, which is what you want when only aggregations or total_hits matter.
from int · default 0
How many leading results to skip.
page 3, twenty per page
{"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.

first page
{
  "query_text": "breach",
  "max_results": 20,
  "sort": [{"_id": {"order": "asc"}}]
}
next page — cursor is the last hit's sort values
{
  "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 Keyword or ISO-8601 Date field, with _id as a second clause to break ties.
  • Avoid _score as 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.