Recherche sémantique

Implémentez un moteur de recherche intelligent qui comprend le sens

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)

Modèles pour le français

  • multilingual-MiniLM : 384 dims, rapide
  • multilingual-MPNet : 768 dims, meilleure qualité
  • CamemBERT : spécifique français
  • OpenAI ada-002 : 1536 dims, API

Similarité cosinus

import numpy as np

def cosine_similarity(a, b):
    return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))

# "résistance" et "combat" sont proches
sim = cosine_similarity(emb1, emb2)
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)

Avantages

  • ✅ Précision améliorée
  • ✅ Meilleure compréhension du contexte
  • ✅ Idéal pour les résultats top-5/10

Inconvénients

  • ❌ Plus lent (modèle plus gros)
  • ❌ Nécessite plus de ressources
  • ❌ À utiliser sur un sous-ensemble
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})")