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-orderssold 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-enabledis 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:
- 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".
- The listing's category's own goal, or the nearest category above it with one.
- 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:
- Recent days count more: each day weighs
0.5^(age / half-life), half-lifesearch-ranking-half-life-days(default 14). - 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.
- 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. - Expected result per impression: the site's click-through × click ratio × conversion, times
units per order for
units, order value forrevenue, and order value × margin rate formargin. The margin rate ismargin / marginRevenue(the revenue whose cost was known), smoothed the same way with three average orders' worth of revenue; a loss scores 0. - 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. - 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 withbadger.search.learned-ranking.enabled=false), per catalogue: check eligibility (ProductEngagementService.eligibilityfor each site selling from it), thenProductRankScoreService.computestreams the sites' dailyallbuckets (ProductEngagementService.scanDays, every context in one pass:allfor 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 inproductRankScores(with the smoothed rates, for the admin); a refused batch is retried the next night. The catalogue'sLearnedRankingState(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.
MerchandisingQueryServiceasksLearnedRankingService.forListing(the site switch, the catalogue's state cached for five minutes, andRankingGoalService.resolve, cached per catalogue for a minute).RankingSort.featuredthen 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(categoryby id, path or name; emptyobjectiveinherits),POST /v1/admin/search/ranking-goals/schedules,DELETE /v1/admin/search/ranking-goals/schedules/{scheduleId}. - MCP:
productRankingactiongoals(withcategory: the goal applying there now, and why);manageProductRankingactionsetGoal(goal; withcategory, that category's own, blank inherits; withfrom,toandname, a schedule, limited tocategoryif given) anddeleteGoalSchedule(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(needsTYPESENSE_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).
Related¶
- Product Engagement: the counts the scores come from
- Search and Discovery: Featured without it