📋 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
| 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
📊 Konfigurationsparameter
CHUNK_MAX_CHARS = 700- Maximale Chunk-GrößeCHUNK_OVERLAP = 200- Überlappung zwischen ChunksHYBRID_ALPHA = 0.6- 60% Embeddings, 40% BM25TOP_K = 5- Anzahl relevanter ChunksTEMPERATURE = 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.
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-textModel - 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)
🔍 Hybrid-Search Algorithmus
Funktionsweise
Die Hybrid-Suche kombiniert zwei unterschiedliche Ansätze und nutzt ihre jeweiligen Stärken:
BM25 (Keyword-Suche)
Stärken:
- Exakte Begriff-Übereinstimmung
- Schnell und effizient
- Gut für spezifische Fachbegriffe
Gewichtung: 40%
Embeddings (Semantik)
Stärken:
- Versteht Bedeutung und Kontext
- Findet ähnliche Konzepte
- Sprachübergreifend
Gewichtung: 60%
Hybrid-Search Ablauf
⚠️ Wichtige Anmerkung
Die Score-Normalisierung ist essentiell! BM25 und Embedding-Scores haben unterschiedliche Skalen. Ohne Normalisierung würde eine Methode die andere dominieren.
💻 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.