Tag articles by topic

Attach to every article the topics it covers, taken from the newsroom’s own list.

No generative AI needed

négligeableInstantYour data never leaves

The verdict

N1 is the first level that learns which words go with a topic, instead of keeping to the ones someone listed: the remote-work article that uses none of the listed terms, and slips past the controlled vocabulary, gets tagged here, and the test measures it. What it costs is not the code, but hand-tagged articles — which a newsroom that has been tagging for years already owns. Its trap is the article that covers several topics: it reaches the threshold on none. The snippet answers with a floor, lower than the threshold, which only applies when at least two topics sit above it — that is the signature of an article splitting itself between topics, not of an article about none. Set that floor on your own archive, as you set the threshold. N2 would lift N1's real limit, adding a topic with no corpus behind it, but it charges a threshold you can only choose with articles already tagged, and an encoder to load in the process that publishes: climb there the day your taxonomy moves faster than you can label.

TF-IDF features and one-versus-rest multi-label classification

No generative AI needed

Lightweight classic modelN1négligeableInstant

Python

snippets/tag-articles-by-topic/n1.pyTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
"""
Tag articles with a one-versus-rest classifier over TF-IDF features.

Rung N1. The controlled vocabulary of N0 sees the words an editor listed. This
sees the words that go with a topic in the articles you have already tagged —
"bureau", "visioconférence" and "domicile" end up carrying the remote
work topic, although no editor would ever have written them in a term list.

One classifier per topic, each answering its own yes-or-no question. That is
what one-versus-rest means, and it is what keeps the tagging multi-label: the
topics do not compete for a single winner, so an article can come back with
two tags, or with none.

The article's TF-IDF vector has length one, though: the more topics it covers,
the less weight each gets. An article made of the training articles of three
topics reaches the threshold on none of them, and the most thorough article of
the week would come back untagged. That is what the floor is for, in `tag`.

The cost of this rung is not the code, which is below. It is the labelled
corpus: articles someone has to tag by hand, and tag again every time the
taxonomy moves.
"""

from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.multiclass import OneVsRestClassifier
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import MultiLabelBinarizer


def train(articles: list[str], topics_per_article: list[list[str]]) -> dict:
    """
    `topics_per_article[i]` is the list of topics of `articles[i]`, possibly
    empty. An untagged article is a useful negative example, not a gap.
    """
    binariser = MultiLabelBinarizer()
    matrix = binariser.fit_transform(topics_per_article)
    pipeline = make_pipeline(
        # Word unigrams and bigrams. Words of one character are dropped first,
        # so "travailler à distance" gives the pair "travailler distance".
        TfidfVectorizer(ngram_range=(1, 2), sublinear_tf=True),
        OneVsRestClassifier(LogisticRegression(C=4.0, max_iter=1000)),
    )
    pipeline.fit(articles, matrix)
    return {"pipeline": pipeline, "topics": list(binariser.classes_)}


def score(model: dict, article: str) -> dict[str, float]:
    """One probability per topic, each independent of the others."""
    probabilities = model["pipeline"].predict_proba([article])[0]
    # With a single topic, scikit-learn solves a binary problem and returns two
    # columns, absent then present: the topics are always the last columns.
    probabilities = probabilities[-len(model["topics"]):]
    return {topic: float(p) for topic, p in zip(model["topics"], probabilities)}


def tag(model: dict, article: str, threshold: float = 0.5, floor: float = 0.3) -> list[str]:
    """
    The topics above the threshold, best first.

    The threshold is yours to set, and it is the first dial. Move it towards 1
    when a wrong tag is worse than a missing one, towards 0 when an editor
    reviews the list anyway and would rather see one topic too many.

    `floor` is the second, and it exists for the article that covers several
    topics at once. Its vector has length one, so the more topics it covers the
    less weight each one gets, and the most thorough article of the week can
    reach the threshold on none of them.

    When nothing reaches the threshold, two things are possible: the article is
    about none of the topics, or it is about several and they shared its
    weight. Two topics above the floor tell the second case from the first, and
    then both come back. One alone does not, because that is what an article
    about nothing looks like. Set `floor` equal to `threshold` to turn the
    whole thing off.
    """
    scored = score(model, article)
    order = lambda topic: (-scored[topic], topic)  # noqa: E731 - read once, used twice
    above = sorted((t for t, value in scored.items() if value >= threshold), key=order)
    if above:
        return above
    near = sorted((t for t, value in scored.items() if value >= floor), key=order)
    return near if len(near) > 1 else []

JavaScript

snippets/tag-articles-by-topic/n1.jsTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
/**
 * Tag articles with a one-versus-rest classifier over TF-IDF features.
 *
 * Rung N1. The controlled vocabulary of N0 sees the words an editor listed.
 * This sees the words that go with a topic in the articles you have already
 * tagged — "bureau", "visioconférence" and "domicile" end up carrying
 * the remote work topic, although no editor would ever have written them in a
 * term list.
 *
 * One classifier per topic, each answering its own yes-or-no question. That is
 * what one-versus-rest means, and it is what keeps the tagging multi-label:
 * the topics do not compete for a single winner, so an article can come back
 * with two tags, or with none.
 *
 * The article's TF-IDF vector has length one, though: the more topics it
 * covers, the less weight each gets. An article made of the training articles
 * of three topics reaches the threshold on none of them, and the most thorough
 * article of the week would come back untagged. That is what the floor is for,
 * in `tag`.
 *
 * Written out in full rather than pulled from a library, with sparse vectors
 * so that training grows with the words each article holds rather than with
 * the whole vocabulary. The cost of this rung is not the code below: it is the
 * labelled corpus someone has to tag by hand, and tag again every time the
 * taxonomy moves.
 */

const TOKEN = /[\p{L}\p{N}_]{2,}/gu;

/**
 * Unigrams and bigrams. Words of one character are dropped first, so
 * "travailler à distance" gives the pair "travailler distance".
 */
function terms(text) {
  const words = text.toLowerCase().match(TOKEN) ?? [];
  const pairs = words.slice(1).map((word, i) => `${words[i]} ${word}`);
  return [...words, ...pairs];
}

/** Sublinear term frequency times inverse document frequency, L2 normalised, as column-value pairs. */
function vectorise(termList, vocabulary, idf) {
  const counts = new Map();
  for (const term of termList) {
    const column = vocabulary.get(term);
    if (column !== undefined) counts.set(column, (counts.get(column) ?? 0) + 1);
  }
  const columns = [...counts.keys()];
  const values = columns.map((column) => (1 + Math.log(counts.get(column))) * idf[column]);
  const norm = Math.sqrt(values.reduce((sum, v) => sum + v * v, 0));
  return { columns, values: norm ? values.map((v) => v / norm) : values };
}

/** The weighted sum of an article's features: the logit of one topic. */
function logit(weights, bias, { columns, values }, scale = 1) {
  let z = bias;
  for (let k = 0; k < columns.length; k += 1) z += scale * weights[columns[k]] * values[k];
  return z;
}

/** One binary classifier, trained by gradient descent one article at a time. */
function fit(rows, labels, width, { epochs, rate, decay }) {
  const weights = new Float64Array(width);
  let bias = 0;
  // The true weights are `scale * weights`. The decay term is the L2 penalty:
  // without it a corpus this small is memorised, and every article comes back
  // scored one or zero. Applied to `scale`, it shrinks every weight in one step.
  let scale = 1;
  for (let epoch = 0; epoch < epochs; epoch += 1) {
    for (let i = 0; i < rows.length; i += 1) {
      const error = 1 / (1 + Math.exp(-logit(weights, bias, rows[i], scale))) - labels[i];
      scale *= 1 - rate * decay;
      const { columns, values } = rows[i];
      for (let k = 0; k < columns.length; k += 1) weights[columns[k]] -= (rate * error * values[k]) / scale;
      bias -= rate * error;
      if (scale < 1e-9) {
        for (let j = 0; j < width; j += 1) weights[j] *= scale;
        scale = 1;
      }
    }
  }
  for (let j = 0; j < width; j += 1) weights[j] *= scale;
  return { weights, bias };
}

/**
 * `topicsPerArticle[i]` is the list of topics of `articles[i]`, possibly
 * empty. An untagged article is a useful negative example, not a gap.
 */
export function train(articles, topicsPerArticle, { epochs = 600, rate = 0.5, decay = 0.005 } = {}) {
  const topics = [...new Set(topicsPerArticle.flat())].sort();
  if (articles.length !== topicsPerArticle.length || topics.length === 0) {
    throw new RangeError('training needs one list of topics per article, and at least one topic');
  }
  const documents = articles.map(terms);
  const frequencies = new Map();
  for (const document of documents) {
    for (const term of new Set(document)) frequencies.set(term, (frequencies.get(term) ?? 0) + 1);
  }
  const vocabulary = new Map([...frequencies.keys()].map((term, column) => [term, column]));
  const idf = [...frequencies.values()].map((df) => Math.log((1 + documents.length) / (1 + df)) + 1);
  const rows = documents.map((document) => vectorise(document, vocabulary, idf));
  const classifiers = topics.map((topic) => {
    const labels = topicsPerArticle.map((list) => (list.includes(topic) ? 1 : 0));
    return fit(rows, labels, idf.length, { epochs, rate, decay });
  });
  return { vocabulary, idf, topics, classifiers };
}

/** One probability per topic, each independent of the others. */
export function score(model, article) {
  const vector = vectorise(terms(article), model.vocabulary, model.idf);
  const scored = {};
  model.topics.forEach((topic, index) => {
    const { weights, bias } = model.classifiers[index];
    scored[topic] = 1 / (1 + Math.exp(-logit(weights, bias, vector)));
  });
  return scored;
}

/**
 * The topics above the threshold, best first.
 *
 * The threshold is yours to set, and it is the first dial. Move it towards 1
 * when a wrong tag is worse than a missing one, towards 0 when an editor
 * reviews the list anyway and would rather see one topic too many.
 *
 * `floor` is the second, and it exists for the article that covers several
 * topics at once. Its vector has length one, so the more topics it covers the
 * less weight each one gets, and the most thorough article of the week can
 * reach the threshold on none of them.
 *
 * When nothing reaches the threshold, two things are possible: the article is
 * about none of the topics, or it is about several and they shared its weight.
 * Two topics above the floor tell the second case from the first, and then both
 * come back. One alone does not, because that is what an article about nothing
 * looks like. Set `floor` equal to `threshold` to turn the whole thing off.
 */
export function tag(model, article, threshold = 0.5, floor = 0.3) {
  const scored = score(model, article);
  const order = (a, b) => scored[b] - scored[a] || (a < b ? -1 : a > b ? 1 : 0);
  const above = model.topics.filter((topic) => scored[topic] >= threshold).sort(order);
  if (above.length > 0) return above;
  const near = model.topics.filter((topic) => scored[topic] >= floor).sort(order);
  return near.length > 1 ? near : [];
}

Try it

interactive

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

The classifiers are trained in your browser as the page loads, on the tagged archive written right beside it. No article leaves the page.

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

Tags attached: Cybersecurity

Each topic answers for itself alone; the publishing threshold sits at 0.50.

In detail

In-house topicClassifier score
Cybersecurity0.70
Remote work0.16
Tax0.12
Hiring0.09

Four classifiers trained on 28 hand-tagged articles.

What this means for you

  • Your data stays put
  • 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

  • No specific scope added: both the labelled corpus and the trained model stay on your infrastructure

Breaking point

The article that covers several topics at once — often the most thorough of the week — reaches the threshold on none of them: its vector has length one, and the topics share its weight. Without the floor in `tag`, an article made of the articles of three topics comes back with no tag at all; the witness is the single-topic article, tagged first time. And a topic nobody labelled does not exist: the article about a subsidy desk comes back empty, and lowering the threshold files it under remote work instead of surfacing the missing topic.

When to move up a level

The newsroom opens a new section and expects tags the same day, before a single article has been labelled by hand.

The four options

  1. N0Controlled vocabulary, term matching after suffix strippingFree · Instant
  2. RecommendedN1TF-IDF features and one-versus-rest multi-label classificationnégligeable · Instant
  3. N2Embedding similarity against the topic descriptions, with no labelled exampleInexpensive · Fast
  4. N3Tagging through a general-purpose model callélevé · About a second

The other options

N0Controlled vocabulary, term matching after suffix strippingFree · Instant

Controlled vocabulary, term matching after suffix stripping

Rule and classic algorithmN0FreeInstant

Python

snippets/tag-articles-by-topic/n0.pyTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
"""
Tag articles from a controlled vocabulary, matched after stripping common endings.

Rung N0. Deterministic, standard library only, and auditable: every tag can be
traced back to the term that produced it, which is what an editor asks for the
first time a tag looks wrong.

Two things make this work.

First, the article and the vocabulary go through the very same pipeline. A
stemmer that is applied to one side only will happily fail to match a word
with itself.

Second, tagging is multi-label by construction. An article covers several
topics, or none; a function that returns one topic per article is answering a
question nobody asked.
"""

import re
import unicodedata

# Endings stripped, longest of any two that overlap first. This is a
# plural-and-suffix stripper, not a lemmatiser: "embauchons" does not become
# "embaucher", "fiscaux" does not become "fiscal", and "poste" and "post" fold
# onto the same stem. Both sides of the comparison get the same treatment.
SUFFIXES = ("ements", "ement", "ations", "ation", "es", "s", "x", "e")

WORD = re.compile(r"[^\W_]+")


def normalise(text: str) -> str:
    """Lowercase and drop accents, so "Fiscalité" and "FISCALITE" meet."""
    # NFD does not split the ligatures, and a soft hyphen or a zero-width
    # space (format characters) would cut a word in two: both are handled here.
    text = text.lower().replace("œ", "oe").replace("æ", "ae")
    decomposed = unicodedata.normalize("NFD", text)
    return "".join(
        c for c in decomposed if not unicodedata.combining(c) and unicodedata.category(c) != "Cf"
    )


def strip_ending(word: str) -> str:
    """Strip one ending, and only when a stem of three letters is left."""
    for suffix in SUFFIXES:
        if word.endswith(suffix) and len(word) - len(suffix) >= 3:
            return word[: -len(suffix)]
    return word


def stems(text: str) -> str:
    """
    The text as a run of stems, padded with spaces at both ends.

    The padding is what lets a multi-word term be found with a plain substring
    search: "impot" can then never match inside "impotent".
    """
    return " " + " ".join(strip_ending(w) for w in WORD.findall(normalise(text))) + " "


def tag(article: str, vocabulary: dict[str, list[str]], min_terms: int = 1) -> list[str]:
    """
    Every topic whose terms appear in the article, best supported first.

    `vocabulary` maps a topic name to the terms that stand for it, single or
    multi-word. Keeping it a parameter is the point of this rung: the taxonomy
    belongs to whoever edits the articles, not to the code.

    `min_terms` is how many distinct terms a topic needs before it is claimed.
    Raise it when a single passing mention is not enough to file an article.
    """
    if min_terms < 1:
        raise ValueError("min_terms must be at least 1: a topic needs a term")
    haystack = stems(article)
    hits = {}
    for topic, terms in vocabulary.items():
        remaining, found = haystack, 0
        # Longest first, and a term found is removed from the text: "impot"
        # inside "credit d impot" is the same mention, not a second term.
        for term in sorted({stems(term) for term in terms}, key=len, reverse=True):
            if term.strip() and term in remaining:
                found += 1
                remaining = remaining.replace(term, " ")
        if found >= min_terms:
            hits[topic] = found
    # Best supported first, then by name once lowercased and stripped of
    # accents, the same order in both languages: a tagging run has to be
    # replayable, and a set has no order to replay.
    return sorted(hits, key=lambda topic: (-hits[topic], normalise(topic), topic))

JavaScript

snippets/tag-articles-by-topic/n0.jsTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
/**
 * Tag articles from a controlled vocabulary, matched after stripping common
 * endings.
 *
 * Rung N0. Deterministic, no dependency, and auditable: every tag can be
 * traced back to the term that produced it, which is what an editor asks for
 * the first time a tag looks wrong.
 *
 * Two things make this work.
 *
 * First, the article and the vocabulary go through the very same pipeline. A
 * stemmer that is applied to one side only will happily fail to match a word
 * with itself.
 *
 * Second, tagging is multi-label by construction. An article covers several
 * topics, or none; a function that returns one topic per article is answering
 * a question nobody asked.
 */

// Endings stripped, longest of any two that overlap first. This is a
// plural-and-suffix stripper, not a lemmatiser: "embauchons" does not become
// "embaucher", "fiscaux" does not become "fiscal", and "poste" and "post" fold
// onto the same stem. Both sides of the comparison get the same treatment.
const SUFFIXES = ['ements', 'ement', 'ations', 'ation', 'es', 's', 'x', 'e'];

const WORD = /[\p{L}\p{N}]+/gu;

/** Lowercase and drop accents, so "Fiscalité" and "FISCALITE" meet. */
export function normalise(text) {
  // NFD does not split the ligatures, and a soft hyphen or a zero-width space
  // (format characters) would cut a word in two: both are handled here.
  const unfolded = text.toLowerCase().replaceAll('œ', 'oe').replaceAll('æ', 'ae');
  return unfolded.normalize('NFD').replace(/[\p{M}\p{Cf}]+/gu, '');
}

/** Strip one ending, and only when a stem of three letters is left. */
export function stripEnding(word) {
  for (const suffix of SUFFIXES) {
    if (word.endsWith(suffix) && word.length - suffix.length >= 3) {
      return word.slice(0, -suffix.length);
    }
  }
  return word;
}

/**
 * The text as a run of stems, padded with spaces at both ends.
 *
 * The padding is what lets a multi-word term be found with a plain substring
 * search: "impot" can then never match inside "impotent".
 */
export function stems(text) {
  const found = normalise(text).match(WORD) ?? [];
  return ` ${found.map(stripEnding).join(' ')} `;
}

/**
 * Every topic whose terms appear in the article, best supported first.
 *
 * `vocabulary` maps a topic name to the terms that stand for it, single or
 * multi-word. Keeping it a parameter is the point of this rung: the taxonomy
 * belongs to whoever edits the articles, not to the code.
 *
 * `minTerms` is how many distinct terms a topic needs before it is claimed.
 * Raise it when a single passing mention is not enough to file an article.
 */
export function tag(article, vocabulary, minTerms = 1) {
  if (!(minTerms >= 1)) throw new RangeError('minTerms must be at least 1: a topic needs a term');
  const haystack = stems(article);
  const hits = new Map();
  for (const [topic, terms] of Object.entries(vocabulary)) {
    let remaining = haystack;
    let found = 0;
    // Longest first, and a term found is removed from the text: "impot"
    // inside "credit d impot" is the same mention, not a second term.
    for (const term of [...new Set(terms.map(stems))].sort((a, b) => b.length - a.length)) {
      if (term.trim() && remaining.includes(term)) {
        found += 1;
        remaining = remaining.replaceAll(term, ' ');
      }
    }
    if (found >= minTerms) hits.set(topic, found);
  }
  // Best supported first, then by name once lowercased and stripped of
  // accents, the same order in both languages: a tagging run has to be
  // replayable, and a set has no order to replay.
  const byName = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
  return [...hits.keys()].sort(
    (a, b) => hits.get(b) - hits.get(a) || byName(normalise(a), normalise(b)) || byName(a, b),
  );
}

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: the article and the taxonomy never leave your infrastructure

Breaking point

A topic covered from end to end without ever being named. “The team is only in the office on Tuesdays, meetings are held over video”: that is remote work from the first word to the last, not one term of the list appears in it, and the article comes back untagged. The only repair is to add a term after each missed article, and the next one, written another way, slips through again.

When to move up a level

Your term list grows at every editorial review, because articles keep coming back empty although they plainly cover a topic.

N2Embedding similarity against the topic descriptions, with no labelled exampleInexpensive · Fast

Embedding similarity against the topic descriptions, with no labelled example

Small self-hosted specialised modelN2InexpensiveFast

Python

snippets/tag-articles-by-topic/n2.pyTested, service simulated — This snippet runs on every build of the site, but its test replaces the external service with a local double: the request sent, the response decoded and the error paths are verified, the real provider’s answer is not.
"""
Zero-shot tagging: compare the article with the labels themselves.

Rung N2. N1 needed a labelled corpus, and a new topic meant labelling it all
again. Here a topic is only a short description, encoded like any other text:
adding one costs a line, and the first article can be tagged the same day.

That is the whole appeal, and it is real. What it costs is a model file to
ship and keep in sync, a warm process to hold it, and a score that is a cosine
rather than a probability: it can be negative, the topics of one article do
not add up to one, and a single threshold has to serve every topic.
"""

from __future__ import annotations

import re

import numpy as np

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

# The encoder reads 128 tokens and silently drops the rest (`max_seq_length`
# in the model's sentence_bert_config.json). With its tokenizer, 400 characters
# of French prose come to 110 tokens, so the article is encoded in passages of
# that size. Text the tokenizer cuts finer, code or a table, can still overflow.
PASSAGE_CHARACTERS = 400

SENTENCE_END = re.compile(r"(?<=[.!?])\s+|\s*\n\s*")


def load_encoder(name: str = MODEL_NAME):
    """The real encoder: fetched once, then held in memory and run locally."""
    from sentence_transformers import SentenceTransformer  # pragma: no cover

    return SentenceTransformer(name)


def build_labeller(topics: dict[str, str], encoder=None) -> dict:
    """
    Encode the topic descriptions once, and keep the encoder for the articles.

    `topics` maps a topic name to the sentence that stands for it. Write it as
    a human would say it out loud: the model card describes an encoder of
    sentences and paragraphs.

    `encoder` is injected so this can be tested without loading a model. Left
    alone, it is the real one above.
    """
    encoder = load_encoder() if encoder is None else encoder
    names = list(topics)
    vectors = _encode(encoder, [topics[name] for name in names])
    return {"topics": names, "vectors": vectors, "encoder": encoder}


def passages(article: str, size: int = PASSAGE_CHARACTERS) -> list[str]:
    """Consecutive sentences joined into pieces of at most `size` characters."""
    pieces: list[str] = []
    for sentence in filter(None, (s.strip() for s in SENTENCE_END.split(article.strip()))):
        if pieces and len(pieces[-1]) + 1 + len(sentence) <= size:
            pieces[-1] = f"{pieces[-1]} {sentence}"
        else:
            pieces.append(sentence)  # a sentence longer than `size` stays whole
    return pieces


def score(labeller: dict, article: str) -> dict[str, float]:
    """
    The best cosine, over the passages of the article, with each topic
    description: a topic covered in one paragraph counts.
    """
    pieces, known = passages(article), labeller["vectors"]
    if not pieces or not labeller["topics"]:
        return {topic: 0.0 for topic in labeller["topics"]}
    vectors = _encode(labeller["encoder"], pieces)
    if vectors.shape[1] != known.shape[1]:
        raise ValueError("the encoder returned vectors of another width for the article")
    # One row per passage, one column per topic: a matrix of cosines.
    best = (vectors @ known.T).max(axis=0)
    return {topic: float(value) for topic, value in zip(labeller["topics"], best)}


def tag(labeller: dict, article: str, threshold: float = 0.3) -> list[str]:
    """
    Every topic whose description is close enough, best first.

    Multi-label falls out of the shape of the thing: each topic is compared
    with the article on its own, so several can pass, or none.

    The threshold is not a probability. It is a cosine, it has no calibrated
    meaning, and choosing it by its effect on errors takes articles whose
    topics are already known — a labelled corpus, the very thing this rung was
    supposed to save you.
    """
    scored = score(labeller, article)
    kept = [topic for topic, value in scored.items() if value >= threshold]
    return sorted(kept, key=lambda topic: (-scored[topic], topic))


def _encode(encoder, texts: list[str]) -> np.ndarray:
    """
    One row of length one per text, or an error: a wrong count, a ragged
    table or a non-number is not a score. Cosine similarity is a dot product
    once both sides have length one.
    """
    raw = encoder.encode(texts)
    if len(raw) != len(texts):
        raise ValueError("the encoder returned a vector count that does not match the texts")
    if not texts:
        return np.zeros((0, 0))
    vectors = np.asarray(raw, dtype=float)  # raises on rows of different widths
    if vectors.ndim != 2 or not np.isfinite(vectors).all():
        raise ValueError("the encoder returned something that is not a table of finite numbers")
    norms = np.linalg.norm(vectors, axis=1, keepdims=True)
    return vectors / np.where(norms == 0, 1.0, norms)

JavaScript

snippets/tag-articles-by-topic/n2.jsTested, service simulated — This snippet runs on every build of the site, but its test replaces the external service with a local double: the request sent, the response decoded and the error paths are verified, the real provider’s answer is not.
/**
 * Zero-shot tagging: compare the article with the labels themselves.
 *
 * Rung N2. N1 needed a labelled corpus, and a new topic meant labelling it all
 * again. Here a topic is only a short description, encoded like any other
 * text: adding one costs a line, and the first article can be tagged the same
 * day.
 *
 * That is the whole appeal, and it is real. What it costs is a model file to
 * ship and keep in sync, a warm process to hold it, and a score that is a
 * cosine rather than a probability: it can be negative, the topics of one
 * article do not add up to one, and a single threshold has to serve every
 * topic.
 */

// The ONNX conversion of sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2.
export const MODEL_NAME = 'Xenova/paraphrase-multilingual-MiniLM-L12-v2';

// The Python encoder reads 128 tokens and silently drops the rest. With this
// model's tokenizer, 400 characters of French prose come to 110 tokens, so the
// article is encoded in passages of that size, in both languages. Text the
// tokenizer cuts finer, code or a table, can still overflow.
export const PASSAGE_CHARACTERS = 400;

const SENTENCE_END = /(?<=[.!?])\s+|\s*\n\s*/;

/** The real encoder: fetched once, then held in memory and run locally. */
export async function loadEncoder(name = MODEL_NAME) {
  const { pipeline } = await import('@huggingface/transformers');
  // Full-precision weights, the ones Python loads, rather than a quantised copy.
  const extract = await pipeline('feature-extraction', name, { dtype: 'fp32' });
  return { encode: async (texts) => (await extract(texts, { pooling: 'mean' })).tolist() };
}

/**
 * Encode the topic descriptions once, and keep the encoder for the articles.
 *
 * `topics` maps a topic name to the sentence that stands for it. Write it as a
 * human would say it out loud: the model card describes an encoder of
 * sentences and paragraphs.
 *
 * `encoder` is injected so this can be tested without loading a model. Left
 * alone, it is the real one above.
 */
export async function buildLabeller(topics, encoder) {
  const model = encoder ?? (await loadEncoder());
  const names = Object.keys(topics);
  const vectors = await encode(model, names.map((name) => topics[name]));
  return { topics: names, vectors, encoder: model };
}

/** Consecutive sentences joined into pieces of at most `size` characters. */
export function passages(article, size = PASSAGE_CHARACTERS) {
  const pieces = [];
  for (const sentence of article.trim().split(SENTENCE_END).map((s) => s.trim()).filter(Boolean)) {
    if (pieces.length && pieces.at(-1).length + 1 + sentence.length <= size) {
      pieces[pieces.length - 1] = `${pieces.at(-1)} ${sentence}`;
    } else {
      pieces.push(sentence); // a sentence longer than `size` stays whole
    }
  }
  return pieces;
}

/**
 * The best cosine, over the passages of the article, with each topic
 * description: a topic covered in one paragraph counts.
 */
export async function score(labeller, article) {
  const pieces = passages(article);
  const vectors = pieces.length ? await encode(labeller.encoder, pieces) : [];
  const width = labeller.vectors[0]?.length;
  if (width !== undefined && vectors.some((vector) => vector.length !== width)) {
    throw new Error('the encoder returned vectors of another width for the article');
  }
  const scored = {};
  labeller.topics.forEach((topic, index) => {
    scored[topic] = vectors.length ? Math.max(...vectors.map((v) => dot(labeller.vectors[index], v))) : 0;
  });
  return scored;
}

/**
 * Every topic whose description is close enough, best first.
 *
 * Multi-label falls out of the shape of the thing: each topic is compared with
 * the article on its own, so several can pass, or none.
 *
 * The threshold is not a probability. It is a cosine, it has no calibrated
 * meaning, and choosing it by its effect on errors takes articles whose topics
 * are already known — a labelled corpus, the very thing this rung was supposed
 * to save you.
 */
export async function tag(labeller, article, threshold = 0.3) {
  const scored = await score(labeller, article);
  return labeller.topics
    .filter((topic) => scored[topic] >= threshold)
    .sort((a, b) => scored[b] - scored[a] || (a < b ? -1 : a > b ? 1 : 0));
}

/** One unit vector per text, or an error: a wrong count or a non-number is not a score. */
async function encode(encoder, texts) {
  const vectors = (await encoder.encode(texts)).map((vector) => Array.from(vector, Number));
  if (vectors.length !== texts.length || new Set(vectors.map((v) => v.length)).size > 1) {
    throw new Error('the encoder returned the wrong number or shape of vectors');
  }
  if (!vectors.every((vector) => vector.every(Number.isFinite))) {
    throw new Error('the encoder returned a value that is not a finite number');
  }
  return vectors.map(unit);
}

/** Cosine similarity is a dot product once both sides have length one. */
function unit(vector) {
  let sum = 0;
  for (const v of vector) sum += v * v;
  const norm = Math.sqrt(sum);
  return norm ? vector.map((v) => v / norm) : [...vector];
}

function dot(a, b) {
  let total = 0;
  for (let i = 0; i < a.length; i += 1) total += a[i] * b[i];
  return total;
}

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
  • Moderate footprint

What this adds to your regulatory scope

  • Processing on your own infrastructure: neither the article nor the topic descriptions leave it
  • The encoder weights come from a third party, with the licence and training-data provenance that implies

Breaking point

The score is a cosine, not a probability: it can be negative, the scores of one article do not add up to one, and a single threshold serves every topic. The test shows it on vectors written by hand. Choosing that threshold by the mistakes it makes takes articles whose topics are already known: the very corpus this level was meant to save. What the same cosine is worth from one topic to the next with the real encoder, no test here measures.

When to move up a level

No setting of the threshold gives you both the real secondary topics and silence on the absent ones, and your reviewers remove more tags than they keep.

N3Tagging through a general-purpose model callélevé · About a second

Tagging through a general-purpose model call

General-purpose LLM APIN3élevéAbout a second

Python

snippets/tag-articles-by-topic/n3.pyTested, service simulated — This snippet runs on every build of the site, but its test replaces the external service with a local double: the request sent, the response decoded and the error paths are verified, the real provider’s answer is not.
"""
Tag articles by asking a general-purpose model.

Rung N3. On this entry it is the only rung that needs neither a term list, nor
a labelled corpus, nor a model file. It is here so you can see what it costs,
not because this entry recommends it.

Note what the code has to do that N0 did not: cap the input size, retry on
failure, parse an answer that is only probably valid JSON, and keep only the
topics that exist in your taxonomy. That last one is not optional — nothing
stops a model from answering a name that is not on the list — and it is why
the vocabulary stays a parameter here, exactly as it was on the bottom rung.

That plumbing is the real cost of this rung, and it is the part your tests have
to cover, because a test against a double says nothing of how the model tags.
"""

from __future__ import annotations

import json
import unicodedata

PROMPT = (
    "Tag the article below with the topics it covers.\n"
    "Choose only from this list, and answer with the spellings given:\n"
    "{topics}\n"
    "An article may cover several topics, or none at all.\n"
    "Answer with JSON only: a list of topic names, empty if none apply.\n\n"
    "Article:\n{article}"
)

MAX_CHARACTERS = 12000

# The provider named here is an example, not a recommendation: the reasoning
# holds for any general-purpose model API, and the client is swappable. Pass
# any object with a `complete(prompt=..., temperature=...)` method.
MODEL = "gpt-4.1-mini"  # an example id: check the parameters your model accepts


class ProviderClient:
    """The one call this snippet makes, on top of the provider's SDK."""

    def __init__(self, sdk=None, model: str = MODEL):
        if sdk is None:  # pragma: no cover - needs a key and a network
            from openai import OpenAI

            sdk = OpenAI()
        self.sdk, self.model = sdk, model

    def complete(self, *, prompt: str, temperature: float) -> str:
        response = self.sdk.chat.completions.create(
            model=self.model,
            messages=[{"role": "user", "content": prompt}],
            temperature=temperature,
        )
        return response.choices[0].message.content


class TaggingUnavailable(Exception):
    """The provider could not be reached, or answered something unusable."""


def tag(article: str, topics: list[str], client=None, *, attempts: int = 3) -> list[str]:
    """
    The topics of the article, in the order of the taxonomy.

    `client` is injected so this function can be tested without a network call.
    In production it defaults to a real provider client.
    """
    # A model charges by the token. Refusing oversized input is not an
    # optimisation, it is a cost control.
    if len(article) > MAX_CHARACTERS:
        raise ValueError(f"article longer than {MAX_CHARACTERS} characters")
    # No text or no topic: the answer is known, and asking would still be billed.
    if not article.strip() or not topics:
        return []
    return _ask(client or ProviderClient(), article, topics, attempts)


def _key(name: str) -> str:
    """The same name whatever its case, its spacing or its Unicode form."""
    return unicodedata.normalize("NFC", name).strip().casefold()


def _ask(client, article: str, topics: list[str], attempts: int) -> list[str]:
    prompt = PROMPT.format(topics="\n".join(f"- {topic}" for topic in topics), article=article)
    last_error: Exception | None = None
    for _ in range(attempts):
        try:
            # Temperature zero, the low end of the range, which the provider
            # documents as more focused and deterministic. It does not promise
            # that two identical calls agree.
            answer = client.complete(prompt=prompt, temperature=0)
        except Exception as error:  # noqa: BLE001 - any provider failure is retried
            last_error = error
            continue
        try:
            return _decode(answer, topics)
        except ValueError as error:  # an unusable answer is asked for again
            last_error = error
    raise TaggingUnavailable(str(last_error))


def _decode(answer, topics: list[str]) -> list[str]:
    """
    Keep only what the taxonomy knows, in the taxonomy's own order. A model
    that answers "actualité juridique" must not create a topic in your
    database, and the order of its answer must not decide the order of yours.
    """
    if not isinstance(answer, str):  # the SDK types the content as optional
        raise ValueError("the model returned no text")
    # A JSON answer wrapped whole in one code fence is read; nothing else is:
    # a fence opened and never closed, or prose around it, is a failed answer.
    text = answer.strip()
    if text.startswith("```") and text.endswith("```") and text.count("```") == 2:
        text = text[3:-3].removeprefix("json")
    try:
        parsed = json.loads(text)
    except RecursionError as error:
        raise ValueError("the answer is nested too deep to read") from error
    # A list of anything but names, or of names none of which is on the list,
    # must not pass for the legitimate empty answer.
    if not isinstance(parsed, list) or not all(isinstance(name, str) for name in parsed):
        raise ValueError("the model answered something that is not a list of names")
    answered = {_key(name) for name in parsed}
    kept = [topic for topic in topics if _key(topic) in answered]
    if parsed and not kept:
        raise ValueError("the model answered only names that are not in the taxonomy")
    return kept

JavaScript

snippets/tag-articles-by-topic/n3.jsTested, service simulated — This snippet runs on every build of the site, but its test replaces the external service with a local double: the request sent, the response decoded and the error paths are verified, the real provider’s answer is not.
/**
 * Tag articles by asking a general-purpose model.
 *
 * Rung N3. On this entry it is the only rung that needs neither a term list,
 * nor a labelled corpus, nor a model file. It is here so you can see what it
 * costs, not because this entry recommends it.
 *
 * Note what the code has to do that N0 did not: cap the input size, retry on
 * failure, parse an answer that is only probably valid JSON, and keep only the
 * topics that exist in your taxonomy. That last one is not optional — nothing
 * stops a model from answering a name that is not on the list — and it is why
 * the vocabulary stays a parameter here, exactly as it was on the bottom rung.
 *
 * That plumbing is the real cost of this rung, and it is the part your tests
 * have to cover, because a test against a double says nothing of how the model
 * tags.
 */

const PROMPT = [
  'Tag the article below with the topics it covers.',
  'Choose only from this list, and answer with the spellings given:',
  '{topics}',
  'An article may cover several topics, or none at all.',
  'Answer with JSON only: a list of topic names, empty if none apply.',
  '',
  'Article:',
].join('\n');

export const MAX_CHARACTERS = 12000;

// The provider named here is an example, not a recommendation: the reasoning
// holds for any general-purpose model API, and the client is swappable. Pass
// any object with a `complete({ prompt, temperature })` method.
export const MODEL = 'gpt-4.1-mini'; // an example id: check the parameters your model accepts

export async function providerClient(sdk, model = MODEL) {
  if (!sdk) {
    const { OpenAI } = await import('openai');
    sdk = new OpenAI();
  }
  return {
    async complete({ prompt, temperature }) {
      const response = await sdk.chat.completions.create({
        model,
        messages: [{ role: 'user', content: prompt }],
        temperature,
      });
      return response.choices[0].message.content;
    },
  };
}

export class TaggingUnavailable extends Error {}

/**
 * The topics of the article, in the order of the taxonomy.
 *
 * @param {string} article
 * @param {string[]} topics the controlled vocabulary, as on rung N0
 * @param {object} options
 * @param {{complete: Function}} [options.client] injected so this can be
 *   tested without a network call; defaults to a real provider client
 * @param {number} [options.attempts]
 */
export async function tag(article, topics, { client, attempts = 3 } = {}) {
  // A model charges by the token. Refusing oversized input is not an
  // optimisation, it is a cost control. Counted in code points, as Python
  // counts characters.
  if ([...article].length > MAX_CHARACTERS) {
    throw new RangeError(`article longer than ${MAX_CHARACTERS} characters`);
  }
  // No text or no topic: the answer is known, and asking would still be billed.
  if (!article.trim() || topics.length === 0) return [];
  return ask(client ?? (await providerClient()), article, topics, attempts);
}

/** The same name whatever its case, its spacing or its Unicode form. */
const key = (name) => name.normalize('NFC').trim().toLowerCase();

async function ask(client, article, topics, attempts) {
  const prompt = `${PROMPT.replace('{topics}', topics.map((t) => `- ${t}`).join('\n'))}\n${article}`;
  let lastError;
  for (let i = 0; i < attempts; i += 1) {
    let answer;
    try {
      // Temperature zero, the low end of the range, which the provider
      // documents as more focused and deterministic. It does not promise
      // that two identical calls agree.
      answer = await client.complete({ prompt, temperature: 0 });
    } catch (error) {
      lastError = error; // any provider failure is retried
      continue;
    }
    try {
      return decode(answer, topics);
    } catch (error) {
      lastError = error; // an unusable answer is asked for again
    }
  }
  throw new TaggingUnavailable(String(lastError));
}

/**
 * Keep only what the taxonomy knows, in the taxonomy's own order. A model that
 * answers "actualité juridique" must not create a topic in your database, and
 * the order of its answer must not decide the order of yours.
 */
function decode(answer, topics) {
  if (typeof answer !== 'string') throw new Error('the model returned no text');
  // A JSON answer wrapped whole in one code fence is read; nothing else is: a
  // fence opened and never closed, or prose around it, is a failed answer.
  const text = answer.trim();
  const fenced = text.startsWith('```') && text.endsWith('```') && text.split('```').length === 3;
  const parsed = JSON.parse(fenced ? text.slice(3, -3).replace(/^json/, '') : text);
  // A list of anything but names, or of names none of which is on the list,
  // must not pass for the legitimate empty answer.
  if (!Array.isArray(parsed) || !parsed.every((name) => typeof name === 'string')) {
    throw new Error('the model answered something that is not a list of names');
  }
  const answered = new Set(parsed.map(key));
  const kept = topics.filter((topic) => answered.has(key(topic)));
  if (parsed.length && !kept.length) {
    throw new Error('the model answered only names that are not in the taxonomy');
  }
  return kept;
}

What this means for you

  • Your data goes to a third party
  • The result varies from one call to the next
  • Hard to test
  • Depends on an outside provider
  • High footprint

What this adds to your regulatory scope

  • Transfer of the article text to a processor, with the contractual framing that implies
  • Processing location to be confirmed with the provider
  • Does not excuse you from confidentiality commitments made on articles not yet published

Breaking point

The answer is free text: the snippet asks for JSON, it cannot enforce it, and "Sure! Here are the topics of this article:" comes down the same channel as a list. An empty list is a legitimate answer here, since some articles carry no topic at all: swallowing the error would make a failure indistinguishable from a correct result, and articles would drop out of every section page without a sound. The snippet raises instead, for prose as for a list of anything but names or of names none of which is in the taxonomy, and the caller decides.

When to move up a level

There is no level above this one.

Further reading

Metadata