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).
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.
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.
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.
os.listdir(). »
— Lao Tseu, s'il avait fait de l'informatique
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")
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)
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']
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", []))
Ne lire que les fichiers .txt avec f.endswith(".txt")
Stocker (doc_id, fréquence) au lieu de juste le nom du fichier, pour pouvoir classer.
Chercher une phrase entière au lieu d'un mot seul : vérifier que les mots se suivent dans le texte.
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.
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.
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é']
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])
split() forever.
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.
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}.
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
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.
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
« 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.
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']
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.
Python from snowballstemmer import stemmer s = stemmer("french") print(s.stemWords(["chemins", "chemine", "cheminerait"])) # → ['chemin', 'chemin', 'chemin']
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 :
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}
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())
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).
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
Python from rank_bm25 import BM25Okapi bm25 = BM25Okapi(docs_tokenises) # list of list of tokens scores = bm25.get_scores(["chat", "chien"])
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 :
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.
| Mot | Plus proches voisins |
|---|---|
| Paris | Lyon, Marseille, Toulouse, Bordeaux, France |
| chat | chien, souris, félin, animal, miauler |
| excellent | superbe, remarquable, formidable, exceptionnel |
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.
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.
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
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.
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
Python from sklearn.metrics.pairwise import cosine_similarity cosine_similarity([vec_a], [vec_b])[0][0]
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.
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])
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])
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.
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.
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)
Tout ce beau code, il faut pouvoir s'en servir. Deux approches :
python recherche.py "chien balle"
→ Retourne les docs classés par score.
Simple, efficace, pas de CSS à écrire.
python recherche.py --web
→ Lance un serveur sur localhost:5000
avec formulaire de recherche et résultats en JSON.
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)
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.