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:

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 · weights · catalog signals

Why a product is where it is

The trace's rank step lists each hit's signals beside its place. On the sofa page, where nothing is typed, they alone decide:

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(", "))'
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 · signals · why

Boost and bury strengths

A rule's boost or bury weighs a hit by the factor of its strength. strengths sets the factors, and these are the defaults:

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 choose what to boost or bury.

Reference: strengths

The order of sections

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

Terminal
curl -s 'http://127.0.0.1:7800/v1/search?query=sofa&redirect=false' | jq -c '[.sections[].type]'
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 · rule 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 shows how a page offers them.

Reference: 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:

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
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.

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

Terminal
curl -s -X DELETE -H "Authorization: Bearer $key" $api/imports/$draft | jq -r .state
200 · 17 ms
discarded

Reference: PATCH .../imports/{import} · GET .../publication

Next

  • Rules: pin, boost or bury products for the searches you choose.
  • Sorting: the orders a shopper chooses.
  • Releases: how each change goes live.

On this page