Multi-factor weighted sorting for relevance
Combine several signals into one comparable score by normalising each to [0,1] and taking a weighted sum. The maths is trivial; the trap is mixing raw, unbounded quantities and letting one signal dominate the rest.
On this page
The problem
You want to sort items by “relevance” and there is no single number that captures it. A product listing has rating, recency, popularity, price, and a freshness flag. A search result has text-match score, click-through history, and recency. A feed has all of the above plus social proof.
The naive shapes fail in predictable ways:
- Sort by one column. Whichever signal you pick, the others vanish. New listings never surface; popular old listings dominate forever.
ORDER BY rating DESC, sales DESC, …. Lexicographic priority — the second column only breaks ties on the first. With float ratings, ties are rare, so the rest of the columns may as well not exist.- Sum the raw signals.
rating + sales + viewslooks reasonable until you realisesalesis 0–1,000,000 andratingis 0–5. Sales swamps everything.
The pattern that works: normalise each signal to a common scale, weight them, sum. The interesting decisions are which signals, how to normalise, and how to set the weights.
The shape
Multiple signals contribute to “relevance” but live on different scales (some bounded, some unbounded, some categorical). A direct sum is dominated by whichever signal happens to have the largest raw range.
For each signal, normalise to [0, 1] so a score of 1 means “best in this dimension”. Multiply each normalised signal by a weight wᵢ (with Σwᵢ = 1), and sum to get a single comparable score. Sort by that score.
One sortable number per item, computable in SQL or in code. Tuning is now a question of choosing weights — explainable, debuggable, and changeable without rewriting the query. Pays for itself the first time someone asks “why is item X above item Y?”
The maths
For each item i and signal s, compute the normalised value nᵢ,s ∈ [0, 1], then:
score(i) = Σ ws · nᵢ,s where Σ ws = 1
Keeping the weights summing to 1 isn’t strictly required, but it makes the final score itself a [0, 1] number, which is easy to reason about and easy to threshold.
Normalisation per signal type
The bug lurks here. The right normalisation depends on the signal.
| Signal type | Example | Normalise by |
|---|---|---|
| Bounded numeric | rating 1–5 | (x - min) / (max - min) |
| Unbounded count | sales, views | log-scale then min-max: log(1 + x) / log(1 + max) |
| Recency | days since post | exponential decay: exp(-age / halfLife) |
| Categorical boost | featured flag | 1 if true, 0 otherwise |
| Distance / negative signal | price (lower is better) | invert: 1 - normalised(price) |
The log-scale step matters a lot for power-law distributions like view counts. Linear normalisation of “0 to 1M views” puts everything from 0 to ~200K into the bottom 20% of the range and crushes the signal. Log-scaling makes the gap between 10 and 100 views worth the same as the gap between 10K and 100K — usually what you actually want.
Worked example
A product listing with four signals. Imagine three items, normalised values shown:
item rating views recency featured
A 1.0 0.40 0.20 0
B 0.6 1.0 0.95 1
C 0.8 0.60 0.50 0
Pick weights (0.4, 0.3, 0.2, 0.1) — rating matters most, views next, recency next, featured boost smallest:
A = 0.4·1.0 + 0.3·0.40 + 0.2·0.20 + 0.1·0 = 0.56
B = 0.4·0.6 + 0.3·1.0 + 0.2·0.95 + 0.1·1 = 0.83
C = 0.4·0.8 + 0.3·0.60 + 0.2·0.50 + 0.1·0 = 0.60
Ranking: B, C, A. A wins on rating but loses everywhere else; B is the well-rounded one. Now change the recency weight to 0.6 (cut from rating) and re-run — that’s the entire tuning loop.
In SQL
The whole pattern fits in one query. Compute maxs in a subquery (or with window functions) so the normalisation references the result-set extremes, not constants you’d have to maintain.
WITH bounds AS (
SELECT
MAX(view_count) AS max_views,
MAX(age_days) AS max_age
FROM listing
WHERE category = 'electronics'
)
SELECT
l.*,
0.4 * ((rating - 1.0) / 4.0)
+ 0.3 * (LN(1 + view_count) / LN(1 + b.max_views))
+ 0.2 * EXP(-age_days / 30.0)
+ 0.1 * (CASE WHEN featured THEN 1 ELSE 0 END) AS score
FROM listing l
CROSS JOIN bounds b
WHERE l.category = 'electronics'
ORDER BY score DESC
LIMIT 50;A few things worth noting in that query:
(rating - 1.0) / 4.0maps1..5to0..1. The bounds are known for this column (it’s a 1–5 star rating), so we hard-code them rather than computing per query.LN(1 + …)overLN(1 + max)is the log-min-max for the unbounded count.EXP(-age_days / 30.0)is a 30-day half-life-ish decay. At 30 days the recency signal ise⁻¹ ≈ 0.37; at 90 days it’se⁻³ ≈ 0.05. The denominator is the tuning knob.- The
featuredboost is intentionally small — a featured flag that adds 0.1 cannot rescue an item that scores 0.2 on everything else. That’s the point.
Tuning the weights
The honest answer is: by hand, with a holdout set of pairs the team agrees on the right order for. A handful of patterns:
-
Start with a guess and a single example
Pick weights that match your intuition (e.g. rating > views > recency > flags). Compute scores for ~10 items you have strong opinions about. Adjust until the ranking matches your intuition.
-
Build a labelled pair set
Collect 50–200 pairs
(A, B, correct_order)from product, support, or user research. The set is the ground truth. -
Grid-search weights
Try a coarse grid (
step = 0.1) over weights that sum to 1. Pick the combination that maximises ”% of pairs ranked correctly”. Refine around the winner. -
Re-test on incidents
Whenever someone files a “why is X ranked here?” bug, add the example to the pair set. Re-run grid search. The set is the note the system learns from.
This is what learning-to-rank ML systems automate. For most teams, manual tuning against a small labelled set is more than enough, ships in a day, and stays explainable forever.
The footguns
ORDER BY rating DESC, sales DESC, recency DESC. Three signals, but the second two only break ties on the first — which almost never tie when rating is a float.
It feels like “multi-factor sort”. Easy to write, no maths required.
Compute a single weighted score and sort by it. The order of ORDER BY columns is for tie-breaking on a derived score, not for expressing relevance.
Other ways this goes wrong:
- Normalising against the entire table, not the result set. One global outlier compresses every item’s score for every query. Normalise inside the query, against the current rows.
- Letting one signal saturate. If
viewsranges from 1 to 50M, even after log-scaling a small number of items occupy the top 90% of the normalised range. Cap or winsorise before scaling. - Mixing scales of “higher is better” and “lower is better” without inverting. Forgetting
1 - normalised(price)puts the most expensive item at the top. - Tuning weights against vibes. Without a labelled pair set, “this feels right” drifts on every meeting. The set is what keeps the conversation grounded.