Skip to content

Ranking by What Sells

Under the default Featured sort, products that match a search equally well (and every product of a category being browsed) normally go in stock first, then by units sold. Ranking by what sells replaces units sold with a learned score for the listing's goal: how much showing the product in one slot of the listing is expected to earn, from how shoppers have responded to it (Product Engagement). It is Phase 1 of the plan in docs/implementation-plan/commerce-search-optimisation.md.

Pins, boosts and buries still come first (Search Rules), a much better text match still beats a best seller, and a sort the shopper picks (price, newest, best selling) is exactly that.

When it applies

All of these:

  • the site's subscription includes it (Enterprise, merchandisingRules; while entitlement enforcement is off, every site is entitled);
  • the site has at least search-ranking-min-monthly-orders sold orders (placed and paid, not cancelled) in the last 30 days, a global setting, default 1,000: below that, each product's rates are too noisy to rank on;
  • search-learned-ranking-enabled is on (Search settings, Rank by What Sells, default on).

The first two are checked by the nightly job (below); a catalogue several sites sell from qualifies when any of them does. Until a site qualifies, nothing changes, and Search > Ranking goals and the Product engagement page say why.

Goals

Goal Score is the expected… per time a product is shown Rewards
units units sold volume, clearing stock
conversion orders what shoppers buy once they see it
revenue (default) revenue what a listing slot earns
margin gross margin what a slot earns the shop, from cost prices: a product without one counts at the site's margin rate, and a site without any ranks as for revenue

Which goal a listing uses:

  1. A running schedule (dates, inclusive, in the site's time zone): one limited to categories beats one for everywhere, and the most specific category wins. "January sale, Clothing: units".
  2. The listing's category's own goal, or the nearest category above it with one.
  3. The site default, search-ranking-objective.

A search (/search) has no category, so only schedules for everywhere and the site default apply; a search inside a category goes by that category. Changing a goal takes effect within a minute, with no reindex: every goal's score is already in the index.

The score

LearnedRankScorer (commerce-core, search.learned), per product, from the last 90 days of engagement:

  1. Recent days count more: each day weighs 0.5^(age / half-life), half-life search-ranking-half-life-days (default 14).
  2. Position bias is taken out. Shoppers click the first few products far more whatever they are, so ranking on raw click-through would keep whatever was already first. The site's own click curve (click-through per position band: 1 to 4, 5 to 12, 13 to 24, 25 to 48, 49 and below, each shrunk towards the overall rate with 100 impressions) gives the clicks a product's impressions would normally earn where they were shown; its click ratio is its clicks over those expected clicks, 1 being average for where it was shown. A product clicked 6% far down a listing beats one clicked 12% in the top four when the top four are clicked 20% on average.
  3. Small numbers are shrunk towards a baseline (pseudo-counts): the product's category (the category listing it is shown in most), and the category towards the site. Click ratio (clicks + 200 impressions' worth of expected clicks × baseline) / (expected clicks + that), conversion (orders + 50 × baseline) / (views + 50), order value and units per order (sum + 3 × baseline) / (orders + 3). One lucky sale doesn't make a star, and a new product in a category that sells well starts higher than one in a category that doesn't.
  4. Expected result per impression: the site's click-through × click ratio × conversion, times units per order for units, order value for revenue, and order value × margin rate for margin. The margin rate is margin / marginRevenue (the revenue whose cost was known), smoothed the same way with three average orders' worth of revenue; a loss scores 0.
  5. Exploration: up to 25% extra for products with few impressions (1 + 0.25 × 200 / (impressions + 200)), fading as they are shown, so new products get the impressions they need to prove themselves.
  6. Relative: divided by what a product with exactly the site's average rates would score, times 1,000, capped at 1,000,000. So 1,000 is average, and a product with no evidence in an average category scores 1,250 (ProductRankScore.UNSEEN).

Scores are ordinal and relative, so the index never holds anything a cost price could be worked back from. Without position bands (engagement recorded before them) a product's expected clicks are its impressions at the overall rate, and without category listings every product is baselined on the site: both give the plain smoothed rates.

The same computation also judges products within single listings, for learned boosts.

How it works

  • Nightly job. LearnedRankingJob (worker, 03:00, badger.search.learned-ranking.cron; once at startup after the popularity refresh, badger.search.learned-ranking.on-startup; off with badger.search.learned-ranking.enabled=false), per catalogue: check eligibility (ProductEngagementService.eligibility for each site selling from it), then ProductRankScoreService.compute streams the sites' daily all buckets (ProductEngagementService.scanDays, every context in one pass: all for the scores, the category listings for each product's baseline, and every listing for learned boosts), matches SKUs to products 500 at a time and scores them (LearnedComputation). Only products whose scores changed are pushed (SearchIndexer.pushRankScores: partial updates, 500 a batch, to the live collection and any being rebuilt), and a product with no engagement left goes back to unseen. Accepted scores are stored in productRankScores (with the smoothed rates, for the admin); a refused batch is retried the next night. The catalogue's LearnedRankingState (learnedRankingStates: eligible, reason, products scored) is what the query layer reads.
  • Index. Four int32 fields, rankUnits, rankConversion, rankRevenue, rankMargin (index revision 9, so every site rebuilds once after the deploy). Full builds and single-product upserts read the stored scores; a product without one is indexed as unseen.
  • Query. MerchandisingQueryService asks LearnedRankingService.forListing (the site switch, the catalogue's state cached for five minutes, and RankingGoalService.resolve, cached per catalogue for a minute). RankingSort.featured then sorts:
no ranking:   _text_match(buckets: 10):desc,
              _eval([(inStock:true && reviewCount:>=3 && rating:>=80):2,(inStock:true):1]):desc,
              rankRevenue:desc
with ranking: _eval([...pins, boosts, learned boosts, buries...]):desc,_text_match(buckets: 10):desc,rankRevenue:desc

Stock and the review lift (well-reviewed products first among those in stock) work as in plain Featured; only the tie-break changes from units sold to the goal's score.

_text_match(buckets: N) splits a search's results into N bands of similar relevance and orders within each by the next fields, so a much better match is never pushed down by a best seller. More bands favour relevance (search-ranking-relevance-buckets, default 10, at most 100). When browsing, every product matches equally, so stock and then the score decide. An index built before the fields existed rejects the sort; the query is retried with units sold and logged. - Goals are stored as a category's MerchandisingNode.rankingObjective (through MerchandisingTreeService.setNodeRankingObjective) and as RankingGoalSchedule documents (rankingGoalSchedules, at most 50 a site, categories kept by path), through RankingGoalService. Both travel with a site transfer: category goals on the nodes, schedules in search/ranking-goal-schedules.json. Scores and state are derived, so they don't.

Managing it

  • Admin: Search > Ranking goals (/admin/search/goals, see the Search section): whether it's in use and why not, the shop default, schedules, and every category's own goal with the one applying today. Search > Product engagement shows the goal applying to the chosen listing and each product's score for it.
  • REST: GET /v1/admin/search/ranking-goals (goals, whether in use, why not, the last run), PUT /v1/admin/search/ranking-goals/default, PUT /v1/admin/search/ranking-goals/categories (category by id, path or name; empty objective inherits), POST /v1/admin/search/ranking-goals/schedules, DELETE /v1/admin/search/ranking-goals/schedules/{scheduleId}.
  • MCP: productRanking action goals (with category: the goal applying there now, and why); manageProductRanking action setGoal (goal; with category, that category's own, blank inherits; with from, to and name, a schedule, limited to category if given) and deleteGoalSchedule (id). See MCP.

Tests

  • LearnedRankScorerTest: decay, smoothing, exploration, each goal rewarding its own thing, clamping, position bias, category baselines, scoring within a listing, home categories.
  • RankingGoalServiceImplTest: schedule beats category beats site, the most specific schedule, inheritance by path, dates inclusive, validation, import by path, the cache.
  • LearnedRankingServiceImplTest, RankingSortTest, MerchandisingQueryServiceTest: when it applies, the sort, a chosen sort ignoring it, the fallback for an older index.
  • MerchandisingQueryServiceTypesenseRuntimeTest (needs TYPESENSE_RUNTIME_URL): against Typesense 29, browsing by score after stock, another goal giving another order, and a search keeping a description-only match last with ten bands but not with one.
  • LearnedRankingJobTest, SearchIndexerRebuildTest: only changes pushed, refused ones retried, unseen for the rest, the reason stored for a shop that doesn't qualify.
  • RankingGoalsRenderTest, ProductEngagementAdminRendererTest, RankingGoalsAdminControllerTest (REST), ProductRankingToolsTest (MCP).