Recherche hybride (sémantique + lexicale)
Objectifs
- Comprendre pourquoi combiner recherche sémantique et lexicale
- Implémenter une recherche hybride avec Reciprocal Rank Fusion
- Utiliser la recherche hybride dans les bases vectorielles
Le problème : aucune approche n’est parfaite
La recherche sémantique excelle sur les synonymes mais peut échouer sur des termes techniques précis. La recherche lexicale fait l’inverse :
# Requête : "erreur 503 nginx"
# Sémantique → trouve "problèmes de serveur web" (bien) mais pas "code 503" (manqué)
# Lexicale → trouve "erreur 503 nginx" exactement (bien) mais pas "timeout du reverse proxy" (manqué)
# Hybride → trouve les deux ✅
Recherche lexicale avec BM25
BM25 est l’algorithme standard de la recherche par mots-clés :
from rank_bm25 import BM25Okapi
import re
def tokeniser(texte: str) -> list[str]:
"""Tokenisation simple pour le français."""
texte = texte.lower()
tokens = re.findall(r"\b\w+\b", texte)
# Supprimer les stop words courants
stop_words = {"le", "la", "les", "de", "du", "des", "un", "une",
"et", "ou", "est", "dans", "pour", "avec", "sur"}
return [t for t in tokens if t not in stop_words]
documents = [
"Comment résoudre l'erreur 503 dans nginx",
"Configuration du reverse proxy avec timeout",
"Guide d'installation de nginx sur Ubuntu",
"Les bases de la cuisine française",
]
# Indexer avec BM25
corpus_tokenise = [tokeniser(doc) for doc in documents]
bm25 = BM25Okapi(corpus_tokenise)
# Rechercher
requete_tokens = tokeniser("erreur 503 nginx")
scores_bm25 = bm25.get_scores(requete_tokens)
for i, score in enumerate(scores_bm25):
print(f"BM25 {score:.3f} | {documents[i]}")
Reciprocal Rank Fusion (RRF)
RRF combine les classements de plusieurs systèmes de recherche. Chaque document reçoit un score basé sur son rang dans chaque liste :
def reciprocal_rank_fusion(
resultats_listes: list[list[tuple[int, float]]],
k: int = 60
) -> list[tuple[int, float]]:
"""Fusionne plusieurs listes de résultats avec RRF.
Args:
resultats_listes: liste de listes de (doc_id, score)
k: constante RRF (60 est standard)
"""
scores_fusionnes: dict[int, float] = {}
for resultats in resultats_listes:
for rang, (doc_id, _) in enumerate(resultats):
if doc_id not in scores_fusionnes:
scores_fusionnes[doc_id] = 0.0
scores_fusionnes[doc_id] += 1.0 / (k + rang + 1)
# Trier par score décroissant
return sorted(
scores_fusionnes.items(),
key=lambda x: x[1],
reverse=True
)
Pipeline hybride complet
from openai import OpenAI
from rank_bm25 import BM25Okapi
import numpy as np
class RechercheHybride:
def __init__(self, model: str = "text-embedding-3-large"):
self.client = OpenAI()
self.model = model
self.documents: list[str] = []
self.embeddings: np.ndarray | None = None
self.bm25: BM25Okapi | None = None
def indexer(self, documents: list[str]):
self.documents = documents
# Index sémantique
response = self.client.embeddings.create(
input=documents, model=self.model
)
vecteurs = sorted(response.data, key=lambda x: x.index)
self.embeddings = np.array(
[v.embedding for v in vecteurs], dtype=np.float32
)
# Index BM25
corpus_tokenise = [tokeniser(d) for d in documents]
self.bm25 = BM25Okapi(corpus_tokenise)
def rechercher(
self, requete: str, k: int = 5,
poids_semantique: float = 0.7,
poids_lexical: float = 0.3
) -> list[dict]:
# Recherche sémantique
query_emb = self.client.embeddings.create(
input=requete, model=self.model
).data[0].embedding
scores_sem = self.embeddings @ np.array(query_emb)
classement_sem = [
(i, float(scores_sem[i]))
for i in np.argsort(scores_sem)[::-1]
]
# Recherche lexicale
query_tokens = tokeniser(requete)
scores_lex = self.bm25.get_scores(query_tokens)
classement_lex = [
(i, float(scores_lex[i]))
for i in np.argsort(scores_lex)[::-1]
]
# Fusion RRF
fusionne = reciprocal_rank_fusion(
[classement_sem, classement_lex]
)
return [
{
"texte": self.documents[doc_id],
"score_rrf": score,
"score_sem": float(scores_sem[doc_id]),
"score_lex": float(scores_lex[doc_id]),
}
for doc_id, score in fusionne[:k]
]
# Utilisation
moteur = RechercheHybride()
moteur.indexer([
"Comment résoudre l'erreur 503 dans nginx",
"Timeout et configuration du reverse proxy",
"Guide d'installation nginx sur Ubuntu",
"Diagnostiquer les problèmes de serveur web",
])
resultats = moteur.rechercher("erreur 503 nginx")
for r in resultats:
print(f"RRF: {r['score_rrf']:.4f} | "
f"Sem: {r['score_sem']:.3f} | "
f"Lex: {r['score_lex']:.3f} | "
f"{r['texte']}")
Recherche hybride native dans Weaviate
Weaviate propose la recherche hybride en une seule requête :
import weaviate
wclient = weaviate.connect_to_local()
collection = wclient.collections.get("Document")
# Recherche hybride native
resultats = collection.query.hybrid(
query="erreur 503 nginx",
alpha=0.7, # 0 = lexicale pure, 1 = sémantique pure
limit=5
)
for obj in resultats.objects:
print(f"[{obj.metadata.score:.3f}] {obj.properties['texte']}")
Résumé
- La recherche hybride combine sémantique (sens) et lexicale (mots exacts)
- Reciprocal Rank Fusion (RRF) fusionne les classements efficacement
- Le paramètre
alphacontrôle l’équilibre entre les deux approches - Weaviate offre la recherche hybride nativement