Überspringen und zum Inhalt gehen →

30 Tage DSPy-Challenge – Tag 18: Multi-Hop-Fragen beantworten

In den vorangegangenen Einheiten habe ich eine einfache RAG-Pipeline (Retrieval Augmented Generation) implementiert. Diese Systeme funktionieren gut, solange die Antwort auf eine Frage in einem einzigen Textabschnitt zu finden ist. In der Realität erfordern viele Anfragen jedoch das Verknüpfen von Informationen aus unterschiedlichen Quellen.

Am 18. Tag der Challenge liegt der Fokus auf sogenannten Multi-Hop-Fragen. Es wird erläutert, wie die Architektur eines DSPy-Moduls angepasst werden muss, um iterative Suchprozesse durchzuführen und komplexe Zusammenhänge aufzulösen.

Theorie: Die Herausforderung von Multi-Hop-QA

Eine Multi-Hop-Frage ist eine Anfrage, die nicht durch einen einzelnen Abrufschritt (Retrieval) beantwortet werden kann, sondern logische Zwischenschritte erfordert.

Ein klassisches Beispiel lautet: „In welcher Stadt wurde der Autor von ‚Harry Potter‘ geboren?“

Ein Standard-RAG-System führt eine Vektorsuche basierend auf der semantischen Ähnlichkeit zur Eingabefrage durch. Wenn kein Dokument existiert, das explizit den Satz „J.K. Rowling, die Autorin von Harry Potter, wurde in Yate geboren“ enthält, scheitert das System oft. Die Information ist auf zwei Fakten verteilt:

Der Autor von Harry Potter ist J.K. Rowling. (Dokument A)
J.K. Rowling wurde in Yate geboren. (Dokument B)

    Um die Frage korrekt zu beantworten, muss das System in „Sprüngen“ (Hops) vorgehen:

    Hop 1: Suche nach „Autor von Harry Potter“. Ergebnis: J.K. Rowling.
    Hop 2: Nutzung des Ergebnisses aus Hop 1 für eine neue Suche: „Geburtsort von J.K. Rowling“.
    Synthese: Kombination der Informationen zur finalen Antwort.

      In DSPy wird dieses Verhalten durch Module realisiert, die den Kontext iterativ erweitern, bevor die endgültige Antwort generiert wird.

      Praxis: Implementierung einer Multi-Hop-Pipeline

      Für die Umsetzung wird ein erweitertes DSPy-Modul benötigt. Anders als bei der einfachen ChainOfThought-Pipeline von Tag 16, muss das Modell hier befähigt werden, eigene Suchanfragen zu generieren, die auf dem bisher gesammelten Kontext basieren.

      Definition der Signaturen

      Es werden zwei Signaturen benötigt. Die erste Signatur dient dazu, basierend auf dem bisherigen Kontext eine neue Suchanfrage zu formulieren. Die zweite Signatur generiert die finale Antwort.

      class GenerateSearchQuery(dspy.Signature):
          """Generiert eine Suchanfrage basierend auf einer Frage und dem bisherigen Kontext, um fehlende Informationen zu finden."""
      
          context = dspy.InputField(desc="Die bisher gesammelten Fakten.")
          question = dspy.InputField(desc="Die ursprüngliche Frage.")
          query = dspy.OutputField(desc="Eine präzise Suchanfrage für den nächsten Informationsschritt.")
      
      class GenerateAnswer(dspy.Signature):
          """Beantwortet die Frage basierend auf dem gesammelten Kontext ausführlich."""
      
          context = dspy.InputField(desc="Alle relevanten Informationen aus mehreren Suchschritten.")
          question = dspy.InputField(desc="Die zu beantwortende Frage.")
          answer = dspy.OutputField(desc="Die finale Antwort.")

      Das Multi-Hop-Modul

      Das folgende Modul implementiert eine Schleife, die mehrfach den Retriever aufruft (in diesem Beispiel wird eine fixe Anzahl von 2 Hops angenommen, um die Komplexität gering zu halten). Dies entspricht einer vereinfachten Form des „Baleen“-Musters in DSPy.

       class MultiHopRAG(dspy.Module):
          def __init__(self, max_hops=2):
              super().__init__()
              self.max_hops = max_hops
      
              # Modul zum Generieren von Suchanfragen
              self.generate_query = dspy.ChainOfThought(GenerateSearchQuery)
      
              # Das Retrieval-Modul (greift auf den global konfigurierten Retriever zu)
              self.retrieve = dspy.Retrieve(k=2)
      
              # Modul zur finalen Antwortgenerierung
              self.generate_answer = dspy.ChainOfThought(GenerateAnswer)
      
          def forward(self, question):
              context = []
      
              for hop in range(self.max_hops):
                  # Schritt 1: Generiere eine Suchanfrage basierend auf Frage + bisherigem Kontext
                  # Beim ersten Hop ist der Kontext noch leer.
                  query_result = self.generate_query(context=context, question=question)
                  search_query = query_result.query
      
                  # Schritt 2: Suche nach Informationen mit der generierten Query
                  raw_passages = self.retrieve(search_query).passages
              
                  # Extrahiere die Strings 
                  passages = [psg for psg in raw_passages]
      
                  # Schritt 3: Erweitere den Kontext
                  # Um Duplikate zu vermeiden, werden nur neue Passagen hinzugefügt.
                  context = list(set(context + passages))
      
              # Schritt 4: Generiere die finale Antwort mit dem angereicherten Kontext
              prediction = self.generate_answer(context=context, question=question)
      
              return prediction

      Voraussetzung ist, dass ein dspy.Retrieve-Modell (z. B. LocalRetriever) und ein Language Model (LM) konfiguriert sind.

      import dspy
      
      print(dspy.__version__)
      
      local_llm = dspy.LM(
          "openai/qwen3:30b", 
          api_base="http://localhost:11434/v1", 
          api_key="no_key_needed"
      )
      
      dspy.configure(lm=local_llm,  cache=False)
      dspy.configure_cache(enable_disk_cache=False)
      dspy.configure_cache(enable_memory_cache=False)
      # Define a simple Retriever class
      class LocalRetriever(dspy.Retrieve):
          def __init__(self, docs, k=3):
              super().__init__(k=k)
              self.docs = docs
              
          def forward(self, query_or_queries, k=None):
              k = k if k is not None else self.k
              query = query_or_queries if isinstance(query_or_queries, str) else query_or_queries[0]
      
              results = [doc for doc in self.docs if any(word.lower() in doc.lower() for word in query.split())]
      
              if not results:
                  results = self.docs
      
              predictions = [dspy.Example(long_text=doc) for doc in results[:k]]
      
              return predictions
      
      # Your documents
      docs = [
          "Gründers von Microsoft ist Bill Gates", 
          "Bill Gates wurde in den USA geboren.", 
          "Die Hauptstadt der USA ist Washington, D.C.."
          
      ]
      
      # Instantiate your custom retriever
      rm = LocalRetriever(docs)
      
      # Configure DSPy to use it
      dspy.configure(rm=rm)

      Ausführung und Analyse

      Nach der Definition des Moduls kann dieses instanziiert und verwendet werden. Dabei ist zu beobachten, wie sich die Suchanfragen zwischen den Hops verändern.

      # Instanziierung
      multi_hop_program = MultiHopRAG(max_hops=2)
      
      # Beispiel einer Multi-Hop-Frage
      question = "Wie heißt die Hauptstadt des Geburtslandes des Gründers von Microsoft?"
      
      # Ausführung
      response = multi_hop_program(question=question)
      
      print(f"Frage: {question}")
      print(f"Antwort: {response.answer}")

      Ausgabe:

      Frage: Wie heißt die Hauptstadt des Geburtslandes des Gründers von Microsoft?
      Antwort: Washington, D.C.

      Interpretation der Abläufe

      Die Analyse der generierten Prompts und Antworten verdeutlicht die Arbeitsweise der implementierten MultiHopRAG-Klasse. Der Prozess gliedert sich in mehrere logische Phasen: die anfängliche Zerlegung der Frage, die Kontext-Erweiterung und die finale Synthese.

      Erster Hop: Zerlegung und Initialisierung

      Im ersten Schritt erhält das Modul GenerateSearchQuery die komplexe Frage sowie einen leeren Kontext. Das Modell analysiert die Abhängigkeiten in der Frage: „Hauptstadt“ → „Geburtsland“ → „Gründer von Microsoft“. Es identifiziert intern bereits „Bill Gates“ als Gründer, erkennt jedoch die Notwendigkeit, das Geburtsland extern zu validieren, da der Kontext leer ist. Es generiert die Suchanfrage Bill Gates birth country. Das Modell priorisiert hier das fehlende Faktum (Geburtsland) über die triviale Information (Name des Gründers), was auf das interne Wissen des LLMs zurückzuführen ist.

      Zweiter Hop: Kontext-Verarbeitung und Verfeinerung

      Im zweiten Schritt wurde der Kontext durch den Retriever angereichert. Er enthält nun zwei Fakten: Bill Gates ist der Gründer und er wurde in den USA geboren. Das Modell gleicht die ursprüngliche Frage mit dem neuen Kontext ab. Die Kette „Gründer = Bill Gates“ und „Geburtsland = USA“ ist gelöst. Das Modell isoliert die verbleibende Unbekannte: die Hauptstadt dieses Landes. Die generierte Query lautet präzise Hauptstadt der USA. Dies demonstriert die Fähigkeit des Systems, den Fokus der Suche basierend auf dem Informationsgewinn zu verschieben (von der Person zum Land).

      Finale Antwort: Synthese

      Im letzten Schritt übernimmt das Modul GenerateAnswer.

      • Reasoning: Das reasoning-Feld zeigt die vollständige logische Kette:
        1. Extraktion des Faktums: Bill Gates ist Gründer.
        2. Verknüpfung: Er wurde in den USA geboren.
        3. Deduktion (bzw. Abruf internen Wissens): Die Hauptstadt der USA ist Washington, D.C.
      • Ergebnis: Die finale Antwort Washington, D.C. wird ausgegeben.

      Fazit der Analyse

      Die Inspektion der Prompts mit dem Befehl local_llm.inspect_history(n=10) zeigt, dass DSPy durch die Verwendung von Signaturen und ChainOfThought in der Lage ist, eine komplexe Frage in sequentielle Suchschritte zu unterteilen. Das System verlässt sich nicht auf einen einzigen „Lucky Shot“ in der Datenbank, sondern baut die Antwort iterativ auf. Auffällig ist hierbei, dass das LLM explizite Retrievals (z. B. ein Dokument, das explizit sagt „Washington ist die Hauptstadt“) durch sein parametrisches Wissen ergänzen kann, solange die spezifischen Fakten (Geburtsort) durch den Kontext bereitgestellt wurden.

      Veröffentlicht in Allgemein