27. August 2024 von Immo Weber und Sascha Windisch
„From RAGs to Riches“: Der Weg von einfacher zu fortschrittlicher Retrieval Augmented Generation
Retrieval Augmented Generation
Mit der Einführung von ChatGPT und Co. ist der Siegeszug der Large Language Models (LLM) in vielen Bereichen kaum noch aufzuhalten. Neben den kreativen Einsatzmöglichkeiten, wie zum Beispiel der Textgenerierung, ist vor allem die Möglichkeit, Fragen an eigene domänenspezifische Daten und Dokumente zu stellen, von großem Interesse in Industrie und öffentlicher Verwaltung. Ein methodisches Framework, das genau diesen Anwendungsfall ermöglicht, ist die sogenannte Retrieval Augmented Generation (RAG, siehe Blogbeitrag „Retrieval Augmented Generation: LLM auf Steroiden“). Trotz der vielfältigen Einsatzmöglichkeiten von RAG gibt es typische Probleme, deren Lösungsansätze im folgenden Artikel vorgestellt werden sollen.
Retrieval Augmented Generation löst ein wichtiges Problem großer Sprachmodelle. Denn obwohl sie auf einer breiten Wissensbasis trainiert wurden, können sie keine Aussagen über domänenspezifisches Wissen, z.B. unternehmensinterne Daten, machen. Eine valide Option ist zwar, LLMs nachträglich mit genau diesen Daten zu trainieren. Dieser Ansatz ist jedoch teuer und mit hohem Aufwand verbunden. Stattdessen ist es mit einem RAG-Framework möglich, das zusätzliche Domänenwissen zusammen mit der Anfrage des Benutzers extern als Kontext an das Sprachmodell zu übergeben. Dazu wird das Domänenwissen zunächst in kleinere Abschnitte (sog. Chunks, Abbildung 1, II) zerlegt und anschließend mit Hilfe eines sogenannten Embedding-Modells vektorisiert (Abbildung 1, III) und in einer Vektordatenbank gespeichert (Abbildung 1, IV). Der Vorteil der Vektorisierung besteht darin, dass die Inhalte, die aus tausenden bis zu vielen Millionen Ausschnitten bestehen können, bei Nutzeranfragen wesentlich effizienter durchsucht werden können. Stellt ein Nutzer eine Anfrage an die Daten (Abbildung 1, 1), so wird diese ebenfalls vektorisiert (Abbildung 1, 2) und mittels einer Ähnlichkeitssuche mit den Einträgen der Vektordatenbank verglichen (Abbildung 1, 3). Die ähnlichsten Einträge werden dann nach einer Sortierung (Ranking) zusammen mit der Anfrage an ein Sprachmodell übergeben (Abbildung 1, 4a und 4b), um schließlich eine Antwort zu generieren (Abbildung 1, 5).
Abbildung 1: Naives RAG-Framework und einige Probleme in jedem Schritt auftreten können.
Herausforderungen
RAG ist in den letzten Monaten zum Modewort schlechthin geworden, wenn es um die Einsatzmöglichkeiten von LLM geht. Es verspricht viel, doch allzu oft muss der Anwender nach der Implementierung einer entsprechenden Pipeline feststellen, dass die Ergebnisse nicht ganz den Erwartungen entsprechen. Wie viele KI-basierte Werkzeuge fällt auch LLM in die Kategorie „leicht zu erlernen, aber schwer zu beherrschen“. Häufig entspricht die Antwort des LLM nicht oder nur unzureichend der Anfrage.
Die Gründe dafür können vielfältig sein. Einige davon werden im Folgenden dargestellt (siehe auch Abbildung 1).
- A) Informationsverlust nach der Vektorisierung: Es ist beispielsweise denkbar, dass bei der Ähnlichkeitssuche, dem so genannten Retrieval, nicht die relevantesten Abschnitte gefunden werden. Ein Grund dafür liegt u.a. in der Vektorisierung. Der Performanzgewinn, also die Geschwindigkeit der Suche, wird durch einen hohen Komprimierungsgrad der Textabschnitte erkauft. Die Abschnitte werden typischerweise in 768- oder 1536-dimensionale Einzelvektoren umgewandelt, was bei der durchgeführten Ähnlichkeitssuche teilweise zu Informationsverlusten führen kann. Insbesondere auch deshalb, weil zum Zeitpunkt des Embeddings noch nicht klar ist, welche Informationen bei einer späteren Abfrage relevant sind und welche eher komprimiert werden können.
- B) Zu kleine Chunks und zu wenig Kontext: Ein weiteres Problem tritt auf, wenn die gefundenen Chunks zwar relevant sind, aber während der Vorverarbeitung in zu kleine Chunks zerlegt wurden. In diesem Fall kann es sein, dass dem LLM zu wenig Information oder Kontext zur Verfügung steht, um eine vollständige, relevante und korrekte Antwort zu generieren.
- C) Verwässerung des Kontextes durch zu große Chunks: Umgekehrt kann auch zu viel Information, beispielsweise durch zu viele oder zu lange im Retrieval gefundene Chunks, zu Problemen bei der Antwortgenerierung durch das LLM führen. Die Fähigkeit eines LLMs, Informationen aus den Textabschnitten im gegebenen Kontext zu extrahieren und zu verarbeiten, wird als LLM Recall bezeichnet. Es hat sich gezeigt, dass LLMs vor allem Schwierigkeiten haben, Informationen zu finden, die in der Mitte des Kontextes auftreten. Aus diesem Grund gehört der sogenannte "Needle-in-a-Haystack"-Test mittlerweile zum Repertoire von LLM-Benchmarks.
- D) Zu komplexe oder spezifische Anfragen: Eine weitere Ursache für Retrieval-Probleme können zu komplexe oder zu spezifische Prompts beziehungsweise Anfragen sein. Diese können zu Problemen bei der Ähnlichkeitssuche führen, wenn eher irrelevante Aspekte des Prompts nach der Komprimierung im Rahmen der Vektorisierung mit den Inhalten der Quelldatenbank verglichen werden.
- E) Semantische Distanz zwischen Frage und Antwort: Da relevante Informationen zur Beantwortung einer Benutzeranfrage durch eine Ähnlichkeitssuche zwischen der Anfrage und den oft sachlichen Aussagen der Quelldokumente gewonnen werden, kann auch die Tatsache, dass Benutzeranfragen in der Regel als Fragen formuliert sind, ein Problem darstellen. Unabhängig vom sachlichen Inhalt weisen Fragen und Aussagen bereits eine gewisse semantische Distanz auf, die einen Vergleich schon grundsätzlich erschwert.
Lösungsansätze
Die in Abbildung 1 dargestellte Pipeline stellt nur das Grundgerüst eines RAG-Frameworks dar. Es kann funktionieren, insbesondere für einfache Anwendungsfälle. Es muss aber nicht! Während sich LLMs selbst vor allem vertikal entwickeln (mit Ausnahme von Konzepten wie den sogenannten Mixture-of-Experts-Modellen) und mit immer mehr Parametern trainiert werden, hat bei RAG vor allem eine horizontale Entwicklung stattgefunden. Aufgrund des großen Interesses und der mittlerweile großen Verbreitung von RAG-Systemen werden die beschriebenen Probleme durch die Entwicklung immer neuer Verfahren adressiert. Im Folgenden werden vier oder fünf Ansätze vorgestellt.
Reranking
Der erste Ansatz zur Optimierung von RAG befasst sich mit dem Problem A., dem fehlerhaften oder suboptimalen Retrieval. Da zum Zeitpunkt des Embeddings der Quelldokumente noch nicht bekannt sein kann, welche Informationen eines Textabschnitts im Rahmen einer Benutzeranfrage relevant sein könnten, können hochrelevante Informationen bei der Vektorisierung verloren gehen. Dies hat zur Folge, dass möglicherweise relevante Textabschnitte bei der Ähnlichkeitssuche viel niedriger gerankt werden, als sie eigentlich sein sollten und somit gar nicht erst an das LLM übergeben werden. Eine denkbare Lösung könnte sein, dem LLM einfach mehr Textabschnitte zu übergeben, um die Wahrscheinlichkeit zu erhöhen, relevantere Abschnitte zu erhalten. Neben dem oben beschriebenen Problem C („Ausdünnung des Kontextes“) verhindert jedoch auch die je nach Modell unterschiedliche maximale Kontextgröße (Input des LLMs) diesen Lösungsansatz. Stattdessen wird versucht, den Retrieval Recall (Anteil der gefundenen relevanten Abschnitte) zu maximieren, indem möglichst viele relevante Dokumente gesucht werden. Gleichzeitig wird versucht, den LLM Recall zu maximieren, indem eine möglichst geringe Anzahl hochrelevanter Dokumente an das LLM übergeben wird.
Dieser letzte Schritt wird durch so genannte Ranking- oder Cross-Encoding-Modelle ermöglicht. Diese erzeugen für jedes gefundene Dokument hochrelevante Ähnlichkeitsscores, indem sie sowohl die Benutzeranfrage als auch das jeweilige Dokument gemeinsam einbetten. Im Gegensatz zum initialen Embedding im klassischen RAG-System kann hier beim Embedding der Dokumente der spezifische Kontext der Benutzeranfrage berücksichtigt werden. Der Nachteil ist jedoch, dass das Embedding während der Laufzeit der Inferenz erfolgt und somit zu deutlich höheren Latenzen bei der Anfrage führt. In einem letzten Schritt werden die durch die Ähnlichkeitssuche ermittelten Dokumente nach ihrem Ranking-Score sortiert und die k relevantesten Dokumente an das LLM übergeben.
Reranking Coding-Beispiel
Wir nutzen die Erfahrungen aus unserem ersten Blog-Beitrag zu RAG (siehe oben1) und setzen auch hier wieder auf Python und LlamaIndex als Framework für die Anbindung an KI-Modelle und -Algorithmen. Die Grundlagen für eine lauffähige Umgebung sind in der LlamaIndex-Dokumentation nachzulesen.
Wir beginnen mit der Erstellung beziehungsweise dem Einlesen der vektorisierten Daten.
# check if storage already exists
PERSIST_DIR = "./storage"
if not os.path.exists(PERSIST_DIR):
# load the documents and create the index
documents = SimpleDirectoryReader("data").load_data()
index = VectorStoreIndex.from_documents(documents)
# store it for later
index.storage_context.persist(persist_dir=PERSIST_DIR)
else:
# load the existing index
storage_context = StorageContext.from_defaults(persist_dir=PERSIST_DIR)
index = load_index_from_storage(storage_context)
Mit diesem Code wird ein neuer Vektorstore erstellt beziehungsweise ein vorhandener eingelesen. Das Objekt index liefert uns eine Dokumentstruktur, mit der wir im Anschluss ein Abfrage-Objekt query_engine erzeugen können.
query_engine = index.as_query_engine(
similarity_top_k=10,
node_postprocessors=[
LLMRerank(
choice_batch_size=5,
top_n=3,
)
],
response_mode="tree_summarize",
)
response = query_engine.query("What did the author do growing up?")
print("Reranked response:")
print(response)
Das Verfahren ist wie folgt:
- Wir suchen zunächst mit der Ähnlichkeitssuche 10 relevante Chunks. Normalerweise ist diese Anzahl mit drei oder fünf deutlich geringer. Mit der größeren Anzahl optimieren wir unseren Retrieval Recall und können das Reranking besser nutzen.
- In einem Nachbearbeitungsschritt erstellen wir einen Postprozessor, der das Reranking aufruft und uns dann die drei relevantesten Treffer zurückliefert.
Mittlerweile gibt es allein hier verschiedene Ansätze, wie das Reranking konkret umgesetzt werden kann. Bei dem Modul LLMRerank wird beispielsweise ein anderes Sprachmodell aufgerufen und mit einem entsprechenden Prompt aufgefordert, die bisherigen Treffer neu zu bewerten.
Context Expansion/Enrichment
Die Probleme B und C können durch eine sogenannte Context Expansion oder Context Enrichment kompensiert werden. Ein relativ einfacher Ansatz besteht darin, die Einbettung für die Ähnlichkeitssuche auf einzelne oder nur wenige Sätze zu beschränken und damit die Relevanz der gefundenen Inhalte zu erhöhen. Bei der Übergabe der gefundenen Abschnitte an das Sprachmodell werden jedoch auch umliegende Sätze übergeben, um den Kontext für die Antwortgenerierung anzureichern („Enrichment“). Die Anzahl der umliegenden Sätze bzw. der Umfang des zusätzlichen Inhalts stellt dabei einen (optimierbaren) Parameter dar.
Noch einen Schritt weiter geht der sogenannte Parent-Child Chunking-Ansatz. Hier wird beim Chunking nicht eine einzige feste Chunkgröße als Parameter vorgegeben, sondern mindestens eine größere („Parent“) und eine kleinere („Child“) Abschnittsgröße. Die Child Chunks, die Teil eines Parent Chunks sind, werden in der (Vektor-)Datenbank aufeinander referenziert. Für die Ähnlichkeitssuche werden wiederum die kleineren und damit relevanteren Abschnitte verwendet. Werden jedoch mehrere Child Chunks zu einem Parent Chunk gefunden, so wird der Parent Chunk zur Antwortgenerierung an den LLM übergeben und nicht mehrere kleinere Abschnitte.
Context Expansion/Enrichment Coding-Beispiel
Für das Codebeispiel bleiben wir in der gewohnten Entwicklungsumgebung. Allerdings müssen wir den Aufbau der Vektordatenbank etwas anpassen. Dazu verwenden wir vor der Erzeugung der Vektordatenbank einen Parser, der nicht nur die Chunks erzeugt, sondern auch die Umgebung des Chunks (Sätze davor und dahinter) in Abhängigkeit der übergebenen Parameter speichert.
node_parser = SentenceWindowNodeParser.from_defaults(
window_size=3,
window_metadata_key="window",
original_text_metadata_key="original_text",
)
Nehmen wir beispielsweise den folgenden Satz:
“I didn't write essays."
Die Umgebung ist dementsprechend:
“What I Worked On February 2021
Before college the two main things I worked on, outside of school, were writing and programming. I didn't write essays. I wrote what beginning writers were supposed to write then, and probably still are: short stories. My stories were awful. They had hardly any plot, just characters with strong feelings, which I imagined made them deep.”
Im Rahmen der Suchanfrage wird dann in der Nachbearbeitung durch den Postprozessor der gefundene Chunk mit der Satzumgebung angereichert und an das Sprachmodell übergeben. Mit diesen angereicherten Texten wird das Sprachmodell entsprechend aufgerufen, um eine deutlich relevantere Antwort zu generieren.
query_engine = sentence_index.as_query_engine(
similarity_top_k=2,
node_postprocessors=[
MetadataReplacementPostProcessor(target_metadata_key="window")
],
)
response = query_engine.query("What did the author do growing up?")
print("Response:")
print(response)
Query Transformation
Das Problem D der zu komplexen Abfragen kann unter anderem mit der Technik der Query Transformation angegangen werden. Auch für diese Technik gibt es verschiedene Ansätze, die jedoch alle gemeinsam haben, dass ein LLM verwendet wird, um eine Benutzerabfrage zu verändern oder anzupassen. Wenn die Komplexität einer Anfrage dadurch entsteht, dass sie im Wesentlichen aus mehreren Teilfragen besteht, kann ein LLM dazu verwendet werden, die ursprüngliche Anfrage in einem Zwischenschritt in diese Teilfragen zu zerlegen („Sub-Query Decoposition“). Diese Teilfragen werden dann zunächst einzeln durch die Ähnlichkeitssuche bearbeitet und schließlich in einem letzten Schritt durch eine einzige Antwort des LLM wieder zusammengeführt.
Beim Step-Back Prompting wird wiederum ein LLM verwendet, um aus der spezifischen Benutzeranfrage zusätzlich eine allgemeinere Frage abzuleiten. Diese wird zusätzlich in der Ähnlichkeitssuche verarbeitet, um dem LLM mehr Kontext für die eigentliche Beantwortung der Frage zu geben.
Query Transformation Coding Beispiel
Für das folgende Codebeispiel wollen wir die folgende Frage beantworten:
"Welches Projekt, Langchain oder LlamaIndex, hatte in den letzten 3 Monaten mehr Updates?"
Um die Frage zu beantworten, müssen zunächst die Teilfragen beantwortet werden, wie viele Updates Langchain und LlamaIndex jeweils in den letzten 3 Monaten hatten, um dann die Einzelantworten zu kombinieren. Für das Codebeispiel gehen wir davon aus, dass wir jeweils ein PDF-Dokument „Updates_LangChain" und „Updates_LlamaIndex“ in einem Ordner „Data“ abgelegt haben. Im Dokument „Updates_LangChain“ könnte dann z.B. die Information hinterlegt sein, dass in den letzten drei Monaten sieben Updates durchgeführt wurden. Analog im anderen Dokument entsprechend die Information, dass im gleichen Zeitraum vier Updates durchgeführt wurden. Zunächst laden wir wieder unsere Module und Umgebungsvariablen:
import os
from dotenv import load_dotenv
from llama_index.core import SimpleDirectoryReader, VectorStoreIndex
from llama_index.llms.openai import OpenAI
load_dotenv('.env')
openai_api_key = os.getenv('openai_api_key')
os.environ["OPENAI_API_KEY"] = openai_api_key
Dann initialisieren wir unser LLM und laden unsere Dokumente in den Vectorstore:
llm = OpenAI()
# Erstelle den Index aus den Dokumenten.
documents = SimpleDirectoryReader("data").load_data()
index = VectorStoreIndex.from_documents(documents)
Anschließend definieren wir unsere initiale Anfrage, definieren einen Prompt zur Zerlegung der Anfrage in Sub-Queries und speichern diese als Liste:
# Der komplexe Query
complex_query = "Welches Projekt, Langchain oder LlamaIndex, hatte in den letzten 3 Monaten mehr Updates?"
# Zerlege den komplexen Query in einfachere Sub-Queries
sub_queries_prompt = (
f"Zerlege den folgenden komplexen Prompt in eine Reihe von einfacheren, "
f"spezifischeren Prompts, die einzeln beantwortet werden können. Gib die Sub-Queries als Liste aus: "
f"'{complex_query}'"
)
# Generiere Sub-Queries dem LLM
sub_queries = llm.complete(sub_queries_prompt)
sub_queries = str(sub_queries)
print(sub_queries)
sub_queries_list = [line.strip() for line in sub_queries.split('\n') if line.strip()]
Schließlich werden die Sub-Queries über eine Schleife einzeln abgefragt, die Antworten aggregiert und einem LLM zusammen mit der initialen Frage zur Beantwortung übergeben:
# Erstelle das Query-Engine-Objekt
query_engine = index.as_query_engine()
# Bearbeite jede Sub-Query und speichere die Antworten
responses = []
for sub_query in sub_queries_list:
response = query_engine.query(sub_query)
responses.append(response)
# Führe die Antworten zusammen und generiere die finale Antwort
final_query = f"Hier sind die Antworten auf die einzelnen Teile: {responses}. "
final_response = llm.complete(
f"Nutze die folgenden Informationen: '{final_query}' um die folgende Frage, inklusive Begründung, zu beantworten: '{complex_query}'"
)
# Ausgabe der finalen Antwort
print("Finale Antwort:")
print(final_response)
Finale Antwort:
Langchain hatte in den letzten 3 Monaten mehr Updates als LlamaIndex. Dies ergibt sich aus den Informationen, dass Langchain 7 Updates hatte, während LlamaIndex nur 4 Updates verzeichnete. Somit ist Langchain das Projekt mit den meisten Updates in diesem Zeitraum.
Hypothetical Questioning/HyDe
Das Problem der geringen semantischen Nähe der Benutzerfragen zu den Sachaussagen in den Quelldokumenten kann auf zwei Arten gelöst werden. Grundsätzlich besteht ein Ansatz (sog. hypothetical questioning) darin, aus jedem Chunk mittels LLM eine hypothetische Frage zu generieren und diese anstelle der Chunks einzubetten. Auf diese Weise werden die Benutzerfragen nicht mehr mit Faktenaussagen, sondern wieder mit Fragen verglichen, was ihre semantische Nähe verbessern soll. Auch ein umgekehrtes Vorgehen ist mit dem sogenannten HyDe-Ansatz („Hypothetical Document Embedding“) denkbar. Hierbei wird aus der Benutzeranfrage ein hypothetisches Dokument, also eine hypothetische Antwort auf die Frage, generiert und dieses wiederum mit den Quelldokumenten abgeglichen.
Ausblick
In diesem Blog-Beitrag wurde gezeigt, mit welchen Techniken eine klassische RAG-Architektur erweitert werden kann, um häufige Probleme bei der LLM-basierten Suche in domänenspezifischen Quelldaten zu adressieren. Neben diesen algorithmischen Ansätzen gibt es auch grundlegendere Strategien, um die Performanz klassischer RAG-Systeme zu verbessern. Eine Möglichkeit ist die Verwendung von Graphdatenbanken anstelle konventioneller relationaler Datenbanken zur Speicherung der domänenspezifischen Quelldokumente. Dadurch ist es möglich, dem LLM bei der Antwortgenerierung nicht nur sachliche Inhalte, sondern auch Beziehungen zwischen verschiedenen Inhalten zur Verfügung zu stellen. Das Konzept des sogenannten GraphRAG wird im nächsten Blog-Beitrag vorgestellt.