6  LLM mit Retrieval Augmented Generation (RAG)

6.1 Einleitung

In diesem Mini-Projekt wurde ein Retrieval-Augmented Generation (RAG) System entwickelt und getestet. Das System kombiniert ein Large Language Model (LLM) mit einer Vektordatenbank, um kontextbasierte Antworten zu generieren. Zusätzlich wurde ein Klassifikator trainiert, der erkennt, ob ein Nutzer den Wunsch äußert, neue Informationen zu speichern.


6.2 Vektordatenbank

6.2.1 Wikipedia-Downloader und Embedding-Prozess

Die Datei fill_db_wikipedia.py übernimmt das Laden, Vorverarbeiten und Vektorisieren ausgewählter Wikipedia-Artikel. Anschließend werden die Vektoren in einer Chroma-Datenbank gespeichert.

wikis = [
    "Künstliche Intellligenz",
    "Deutscher Bundestag",
    "Bundeskanzler (Deutschland)"
]

Zunächst wird eine Verbindung zur deutschen Wikipedia aufgebaut und die Artikeltexte geladen. Die Texte werden dann in kleinere Abschnitte (“Chunks”) zerteilt:

text_splitter = CharacterTextSplitter(chunk_size=2000, chunk_overlap=50)
doc_splits = text_splitter.split_text(wikitext)

Die einzelnen Textabschnitte werden anschließend mit einem Ollama Embedding-Modell (mxbai-embed-large) in Vektoren umgewandelt und persistent in Chroma gespeichert:

vectorstore = Chroma.from_texts(
    texts=doc_splits,
    embedding=OllamaEmbeddings(model=EMBED_MODEL),
    persist_directory="./test_db",
)

6.3 LLM mit RAG-Integration

Die Datei llm_rag_test.py zeigt die Nutzung des LLM mit und ohne RAG.

6.3.1 Schritt 1: Modelle laden

Zunächst werden das Sprachmodell und das Embedding-Modell heruntergeladen:

models_to_download = [MODEL, EMBED_MODEL]
for model in models_to_download:
    ollama.pull(model, stream=True)

6.3.2 Schritt 2: Anfrage formulieren

Eine Beispiel-Frage wird definiert:

question = "Wer ist der aktuelle Bundeskanzler von Deutschland?"

6.3.3 Schritt 3: Beantwortung ohne Kontext

Das LLM erhält die Frage ohne jeglichen zusätzlichen Kontext:

before_rag_template = "Beantworte die folgende Frage: {question}"
before_rag_chain = before_rag_prompt | model_local | StrOutputParser()

Das Modell antwortet allein basierend auf seinem vortrainierten Wissen.

6.3.4 Schritt 4: Beantwortung mit RAG

Für die RAG-Version wird ein Retriever aus der Chroma-Datenbank erstellt. Dieser liefert relevante Dokumente zur gestellten Frage:

retriever = vectorstore.as_retriever()
Diese Dokumente werden dann in den Prompt eingefügt:

after_rag_template = (
    "Beantworte die Frage nur mithilfe des folgenden Kontextes:\n{context}\nFrage: {question}"
)
after_rag_chain = (
    {"context": retriever, "question": RunnablePassthrough()}
    | after_rag_prompt
    | model_local
    | StrOutputParser()
)

Das Modell wird angewiesen, ausschließlich basierend auf dem gefundenen Kontext zu antworten – was für kontrollierbare und aktuelle Antworten sorgen soll.

6.4 Ergebnis

Der Unterschied zwischen der Antwort ohne Kontext und mit RAG ist klar sichtbar. Während das Modell ohne RAG möglicherweise veraltete oder erfundene Informationen liefert, bezieht es sich mit RAG konkret auf den eingebetteten Inhalt der Wikipedia-Artikel.

Beobachtungen: Die Integration der Vektordatenbank führt zu teils zu zuverlässigeren Antworten. Allerdings erkennt das Modell nicht garantiert die richtigen Informationen in den bereitgestellten Texten. Die Performanz hängt stark von der Qualität und Relevanz der eingebetteten Dokumente ab.

Beispielausgabe

6.5 Zusatz: Klassifikator zur Speichererkennung

Die Datei classifier_training.py trainiert ein Klassifikationsmodell basierend auf TinyBERT. Ziel ist es, Nachrichten zu erkennen, die eine Speicheranweisung enthalten könnten (z. B. “Merk dir das” oder “Speichere folgende Information”).

Beispiel Inhalt der Trainingsdaten:

"Bitte speichere folgende Information: Mein Name ist Max Mustermann.",1
"Merke dir bitte: Ich wohne in der Musterstraße 123.",1
"Füge den Kontakt 'Anna Schmidt' in meine Datenbank ein.",1
...
"Wie funktioniert ein Elektromotor?",0
"Erzähle mir etwas über Albert Einstein.",0
"Was sind die Hauptstädte der europäischen Länder?",0
...

6.5.1 Trainingsprozess

  1. Laden von CSV-Daten mit Labeln (0 = keine Speicheranweisung, 1 = Speicheranweisung).
  2. Tokenisierung mit AutoTokenizer.
  3. Training mit dem Trainer-Interface von Huggingface.
trainer = Trainer(
    model=model,
    args=training_args,
    train_dataset=tokenized_datasets["train"],
    eval_dataset=tokenized_datasets["validation"],
    compute_metrics=compute_metrics,
)

Nach dem Training wird das Modell gespeichert und kann direkt in einer pipeline genutzt werden:

classifier = pipeline("text-classification", model=output_dir, tokenizer=output_dir)
result = classifier("Wie ist das Wetter")
print(result)

6.5.2 Anwendung

Der Klassifikator kann in ein Dialogsystem integriert werden, um dynamisch zu erkennen, ob Nutzereingaben gespeichert werden sollen – eine wichtige Funktion für interaktive Systeme mit Langzeitgedächtnis.

6.6 Alternativ: Web-UI zum Ausführen des Scripts

Die Web-UI besteht aus einer einfachen HTML-Seite mit Chat-Oberfläche und einem Flask-Backend. Nutzer:innen können eine Nachricht eingeben und optional zwischen der Verwendung von RAG („mit“ oder „ohne“) wählen. Die Nachricht wird per POST an das Backend gesendet (/chat), das anschließend das Python-Script run_llm.py aufruft und dessen Ausgabe direkt zurückgibt. Die Antwort erscheint dann im Chatfenster im Browser.

6.6.1 Frontend: index.html

Im Frontend wird die Eingabe verarbeitet und per fetch-Aufruf an den Server gesendet:

fetch(`/chat?rag=${rag}`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ message: userMessage })
});

Die Antwort wird als JSON entgegengenommen und im Chatbereich angezeigt.

6.6.2 Backend: app.py

Das Flask-Backend stellt zwei Routen bereit:

Startseite mit der HTML-Oberfläche:

@app.route("/")
def index():
    return render_template("index.html")

API-Endpunkt für den Chat:

@app.route("/chat", methods=["POST"])
def chat():
    data = request.get_json()
    user_message = data.get("message", "")
    rag_select = request.args.get("rag", "mit")

    script_path = os.path.join(SCRIPTS_DIR, "run_llm.py")

    try:
        result = subprocess.run(
            ["python", script_path, user_message, rag_select],
            text=True,
            capture_output=True,
            check=True,
        )
        output = result.stdout.strip()
    except subprocess.CalledProcessError as e:
        output = f"Fehler beim Ausführen von 'run_llm.py': {e.stderr.strip()}"

    return jsonify({"response": output})

6.6.3 Screenshot der UI

Webinterface

6.6.4 Ganzer Code

index.html:

<!DOCTYPE html>
<html lang="de">
<head>
  <meta charset="UTF-8">
  <title>Randomly Awesome Genius</title>
  <style>
    :root {
      --bg-main: #212121;
      --bg-chat: #202123;
      --bg-user: #323232d9;
      --bg-bot: #323232d9;
      --text: #ffffff;
      --input-bg: #40414f;
      --accent: #10a37f;
    }

    * {
      box-sizing: border-box;
    }

    body {
      margin: 0;
      padding: 0;
      font-family: "Segoe UI", sans-serif;
      background-color: var(--bg-main);
      color: var(--text);
      display: flex;
      flex-direction: column;
      height: 100vh;
    }

    header {
      background-color: var(--bg-chat);
      padding: 16px;
      text-align: center;
      font-size: 18px;
      font-weight: 500;
      border-bottom: 1px solid #2d2f38;
    }

    #chat-box {
      flex: 1;
      padding: 20px;
      overflow-y: auto;
      display: flex;
      flex-direction: column;
      gap: 14px;
    }

    .message {
      max-width: 80%;
      padding: 14px 16px;
      border-radius: 12px;
      font-size: 15px;
      line-height: 1.5;
      white-space: pre-wrap;
    }

    .user {
      align-self: flex-end;
      background-color: var(--bg-user);
      color: #fff;
    }

    .bot {
      align-self: flex-start;
      background-color: var(--bg-bot);
      color: #fff;
    }

    footer {
      display: flex;
      gap: 8px;
      padding: 14px;
      background-color: var(--bg-chat);
      border-top: 1px solid #2d2f38;
    }

    input, select {
      background-color: var(--input-bg);
      color: white;
      border: 1px solid #555;
      border-radius: 8px;
      padding: 10px;
      font-size: 15px;
    }

    input {
      flex: 1;
    }

    select {
      max-width: 160px;
    }

    button {
      background-color: var(--accent);
      color: white;
      border: none;
      padding: 10px 16px;
      font-size: 15px;
      border-radius: 8px;
      cursor: pointer;
      transition: background-color 0.2s ease;
    }

    button:hover {
      background-color: #0e8f70;
    }

    ::placeholder {
      color: #aaa;
    }

    .typing-indicator {
      display: inline-block;
      font-size: 20px;
      line-height: 1;
    }

    .typing-indicator span {
      display: inline-block;
      width: 6px;
      height: 6px;
      margin: 0 2px;
      background: #ccc;
      border-radius: 50%;
      animation: bounce 1.4s infinite ease-in-out both;
    }

    .typing-indicator span:nth-child(2) {
      animation-delay: 0.2s;
    }

    .typing-indicator span:nth-child(3) {
      animation-delay: 0.4s;
    }

    @keyframes bounce {
      0%, 80%, 100% {
        transform: scale(0);
      }
      40% {
        transform: scale(1);
      }
    }
  </style>
</head>
<body>
  <header>Randomly Awesome Genius</header>

  <div id="chat-box"></div>

  <footer>
    <select id="rag-select">
      <option value="ohne">Ohne RAG</option>
      <option value="mit">Mit RAG</option>
    </select>
    <input type="text" id="input" placeholder="Nachricht eingeben..." />
    <button onclick="sendMessage()">Senden</button>
  </footer>

  <script>
    async function sendMessage() {
      const inputField = document.getElementById("input");
      const chatBox = document.getElementById("chat-box");
      const rag = document.getElementById("rag-select").value;
      const userMessage = inputField.value.trim();

      if (!userMessage) return;

      const userDiv = document.createElement("div");
      userDiv.className = "message user";
      userDiv.textContent = userMessage;
      chatBox.appendChild(userDiv);

      inputField.value = "";
      chatBox.scrollTop = chatBox.scrollHeight;

      const loadingDiv = document.createElement("div");
      loadingDiv.className = "message bot";
      loadingDiv.innerHTML = `<div class="typing-indicator"><span></span><span></span><span></span></div>`;
      chatBox.appendChild(loadingDiv);
      chatBox.scrollTop = chatBox.scrollHeight;

      try {
        const res = await fetch(`/chat?rag=${rag}`, {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ message: userMessage })
        });

        const data = await res.json();

        chatBox.removeChild(loadingDiv);

        const botDiv = document.createElement("div");
        botDiv.className = "message bot";
        botDiv.textContent = data.response;
        chatBox.appendChild(botDiv);
        chatBox.scrollTop = chatBox.scrollHeight;
      } catch (error) {
        const errorDiv = document.createElement("div");
        errorDiv.className = "message bot";
        errorDiv.textContent = "Fehler beim Abrufen der Antwort: " + error.message;
        chatBox.appendChild(errorDiv);
      }
    }

    document.getElementById("input").addEventListener("keydown", function(e) {
      if (e.key === "Enter") {
        e.preventDefault();
        sendMessage();
      }
    });
  </script>
</body>
</html>

app.py:

from flask import Flask, request, jsonify, render_template
import subprocess
import os

app = Flask(__name__)
SCRIPTS_DIR = os.path.join(os.path.dirname(__file__), "scripts")

@app.route("/")
def index():
    return render_template("index.html")

@app.route("/chat", methods=["POST"])
def chat():
    data = request.get_json()
    user_message = data.get("message", "")
    rag_select = request.args.get("rag", "mit")
    
    script_path = os.path.join(SCRIPTS_DIR, "run_llm.py")

    try:
        result = subprocess.run(
            ["python", script_path, user_message, rag_select],
            text=True,
            capture_output=True,
            check=True,
        )
        output = result.stdout.strip()
    except subprocess.CalledProcessError as e:
        output = f"Fehler beim Ausführen von 'run_llm.py': {e.stderr.strip()}"

    return jsonify({"response": output})

if __name__ == "__main__":
    app.run(debug=True)

run_llm.py:

# Disclaimer: Alle Print-Ausgaben bis auf die Rückgabe des LLM sind auskommentiert, 
# damit der Kontext funktioniert müssen vorher die Artikel geladen werden.
from langchain_chroma import Chroma
from langchain_ollama import ChatOllama
from langchain_ollama import OllamaEmbeddings
from langchain_core.runnables import RunnablePassthrough
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
import ollama
from tqdm import tqdm
import sys

# settings 
EMBED_MODEL = 'mxbai-embed-large'
MODEL = 'gemma3:27b'
TOKEN_LIMIT = 1000
TEMP = 0.5

#Downloading models...

models_to_download = [MODEL, EMBED_MODEL]

for model in models_to_download:
    #print(f"-------\nPulling {model}...\n-------")
    bars = {}
    for progress in ollama.pull(model, stream=True):
        digest = progress.get('digest')
        if not digest:
            #print(progress.get('status'))
            continue

        if digest not in bars:
            bars[digest] = tqdm(total=progress.get('total'), desc=f'pulling {digest[7:19]}', unit='B', unit_scale=True)

        #bars[digest].update(progress.get('completed', 0) - bars[digest].n)
        if progress.get('completed') == progress.get('total'):
            bars[digest].close()

# question and context to be embedded
question = sys.argv[1]

# LLM settings
model_local = ChatOllama(
    model=MODEL,
    num_predict=TOKEN_LIMIT,
    temperature=TEMP,
    stream=True,
    stop=[
        "<|start_header_id|>",
        "<|end_header_id|>",
        "<|eot_id|>",
        "<|reserved_special_token",
    ],
)

if (len(sys.argv) > 2 and sys.argv[2] == 'mit'):#Generating answer with RAG...
    print("Antwort mit RAG:\n")
    # load content of vector database
    vectorstore = Chroma(
        persist_directory="./../test_db",
        embedding_function=OllamaEmbeddings(model=EMBED_MODEL),
    )
    retriever = vectorstore.as_retriever()
    after_rag_template = (
        "Beantworte die Frage nur mithilfe des folgenden Kontextes:\n{context}\nFrage: {question}"
    )

    after_rag_prompt = ChatPromptTemplate.from_template(
        template=after_rag_template
    )
    after_rag_chain = (
        {"context": retriever, "question": RunnablePassthrough()}
        | after_rag_prompt
        | model_local
        | StrOutputParser()
    )

    for chunk in after_rag_chain.stream(question):
        print(chunk, end="", flush=True)
else:
    print("Antwort ohne RAG:\n")
    before_rag_template = (
        "Beantworte die folgende Frage"
        + ": {question}"
    )
    before_rag_prompt = ChatPromptTemplate.from_template(
        template=before_rag_template
    )
    before_rag_chain = before_rag_prompt | model_local | StrOutputParser()

    for chunk in before_rag_chain.stream(question):
        print(chunk, end="", flush=True)    
print() 

6.7 Fazit

Das Projekt zeigt, wie ein LLM durch zusätzliche Informationen aus einer Vektordatenbank verbessert werden kann. Die Technik garantiert allerdings nicht immer eine perfekte Antwort. Das Aufbereiten des verwendeten Kontextes ist hierzu sehr wichtig.

Die Ergänzung um einen Klassifikator erlaubt es dem Model darüber hinaus kontextsensitiv zu reagieren und neue Informationen zu erkennen.