Einführung: Retrieval-Augmented Generation (RAG)#
Dieses Notebook demonstriert die Grundstruktur eines Retrieval-Augmented Generation (RAG) Systems.
RAG kombiniert die Stärken von Retrieval- und Generierungsmodellen, um präzise Antworten auf Fragen zu liefern, die auf spezifischen Dokumenten basieren.
Retrieval-Modelle durchsuchen große Dokumentensammlungen, um relevante Informationen zu finden, während Generierungsmodelle diese Informationen nutzen, um kohärente Antworten zu formulieren.
Praktisch kann ein solches System genutzt werden, um Fragen zu beantworten, die auf einem bestimmten Dokument basieren, ohne dass das zugrunde liegende Sprachmodell neu trainiert werden muss. RAG, mit einigen weiteren Tricks, ist damit die Grundlage für Systeme wie PaperQA oder ScholarQA die Fragen basierend auf wissenschaftlichen Artikeln beantworten können. Da Retrieval verwendet wird, kann immer auch eine Referenz zu den Quellen gegeben werden, die für die Antwort verwendet wurden.
Aufbau eines RAG-Systems#
Prinzipiell besteht ein RAG-System aus zwei Hauptkomponenten:
Retrieval: Hierbei werden relevante Abschnitte aus einem Dokument oder einer Sammlung von Dokumenten abgerufen, die für die Beantwortung der gestellten Frage nützlich sein könnten. Hierfür werden Dokumente in kleine Abschnitte („Chunks“) unterteilt und in einem Vektor-Datenbankindex gespeichert. Der Index wird erstellt, indem die Abschnitte in Vektoren umgewandelt werden, die dann in der Datenbank gespeichert werden. Wenn eine Frage gestellt wird, wird der Index durchsucht, um die relevantesten Abschnitte zu finden.
Generierung: Basierend auf den abgerufenen Informationen generiert ein Sprachmodell eine Antwort auf die gestellte Frage.
Ziel des Notebooks#
Fragen zu benutzerdefinierten Dokumenten beantworten – ohne das Modell neu zu trainieren.
Voraussetzungen#
Python 3.8 oder höher
Ein Ordner mit PDF-Dokumenten, die du verwenden möchtest (z.B.
./data
)
# --- Benötigten Pakete installieren ---
!pip install -q pymupdf # Für PDF-Text-Extraktion
!pip install -q numpy # Für Cosine Similarity & Vektorberechnungen
!pip install -q litellm # Für Zugriff auf Embedding- & Sprachmodelle via API
!pip install -q langchain # Für die Verwaltung von Embeddings und Modellen
# --- Imports ---
import os
import fitz # PyMuPDF
import numpy as np
import litellm
from langchain.text_splitter import RecursiveCharacterTextSplitter
from typing import List, Tuple
from dotenv import load_dotenv
Zunachst laden wir die Umgebungsvariablen aus der .env
-Datei, die API-Schlüssel und andere Konfigurationen enthalten sollte.
Es ist zu empfehlen, dass solche API-Schlüssel nicht direkt im Code stehen, sondern in einer .env
-Datei gespeichert werden.
Dafur erstellst du im gleichen Verzeichnis wie dieses Skript eine Datei mit dem Namen .env
und fügst dort deine API-Schlüssel ein, z.B.:
OPENAI_API_KEY=your_openai_api_key
Alternativ kann auch Groq oder ein anderer Provider verwendet werden. Eine komplette Übersicht gibt es auf https://docs.litellm.ai/docs/providers.
Cohere bietet kostenlose API Keys mit einer Token-Begrenzung an https://docs.cohere.com/v2/docs/rate-limits
load_dotenv()
lädt die Umgebungsvariablen aus der .env
-Datei, sodass wir sie im Code verwenden können.
load_dotenv() # Lädt Umgebungsvariablen aus .env-Datei
False
📄 Schritt 1: PDF-Dokumente einlesen#
Zuerst werden PDF-Dateien mit dem Python-Paket fitz
(PyMuPDF) in reinen Text umgewandelt. Dafür definieren wir eine Funktion extract_text_from_pdfs
, die alle PDF-Dateien in einem angegebenen Verzeichnis liest und den Text extrahiert.
Die Funktion ist unvollständig und muss noch implementiert werden. Hinweise zum Implementieren der Funktion findest du in den Kommentaren im Code sowie in der Dokumentation von fitz
(PyMuPDF).
def extract_text_from_pdf_folder(pdf_folder_path):
all_text = ""
# Über alle PDF-Dateien im Ordner iterieren
for filename in os.listdir(pdf_folder_path):
if filename.lower().endswith(".pdf"):
file_path = os.path.join(pdf_folder_path, filename)
# TODO: Öffne die PDF-Datei mit fitz.open(file_path) als doc
for page in doc:
# TODO: Verwende .get_text(), um Text zu extrahieren und zu all_text hinzuzufügen
pass
return all_text
Nun können wir die Funktion zum Extrahieren von Text aus PDF-Dateien testen:
# --- Aufruf der Extraktion ---
pdf_folder = "..." # TODO: Gib den Ordnerpfad mit den PDFs an
raw_text = extract_text_from_pdf_folder(pdf_folder)
---------------------------------------------------------------------------
FileNotFoundError Traceback (most recent call last)
Cell In[5], line 3
1 # --- Aufruf der Extraktion ---
2 pdf_folder = "..." # TODO: Gib den Ordnerpfad mit den PDFs an
----> 3 raw_text = extract_text_from_pdf_folder(pdf_folder)
Cell In[4], line 5, in extract_text_from_pdf_folder(pdf_folder_path)
2 all_text = ""
4 # Über alle PDF-Dateien im Ordner iterieren
----> 5 for filename in os.listdir(pdf_folder_path):
6 if filename.lower().endswith(".pdf"):
7 file_path = os.path.join(pdf_folder_path, filename)
FileNotFoundError: [Errno 2] No such file or directory: '...'
Und uns den extrahierten Text anzeigen lassen
print(raw_text[1000])
✂️ Schritt 2: Text in Chunks zerlegen#
Der extrahierte Text wird nun in kleinere, überlappende Abschnitte (Chunks) aufgeteilt.
Hierfur verwenden wir eine Methode die RecursiveCharacterTextSplitter
genannt wird. Diese Methode teilt den Text in kleinere Abschnitte auf, die für die spätere Verarbeitung durch das Retrieval-Modell geeignet sind. Die Chunks werden so erstellt, dass sie eine maximale Länge haben und überlappende Teile enthalten, um sicherzustellen, dass wichtige Informationen nicht verloren gehen. Die Abschnitte werden hierbei erstellt indem der Text an einer definierten Liste von Trennzeichen (wie Absätzen oder Sätzen) aufgeteilt wird. Diese Liste wird durchgegangen bis der Text in kleinere Abschnitte zerlegt ist, die eine maximale Länge nicht überschreiten.
Mehr Informationen dazu können in der LangChain Dokumentation gefunden werden.
Diese Einheiten können später effizient eingebettet und durchsucht werden.
# --- Text in überlappende Chunks aufteilen ---
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=___, # TODO: Wähle sinnvolle Chunk-Größe
chunk_overlap=___ # TODO: Wähle Überlappung
)
chunks = text_splitter.split_text() # TODO: Gib den zu chunkenden Text ein
print(f"{len(chunks)} Chunks erstellt.")
🔢 Schritt 3: Chunks embedden#
Nun werden die erzeugten Chunks in numerische Vektoren (Embeddings) umgewandelt.
Diese Vektoren repräsentieren die semantische Bedeutung der Chunks und ermöglichen es, ähnliche Chunks zu finden. Texte mit ähnlicher Bedeutung werden in der Vektor-Datenbank nahe beieinander liegen.
Text in Vektoren umzuwandeln, wird als „Embedding“ bezeichnet und ist der erste Schritt in Sprachmodellen, um Text in eine Form zu bringen, die von Computern verarbeitet werden kann. Hier verwenden wir die Embeddings allerdings auch um die Chunks in einer Vektor-Datenbank zu speichern, damit sie später für die Retrieval-Komponente des RAG-Systems verwendet werden können.
Dies erfolgt mithilfe der OpenAI API über LiteLLM
. Es können auch andere Embedding-Modelle verwendet werden, die in der Lage sind, Text in Vektoren umzuwandeln.
def embed_text_with_litellm(text: str, model: str = 'text-embedding-3-small') -> List[float]:
"""
Embeds the given text using the LiteLLM API.
"""
response = litellm.embedding(
model=model,
input=text
)
return response["data"][0]["embedding"]
Das können wir testen indem wir verschiedene Texte einbetten und die Vektoren vergleichen. Um Vektoren zu vergleichen, können wir den Winkel zwischen ihnen berechnen. Ein kleiner Winkel bedeutet, dass die Vektoren ähnlich sind.
def cosine_similarity(vec1: np.ndarray, vec2: np.ndarray) -> float:
"""
Berechnet die Cosine Similarity zwischen zwei Vektoren.
"""
dot_product = np.dot(vec1, vec2)
norm_a = np.linalg.norm(vec1)
norm_b = np.linalg.norm(vec2)
if norm_a == 0 or norm_b == 0:
return 0.0
return dot_product / (norm_a * norm_b)
text_a = "Chemie"
text_b = "Chemie ist die Wissenschaft von Stoffen und deren Umwandlungen."
text_c = "Mathematik ist die Wissenschaft von Zahlen und Formen."
cosine_a_b = cosine_similarity(
np.array(embed_text_with_litellm(text_a)),
np.array(embed_text_with_litellm(text_b))
)
cosine_a_c = cosine_similarity(
np.array(embed_text_with_litellm(text_a)),
np.array(embed_text_with_litellm(text_c))
)
print(f"Cosine Similarity zwischen '{text_a}' und '{text_b}': {cosine_a_b:.4f}")
Cosine Similarity zwischen 'Chemie' und 'Chemie ist die Wissenschaft von Stoffen und deren Umwandlungen.': 0.7127
print(f"Cosine Similarity zwischen '{text_a}' und '{text_c}': {cosine_a_c:.4f}")
Cosine Similarity zwischen 'Chemie' und 'Mathematik ist die Wissenschaft von Zahlen und Formen.': 0.3313
Nun können wir die Chunks in Embeddings umwandeln und die Cosine Similarity berechnen:
# Beispielkonfiguration (OpenAI, austauschbar)
litellm_model = "openai/embedding-3-small"
# Liste von Embeddings vorbereiten
embeddings = []
for chunk in chunks:
embeddings.append(embed_text_with_litellm(chunk, model=litellm_model))
print(f"{len(embeddings)} Embeddings erstellt.")
🧠 Schritt 4: Erstellung eines Vektorstores#
Die Embeddings und ihre zugehörigen Textabschnitte werden in einem einfachen Vektorstore gespeichert.
Dazu erstellen wir eine Liste von Paaren bestehend aus:
einem Embedding
dem zugehörigen Textabschnitt
➡️ Dies erlaubt später eine schnelle semantische Suche.
Die zip
-Funktion in Python kann verwendet werden, um zwei Listen zu kombinieren, sodass jedes Element der ersten Liste mit dem entsprechenden Element der zweiten Liste gepaart wird.
vectorstore = list(zip(___, ___)) # TODO: Embeddings + zugehörige Text Chunk definieren
🔍 Schritt 5: Retrieval der relevanten Textabschnitte#
Um zu einer Nutzeranfrage passende Textstellen zu finden, berechnen wir die Cosine Similarity
zwischen dem Embedding der Frage und allen gespeicherten Embeddings.
Nun sollen die k ähnlichsten Chunks zur Nutzeranfrage (query
) gefunden und zurückgegeben werden.
Hierzu definieren wir die Funktion retrieve_top_k
. Diese Funktion nimmt die Nutzeranfrage (query
) und die Anzahl der gewünschten Ergebnisse (k
) als Eingabeparameter. Sie berechnet die Cosine Similarity zwischen dem Embedding der Anfrage und den gespeicherten Embeddings und gibt die k
ähnlichsten Chunks zurück.
Die Funktion retrieve_top_k
wird wie folgt implementiert:
Berechnung des Embeddings der Anfrage (
query_embedding
).Berechnung der Cosine Similarity zwischen dem
query_embedding
und allen im Vektorstorevectorstore
gespeicherten Embeddings mittelscosine_similarity
.Sortierung der Ergebnisse nach der Cosine Similarity in absteigender Reihenfolge. Die Cosine Similarity kann zwischen -1 und 1 liegen, wobei 1 die höchste Ähnlichkeit bedeutet. Deshalb sortieren wir die Ergebnisse in absteigender Reihenfolge.
Rückgabe der
k
ähnlichsten Chunks und ihrer Cosine Similarity-Werte. Hierbei müssen wir darauf achten, dass in manchen Fällenk
größer sein kann als die Anzahl der verfügbaren Chunks. In diesem Fall sollten wir nur die verfügbaren Chunks zurückgeben.
# --- Ähnlichste Chunks zur Nutzerfrage finden ---
def retrieve_top_k(query: str, k: int = 3) -> List[str]:
query_embedding = np.array(embed_text_with_litellm(query, model=litellm_model))
# Ähnlichkeit mit allen gespeicherten Embeddings berechnen
scored_chunks = []
for embedding, text in vectorstore:
embedding = np.array(embedding)
score = cosine_similarity(___, ___) # TODO: Cosine Similarity korrekt aufrufen
scored_chunks.append((score, text))
# Chunks nach Score absteigend sortieren
scored_chunks = sorted(
___, # TODO: Liste der Scoring-Ergebnisse einsetzen
key=lambda x: x[0], # Sortiere nach dem ersten Element im Tupel = Score
reverse=True # Höchste Scores zuerst
)
# Texte der Top-k Ergebnisse zurückgeben
top_chunks = []
for i in range(min(___, len(___))): # TODO: Ersetze beide ___ mit der gewünschten Anzahl an Ergebnissen und der Länge der Liste scored_chunks
top_chunks.append(scored_chunks[i][1])
return top_chunks
💬 Schritt 6: Nutzeranfrage stellen und Antwort generieren#
Die Nutzerfrage wird zunächst ebenfalls in ein Embedding umgewandelt.
Danach werden die semantisch ähnlichsten Chunks aus dem Vektorstore geladen.
Diese bilden den Kontext, den das Sprachmodell (z.B. GPT-4) verwendet, um eine Antwort zu generieren.
# --- Nutzerfrage stellen ---
query = "..." # TODO: Gib hier deine Frage ein
Nun können wir die Top-k Chunks abrufen:
top_chunks = retrieve_top_k(___, ___) # TODO: Argumente der Abfragefunktion definieren
Um basierend auf der Literatur und Nutzerfrage eine Antwort zu generieren, kannst du ein Sprachmodell verwenden.
Hiefur kombinieren wir die Top-k Chunks und senden zwei Messages
an das Modell.
Eine Message
ist der sogenannte System Prompt
, der dem Modell Kontext gibt. Der System Prompt
definiert die Rolle des Modells und gibt Anweisungen, wie es antworten soll. Er wird in der Regel einmalig zu Beginn der Konversation festgelegt und bleibt während der gesamten Sitzung unverändert.
Die andere Message
ist die User Message
, die die eigentliche Nutzerfrage enthält. In dieser Message
wird das Modell aufgefordert, eine Antwort auf die gestellte Frage zu generieren. Hierfür wird der Text der Top-k Chunks als Kontext hinzugefügt, um dem Modell relevante Informationen zu liefern.
# --- Prompt vorbereiten ---
retrieved_context = "\n\n".join(top_chunks)
# --- Prompt + Frage an Sprachmodell übergeben (z.B. GPT-4 via LiteLLM) ---
response = litellm.completion(
model="gpt-4", # TODO: Modell ggf. anpassen
messages=[
{"role": "system", "content": "Beantworte Fragen basierend auf den folgenden Textauszügen."},
{"role": "user", "content": f"Textauszüge: {___}\n\nFrage: {___}"} # TODO: Ähnliche Textauszüge und Nutzteranfrage definieren
]
)
# --- Antwort anzeigen ---
print("Antwort:")
print(response["choices"][0]["message"]["content"])
Zusammenfassung#
In diesem Tutorial hast du Schritt für Schritt ein einfaches Retrieval-Augmented Generation (RAG) System aufgebaut.
Du hast gelernt:
wie man Dokumente in Text umwandelt,
wie man diesen Text in verarbeitbare Chunks aufteilt,
wie man eigene Embeddings erzeugt,
und wie man eine semantische Suche selbst implementiert.
Anschließend konntest du mit Hilfe eines Sprachmodells Fragen zu beliebigen Dokumenten beantworten.
🔧 Dieses Grundgerüst lässt sich nun beliebig erweitern – z.B. mit:
Vektor-Datenbanken wie FAISS, Chroma oder Weaviate
lokalen Sprachmodellen (z.B. über Ollama, Hugging Face)
anderen Datenquellen (z.B. HTML, CSV, Notizen, Mails)
In der Praxis wird RAG häufig in Kombination mit anderen Techniken verwendet, um die Präzision und Relevanz der Antworten zu verbessern. Sehr hilfreich kann es sein das Sprachmodell mehrmals aufzurufen: zum Beispiel um viele Chunks zusammenzufassen und die Relevanz der Chunks zu bewerten, bevor die finale Antwort generiert wird. Sehr oft wird auch die Suche in Vektor-Datenbanken mit einer „klassichen“ Textsuche kombiniert, um die Relevanz der Ergebnisse zu erhöhen.