Afficher des articles similaires

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

Pas besoin d’IA générative

négligeableInstantanéVos données ne sortent pas

Le verdict

N1 ne dépend ni de l’étiquetage ni d’un modèle. 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 ou se remplit de voisins parfaits que rien ne rapproche ; 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’ajouter à chaque construction de la table un modèle à charger.

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

Pas besoin d’IA générative

Modèle classique légerN1négligeableInstantané

Python

snippets/show-similar-articles/n1.pyTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
"""
Articles liés par le texte lui-même : TF-IDF, puis similarité cosinus.

Niveau N1. N0 ne voit que ce que quelqu'un a pensé à étiqueter. Celui-ci lit
l'article, et laisse le corpus décider quels mots comptent : plus un mot est
rare dans le corpus, plus il pèse. À la différence d'une étiquette portée
par tous les articles en N0, un mot présent dans tous les articles pèse
encore quelque chose : c'est pourquoi la liste de mots vides ci-dessous fait
partie du travail, une par langue.

Toujours calculé hors ligne, une fois par modification du fonds, et il rend
une table de voisins de la même forme que N0. Le code qui affiche la page ne
change pas quand on passe d'un niveau au suivant : c'est ce qui rend le
passage bon marché.
"""

import unicodedata

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.
    """
    title, body = article["title"] or "", article["body"] or ""  # a NULL column is empty
    # NFC: the same accented word typed on two systems is one token, not two halves.
    return unicodedata.normalize("NFC", f"{title} {title} {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 words every article shares still count:
    on the test corpus, the knife-sharpening article gets the site announcement
    as a neighbour.

    `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)
    try:
        matrix = vectoriser.fit_transform(article_text(a) for a in articles)
    except ValueError:  # no word left at all: nothing to compare, no neighbours
        return {article["id"]: [] for article 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.jsTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
/**
 * Articles liés par le texte lui-même : TF-IDF, puis similarité cosinus.
 *
 * Niveau N1. N0 ne voit que ce que quelqu'un a pensé à étiqueter. Celui-ci lit
 * l'article, et laisse le corpus décider quels mots comptent : plus un mot est
 * rare dans le corpus, plus il pèse. À la différence d'une étiquette portée
 * par tous les articles en N0, un mot présent dans tous les articles pèse
 * encore quelque chose : c'est pourquoi la liste de mots vides ci-dessous fait
 * partie du travail, une par langue.
 *
 * Écrit en entier plutôt que tiré d'une bibliothèque, parce que TF-IDF est un
 * comptage, un logarithme et une division. Le découpage en jetons et la
 * pondération ci-dessous sont ceux de scikit-learn par défaut : ce fichier
 * classe donc un corpus comme le fait la version Python.
 */

// Runs of two or more letters, digits or underscores, as scikit-learn's
// default token pattern. A one-letter word, such as the C of "Programming in
// C", is dropped with the rest.
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) {
  const title = article.title ?? '';
  const body = article.body ?? ''; // a NULL column is empty
  // NFC: the same accented word typed on two systems is one token, not two halves.
  return `${title} ${title} ${body}`.normalize('NFC');
}

/** 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 scikit-learn does by default: as if one extra document held
  // every term once. A term present everywhere weighs 1, the least possible.
  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 words every article shares still count: on
 * the test corpus, the knife-sharpening article gets the site announcement as
 * a neighbour.
 *
 * `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;
}

Essayer

interactif

Le code ci-dessus tourne dans votre navigateur. Écrivez dans le champ : le résultat se refait sous vos yeux.

Le tableau montre tout le classement, plancher compris : les articles au-dessus du plancher sont ceux qui s’afficheraient sous l’article, les autres sont écartés. Un zéro n’est pas un score faible, c’est l’absence de tout mot commun, mots vides mis à part.

à vous d’écrire

Recalculé à chaque frappe.

Ou partez d’un exemple

La saisie libre et les autres exemples ont besoin de JavaScript. L’exemple affiché ci-dessous, lui, a été calculé à la construction du site.

Ce que le code en fait

Ce qu’il renvoie

Article du fondsProximitéDans le bloc
Cuire un pain de seigle dense0,58oui
Baking a dense rye loaf0,09oui
Comment nous joindre pendant les travaux0,00non
Entretenir un levain naturel0,00non
Keeping a sourdough starter alive0,00non
Kimchi en bocal0,00non
Pickled cucumbers in brine0,00non
The office is moving in June0,00non

2 articles sur 8 passent le plancher de 0,05.

Ce que ça implique

  • Vos données ne sortent pas
  • Résultat toujours identique
  • Se teste unitairement, cas par cas
  • Dépend d’une bibliothèque
  • Empreinte faible

Ce que cela ajoute à votre 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 des mots de liaison et le mot anglais pour « jour », obtient un score strictement supérieur ; sans ce mot, elle passe encore devant. Aucun seuil ne rattrape cela.

Quand passer au niveau suivant

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.

Les quatre options

  1. N0Chevauchement d’étiquettes, pondéré par la rareté de l’étiquetteGratuit · Instantané
  2. RecommandéN1TF-IDF sur le texte, similarité cosinus, calculée hors lignenégligeable · Instantané
  3. N2Embeddings de documents auto-hébergés, comparés par cosinusPeu coûteux · Rapide
  4. N3Niveau sans objet ici

Les autres options

N0Chevauchement d’étiquettes, pondéré par la rareté de l’étiquetteGratuit · Instantané

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

Règle et algorithme classiqueN0GratuitInstantané

Python

snippets/show-similar-articles/n0.pyTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
"""
Articles liés par chevauchement d'étiquettes, pondéré par la rareté de
chaque étiquette.

Niveau N0. Sans dépendance, déterministe, et calculé une fois pour le fonds
entier au lieu de l'être à chaque affichage de page : les voisins d'un
article ne dépendent pas de qui le lit, donc aucun lecteur ne devrait jamais
attendre ce calcul.

Compter les étiquettes partagées est la version évidente, et c'est la
mauvaise. Une étiquette que tous les articles portent ne dit rien d'aucun
d'eux ; une étiquette que trois articles portent dit presque tout de ces
trois-là. Pondérer chaque étiquette par sa rareté fait toute la différence
entre un bloc utile et un bloc qui range l'annonce du site à côté de chaque
article du site.
"""

import math
import unicodedata
from collections import Counter


def tag_set(tags) -> set[str]:
    """Each tag once, in NFC, so that a tag typed on two systems meets itself."""
    if isinstance(tags, str):
        # A lone string would be read letter by letter, and "blog" would meet "gloss".
        raise TypeError("tags must be a list of strings, not a string")
    return {unicodedata.normalize("NFC", tag) for tag in tags}


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 tag_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 tag_set(first_tags)}
    second = {tag: weights.get(tag, 0.0) for tag in tag_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.jsTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
/**
 * Articles liés par chevauchement d'étiquettes, pondéré par la rareté de
 * chaque étiquette.
 *
 * Niveau N0. Sans dépendance, déterministe, et calculé une fois pour le fonds
 * entier au lieu de l'être à chaque affichage de page : les voisins d'un
 * article ne dépendent pas de qui le lit, donc aucun lecteur ne devrait jamais
 * attendre ce calcul.
 *
 * Compter les étiquettes partagées est la version évidente, et c'est la
 * mauvaise. Une étiquette que tous les articles portent ne dit rien d'aucun
 * d'eux ; une étiquette que trois articles portent dit presque tout de ces
 * trois-là. Pondérer chaque étiquette par sa rareté fait toute la différence
 * entre un bloc utile et un bloc qui range l'annonce du site à côté de chaque
 * article du 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.
 */
/** Each tag once, in NFC, so that a tag typed on two systems meets itself. */
export function tagSet(tags) {
  // A lone string would be read letter by letter, and "blog" would meet "gloss".
  if (typeof tags === 'string') throw new TypeError('tags must be an array of strings, not a string');
  return new Set(Array.from(tags, (tag) => tag.normalize('NFC')));
}

export function tagWeights(articles) {
  const counts = new Map();
  for (const article of articles) {
    for (const tag of tagSet(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([...tagSet(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;
}

Ce que ça implique

  • Vos données ne sortent pas
  • Résultat toujours identique
  • Se teste unitairement, cas par cas
  • Aucun fournisseur dans la boucle
  • Empreinte négligeable

Ce que cela ajoute à votre 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é juste, 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. À un article près, l’échec change de sens : qu’un quatrième article ne porte pas « article », et les trois autres deviennent voisins parfaits, à 1,0, sans rien avoir en commun qu’une étiquette générique.

Quand passer au niveau suivant

La table que vous construisez contient des lignes vides, ou des voisins à 1,0 qui ne partagent qu’une étiquette générique : vos étiquettes sont sur tous les articles ou presque, ou sur un seul.

N2Embeddings de documents auto-hébergés, comparés par cosinusPeu coûteux · Rapide

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

Petit modèle spécialisé auto-hébergéN2Peu coûteuxRapide

Python

snippets/show-similar-articles/n2.pyTesté, service simulé — Cet extrait s’exécute à chaque construction du site, mais son test remplace le service externe par un double local : la requête envoyée, la réponse décodée et les cas d’erreur sont vérifiés, la réponse du vrai fournisseur ne l’est pas.
"""
Articles liés par des embeddings de documents auto-hébergés.

Niveau N2. N1 compare des mots. Deux articles qui traitent du même sujet
avec deux vocabulaires, ou dans deux langues, ne partagent aucun terme et
obtiennent exactement zéro. L'encodeur nommé ci-dessous est multilingue,
entraîné pour qu'une phrase et sa traduction tombent près l'une de l'autre :
c'est ce qui pourrait réunir l'article français et l'article anglais sur le
levain, ce que le double local du test ne fait pas. Il ne lit que le début
d'un long article (128 jetons en Python, 512 en JavaScript) et coupe le
reste. Cet écart est un piège : le même article reçoit deux vecteurs
différents selon l'environnement qui l'a encodé, et aucune erreur ne le
signale. Construisez la table et encodez un article neuf avec le même. Et
pour des articles, encoder le titre et le chapeau vaut souvent mieux que le
texte entier coupé au milieu d'un paragraphe : ils disent le sujet, et ils
tiennent dans la fenêtre.

La table est toujours construite hors ligne, une fois par modification du
fonds, et elle a la même forme que celle que rendent N0 et N1 : le code qui
affiche la page ne change jamais. Ce qui change, c'est ce que la
construction fait tourner : un modèle dont les poids sont téléchargés une
fois, puis chargés à chaque construction de la table.

L'encodeur est un paramètre doté d'une valeur par défaut réelle : le lecteur
voit ainsi le code de chargement, tandis que le test injecte un double local.
"""

import math

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'] or ''}. {article['body'] or ''}"  # a NULL column is empty


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 - downloads and loads the weights
        from sentence_transformers import SentenceTransformer

        encoder = SentenceTransformer(MODEL_NAME)

    # One batched call, over the whole corpus, every time the table is built.
    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")
    # A NaN would silently empty the table, a short vector would score 1.0 with anything.
    if any(len(v) != len(vectors[0]) or not all(map(math.isfinite, v)) for v in vectors):
        raise EncodingFailed("vectors of different sizes, or values that are not finite numbers")

    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.jsTesté, service simulé — Cet extrait s’exécute à chaque construction du site, mais son test remplace le service externe par un double local : la requête envoyée, la réponse décodée et les cas d’erreur sont vérifiés, la réponse du vrai fournisseur ne l’est pas.
/**
 * Articles liés par des embeddings de documents auto-hébergés.
 *
 * Niveau N2. N1 compare des mots. Deux articles qui traitent du même sujet
 * avec deux vocabulaires, ou dans deux langues, ne partagent aucun terme et
 * obtiennent exactement zéro. L'encodeur nommé ci-dessous est multilingue,
 * entraîné pour qu'une phrase et sa traduction tombent près l'une de l'autre :
 * c'est ce qui pourrait réunir l'article français et l'article anglais sur le
 * levain, ce que le double local du test ne fait pas. Il ne lit que le début
 * d'un long article (128 jetons en Python, 512 en JavaScript) et coupe le
 * reste. Cet écart est un piège : le même article reçoit deux vecteurs
 * différents selon l'environnement qui l'a encodé, et aucune erreur ne le
 * signale. Construisez la table et encodez un article neuf avec le même. Et
 * pour des articles, encoder le titre et le chapeau vaut souvent mieux que le
 * texte entier coupé au milieu d'un paragraphe : ils disent le sujet, et ils
 * tiennent dans la fenêtre.
 *
 * La table est toujours construite hors ligne, une fois par modification du
 * fonds, et elle a la même forme que celle que rendent N0 et N1 : le code qui
 * affiche la page ne change jamais. Ce qui change, c'est ce que la
 * construction fait tourner : un modèle dont les poids sont téléchargés une
 * fois, puis chargés à chaque construction de la table.
 *
 * L'encodeur est un paramètre doté d'une valeur par défaut réelle : le lecteur
 * voit ainsi le code de chargement, tandis que le test injecte un double local.
 */

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 ?? ''}`; // a NULL column is empty
}

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

/**
 * 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('@huggingface/transformers');
    const extract = await pipeline('feature-extraction', MODEL_NAME);
    encoder = { encode: async (batch) => (await extract(batch, { pooling: 'mean' })).tolist() };
  }

  // One batched call, over the whole corpus, every time the table is built.
  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`);
  }
  // A NaN would silently empty the table, a short vector would score 1.0 with anything.
  if (vectors.some((v) => v.length !== vectors[0].length || !v.every(Number.isFinite))) {
    throw new EncodingFailed('vectors of different sizes, or values that are not finite numbers');
  }

  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;
}

Ce que ça implique

  • Vos données restent chez vous
  • Résultat toujours identique
  • Se mesure sur un échantillon, pas cas par cas
  • Dépend d’une bibliothèque
  • Empreinte élevée

Ce que cela ajoute à votre 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 seul vecteur, qui mêle tout ce qu’il contient : un paragraphe digressif ajouté à l’article sur le pain de seigle — le four, la planche, le pot de confiture, la poêle — suffit à lui coûter sa place de premier voisin, et les vecteurs de ce test sont ceux du double local. Et le modèle nommé ne lit que le début d’un long article, 128 jetons en Python et 512 en JavaScript : ce qui dépasse ne dilue pas le vecteur, il n’y entre pas — construisez donc la table et interrogez-la avec le même environnement, faute de quoi le même article y aura deux vecteurs.

Quand passer au niveau suivant

Il n’y a pas de niveau 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. Avant d’en arriver là, pour des articles, encodez le titre et le chapeau plutôt que le texte entier coupé au milieu du deuxième paragraphe : ils disent le sujet, et ils tiennent dans la fenêtre.

N3Niveau sans objet ici

Niveau sans objet ici

API de LLM généralisteN3

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.

Pour aller plus loin

Source des chiffres

Métadonnées