Extraire les mots-clés d’un document

Dégager les termes qui caractérisent un document, pour l’indexer, l’étiqueter ou en donner un aperçu.

Pas besoin d’IA générative

GratuitRapideVos données ne sortent pas

Le verdict

N1, parce que « caractériser » veut dire « distinguer » et que distinguer demande une collection. Le test de la fiche met les deux niveaux côte à côte sur le même contrat : lu seul, il rend « conditions générales » et « présentes conditions générales » ; lu dans son dossier de quatre contrats, il rend « moulins », « café » et « moulin », et le passe-partout tombe à zéro. Ces trois-là sont à égalité de score, et c’est le cas ordinaire sur des documents courts : le classement se départage alors sur la longueur du terme puis sur son rang d’apparition, jamais sur l’alphabet, et `tied_at_cut` dit combien d’autres partagent le score du dernier retenu. Les deux niveaux partagent le même découpage et la même liste de mots vides ; seul le classement change. Trois décisions portent le reste. La liste de mots vides est exigée de l’appelant, parce qu’elle est propre à chaque langue et qu’elle est tout le travail. Le logarithme n’est pas planché à un comme le fait `scikit-learn` : un terme présent dans tous vos documents n’est le mot-clé d’aucun, et l’arrondir à « un peu » le remettrait dans la liste. Et les outils du domaine sont nommés sans être appelés : `yake` en Python est sous LGPL, et `keyword-extractor` en JavaScript publie un paquet sans licence déclarée.

Les mêmes candidats, pesés contre le reste de la collection

Pas besoin d’IA générative

Modèle classique légerN1GratuitRapide

Python

snippets/extract-key-terms-from-a-document/n1.pyTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
"""
Les termes qui distinguent ce document des autres du même dossier.

Niveau N1. Le niveau N0 lit un document et ne peut dire que ce qu'il répète.
Tous les contrats d'un dossier de contrats répètent « conditions générales de
vente », et relire ce contrat-là plus attentivement ne révélera jamais que
l'expression ne vaut rien : l'information est dans le dossier, pas dans le
document.

Les candidats sont donc les mêmes qu'au niveau N0 — même découpage, même
liste de mots vides — et seul le classement change. Chaque mot reçoit le
logarithme du nombre de documents divisé par le nombre de ceux qui le
contiennent, et une expression vaut la moyenne de ses mots, multipliée par le
nombre de fois où le document la dit. Un mot présent dans tous les documents
vaut alors exactement zéro, et c'est tout l'objet.

Les égalités sont le cas ordinaire, pas l'exception. Sur un document court,
chaque expression apparaît une fois et dans aucun autre document : elles
valent toutes log(N), et sur un compte rendu de conseil municipal, neuf
termes partagent le même score. Ce qui les départage doit donc vouloir dire
quelque chose — l'expression la plus longue d'abord, parce qu'elle en dit
plus, puis celle qui apparaît le plus tôt, parce qu'un document annonce son
sujet tôt. La chaîne ne vient qu'en dernier, et seulement pour que les deux
langages répondent dans le même ordre : classer par l'alphabet mettait le
sujet du document là où tombe son initiale. Et `tied_at_cut` dit combien de
termes restés dehors partagent le score du dernier retenu, pour qu'un
appelant qui lit « les cinq meilleurs » de douze équivalents le sache.

Deux documents ne sont pas une collection : avec deux, log(2/1) vaut 0,69 et
log(2/2) vaut zéro, si bien que le classement est binaire — présent chez
l'autre, ou non. Le plancher est à deux parce qu'en dessous il n'y a rien à
comparer, pas parce que deux suffisent.

Une différence assumée avec la formule habituelle : `scikit-learn` lisse, et
plancher ce logarithme à un fait qu'un terme présent partout pèse encore
quelque chose. C'est juste quand les poids alimentent une similarité — la
fiche sur les articles voisins emploie la forme lissée pour cette raison.
C'est faux ici : un terme présent dans tous les documents de votre collection
n'est le mot-clé d'aucun d'eux, et l'arrondir à « un peu » le remettrait dans
la liste.

Le modèle, c'est la collection. Il s'ajuste en mémoire, en une passe, à
partir des documents que vous détenez déjà ; il n'y a rien à télécharger et
rien à envoyer.
"""

from __future__ import annotations

import math

from n0 import candidates_of, extract_key_terms

# Kept so the two rungs are told apart in a report that mixes them.
SOURCE = "corpus"


def extract_key_terms_in_corpus(documents, stop_words, *, top: int = 8) -> dict:
    """
    For each document, the phrases the rest of the collection does not share.

    `documents` is the collection itself: the ranking is only as good as what
    you compare against, and two documents are not a collection.
    """
    if not isinstance(documents, (list, tuple)):
        return {"documents": [], "reason": f"expected a list, not {type(documents).__name__}"}
    if len(documents) < 2:
        return {"documents": [], "reason": "a corpus of at least two documents is required"}
    stop = list(stop_words or [])
    if not stop:
        return {"documents": [], "reason": "a stop list is required, one per language"}

    per_document = [candidates_of(text if isinstance(text, str) else "", stop)
                    for text in documents]
    holders = {}
    for phrases in per_document:
        for word in {word for phrase in phrases for word in phrase.split(" ")}:
            holders[word] = holders.get(word, 0) + 1
    total = len(documents)
    idf = {word: math.log(total / count) for word, count in holders.items()}

    ranked = []
    for index, text in enumerate(documents):
        counted = extract_key_terms(text if isinstance(text, str) else "", stop,
                                    top=len(per_document[index]) or 1)["terms"]
        terms = [{"text": term["text"], "key": term["key"], "count": term["count"],
                  "first": term["first"],
                  "score": _rounded(term["count"] * _mean(term["key"], idf))}
                 for term in counted]
        # Equal scores are the ordinary case, not the exception: on a short
        # document every phrase appears once and in no other document, so every
        # one of them scores log(N). What breaks the tie has to mean something
        # — the longer phrase first, because it says more, then the one that
        # appears earliest, because a document states its subject early. The
        # key comes last, and only so that two languages answer in the same
        # order.
        terms.sort(key=lambda t: (-t["score"], -t["count"],
                                  -len(t["key"].split(" ")), t["first"], t["key"]))
        kept = terms[:top]
        # How many terms outside the cut share the score of the last one kept:
        # a caller that reads « the top five » of twelve equals should know.
        tied = 0
        if kept and len(terms) > len(kept):
            tied = sum(1 for term in terms[len(kept):] if term["score"] == kept[-1]["score"])
        ranked.append({"index": index, "terms": kept, "tied_at_cut": tied})
    return {"documents": ranked, "reason": None}


def _mean(key: str, idf: dict) -> float:
    words = key.split(" ")
    return sum(idf.get(word, 0.0) for word in words) / len(words)


def _rounded(value: float) -> float:
    """
    Four decimals, by the same two operations in both languages: Python and
    JavaScript do not round a half the same way, and a ranking must not depend
    on which one ran.
    """
    return math.floor(value * 10000 + 0.5) / 10000

JavaScript

snippets/extract-key-terms-from-a-document/n1.jsTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
/**
 * Les termes qui distinguent ce document des autres du même dossier.
 *
 * Niveau N1. Le niveau N0 lit un document et ne peut dire que ce qu'il répète.
 * Tous les contrats d'un dossier de contrats répètent « conditions générales de
 * vente », et relire ce contrat-là plus attentivement ne révélera jamais que
 * l'expression ne vaut rien : l'information est dans le dossier, pas dans le
 * document.
 *
 * Les candidats sont donc les mêmes qu'au niveau N0 — même découpage, même
 * liste de mots vides — et seul le classement change. Chaque mot reçoit le
 * logarithme du nombre de documents divisé par le nombre de ceux qui le
 * contiennent, et une expression vaut la moyenne de ses mots, multipliée par le
 * nombre de fois où le document la dit. Un mot présent dans tous les documents
 * vaut alors exactement zéro, et c'est tout l'objet.
 *
 * Les égalités sont le cas ordinaire, pas l'exception. Sur un document court,
 * chaque expression apparaît une fois et dans aucun autre document : elles
 * valent toutes log(N), et sur un compte rendu de conseil municipal, neuf
 * termes partagent le même score. Ce qui les départage doit donc vouloir dire
 * quelque chose — l'expression la plus longue d'abord, parce qu'elle en dit
 * plus, puis celle qui apparaît le plus tôt, parce qu'un document annonce son
 * sujet tôt. La chaîne ne vient qu'en dernier, et seulement pour que les deux
 * langages répondent dans le même ordre : classer par l'alphabet mettait le
 * sujet du document là où tombe son initiale. Et `tied_at_cut` dit combien de
 * termes restés dehors partagent le score du dernier retenu, pour qu'un
 * appelant qui lit « les cinq meilleurs » de douze équivalents le sache.
 *
 * Deux documents ne sont pas une collection : avec deux, log(2/1) vaut 0,69 et
 * log(2/2) vaut zéro, si bien que le classement est binaire — présent chez
 * l'autre, ou non. Le plancher est à deux parce qu'en dessous il n'y a rien à
 * comparer, pas parce que deux suffisent.
 *
 * Une différence assumée avec la formule habituelle : `scikit-learn` lisse, et
 * plancher ce logarithme à un fait qu'un terme présent partout pèse encore
 * quelque chose. C'est juste quand les poids alimentent une similarité — la
 * fiche sur les articles voisins emploie la forme lissée pour cette raison.
 * C'est faux ici : un terme présent dans tous les documents de votre collection
 * n'est le mot-clé d'aucun d'eux, et l'arrondir à « un peu » le remettrait dans
 * la liste.
 *
 * Le modèle, c'est la collection. Il s'ajuste en mémoire, en une passe, à
 * partir des documents que vous détenez déjà ; il n'y a rien à télécharger et
 * rien à envoyer.
 */

import { candidatesOf, extractKeyTerms } from './n0.js';

// Kept so the two rungs are told apart in a report that mixes them.
export const SOURCE = 'corpus';

/**
 * For each document, the phrases the rest of the collection does not share.
 *
 * `documents` is the collection itself: the ranking is only as good as what
 * you compare against, and two documents are not a collection.
 */
export function extractKeyTermsInCorpus(documents, stopWords, { top = 8 } = {}) {
  if (!Array.isArray(documents)) {
    return { documents: [], reason: `expected a list, not ${typeof documents}` };
  }
  if (documents.length < 2) {
    return { documents: [], reason: 'a corpus of at least two documents is required' };
  }
  const stop = [...(stopWords ?? [])];
  if (stop.length === 0) {
    return { documents: [], reason: 'a stop list is required, one per language' };
  }

  const perDocument = documents.map((text) => candidatesOf(typeof text === 'string' ? text : '', stop));
  const holders = new Map();
  for (const phrases of perDocument) {
    const words = new Set(phrases.flatMap((phrase) => phrase.split(' ')));
    for (const word of words) holders.set(word, (holders.get(word) ?? 0) + 1);
  }
  const total = documents.length;
  const idf = new Map([...holders].map(([word, count]) => [word, Math.log(total / count)]));

  const ranked = documents.map((text, index) => {
    const counted = extractKeyTerms(typeof text === 'string' ? text : '', stop,
      { top: perDocument[index].length || 1 }).terms;
    const terms = counted.map((term) => ({
      text: term.text,
      key: term.key,
      count: term.count,
      first: term.first,
      score: rounded(term.count * mean(term.key, idf)),
    }));
    // Equal scores are the ordinary case, not the exception: on a short
    // document every phrase appears once and in no other document, so every
    // one of them scores log(N). What breaks the tie has to mean something —
    // the longer phrase first, because it says more, then the one that appears
    // earliest, because a document states its subject early. The key comes
    // last, and only so that two languages answer in the same order.
    const mots = (term) => term.key.split(' ').length;
    terms.sort((a, b) => b.score - a.score || b.count - a.count
      || mots(b) - mots(a) || a.first - b.first
      || (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
    const kept = terms.slice(0, top);
    // How many terms outside the cut share the score of the last one kept: a
    // caller that reads « the top five » of twelve equals should know.
    const tiedAtCut = kept.length > 0 && terms.length > kept.length
      ? terms.slice(kept.length).filter((term) => term.score === kept.at(-1).score).length
      : 0;
    return { index, terms: kept, tied_at_cut: tiedAtCut };
  });
  return { documents: ranked, reason: null };
}

function mean(key, idf) {
  const words = key.split(' ');
  return words.reduce((sum, word) => sum + (idf.get(word) ?? 0), 0) / words.length;
}

/**
 * Four decimals, by the same two operations in both languages: Python and
 * JavaScript do not round a half the same way, and a ranking must not depend
 * on which one ran.
 */
function rounded(value) {
  return Math.floor(value * 10000 + 0.5) / 10000;
}

Essayer

interactif

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

Rien ne part sur le réseau : le calcul se fait dans votre navigateur. Votre texte est comparé aux trois contrats des exemples, et à eux seuls.

à 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

4 terme(s), pesés contre trois autres contrats

Le détail

TermePoids dans la collectionOccurrences
moulins1.38631
café1.38631
moulin1.38631
garanti deux ans0.42281
vente02
société Lumière vend01
présentes conditions générales01
Conditions générales01

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 faible

Ce que cela ajoute à votre périmètre réglementaire

  • Aucun périmètre spécifique ajouté : la collection ne quitte pas votre infrastructure
  • Aucun apprentissage préalable : la pondération est recalculée sur votre seule collection, à chaque passage

Point de rupture

Sur une collection courte, tout se vaut : un terme vu une fois dans son document et nulle part ailleurs vaut log(N) comme tous ses voisins, et huit termes restent à égalité derrière les trois retenus. Témoin dans le même test : `tied_at_cut` les compte, et le départage se fait sur la longueur du terme puis sur son rang d’apparition.

Quand passer au niveau suivant

Vos documents disent la même chose avec deux vocabulaires — deux langues, ou le jargon d’un rédacteur face aux mots courants d’un autre. Le comptage ne rapproche pas deux mots qui ne s’écrivent pas pareil.

Les quatre options

  1. N0Candidats coupés aux mots vides, notés à l’intérieur du documentGratuit · Instantané
  2. RecommandéN1Les mêmes candidats, pesés contre le reste de la collectionGratuit · Rapide
  3. N2Niveau sans objet ici
  4. N3Niveau sans objet ici

Les autres options

N0Candidats coupés aux mots vides, notés à l’intérieur du documentGratuit · Instantané

Candidats coupés aux mots vides, notés à l’intérieur du document

Règle et algorithme classiqueN0GratuitInstantané

Python

snippets/extract-key-terms-from-a-document/n0.pyTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
"""
Les termes sur lesquels un document revient, tirés du document seul.

Niveau N0. La méthode est celle que RAKE a rendue courante : les mots avec
lesquels une langue fabrique ses phrases — articles, prépositions,
auxiliaires — sont aussi ce qui sépare une idée de la suivante, si bien que
couper le texte à chaque mot vide laisse les candidats entiers. Chaque
candidat est ensuite noté d'après la fréquence de ses mots et la longueur des
phrases où ils apparaissent, ce qui favorise une expression répétée et
précise plutôt qu'un mot seulement fréquent.

La liste de mots vides n'est pas livrée ici, et c'est le sujet. Elle est tout
le travail, elle change avec chaque langue, et elle appartient à l'appelant,
qui sait dans quelle langue sont ses documents — c'est la position de la
fiche sur les articles voisins. Sans elle, les mots-clés de n'importe quel
texte français sont « de », « la » et « le » : une liste vide est donc
refusée plutôt que servie.

Ce que ce niveau ne sait pas faire est dans son énoncé : il lit un document,
donc il peut dire ce que ce document répète, jamais ce qui le distingue des
quatre cents autres du même dossier. Tous les contrats d'un dossier de
contrats disent « conditions générales de vente », et rien ici ne le sait. Le
niveau N1, si.

Les outils qui font cela en un appel : `yake` en Python, dont les listes de
mots vides couvrent une quarantaine de langues, et, en JavaScript,
`keyword-extractor`, dont le paquet publié ne déclare aucune licence.
"""

from __future__ import annotations

import re
import unicodedata

# A word starts with a letter: « 14 » is not a key term of anything, « 14
# jours » may be. The hyphen stays inside, so « porte-monnaie » is one word.
WORD = re.compile(r"[^\W\d_][\w-]*", re.UNICODE)

# Two words belong to the same candidate only when nothing but space
# separates them. Punctuation ends a candidate, and so does anything this
# pattern does not list — the apostrophe among them, because French elides
# on it and « l'entreprise » is a stop word followed by a term.
SPACES = re.compile(r"[ \t\r\n\f\v\u00a0\u202f\u2028\u2029]+")

# Longer than this and it is a sentence, not a term.
MAX_WORDS = 4


def extract_key_terms(text, stop_words, *, top: int = 8) -> dict:
    """
    The phrases this document repeats, ranked without any other document.

    `stop_words` is a per-language list, required: it is what cuts the text
    into candidates, and no list means no language declared.
    """
    if not isinstance(text, str):
        return {"terms": [], "reason": f"expected text, not {type(text).__name__}"}
    stop = {_fold(word) for word in stop_words or []}
    if not stop:
        return {"terms": [], "reason": "a stop list is required, one per language"}

    candidates = _candidates(text, stop)
    degree, frequency = {}, {}
    for words in candidates:
        for word in words:
            key = _fold(word)
            degree[key] = degree.get(key, 0) + len(words)
            frequency[key] = frequency.get(key, 0) + 1

    seen = {}
    for words in candidates:
        key = " ".join(_fold(word) for word in words)
        # `first` is the rank of the phrase's first appearance in the document.
        # It is what rung N1 uses to break a tie by something that means
        # something: on a short document every phrase scores the same, and
        # ordering equals by their spelling puts the subject wherever its
        # initial falls in the alphabet.
        entry = seen.setdefault(key, {"text": " ".join(words), "key": key, "count": 0,
                                      "first": len(seen),
                                      "score": sum(degree[_fold(w)] / frequency[_fold(w)]
                                                   for w in words)})
        entry["count"] += 1

    # Sorted on three keys so that two phrases of equal score always come back
    # in the same order, in both languages.
    terms = sorted(seen.values(), key=lambda t: (-t["score"], -t["count"], t["key"]))
    return {"terms": terms[:top], "reason": None}


def candidates_of(text: str, stop_words) -> list[str]:
    """The candidate phrases, keyed and deduplicated — what rung N1 ranks."""
    stop = {_fold(word) for word in stop_words or []}
    return sorted({" ".join(_fold(w) for w in words) for words in _candidates(text, stop)})


def _candidates(text: str, stop: set) -> list[list[str]]:
    """Runs of words with no stop word and no punctuation inside them."""
    # NFC once, on the whole text: in the decomposed form the accent is a
    # character of its own, and « société » would be cut in two.
    text = unicodedata.normalize("NFC", text)
    phrases, current, end = [], [], 0
    for match in WORD.finditer(text):
        if current and not SPACES.fullmatch(text[end:match.start()]):
            phrases.append(current)
            current = []
        end = match.end()
        if match.group().lower() in stop:
            if current:
                phrases.append(current)
            current = []
        elif len(current) < MAX_WORDS:
            current.append(match.group())
            if len(current) == MAX_WORDS:
                phrases.append(current)
                current = []
    if current:
        phrases.append(current)
    return phrases


def _fold(word: str) -> str:
    """One spelling per term; the text has already been normalised to NFC."""
    return unicodedata.normalize("NFC", word).lower()

JavaScript

snippets/extract-key-terms-from-a-document/n0.jsTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
/**
 * Les termes sur lesquels un document revient, tirés du document seul.
 *
 * Niveau N0. La méthode est celle que RAKE a rendue courante : les mots avec
 * lesquels une langue fabrique ses phrases — articles, prépositions,
 * auxiliaires — sont aussi ce qui sépare une idée de la suivante, si bien que
 * couper le texte à chaque mot vide laisse les candidats entiers. Chaque
 * candidat est ensuite noté d'après la fréquence de ses mots et la longueur des
 * phrases où ils apparaissent, ce qui favorise une expression répétée et
 * précise plutôt qu'un mot seulement fréquent.
 *
 * La liste de mots vides n'est pas livrée ici, et c'est le sujet. Elle est tout
 * le travail, elle change avec chaque langue, et elle appartient à l'appelant,
 * qui sait dans quelle langue sont ses documents — c'est la position de la
 * fiche sur les articles voisins. Sans elle, les mots-clés de n'importe quel
 * texte français sont « de », « la » et « le » : une liste vide est donc
 * refusée plutôt que servie.
 *
 * Ce que ce niveau ne sait pas faire est dans son énoncé : il lit un document,
 * donc il peut dire ce que ce document répète, jamais ce qui le distingue des
 * quatre cents autres du même dossier. Tous les contrats d'un dossier de
 * contrats disent « conditions générales de vente », et rien ici ne le sait. Le
 * niveau N1, si.
 *
 * Les outils qui font cela en un appel : `yake` en Python, dont les listes de
 * mots vides couvrent une quarantaine de langues, et, en JavaScript,
 * `keyword-extractor`, dont le paquet publié ne déclare aucune licence.
 */

// A word starts with a letter: « 14 » is not a key term of anything, « 14
// jours » may be. The hyphen stays inside, so « porte-monnaie » is one word.
const WORD = /\p{L}[\p{L}\p{N}_-]*/gu;

// Two words belong to the same candidate only when nothing but space
// separates them. Punctuation ends a candidate, and so does anything this
// pattern does not list — the apostrophe among them, because French elides
// on it and « l'entreprise » is a stop word followed by a term.
const SPACES = /^[ \t\r\n\f\v\u00a0\u202f\u2028\u2029]+$/;

// Longer than this and it is a sentence, not a term.
export const MAX_WORDS = 4;

/**
 * The phrases this document repeats, ranked without any other document.
 *
 * `stopWords` is a per-language list, required: it is what cuts the text into
 * candidates, and no list means no language declared.
 */
export function extractKeyTerms(text, stopWords, { top = 8 } = {}) {
  if (typeof text !== 'string') {
    return { terms: [], reason: `expected text, not ${typeof text}` };
  }
  const stop = new Set((stopWords ?? []).map(fold));
  if (stop.size === 0) {
    return { terms: [], reason: 'a stop list is required, one per language' };
  }

  const candidates = phrasesOf(text, stop);
  const degree = new Map();
  const frequency = new Map();
  for (const words of candidates) {
    for (const word of words) {
      const key = fold(word);
      degree.set(key, (degree.get(key) ?? 0) + words.length);
      frequency.set(key, (frequency.get(key) ?? 0) + 1);
    }
  }

  const seen = new Map();
  for (const words of candidates) {
    const key = words.map(fold).join(' ');
    if (!seen.has(key)) {
      const score = words.reduce((sum, w) => sum + degree.get(fold(w)) / frequency.get(fold(w)), 0);
      // `first` is the rank of the phrase's first appearance in the document.
      // It is what rung N1 uses to break a tie by something that means
      // something: on a short document every phrase scores the same, and
      // ordering equals by their spelling puts the subject wherever its
      // initial falls in the alphabet.
      seen.set(key, {
        text: words.join(' '), key, count: 0, first: seen.size, score,
      });
    }
    seen.get(key).count += 1;
  }

  // Sorted on three keys so that two phrases of equal score always come back
  // in the same order, in both languages.
  const terms = [...seen.values()].sort((a, b) => b.score - a.score
    || b.count - a.count || (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
  return { terms: terms.slice(0, top), reason: null };
}

/** The candidate phrases, keyed and deduplicated — what rung N1 ranks. */
export function candidatesOf(text, stopWords) {
  const stop = new Set((stopWords ?? []).map(fold));
  const keys = new Set(phrasesOf(text, stop).map((words) => words.map(fold).join(' ')));
  return [...keys].sort();
}

/** Runs of words with no stop word and no punctuation inside them. */
function phrasesOf(text, stop) {
  // NFC once, on the whole text: in the decomposed form the accent is a
  // character of its own, and « société » would be cut in two.
  const normalised = text.normalize('NFC');
  const phrases = [];
  let current = [];
  let end = 0;
  for (const match of normalised.matchAll(WORD)) {
    if (current.length && !SPACES.test(normalised.slice(end, match.index))) {
      phrases.push(current);
      current = [];
    }
    end = match.index + match[0].length;
    if (stop.has(match[0].toLowerCase())) {
      if (current.length) phrases.push(current);
      current = [];
    } else if (current.length < MAX_WORDS) {
      current.push(match[0]);
      if (current.length === MAX_WORDS) {
        phrases.push(current);
        current = [];
      }
    }
  }
  if (current.length) phrases.push(current);
  return phrases;
}

/** One spelling per term; the text has already been normalised to NFC. */
function fold(word) {
  return word.normalize('NFC').toLowerCase();
}

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 document est lu dans votre processus et n’est transmis à personne
  • Un contrat ou un courrier porte des données personnelles : ce niveau est celui qui les garde chez vous

Point de rupture

Un terme français construit sur une préposition est coupé en deux, parce que la préposition est dans la liste qui fait marcher la méthode : dans le test, « moulin à café » revient en « moulin » d’un côté et « café » de l’autre. Témoin dans le même test : « droit français », qui ne passe par aucune préposition, revient d’un seul tenant.

Quand passer au niveau suivant

Les mêmes termes reviennent en tête de tous vos documents : c’est le passe-partout de votre dossier, et ce niveau n’a aucun moyen de le savoir.

N2Niveau sans objet ici

Niveau sans objet ici

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

Un encodeur auto-hébergé classerait les mêmes candidats d’après leur proximité avec le document entier, ce que fait `KeyBERT`. Le gain porte sur la formulation, et le prix est un modèle résident par langue traitée. Surtout, il ne répond pas à la question de cette fiche : comme le niveau N0, il lit un document à la fois, donc il ignore ce que tous les documents du dossier ont en commun — et c’est cela, ici, qui sépare un mot-clé d’un passe-partout.

N3Niveau sans objet ici

Niveau sans objet ici

API de LLM généralisteN3

Un modèle généraliste rend volontiers des termes qui ne sont pas dans le document. C’est un défaut pour un index — un lecteur qui cherche ce terme trouvera un document qui ne le contient pas — et c’est une facture par document pour un résultat qui ne se vérifie pas. Pour poser des étiquettes prises dans une liste de la maison, la fiche voisine sur l’étiquetage par thème traite le sujet, et elle s’appuie sur vos propres articles déjà étiquetés.

Pour aller plus loin

Source des chiffres

Métadonnées