Eine RAG-Pipeline (Retrieval Augmented Generation) nimmt deine eigenen Dokumente, findet relevante Teile und lässt ein LLM darauf antworten. Das funktioniert auch vollständig lokal — ohne Cloud-Abhängigkeit.

Wir bauen hier ein Produktionssystem mit echten Working Examples.

Was brauchst du?

  • Python 3.10+
  • 8 GB RAM (min.), 16 GB empfohlen
  • GPU optional aber empfohlen (NVIDIA mit CUDA)
  • ~2 GB Disk für Modelle

Teil 1: Setup & Installation

Virtuelle Umgebung erstellen

python -m venv rag-env
source rag-env/bin/activate  # Windows: rag-env\Scripts\activate

pip install --upgrade pip

Abhängigkeiten installieren

pip install \
  langchain==0.2.6 \
  langchain-community==0.0.28 \
  chromadb==0.4.28 \
  ollama==0.1.12 \
  pypdf==4.1.0 \
  python-dotenv==1.0.0

Warum diese Versionen? Wir verwenden stabile, bewährte Versionen. Neuere Versionen können Breaking Changes haben.

Teil 2: Ollama einrichten

Ollama lädt Modelle lokal — ohne API-Kosten.

Installation

Geh zu https://ollama.ai und installiere Ollama für dein OS.

Modell laden

ollama pull llama2:7b-chat-q4_0

Das q4_0 bedeutet 4-Bit Quantisierung = 5 GB Download, 4 GB RAM im Speicher.

Test: Ollama läuft?

curl http://localhost:11434/api/generate -d '{
  "model": "llama2:7b-chat-q4_0",
  "prompt": "Wer war Albert Einstein?",
  "stream": false
}' | python -m json.tool

Wenn JSON mit response zurückkommt → OK.

Teil 3: ChromaDB initialisieren

ChromaDB speichert Embeddings mit Metadaten.

Persistente Datenbank erstellen

# setup_chromadb.py
import chromadb

# Lokale persistente Kollektion
client = chromadb.PersistentClient(path="./chroma_data")

# Kollektion erstellen (oder laden wenn schon vorhanden)
collection = client.get_or_create_collection(
    name="dokumente",
    metadata={"hnsw:space": "cosine"}
)

print(f"Collection '{collection.name}' erstellt/geladen")
print(f"Dokumente im Index: {collection.count()}")

Laufen:

python setup_chromadb.py

Output: Dokumente im Index: 0 (noch leer).

Teil 4: Dokumente laden & chunken

RAG braucht gute Chunks: nicht zu klein (verliert Kontext), nicht zu gross (verliert Relevanz).

PDF-Datei laden

# load_documents.py
from langchain_community.document_loaders import PyPDFLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter
import chromadb

# PDF laden
loader = PyPDFLoader("./sample.pdf")
documents = loader.load()

print(f"PDF geladen: {len(documents)} Seiten")

# In Chunks aufteilen
splitter = RecursiveCharacterTextSplitter(
    chunk_size=800,      # Zeichen pro Chunk
    chunk_overlap=100,   # Überlappung (für Kontext)
    separators=["\n\n", "\n", " ", ""]
)

chunks = splitter.split_documents(documents)
print(f"Chunks erstellt: {len(chunks)}")

# Sample-Chunk anschauen
print("\n--- Chunk 0 ---")
print(chunks[0].page_content[:200])

Chunk-Tuning:

  • chunk_size=800: Gut für deutsche Dokumentation
  • chunk_overlap=100: Überlappung hilft bei Grenzbereichen
  • separators: Zuerst auf Absätze, dann Sätze splitten

Test-PDF erstellen

Keine PDF? Hier ein Test:

# create_test_pdf.py
from reportlab.lib.pagesizes import letter
from reportlab.pdfgen import canvas

def create_sample_pdf():
    c = canvas.Canvas("./sample.pdf", pagesize=letter)

    texts = [
        "Künstliche Intelligenz und ihre Auswirkungen",
        "Machine Learning Modelle benötigen große Datenmengen.",
        "RAG-Systeme kombinieren Retrieval mit Generierung.",
        "Die Zukunft liegt in hybriden Ansätzen.",
    ]

    y = 750
    for text in texts:
        c.drawString(50, y, text)
        y -= 50

    c.save()
    print("sample.pdf erstellt")

create_sample_pdf()

Laufen:

pip install reportlab
python create_test_pdf.py
python load_documents.py

Teil 5: Embeddings erzeugen

Ein Embedding ist ein numerischer "Fingerabdruck" eines Textes. Ähnliche Texte haben ähnliche Embeddings.

Mit Ollama-Embedding

# embed_documents.py
from langchain_community.document_loaders import PyPDFLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_community.embeddings import OllamaEmbeddings
import chromadb

# Modell laden (falls noch nicht getan)
import subprocess
subprocess.run(["ollama", "pull", "nomic-embed-text"], check=True)

# Loader & Splitter
loader = PyPDFLoader("./sample.pdf")
documents = loader.load()

splitter = RecursiveCharacterTextSplitter(
    chunk_size=800,
    chunk_overlap=100,
)
chunks = splitter.split_documents(documents)

# Embeddings erzeugen (lokal via Ollama)
embeddings = OllamaEmbeddings(model="nomic-embed-text")

# ChromaDB client
client = chromadb.PersistentClient(path="./chroma_data")
collection = client.get_or_create_collection(
    name="dokumente",
    metadata={"hnsw:space": "cosine"}
)

# Chunks in ChromaDB einträgen
for i, chunk in enumerate(chunks):
    embedding_vector = embeddings.embed_query(chunk.page_content)

    collection.add(
        ids=[f"chunk_{i}"],
        documents=[chunk.page_content],
        metadatas=[{
            "source": chunk.metadata.get("source", "unknown"),
            "page": chunk.metadata.get("page", 0)
        }],
        embeddings=[embedding_vector]  # Optional: manuell speichern
    )
    print(f"Chunk {i} embedded")

print(f"\nGesamt {collection.count()} Chunks in ChromaDB")

Embedding-Modelle:

  • nomic-embed-text: 768-dim, gut für Deutsch
  • mxbai-embed-large: 1024-dim, noch besser
  • all-minilm-l6-v2: Klein & schnell (22 MB)

Laufen:

python embed_documents.py

Teil 6: Retrieval — ähnliche Dokumente finden

# retrieve.py
from langchain_community.embeddings import OllamaEmbeddings
import chromadb

embeddings = OllamaEmbeddings(model="nomic-embed-text")

client = chromadb.PersistentClient(path="./chroma_data")
collection = client.get_or_create_collection(name="dokumente")

# Abfrage
query = "Was ist Machine Learning?"
query_embedding = embeddings.embed_query(query)

# Top 3 ähnliche Chunks finden
results = collection.query(
    query_embeddings=[query_embedding],
    n_results=3,
    include=["documents", "metadatas", "distances"]
)

print("Top Retrieval Results:")
for i, (doc, meta, distance) in enumerate(zip(
    results["documents"][0],
    results["metadatas"][0],
    results["distances"][0]
)):
    print(f"\n--- Result {i+1} (Ähnlichkeit: {1 - distance:.2%}) ---")
    print(f"Source: {meta['source']}, Page: {meta['page']}")
    print(f"Text: {doc[:200]}...")

Laufen:

python retrieve.py

Teil 7: Generation — RAG vollständig

Jetzt kombinieren wir Retrieval + LLM:

# rag_pipeline.py
from langchain_community.embeddings import OllamaEmbeddings
from langchain_community.llms import Ollama
from langchain.prompts import PromptTemplate
from langchain.chains import RetrievalQA
from langchain_community.vectorstores import Chroma
import chromadb

# ChromaDB als LangChain VectorStore
client = chromadb.PersistentClient(path="./chroma_data")
embeddings = OllamaEmbeddings(model="nomic-embed-text")

vectorstore = Chroma(
    client=client,
    collection_name="dokumente",
    embedding_function=embeddings
)

# LLM initialisieren
llm = Ollama(model="llama2:7b-chat-q4_0", temperature=0.3)

# Custom Prompt
template = """Du bist ein hilfreicher Assistent.
Beantworte die Frage basierend auf dem folgenden Kontext.
Wenn du die Antwort nicht im Kontext findest, sag "Ich weiß es nicht".

Kontext:
{context}

Frage: {question}

Antwort:"""

prompt = PromptTemplate(
    template=template,
    input_variables=["context", "question"]
)

# RAG Chain
qa_chain = RetrievalQA.from_chain_type(
    llm=llm,
    chain_type="stuff",  # Einfach: alle Chunks zusammenfügen
    retriever=vectorstore.as_retriever(search_kwargs={"k": 3}),
    chain_type_kwargs={"prompt": prompt},
    return_source_documents=True
)

# Abfrage
result = qa_chain({"query": "Was ist RAG?"})

print("Antwort:")
print(result["result"])

print("\nQuellen:")
for doc in result["source_documents"]:
    print(f"- {doc.metadata['source']} (S.{doc.metadata['page']})")

Laufen:

python rag_pipeline.py

Output:

Antwort:
RAG ist eine Methode, die Dokumentenretrieval mit Sprachmodellen kombiniert...

Quellen:
- ./sample.pdf (S.0)
- ./sample.pdf (S.1)

Teil 8: Production-Grade RAG mit Caching

Produktion braucht Speed. Wir cachen häufig gestellte Fragen:

# rag_production.py
from langchain_community.embeddings import OllamaEmbeddings
from langchain_community.llms import Ollama
from langchain.prompts import PromptTemplate
from langchain.chains import RetrievalQA
from langchain_community.vectorstores import Chroma
import chromadb
import hashlib
import json
from pathlib import Path

# Cache-Datei
CACHE_FILE = "rag_cache.json"

def load_cache():
    if Path(CACHE_FILE).exists():
        with open(CACHE_FILE) as f:
            return json.load(f)
    return {}

def save_cache(cache):
    with open(CACHE_FILE, "w") as f:
        json.dump(cache, f)

def get_query_hash(query):
    return hashlib.md5(query.lower().encode()).hexdigest()

cache = load_cache()

# ChromaDB setup
client = chromadb.PersistentClient(path="./chroma_data")
embeddings = OllamaEmbeddings(model="nomic-embed-text")
vectorstore = Chroma(
    client=client,
    collection_name="dokumente",
    embedding_function=embeddings
)

llm = Ollama(model="llama2:7b-chat-q4_0", temperature=0.3)

template = """Du bist ein hilfreicher Assistent.
Beantworte die Frage basierend auf dem folgenden Kontext.

Kontext:
{context}

Frage: {question}

Antwort:"""

prompt = PromptTemplate(
    template=template,
    input_variables=["context", "question"]
)

qa_chain = RetrievalQA.from_chain_type(
    llm=llm,
    chain_type="stuff",
    retriever=vectorstore.as_retriever(search_kwargs={"k": 3}),
    chain_type_kwargs={"prompt": prompt},
    return_source_documents=True
)

# Abfrage mit Cache
def query_with_cache(question):
    query_hash = get_query_hash(question)

    # Im Cache?
    if query_hash in cache:
        print(f"✓ Cache hit: {question}")
        return cache[query_hash]

    # Nicht im Cache → generieren
    print(f"✗ Cache miss, generiere Antwort...")
    result = qa_chain({"query": question})

    # In Cache speichern
    cache[query_hash] = {
        "answer": result["result"],
        "sources": [
            {
                "source": doc.metadata["source"],
                "page": doc.metadata["page"]
            }
            for doc in result["source_documents"]
        ]
    }
    save_cache(cache)

    return cache[query_hash]

# Test
answer = query_with_cache("Was ist RAG?")
print("Antwort:", answer["answer"][:200])

# Zweimal fragen → Cache hit
answer2 = query_with_cache("Was ist RAG?")

Teil 9: Error Handling & Monitoring

Real-World Systeme brauchen Robustheit:

# rag_robust.py
import logging
from typing import Optional, Dict
from langchain_community.embeddings import OllamaEmbeddings
from langchain_community.llms import Ollama
from langchain.chains import RetrievalQA
from langchain_community.vectorstores import Chroma
import chromadb

# Logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

class RAGPipeline:
    def __init__(self, model_name="llama2:7b-chat-q4_0", embedding_model="nomic-embed-text"):
        try:
            self.embeddings = OllamaEmbeddings(model=embedding_model)
            self.llm = Ollama(model=model_name, temperature=0.3)

            client = chromadb.PersistentClient(path="./chroma_data")
            self.vectorstore = Chroma(
                client=client,
                collection_name="dokumente",
                embedding_function=self.embeddings
            )

            logger.info(f"RAG Pipeline initialisiert: {self.vectorstore._collection.count()} Chunks")
        except Exception as e:
            logger.error(f"Fehler bei RAG-Initialisierung: {e}")
            raise

    def query(self, question: str, max_retries: int = 2) -> Optional[Dict]:
        """Sichere Abfrage mit Fehlerbehandlung"""

        # Validierung
        if not question.strip():
            logger.warning("Leere Frage")
            return None

        if len(question) > 1000:
            logger.warning("Frage zu lang (>1000 Zeichen)")
            return None

        # Retry-Loop
        for attempt in range(max_retries):
            try:
                logger.info(f"Versuch {attempt + 1}: '{question}'")

                # Retrieval
                retriever = self.vectorstore.as_retriever(search_kwargs={"k": 3})
                relevant_docs = retriever.get_relevant_documents(question)

                if not relevant_docs:
                    logger.warning(f"Keine relevanten Dokumente gefunden")
                    return {
                        "answer": "Keine relevanten Dokumente gefunden.",
                        "sources": [],
                        "confidence": 0.0
                    }

                # Generation
                context = "\n\n".join([doc.page_content for doc in relevant_docs])
                prompt = f"Frage: {question}\n\nKontext:\n{context}\n\nAntwort:"

                answer = self.llm(prompt)

                return {
                    "answer": answer,
                    "sources": [doc.metadata.get("source") for doc in relevant_docs],
                    "confidence": 0.85  # Vereinfacht
                }

            except Exception as e:
                logger.error(f"Versuch {attempt + 1} fehlgeschlagen: {e}")
                if attempt == max_retries - 1:
                    return {
                        "answer": "Fehler bei der Verarbeitung. Versuchen Sie es später.",
                        "sources": [],
                        "confidence": 0.0
                    }

# Test
rag = RAGPipeline()
result = rag.query("Was ist Künstliche Intelligenz?")
print(result)

Teil 10: Performance-Tuning

Embedding-Caching

Embeddings sind teuer. Zwischenspeichern:

# caching_embeddings.py
from langchain_community.embeddings import OllamaEmbeddings
import json
from pathlib import Path

class CachedOllamaEmbeddings(OllamaEmbeddings):
    def __init__(self, *args, cache_file="embedding_cache.json", **kwargs):
        super().__init__(*args, **kwargs)
        self.cache_file = cache_file
        self.cache = self._load_cache()

    def _load_cache(self):
        if Path(self.cache_file).exists():
            with open(self.cache_file) as f:
                return json.load(f)
        return {}

    def _save_cache(self):
        with open(self.cache_file, "w") as f:
            json.dump(self.cache, f)

    def embed_query(self, text: str):
        if text in self.cache:
            return self.cache[text]

        embedding = super().embed_query(text)
        self.cache[text] = embedding
        self._save_cache()

        return embedding

# Nutzen
embeddings = CachedOllamaEmbeddings(model="nomic-embed-text")

Retriever k-Tuning

# Zu viele Chunks → Noise
# Zu wenige → Fehlende Info

# Test verschiedene k-Werte
for k in [1, 3, 5, 10]:
    retriever = vectorstore.as_retriever(search_kwargs={"k": k})
    docs = retriever.get_relevant_documents("Meine Abfrage")
    print(f"k={k}: {len(docs)} Dokumente, total {sum(len(d.page_content) for d in docs)} Zeichen")
    # Zu viel Text → LLM wird langsam
    # Zu wenig Text → LLM hat nicht genug Kontext

Fehlerbehebung

Problem: "Connection refused" bei Ollama

# Ollama läuft nicht
ollama serve

# Oder im anderen Terminal testen
curl http://localhost:11434/api/tags

Problem: ChromaDB ist leer

import chromadb
client = chromadb.PersistentClient(path="./chroma_data")
collections = client.list_collections()
print(collections)

# Falls leer: Documents nochmal laden & embedden

Problem: LLM-Antwort ist Unsinn

Ursachen:

  1. Falsche Chunks retrieved → prüfe chunk_size / chunk_overlap
  2. Prompt zu komplex → vereinfachen
  3. Modell zu klein → llama2:7bllama2:13b probieren
  4. Temperature zu hoch → auf 0.1–0.3 setzen

Zusammenfassung

Eine RAG-Pipeline hat 5 Teile:

  1. Dokumente laden → PyPDFLoader, RecursiveCharacterTextSplitter
  2. Embeddings erzeugen → OllamaEmbeddings lokal
  3. Index speichern → ChromaDB persistent
  4. Retrieval → Top-k ähnliche Chunks
  5. Generation → LLM antwortet mit Kontext

Das funktioniert vollständig offline. Kein API-Limit, keine Kosten pro Abfrage.

Nächste Schritte:

  • RAG mit mehreren Datenquellen (CSV, Webseiten, etc.)
  • Reranking für bessere Relevanz
  • Zusammenfassungs-Chains für lange Antworten