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
"""
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) / 10000JavaScript
/**
* 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
interactifLe 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.
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 qu’il renvoie
4 terme(s), pesés contre trois autres contrats
Le détail
| Terme | Poids dans la collection | Occurrences |
|---|---|---|
| moulins | 1.3863 | 1 |
| café | 1.3863 | 1 |
| moulin | 1.3863 | 1 |
| garanti deux ans | 0.4228 | 1 |
| vente | 0 | 2 |
| société Lumière vend | 0 | 1 |
| présentes conditions générales | 0 | 1 |
| Conditions générales | 0 | 1 |
Là, ça casse
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
- N0Candidats coupés aux mots vides, notés à l’intérieur du documentGratuit · Instantané
- RecommandéN1Les mêmes candidats, pesés contre le reste de la collectionGratuit · Rapide
- N2Niveau sans objet ici
- 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
"""
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
/**
* 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
- yake — l’extracteur de mots-clés non supervisé, et ses listes de mots vides par langue
- keyword-extractor — le module JavaScript, et ses listes de mots vides
- Étiqueter des articles par thème — la fiche qui traite la liste d’étiquettes de la maison
- Montrer des articles voisins — la fiche qui emploie la forme lissée du même calcul
Source des chiffres
- yake sur PyPI (consulté) : version 0.7.3, publiée le 9 février 2026, licence LGPLv3 ; six dépendances, dont numpy et networkx
- keyword-extractor sur npm (consulté) : version 0.3.0, publiée le 13 août 2026, aucun champ de licence dans le paquet publié ; le dépôt porte un fichier LICENSE daté de 2013
- rake-nltk sur PyPI (consulté) : version 1.0.6, publiée le 15 septembre 2021, licence MIT ; c’est la mise en œuvre de référence de la méthode employée au niveau N0
- retext-keywords sur npm (consulté) : version 8.0.2, publiée le 23 octobre 2024, licence MIT ; il dépend de `stemmer`, qui met en œuvre l’algorithme de Porter, écrit pour l’anglais
Métadonnées
Information générale, tenue à jour au mieux. Ce n’est pas un avis juridique. Faites valider votre situation par un professionnel.