Startseite / Artikel / Hybride RAG-Aufrufmethode mit pgvector, BM25 und einem Cross-Encoder zur Neubewertung

Hybride RAG-Aufrufmethode mit pgvector, BM25 und einem Cross-Encoder zur Neubewertung

Erfahren Sie, warum reine Vektorabfragen Teilenummern und Fehlercodes übersehen, und wie Sie pgvector, BM25 sowie Reranking in LangChain kombinieren können, um eine präzise RAG-Auswertung zu erzielen.

1524 Wörter

Ein Prototyp für Retrieval-augmented Generation (RAG), der auf herkömmlicher Vektorsuche basiert, beeindruckt in Demonstrationen in der Regel, enttäuscht aber anschließend echte Nutzer. Wenn man ihn nach dem Wartungsplan für ein bestimmtes Pumpenmodell fragt, liefert er allgemeine Ratschläge zu Pumpen; bei der Suche nach einem exakten Fehlercode erscheint der entsprechende FehlerbehebungsSchritt nie. Diese Anleitung erklärt, warum dichte Retrieval-Methoden bei exakten Identifikatoren versagen, und zeigt, wie man dies mit einem hybriden Ansatz beheben kann: pgvector für semantische Suche, BM25 für Schlüsselwortabgleich sowie ein Reranker, um zu entscheiden, welche Datenblöcke tatsächlich zum LLM gelangen.

Warum die Vektorsuche exakte Identifikatoren verpasst

Embeddings sind hervorragend bei der Erfassung von Bedeutungen. Eine Abfrage nach „automobile“ führt zu Dokumenten über „Cars“ und „Fahrzeuge“, weil das Embedding-Modell verwandte Konzepte in einem hochdimensionalen Raum nahe beieinander platziert. Genau diese Eigenschaft ist jedoch auch die Schwäche – Ähnlichkeit bezieht sich auf semantische Nähe und nicht auf exakte Zeichenfolgen.

Wenn ein Benutzer nach einer Teilenummer wie TX-99402 oder einem Fehlercode wie E-404 sucht, kann das Embedding dieser Zeichenkette sehr nahe bei TX-99401 oder allgemeinem Fehlerbehebungstext liegen. Der Suchmechanismus liefert daher Dokumente, die „etwas Ähnliches“ behandeln, anstatt dasjenige, das den exakt eingegebenen Begriff enthält. In technischen Handbüchern, Produktkatalogen und Support-Wissensbasen, in denen Identifikatoren den größten Teil der Bedeutung tragen, ist dies die häufigste Fehlerart.

Hybride Suche: dichte und spärliche Suchverfahren parallel

Die Lösung besteht darin, zwei komplementäre Suchverfahren zu verwenden und das Ergebnis ihrer Suche zu kombinieren:

  1. Dichte Suche (Vektorabfrage) erfasst Kontext und Bedeutung. Anstatt eine separate Vektordatenbank zu nutzen, können Sie Embeddings mit der pgvector-Erweiterung in PostgreSQL speichern. Viele Anwendungen laufen bereits auf Postgres, wodurch das gesamte System kompakt bleibt und Sie weiterhin vertraute Funktionen wie Backups, Zugriffssteuerung und Transaktionen nutzen können.
  2. Spärliche Suche (Schlüsselwortabfrage) erfasst exakte Übereinstimmungen, Akronymen sowie Fachbegriffe aus einem bestimmten Bereich. Der Standardalgorithmus hierfür ist BM25 – eine seit langem etablierte Rangierfunktion, die Dokumente danach bewertet, wie oft Suchbegriffe vorkommen, wobei das Vorkommen dieser Begriffe im gesamten Korpus berücksichtigt sowie die Länge der Dokumente normalisiert wird.

Ein nützliches mentales Modell: Die Vektorsuche findet das richtige Umfeld, und BM25 ermittelt die genaue Hausnummer. Man nimmt die besten Ergebnisse aus beiden Methoden und fügt sie zusammen. Weitere Informationen darüber, bei welchen Anwendungen jede Methode vorteilhaft ist, finden Sie in unserem Artikel über hybride Suche für technisches Wissen.

Warum die zusammengeführten Ergebnisse einen Neurankierer benötigen

Die hybride Suche stellt sofort ein Problem dar. Man verfügt nun über zwei gerankte Listen, deren Bewertungen nicht vergleichbar sind. Eine BM25-Bewertung hängt von Termfrequenzen ab und ist unbegrenzt, während die Vektorähnlichkeit auf der Kosinusdistanz in einer völlig anderen Skala beruht. Eine semantische Bewertung von 0,82 ist weder besser noch schlechter als eine BM25-Bewertung von 14,5; das Sortieren der Gesamtheit nach der Rohbewertung ist sinnlos.

Ein Reranker löst dieses Problem, indem er die ursprünglichen Bewertungen ignoriert. Es handelt sich um ein separates Modell, in der Regel einen Cross-Encoder, der die Abfrage und ein Kandidatendokument gemeinsam liest und für dieses Paar eine einzige Relevanzbewertung ausgibt. Da er beide Texte auf einmal betrachtet, kann er die Relevanz weitaus präziser beurteilen als durch den Vergleich von zwei unabhängig berechneten Embeddings.

Der Ablauf ist wie folgt:

  1. Holen Sie 10 Kandidaten aus jedem Suchmechanismus, pgvector und BM25.
  2. Fassen Sie sie zusammen, sodass insgesamt bis zu 20 Blöcke entstehen.
  3. Bewerten Sie jeden Block mit dem Reranker im Vergleich zur Abfrage.
  4. Behalten Sie die besten 3 Blöcke bei und übergeben Sie nur diese an das LLM.

Kleinere, bessere Blöcke bedeuten außerdem eine kürzere Anfrage, weniger Störungen, die das Modell ignorieren muss, sowie geringere Tokenkosten.

Die Latenzkosten

Cross-Encoders sind teuer. Die Verarbeitung von 20 Dokumenten verlängert jede Anfrage erheblich, und in einer Streaming-Chat-API (zum Beispiel einer mit FastAPI gebauten) verzögert dies die Auslieferung des ersten Tokens. Messen Sie diesen Schritt separat in Ihrer Latenzüberwachung. Der Gewinn an Genauigkeit lohnt sich in der Regel, passen Sie jedoch die Anzahl der Kandidaten an Ihr Budget an; unser Artikel darüber, warum Reranking seine Latenz rechtfertigen muss, geht dieser Abwägung genauer nach.

Implementierung des Pipelines mit LangChain

LangChain bietet Bausteine für jeden Bestandteil, sodass das gesamte Pipeline in zwei kurze Python-Funktionen passt. Die untenstehenden Beispiele sind konzeptionell; passen Sie die Verbindungszeichenfolge, Modelle und Dateipfade an Ihre Umgebung an.

Eingabe: In Blöcke aufteilen, einbetten und zweimal indizieren

Die Einlesefunktion lädt eine Textdatei, teilt sie in Blöcke à 1.000 Zeichen mit einer Überlappung von 100 Zeichen auf und indiziert diese Blöcke anschließend auf zwei Arten. Zunächst werden sie mit OpenAI’s text-embedding-3-small-Modell eingebettet und über PGVector.from_documents in einer pgvector-Kollektion gespeichert. Zweitens wird ein BM25Retriever auf die Blöcke angewendet und mithilfe von pickle auf die Festplatte serialisiert, da BM25 seinen Index im Speicher erstellt.

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")

Dinge, die man berücksichtigen sollte:

  • Der BM25-Index ist ein Zeitpunktssnapshot. Wenn sich die Dokumente ändern, muss er neu erstellt und gespeichert werden, da er sonst aus dem Einklang mit der Vektordatenbank gerät.
  • Laden Sie nur Dateien, die Sie selbst erstellt haben. Das Laden einer pickle-Datei führt zum Ausführen von Code, wodurch manipulierte Dateien ein Sicherheitsrisiko darstellen.
  • Die Verbindungszeichenkette enthält Anmeldeinformationen; laden Sie sie aus der Konfiguration, anstatt sie fest einzubinden.
  • Falls Sie die Schlüsselwortsuche auch im Datenbankinneren beibehalten möchten, bietet Postgres’ eingebaute Volltextsuche eine Alternative zum internen BM25-Index mit unterschiedlichem Ranking-Verhalten.
  • Auswertung: Ensemble und anschließendes Neuranking

    Die Auswertungsfunktion erstellt erneut beide Suchmechanismen und verknüpft sie miteinander. Der pgvector-Suchmechanismus gibt die 10 besten semantischen Ergebnisse zurück (k=10), und auch der dekomprimierte BM25-Suchmechanismus soll 10 Ergebnisse liefern. Ein EnsembleRetriever fügt sie mit jeweils einem Gewicht von 0,5 zusammen. Ein CohereRerank-Kompressor mit top_n=3 umhüllt das Ensemble innerhalb eines ContextualCompressionRetriever, sodass jede Abfrage in einem einzigen invoke-Aufruf durch Auswertung, Zusammenführung und Neuranking läuft.

    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?")
    

    Einige Details, die leicht übersehen werden können:

    • EnsembleRetriever fügt keine Rohwerte hinzu. Es verschmilzt die Listen nach Rang mithilfe der gewichteten Reciprocal Rank Fusion, wodurch das oben beschriebene Skalierungsproblem umgangen wird. Zudem werden Duplikate entfernt, sodass der Neurankierer bei identischen Ergebnissen durch beide Retriever weniger als 20 Blöcke erhalten kann.
    • Veröffentlichen Sie niemals einen API-Schlüssel im Quellcode. Lesen Sie den Cohere-Schlüssel aus einer Umgebungsvariable oder einem Secrets-Manager.
    • setup_hybrid_retriever() wird bei jeder Anfrage hier ausgeführt, wobei jeweils eine erneute Verbindung zu Postgres hergestellt und BM25 wieder initialisiert wird. In einem echten Service sollte der Retriever einmal bei der Startzeit erstellt und danach wiederverwendet werden.
  • LangChain hat seine Pakete in den verschiedenen Versionen umorganisiert, wodurch Klassen wie EnsembleRetriever und ContextualCompressionRetriever in Ihrer Version möglicherweise in einem anderen Paket vorhanden sind. Überprüfen Sie die aktuellen LangChain-Dokumente, falls eine Importierung fehlschlägt.
  • Hauptpunkte

    • Pure-Vektorabfragen sind bei exakten Token wie Teilenummern, SKUs und Fehlercodes schwach; BM25 schließt diese Lücke.
    • pgvector ermöglicht es Ihnen, eine dichte Abfragemöglichkeit zu einer bestehenden PostgreSQL-Infrastruktur hinzuzufügen, ohne eine separate Vektordatenbank verwenden zu müssen.
    • Die Scores verschiedener Abfragesysteme sind nicht vergleichbar, daher sollten sie nach Rang zusammengeführt werden und ein Cross-Encoder für die endgültige Sortierung sorgen.
    • Die Neubewertung verbessert die Präzision, verursacht aber mehr Latenz; planen Sie den Umfang des Kandidatenpools sorgfältig und überwachen Sie ihn.
    • Betrachten Sie den BM25-Index als Build-Artifact, das regelmäßig mit neuen Daten aktualisiert werden muss, und halten Sie Anmeldeinformationen außerhalb des Codes.

    Die hybride Suchmethode garantiert keine perfekten Antworten, beseitigt aber den häufigsten Grund dafür, dass produzierte RAG-Systeme plausibel erscheinende, aber falsche Kontexte liefern.

    Verwandte Artikel