🔍 Hybrid RAG-Chat

BM25 + Embeddings + ChromaDB • Intelligente Dokumentensuche mit Retrieval-Augmented Generation

Autor: Ralf Spielmann

📋 Projektübersicht

Was ist Hybrid RAG-Chat?

Ein fortschrittliches Retrieval-Augmented Generation (RAG) System, das zwei leistungsstarke Suchmethoden kombiniert:

  • BM25 (Best Match 25) - Keyword-basierte Suche für exakte Begriffe
  • Embedding-basierte Suche - Semantisches Verständnis für kontextuelle Relevanz

🎯 Hauptziele

  • Präzise Beantwortung von Fragen basierend auf Dokumenten
  • Kombination von Keyword- und semantischer Suche für optimale Ergebnisse
  • Streaming-Antworten für bessere User Experience
  • Strikte Kontrolle über Antwortqualität durch System-Prompts

🚀 Performance

Effiziente Indizierung mit persistentem Cache - Chunks werden nur einmal erstellt und wiederverwendet.

🎯 Genauigkeit

Hybrid-Suche (60% Embeddings, 40% BM25) für optimale Balance zwischen Semantik und Keywords.

💬 Streaming

Echtzeit-Antworten mit Cursor-Effekt für interaktive Benutzererfahrung.

🔒 Kontrolle

Strikte System-Prompts verhindern Halluzinationen und erzwingen kontextbasierte Antworten.

🛠️ Technologie-Stack

Python 3.x Streamlit Ollama (LLaMA3) ChromaDB BM25Okapi PyTorch NumPy
Technologie Zweck Version/Model
Streamlit Web-Interface und Chat-UI Latest
Ollama LLM für Antwortgenerierung LLaMA3
ChromaDB Vektor-Datenbank für Embeddings Persistent Client
BM25Okapi Keyword-basierte Suche rank_bm25
PyTorch Tensor-Operationen für Embeddings GPU/MPS/CPU Support
nomic-embed-text Embedding-Model Via Ollama

🏗️ System-Architektur

RAG-Pipeline Flow

1. Dokument laden (dokument.txt)
2. Text-Chunking (700 Zeichen, 200 Überlappung)
3. Parallel: BM25-Index + Embeddings erstellen
4. Speichern: BM25 (Pickle) + Embeddings (ChromaDB)
5. User-Query → Hybrid-Search
6. Top-K Chunks als Kontext
7. LLM generiert Antwort (Streaming)

📊 Konfigurationsparameter

  • CHUNK_MAX_CHARS = 700 - Maximale Chunk-Größe
  • CHUNK_OVERLAP = 200 - Überlappung zwischen Chunks
  • HYBRID_ALPHA = 0.6 - 60% Embeddings, 40% BM25
  • TOP_K = 5 - Anzahl relevanter Chunks
  • TEMPERATURE = 0.3 - LLM-Kreativität (niedrig = faktentreu)

⚙️ Hauptkomponenten

1. Text-Verarbeitung

load_file(path: str)

Lädt Textdateien mit UTF-8 Encoding und Fehlerbehandlung.

chunk_text(text: str, max_chars: int, overlap: int)

Teilt Text in überlappende Chunks für besseren Kontext.

Warum Überlappung?
Verhindert, dass wichtige Informationen zwischen Chunks verloren gehen. Mit 200 Zeichen Überlappung bleibt der Kontext erhalten.

2. Embedding-Generierung

embed_texts(texts: List[str], model: str)

  • Erstellt Vektor-Embeddings für mehrere Texte
  • Nutzt Ollama mit nomic-embed-text Model
  • Normalisiert Vektoren für konsistente Vergleiche
  • Unterstützt CUDA, MPS (Apple Silicon) und CPU

embed_query(query: str, model: str)

Optimierte Version für einzelne Queries mit automatischer Gerätewahl.

3. Index-Management

create_bm25_index(chunks: List[str])

Erstellt BM25-Index durch Tokenisierung der Chunks.

get_or_create_hybrid_index(...)

Intelligente Index-Verwaltung:

  • Prüft, ob Index bereits existiert
  • Erstellt bei Bedarf neuen Index (ChromaDB + BM25)
  • Lädt bestehenden Index für Performance
  • Persistiert beide Indizes (ChromaDB + Pickle)

💻 Code-Beispiele

1. Konfiguration

# Modell-Konfiguration
EMBED_MODEL = "nomic-embed-text"
CHAT_MODEL = "llama3"
TEMPERATURE = 0.3  # Niedrig für faktentreue Antworten

# Hybrid-Search Gewichtung
HYBRID_ALPHA = 0.6  # 60% Embeddings, 40% BM25

# Chunking-Parameter
CHUNK_MAX_CHARS = 700
CHUNK_OVERLAP = 200

2. Text-Chunking

def chunk_text(text: str, max_chars: int, overlap: int):
    chunks = []
    start = 0
    step = max_chars - overlap
    
    while start < len(text):
        end = min(start + max_chars, len(text))
        chunk = text[start:end]
        if chunk:
            chunks.append(chunk)
        start += step
    
    return chunks

3. Embedding-Erstellung

def embed_query(query: str, model: str):
    # Automatische Gerätewahl
    device = "cuda" if torch.cuda.is_available() \
        else "mps" if torch.backends.mps.is_available() \
        else "cpu"
    
    # Embedding von Ollama generieren
    resp = ollama.embeddings(model=model, prompt=query)
    vec = torch.tensor(resp["embedding"], device=device)
    
    # Normalisierung für konsistente Vergleiche
    vec = vec / (vec.norm() + 1e-12)
    
    return vec.cpu().numpy().reshape(1, -1)

4. Hybrid-Search Core

def hybrid_search(query, collection, bm25, chunks, top_k, alpha):
    # 1. BM25-Scores (Keyword)
    bm25_scores = bm25.get_scores(query.lower().split())
    bm25_norm = normalize(bm25_scores)
    
    # 2. Embedding-Scores (Semantik)
    query_vec = embed_query(query, EMBED_MODEL)
    results = collection.query(query_embeddings=query_vec)
    embedding_scores = convert_distances_to_similarity(results)
    embedding_norm = normalize(embedding_scores)
    
    # 3. Kombiniere mit Alpha-Gewichtung
    hybrid_scores = alpha * embedding_norm + (1 - alpha) * bm25_norm
    
    # 4. Wähle Top-K Chunks
    top_indices = np.argsort(hybrid_scores)[::-1][:top_k]
    return [chunks[i] for i in top_indices]

5. RAG-Antwort mit Streaming

def rag_answer_hybrid(query, collection, bm25, chunks, stream=True):
    # Hybrid-Suche durchführen
    selected = hybrid_search(query, collection, bm25, chunks)
    
    # Kontext erstellen
    context = "\n\n".join(selected)
    
    # Prompt mit XML-Struktur
    user_prompt = f"""<kontext>{context}</kontext>
<frage>{query}</frage>"""
    
    messages = [
        {"role": "system", "content": SYSTEM_PROMPT},
        {"role": "user", "content": user_prompt}
    ]
    
    return ollama.chat(model=CHAT_MODEL, messages=messages, stream=stream)

6. System-Prompt (optimiert)

"""Du bist ein sachlicher Informationsassistent.

<grundregeln>
1. Lies den KONTEXT sorgfältig
2. Beantworte NUR mit Informationen aus dem KONTEXT
3. Wenn KONTEXT die Antwort enthält: Gib informative Antwort
4. Wenn NICHT im KONTEXT: "Dazu finde ich keine Informationen."
5. NIEMALS beides kombinieren
6. NIEMALS Meta-Kommentare
</grundregeln>

<antwortformat>
- Beginne direkt (KEINE "Ja!", "Definitiv!")
- Nutze wertschätzende Adjektive im Satzverlauf
- Einfache Fragen: 1-2 Sätze mit Details
- Komplexe Fragen: 3-4 Sätze
</antwortformat>"""

🎯 Warum dieser System-Prompt?

  • Klare XML-Struktur: Eindeutige Abgrenzung der Regeln
  • Strikte Kontexttreue: Verhindert Halluzinationen
  • Definierte Antwortstile: Konsistente Ausgaben
  • Wertschätzend aber sachlich: Positive Tonalität ohne Übertreibung

🖥️ Streamlit-Interface

Chat-Interface Features

  • Streaming-Antworten: Echtzeit-Textgenerierung mit Cursor-Effekt
  • Chat-Historie: Persistente Speicherung im Session State
  • Walrus-Operator: Elegante Input-Verarbeitung
  • Index-Caching: Einmalige Index-Erstellung für Performance
# Streamlit Chat-Loop
if prompt := st.chat_input("Welche Frage hast du?"):
    with st.chat_message("assistant"):
        full_response = ""
        placeholder = st.empty()
        
        # Stream die Antwort
        for chunk in rag_answer_hybrid(prompt, stream=True):
            full_response += chunk["message"]["content"]
            placeholder.markdown(full_response + "▌")  # Cursor
        
        placeholder.markdown(full_response)  # Final

⚡ Performance-Optimierungen

💾 Index-Persistenz

BM25 (Pickle) und ChromaDB werden auf Disk gespeichert und wiederverwendet.

🔄 Lazy Loading

Index wird nur bei Bedarf neu erstellt, sonst aus Cache geladen.

🧮 Normalisierung

Einheitliche Score-Skalen für faire Gewichtung zwischen BM25 und Embeddings.

🎮 GPU-Support

Automatische CUDA/MPS/CPU-Wahl für optimale Hardware-Nutzung.

📊 Typische Laufzeiten

  • Index-Erstellung: 5-10 Sekunden (einmalig)
  • Index-Laden: < 1 Sekunde
  • Hybrid-Search: 0.1-0.3 Sekunden
  • LLM-Antwort: 2-5 Sekunden (Stream)

🎯 Anwendungsfälle

📚 Dokumenten-QA

Beantworte Fragen zu technischen Dokumentationen, Handbüchern oder Wissensdatenbanken.

👤 Personal-Profil-Chat

Intelligente Assistenten für Lebensläufe, Portfolios oder Unternehmensprofile.

📖 Forschungs-Assistent

Durchsuche wissenschaftliche Paper und extrahiere relevante Informationen.

🏢 Unternehmens-KI

Interne Wissensdatenbanken durchsuchbar machen für Mitarbeiter.

✨ Vorteile des Hybrid-Ansatzes

Warum Hybrid besser ist als nur Embeddings oder nur BM25?

Aspekt Nur BM25 Nur Embeddings Hybrid
Exakte Keywords ✅ Sehr gut ⚠️ Teilweise ✅ Sehr gut
Semantisches Verständnis ❌ Schwach ✅ Sehr gut ✅ Sehr gut
Synonyme erkennen ❌ Nein ✅ Ja ✅ Ja
Fachbegriffe ✅ Sehr gut ⚠️ Variabel ✅ Sehr gut
Performance ✅ Schnell ⚠️ Langsamer ✅ Optimiert
Robustheit ⚠️ Mittel ⚠️ Mittel ✅ Hoch

🎯 Beispiel: Hybrid in Aktion

Query: "Hat er ML-Erfahrung?"

  • BM25: Findet "ML" (Keyword-Match)
  • Embeddings: Findet "Machine Learning", "KI-Projekte", "Künstliche Intelligenz" (Semantik)
  • Hybrid: Kombiniert beide → Beste Chunks mit exakten und verwandten Begriffen

🚀 Getting Started

Installation & Setup

1. Voraussetzungen

# Ollama installieren und Modelle herunterladen
ollama pull llama3
ollama pull nomic-embed-text

# Python-Pakete installieren
pip install streamlit chromadb rank-bm25 torch numpy ollama

2. Umgebungsvariablen setzen

export OMP_NUM_THREADS=1
export KMP_DUPLICATE_LIB_OK=TRUE

3. Anwendung starten

streamlit run hybrid_search.py

4. Eigene Dokumente verwenden

# In hybrid_search.py anpassen:
DOC_PATHS = ["/pfad/zu/deinem/dokument.txt"]
BM25_INDEX_PATH = "/pfad/zum/bm25_index.pkl"

⚠️ Wichtige Hinweise

  • Bei großen Dokumenten dauert die Index-Erstellung länger (nur einmalig)
  • ChromaDB und BM25-Index müssen im selben Ordner bleiben
  • Ollama muss lokal laufen für LLM und Embeddings
  • Für Apple Silicon: MPS-Support wird automatisch genutzt

🔮 Mögliche Erweiterungen

📄 Multi-Dokument-Support

Mehrere Dokumente gleichzeitig durchsuchen mit Source-Attribution.

🔄 Re-Ranking

Cross-Encoder für finales Re-Ranking der Top-K Ergebnisse.

💾 Chat-History

Konversations-Kontext für Follow-up-Fragen nutzen.

📊 Visualisierungen

Score-Verteilung und Chunk-Relevanz grafisch darstellen.

🎛️ Dynamisches Alpha

Automatische Gewichtung basierend auf Query-Typ.

🌐 API-Endpoint

REST-API für Integration in andere Anwendungen.