01
📖 Introduction
La recherche sémantique est une approche qui trouve des documents par similarité de sens plutôt que par correspondance exacte de mots-clés. Elle permet à vos utilisateurs de trouver ce qu'ils cherchent même s'ils n'utilisent pas les bons mots.
📝 Exemple :
Requête : "discours sur la liberté"
→ Trouve "Appel du 18 juin" (Charles de Gaulle)
→ Trouve "Discours sur l'avenir de l'Europe" (Victor Hugo)
→ Résultats pertinents même sans le mot "liberté"
💡 Ce que vous allez apprendre :
- ✅ Générer et utiliser des embeddings
- ✅ Implémenter la recherche vectorielle avec FAISS
- ✅ Améliorer les résultats avec le re-ranking
- ✅ Combiner recherche vectorielle et mots-clés
- ✅ Évaluer et optimiser votre moteur de recherche
02
🔢 Les embeddings : le cœur de la recherche sémantique
from sentence_transformers import SentenceTransformer
# Modèle d'embedding multilingue
model = SentenceTransformer('paraphrase-multilingual-MiniLM-L12-v2')
# Générer des embeddings
texts = [
"L'Appel du 18 juin appelle à la résistance",
"Victor Hugo a écrit Les Misérables",
"Recette de quiche lorraine"
]
embeddings = model.encode(texts)
print(embeddings.shape) # (3, 384)
03
🔍 Recherche vectorielle avec FAISS
Implémentation de base
import faiss
import numpy as np
# Index FAISS (produit scalaire = similarité cosinus normalisée)
dimension = 384
index = faiss.IndexFlatIP(dimension)
# Normaliser et ajouter les embeddings
faiss.normalize_L2(embeddings) # Normalisation pour similarité cosinus
index.add(embeddings)
# Recherche
query = "discours sur la liberté"
query_embedding = model.encode([query])
faiss.normalize_L2(query_embedding)
k = 5 # Nombre de résultats
scores, indices = index.search(query_embedding, k)
for score, idx in zip(scores[0], indices[0]):
print(f"{texts[idx]} (score: {score:.3f})")
📊 Résultats pour "discours sur la liberté" :
1. L'Appel du 18 juin appelle à la résistance (score: 0.87)
2. Discours sur l'avenir de l'Europe (score: 0.76)
3. Discours sur la peine de mort (score: 0.71)
4. ...
10. Recette de quiche lorraine (score: 0.12)
💡 Types d'index FAISS :
- IndexFlatIP : Exact, lent sur grands volumes
- IndexIVFFlat : Approximatif, rapide
- IndexHNSW : Très rapide, haute précision
04
📊 Re-ranking : améliorer la précision
Le re-ranking consiste à réordonner les résultats avec un modèle plus précis (mais plus lent) pour améliorer la qualité.
from sentence_transformers import CrossEncoder
# Modèle de re-ranking (plus précis)
reranker = CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')
def rerank_results(query, documents, top_k=5):
# Paires (query, document)
pairs = [(query, doc) for doc in documents]
scores = reranker.predict(pairs)
# Trier par score
results = sorted(zip(documents, scores), key=lambda x: x[1], reverse=True)
return results[:top_k]
# Exemple
initial_results = get_initial_results(query, k=20)
reranked = rerank_results(query, initial_results, k=5)
05
🔄 Recherche hybride (vectorielle + mots-clés)
La recherche hybride combine les forces des deux approches : similarité sémantique (vectorielle) et correspondance exacte (mots-clés).
from rank_bm25 import BM25Okapi
class HybridSearch:
def __init__(self, documents, embeddings, index):
self.documents = documents
self.embeddings = embeddings
self.index = index
# Index BM25 pour recherche par mots-clés
tokenized_docs = [doc.lower().split() for doc in documents]
self.bm25 = BM25Okapi(tokenized_docs)
def search(self, query, k=10, vector_weight=0.5, keyword_weight=0.5):
# Recherche vectorielle
query_embedding = model.encode([query])
faiss.normalize_L2(query_embedding)
vector_scores, indices = self.index.search(query_embedding, k*2)
# Recherche BM25
keyword_scores = self.bm25.get_scores(query.split())
# Fusion RRF (Reciprocal Rank Fusion)
final_scores = {}
for rank, idx in enumerate(indices[0]):
final_scores[idx] = final_scores.get(idx, 0) + vector_weight / (rank + 60)
for idx, score in enumerate(keyword_scores):
if score > 0:
final_scores[idx] = final_scores.get(idx, 0) + keyword_weight / (idx + 60)
# Trier et retourner
sorted_results = sorted(final_scores.items(), key=lambda x: x[1], reverse=True)[:k]
return [(self.documents[idx], score) for idx, score in sorted_results]
💡 RRF (Reciprocal Rank Fusion) : Méthode standard pour fusionner plusieurs classements. Le paramètre 60 est empirique.
06
⚡ Optimisation des performances
Indexation avancée
# Index HNSW (rapide et précis)
index = faiss.IndexHNSWFlat(dimension, 32) # 32 = paramètre M
# Index IVF (pour millions de documents)
nlist = 100 # nombre de clusters
quantizer = faiss.IndexFlatIP(dimension)
index = faiss.IndexIVFFlat(quantizer, dimension, nlist)
index.train(embeddings) # Entraînement nécessaire
index.add(embeddings)
Cache des embeddings
import hashlib
import pickle
class EmbeddingCache:
def __init__(self, cache_file="embedding_cache.pkl"):
self.cache_file = cache_file
self.cache = self._load()
def _hash(self, text):
return hashlib.md5(text.encode()).hexdigest()
def get(self, text):
key = self._hash(text)
return self.cache.get(key)
def set(self, text, embedding):
key = self._hash(text)
self.cache[key] = embedding
self._save()
def _save(self):
with open(self.cache_file, 'wb') as f:
pickle.dump(self.cache, f)
⚠️ Points d'attention :
- Mémoire : un index de 1M vecteurs de 384 dims = ~1.5 Go
- Latence : IndexFlat (10ms/requête) vs HNSW (1ms/requête)
- Mise à jour : Les index FAISS sont statiques (reconstruire pour ajouter)
07
📈 Évaluation des résultats
def evaluate_search(search_function, queries, relevant_docs):
"""Calcule le Mean Reciprocal Rank (MRR)"""
mrr_sum = 0
for query, relevant in zip(queries, relevant_docs):
results = search_function(query, k=10)
results_ids = [doc.id for doc in results]
# Trouver le rang du premier document pertinent
for rank, doc_id in enumerate(results_ids, 1):
if doc_id in relevant:
mrr_sum += 1 / rank
break
return mrr_sum / len(queries)
# Exemple
queries = ["discours résistance", "Victor Hugo livre"]
relevant = [["appel_18_juin", "appel_22_juin"], ["les_miserables"]]
mrr = evaluate_search(hybrid_search, queries, relevant)
print(f"MRR: {mrr:.3f}")
💡 Métriques d'évaluation :
- MRR : Mean Reciprocal Rank (position du premier résultat pertinent)
- Recall@k : Proportion de documents pertinents trouvés dans top-k
- Precision@k : Proportion de résultats pertinents dans top-k
- NDCG : Normalized Discounted Cumulative Gain (prend en compte l'ordre)
08
🎯 Exemple complet : Moteur de recherche de discours
class SpeechSearchEngine:
def __init__(self, speeches_data):
self.speeches = speeches_data
self.texts = [s["content"] for s in speeches_data]
# Modèle d'embedding
self.model = SentenceTransformer('paraphrase-multilingual-MiniLM-L12-v2')
# Index FAISS
embeddings = self.model.encode(self.texts)
dimension = embeddings.shape[1]
self.index = faiss.IndexFlatIP(dimension)
faiss.normalize_L2(embeddings)
self.index.add(embeddings)
def search(self, query, k=5):
# Embedding de la requête
query_embedding = self.model.encode([query])
faiss.normalize_L2(query_embedding)
# Recherche
scores, indices = self.index.search(query_embedding, k)
# Résultats
results = []
for score, idx in zip(scores[0], indices[0]):
results.append({
"title": self.speeches[idx]["title"],
"speaker": self.speeches[idx]["speaker"],
"date": self.speeches[idx]["date"],
"score": float(score),
"snippet": self.texts[idx][:200] + "..."
})
return results
# Utilisation
engine = SpeechSearchEngine(speeches_data)
results = engine.search("discours sur la liberté et la résistance")
for r in results:
print(f"{r['title']} - {r['speaker']} (score: {r['score']:.3f})")