Accueil / Articles / Recherche hybride RAG avec pgvector, BM25 et un rerankeur à encodeur croisé

Recherche hybride RAG avec pgvector, BM25 et un rerankeur à encodeur croisé

Découvrez pourquoi la recherche vectorielle pure échoue à identifier les numéros de pièce et les codes d’erreur, ainsi que la manière de combiner pgvector, BM25 et le réclassement dans LangChain pour une récupération RAG précise.

1524 mots

Pourquoi la recherche vectorielle rate les identifiants exacts

Les embeddings sont excellents pour comprendre le sens. Une requête pour « automobile » se retrouve près de documents sur « voitures » et « véhicules », car le modèle d’embedding place les concepts liés proches les uns des autres dans un espace à haute dimension. Cette même propriété constitue cependant une faiblesse : la similarité repose sur la proximité sémantique, et non sur des séquences de caractères exactes.

Lorsqu’un utilisateur cherche un numéro de pièce comme TX-99402 ou un code d’erreur comme E-404, l’embedding de cette chaîne peut se trouver très proche de TX-99401 ou de textes génériques d’assistance. Le système de récupération renvoie alors des documents « similaires » plutôt que celui contenant précisément le terme saisi par l’utilisateur. Pour les manuels techniques, les catalogues de produits et les bases de connaissances d’assistance, où les identifiants portent la majeure partie du sens, ce mode de défaillance est prédominant.

Récupération hybride : dense et sparse en parallèle

La solution consiste à exécuter deux outils de récupération complémentaires et à combiner leurs résultats :

  1. Récupération dense (recherche vectorielle) : elle prend en compte le contexte et la signification. Au lieu d’utiliser une base de données vectorielle distincte, vous pouvez stocker les embeddings dans PostgreSQL grâce à l’extension pgvector. De nombreuses applications fonctionnent déjà sur Postgres, donc l’ajout d’une colonne vectorielle permet de maintenir une architecture légère tout en bénéficiant de sauvegardes, de contrôles d’accès et de transactions familiers.
  2. Récupération sparse (recherche par mots-clés) : elle permet de trouver des correspondances exactes, des acronymes et du jargon sectoriel. L’algorithme standard est BM25, une fonction de classement éprouvée qui évalue les documents en fonction de la fréquence d’apparition des termes de recherche, pondérée par leur rareté dans le corpus et normalisée en fonction de la longueur du document.

la recherche hybride pour les connaissances techniques.

Pourquoi les résultats fusionnés nécessitent un réclassificateur

0,82 n’est ni meilleur ni pire qu’un score BM25 de 14,5 ; trier l’ensemble selon le score brut n’a aucun sens.

  1. Récupérer 10 documents candidats auprès de chaque outil de récupération, pgvector et BM25.
  2. Les regrouper pour obtenir jusqu’à 20 fragments.
  3. Évaluer chaque fragment par rapport à la requête à l’aide du reranker.
  4. Conserver les 3 meilleurs fragments et ne les transmettre qu’au LLM.

Les cross-encoders sont coûteux. L’analyse de 20 documents ajoute un temps significatif à chaque requête, et dans une API de chat en streaming (par exemple celle construite avec FastAPI), cela retarde la livraison du premier token. Mesurez cette étape séparément dans votre suivi de la latence. L’amélioration de la précision en vaut généralement la peine, mais ajustez le nombre de candidats en fonction de votre budget ; notre article expliquant pourquoi le reranking doit justifier sa latence aborde plus en détail ce compromis.

Mise en œuvre du pipeline avec LangChain

LangChain fournit des éléments de base pour chaque composant, de sorte que tout le pipeline tient dans deux fonctions Python courtes. Les exemples ci-dessous sont conceptuels ; adaptez la chaîne de connexion, les modèles et les chemins de fichiers à votre environnement.

Ingestion : découper en blocs, embedder et indexer deux fois

La fonction d’ingestion charge un fichier de texte, le divise en blocs de 1 000 caractères avec une superposition de 100 caractères, puis indexe ces mêmes blocs de deux manières. Tout d’abord, elle les incorpore à l’aide du modèle text-embedding-3-small d’OpenAI et les stocke dans une collection pgvector via PGVector.from_documents. Ensuite, elle applique un BM25Retriever aux blocs et les serialise sur disque avec pickle, car BM25 construit son index en mémoire.

import pickle
from langchain_community.document_loaders import TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings
from langchain_postgres.vectorstores import PGVector
from langchain_community.retrievers import BM25Retriever

CONNECTION_STRING = "postgresql+psycopg://user:password@localhost:5432/mydb"
COLLECTION_NAME = "hybrid_docs"

def ingest_documents(file_path: str):
    # 1. Load and chunk the document
    loader = TextLoader(file_path)
    docs = loader.load()

    text_splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=100)
    chunks = text_splitter.split_documents(docs)

    # 2. Store dense embeddings in pgvector
    embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
    PGVector.from_documents(
        embedding=embeddings,
        documents=chunks,
        collection_name=COLLECTION_NAME,
        connection=CONNECTION_STRING,
    )

    # 3. Fit and save the BM25 sparse retriever
    bm25_retriever = BM25Retriever.from_documents(chunks)
    with open("bm25_retriever.pkl", "wb") as f:
        pickle.dump(bm25_retriever, f)

    print(f"Successfully ingested {len(chunks)} chunks.")

# Example usage:
# ingest_documents("technical_manual.txt")

Points à garder à l’esprit :

  • L’index BM25 est un instantané. Lorsque les documents changent, vous devez le réajuster et le sauvegarder à nouveau, sinon il deviendra décalé par rapport au stockage vectoriel.
  • N’ouvrez que les fichiers pickle que vous avez créés vous-même. Le chargement d’un fichier pickle exécute du code, donc un fichier altéré représente un risque pour la sécurité.
  • La chaîne de connexion contient des identifiants ; chargez-la depuis la configuration plutôt que de la coder directement.
  • Si vous préférez conserver également une recherche par mots-clés au sein de la base de données, la recherche plein texte intégrée à PostgreSQL constitue une alternative à l’index BM25 en mémoire, offrant un comportement de classement différent.
  • Récupération : ensemble, puis réclassement

    La fonction de récupération reconstruit les deux outils de recherche et les relie entre eux. L’outil pgvector renvoie les 10 meilleures correspondances sémantiques (k=10), et l’outil BM25 non décompressé est également réglé pour en retourner 10. Un EnsembleRetriever les fusionne avec des poids égaux de 0,5 chacun. Un compresseur CohereRerank avec top_n=3 enveloppe cet ensemble dans un ContextualCompressionRetriever, de sorte que chaque requête passe par la récupération, la fusion et le réclassement au cours d’une seule appelation invoke.

    import pickle
    from langchain_openai import OpenAIEmbeddings
    from langchain_postgres.vectorstores import PGVector
    from langchain.retrievers import EnsembleRetriever, ContextualCompressionRetriever
    from langchain_cohere import CohereRerank
    
    CONNECTION_STRING = "postgresql+psycopg://user:password@localhost:5432/mydb"
    COLLECTION_NAME = "hybrid_docs"
    
    def setup_hybrid_retriever():
        # 1. Initialize Vector Retriever
        embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
        vectorstore = PGVector(
            connection=CONNECTION_STRING,
            embeddings=embeddings,
            collection_name=COLLECTION_NAME,
        )
        # Fetch top 10 semantic matches
        pgvector_retriever = vectorstore.as_retriever(search_kwargs={"k": 10})
    
        # 2. Load Keyword Retriever (BM25)
        with open("bm25_retriever.pkl", "rb") as f:
            bm25_retriever = pickle.load(f)
        # Fetch top 10 exact keyword matches
        bm25_retriever.k = 10
    
        # 3. Merge pools with EnsembleRetriever (50/50 weighting)
        hybrid_retriever = EnsembleRetriever(
            retrievers=[bm25_retriever, pgvector_retriever],
            weights=[0.5, 0.5]
        )
    
        # 4. Rerank the combined 20 chunks to output the absolute top 3
        reranker = CohereRerank(cohere_api_key="YOUR_COHERE_API_KEY", top_n=3)
        advanced_retriever = ContextualCompressionRetriever(
            base_compressor=reranker,
            base_retriever=hybrid_retriever
        )
    
        return advanced_retriever
    
    def query_system(query: str):
        retriever = setup_hybrid_retriever()
        best_docs = retriever.invoke(query)
    
        for i, doc in enumerate(best_docs):
            print(f"\n--- Result {i+1} ---")
            print(doc.page_content)
    
    # Example usage:
    # query_system("What is the warranty period for the TX-99402 sensor?")
    

    Quelques détails facilement négligés :

    • EnsembleRetriever ne prend pas en compte les scores bruts. Il fusionne les listes selon le classement à l’aide de la méthode de fusion par rang réciproque pondéré, ce qui permet d’éviter le déséquilibre de échelle mentionné précédemment. Il supprime également les doublons, de sorte que le réclassificateur peut recevoir moins de 20 fragments lorsque les deux récupérateurs en trouvent le même.
    • N’envoyez jamais de clé API dans le code source. Lisez la clé Cohere depuis une variable d’environnement ou un gestionnaire de secrets.
    • setup_hybrid_retriever() s’exécute à chaque requête, se reconnectant à Postgres et déserialisant BM25 à chaque fois. Dans un service réel, construisez le récupérateur une seule fois au démarrage et réutilisez-le.
  • LangChain a réorganisé ses packages au fil des versions, et des classes telles que EnsembleRetriever et ContextualCompressionRetriever peuvent se trouver dans un package différent dans votre version. Vérifiez la documentation actuelle de LangChain si une importation échoue.
  • Points clés

    • La recherche vectorielle pure est peu efficace pour des tokens exacts tels que les numéros de pièce, les SKUs et les codes d’erreur ; BM25 comble cette lacune.
    • pgvector vous permet d’ajouter une recherche dense à un stack PostgreSQL existant sans avoir besoin d’une base de données vectorielle distincte.
    • Les scores provenant de différents récupérateurs ne sont pas comparables, il faut donc les fusionner en fonction du rang et laisser un réordonneur croisé effectuer le classement final.
    • Le réordonnement améliore la précision mais augmente la latence ; choisissez délibérément la taille du pool de candidats et surveillez-la.
    • Considérez l’index BM25 comme un artefact de construction qui doit être mis à jour avec les données, et gardez les identifiants hors du code.

    La récupération hybride ne garantit pas des réponses parfaites, mais elle élimine la cause la plus fréquente pour laquelle les systèmes RAG en production fournissent un contexte plausible mais erroné.

    Lectures complémentaires