Rank products by relevance

Order the products on a catalogue page so the most relevant ones come first.

No AI needed

FreeInstantYour data never leaves

The verdict

N0 wins because the only deliverable that matters here is an order you can defend: the four weights are call arguments, the function hands back the signals next to the score, and a complaint from the shop floor is settled by moving a number that same afternoon. N1 costs nothing more at serving time — a weighted sum over the same signals, handed back next to the score — but it trades a unit test for a click log to collect, keep and trust, and its own test shows it learns nothing about a signal the previous ranking never varied. Move up to N1 the day your traffic says something your weights do not, and serve with its own function then: learned weights can be negative, and N0’s mean refuses them.

Weighted score over four signals between nought and one

No AI needed

Rule and classic algorithmN0FreeInstant

Python

snippets/rank-products-by-relevance/n0.pyTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
"""
Rank products with a weighted score: text match, availability, margin, popularity.

Rung N0. Deterministic, standard library only, and above all arguable: the
four weights are arguments, not constants buried in the code. That is the
point of the whole approach. When sales say the top result is wrong, the
conversation is about a number a merchandiser can read, and the answer arrives
in an afternoon rather than in a retraining cycle.

Two decisions make it usable.

Every signal sits on the same nought-to-one scale before the weights touch it:
text and availability by construction, margin and popularity because a value
outside is brought back onto it rather than left to swamp the others. A weight
of two really does mean twice as much, and the score, a mean over weights that
cannot be negative, stays inside the same scale.

Brought back, and not refused: a margin of -0.05 is a real thing — end-of-season
stock sold at a loss — and this function is called on every page of results. One
badly filled product must not take the whole category page down with it. The
products whose signals had to be moved come back named, so that the fault is
visible rather than silent. The weights are another matter: they come from the
code, and a weight that is not a finite number, zero or above, is refused.

The sort is stable, so two products the score cannot separate stay in the
order the catalogue gave them. An unstable sort would reshuffle equal results
between two page loads, and nobody would be able to reproduce a complaint.
"""

import math
import unicodedata

# The order the weights are applied in. Fixing it keeps the arithmetic
# identical everywhere, which is what makes a ranking reproducible.
SIGNALS = ("text", "availability", "margin", "popularity")

# A starting point, not a truth. These are the numbers to argue about.
DEFAULT_WEIGHTS = {"text": 6.0, "availability": 2.0, "margin": 1.0, "popularity": 3.0}


def fold(text: str) -> str:
    """
    Lowercase and drop accents, so that "crème" finds "creme".

    Only the diacritics Latin scripts use (U+0300 to U+036F) are dropped: in
    Devanagari or Arabic the vowel signs are marks too, and dropping them
    would turn one word into another.
    """
    decomposed = unicodedata.normalize("NFD", text.lower())
    return "".join(c for c in decomposed if not "\u0300" <= c <= "\u036f")


def terms(text: str) -> list[str]:
    """Split on anything that is not a letter, a mark or a digit."""
    letters = "".join(c if c.isalnum() or unicodedata.category(c)[0] == "M" else " " for c in fold(text))
    return letters.split()


def text_match(query: str, product: dict) -> float:
    """
    Share of the query terms found at the start of a word of the product.

    Prefix matching, not equality: a shopper who types "chauss" is looking
    for "chaussures", and "sandale" finds "sandales". Not the other way round:
    "sandales" does not find "sandale".

    It is a share, not a relevance score. On a one-word query every product
    that holds the word scores 1.0, and the ranking is then decided entirely by
    stock, margin and popularity. If you already run a search engine — BM25 in
    PostgreSQL, Elasticsearch, Meilisearch — put its score here instead,
    normalised to nought-to-one. The value of this rung is the arbitration
    between the four weights, not this signal.
    """
    wanted = terms(query)
    if not wanted:
        return 0.0
    haystack = terms(product["title"] + " " + " ".join(product.get("tags", ())))
    found = sum(1 for term in wanted if any(word.startswith(term) for word in haystack))
    return found / len(wanted)


# The signals that come from the catalogue rather than from the query, and can
# therefore arrive out of scale.
FROM_DATA = ("margin", "popularity")


def on_scale(value) -> tuple[float, bool]:
    """
    Bring a value onto the nought-to-one scale, and say whether it had to move.

    Anything that is not a finite number reads as nought: a margin arriving as
    None, as a string or as NaN is a data fault, not a ranking signal, and it
    is reported rather than trusted.
    """
    number = value if isinstance(value, (int, float)) and not isinstance(value, bool) else None
    if number is None or not math.isfinite(number):
        return 0.0, True
    bounded = min(max(float(number), 0.0), 1.0)
    return bounded, bounded != float(number)


def out_of_scale(product: dict) -> list[str]:
    """The catalogue signals `signals` had to move, named for the caller."""
    return [name for name in FROM_DATA if on_scale(product[name])[1]]


def signals(product: dict, query: str) -> dict[str, float]:
    """The four signals, each on the same nought-to-one scale."""
    measured = {
        "text": text_match(query, product),
        "availability": 1.0 if product["in_stock"] else 0.0,
    }
    for name in FROM_DATA:
        measured[name] = on_scale(product[name])[0]
    return measured


def score(measured: dict[str, float], weights: dict[str, float]) -> float:
    """Weighted mean of the signals, so the score stays on the same scale."""
    missing = [name for name in SIGNALS if name not in weights]
    if missing:
        raise ValueError(f"every signal needs a weight, and these have none: {', '.join(missing)}")
    for name in SIGNALS:
        weight = weights[name]
        ok = isinstance(weight, (int, float)) and not isinstance(weight, bool) and math.isfinite(weight)
        if not ok or weight < 0:
            # Unlike a signal, a weight comes from the code, not from the
            # catalogue: it is refused rather than brought back into line.
            raise ValueError(f"a weight must be a finite number, nought or above: {name} is {weight!r}")
    total = 0.0
    weighted = 0.0
    for name in SIGNALS:
        total += weights[name]
        weighted += weights[name] * measured[name]
    return weighted / total if total else 0.0


def rank(products: list[dict], query: str, weights: dict = DEFAULT_WEIGHTS) -> list[dict]:
    """
    Sort the catalogue, best first, and hand back the reason for each place.

    Returning the signals alongside the score costs nothing and settles most
    arguments before they start: whoever asks why a product came third can see
    which signal held it back. `out_of_scale` names the catalogue signals that
    had to be brought back onto the scale, so that a margin of -0.05 shows up
    in the output instead of taking the page down.
    """
    scored = []
    for product in products:
        measured = signals(product, query)
        scored.append({
            "product": product,
            "score": score(measured, weights),
            "signals": measured,
            "out_of_scale": out_of_scale(product),
        })
    # Stable: products the score cannot separate keep their catalogue order.
    scored.sort(key=lambda row: -row["score"])
    return scored

JavaScript

snippets/rank-products-by-relevance/n0.jsTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
/**
 * Rank products with a weighted score: text match, availability, margin, popularity.
 *
 * Rung N0. Deterministic, no dependency, and above all arguable: the four
 * weights are arguments, not constants buried in the code. That is the point
 * of the whole approach. When sales say the top result is wrong, the
 * conversation is about a number a merchandiser can read, and the answer
 * arrives in an afternoon rather than in a retraining cycle.
 *
 * Two decisions make it usable.
 *
 * Every signal sits on the same nought-to-one scale before the weights touch
 * it: text and availability by construction, margin and popularity because a
 * value outside is brought back onto it rather than left to swamp the others. A
 * weight of two really does mean twice as much, and the score, a mean over
 * weights that cannot be negative, stays inside the same scale.
 *
 * Brought back, and not refused: a margin of -0.05 is a real thing —
 * end-of-season stock sold at a loss — and this function is called on every page
 * of results. One badly filled product must not take the whole category page
 * down with it. The products whose signals had to be moved come back named, so
 * that the fault is visible rather than silent. The weights are another matter:
 * they come from the code, and a weight that is not a finite number, zero or
 * above, is refused.
 *
 * The sort is stable, so two products the score cannot separate stay in the
 * order the catalogue gave them. An unstable sort would reshuffle equal
 * results between two page loads, and nobody would be able to reproduce a
 * complaint.
 */

// The order the weights are applied in. Fixing it keeps the arithmetic
// identical everywhere, which is what makes a ranking reproducible.
export const SIGNALS = ['text', 'availability', 'margin', 'popularity'];

// A starting point, not a truth. These are the numbers to argue about.
export const DEFAULT_WEIGHTS = { text: 6, availability: 2, margin: 1, popularity: 3 };

/**
 * Lowercase and drop accents, so that "crème" finds "creme".
 *
 * Only the diacritics Latin scripts use (U+0300 to U+036F) are dropped: in
 * Devanagari or Arabic the vowel signs are marks too, and dropping them would
 * turn one word into another.
 */
export function fold(text) {
  return text.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '');
}

/** Split on anything that is not a letter, a mark or a digit. */
export function terms(text) {
  return fold(text).match(/[\p{L}\p{M}\p{N}]+/gu) ?? [];
}

/**
 * Share of the query terms found at the start of a word of the product.
 *
 * Prefix matching, not equality: a shopper who types "chauss" is looking for
 * "chaussures", and "sandale" finds "sandales". Not the other way round:
 * "sandales" does not find "sandale".
 *
 * It is a share, not a relevance score. On a one-word query every product that
 * holds the word scores 1.0, and the ranking is then decided entirely by stock,
 * margin and popularity. If you already run a search engine — BM25 in
 * PostgreSQL, Elasticsearch, Meilisearch — put its score here instead,
 * normalised to nought-to-one. The value of this rung is the arbitration
 * between the four weights, not this signal.
 */
export function textMatch(query, product) {
  const wanted = terms(query);
  if (wanted.length === 0) return 0;
  const haystack = terms(`${product.title} ${(product.tags ?? []).join(' ')}`);
  const found = wanted.filter((term) => haystack.some((word) => word.startsWith(term)));
  return found.length / wanted.length;
}

// The signals that come from the catalogue rather than from the query, and can
// therefore arrive out of scale.
export const FROM_DATA = ['margin', 'popularity'];

/**
 * Bring a value onto the nought-to-one scale, and say whether it had to move.
 *
 * Anything that is not a finite number reads as nought: a margin arriving as
 * null, as a string or as NaN is a data fault, not a ranking signal, and it is
 * reported rather than trusted.
 */
export function onScale(value) {
  if (typeof value !== 'number' || !Number.isFinite(value)) return [0, true];
  const bounded = Math.min(Math.max(value, 0), 1);
  return [bounded, bounded !== value];
}

/** The catalogue signals `signals` had to move, named for the caller. */
export function outOfScale(product) {
  return FROM_DATA.filter((name) => onScale(product[name])[1]);
}

/** The four signals, each on the same nought-to-one scale. */
export function signals(product, query) {
  // A field the catalogue does not have at all is a schema fault, not a dirty
  // value: it is refused, as it is in Python.
  for (const name of [...FROM_DATA, 'inStock']) {
    if (!(name in product)) throw new RangeError(`the catalogue gives no ${name} for this product`);
  }
  return {
    text: textMatch(query, product),
    availability: product.inStock ? 1 : 0,
    margin: onScale(product.margin)[0],
    popularity: onScale(product.popularity)[0],
  };
}

/** Weighted mean of the signals, so the score stays on the same scale. */
export function score(measured, weights) {
  const missing = SIGNALS.filter((name) => !(name in weights));
  if (missing.length > 0) {
    throw new RangeError(`every signal needs a weight, and these have none: ${missing.join(', ')}`);
  }
  for (const name of SIGNALS) {
    const weight = weights[name];
    // Unlike a signal, a weight comes from the code, not from the catalogue:
    // it is refused rather than brought back into line.
    if (typeof weight !== 'number' || !Number.isFinite(weight) || weight < 0) {
      throw new RangeError(`a weight must be a finite number, nought or above: ${name} is ${weight}`);
    }
  }
  let total = 0;
  let weighted = 0;
  for (const name of SIGNALS) {
    total += weights[name];
    weighted += weights[name] * measured[name];
  }
  return total ? weighted / total : 0;
}

/**
 * Sort the catalogue, best first, and hand back the reason for each place.
 *
 * Returning the signals alongside the score costs nothing and settles most
 * arguments before they start: whoever asks why a product came third can see
 * which signal held it back. `outOfScale` names the catalogue signals that had
 * to be brought back onto the scale, so that a margin of -0.05 shows up in the
 * output instead of taking the page down.
 */
export function rank(products, query, weights = DEFAULT_WEIGHTS) {
  const scored = products.map((product) => {
    const measured = signals(product, query);
    return {
      product,
      score: score(measured, weights),
      signals: measured,
      outOfScale: outOfScale(product),
    };
  });
  // Array.prototype.sort is stable: products the score cannot separate keep
  // their catalogue order.
  scored.sort((a, b) => b.score - a.score);
  return scored;
}

Try it

interactive

The code above runs in your browser. Type in the box: the result is redone in front of you.

The ranking hands back the four signals along with the score: anyone arguing about a place can see in the table what produced it.

yours to edit

Recomputed on every keystroke.

Or start from an example

Free typing and the other examples need JavaScript. The example shown below was computed when the site was built.

What the code makes of it

What it returns

ProductTextIn stockMarginPopularityScore
Route 5 running shoes100 %yes35 %80 %85 %
Running socks50 %yes60 %55 %62 %
Trail 3 running shoes100 %yes42 %30 %60 %
Cadence GPS watch50 %no25 %70 %50 %
Hiking rucksack0 %yes48 %20 %31 %
Ultra hiking sandals0 %yes40 %5 %23 %

Weights: text 3, stock 2, margin 1, popularity 6. The highlighted cell is the signal that pushed the first product hardest.

What this means for you

  • Your data stays put
  • Always the same result
  • Unit-testable, case by case
  • No provider in the loop
  • Negligible footprint

What this adds to your regulatory scope

  • No specific scope added: no personal data enters the score, and the catalogue never leaves your infrastructure
  • Margin is an explicit ranking parameter: what has to be disclosed about the main ranking parameters depends on your status as an intermediary, and is worth checking

Breaking point

The weights are set by hand on the catalogue of the day, and they go stale in silence. The test tunes them on the autumn catalogue, where the popular products were also the relevant ones; come spring, a range that has barely sold anything yet loses on its own query, “sandales randonnée”, to last season’s bestseller — same weights, same code. Nothing flags it: no exception, no failing test, no log line. Somebody has to notice, and move a number.

When to move up a level

You are replaying the same arbitration between relevance, margin and popularity every time the season turns, and your click log is thick enough to say what shoppers actually pick.

The four options

  1. RecommendedN0Weighted score over four signals between nought and oneFree · Instant
  2. N1Weights learned from the click log, one pair at a timenégligeable · Instant
  3. N2Not worth it here
  4. N3Not worth it here

The other options

N1Weights learned from the click log, one pair at a timenégligeable · Instant

Weights learned from the click log, one pair at a time

Lightweight classic modelN1négligeableInstant

Python

snippets/rank-products-by-relevance/n1.pyTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
"""
Learn the ranking weights from past interactions instead of setting them by hand.

Rung N1. The score is built on the same four signals as N0, each between
nought and one. What changes is where the four weights come from: a
merchandiser's judgement on N0, the click log here.

The method is pairwise, and only one kind of pair counts: a clicked product
against a product that was shown **above** it and passed over. That is the
"Click > Skip Above" strategy of Joachims et al., and the reason for it is the
position bias they measured: a product shown below the click may never have
been looked at, so pairing it with the click teaches nothing about the
products and everything about the order the previous ranking already produced.
The model would learn to reproduce N0, which is the opposite of why one climbs
here.

Each surviving pair becomes one training row, the difference between the two
signal vectors, and a logistic regression on those differences gives back the
weights of the original score. The signals shown to the shop stay the same;
the score does not. Learned weights can be negative, so the score is a plain
sum over weights whose absolute values add up to one, between minus one and
one, where N0 takes a mean of weights that cannot be negative.

Every pair is added in both directions, one labelled a win and one a loss.
That keeps the two classes balanced, and it is why the model carries no
intercept: a constant would shift both directions of the same pair the same
way, which is meaningless when comparing two products of one result page.
"""

import math

import numpy as np
from sklearn.linear_model import LogisticRegression

SIGNALS = ("text", "availability", "margin", "popularity")


def _in_scale(value) -> bool:
    """A finite number between 0 and 1, the scale N0 serves the signals on."""
    if not isinstance(value, (int, float)) or isinstance(value, bool):
        return False
    return math.isfinite(value) and 0.0 <= value <= 1.0


def pairs(impressions: list[list[dict]]) -> tuple[np.ndarray, np.ndarray]:
    """
    Turn result pages into training rows.

    `impressions` is one entry per result page shown to a shopper, **in the
    order the page displayed them**, each item holding the signals logged at
    serving time and whether it was clicked. That order is the whole point:
    only the products above a click are paired with it.

    Logging the signals rather than recomputing them later matters too: a
    product that has since gone out of stock must be trained on the
    availability it had on the day, not on today's.

    A log where the first result is always clicked produces no pair at all,
    and `learn_weights` says so rather than inventing weights.
    """
    rows, labels = [], []
    for page in impressions:
        # The type is checked, not only the range: a signal logged as None or
        # as a string is a broken log, not a value to be cleaned up here.
        if not all(_in_scale(item["signals"][name]) for item in page for name in SIGNALS):
            raise ValueError("every logged signal must lie between 0 and 1")
        for position, item in enumerate(page):
            if not item["clicked"]:
                continue
            winner = item["signals"]
            for above in page[:position]:
                if above["clicked"]:
                    continue  # two clicks say nothing about each other
                difference = [winner[name] - above["signals"][name] for name in SIGNALS]
                rows.append(difference)
                labels.append(1)
                rows.append([-value for value in difference])
                labels.append(0)
    return np.array(rows, dtype=float), np.array(labels)


def learn_weights(impressions: list[list[dict]], regularisation: float = 1.0) -> dict:
    """
    Fit the weights, and hand them back on the scale of the hand-set ones.

    Dividing by the total absolute weight makes the result readable next to
    the numbers of N0, and comparable between two months of log. It changes no
    ranking: scaling every weight scales every score the same way.
    """
    rows, labels = pairs(impressions)
    if len(rows) == 0:
        raise ValueError(
            "no click with an ignored product above it in the log: nothing to learn from"
        )
    model = LogisticRegression(C=regularisation, fit_intercept=False, max_iter=1000)
    model.fit(rows, labels)
    learnt = model.coef_[0]
    scale = float(np.abs(learnt).sum())
    if scale == 0:
        raise ValueError("clicked and ignored products never differ in the log: nothing to learn from")
    return {name: float(value) / scale for name, value in zip(SIGNALS, learnt)}


def rank(candidates: list[dict], weights: dict) -> list[dict]:
    """
    Score candidates whose signals were computed by the serving pipeline.

    A sum weighted over the same signals, the same stable sort, the signals
    handed back with each candidate. Not N0's mean: dividing by the signed
    total of learned weights would reverse the order when that total is
    negative, and wipe it out when it is nought.
    """
    scored = []
    for candidate in candidates:
        total = 0.0
        for name in SIGNALS:
            total += weights[name] * candidate["signals"][name]
        scored.append({"candidate": candidate, "score": total})
    scored.sort(key=lambda row: -row["score"])
    return scored

JavaScript

snippets/rank-products-by-relevance/n1.jsTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
/**
 * Learn the ranking weights from past interactions instead of setting them by hand.
 *
 * Rung N1. The score is built on the same four signals as N0, each between
 * nought and one. What changes is where the four weights come from: a
 * merchandiser's judgement on N0, the click log here.
 *
 * The method is pairwise, and only one kind of pair counts: a clicked product
 * against a product that was shown **above** it and passed over. That is the
 * "Click > Skip Above" strategy of Joachims et al., and the reason for it is the
 * position bias they measured: a product shown below the click may never have
 * been looked at, so pairing it with the click teaches nothing about the
 * products and everything about the order the previous ranking already
 * produced. The model would learn to reproduce N0, which is the opposite of why
 * one climbs here.
 *
 * Each surviving pair becomes one training row, the difference between the
 * two signal vectors, and a logistic regression on those differences gives
 * back the weights of the original score. The signals shown to the shop stay
 * the same; the score does not. Learned weights can be negative, so the score
 * is a plain sum over weights whose absolute values add up to one, between
 * minus one and one, where N0 takes a mean of weights that cannot be negative.
 *
 * Every pair is added in both directions, one labelled a win and one a loss.
 * That keeps the two classes balanced, and it is why the model carries no
 * intercept: a constant would shift both directions of the same pair the same
 * way, which is meaningless when comparing two products of one result page.
 *
 * The fit is forty lines of gradient descent rather than a dependency,
 * which is the argument of this whole rung.
 */

export const SIGNALS = ['text', 'availability', 'margin', 'popularity'];

/**
 * Every signal a finite number between 0 and 1, the scale N0 serves them on.
 *
 * The type is checked, not only the comparison: `null >= 0` is true in
 * JavaScript, and a signal logged as null or as the string "0.9" would slip
 * through and count as something. A logged signal is not a catalogue field to
 * be cleaned up here — it is what the serving pipeline wrote down, and if it is
 * not a number the log is broken.
 */
const inScale = (signals) => SIGNALS.every((name) => typeof signals[name] === 'number'
  && Number.isFinite(signals[name]) && signals[name] >= 0 && signals[name] <= 1);

/**
 * Turn result pages into training rows.
 *
 * `impressions` is one entry per result page shown to a shopper, **in the order
 * the page displayed them**, each item holding the signals logged at serving
 * time and whether it was clicked. That order is the whole point: only the
 * products above a click are paired with it.
 *
 * Logging the signals rather than recomputing them later matters too: a product
 * that has since gone out of stock must be trained on the availability it had
 * on the day, not on today's.
 *
 * A log where the first result is always clicked produces no pair at all, and
 * `learnWeights` says so rather than inventing weights.
 */
export function pairs(impressions) {
  const rows = [];
  const labels = [];
  for (const page of impressions) {
    if (!page.every((item) => inScale(item.signals))) throw new RangeError('every logged signal must lie between 0 and 1');
    page.forEach((item, position) => {
      if (!item.clicked) return;
      for (const above of page.slice(0, position)) {
        if (above.clicked) continue; // two clicks say nothing about each other
        const difference = SIGNALS.map((name) => item.signals[name] - above.signals[name]);
        rows.push(difference);
        labels.push(1);
        rows.push(difference.map((value) => -value));
        labels.push(0);
      }
    });
  }
  return { rows, labels };
}

/**
 * Fit the weights, and hand them back on the scale of the hand-set ones.
 *
 * Dividing by the total absolute weight makes the result readable next to the
 * numbers of N0, and comparable between two months of log. It changes no
 * ranking: scaling every weight scales every score the same way.
 */
export function learnWeights(impressions, { regularisation = 1, epochs = 600, rate = 0.5 } = {}) {
  const { rows, labels } = pairs(impressions);
  if (rows.length === 0) {
    throw new Error('no click with an ignored product above it in the log: nothing to learn from');
  }
  const learnt = new Array(SIGNALS.length).fill(0);
  for (let epoch = 0; epoch < epochs; epoch += 1) {
    const gradient = new Array(SIGNALS.length).fill(0);
    for (let i = 0; i < rows.length; i += 1) {
      const z = rows[i].reduce((sum, value, j) => sum + learnt[j] * value, 0);
      const error = 1 / (1 + Math.exp(-z)) - labels[i];
      rows[i].forEach((value, j) => { gradient[j] += (error * value) / rows.length; });
    }
    // The penalty of scikit-learn's `C`, divided by the row count because the
    // gradient above is a mean: it is what makes both versions fit one model.
    const penalty = regularisation * rows.length;
    learnt.forEach((w, j) => { learnt[j] = w - rate * (gradient[j] + w / penalty); });
  }
  const scale = learnt.reduce((sum, value) => sum + Math.abs(value), 0);
  if (!scale) throw new Error('clicked and ignored products never differ in the log: nothing to learn from');
  return Object.fromEntries(SIGNALS.map((name, j) => [name, learnt[j] / scale]));
}

/**
 * Score candidates whose signals were computed by the serving pipeline.
 *
 * A sum weighted over the same signals, the same stable sort, the signals
 * handed back with each candidate. Not N0's mean: dividing by the signed total
 * of learned weights would reverse the order when that total is negative, and
 * wipe it out when it is nought.
 */
export function rank(candidates, weights) {
  const scored = candidates.map((candidate) => ({
    candidate,
    score: SIGNALS.reduce((sum, name) => sum + weights[name] * candidate.signals[name], 0),
  }));
  scored.sort((a, b) => b.score - a.score);
  return scored;
}

What this means for you

  • Your data stays on your own machines
  • Always the same result
  • Measured on a sample, not case by case
  • Depends on a library
  • Low footprint

What this adds to your regulatory scope

  • The click log is behavioural data: tied to a session or account identifier, it falls within processing of personal data on your own infrastructure
  • Keeping it falls within the scope of your record of processing activities and of the retention period you set
  • Learned weights are still ranking parameters, with the same disclosure scope as hand-set ones

Breaking point

The log only teaches what the previous ranking varied. The test gives every product on every page the same margin: that column holds nothing but zeroes, the learned weight comes out exactly nought, and a page where margin is the only difference between two products comes back with two identical scores. Same blindness at the other end: a log where the shopper always takes the first result forms no pair at all, since this level only pairs a click with the products shown above it, and `learn_weights` says so rather than inventing weights. No amount of extra traffic changes that: only a change in what gets shown will — exploration, interleaving, or a small share of traffic served in a shuffled order.

When to move up a level

There is no level above this one here: past it you do not change model, you change what you show — exploration, interleaving, a share of traffic served in a shuffled order — so that the log teaches something other than yesterday’s ranking.

N2Not worth it here

Not worth it here

Small self-hosted specialised modelN2

Ranking here turns on four signals, three of which are business figures — stock, margin, sales — that appear in no text a semantic encoder could read. A self-hosted model would improve the text match alone, over product titles a few words long, and a permanent service would have to be kept running for it.

N3Not worth it here

Not worth it here

General-purpose LLM APIN3

Rankings get challenged: the shop floor wants to know why its product came third, and the same query has to give the same order the next day. A general-purpose model hands back an order without the numbers that decided it, and its API is non-deterministic by default, as the provider used as an example says itself; on top of that it would have to be called on every result page for an arbitration between margin, stock and relevance that fits in four numbers.

Further reading

Where the figures come from

Metadata