Aller au contenu principal

Recherche hybride (sémantique + lexicale)

Mis à jour le 29 juillet 2026

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 exactement l’inverse. Prenez une requête d’administrateur système, « erreur 503 nginx », et observez le comportement des deux moteurs sur une base de documentation technique.

# 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 ✅

Le sémantique comprend qu’il s’agit d’un incident serveur et remonte les articles généraux, mais le nombre 503 lui échappe : pour un modèle d’embedding, 503 et 504 sont deux nombres à trois chiffres, sémantiquement voisins alors qu’ils désignent deux pannes différentes. Le lexical, lui, retrouve le code exact mais passe à côté de l’article sur le timeout du reverse proxy, qui décrit pourtant la cause la plus fréquente. Aucun des deux ne donne la bonne page de résultats ; leur union, si.

Recherche lexicale avec BM25

BM25 est l’algorithme standard de la recherche par mots-clés. Il pondère chaque terme selon sa rareté dans le corpus et sa fréquence dans le document, ce qui fait naturellement ressortir les termes discriminants comme « 503 » ou « nginx ». Encore faut-il lui fournir des tokens propres : la fonction de tokenisation met en minuscules, extrait les mots et retire les mots vides du français, qui n’apportent aucune information de pertinence.

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)

Reste à combiner les deux classements, et c’est plus délicat qu’il n’y paraît : un score cosinus vit entre 0 et 1, un score BM25 n’a pas de borne supérieure. Les additionner directement reviendrait à laisser BM25 écraser le sémantique. RRF contourne le problème en ignorant les scores et en ne retenant que les rangs : chaque document reçoit 1 / (k + rang) dans chaque liste, et l’on somme ces contributions. Un document bien classé partout devance ainsi un document excellent dans une seule 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
    )

La constante k, fixée à 60 par convention issue de la littérature, amortit l’écart entre les premières positions : sans elle, le premier rang pèserait deux fois le deuxième, ce qui rendrait la fusion trop sensible au moindre écart de classement.

Pipeline hybride complet

La classe ci-dessous maintient les deux index en parallèle sur le même corpus, lance les deux recherches à chaque requête et fusionne les classements. Elle retourne aussi les scores individuels — détail précieux en phase de mise au point, car c’est en les comparant qu’on comprend lequel des deux moteurs a porté un résultat.

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']}")

Sur ce corpus de quatre documents, l’article « erreur 503 dans nginx » domine le classement lexical et l’article « problèmes de serveur web » domine le sémantique ; la fusion les fait remonter tous les deux dans le trio de tête, ce qu’aucune des deux méthodes n’aurait produit seule.

Recherche hybride native dans Weaviate

Si vous utilisez déjà Weaviate, inutile d’écrire tout cela : la base intègre la fusion et l’expose en une seule requête, avec un paramètre alpha qui règle l’équilibre entre les deux moteurs — 0 pour du lexical pur, 1 pour du sémantique pur.

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']}")

Un alpha de 0,7 constitue un point de départ raisonnable, mais il n’a rien d’universel : un corpus riche en références techniques et en codes d’erreur gagnera à descendre vers 0,5, tandis qu’une base de connaissances rédigée en langage courant supporte 0,8. Ne réglez ce curseur qu’avec un jeu d’évaluation sous les yeux — c’est précisément l’objet de la leçon suivante.

Résumé

  • La recherche hybride combine sémantique (sens) et lexicale (mots exacts)
  • Reciprocal Rank Fusion (RRF) fusionne les classements efficacement
  • Le paramètre alpha contrôle l’équilibre entre les deux approches
  • Weaviate offre la recherche hybride nativement