Catalogue Recommander

Afficher des articles similaires

Proposer, sous un article, trois ou quatre autres articles du site qui traitent du même sujet.

RecommandéN1 Révisée le

Récapitulatif des barreaux
Barreau Approche Coût Latence Données Déterministe Verdict
Règle et algorithme classique N0 — Règle et algorithme classique Chevauchement d'étiquettes, pondéré par la rareté de l'étiquette Nul <1 ms Rien ne sort Oui
Modèle classique léger N1 — Modèle classique léger TF-IDF sur le texte, similarité cosinus, calculée hors ligne Négligeable ~10 ms Rien ne sort Oui Recommandé
Petit modèle spécialisé auto-hébergé N2 — Petit modèle spécialisé auto-hébergé Embeddings de documents auto-hébergés, comparés par cosinus Faible ~100 ms Reste dans votre infrastructure Oui
API de LLM généraliste N3 — API de LLM généraliste Barreau absent Recalculer une similarité par appel de modèle à chaque affichage de page, pour un résultat qui ne change pas entre deux publications, est un gaspillage. Le classement ne dépend ni du lecteur ni de l'instant : le seul gain envisageable serait sa qualité, et elle se paierait à chaque affichage au lieu d'une fois par publication.

N0 — Règle et algorithme classique Règle et algorithme classique

Chevauchement d'étiquettes, pondéré par la rareté de l'étiquette

Coût
Nul
Latence
<1 ms

Preuve d’exécution : Code exécuté tel quel

Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.

Python

snippets/show-similar-articles/n0.py
"""
Related articles from tag overlap, weighted by how rare each tag is.

Rung N0. Standard library, deterministic, and computed once for the whole
corpus instead of on every page view: the neighbours of an article do not
depend on who is reading it, so no reader should ever wait for this.

Counting shared tags is the obvious version, and it is the wrong one. A tag
every article carries says nothing about any of them; a tag three articles
carry says almost everything about those three. Weighting each tag by its
rarity is the whole difference between a useful block and one that files the
site announcement next to every article on the site.
"""

import math
from collections import Counter


def tag_weights(articles: list[dict]) -> dict[str, float]:
    """
    Inverse document frequency of every tag in the corpus.

    log(corpus size / tag count) is exactly zero for a tag carried by every
    article. That is the point, not a rounding accident: such a tag must not
    create any similarity at all.
    """
    counts = Counter(tag for article in articles for tag in set(article["tags"]))
    return {tag: math.log(len(articles) / count) for tag, count in counts.items()}


def _norm(vector: dict[str, float]) -> float:
    return math.sqrt(sum(weight * weight for weight in vector.values()))


def similarity(first_tags, second_tags, weights: dict[str, float]) -> float:
    """Cosine between two tag sets, each weighted by tag rarity."""
    first = {tag: weights.get(tag, 0.0) for tag in set(first_tags)}
    second = {tag: weights.get(tag, 0.0) for tag in set(second_tags)}
    shared = sum(first[tag] * second[tag] for tag in first.keys() & second.keys())
    norms = _norm(first) * _norm(second)
    # An article carrying only universal tags has a null vector, and no
    # neighbours. Saying nothing is the honest answer here.
    return shared / norms if norms else 0.0


def build_neighbour_table(articles: list[dict], k: int = 5, minimum: float = 0.0) -> dict:
    """
    Return `{article id: [(neighbour id, score), ...]}`, best neighbour first.

    Built offline, for the whole corpus at once, when an article is published
    or retagged. Rendering the block on a page is then a lookup in this table,
    never a search.
    """
    weights = tag_weights(articles)
    table = {}
    for article in articles:
        neighbours = []
        for other in articles:
            if other["id"] == article["id"]:
                continue
            score = round(similarity(article["tags"], other["tags"], weights), 3)
            if score > minimum:
                neighbours.append((other["id"], score))
        # Ties broken by identifier, so that two builds give the same page.
        neighbours.sort(key=lambda neighbour: (-neighbour[1], neighbour[0]))
        table[article["id"]] = neighbours[:k]
    return table

JavaScript

snippets/show-similar-articles/n0.js
/**
 * Related articles from tag overlap, weighted by how rare each tag is.
 *
 * Rung N0. No dependency, deterministic, and computed once for the whole
 * corpus instead of on every page view: the neighbours of an article do not
 * depend on who is reading it, so no reader should ever wait for this.
 *
 * Counting shared tags is the obvious version, and it is the wrong one. A tag
 * every article carries says nothing about any of them; a tag three articles
 * carry says almost everything about those three. Weighting each tag by its
 * rarity is the whole difference between a useful block and one that files
 * the site announcement next to every article on the site.
 */

/**
 * Inverse document frequency of every tag in the corpus.
 *
 * log(corpus size / tag count) is exactly zero for a tag carried by every
 * article. That is the point, not a rounding accident: such a tag must not
 * create any similarity at all.
 */
export function tagWeights(articles) {
  const counts = new Map();
  for (const article of articles) {
    for (const tag of new Set(article.tags)) counts.set(tag, (counts.get(tag) ?? 0) + 1);
  }
  const weights = new Map();
  for (const [tag, count] of counts) weights.set(tag, Math.log(articles.length / count));
  return weights;
}

function norm(vector) {
  return Math.hypot(...vector.values());
}

/** Cosine between two tag sets, each weighted by tag rarity. */
export function similarity(firstTags, secondTags, weights) {
  const vector = (tags) => new Map([...new Set(tags)].map((t) => [t, weights.get(t) ?? 0]));
  const first = vector(firstTags);
  const second = vector(secondTags);

  let shared = 0;
  for (const [tag, weight] of first) shared += weight * (second.get(tag) ?? 0);
  const norms = norm(first) * norm(second);
  // An article carrying only universal tags has a null vector, and no
  // neighbours. Saying nothing is the honest answer here.
  return norms ? shared / norms : 0;
}

/**
 * Return `{ [article id]: [[neighbour id, score], ...] }`, best first.
 *
 * Built offline, for the whole corpus at once, when an article is published
 * or retagged. Rendering the block on a page is then a lookup in this table,
 * never a search.
 */
export function buildNeighbourTable(articles, { k = 5, minimum = 0 } = {}) {
  const weights = tagWeights(articles);
  const table = {};
  for (const article of articles) {
    const neighbours = [];
    for (const other of articles) {
      if (other.id === article.id) continue;
      const score = Math.round(similarity(article.tags, other.tags, weights) * 1000) / 1000;
      if (score > minimum) neighbours.push([other.id, score]);
    }
    // Ties broken by identifier, so that two builds give the same page.
    neighbours.sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1));
    table[article.id] = neighbours.slice(0, k);
  }
  return table;
}

Risques

Sortie de données
Rien ne sort
Déterminisme
Oui
Testabilité
Testable unitairement
Dépendance fournisseur
Aucune
Empreinte
Négligeable
Périmètre réglementaire
  • Aucun périmètre spécifique ajouté : le fonds ne quitte pas votre infrastructure
  • Aucun profilage du lecteur : la table de voisins est la même pour tout le monde

Point de rupture

Un fonds mal étiqueté ne produit aucune similarité, et l'échec est total plutôt que dégradé. Le test construit deux corpus : dans le premier, chaque article porte « blog » et « article », dont le poids vaut exactement zéro ; dans le second, chaque article porte une étiquette que lui seul porte. Les deux tables sortent entièrement vides, et le bloc n'apparaît sur aucune page.

Quand monter d’un barreau

La table que vous construisez contient des lignes vides, parce que vos étiquettes sont soit sur tous les articles, soit sur un seul.

N1 — Modèle classique léger Modèle classique léger Recommandé

TF-IDF sur le texte, similarité cosinus, calculée hors ligne

Coût
Négligeable
Latence
~10 ms

Preuve d’exécution : Code exécuté tel quel

Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.

Python

snippets/show-similar-articles/n1.py
"""
Related articles from the text itself: TF-IDF, then cosine similarity.

Rung N1. N0 only sees what someone remembered to tag. This reads the article,
and lets the corpus decide which words matter: a word appearing in every
article weighs almost nothing, which is the idea of N0 applied to vocabulary
instead of labels. Nobody has to maintain anything.

Still computed offline, once per corpus change, and it returns the same
neighbour table as N0. The page-rendering code does not change when you move
from one rung to the next, which is what makes the move cheap.
"""

from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import linear_kernel


def article_text(article: dict) -> str:
    """
    The title counts twice.

    A word in a title is a stronger claim about the subject than the same word
    buried in the fourth paragraph, and repeating the title is the cheapest
    way to say so to a bag-of-words model.
    """
    return f"{article['title']} {article['title']} {article['body']}"


def build_neighbour_table(
    articles: list[dict],
    k: int = 5,
    minimum: float = 0.05,
    stop_words: list[str] | None = None,
) -> dict:
    """
    Return `{article id: [(neighbour id, score), ...]}`, best neighbour first.

    `stop_words` is a per-language list, so it belongs to the caller and not
    to this function. Without one the ranking still works, because a word in
    every article is downweighted anyway, but the floor has to do more of the
    job on a small corpus.

    `minimum` is that floor, not a knob to be tweaked until the block looks
    full: below it, two articles share ordinary words and nothing else, and
    showing no block beats showing a wrong one.
    """
    if len(articles) < 2:
        return {article["id"]: [] for article in articles}

    # Rows come out l2-normalised, so their dot product is already a cosine.
    vectoriser = TfidfVectorizer(stop_words=stop_words)
    matrix = vectoriser.fit_transform(article_text(a) for a in articles)
    scores = linear_kernel(matrix)

    table = {}
    for index, article in enumerate(articles):
        neighbours = [
            (other["id"], round(float(scores[index][position]), 3))
            for position, other in enumerate(articles)
            if position != index
        ]
        # Ties broken by identifier, so that two builds give the same page.
        neighbours = sorted(
            (n for n in neighbours if n[1] > minimum),
            key=lambda neighbour: (-neighbour[1], neighbour[0]),
        )
        table[article["id"]] = neighbours[:k]
    return table

JavaScript

snippets/show-similar-articles/n1.js
/**
 * Related articles from the text itself: TF-IDF, then cosine similarity.
 *
 * Rung N1. N0 only sees what someone remembered to tag. This reads the
 * article, and lets the corpus decide which words matter: a word appearing in
 * every article weighs almost nothing, which is the idea of N0 applied to
 * vocabulary instead of labels. Nobody has to maintain anything.
 *
 * Written out rather than pulled from a library, because TF-IDF is a count, a
 * logarithm and a division. The weighting below is the standard smoothed one,
 * so this ranks a corpus exactly as the Python version does.
 */

// Runs of two or more letters, digits or underscores. A one-letter token
// carries no subject, and dropping it costs nothing.
const TOKEN = /[\p{L}\p{N}_]{2,}/gu;

export function tokenise(text, stopWords = new Set()) {
  return (text.toLowerCase().match(TOKEN) ?? []).filter((term) => !stopWords.has(term));
}

/** The title counts twice: a word in a title is a stronger claim. */
export function articleText(article) {
  return `${article.title} ${article.title} ${article.body}`;
}

/** One l2-normalised TF-IDF vector per document, as a Map of term to weight. */
export function vectorise(texts, stopWords = new Set()) {
  const documents = texts.map((text) => tokenise(text, stopWords));
  const appearances = new Map();
  for (const terms of documents) {
    for (const term of new Set(terms)) appearances.set(term, (appearances.get(term) ?? 0) + 1);
  }

  // Smoothed: as if one extra document held every term, so a term present
  // everywhere still has a defined weight instead of a division by zero.
  const idf = (term) => Math.log((1 + documents.length) / (1 + appearances.get(term))) + 1;

  return documents.map((terms) => {
    const vector = new Map();
    for (const term of terms) vector.set(term, (vector.get(term) ?? 0) + 1);
    for (const [term, count] of vector) vector.set(term, count * idf(term));
    const norm = Math.hypot(...vector.values());
    if (norm) for (const [term, weight] of vector) vector.set(term, weight / norm);
    return vector;
  });
}

/** Both vectors are unit length, so their dot product is the cosine. */
function cosine(first, second) {
  let total = 0;
  for (const [term, weight] of first) total += weight * (second.get(term) ?? 0);
  return total;
}

/**
 * Return `{ [article id]: [[neighbour id, score], ...] }`, best first.
 *
 * `stopWords` is a per-language list, so it belongs to the caller and not to
 * this function. Without one the ranking still works, because a word in every
 * article is downweighted anyway, but the floor has to do more of the job on
 * a small corpus.
 *
 * `minimum` is that floor, not a knob to be tweaked until the block looks
 * full: below it, two articles share ordinary words and nothing else, and
 * showing no block beats showing a wrong one.
 */
export function buildNeighbourTable(articles, { k = 5, minimum = 0.05, stopWords = [] } = {}) {
  if (articles.length < 2) return Object.fromEntries(articles.map((a) => [a.id, []]));

  const vectors = vectorise(articles.map(articleText), new Set(stopWords));
  const table = {};
  articles.forEach((article, index) => {
    const neighbours = [];
    articles.forEach((other, position) => {
      if (position === index) return;
      const score = Math.round(cosine(vectors[index], vectors[position]) * 1000) / 1000;
      if (score > minimum) neighbours.push([other.id, score]);
    });
    // Ties broken by identifier, so that two builds give the same page.
    neighbours.sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1));
    table[article.id] = neighbours.slice(0, k);
  });
  return table;
}

Risques

Sortie de données
Rien ne sort
Déterminisme
Oui
Testabilité
Testable unitairement
Dépendance fournisseur
Bibliothèque
Empreinte
Faible
Périmètre réglementaire
  • Aucun périmètre spécifique ajouté : le fonds ne quitte pas votre infrastructure
  • Aucun apprentissage préalable : la pondération est recalculée sur votre seul corpus à chaque construction de la table

Point de rupture

TF-IDF compare des chaînes de caractères, pas des sens. Le test met côte à côte l'article anglais sur le levain et son jumeau français : leur cosinus vaut exactement zéro, pas une valeur basse. Dans le même corpus, une annonce de déménagement de bureau, qui ne partage avec l'article anglais que de la grammaire, obtient un score strictement supérieur. Aucun seuil ne rattrape cela.

Quand monter d’un barreau

Votre fonds dit la même chose avec deux vocabulaires : deux langues, ou le jargon d'un auteur face aux mots courants d'un autre.

N2 — Petit modèle spécialisé auto-hébergé Petit modèle spécialisé auto-hébergé

Embeddings de documents auto-hébergés, comparés par cosinus

Coût
Faible
Latence
~100 ms

Preuve d’exécution : Code exécuté, service externe simulé

Cet extrait s’exécute à chaque construction du site, mais son test remplace le service externe par un double local. Ce qui est vérifié : la requête envoyée, la réponse décodée et les cas d’erreur. Ce qui ne l’est pas : la qualité de la réponse du modèle.

Python

snippets/show-similar-articles/n2.py
"""
Related articles from self-hosted document embeddings.

Rung N2. N1 compares words. Two articles that cover the same subject with two
vocabularies, or in two languages, share no term and score exactly zero. An
encoder maps each article to a vector where meaning, not spelling, decides the
distance, so the French and the English piece on sourdough can land together.

The table is still built offline, once per corpus change, and it has the same
shape as the one N0 and N1 return: the page-rendering code never changes.
What changes is what you now run — a few hundred megabytes of weights, a
process to keep warm, and a vector index once the corpus outgrows a list.

The encoder is a parameter with a real default, so the reader sees the loading
code while the test injects a local double.
"""

MODEL_NAME = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"


class EncodingFailed(RuntimeError):
    """The encoder could not be run, or returned something unusable."""


def article_text(article: dict) -> str:
    """One string per article. An encoder wants a sentence, not a bag of words."""
    return f"{article['title']}. {article['body']}"


def unit(vector) -> list[float]:
    """Normalise once, so that a cosine is a dot product afterwards."""
    values = [float(value) for value in vector]
    norm = sum(value * value for value in values) ** 0.5
    return [value / norm for value in values] if norm else values


def build_neighbour_table(articles: list[dict], encoder=None, k: int = 5,
                          minimum: float = 0.0) -> dict:
    """Return `{article id: [(neighbour id, score), ...]}`, best neighbour first."""
    if len(articles) < 2:
        return {article["id"]: [] for article in articles}
    texts = [article_text(article) for article in articles]

    if encoder is None:  # pragma: no cover - loads several hundred megabytes
        from sentence_transformers import SentenceTransformer

        encoder = SentenceTransformer(MODEL_NAME)

    # One batched call. Encoding article by article wastes most of the machine,
    # and this runs over the whole corpus every time an article is published.
    try:
        vectors = [unit(vector) for vector in encoder.encode(texts)]
    except Exception as error:  # noqa: BLE001 - a model failure is not the caller's fault
        raise EncodingFailed(str(error)) from error
    if len(vectors) != len(texts):
        raise EncodingFailed(f"{len(vectors)} vectors returned for {len(texts)} articles")

    table = {}
    for index, article in enumerate(articles):
        neighbours = []
        for position, other in enumerate(articles):
            if position == index:
                continue
            score = round(sum(x * y for x, y in zip(vectors[index], vectors[position])), 3)
            if score > minimum:
                neighbours.append((other["id"], score))
        # Ties broken by identifier, so that two builds give the same page.
        neighbours.sort(key=lambda neighbour: (-neighbour[1], neighbour[0]))
        table[article["id"]] = neighbours[:k]
    return table

JavaScript

snippets/show-similar-articles/n2.js
/**
 * Related articles from self-hosted document embeddings.
 *
 * Rung N2. N1 compares words. Two articles that cover the same subject with
 * two vocabularies, or in two languages, share no term and score exactly
 * zero. An encoder maps each article to a vector where meaning, not spelling,
 * decides the distance, so the French and the English piece on sourdough can
 * land together.
 *
 * The table is still built offline, once per corpus change, and it has the
 * same shape as the one N0 and N1 return: the page-rendering code never
 * changes. What changes is what you now run — a few hundred megabytes of
 * weights, a process to keep warm, and a vector index once the corpus
 * outgrows an array.
 *
 * The encoder is a parameter with a real default, so the reader sees the
 * loading code while the test injects a local double.
 */

export const MODEL_NAME = 'Xenova/paraphrase-multilingual-MiniLM-L12-v2';

export class EncodingFailed extends Error {}

/** One string per article. An encoder wants a sentence, not a bag of words. */
export function articleText(article) {
  return `${article.title}. ${article.body}`;
}

/** Normalise once, so that a cosine is a dot product afterwards. */
export function unit(vector) {
  const norm = Math.hypot(...vector);
  return norm ? vector.map((value) => value / norm) : [...vector];
}

/**
 * Return `{ [article id]: [[neighbour id, score], ...] }`, best first.
 *
 * @param {object[]} articles
 * @param {object} options
 * @param {{encode: Function}} [options.encoder] injected so this can be
 *   tested without downloading a model; defaults to a real local encoder
 */
export async function buildNeighbourTable(articles, { encoder, k = 5, minimum = 0 } = {}) {
  if (articles.length < 2) return Object.fromEntries(articles.map((a) => [a.id, []]));
  const texts = articles.map(articleText);

  if (!encoder) {
    // Downloads and loads the weights, so it is never reached in the tests.
    const { pipeline } = await import('@xenova/transformers');
    const extract = await pipeline('feature-extraction', MODEL_NAME);
    encoder = { encode: async (batch) => (await extract(batch, { pooling: 'mean' })).tolist() };
  }

  // One batched call. Encoding article by article wastes most of the machine,
  // and this runs over the whole corpus every time an article is published.
  let vectors;
  try {
    vectors = (await encoder.encode(texts)).map(unit);
  } catch (error) {
    throw new EncodingFailed(String(error));
  }
  if (vectors.length !== texts.length) {
    throw new EncodingFailed(`${vectors.length} vectors returned for ${texts.length} articles`);
  }

  const table = {};
  articles.forEach((article, index) => {
    const neighbours = [];
    articles.forEach((other, position) => {
      if (position === index) return;
      const cosine = vectors[index].reduce((sum, value, d) => sum + value * vectors[position][d], 0);
      const score = Math.round(cosine * 1000) / 1000;
      if (score > minimum) neighbours.push([other.id, score]);
    });
    // Ties broken by identifier, so that two builds give the same page.
    neighbours.sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1));
    table[article.id] = neighbours.slice(0, k);
  });
  return table;
}

Risques

Sortie de données
Reste dans votre infrastructure
Déterminisme
Oui
Testabilité
Testable statistiquement
Dépendance fournisseur
Bibliothèque
Empreinte
Élevée
Périmètre réglementaire
  • Le fonds est encodé sur votre infrastructure : rien ne part chez un tiers
  • Les poids du modèle viennent d'un tiers, avec la licence et la provenance que cela suppose de vérifier

Point de rupture

Un article reçoit un vecteur, quelle que soit sa longueur : un texte qui aborde huit sujets les porte chacun au huitième. Le test ajoute un paragraphe digressif à l'article sur le pain de seigle — le four, la planche, le pot de confiture, la poêle — et cela suffit à lui coûter sa place de premier voisin : l'article sur le levain se retrouve apparié à celui sur la poêle en fonte, et l'article rallongé perd au passage son propre meilleur voisin. Le double local du test amplifie l'écart ; la dilution, elle, est celle des vrais encodeurs.

Quand monter d’un barreau

Il n'y a pas de barreau au-dessus dans cette fiche : au-delà, on ne change plus d'algorithme, on découpe les articles en passages et on indexe les passages.

N3 — API de LLM généraliste API de LLM généraliste

Barreau absent

Recalculer une similarité par appel de modèle à chaque affichage de page, pour un résultat qui ne change pas entre deux publications, est un gaspillage. Le classement ne dépend ni du lecteur ni de l'instant : le seul gain envisageable serait sa qualité, et elle se paierait à chaque affichage au lieu d'une fois par publication.

Le verdict

RecommandéN1

N1 est le barreau qui ne dépend de personne. N0 est gratuit et se teste unitairement, mais il ne voit que ce que quelqu'un a pensé à étiqueter, et le jour où l'étiquetage se relâche la table se vide au lieu de se dégrader ; N1 lit l'article et laisse le corpus décider quels mots comptent, pour la même table construite hors ligne. Son prix est une liste de mots vides par langue, sans laquelle le test apparie l'article sur l'affûtage avec l'annonce du site : montez à N2 quand votre fonds dit la même chose en deux langues, pas avant, sous peine d'échanger une bibliothèque contre un service à tenir chaud.

Pour aller plus loin

Métadonnées