# Ranking

How OrbSearch orders the products that match equally well, by a business score from your catalog's sales, ratings and dates, and how to read why a product stands where it does.

Relevance comes first: a product that holds more of the query's words, with fewer typos, ranks higher, and a sort the shopper chose comes next. Among the hits still tied, and on a category page where every product matches, the business score decides. `ranking.json` builds it from the catalog's signals, and the home store weighs sales most:

```json title="tests/fixtures/home/ranking.json (trimmed)"
{
    "signals": [
        { "code": "sales_30d", "direction": "higher_is_better", "normalize": "percentile" },
        {
            "code": "rating",
            "direction": "higher_is_better",
            "normalize": { "damped": { "count_signal": "rating_count", "prior_count": 10 } }
        },
        { "code": "released_at", "direction": "newer_is_better", "normalize": { "half_life_days": 180 } }
    ],
    "weights": { "product": { "sales_30d": 0.5, "rating": 0.3, "released_at": 0.2 } }
}
```

- `sales_30d` counts by its percentile in the catalog, so the best seller scores close to 1.
- `rating` is pulled towards the catalog's mean while few votes back it: with 10 votes in `rating_count`, it counts half its own and half the mean.
- `released_at` loses half its score every 180 days, so a new product starts near 1.
- `weights` mix them per type into one weighted mean from 0 to 1, half of it sales here.

Each product brings its signals in the catalog, such as `"sales_30d": 61` for `SOFA-ASKA-2`. A signal a product lacks counts 0, and a rating without votes counts the mean.

**Reference:** [`signals`](/docs/reference/release-files/ranking#signals) · [`weights`](/docs/reference/release-files/ranking#weights) · [catalog `signals`](/docs/reference/catalog/entity#signals)

## Why a product is where it is

The [trace](/docs/reference/glossary#trace)'s `rank` step lists each hit's signals beside its place. On the sofa page, where nothing is typed, they alone decide:

```sh title="Terminal"
curl -s 'http://127.0.0.1:7800/v1/search?on=category_page&category=wohnen/sofas&debug=1' \
  | jq -r '.trace.steps[] | select(.step == "rank") | .sections[] | select(.type == "product") | .hits[]
      | "\(.entity)  " + ([.signals | sort_by(.signal)[] | "\(.signal) \(.value)"] | join(", "))'
```

```text title="Answer: 200 · 12 ms · release b25a6aaa"
product:SOFA-ASKA-2  rating 4.3, released_at 2025-09-01, sales_30d 61
product:SOFA-LUND-3  rating 4.6, released_at 2026-02-15, sales_30d 37
product:SOFA-NIKO-SCHLAF  rating 4.2, released_at 2026-06-08, sales_30d 15
product:SOFA-VIDAR-ECK  rating 4.1, released_at 2026-05-20, sales_30d 12
```

Aska sold the most, so it leads Lund, which rates higher and is newer. Each hit's `why` says it in a sentence: its business score and the signal that adds the most. In the panel's **Playground**, **Why here?** under each product gives the same reasons.

**Reference:** [`rank`](/docs/reference/trace#rank) · [`signals`](/docs/reference/trace#rank.sections.hits.signals) · [`why`](/docs/reference/trace#rank.sections.hits.why)

## Boost and bury strengths

A rule's [boost or bury](/docs/reference/glossary#boost-and-bury) weighs a hit by the factor of its strength. `strengths` sets the factors, and these are the defaults:

```json title="ranking.json"
{
    "strengths": {
        "boost": { "slight": 1.5, "medium": 2, "strong": 4 },
        "bury": { "slight": 0.67, "medium": 0.5, "strong": 0.25 }
    }
}
```

The hit's `why` names the factor it got, such as "The rule “Out-of-stock products last” buries it, weighing it ×0.25." [Rules](/docs/rules#effects) choose what to boost or bury.

**Reference:** [`strengths`](/docs/reference/release-files/ranking#strengths)

## The order of sections

A search answers in sections, one per type, and `sections` puts them in order:

```sh title="Terminal"
curl -s 'http://127.0.0.1:7800/v1/search?query=sofa&redirect=false' | jq -c '[.sections[].type]'
```

```json title="Answer: 200 · 18 ms · release b25a6aaa"
["category","product","content","brand"]
```

The home store writes `"sections": ["category", "product", "content"]`. Brands, which it leaves out, follow in the order of `types.json`. A rule's `sections` effect reorders them for the requests it matches, as `home/guides-for-questions` would put guides first for a question.

**Reference:** [`sections`](/docs/reference/release-files/ranking#sections) · [rule `sections`](/docs/reference/release-files/rules#then.sections)

## Sorts

`ranking.json` also defines the orders a shopper can choose, such as `price_asc`, and a chosen order comes before the business score. [Sorting](/docs/sorting) shows how a page offers them.

**Reference:** [`sorts`](/docs/reference/release-files/ranking#sorts)

## How a change goes live

The business score is computed when the indexes are built, so a change to the signals or weights rebuilds them. Change the weights in a draft, and ask how it would go live:

```sh title="Terminal"
draft=$(curl -s -X POST -H "Authorization: Bearer $key" -H 'Content-Type: application/x-ndjson' \
  --data-binary @tests/fixtures/home/catalog.jsonl "$api/imports?name=catalog.jsonl" | jq -r .import.id)
version=$(curl -s -H "Authorization: Bearer $key" $api/imports/$draft | jq -r .version)
jq --arg version "$version" '{version: $version, files: {"ranking.json": (.weights.product.rating = 0.5
    | .weights.product.sales_30d = 0.3)}}' tests/fixtures/home/ranking.json \
  | curl -s -X PATCH -H "Authorization: Bearer $key" -H 'Content-Type: application/json' -d @- $api/imports/$draft >/dev/null
curl -s -H "Authorization: Bearer $key" $api/imports/$draft/publication | jq -r .course.sentence
```

```text title="Answer: 200 · 25 ms"
Rebuilds the search: ranking.json changed what its documents are built from.
```

New indexes are built beside the live ones, and the shop answers with the live release meanwhile. A change to the strengths, the sections or the labels of sorts goes live at once.

<Callout type="warn">
    A draft's preview searches the live index, whose scores the live weights made. A new weight shows once the draft is
    live.
</Callout>

Publish the draft with `POST $api/imports/$draft/publish`, or discard it:

```sh title="Terminal"
curl -s -X DELETE -H "Authorization: Bearer $key" $api/imports/$draft | jq -r .state
```

```text title="Answer: 200 · 17 ms"
discarded
```

**Reference:** [`PATCH .../imports/{import}`](/docs/reference/management-api#change-import) · [`GET .../publication`](/docs/reference/management-api#show-import-publication)

## Next

- [Rules](/docs/rules): pin, boost or bury products for the searches you choose.
- [Sorting](/docs/sorting): the orders a shopper chooses.
- [Releases](/docs/releases): how each change goes live.
