Du Texte au Sens

Un voyage initiatique du .split() aux embeddings,
en passant par TF-IDF, BM25, le stemming, et les réseaux de neurones.
Avec humour, culture, et du code qui marche (parfois du premier coup).

PROLOGUE

Mais qu'est-ce que le Web Sémantique ?

Tim Berners-Lee (l'inventeur du Web, pas un groupe de rock) rêvait d'un web où les machines comprendraient le sens des données, pas seulement leur forme. En attendant que ce rêve devienne réalité — et que les ordinateurs arrêtent de confondre « avocat » (le fruit) avec « avocat » (la personne qui vous défend au tribunal) — on peut déjà faire beaucoup de choses avec des maths pas si compliquées.

🧠 Le saviez-vous ? En 1950, Alan Turing proposait le test éponyme pour déterminer si une machine pense. En 2024, on a des IA qui écrivent des poèmes mais qui suggèrent encore d'ajouter de la colle à la pizza pour faire tenir le fromage. Le chemin est long. Mais on va le faire ensemble. Et en s'amusant.

Ce cours vous emmène du texte brut à un moteur de recherche sémantique fonctionnel. Chaque étape est d'abord implémentée à la main (pour comprendre), puis avec une bibliothèque (pour être efficace). On commence par des trucs basiques comme découper une chaîne, et on finit par des plongements vectoriels et un serveur web. Prêt ? C'est parti.

CHAPITRE 0

L'approche naïve : lire, découper, associer

Avant de construire un moteur de recherche digne de ce nom, commençons par la version la plus basique qui soit : on liste les fichiers d'un répertoire, on lit leur contenu, on découpe en mots, et on fabrique une table mot → liste de documents.

« Le chemin le plus long commence par un premier pas. Et souvent, ce premier pas, c'est un os.listdir(). » — Lao Tseu, s'il avait fait de l'informatique

Étape 1 : lister et lire

Python
import os

for f in os.listdir("."):
    if not os.path.isfile(f):
        continue
    with open(f, errors="ignore") as fh:
        contenu = fh.read()
        print(f, len(contenu), "caractères")

Étape 2 : découper en mots uniques

Python
contenu = "le chat dort le chat mange"
mots = contenu.split()
print(mots)
# → ['le', 'chat', 'dort', 'le', 'chat', 'mange']

mots_uniques = list(set(mots))
print(mots_uniques)
# → ['le', 'chat', 'dort', 'mange']  (ordre variable)

Étape 3 : la table mot → fichiers

Python
from collections import defaultdict

index = defaultdict(list)

for f in os.listdir("."):
    if not os.path.isfile(f):
        continue
    with open(f, errors="ignore") as fh:
        mots = set(fh.read().split())
    for mot in mots:
        index[mot].append(f)

# Maintenant :
print(index["chat"])  # → ['doc1.txt', 'doc3.txt']

Étape 4 : recherche naïve

Python
# Chercher les documents contenant "chat" ET "canapé"
set(index.get("chat", [])) & set(index.get("canapé", []))

# Chercher "chat" OU "chien"
set(index.get("chat", [])) | set(index.get("chien", []))

# Compter les occurrences d'un mot
len(index.get("chat", []))
🧪 Pourquoi c'est naïf ? Cette approche a plein de défauts : Mais c'est la base. Tout le reste du cours consiste à corriger ces défauts un par un.

Variations intéressantes

📁 Par extension

Ne lire que les fichiers .txt avec f.endswith(".txt")

📊 Compteur

Stocker (doc_id, fréquence) au lieu de juste le nom du fichier, pour pouvoir classer.

🔍 Expression exacte

Chercher une phrase entière au lieu d'un mot seul : vérifier que les mots se suivent dans le texte.

⚡ Cache

Utiliser @lru_cache ou un cache JSON pour ne pas re-parcourir tous les fichiers à chaque démarrage.

Ce chapitre zéro pose la fondation. Dans les chapitres suivants, on va améliorer chaque brique : tokenisation intelligente (chap. 1), index inversé optimisé (chap. 2), recherche booléenne (chap. 3), stemming (chap. 4), pondération TF-IDF (chap. 5), etc. Accrochez-vous, ça devient intéressant.

CHAPITRE 1

Tokenisation : couper les mots en morceaux

Avant de faire des trucs intelligents, il faut déjà savoir ce qu'est un mot. Ça a l'air con, mais demandez à un ordinateur de séparer « l'avocat » en deux tokens, et vous verrez.

« Le langage est la source des malentendus. » — Antoine de Saint-Exupéry (et probablement un développeur après un bug de split)

À la main

Python
def tokeniser(texte):
    return texte.lower().split()

# Supprimer les mots vides (stopwords)
stopwords = {"le", "la", "les", "un", "une", "des", "du", "et", "ou", "dans", "sur", "avec"}

def nettoyer_mots(mots):
    return [m for m in mots if m not in stopwords]

# Exemple
print(nettoyer_mots(tokeniser("Le chat dort sur le canapé")))
# → ['chat', 'dort', 'canapé']

Avec une bibliothèque

Python
from nltk.tokenize import word_tokenize
from nltk.corpus import stopwords

mots = word_tokenize("Le chat dort sur le canapé".lower())
stop = set(stopwords.words('french'))
print([m for m in mots if m not in stop])
🤡 Variation culturelle : La tokenisation en chinois est un enfer — il n'y a pas d'espaces. Les Chinois utilisent des bibliothèques comme jieba qui font de la segmentation probabiliste. En français, on a juste à couper aux espaces. On est chanceux. Profitez-en. split() forever.
CHAPITRE 2

Index inversé : le dictionnaire qui change le monde

L'index inversé, c'est le cœur de tout moteur de recherche. Au lieu de se demander « quels mots contient ce document ? », on stocke « quels documents contiennent ce mot ? ». Google, Bing, et même votre petit moteur de recherche maison — c'est ça.

« Si vous voulez trouver une aiguille dans une meule de foin, brûlez la meule et cherchez la cendre. » — Sun Tzu (ou un DBA qui a oublié d'indexer sa table)

À la main

Python
from collections import defaultdict

corpus = [
    "le chat dort sur le canapé",
    "le chien court dans le jardin",
    "le chat et le chien jouent ensemble",
]

# Construction de l'index inversé
index = defaultdict(list)
for doc_id, doc in enumerate(corpus):
    for mot in set(doc.lower().split()):
        index[mot].append(doc_id)

print(dict(index))
# → {'le': [0,1,2], 'chat': [0,2], 'dort': [0], 'canapé': [0],
#     'chien': [1,2], 'court': [1], 'dans': [1], 'jardin': [1],
#     'et': [2], 'jouent': [2], 'ensemble': [2]}

Maintenant, chercher les documents contenant à la fois « chat » et « chien » revient à faire une intersection d'ensembles : set(index['chat']) & set(index['chien']){2}.

🎭 Point culture : Le premier moteur de recherche à index inversé fut Archie (1990), qui indexait les noms de fichiers FTP. Il s'appelait Archie parce qu'il était comme une archive — et ça a donné le nom à Veronica et Jughead qui indexaient le contenu des pages. Oui, comme les personnages d'Archie Comics. Les informaticiens ont le sens de l'humour. Enfin, une certaine forme d'humour.

Variante : avec compression (delta encoding)

Python
# Transformer les listes de doc_id en différences pour compresser
index_compresse = {}
for mot, liste in index.items():
    ids_tries = sorted(liste)
    delta = []
    prev = 0
    for doc_id in ids_tries:
        delta.append(doc_id - prev)
        prev = doc_id
    index_compresse[mot] = delta

# → 'chat': [0, 2]  (au lieu de [0, 2], même chose ici)
# → sur de grands index, on gagne énormément de place
CHAPITRE 3

Recherche booléenne : ET, OU, et la naissance de la logique

Une fois qu'on a un index inversé, on peut répondre à des questions comme « documents contenant mot1 ET (mot2 OU mot3) ». C'est ce qu'on appelle la recherche booléenne.

L'idée : on parse la requête comme une expression mathématique avec précédence des opérateurs. Le ET est prioritaire sur OU. Exactement comme en algèbre — mais avec des ensembles de documents au lieu de nombres.

Parseur d'expressions

Python
def evaluer_requete(requete, index):
    tokens = requete.replace('(', ' ( ').replace(')', ' ) ').split()

    def parse_ou():
        gauche = parse_et()
        while tokens and tokens[0] == 'ou':
            tokens.pop(0)
            droite = parse_et()
            gauche = gauche | droite
        return gauche

    def parse_et():
        gauche = parse_atome()
        while tokens and tokens[0] == 'et':
            tokens.pop(0)
            droite = parse_atome()
            gauche = gauche & droite
        return gauche

    def parse_atome():
        t = tokens.pop(0)
        if t == '(':
            res = parse_ou()
            tokens.pop(0)  # )
            return res
        return set(index.get(t, []))

    return parse_ou()

# Exemple
résultat = evaluer_requete("chat et (chien ou souris)", index)
# → retourne l'ensemble des doc_id correspondant
📜 Anecdote : George Boole, qui a inventé l'algèbre booléenne au XIXe siècle, n'avait probablement pas anticipé que son travail servirait un jour à chercher des vidéos de chats sur Internet. Il pensait plutôt à formaliser la logique aristotélicienne. Les chats sont venus après.
CHAPITRE 4

Stemming : « chat », « chats », « chatte » → « chat »

« Chercher », « cherche », « cherchait », « chercherons » — c'est la même idée. Pourquoi ne pas tout ramener à « cherch » (le radical) ? C'est le principe du stemming.

À la main (Porter simplifié pour le français)

Python
def stem(mot):
    w = mot.lower()
    if len(w) <= 2:
        return w

    # Pluriels
    if w.endswith("aux"):
        w = w[:-2] + "al"
    elif w.endswith("s") and not w.endswith("ss"):
        w = w[:-1]

    # Suffixes verbaux
    for suf in ("aient", "aient", "er", "ir",
                    "ant", "ent", "ez", "ons"):
        if w.endswith(suf) and len(w) > len(suf) + 1:
            w = w[:-len(suf)]
            break

    # -e final
    if w.endswith("e") and len(w) > 2:
        w = w[:-1]

    return w

print([stem(m) for m in ["chemins", "chemine", "cheminerait"]])
# → ['chemin', 'chemin', 'chemin']
🌍 Culture G : L'algorithme de stemming le plus connu est le Porter Stemmer (1980). Martin Porter a créé ça pour l'anglais. Son site web ? tartarus.org. Parce que les informaticiens de Cambridge ont le sens esthétique. Le stemming français est un enfer : trop de règles, trop d'exceptions. « cheval » → pluriel = « chevaux » (pas juste un 's' à la fin). Les langues latines, c'est le drame.

Avec la bibliothèque snowballstemmer

Python
from snowballstemmer import stemmer
s = stemmer("french")
print(s.stemWords(["chemins", "chemine", "cheminerait"]))
# → ['chemin', 'chemin', 'chemin']
CHAPITRE 5

TF-IDF : pondérer les mots qui comptent vraiment

Tous les mots ne se valent pas. « le » apparaît dans tous les documents → pas discriminant. « ornithorynque » apparaît rarement → super important. TF-IDF (Term Frequency — Inverse Document Frequency) quantifie ça :

À la main

Python
import math
from collections import Counter

docs = [
    ["chat", "dort", "canapé"],
    ["chien", "court", "jardin"],
    ["chat", "chien", "jouent"],
]
N = len(docs)
vocabulaire = sorted(set(m for doc in docs for m in doc))

# TF : fréquence de chaque mot dans chaque document
tf = []
for doc in docs:
    tf.append(Counter(doc))

# IDF : log(N / df)
df = Counter()
for mot in vocabulaire:
    df[mot] = sum(1 for doc in docs if mot in doc)

idf = {mot: math.log(N / df[mot]) for mot in vocabulaire}

# TF-IDF
for i, doc in enumerate(docs):
    print({mot: tf[i][mot] * idf[mot] for mot in doc})
# → {'chat': 0.405, 'dort': 1.099, 'canapé': 1.099}
#    {'chien': 0.405, 'court': 1.099, 'jardin': 1.099}
#    {'chat': 0.405, 'chien': 0.405, 'jouent': 1.099}

Avec sklearn

Python
from sklearn.feature_extraction.text import TfidfVectorizer

corpus = [
    "le chat dort sur le canapé",
    "le chien court dans le jardin",
    "le chat et le chien jouent ensemble",
]
vec = TfidfVectorizer()
X = vec.fit_transform(corpus)
print(vec.get_feature_names_out())
print(X.toarray())
📊 Fun fact : L'IDF a été introduit par Karen Spärck Jones en 1972. Elle disait : « La spécialité d'un terme est inversement proportionnelle au nombre de documents dans lesquels il apparaît. » Proprement dit. Elle est aussi une des pionnières du NLP et mérite bien plus de reconnaissance. Si vous croisez une rue à son nom, saluez-la.
CHAPITRE 6

BM25 : quand TF-IDF fait de la musculation

BM25 (Best Match 25) améliore TF-IDF en ajoutant :

Formule magique :

Score(q, d) = Σ IDF(qᵢ) × f(qᵢ, d) × (k₁ + 1) / (f(qᵢ, d) + k₁ × (1 - b + b × |d| / avg_dl))
  

Avec k₁ = 1.5 et b = 0.75 (valeurs magiques trouvées empiriquement).

À la main

Python
def bm25_score(requete_mots, doc_idx, docs, df, N, k1=1.5, b=0.75):
    longueurs = [len(d) for d in docs]
    avg_dl = sum(longueurs) / N
    ld = longueurs[doc_idx]
    freq = Counter(docs[doc_idx])
    score = 0.0
    for mot in requete_mots:
        if mot in freq:
            f = freq[mot]
            idf = math.log((N - df[mot] + 0.5) / (df[mot] + 0.5) + 1)
            num = f * (k1 + 1)
            den = f + k1 * (1 - b + b * ld / avg_dl)
            score += idf * num / den
    return score

Avec rank_bm25

Python
from rank_bm25 import BM25Okapi

bm25 = BM25Okapi(docs_tokenises)  # list of list of tokens
scores = bm25.get_scores(["chat", "chien"])
🏋️ D'où vient BM25 ? C'est le 25e essai du modèle « Best Match » développé à l'université de Londres. Les 24 premiers ne marchaient pas assez bien. La persévérance paie. En recherche d'information, BM25 est toujours un standard, même à l'ère des transformers. Parfois, les vieilles recettes sont les meilleures.
CHAPITRE 7

Word Embeddings : les mots dans l'espace

Jusqu'ici, on a traité les mots comme des atomes — « chat » et « chien » sont aussi différents que « chat » et « ornithorynque ». Pourtant, on sent bien que « chat » et « chien » sont plus proches entre eux qu'avec « ordinateur ».

Les word embeddings (plongements lexicaux) représentent chaque mot par un vecteur numérique (ex: 50, 100, 300 dimensions) de sorte que :

« You shall know a word by the company it keeps. » — John Rupert Firth (linguiste, 1957)

C'est le principe du word2vec de Mikolov (Google, 2013). Un mot est défini par son contexte. « Roi » - « Homme » + « Femme » ≈ « Reine ». Et oui, ça marche vraiment.

MotPlus proches voisins
ParisLyon, Marseille, Toulouse, Bordeaux, France
chatchien, souris, félin, animal, miauler
excellentsuperbe, remarquable, formidable, exceptionnel
CHAPITRE 8

Skip-gram avec Negative Sampling : le moteur des embeddings

L'architecture Skip-gram fait deviner le contexte à partir du mot central. On prend un mot, on tire des mots voisins (positifs) et des mots aléatoires (négatifs), et on entraîne un petit réseau de neurones à faire la différence.

🏆 Fun fact : L'article « Efficient Estimation of Word Representations in Vector Space » (Mikolov et al., 2013) est l'un des plus cités en NLP, avec plus de 40 000 citations. Pas mal pour un stage d'été chez Google.

À la main

Python
import numpy as np
from math import exp

class SkipGram:
    def __init__(self, vocab_size, dim=50):
        self.dim = dim
        self.W = np.random.randn(vocab_size, dim) * 0.01  # mot → hidden
        self.C = np.random.randn(dim, vocab_size) * 0.01  # hidden → contexte

    def train(self, pairs, neg_samples=5, lr=0.05):
        for center, pos_ctx in pairs:
            h = self.W[center]
            sig = 1 / (1 + exp(-self.C[:, pos_ctx] @ h))
            grad_h = self.C[:, pos_ctx] * (sig - 1)
            self.C[:, pos_ctx] -= lr * h * (sig - 1)

            for _ in range(neg_samples):
                neg = np.random.randint(0, self.C.shape[1])
                sig = 1 / (1 + exp(-self.C[:, neg] @ h))
                self.C[:, neg] -= lr * h * sig
                grad_h += self.C[:, neg] * sig

            self.W[center] -= lr * grad_h

    def embedding(self, word_idx):
        return self.W[word_idx]

Pour l'utiliser, on parcourt le corpus avec une fenêtre glissante, on génère les paires (mot_central, mot_contexte), et on entraîne. Après suffisamment d'itérations, self.W contient les embeddings.

Avec gensim

Python
from gensim.models import Word2Vec
from gensim.utils import simple_preprocess

sentences = [simple_preprocess(phrase) for phrase in corpus]
model = Word2Vec(sentences, vector_size=50, window=2, sg=1, negative=3, epochs=100)

model.wv.most_similar("chat")  # → [('chien', 0.87), ('souris', 0.72), ...]
vec_chat = model.wv["chat"]       # → array of 50 floats
CHAPITRE 9

Similarité cosinus : mesurer la proximité

On a des vecteurs. Cool. Maintenant, comment on compare deux vecteurs ? La similarité cosinus mesure l'angle entre deux vecteurs :

cos(a, b) = (a · b) / (||a|| × ||b||)
  

Résultat entre -1 (opposé) et 1 (identique). Pour les embeddings, c'est généralement entre 0 et 1.

À la main

Python
def cosinus(v1, v2):
    norm = sum(a*b for a,b in zip(v1, v2))
    den = math.sqrt(sum(a*a for a in v1)) * math.sqrt(sum(b*b for b in v2))
    return norm / den if den != 0 else 0

Avec sklearn

Python
from sklearn.metrics.pairwise import cosine_similarity
cosine_similarity([vec_a], [vec_b])[0][0]
📐 Application magique : On peut calculer la similarité entre documents en faisant la moyenne des embeddings des mots qui les composent. → Deux documents qui parlent de sports auront des vecteurs proches, même s'ils n'utilisent pas exactement les mêmes mots. C'est ça, la sémantique.
CHAPITRE 10

Ranking : trier par pertinence

Au lieu de retourner « oui ou non », on classe les documents par score de similarité avec la requête. Le ranking, c'est ce qui distingue Google de la recherche Windows.

À la main (TF-IDF + produit scalaire)

Python
# Construire les vecteurs TF-IDF des documents
vecs_docs = []
for i in range(N):
    v = [tf[i][mot] * idf[mot] for mot in vocabulaire]
    vecs_docs.append(v)

# Vecteur TF-IDF de la requête
def vecteur_requete(mots):
    v = [0.0] * len(vocabulaire)
    compteur = Counter(mots)
    for mot in set(mots):
        if mot in idf:
            idx = vocabulaire.index(mot)
            v[idx] = compteur[mot] * idf[mot]
    return v

# Classer
vq = vecteur_requete(["chat", "canapé"])
scores = [(NOMS_DOCS[i], sum(a*b for a,b in zip(vq, vecs_docs[i])))
          for i in range(N)]
scores.sort(key=lambda x: -x[1])

Avec sklearn

Python
vectorizer = TfidfVectorizer()
X = vectorizer.fit_transform(corpus)
vq = vectorizer.transform([requete])
sims = cosine_similarity(vq, X)[0]
resultats = sorted(zip(NOMS_DOCS, sims), key=lambda x: -x[1])
CHAPITRE 11

Doc2Vec : des phrases dans l'espace

Si on peut vectoriser des mots, pourquoi pas des documents entiers ? Doc2Vec (Mikolov & Le, 2014) ajoute un vecteur de document qui s'entraîne en même temps que les mots.

Avec gensim

Python
from gensim.models.doc2vec import Doc2Vec, TaggedDocument

docs_etiquettes = [
    TaggedDocument(doc, ["doc1"])
    for doc in docs_tokenises
]
modele = Doc2Vec(docs_etiquettes, vector_size=50, window=3, min_count=1, epochs=40)

# Inferer le vecteur d'une nouvelle phrase
vq = modele.infer_vector(["animal", "dort", "maison"])

# Trouver les documents les plus proches
modele.dv.most_similar([vq])

Avantage : on peut chercher avec une phrase entière, pas seulement des mots-clés. « animal qui dort dans la maison » trouvera le doc sur le chat même si le mot « animal » n'apparaît pas dans le texte.

🎬 Culture pop : En 2014, quand Doc2Vec est sorti, les gens criaient à la révolution. Aujourd'hui, on utilise plutôt des modèles transformers (BERT, Sentence-BERT) qui sont bien plus puissants. Mais le principe reste le même : une phrase = un vecteur. C'est comme la différence entre une Ford T et une Tesla. Les deux roulent, mais l'une a la clim.
CHAPITRE 12

Whoosh : le moteur de recherche en pur Python

Whoosh est une bibliothèque Python qui implémente un moteur de recherche complet : indexation, stemming, BM25, recherche par requête, etc. Parfait pour comprendre comment ça marche sans plonger dans le C++ de Lucene (le moteur derrière Elasticsearch).

Python
import whoosh.index as whoosh_index
import whoosh.fields as whoosh_fields
import whoosh.qparser as whoosh_qparser
from whoosh.analysis import StemmingAnalyzer

# 1. Définir le schéma
schema = whoosh_fields.Schema(
    titre=whoosh_fields.TEXT(stored=True),
    contenu=whoosh_fields.TEXT(analyzer=StemmingAnalyzer())
)

# 2. Créer l'index
ix = whoosh_index.create_in("indexdir", schema)
writer = ix.writer()
writer.add_document(titre="doc1", contenu="le chat dort sur le canapé")
writer.commit()

# 3. Chercher
with ix.searcher() as s:
    parser = whoosh_qparser.QueryParser("contenu", ix.schema)
    q = parser.parse("chat canapé")
    results = s.search(q)
    for r in results:
        print(r["titre"], r.score)
⚡ Le saviez-vous ? Elasticsearch, qui utilise Lucene (Java), est le moteur de recherche le plus populaire au monde pour les applications web. Whoosh est son petit cousin Python. Moins rapide, mais plus simple à comprendre. C'est comme comparer un vélo et une fusée. La fusée va sur la Lune, mais le vélo, vous pouvez le réparer avec un tournevis.
CHAPITRE 13

Interface : donner vie au moteur

Tout ce beau code, il faut pouvoir s'en servir. Deux approches :

💻 CLI (ligne de commande)

python recherche.py "chien balle"
→ Retourne les docs classés par score. Simple, efficace, pas de CSS à écrire.

🌐 Web (Flask)

python recherche.py --web
→ Lance un serveur sur localhost:5000 avec formulaire de recherche et résultats en JSON.

Extrait du serveur Flask

Python
from flask import Flask, request, jsonify

app = Flask(__name__)

# Préparer le modèle (une fois au démarrage)
vectorizer = TfidfVectorizer()
X = vectorizer.fit_transform(corpus)

@app.route("/rechercher")
def rechercher():
    requete = request.args.get("q", "")
    vq = vectorizer.transform([requete])
    sims = cosine_similarity(vq, X)[0]
    resultats = [
        {"document": NOMS_DOCS[i], "score": round(float(sims[i]), 4)}
        for i in range(len(corpus))
    ]
    resultats.sort(key=lambda x: -x["score"])
    return jsonify(resultats)
🎨 Variation artistique : Vous pouvez remplacer le JSON par une interface en HTML pur avec des couleurs, ou ajouter une API REST complète, ou même une barre de recherche style Google avec suggestions. Le code est le même. Seule la vitrine change.
ÉPILOGUE

Et maintenant ? Les voies du sens sont infinies

Ce cours vous a emmené du .split() à un moteur de recherche sémantique fonctionnel. Vous avez compris chaque brique, pas seulement copié-collé des bibliothèques.

Code disponible dans recherche.py
Exécutez-le, modifiez-le, cassez-le, réparez-le.
C'est comme ça qu'on apprend.