Extract the key terms of a document

Surface the terms that characterise a document, so it can be indexed, labelled or previewed.

No generative AI needed

FreeFastYour data never leaves

The verdict

N1, because “characterise” means “distinguish”, and distinguishing needs a collection. This entry’s test puts the two levels side by side on the same contract: read alone it returns “conditions générales” and “présentes conditions générales”; read inside its folder of four contracts it returns “moulins”, “café” and “moulin”, and the boilerplate falls to zero. Those three are tied on score, which is the ordinary case on short documents: ties are then settled on the length of the term, then on where it first appears, never on the alphabet, and `tied_at_cut` says how many others share the score of the last one kept. Both levels share the same cut and the same stop list; only the ranking changes. Three decisions carry the rest. The stop list is required of the caller, because it is specific to each language and because it is the whole job. The logarithm is not floored at one the way scikit-learn floors it: a term present in all your documents is the key term of none of them, and rounding it to “a little” would put it back in the list. And the tools in the field are named without being called: yake in Python is under the LGPL, and keyword-extractor in JavaScript publishes a package with no declared licence.

The same candidates, weighed against the rest of the collection

No generative AI needed

Lightweight classic modelN1FreeFast

Python

snippets/extract-key-terms-from-a-document/n1.pyTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
"""
The terms that tell this document apart from the others in the same folder.

Rung N1. Rung N0 reads one document and can only say what it repeats. Every
contract in a folder of contracts repeats « conditions générales de vente »,
and no amount of reading that one contract more carefully will reveal that the
phrase is worthless: the information is in the folder, not in the document.

So the candidates are the same as at rung N0 — the same cut, the same stop
list — and only the ranking changes. Each word gets the logarithm of how many
documents there are over how many contain it, and a phrase is worth the
average of its words, times how often the document says it. A word in every
document is then worth exactly nothing, which is the whole point.

One deliberate difference from the usual formula: `scikit-learn` smooths, and
floors that logarithm at one, so a term in every document still weighs
something. That is right when the weights feed a similarity — the entry on
showing similar articles uses the smoothed form for that reason. It is wrong
here: a term in every document of your collection is not a key term of any of
them, and rounding it to « a little » would put it back in the list.

The model is the collection. It is fitted in memory, in one pass, from the
documents you already hold; there is nothing to download and nothing to send.
"""

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.jsTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
/**
 * The terms that tell this document apart from the others in the same folder.
 *
 * Rung N1. Rung N0 reads one document and can only say what it repeats. Every
 * contract in a folder of contracts repeats « conditions générales de vente »,
 * and no amount of reading that one contract more carefully will reveal that
 * the phrase is worthless: the information is in the folder, not in the
 * document.
 *
 * So the candidates are the same as at rung N0 — the same cut, the same stop
 * list — and only the ranking changes. Each word gets the logarithm of how
 * many documents there are over how many contain it, and a phrase is worth the
 * average of its words, times how often the document says it. A word in every
 * document is then worth exactly nothing, which is the whole point.
 *
 * One deliberate difference from the usual formula: `scikit-learn` smooths,
 * and floors that logarithm at one, so a term in every document still weighs
 * something. That is right when the weights feed a similarity — the entry on
 * showing similar articles uses the smoothed form for that reason. It is wrong
 * here: a term in every document of your collection is not a key term of any
 * of them, and rounding it to « a little » would put it back in the list.
 *
 * The model is the collection. It is fitted in memory, in one pass, from the
 * documents you already hold; there is nothing to download and nothing to send.
 */

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

Try it

interactive

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

Nothing goes out on the network: the computing happens in your browser. Your text is weighed against the three contracts of the examples, and against them only.

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

4 term(s), weighed against three other contracts

In detail

TermWeight in the collectionOccurrences
grinders1.38631
coffee grinder carries0.92421
Lumiere Ltd sells coffee0.34661
two year warranty0.19181
sale03
Terms02
conditions02
fourteen days01

What this means for you

  • Your data stays put
  • Always the same result
  • Unit-testable, case by case
  • No provider in the loop
  • Low footprint

What this adds to your regulatory scope

  • No specific scope added: the collection never leaves your infrastructure
  • No prior training: the weighting is recomputed from your own collection alone, on every pass

Breaking point

On a short collection everything is worth the same: a term seen once in its document and nowhere else scores log(N) like all its neighbours, and eight terms stay tied behind the three that are kept. Witness in the same test: `tied_at_cut` counts them, and ties are settled on the length of the term, then on where it first appears.

When to move up a level

Your documents say the same thing in two vocabularies — two languages, or one writer’s jargon against another’s plain words. Counting does not bring together two words that are not spelled alike.

The four options

  1. N0Candidates cut at the stop words, scored inside the documentFree · Instant
  2. RecommendedN1The same candidates, weighed against the rest of the collectionFree · Fast
  3. N2Not worth it here
  4. N3Not worth it here

The other options

N0Candidates cut at the stop words, scored inside the documentFree · Instant

Candidates cut at the stop words, scored inside the document

Rule and classic algorithmN0FreeInstant

Python

snippets/extract-key-terms-from-a-document/n0.pyTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
"""
The terms a document keeps coming back to, taken from the document alone.

Rung N0. The method is the one RAKE made common: the words a language uses to
build sentences — articles, prepositions, auxiliaries — are also what separates
one idea from the next, so cutting the text at every stop word leaves the
candidate phrases whole. Each candidate is then scored by how often its words
appear and how long the phrases they appear in are, which favours a phrase that
is repeated and specific over a word that is merely frequent.

The stop list is not shipped here, and that is the point. It is the whole job,
it is different in every language, and it belongs to the caller who knows what
language their documents are in — the same stance as the entry on showing
similar articles. Without one, the key terms of any French text are « de »,
« la » and « le », so an empty list is refused rather than served.

What this rung cannot do is in its name: it reads one document, so it can tell
what that document repeats, never what makes it different from the four hundred
others in the same folder. Every contract in a folder of contracts says
« conditions générales de vente », and nothing here knows that. Rung N1 does.

The tools that do this in one call: `yake` in Python, whose stop lists cover
some forty languages, and, in JavaScript, `keyword-extractor`, whose published
package declares no 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.jsTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
/**
 * The terms a document keeps coming back to, taken from the document alone.
 *
 * Rung N0. The method is the one RAKE made common: the words a language uses
 * to build sentences — articles, prepositions, auxiliaries — are also what
 * separates one idea from the next, so cutting the text at every stop word
 * leaves the candidate phrases whole. Each candidate is then scored by how
 * often its words appear and how long the phrases they appear in are, which
 * favours a phrase that is repeated and specific over a word that is merely
 * frequent.
 *
 * The stop list is not shipped here, and that is the point. It is the whole
 * job, it is different in every language, and it belongs to the caller who
 * knows what language their documents are in — the same stance as the entry on
 * showing similar articles. Without one, the key terms of any French text are
 * « de », « la » and « le », so an empty list is refused rather than served.
 *
 * What this rung cannot do is in its name: it reads one document, so it can
 * tell what that document repeats, never what makes it different from the four
 * hundred others in the same folder. Every contract in a folder of contracts
 * says « conditions générales de vente », and nothing here knows that. Rung N1
 * does.
 *
 * The tools that do this in one call: `yake` in Python, whose stop lists cover
 * some forty languages, and, in JavaScript, `keyword-extractor`, whose
 * published package declares no 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();
}

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 document is read inside your own process and is sent to nobody
  • A contract or a letter carries personal data: this is the level that keeps it at home

Breaking point

A French term built on a preposition is cut in two, because the preposition is in the list that makes the method work: in the test, “moulin à café” comes back as “moulin” on one side and “café” on the other. Witness in the same test: “droit français”, which goes through no preposition, comes back whole.

When to move up a level

The same terms come top on every one of your documents: that is your folder’s boilerplate, and this level has no way of knowing it.

N2Not worth it here

Not worth it here

Small self-hosted specialised modelN2

A self-hosted encoder would rank the same candidates by how close they sit to the whole document, which is what KeyBERT does. The gain is on phrasing, and the price is a resident model per language handled. Above all it does not answer this entry’s question: like level N0, it reads one document at a time, so it knows nothing of what every document in the folder has in common — and that, here, is what separates a key term from boilerplate.

N3Not worth it here

Not worth it here

General-purpose LLM APIN3

A general-purpose model will happily return terms that are not in the document. That is a defect for an index — a reader searching for that term will find a document that does not contain it — and it is one bill per document for a result that cannot be checked. To attach labels taken from an in-house list, the neighbouring entry on tagging by topic covers the subject, and it leans on your own already-tagged articles.

Further reading

Where the figures come from

Metadata