<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Me |</title><link>https://aretascodes.dev/de/authors/me/</link><atom:link href="https://aretascodes.dev/de/authors/me/index.xml" rel="self" type="application/rss+xml"/><description>Me</description><generator>HugoBlox Kit (https://hugoblox.com)</generator><language>de-DE</language><lastBuildDate>Fri, 12 Jun 2026 00:00:00 +0000</lastBuildDate><image><url>https://aretascodes.dev/media/authors/me_hu_be2f8398e3742287.jpg</url><title>Me</title><link>https://aretascodes.dev/de/authors/me/</link></image><item><title>CogniVault Backend erklärt, Teil 1 · Das Backend kennenlernen: Drei Prozesse, vier Schichten</title><link>https://aretascodes.dev/de/blog/backend-explained-meet-the-backend/</link><pubDate>Fri, 12 Jun 2026 00:00:00 +0000</pubDate><guid>https://aretascodes.dev/de/blog/backend-explained-meet-the-backend/</guid><description>
&lt;blockquote class="border-l-4 border-neutral-300 dark:border-neutral-600 pl-4 italic text-neutral-600 dark:text-neutral-400 my-6"&gt;
&lt;p&gt;Alle Abkürzungen werden im Anhang am Ende der Seite vollständig erklärt.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Wenn Leute das erste Mal das CogniVault-Repository öffnen, höre ich meistens eine Variante der Frage: &lt;em&gt;&amp;ldquo;Wo fange ich überhaupt an?&amp;rdquo;&lt;/em&gt; Da gibt es einen RAG-Agenten, einen FAISS-Index, einen DBOS-Workflow, einen Ollama-Host — und wenn du gerade erst in die Tech-Welt einsteigst, ist jedes dieser Wörter wie eine verschlossene Tür.&lt;/p&gt;
&lt;p&gt;Diese Serie öffnet die Türen, eine nach der anderen. Kein RAG-Vorwissen vorausgesetzt, jede Abkürzung wird ausgeschrieben und jede Behauptung lässt sich im
nachprüfen. Falls du meine
schon gelesen hast, betrachte diese Serie als die Führung, die eigentlich hätte zuerst kommen sollen.&lt;/p&gt;
&lt;p&gt;Lass uns das mal aufzeichnen.&lt;/p&gt;
&lt;h2 id="die-ganze-app-besteht-aus-drei-prozessen"&gt;Die ganze App besteht aus drei Prozessen&lt;/h2&gt;
&lt;p&gt;Mit CogniVault kannst du mit deinen eigenen Dokumenten chatten und sie in Quizzes, Workshops, Karteikarten und Mindmaps verwandeln — und dabei verlässt absolut nichts jemals deinen Rechner. (Das &lt;em&gt;Warum&lt;/em&gt; hinter dieser Einschränkung ist eine eigene Geschichte:
.)&lt;/p&gt;
&lt;p&gt;Man könnte erwarten, dass so eine App ein Wildwuchs an Microservices ist. Aber es sind genau drei Prozesse:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Prozess&lt;/th&gt;
&lt;th&gt;Was er macht&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Das Python-Backend&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Eine FastAPI-App auf Port 8000 — sie serviert auch das kompilierte React-Frontend als statische Dateien&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Ollama&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Der lokale Model-Server auf Port 11434, auf dem die KI-Modelle laufen&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;PostgreSQL&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Ein Docker-Container, der &lt;em&gt;nur&lt;/em&gt; für Workflow-Checkpoints genutzt wird — niemals für deine Dokumente&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Alles andere — deine Dateien, der Suchindex, dein Chatverlauf, deine Quiz-Ergebnisse — ist einfach eine Datei auf der Festplatte. Das ist keine Faulheit; das ist das Privatsphäre-Argument physisch umgesetzt. Du kannst jedes Byte, das die App speichert, mit einem Texteditor und einem SQLite-Browser öffnen.&lt;/p&gt;
&lt;h2 id="die-vier-schichten"&gt;Die vier Schichten&lt;/h2&gt;
&lt;p&gt;Bevor wir Technologien beim Namen nennen, hier das mentale Modell, das du für die ganze Serie im Kopf behalten solltest. Das Backend besteht von oben nach unten aus vier Schichten:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Schicht 1 — die Web-Schicht.&lt;/strong&gt; Eine FastAPI-Anwendung nimmt jeden HTTP-Request entgegen und routet ihn an einen von sechs Routern: Chat (&lt;code&gt;/rag&lt;/code&gt;), Wissensmanagement (&lt;code&gt;/upload&lt;/code&gt;, &lt;code&gt;/ingest&lt;/code&gt;), Lerntools (&lt;code&gt;/api/study/*&lt;/code&gt;), Fortschritt (&lt;code&gt;/api/progress/*&lt;/code&gt;), Sprache (&lt;code&gt;/api/transcribe&lt;/code&gt;) und Chatverlauf (&lt;code&gt;/api/history&lt;/code&gt;). FastAPI (ein modernes Python-Web-Framework) generiert unter &lt;code&gt;/api/docs&lt;/code&gt; zudem automatisch eine interaktive API-Dokumentation, was der beste Weg ist, um das Backend zu erkunden, ohne eine Zeile Code lesen zu müssen.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Schicht 2 — die Intelligenz-Schicht.&lt;/strong&gt; Zwei KI-Modelle mit zwei verschiedenen Jobs. &lt;code&gt;gemma4:e4b&lt;/code&gt; &lt;em&gt;generiert&lt;/em&gt;: Chat-Antworten, Gedankengänge (Reasoning), Bildanalyse und Tool-Aufrufe. &lt;code&gt;embeddinggemma&lt;/code&gt; &lt;em&gt;erstellt Embeddings&lt;/em&gt;: Es verwandelt Text in Vektoren (Zahlenlisten, die Bedeutung einfangen), sodass ähnliche Ideen mathematisch gefunden werden können. Beide laufen innerhalb von Ollama — stell dir Ollama wie Docker vor, aber für KI-Modelle.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Schicht 3 — die Retrieval-Schicht.&lt;/strong&gt; Eine Suchmaschine über deine Dokumente, die &lt;em&gt;semantische&lt;/em&gt; Suche (finde Dinge mit gleicher Bedeutung) mit &lt;em&gt;Keyword&lt;/em&gt;-Suche (finde exakte Zeichenketten) kombiniert. Teil 3 dieser Serie dreht sich komplett um diese Schicht.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Schicht 4 — die Persistenz-Schicht.&lt;/strong&gt; Vier Speichersysteme, jedes für einen speziellen Job ausgewählt: ein FAISS-Index plus einer JSON-Datei für durchsuchbares Wissen, SQLite für Lerndaten, PostgreSQL für Workflow-Checkpoints und einfache JSON-Dateien für den Chatverlauf.&lt;/p&gt;
&lt;h2 id="ein-diagramm-alle-wichtigen-teile"&gt;Ein Diagramm, alle wichtigen Teile&lt;/h2&gt;
&lt;div class="mermaid"&gt;flowchart TB
subgraph CLIENT["Browser"]
UI["React Frontend&lt;br/&gt;(kompiliert, serviert von FastAPI)"]
end
subgraph SERVER["FastAPI Backend — Port 8000"]
ROUTERS["6 Router&lt;br/&gt;rag · knowledge · study ·&lt;br/&gt;progress · audio · history"]
AGENT["RAG Agent&lt;br/&gt;(Strands SDK, 6 Tools)"]
VDB["VectorDB&lt;br/&gt;FAISS + BM25 + RRF"]
INGEST["Ingestion&lt;br/&gt;(DBOS dauerhafter Workflow)"]
GEN["Study Generatoren&lt;br/&gt;quiz · workshop · cards · mindmap"]
PROG["Fortschrittstracker&lt;br/&gt;+ 25 Achievements"]
end
subgraph OLLAMA["Ollama — Port 11434"]
GEMMA["gemma4:e4b&lt;br/&gt;chat · thinking · vision · tools"]
EMBED["embeddinggemma&lt;br/&gt;Text zu Vektoren"]
end
subgraph STORAGE["Lokaler Speicher"]
FAISSF["vector_store.faiss + .json"]
SQLITE["progress.db (SQLite)"]
PG["PostgreSQL&lt;br/&gt;nur Workflow-Status"]
DOCS["docs/ Ordner + chat_history.json"]
end
UI --&gt; ROUTERS
ROUTERS --&gt; AGENT --&gt; VDB
AGENT --&gt; GEMMA
VDB --&gt; EMBED
ROUTERS --&gt; INGEST --&gt; EMBED
INGEST --&gt; PG
INGEST --&gt; FAISSF
VDB --- FAISSF
ROUTERS --&gt; GEN --&gt; GEMMA
GEN --&gt; SQLITE
ROUTERS --&gt; PROG --&gt; SQLITE
ROUTERS --&gt; DOCS
&lt;/div&gt;
&lt;p&gt;Behalte dieses Bild im Hinterkopf — die Teile 2, 3 und 4 zoomen jeweils in einen Bereich davon hinein.&lt;/p&gt;
&lt;h2 id="der-tech-stack-und-warum-jedes-teil-seinen-platz-verdient-hat"&gt;Der Tech-Stack und warum jedes Teil seinen Platz verdient hat&lt;/h2&gt;
&lt;p&gt;Die komplette Liste der Abhängigkeiten (Dependencies) lebt in der &lt;code&gt;requirements.txt&lt;/code&gt;. Hier ist das, was wichtig ist, gruppiert nach Aufgabe:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Requests bearbeiten.&lt;/strong&gt; FastAPI definiert die Endpoints und validiert jeden Request und jede Response mit Pydantic (einer Datenvalidierungs-Bibliothek — stell es dir wie einen strengen Zollbeamten für JSON vor). Uvicorn ist der ASGI-Server (Asynchronous Server Gateway Interface — der Python-Standard, der es einem Prozess erlaubt, viele gleichzeitige Requests zu jonglieren), der das Ganze am Ende ausführt.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Denken.&lt;/strong&gt; Ollama hostet &lt;code&gt;gemma4:e4b&lt;/code&gt; — das &lt;code&gt;e4b&lt;/code&gt;-Tag steht für die Variante mit rund vier Milliarden effektiven Parametern, was ungefähr einem 9,6 GB Download entspricht — und &lt;code&gt;embeddinggemma&lt;/code&gt; (ca. 622 MB). Das Agentenverhalten wird mit dem Strands Agents SDK gebaut, welches das Modell in einen Loop (Schleife) verpackt, in dem es Tools aufrufen, die Ergebnisse lesen und erst danach antworten kann. (Wo ich Ollama in Relation zu Docker laufen lasse, ist eine bewusste Entscheidung mit eigener Hintergrundgeschichte:
.)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Dinge finden.&lt;/strong&gt; FAISS (Facebook AI Similarity Search — Metas Vektorsuch-Bibliothek) kümmert sich um semantische Lookups; &lt;code&gt;rank-bm25&lt;/code&gt; kümmert sich um Keyword-Lookups; eine Formel namens Reciprocal Rank Fusion vereint die beiden. Teil 3 packt all das genauer aus.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Dokumente lesen.&lt;/strong&gt; &lt;code&gt;pypdf&lt;/code&gt; für PDFs, mit einem OCR-Fallback (Optical Character Recognition — verwandelt Bilder von Text in echten Text) für gescannte Seiten via &lt;code&gt;pymupdf&lt;/code&gt; und Tesseract. Word, PowerPoint und Excel bekommen jeweils ihren eigenen Extractor. &lt;code&gt;trafilatura&lt;/code&gt; zieht sauberen Artikeltext aus Webseiten.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Keine Arbeit verlieren.&lt;/strong&gt; DBOS macht die Ingestion-Pipeline dauerhaft (durable) — jeder Schritt bekommt einen Checkpoint in PostgreSQL, sodass bei einem Absturz fortgesetzt statt von vorn begonnen wird. Teil 2 zeigt das in Aktion.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Sich erinnern.&lt;/strong&gt; SQLite — eine vollwertige Datenbank-Engine, die in einer einzigen Datei namens &lt;code&gt;progress.db&lt;/code&gt; lebt — hält deine Lernsitzungen, Errungenschaften (Achievements), Quizzes, Workshops, Karteikartendecks und Mindmaps fest.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="anhang-abkürzungen-in-diesem-post"&gt;Anhang: Abkürzungen in diesem Post&lt;/h2&gt;
&lt;p&gt;Das Versprechen dieser Serie ist &amp;ldquo;keine unerklärten Abkürzungen&amp;rdquo;, also ist hier die Tabelle, von der ich wünschte, sie wäre in jedem technischen Tutorial dabei.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Abkürzung&lt;/th&gt;
&lt;th&gt;Volle Form&lt;/th&gt;
&lt;th&gt;Bedeutung in einfachem Deutsch&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;LLM&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Large Language Model&lt;/td&gt;
&lt;td&gt;Ein neuronales Netz, das mit massenhaft Text trainiert wurde und Sprache lesen und generieren kann&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;RAG&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Retrieval-Augmented Generation&lt;/td&gt;
&lt;td&gt;Hole erst relevante Passagen aus &lt;em&gt;deinen&lt;/em&gt; Dokumenten und lass das Modell daraus antworten — statt aus seinem Trainingsgedächtnis&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;API&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Application Programming Interface&lt;/td&gt;
&lt;td&gt;Die Menge an URLs, die das Frontend aufruft, um mit dem Backend zu sprechen&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ASGI&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Asynchronous Server Gateway Interface&lt;/td&gt;
&lt;td&gt;Der Python-Standard, der es dem Server erlaubt, viele Anfragen gleichzeitig zu bearbeiten&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;JSON&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;JavaScript Object Notation&lt;/td&gt;
&lt;td&gt;Das universelle Textformat für strukturierte Daten&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;NDJSON&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Newline-Delimited JSON&lt;/td&gt;
&lt;td&gt;Ein Stream, bei dem jede Zeile ein eigenes JSON-Objekt ist — ideal, um KI-Antworten Stück für Stück (Chunk für Chunk) zu streamen&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;FAISS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Facebook AI Similarity Search&lt;/td&gt;
&lt;td&gt;Metas Bibliothek, um Vektoren zu speichern und die ähnlichsten schnell zu finden&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;BM25&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Best Match 25&lt;/td&gt;
&lt;td&gt;Eine klassische Keyword-Ranking-Formel — die 25. Ranking-Funktion, die im Okapi Information-Retrieval-System entwickelt wurde&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;RRF&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Reciprocal Rank Fusion&lt;/td&gt;
&lt;td&gt;Eine Formel zum Zusammenführen mehrerer gerankter Ergebnislisten, die nur die Ränge benutzt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ANN&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Approximate Nearest Neighbour&lt;/td&gt;
&lt;td&gt;Eine Geschwindigkeits-Abkürzung, die viele Vektordatenbanken nehmen. CogniVault nutzt stattdessen bewusst einen &lt;em&gt;exakten&lt;/em&gt; Index — präzise und für eine persönliche Bibliothek völlig schnell genug&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;DBOS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Database-Oriented Operating System (das Forschungsprojekt, aus dem es entstand)&lt;/td&gt;
&lt;td&gt;Eine Bibliothek, die Workflow-Schritte in einer Datenbank zwischenspeichert, sodass abgestürzte Jobs fortgesetzt werden können&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SQL / SQLite&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Structured Query Language / SQLite&lt;/td&gt;
&lt;td&gt;Die Sprache von relationalen Datenbanken / eine winzige Datenbank, die in einer Datei lebt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;OCR&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Optical Character Recognition&lt;/td&gt;
&lt;td&gt;Verwandelt Bilder von Text (Scans) in maschinenlesbaren Text&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SHA-256&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Secure Hash Algorithm, 256-bit&lt;/td&gt;
&lt;td&gt;Eine Fingerabdruck-Funktion — jede Datei wird auf einen eindeutigen Hash abgebildet, genutzt um geänderte Dateien zu erkennen&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;CORS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Cross-Origin Resource Sharing&lt;/td&gt;
&lt;td&gt;Browser-Regeln, die kontrollieren, welche Websites die API aufrufen dürfen&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SSRF&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Server-Side Request Forgery&lt;/td&gt;
&lt;td&gt;Ein Angriff, bei dem ein Server ausgetrickst wird, interne URLs abzurufen — der URL-Import Endpoint schützt davor&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;MCQ&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Multiple-Choice Question&lt;/td&gt;
&lt;td&gt;Einer der beiden Quizfragen-Typen&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;KB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Knowledge Base&lt;/td&gt;
&lt;td&gt;All deine eingelesenen, durchsuchbaren Dokumente&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;(Jede Behauptung in dieser Serie kann direkt im
überprüft werden — die relevante Datei wird immer genannt, wenn es wichtig ist, und die Repository-README skizziert die komplette Architektur.)&lt;/p&gt;
&lt;h2 id="fazit"&gt;Fazit&lt;/h2&gt;
&lt;p&gt;Nimm die Abkürzungen weg, und CogniVault ist ein kleines System: ein Webserver, eine Modell-Laufzeitumgebung, eine Haltbarkeits-Datenbank (Durability Database) und eine Handvoll Dateien. Die Raffinesse liegt nicht in der Anzahl der Teile — sie liegt darin, wie ein paar gut gewählte Teile zusammenarbeiten. Von dieser Zusammenarbeit handeln die nächsten drei Teile.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Als Nächstes:&lt;/strong&gt;
— wie ein 1.000-seitiges gescanntes PDF zu etwas wird, das die KI in Sekunden durchsuchen kann, und warum die Pipeline einen Absturz bei Seite 800 überlebt.&lt;/p&gt;</description></item><item><title>CogniVault Backend erklärt, Teil 2 · Von der Datei zum durchsuchbaren Wissen</title><link>https://aretascodes.dev/de/blog/backend-explained-ingestion/</link><pubDate>Fri, 12 Jun 2026 00:00:00 +0000</pubDate><guid>https://aretascodes.dev/de/blog/backend-explained-ingestion/</guid><description>
&lt;blockquote class="border-l-4 border-neutral-300 dark:border-neutral-600 pl-4 italic text-neutral-600 dark:text-neutral-400 my-6"&gt;
&lt;p&gt;Alle Abkürzungen werden im Anhang am Ende der Seite vollständig erklärt.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Ein LLM kann dein PDF nicht &amp;ldquo;öffnen&amp;rdquo;. Dieser Satz überrascht viele Neulinge, also lass uns das kurz sacken lassen: Wenn du in CogniVault mit deinen Dokumenten chattest, fasst das Modell die Originaldateien nie an. Es muss etwas &lt;em&gt;zwischen&lt;/em&gt; &amp;ldquo;Ich habe eine Datei in den Browser gezogen&amp;rdquo; und &amp;ldquo;Die KI hat mir gerade Seite 47 zitiert&amp;rdquo; passieren.&lt;/p&gt;
&lt;p&gt;Dieses Etwas nennt man &lt;strong&gt;Ingestion&lt;/strong&gt; (Datenaufnahme), und darum geht es in diesem Teil. In
haben wir das große Ganze skizziert; heute zoomen wir in einen bestimmten Bereich – das Fließband, das Dateien in durchsuchbares Wissen verwandelt.&lt;/p&gt;
&lt;h2 id="das-fließband"&gt;Das Fließband&lt;/h2&gt;
&lt;p&gt;Stell dir die Ingestion wie ein Fließband mit vier Stationen vor:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Extrahieren:&lt;/strong&gt; Den Text aus jeder Datei herausholen – auch aus gescannten.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Chunken (Zerlegen):&lt;/strong&gt; Den Text in Stücke zerschneiden, die klein genug sind, um in einen Prompt zu passen.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Embedden (Einbetten):&lt;/strong&gt; Jeden Chunk in einen Vektor (eine Liste von Zahlen, die seine Bedeutung einfängt) verwandeln, damit ähnliche Ideen im Vektorraum nah beieinander landen.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Speichern:&lt;/strong&gt; Vektoren und Metadaten so ablegen, dass sie später durchsucht werden können.&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="mermaid"&gt;flowchart TD
A["Upload&lt;br/&gt;POST /upload&lt;br/&gt;gespeichert in docs/"] --&gt; B
subgraph WF["DBOS dauerhafter Workflow"]
B["Schritt 1&lt;br/&gt;Welche Dateien haben sich geändert?&lt;br/&gt;SHA-256 Fingerabdrücke"] --&gt; C["Schritt 2&lt;br/&gt;Text extrahieren&lt;br/&gt;pro Format + OCR-Fallback"]
C --&gt; D["Chunking&lt;br/&gt;1000 Zeichen, 100 Überlappung"]
D --&gt; E["Schritt 3&lt;br/&gt;Embedden&lt;br/&gt;embeddinggemma, 5er-Batches"]
E --&gt; F["Schritt 4&lt;br/&gt;Speichern&lt;br/&gt;FAISS Index + Metadaten JSON"]
end
F --&gt; G["In-Memory Index neu laden&lt;br/&gt;sofort durchsuchbar"]
&lt;/div&gt;
&lt;p&gt;Ziemlich simpel. Die spannende Ingenieursarbeit steckt in den Fehlerfällen – fangen wir also damit an.&lt;/p&gt;
&lt;h2 id="das-kassenbuch-der-fabrik-warum-die-pipeline-keine-arbeit-verlieren-darf"&gt;Das Kassenbuch der Fabrik: Warum die Pipeline keine Arbeit verlieren darf&lt;/h2&gt;
&lt;p&gt;Das Embedden einer großen Bibliothek dauert Minuten. Was passiert, wenn dein Laptop bei Seite 800 eines 1.000-seitigen Handbuchs in den Ruhezustand geht? Bei einem einfachen Python-Skript fängt alles wieder bei Seite 1 an.&lt;/p&gt;
&lt;p&gt;CogniVault schreibt die Pipeline stattdessen als einen &lt;strong&gt;dauerhaften DBOS-Workflow&lt;/strong&gt;. Stell dir eine Fabrik vor, in der jede Station einen dauerhaften Stempel in ein Kassenbuch drückt, sobald sie eine Kiste fertiggestellt hat. Fällt der Strom aus, baut niemand fertige Kisten neu zusammen – die Arbeiter lesen das Buch und machen beim ersten ungestempelten Eintrag weiter.&lt;/p&gt;
&lt;p&gt;DBOS ist dieses Buch, und PostgreSQL ist das Papier, auf dem es geschrieben steht. Jede Station der Pipeline ist ein mit Checkpoints versehener Schritt; nach einem Neustart liefern abgeschlossene Schritte sofort ihre aufgezeichneten Ergebnisse zurück und die Ausführung geht beim ersten unfertigen Schritt weiter. Ein fehlgeschlagener Embedding-Batch wird einfach nochmal probiert.&lt;/p&gt;
&lt;p&gt;Das ist auch der Mechanismus hinter der Live-Fortschrittsanzeige in der UI: Der Start einer Ingestion liefert eine &lt;code&gt;workflow_id&lt;/code&gt; zurück, und das Frontend fragt regelmäßig einen Status-Endpoint ab, der meldet, welche Schritte abgeschlossen sind, welche laufen und welche noch warten.&lt;/p&gt;
&lt;p&gt;Ich habe einen ausführlichen Deep-Dive über diesen Mechanismus geschrieben – inklusive dessen, was passiert, wenn du den Prozess mitten in der Ingestion mit &lt;code&gt;kill -9&lt;/code&gt; beendest – in
.&lt;/p&gt;
&lt;h2 id="fingerabdrücke-kein-blindflug-sha-256-änderungserkennung"&gt;Fingerabdrücke, kein Blindflug: SHA-256 Änderungserkennung&lt;/h2&gt;
&lt;p&gt;Deine komplette Bibliothek jedes Mal neu zu embedden, wenn du eine einzige Datei hinzufügst, wäre Verschwendung. Bevor also irgendwelche Arbeit passiert, berechnet die Pipeline für jede Datei einen &lt;strong&gt;SHA-256 Hash&lt;/strong&gt; (einen Inhalts-Fingerabdruck – ändere ein Zeichen in der Datei, und der Fingerabdruck ändert sich komplett) und vergleicht ihn mit dem Fingerabdruck, der bei den vorhandenen Chunks der Datei gespeichert ist:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Noch nie gesehen&lt;/strong&gt; → einlesen (ingest).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fingerabdruck hat sich geändert&lt;/strong&gt; → die alten Chunks werden &lt;em&gt;soft-gelöscht&lt;/em&gt; und die Datei wird neu eingelesen.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fingerabdruck identisch&lt;/strong&gt; → komplett überspringen.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Warum &amp;ldquo;soft&amp;rdquo;-gelöscht? Weil der FAISS-Indextyp, den CogniVault nutzt, keine einzelnen Vektoren entfernen kann. Veraltete Chunks werden in den Metadaten einfach als &lt;code&gt;deleted: true&lt;/code&gt; markiert; ihre Vektoren bleiben im Index, aber jede Suche filtert sie heraus. Das ist eine ehrliche, langweilige Lösung – und sie korrumpiert niemals den Index.&lt;/p&gt;
&lt;h2 id="jedes-format-kriegt-eine-sonderbehandlung"&gt;Jedes Format kriegt eine Sonderbehandlung&lt;/h2&gt;
&lt;p&gt;Hier ist ein Detail, das eine Demo von einem Produkt unterscheidet. Eine naive Pipeline extrahiert einfach &amp;ldquo;den ganzen Text&amp;rdquo; und macht dann Feierabend. Bei CogniVault bekommt jedes Format einen Extractor, der genau die &lt;em&gt;Struktur&lt;/em&gt; beibehält, die das Retrieval später braucht:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Format&lt;/th&gt;
&lt;th&gt;Strategie&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;PDF&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Seite für Seite, wobei die Seitenzahlen behalten werden (diese werden später zu Quellenangaben). Jede Seite mit weniger als 50 Zeichen gilt als gescannt und wird an die OCR geschickt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Gescannte Seite&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Die Seite wird mit etwa 144 dpi als Bild gerendert, dann extrahiert Tesseract OCR (Optical Character Recognition – Text aus Bildern auslesen) die Wörter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Markdown&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Wird an Überschriften aufgeteilt; jeder Abschnitts-Chunk bekommt einen Breadcrumb-Präfix wie &lt;code&gt;[Section: Intro &amp;gt; Setup]&lt;/code&gt;, damit sein Embedding die Dokumentenhierarchie in sich trägt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;CSV&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Zeilen werden in 20er-Gruppen gechunkt – und &lt;em&gt;jeder&lt;/em&gt; Chunk bekommt die Kopfzeile vorangestellt, sodass das Modell immer die Spaltennamen kennt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Excel&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Gleiches Zeilen-Gruppen-Prinzip pro Arbeitsblatt, mit dem Präfix &lt;code&gt;[Sheet: name]&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;PowerPoint&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Ein Chunk pro Folie&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Word&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Absätze plus Tabellenzellen&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Webseiten&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Werden bei Bedarf abgerufen und auf sauberen Artikeltext reduziert – geschützt durch einen SSRF-Guard (Schutz vor Server-Side Request Forgery: der Server weigert sich, private oder interne Adressen abzurufen)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Frag dich mal, warum das CSV-Detail wichtig ist. Wenn Chunk 14 eines Spreadsheets einfach nur zwanzig nackte Zahlenreihen enthält, wird keine Suche ihn jemals mit der Frage &amp;ldquo;Wie hoch war das Q3-Budget?&amp;rdquo; in Verbindung bringen. Stellst du die Kopfzeile voran, &lt;em&gt;weiß&lt;/em&gt; der Chunk, dass er Budget-Spalten enthält. Struktur ist der Treibstoff fürs Retrieval.&lt;/p&gt;
&lt;h2 id="chunking-1000-zeichen-mit-100-zeichen-sicherheitsnetz-überlappung"&gt;Chunking: 1.000 Zeichen mit 100 Zeichen Sicherheitsnetz (Überlappung)&lt;/h2&gt;
&lt;p&gt;Lange Texte werden in Stücke von etwa 1.000 Zeichen zerlegt, wobei sich benachbarte Stücke um 100 Zeichen überlappen. Diese Überlappung ist eine Versicherung: Ein Satz, der genau an der Chunk-Grenze zerschnitten wird, taucht in einem der beiden Nachbarn immer noch als Ganzes auf, sodass keine Idee in die Lücke zwischen den Chunks fällt.&lt;/p&gt;
&lt;h2 id="embedden-und-speichern"&gt;Embedden und Speichern&lt;/h2&gt;
&lt;p&gt;Chunks werden von &lt;code&gt;embeddinggemma&lt;/code&gt; (via Ollama) in 5er-Batches embedded – jeder Chunk wird zu einem Vektor. Die Vektoren werden normalisiert und an einen FAISS-Index angehängt; daneben hält eine JSON-Datei für jeden Chunk den Quelldateinamen, die Seitenzahl, die Kategorie, den Fingerabdruck und den eigentlichen Text fest. Der Index speichert die Zahlen; das JSON speichert die Bedeutung.&lt;/p&gt;
&lt;p&gt;Eine Designentscheidung, die man für Anfänger hervorheben sollte: Das hier ist ein &lt;strong&gt;exakter&lt;/strong&gt; Index, kein approximativer. Viele Vektor-Datenbanken nutzen ANN (Approximate Nearest Neighbour)-Abkürzungen, die bei riesiger Skalierung ein wenig Genauigkeit gegen Geschwindigkeit tauschen. Im Maßstab einer persönlichen Bibliothek brauchst du diesen Kompromiss nicht – CogniVault prüft jeden Vektor bei jeder Suche und ist trotzdem schnell.&lt;/p&gt;
&lt;h2 id="die-gesamte-reise-von-anfang-bis-ende"&gt;Die gesamte Reise, von Anfang bis Ende&lt;/h2&gt;
&lt;div class="mermaid"&gt;%%{init: {'sequence': {'actorFontSize': 28, 'messageFontSize': 24, 'loopTextFontSize': 22, 'noteFontSize': 22}}}%%
sequenceDiagram
actor U as Du
participant F as Frontend
participant B as FastAPI
participant W as DBOS Workflow
participant O as Ollama (embeddinggemma)
participant V as FAISS + Metadaten
U-&gt;&gt;F: Drag and Drop einer Datei, Kategorie wählen
F-&gt;&gt;B: POST /upload
B-&gt;&gt;B: Typ und Größe validieren, in docs/ speichern
F-&gt;&gt;B: POST /ingest
B-&gt;&gt;W: Dauerhaften Workflow starten
B--&gt;&gt;F: workflow_id
loop Status abfragen
F-&gt;&gt;B: GET /ingest/status/{workflow_id}
B--&gt;&gt;F: Schrittliste (steuert die Fortschrittsanzeige)
end
W-&gt;&gt;W: SHA-256 Änderungserkennung
W-&gt;&gt;W: Text extrahieren (pro Format, OCR falls gescannt)
W-&gt;&gt;W: Chunking (1000 Zeichen / 100 Überlappung)
W-&gt;&gt;O: Embedden in 5er-Batches
O--&gt;&gt;W: Vektoren
W-&gt;&gt;V: Vektoren + Metadaten anhängen
B--&gt;&gt;F: SUCCESS — Index neu geladen
F--&gt;&gt;U: "Wissens-Sync abgeschlossen"
&lt;/div&gt;
&lt;h2 id="fazit"&gt;Fazit&lt;/h2&gt;
&lt;p&gt;Bei der Ingestion entscheidet sich meistens die eigentliche RAG-Qualität – lange bevor irgendwelches clevere Prompting ins Spiel kommt. Beibehaltene Seitenzahlen, Header, die in jeden Spreadsheet-Chunk kopiert werden, gerettete Scans durch OCR, und ein Kassenbuch, das das Ganze absturzsicher macht: Nichts davon ist glamourös, aber alles davon zeigt sich später in Form von Antworten, die die richtige Seite zitieren.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id="anhang-abkürzungen-in-diesem-post"&gt;Anhang: Abkürzungen in diesem Post&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Abkürzung&lt;/th&gt;
&lt;th&gt;Volle Form&lt;/th&gt;
&lt;th&gt;Bedeutung&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;LLM&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Large Language Model&lt;/td&gt;
&lt;td&gt;Ein neuronales Netz, trainiert mit riesigen Textmengen, das Sprache lesen und erzeugen kann&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;DBOS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Database-Oriented Operating System&lt;/td&gt;
&lt;td&gt;Die Bibliothek, die Workflow-Schritte in PostgreSQL sichert, damit abgestürzte Jobs fortgesetzt werden können&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SHA-256&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Secure Hash Algorithm, 256-bit&lt;/td&gt;
&lt;td&gt;Ein Inhalts-Fingerabdruck – ändere ein Byte einer Datei und der Hash ändert sich komplett&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;OCR&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Optical Character Recognition&lt;/td&gt;
&lt;td&gt;Text aus Bildern lesen – der Rettungsweg für gescannte PDF-Seiten&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SSRF&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Server-Side Request Forgery&lt;/td&gt;
&lt;td&gt;Ein Angriff, bei dem ein Server ausgetrickst wird, interne URLs abzurufen; der URL-Importer blockiert dies&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;FAISS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Facebook AI Similarity Search&lt;/td&gt;
&lt;td&gt;Der Vektor-Index, an den die Embeddings angehängt werden&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ANN&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Approximate Nearest Neighbour&lt;/td&gt;
&lt;td&gt;Die Genauigkeit-gegen-Geschwindigkeit-Abkürzung, die CogniVault absichtlich &lt;em&gt;nicht&lt;/em&gt; nimmt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;dpi&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Dots Per Inch&lt;/td&gt;
&lt;td&gt;Bildauflösung – gescannte Seiten werden vor der OCR mit ca. 144 dpi gerendert&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;JSON&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;JavaScript Object Notation&lt;/td&gt;
&lt;td&gt;Das Format der Chunk-Metadaten-Datei neben dem FAISS-Index&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;PDF / CSV&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Portable Document Format / Comma-Separated Values&lt;/td&gt;
&lt;td&gt;Zwei der acht+ unterstützten Dateiformate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;API&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Application Programming Interface&lt;/td&gt;
&lt;td&gt;Die Endpoints (&lt;code&gt;/upload&lt;/code&gt;, &lt;code&gt;/ingest&lt;/code&gt;, &lt;code&gt;/ingest/status/…&lt;/code&gt;), die den Ablauf steuern&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Als Nächstes:&lt;/strong&gt;
— hybrides Retrieval, der 6-Tools-Agent und der 2-Phasen-Stream, der zeigt, wie das Modell denkt, bevor es antwortet.&lt;/p&gt;</description></item><item><title>CogniVault Backend erklärt, Teil 3 · Wie aus einer Frage eine belegte Antwort wird</title><link>https://aretascodes.dev/de/blog/backend-explained-rag-agent/</link><pubDate>Fri, 12 Jun 2026 00:00:00 +0000</pubDate><guid>https://aretascodes.dev/de/blog/backend-explained-rag-agent/</guid><description>
&lt;blockquote class="border-l-4 border-neutral-300 dark:border-neutral-600 pl-4 italic text-neutral-600 dark:text-neutral-400 my-6"&gt;
&lt;p&gt;Alle Abkürzungen werden im Anhang am Ende der Seite vollständig erklärt.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Du tippst eine Frage ein. Ein paar Sekunden später bekommst du eine Antwort mit Fußnoten — genaue Angabe der Dokumente und Seiten, aus denen sie stammt. Dieser Teil geht alles durch, was dazwischen passiert.&lt;/p&gt;
&lt;p&gt;In
haben wir die Wissensbasis aufgebaut: jedes Dokument gechunkt, embedded und indiziert. Jetzt fangen wir an, sie zu &lt;em&gt;nutzen&lt;/em&gt; — und hier hört CogniVault auf, nur eine Pipeline zu sein, und fängt an, spannend zu werden.&lt;/p&gt;
&lt;h2 id="zwei-bibliothekare-weil-einer-dich-immer-wieder-hängen-lässt"&gt;Zwei Bibliothekare, weil einer dich immer wieder hängen lässt&lt;/h2&gt;
&lt;p&gt;Stell dir eine Bibliothek vor mit einer Bibliothekarin, die alles nach &lt;em&gt;Vibes&lt;/em&gt; ordnet. Frag sie nach &amp;ldquo;Prozeduren bei Server-Ausfall&amp;rdquo; und sie ist genial — sie versteht, was du meinst, und findet Dokumente, die das Konzept diskutieren, egal welche Wörter sie benutzen. Aber frag sie nach &amp;ldquo;Fehlercode 404B&amp;rdquo;, zuckt sie mit den Schultern und reicht dir allgemeine Netzwerk-Guides. Mit exakten Zeichenketten kann sie nichts anfangen.&lt;/p&gt;
&lt;p&gt;Am Ende des Flurs sitzt ein zweiter Bibliothekar mit einem Zettelkasten. Er findet den genauen String &amp;ldquo;404B&amp;rdquo; sofort — aber stell ihm eine konzeptionelle Frage, die anders formuliert ist als im Quelltext, und er findet überhaupt nichts.&lt;/p&gt;
&lt;p&gt;Das sind die zwei Hälften der Suche:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Semantische Suche (FAISS)&lt;/strong&gt; — deine Frage wird in einen Vektor umgewandelt (embedded), und der Index findet Chunks, deren Vektoren in die gleiche Richtung zeigen (technisch gesehen: Cosinus-Ähnlichkeit — wie gut zwei Pfeile übereinstimmen). Super für die Bedeutung, blind für exakte Identifikatoren.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Keyword-Suche (BM25)&lt;/strong&gt; — eine Bewertungsformel (Scoring), die Chunks belohnt, die deine &lt;em&gt;exakten&lt;/em&gt; Wörter enthalten, gewichtet danach, wie markant diese Wörter sind. Super für Identifikatoren, blind für Synonyme.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;CogniVault fragt &lt;strong&gt;jedes Mal beide Bibliothekare&lt;/strong&gt;, und verschmilzt dann ihre Antworten mit &lt;strong&gt;Reciprocal Rank Fusion (RRF)&lt;/strong&gt; — einer Formel, die gerankte Listen kombiniert, indem sie nur die &lt;em&gt;Positionen&lt;/em&gt; nutzt:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;score(chunk) = summe aus beiden Listen von 1 / (60 + rang)
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Ein Chunk, der von einem der beiden Bibliothekare hoch gerankt wird, punktet gut; ein Chunk, den beide gut fanden, schwimmt ganz nach oben. Die Eleganz liegt darin, was &lt;em&gt;fehlt&lt;/em&gt;: Du musst niemals die Ähnlichkeits-Scores von FAISS mit der komplett anderen Skala von BM25 abgleichen, weil Ränge (Ranks) der einzige Input sind. Die Konstante 60 stammt direkt aus dem ursprünglichen Research-Paper von 2009, und ja, sie ist auch im Code zitiert.&lt;/p&gt;
&lt;p&gt;Ein paar Implementierungsdetails, die du kennen solltest: Beide Suchen holen absichtlich zu viel (mindestens 20 Kandidaten jeweils), damit die Fusion Material zum Arbeiten hat; sehr schwache semantische Treffer werden fallengelassen, aber ein perfekt auf Keywords passender Chunk kann durch die Fusion trotzdem noch gerettet werden; und die finale Antwort nutzt die Top-7-Chunks. Ich habe dieses ganze Setup in
gegen eine reine Vektorsuche gebenchmarkt, falls du die Kriegsgeschichten dazu lesen willst.&lt;/p&gt;
&lt;h2 id="der-agent-ein-modell-das-selbst-entscheidet"&gt;Der Agent: Ein Modell, das selbst entscheidet&lt;/h2&gt;
&lt;p&gt;Hier ist der zweite Punkt, der Anfänger oft ins Straucheln bringt: Der Chat von CogniVault ist nicht einfach &amp;ldquo;Kopiere Chunks in einen Prompt, bekomme eine Antwort.&amp;rdquo; Es ist ein &lt;strong&gt;Agent&lt;/strong&gt; — ein Modell, das in einer Schleife läuft, in der es sich &lt;em&gt;entscheiden&lt;/em&gt; kann, Tools aufzurufen, deren Ergebnisse zu lesen und erst dann zu antworten.&lt;/p&gt;
&lt;p&gt;Gebaut mit dem Strands Agents SDK, bekommt der Agent sechs Tools:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Aufgabe&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;search_knowledge_base&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Das Kern-RAG-Tool — führt die hybride Suche von oben aus, liefert Chunks mit Quelle und Seite zurück&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;list_documents&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Nachschauen, was im Vault (Tresor) liegt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;analyze_document&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Strukturierte Analyse eines Dokuments: Themen, Entitäten, Fakten, Zusammenfassung&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;compare_documents&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Beantwortung einer Frage durch den direkten Vergleich von zwei Dokumenten&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;calculator&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Sicheres Rechnen — der Ausdruck wird in einen Syntaxbaum (AST) geparst und nur erlaubte Operatoren werden ausgeführt. Niemals &lt;code&gt;eval()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;current_time&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Datum und Uhrzeit&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Es gibt hier kein fest programmiertes Routing. Das &lt;em&gt;Modell&lt;/em&gt; liest deine Frage und entscheidet, welche Tools es aufruft, geleitet von seinem System-Prompt. Fragst du &amp;ldquo;Vergleiche die zwei Verträge hinsichtlich der Kündigungsklauseln&amp;rdquo;, greift es zum &lt;code&gt;compare_documents&lt;/code&gt;; fragst du &amp;ldquo;Was sind 15% von 2.340&amp;rdquo;, nutzt es den Taschenrechner, anstatt Mathematik zu halluzinieren.&lt;/p&gt;
&lt;p&gt;Zwei Sicherheitsdetails, auf die Anfänger achten sollten, weil sie den Unterschied zwischen einem Spielzeug und einem Produkt ausmachen: &lt;strong&gt;Für jeden Request wird ein frischer Agent gebaut&lt;/strong&gt; (kein geteilter State, der zwischen parallelen Chats überspricht), und die Dokumentenanalyse-Tools rufen das Modell &lt;em&gt;direkt&lt;/em&gt; auf statt über den Agenten — sonst könnte ein Agent, der ein Tool aufruft, das wiederum den Agenten aufruft, in einer Endlosschleife feststecken.&lt;/p&gt;
&lt;h2 id="dem-modell-beim-denken-zusehen"&gt;Dem Modell beim Denken zusehen&lt;/h2&gt;
&lt;p&gt;Wenn du eine Nachricht absendest, streamt die Antwort als &lt;strong&gt;NDJSON&lt;/strong&gt; (Newline-Delimited JSON — jede Zeile des Streams ist ein eigenes kleines JSON-Objekt). Und das passiert in zwei Phasen:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Phase 1 — Denken.&lt;/strong&gt; Gemmas Argumentationskette (Reasoning Chain) streamt zuerst und wird im aufklappbaren Panel über der Antwort gerendert. Es ist absichtlich so gebaut, dass es nicht zwingend klappen muss (Best-Effort): Falls es aus irgendeinem Grund fehlschlägt, kommt die Antwort trotzdem.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Phase 2 — Die Agenten-Antwort.&lt;/strong&gt; Tools laufen, Zitate (Quellenangaben) tauchen im Quellen-Panel auf, sobald die Suche abgeschlossen ist — &lt;em&gt;bevor&lt;/em&gt; die Antwort fertig geschrieben ist — und der Antworttext streamt herein.&lt;/p&gt;
&lt;div class="mermaid"&gt;flowchart TB
Q["Deine Frage&lt;br/&gt;(plus optionale Bilder, Dateien, Scope)"] --&gt; P1
subgraph STREAM["POST /rag — ein NDJSON-Stream"]
P1["Phase 1: Denken&lt;br/&gt;Reasoning-Chunks streamen zuerst"]
P1 --&gt; P2["Phase 2: Agent&lt;br/&gt;frisch pro Request, Historie wiederhergestellt"]
P2 --&gt;|"entscheidet sich aufzurufen"| T["search_knowledge_base"]
T --&gt; D["FAISS&lt;br/&gt;semantisch"]
T --&gt; S["BM25&lt;br/&gt;Keywords"]
D --&gt; RRF["RRF Fusion — Top 7 Chunks"]
S --&gt; RRF
RRF --&gt;|"Chunks + Quellenangaben"| P2
P2 --&gt; OUT["Quellenangaben, dann Antworttext,&lt;br/&gt;dann ein Speicher-Nutzungs-Report"]
end
&lt;/div&gt;
&lt;p&gt;Jede Zeile im Stream ist typisiert: &lt;code&gt;thinking&lt;/code&gt;, &lt;code&gt;metadata&lt;/code&gt; (eine Quelle/Zitat), &lt;code&gt;text&lt;/code&gt; (Antwort), &lt;code&gt;memory&lt;/code&gt; (wie voll das Konversations-Budget ist) oder &lt;code&gt;error&lt;/code&gt;. Das Frontend liest einfach die Zeilen und leitet sie in das richtige Panel weiter. Ich habe dieses Design zerlegt — und erklärt, warum das Denken &lt;em&gt;vor&lt;/em&gt; den Tool-Aufrufen kommt — in
.&lt;/p&gt;
&lt;h2 id="ein-speicher-budget-kein-fassloses-loch"&gt;Ein Speicher-Budget, kein fassloses Loch&lt;/h2&gt;
&lt;p&gt;Gemmas Context Window (die Textmenge, die das Modell auf einmal betrachten kann) beträgt 128K Token, aber CogniVault lässt den Chatverlauf nicht über das komplette Fenster wuchern. Jede Chat-Session bekommt ein Budget von 48.000 Zeichen — grob 12.000 Token. Überschreitest du es, fällt das &lt;em&gt;älteste&lt;/em&gt; Frage-Antwort-Paar leise als erstes heraus. So bleibt der Großteil des Fensters frei für das, was wirklich zählt: deine aktuelle Frage und die abgerufenen Chunks.&lt;/p&gt;
&lt;p&gt;Zwei Resilienz-Tricks, die du für deine eigenen Projekte klauen solltest:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Reboots überleben.&lt;/strong&gt; In-Memory-Verlauf stirbt mit dem Prozess. Deshalb baut die erste Nachricht in einer Session nach einem Backend-Neustart ihren Verlauf aus dem Chat-Log wieder auf, den das Frontend persistiert hat. Multi-Turn-Gedächtnis überlebt Neustarts.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Bearbeiten und neu generieren.&lt;/strong&gt; Wenn du eine frühere Nachricht bearbeitest, wird der gespeicherte Verlauf auf genau diesen Punkt zurückgespult, bevor neu gefragt wird — das Modell vergisst buchstäblich die Zeitlinie, die jetzt nicht mehr existiert.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="scope-die-ki-auf-bestimmte-dokumente-festnageln"&gt;Scope: Die KI auf bestimmte Dokumente festnageln&lt;/h2&gt;
&lt;p&gt;Noch ein letztes Feature, und eine Lektion über kleine lokale Modelle. Du kannst einen Chat auf bestimmte Dateien oder eine Kategorie pinnen (Scope). Dieser Filter reist mit dem Request &lt;em&gt;und&lt;/em&gt; eine zwingende Such-Anweisung wird sowohl in den System-Prompt als auch in deine eigentliche Nutzer-Nachricht injiziert.&lt;/p&gt;
&lt;p&gt;Warum in beide? Weil kleine Modelle manchmal Anweisungen ignorieren, die nur im System-Prompt stehen — aber sie können nicht ignorieren, was direkt in der Frage steckt. Gürtel und Hosenträger. Wenn du mit 4-Milliarden-Parameter-Modellen arbeitest statt mit den größten Frontrunnern, lernst du, Anweisungen so zu platzieren, dass man sie unmöglich übersehen kann, anstatt nur zu hoffen, dass sie befolgt werden.&lt;/p&gt;
&lt;h2 id="fazit"&gt;Fazit&lt;/h2&gt;
&lt;p&gt;Eine belegte Antwort ist das Zusammenspiel von vier Systemen: Zwei Retriever decken gegenseitig ihre blinden Flecken ab, eine Fusionsformel, die nichts weiter braucht als Ränge, ein Agent, der sich seine Tools selbst aussucht, und ein Stream, der seinen Lösungsweg offenlegt. Keines der vier ist für sich genommen exotisch — das eigentliche Produkt ist ihre Zusammenarbeit.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="anhang-abkürzungen-in-diesem-post"&gt;Anhang: Abkürzungen in diesem Post&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Abkürzung&lt;/th&gt;
&lt;th&gt;Volle Form&lt;/th&gt;
&lt;th&gt;Bedeutung&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;RAG&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Retrieval-Augmented Generation&lt;/td&gt;
&lt;td&gt;Hole erst relevante Passagen aus deinen eigenen Dokumenten; lass das Modell daraus antworten&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;FAISS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Facebook AI Similarity Search&lt;/td&gt;
&lt;td&gt;Die semantische (bedeutungsbasierte) Hälfte der hybriden Suche&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;BM25&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Best Match 25&lt;/td&gt;
&lt;td&gt;Die Keyword-Hälfte — eine klassische Ranking-Formel aus dem Okapi Information-Retrieval-System&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;RRF&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Reciprocal Rank Fusion&lt;/td&gt;
&lt;td&gt;Vereint die beiden gerankten Listen und nutzt dafür nur den Rang jedes Chunks: &lt;code&gt;score = Σ 1/(60 + rang)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;NDJSON&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Newline-Delimited JSON&lt;/td&gt;
&lt;td&gt;Ein Stream, bei dem jede Zeile ein eigenes komplettes JSON-Objekt ist — das Format der Chat-Antwort&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;JSON&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;JavaScript Object Notation&lt;/td&gt;
&lt;td&gt;Das universelle Textformat für strukturierte Daten&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;AST&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Abstract Syntax Tree&lt;/td&gt;
&lt;td&gt;Die geparste Form eines Ausdrucks — wie der Taschenrechner rechnet, ohne &lt;code&gt;eval()&lt;/code&gt; zu nutzen&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;LLM&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Large Language Model&lt;/td&gt;
&lt;td&gt;Ein neuronales Netz, trainiert mit riesigen Textmengen, das Sprache lesen und erzeugen kann&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SDK&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Software Development Kit&lt;/td&gt;
&lt;td&gt;Eine Bibliothek von Bausteinen — hier Strands, das die Agenten-Schleife bereitstellt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;K&lt;/strong&gt; (in 128K)&lt;/td&gt;
&lt;td&gt;Kilo (Tausend)&lt;/td&gt;
&lt;td&gt;128K Token ≈ 128.000 Token — Gemmas Context Window&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Als Nächstes:&lt;/strong&gt;
— die gleiche Maschinerie, aber ausgerichtet auf das Erstellen von Quizzes, Workshops, Karteikarten und Mindmaps, plus eine Tabelle mit jedem Byte, das die App speichert und wo genau es lebt.&lt;/p&gt;</description></item><item><title>CogniVault Backend erklärt, Teil 4 · Study Tools, Fortschritt und die Privacy-Belege</title><link>https://aretascodes.dev/de/blog/backend-explained-study-hub-privacy/</link><pubDate>Fri, 12 Jun 2026 00:00:00 +0000</pubDate><guid>https://aretascodes.dev/de/blog/backend-explained-study-hub-privacy/</guid><description>
&lt;blockquote class="border-l-4 border-neutral-300 dark:border-neutral-600 pl-4 italic text-neutral-600 dark:text-neutral-400 my-6"&gt;
&lt;p&gt;Alle Abkürzungen werden im Anhang am Ende der Seite vollständig erklärt.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;In
haben wir eine Frage durch das hybride Retrieval und den Agenten-Loop bis hin zur zitierten Antwort verfolgt. In diesem letzten Teil wird dieselbe Maschinerie auf ein ganz anderes Ziel ausgerichtet: &lt;em&gt;Dir etwas beizubringen&lt;/em&gt; — und dann schließen wir die Serie ab, indem wir das zentrale Versprechen des Projekts überprüfen: Nichts verlässt deinen Rechner.&lt;/p&gt;
&lt;h2 id="ein-rezept-vier-lerntools"&gt;Ein Rezept, vier Lerntools&lt;/h2&gt;
&lt;p&gt;CogniVault generiert Quizzes, mehrteilige Workshops, Karteikartendecks und Mindmaps aus deinen Dokumenten. Vier verschiedene Outputs — aber unter der Haube steckt ein gemeinsames Fünf-Schritte-Rezept:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Abrufen (Retrieve).&lt;/strong&gt; Dieselbe hybride Suche wie aus Teil 3, aber statt deiner Frage lautet der Such-Prompt etwas Breitgefächertes wie &lt;em&gt;&amp;ldquo;Schlüsselkonzepte, Definitionen, wichtige Fakten, Hauptideen&amp;rdquo;&lt;/em&gt;, eingeschränkt auf die von dir ausgewählten Dokumente. Bis zu 15 repräsentative Chunks kommen zurück.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Mit einem Template prompten.&lt;/strong&gt; Die Anweisungen an Gemma sind nicht tief in Python vergraben — es sind editierbare Markdown-Dateien im Ordner &lt;code&gt;backend/prompts/&lt;/code&gt; (&lt;code&gt;quiz.md&lt;/code&gt;, &lt;code&gt;flashcards.md&lt;/code&gt; usw.). Legst du eine modifizierte Kopie in &lt;code&gt;backend/prompts/custom/&lt;/code&gt; ab, überschreibt sie die mitgelieferte Version beim allerersten Request danach. Kein Neustart, keine Code-Änderung. Prompt Engineering als reine Konfiguration.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Output erzwingen (Constrain).&lt;/strong&gt; Ein kleines lokales Modell zu bitten, &amp;ldquo;bitte gib JSON zurück&amp;rdquo;, klappt in den meisten Fällen — und &lt;em&gt;in den meisten Fällen&lt;/em&gt; bedeutet in Produktion schlichtweg einen Bug. CogniVault nutzt Ollamas grammatikgebundene Generierung (&lt;code&gt;format=&amp;quot;json&amp;quot;&lt;/code&gt;), was ungültiges JSON nicht nur unwahrscheinlich, sondern unmöglich macht, gepaart mit einer niedrigen Temperature für Konstanz. Die ganze Geschichte, wie man zuverlässige Strukturen aus einem 4-Milliarden-Parameter-Modell presst, findest du in
.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Defensiv validieren.&lt;/strong&gt; Jedes generierte Item wird Feld für Feld gecheckt. Fehlerhafte Items werden &lt;em&gt;verworfen&lt;/em&gt;, statt den kompletten Batch crashen zu lassen. Kleine Modelle verhauen manchmal eine von zehn Fragen; ein Produkt sollte deswegen nicht gleich zusammenbrechen.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Persistieren.&lt;/strong&gt; Alles landet in SQLite. Quizzes sind später fortsetzbar, Workshop-Fortschritte überleben Neustarts und der Status von Karteikarten wird pro Deck gespeichert.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Hier ist das Rezept für ein Quiz in Aktion:&lt;/p&gt;
&lt;div class="mermaid"&gt;%%{init: {'sequence': {'actorFontSize': 28, 'messageFontSize': 24, 'loopTextFontSize': 22, 'noteFontSize': 22}}}%%
sequenceDiagram
actor U as Du
participant F as Study Hub UI
participant B as FastAPI
participant V as VectorDB
participant O as Ollama (gemma4:e4b)
participant S as SQLite
U-&gt;&gt;F: Scope, Schwierigkeit, Fragenanzahl wählen
F-&gt;&gt;B: POST /api/study/quiz/generate
B-&gt;&gt;V: Hybride Suche, eingeschränkt auf deine Dokumente
V--&gt;&gt;B: Bis zu 15 repräsentative Chunks
B-&gt;&gt;B: Render das quiz.md Prompt-Template
B-&gt;&gt;O: chat(format="json", niedrige Temperature)
O--&gt;&gt;B: Grammatikgebundenes JSON
B-&gt;&gt;B: Jede Frage validieren, schlechte verwerfen
B-&gt;&gt;S: Quiz speichern (später fortsetzbar)
B--&gt;&gt;F: Typisierte Antwort
F--&gt;&gt;U: Spielen, einreichen, punkten — und vielleicht ein neues Badge
&lt;/div&gt;
&lt;p&gt;Die vier Tools unterscheiden sich nur in ihrem Template und ihrer Form: Quizzes produzieren Multiple-Choice- und Wahr/Falsch-Fragen mit Erklärungen; Workshops erstellen zuerst eine Gliederung und schreiben dann jede Lektion &lt;em&gt;on demand&lt;/em&gt;, wenn du sie öffnest; Karteikarten liefern Vorder-/Rückseiten-Paare; Mindmaps generieren einen Themenbaum, den das Frontend als interaktives Diagramm rendert. (Dieser Renderer war übrigens sein eigenes kleines Abenteuer:
.)&lt;/p&gt;
&lt;h2 id="sessions-die-sich-selbst-tracken"&gt;Sessions, die sich selbst tracken&lt;/h2&gt;
&lt;p&gt;Die meisten Lern-Apps zwingen dich, einen Start-Button zu drücken, und die meisten Leute vergessen es. CogniVault geht da einen anderen Weg: &lt;strong&gt;Lernsessions werden abgeleitet, nicht manuell gestartet&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Jede Chatnachricht verlängert entweder die aktuelle Session oder — nach einer 15-minütigen Pause — startet stillschweigend eine neue. Geh einen Kaffee holen, komm zurück, mach weiter: gleiche Session. Komm morgen wieder: neue Session. Keine Buttons, kein Vergessen.&lt;/p&gt;
&lt;p&gt;Jede Nachricht speichert außerdem ein winziges Event (Zeitstempel, ob du einen Scope-Filter oder Dateianhänge genutzt hast) in &lt;code&gt;progress.db&lt;/code&gt; — eine SQLite-Datenbank, also eine komplette relationale Datenbank in einer einzigen Datei. Elf Tabellen halten alles fest: Sessions, Nachrichten-Events, verdiente Badges, Quiz-Versuche und gespeicherte Quizzes, Workshops und Lektionen, Decks und Karten sowie Mindmaps.&lt;/p&gt;
&lt;p&gt;Eine kleine Engineering-Note, die sich abzugucken lohnt: Der Tracking-Call im Chat-Endpoint ist so verpackt, dass er den Chat &lt;em&gt;niemals&lt;/em&gt; blockieren oder abbrechen kann. Analytics müssen immer Beifahrer sein, niemals der Fahrer.&lt;/p&gt;
&lt;h2 id="25-badges-als-daten-definiert"&gt;25 Badges, als Daten definiert&lt;/h2&gt;
&lt;p&gt;Die Achievements sind nicht als &lt;code&gt;if&lt;/code&gt;-Statements im Code verstreut. Sie leben in einer einzigen JSON-Datei — 25 Einträge, jeder mit Code, Name, Icon, der Metrik, die er überwacht, und einem Zielwert. Nach jeder relevanten Aktion prüft ein Evaluator jede Definition gegen die Datenbank und speichert neu verdiente Badges. Einige Badges bilden Leitern und verweisen auf das nächste Level.&lt;/p&gt;
&lt;p&gt;Deklarativ schlägt hier imperativ aus einem einfachen Grund: Badge Nummer 26 hinzuzufügen bedeutet einen JSON-Eintrag zu ergänzen, nicht neue Logik zu schreiben. Das Design hinter den Streaks (Serien), der Pausen-Regel und der 90-Tage-Heatmap hat einen eigenen Artikel bekommen:
.&lt;/p&gt;
&lt;h2 id="spracheingabe-ganz-ohne-cloud-mikrofon"&gt;Spracheingabe, ganz ohne Cloud-Mikrofon&lt;/h2&gt;
&lt;p&gt;Der Mikrofon-Button wird von &lt;strong&gt;faster-whisper&lt;/strong&gt; befeuert — OpenAIs Whisper Spracherkennungsmodell, neu implementiert auf einer schnelleren Inference-Engine. Es läuft auf deiner CPU mit int8-Quantisierung (8-Bit-Zahlen statt 32-Bit: kleiner, schneller, genau genug). Kein Ton verlässt jemals deinen Rechner.&lt;/p&gt;
&lt;p&gt;Das Modell wird erst bei der ersten Transkription geladen (Lazy Loading), damit die App sofort startet. Und wenn faster-whisper gar nicht erst installiert ist, versteckt das Frontend den Mikrofon-Button einfach. Features sollten sanft degradieren, nicht explodieren.&lt;/p&gt;
&lt;h2 id="die-privacy-belege"&gt;Die Privacy-Belege&lt;/h2&gt;
&lt;p&gt;Die Serie begann mit einem Versprechen: &lt;em&gt;Nichts verlässt deinen Rechner.&lt;/em&gt; Versprechen sind billig — hier ist das Audit. Jedes Byte, das CogniVault speichert, und wo genau es lebt:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Daten&lt;/th&gt;
&lt;th&gt;Speicherort&lt;/th&gt;
&lt;th&gt;Format&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Deine hochgeladenen Dateien&lt;/td&gt;
&lt;td&gt;Ordner &lt;code&gt;docs/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Die Originaldateien&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Suchvektoren&lt;/td&gt;
&lt;td&gt;&lt;code&gt;vector_store.faiss&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;FAISS Binär-Index&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chunk-Text und Metadaten&lt;/td&gt;
&lt;td&gt;&lt;code&gt;vector_store.json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;JSON&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Datei-zu-Kategorie-Zuweisung&lt;/td&gt;
&lt;td&gt;&lt;code&gt;categories.json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;JSON&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chat-Sessions&lt;/td&gt;
&lt;td&gt;&lt;code&gt;chat_history.json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;JSON&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sessions, Badges, Quizzes, Workshops, Decks, Mindmaps&lt;/td&gt;
&lt;td&gt;&lt;code&gt;progress.db&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;SQLite&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ingestion Checkpoints&lt;/td&gt;
&lt;td&gt;PostgreSQL (lokales Docker-Volume)&lt;/td&gt;
&lt;td&gt;DBOS Systemtabellen&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Die KI-Modelle selbst&lt;/td&gt;
&lt;td&gt;Ollamas lokaler Model-Store&lt;/td&gt;
&lt;td&gt;Modell-Gewichte&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Nichts aus dieser Tabelle liegt auf dem Computer von jemand anderem. Inference geht an &lt;code&gt;localhost&lt;/code&gt;. Embeddings gehen an &lt;code&gt;localhost&lt;/code&gt;. Der einzige ausgehende Request, den das Backend jemals macht, ist der URL-Import — und das nur auf deinen ausdrücklichen Wunsch und geschützt davor, private Adressen abzurufen. Die App zeigt diese Statistiken sogar live im &amp;ldquo;Privacy Vault Audit&amp;rdquo;-Panel an.&lt;/p&gt;
&lt;p&gt;Und weil Vertrauen mehr braucht als nur eine Tabelle: Das gesamte Backend ist von einer pytest-Suite abgedeckt, die du selbst ausführen kannst. Der Ansatz dazu ist in
dokumentiert.&lt;/p&gt;
&lt;h2 id="fazit-der-serie"&gt;Fazit der Serie&lt;/h2&gt;
&lt;p&gt;Vier Teile, eine Architektur:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;
&lt;/strong&gt; — Drei Prozesse, vier Schichten und ein Decoder-Ring für den Jargon&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;
&lt;/strong&gt; — Eine dauerhafte, formatbewusste Pipeline, die jedes Dokument in durchsuchbare Vektoren verwandelt&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;
&lt;/strong&gt; — Zwei Retriever, die gegenseitig ihre blinden Flecken abdecken, durch Rang fusioniert und von einem Agenten gesteuert werden&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Teil 4&lt;/strong&gt; — Dieselbe Maschinerie, die Lernmaterialien generiert, Fortschritt ohne Buttons trackt, und eine Speichermap ohne Cloud-Abhängigkeiten&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Wenn es ein Hauptthema gibt, dann das: &lt;strong&gt;Langweilige, verifizierbare Entscheidungen im Dienste der Privatsphäre&lt;/strong&gt;. Exakte Suche statt Approximation. SQLite-Dateien statt gehosteter Datenbanken. Grammatikgebundenes JSON statt hoffnungsvollem Parsen. Soft-Deletes statt cleverer Index-Eingriffe. Jedes Puzzleteil ist etwas, das du öffnen, lesen und überprüfen kannst — und genau das ist der Punkt.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="anhang-abkürzungen-in-diesem-post"&gt;Anhang: Abkürzungen in diesem Post&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Abkürzung&lt;/th&gt;
&lt;th&gt;Volle Form&lt;/th&gt;
&lt;th&gt;Bedeutung&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;JSON&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;JavaScript Object Notation&lt;/td&gt;
&lt;td&gt;Das strukturierte Format, das die Generatoren vom Modell erzwingen&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SQLite / SQL&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;(SQL = Structured Query Language)&lt;/td&gt;
&lt;td&gt;Eine komplette relationale Datenbank, die in einer einzigen Datei lebt (&lt;code&gt;progress.db&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;MCQ&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Multiple-Choice Question&lt;/td&gt;
&lt;td&gt;Einer der beiden Quiz-Fragentypen (der andere ist Wahr/Falsch)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;CPU&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Central Processing Unit&lt;/td&gt;
&lt;td&gt;Hier läuft Whisper — keine Grafikkarte notwendig&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;int8&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;8-bit integer (Quantisierung)&lt;/td&gt;
&lt;td&gt;Modellgewichte als kleine Integer speichern: kleiner, schneller, genau genug&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;KI&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Künstliche Intelligenz (AI)&lt;/td&gt;
&lt;td&gt;Software, die Aufgaben ausführt, die normalerweise menschliche Intelligenz erfordern&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;API&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Application Programming Interface&lt;/td&gt;
&lt;td&gt;Die Endpunkte, die der Study Hub und das Dashboard aufrufen&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;FAISS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Facebook AI Similarity Search&lt;/td&gt;
&lt;td&gt;Der Vektor-Index in der Privacy-Tabelle&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;DBOS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Database-Oriented Operating System&lt;/td&gt;
&lt;td&gt;Die Durable-Workflow-Bibliothek, deren Checkpoints in PostgreSQL leben&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SSRF&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Server-Side Request Forgery&lt;/td&gt;
&lt;td&gt;Die Art von Angriff, vor dem der URL-Importer schützt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;PNG / PDF&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Portable Network Graphics / Portable Document Format&lt;/td&gt;
&lt;td&gt;Zwei der Export-Formate für Mindmaps (neben Markdown)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SVG&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Scalable Vector Graphics&lt;/td&gt;
&lt;td&gt;Das Zeichenformat im Browser, das hinter dem interaktiven Mindmap-Rendering steckt&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Nächste Schritte:&lt;/strong&gt; Klone
und lies mit — die README skizziert die komplette Architektur, und jede Behauptung in dieser Serie kann direkt mit dem Code unter &lt;code&gt;backend/&lt;/code&gt; abgeglichen werden. Und falls du Lust auf die Deep-Dive-Versionen dieser Themen hast: Die
knüpft genau da an, wo dieser Rundgang endet.&lt;/p&gt;</description></item><item><title>Teil 3 · CogniVault Architektur: Warum wir Ollama nicht in Docker packen</title><link>https://aretascodes.dev/de/blog/cognivault-deployment-architecture/</link><pubDate>Wed, 03 Jun 2026 00:00:00 +0000</pubDate><guid>https://aretascodes.dev/de/blog/cognivault-deployment-architecture/</guid><description>
&lt;blockquote class="border-l-4 border-neutral-300 dark:border-neutral-600 pl-4 italic text-neutral-600 dark:text-neutral-400 my-6"&gt;
&lt;p&gt;Alle Abkürzungen werden im Anhang am Ende der Seite ausführlich erklärt.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Die goldene Regel für modernes Software-Deployment heißt Containerisierung. Pack alles in Docker, um die Abhängigkeiten zu isolieren, und es läuft auf jeder Maschine absolut identisch.&lt;/p&gt;
&lt;p&gt;Als ich CogniVault anfangs entworfen habe, war der erste Impuls, den FastAPI-Server, die PostgreSQL-Datenbank und die Ollama LLM-Engine in ein einziges, sicheres Docker-Netzwerk zu stecken.&lt;/p&gt;
&lt;p&gt;Aber das haben wir nicht getan. Wir haben Ollama nativ auf dem Host-System laufen lassen. Schauen wir uns mal an, warum.&lt;/p&gt;
&lt;h2 id="das-gpu-passthrough-problem"&gt;Das GPU-Passthrough-Problem&lt;/h2&gt;
&lt;p&gt;Stell dir deine GPU wie die Küche in einem Restaurant vor. Die Köche (deine KI-Modelle) müssen &lt;em&gt;in der Küche&lt;/em&gt; sein — am Herd stehen, die Hände an den Geräten. Stell dir nun vor, du sagst den Köchen, sie müssten aus einem verschlossenen Konferenzraum am Ende des Flurs kochen und Anweisungen durch eine Durchreiche rufen. Technisch gesehen kommt vielleicht immer noch Essen heraus. Aber es wird nicht schnell gehen.&lt;/p&gt;
&lt;p&gt;Dieser verschlossene Raum ist ein Container. Large Language Models wie Gemma 4 brauchen direkten, ungehinderten Zugriff auf die GPU deiner Hardware (wie Apple Silicons Unified Memory oder eine dedizierte Nvidia-Karte), um Text schnell genug für ein Echtzeit-Chat-Interface zu generieren. Und die Situation ist je nach Plattform unterschiedlich:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Auf macOS&lt;/strong&gt; lässt Docker Container in einer ressourcenschonenden virtuellen Maschine laufen — und es gibt aktuell &lt;strong&gt;überhaupt kein GPU (Metal) Passthrough&lt;/strong&gt;. Ein Ollama-Container auf einem Mac läuft also nur über die CPU. Für eine Chat-App ist das an sich schon ein K.o.-Kriterium.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Unter Linux&lt;/strong&gt; gibt es Nvidia GPU-Passthrough und es funktioniert auch, aber es erfordert zusätzliche Toolkit-Konfiguration, die die &amp;ldquo;es funktioniert einfach&amp;rdquo;-Philosophie der lokalen Entwicklung zunichte macht.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Wenn man Ollama nativ laufen lässt, umgeht man diese ganze Kategorie von Problemen.&lt;/p&gt;
&lt;h2 id="die-brückenlösung"&gt;Die Brückenlösung&lt;/h2&gt;
&lt;p&gt;CogniVault verwendet ein geteiltes Deployment-Modell, das die Anwendungslogik von der rechenintensiven KI-Verarbeitung trennt.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Die sicheren Räume (Docker):&lt;/strong&gt; PostgreSQL — wo das DBOS-Workflow-Ledger aus
liegt — befindet sich in einem &lt;strong&gt;Docker Bridge Network&lt;/strong&gt; (einem privaten virtuellen Netzwerk). Isoliert, sauber, reproduzierbar.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Das Hauptgebäude (Nativer Host):&lt;/strong&gt; Ollama läuft direkt auf deinem Mac-, Windows- oder Linux-Betriebssystem und hat so direkten Zugriff auf deine GPU.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;CogniVault wird tatsächlich mit &lt;strong&gt;zwei Ausführungsmodi&lt;/strong&gt; ausgeliefert, und es lohnt sich, hier genau zu sein:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Der Standardmodus (&lt;code&gt;scripts/start.sh&lt;/code&gt;):&lt;/strong&gt; Nur PostgreSQL läuft in Docker. Das FastAPI-Backend läuft ebenfalls nativ (&lt;code&gt;python -m backend.main&lt;/code&gt;), direkt neben Ollama. Das ist der einfachste Loop für die lokale Entwicklung.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Der vollcontainerisierte Modus (&lt;code&gt;docker-compose.yaml&lt;/code&gt;):&lt;/strong&gt; Die FastAPI-App gesellt sich zu Postgres ins Compose-Netzwerk. In diesem Modus erreicht der App-Container die native Ollama-Engine über eine spezielle Docker-Routing-Adresse: &lt;code&gt;host.docker.internal:11434&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;So oder so bleibt die Regel die gleiche: &lt;strong&gt;Das Modell kommt niemals in die Box.&lt;/strong&gt;&lt;/p&gt;
&lt;div class="mermaid"&gt;graph TD
Client[📱 Browser / Nutzer] --&gt;|HTTP: 8000| App
subgraph HostMachine ["Host-OS: Nativer GPU-Zugriff"]
Ollama[🧠 Ollama Engine]
Models[(gemma4:e4b)]
Ollama &lt;--&gt; Models
subgraph DockerNetzwerk ["Docker Compose Netzwerk"]
App[🖥️ FastAPI App Container]
Postgres[(🐘 PostgreSQL)]
App &lt;--&gt;|Interner Port 5432| Postgres
end
App &lt;--&gt;|host.docker.internal:11434| Ollama
end
&lt;/div&gt;
&lt;h3 id="was-ist-mit-der-vektor-datenbank"&gt;Was ist mit der Vektor-Datenbank?&lt;/h3&gt;
&lt;p&gt;Dir fällt vielleicht auf, dass FAISS hier kein Container ist. Im Gegensatz zu massiven SQL-Datenbanken ist FAISS extrem leichtgewichtig. In CogniVault läuft FAISS direkt im Speicher des FastAPI-Python-Prozesses und speichert seine Daten in einem lokalen Ordner. Es braucht keinen eigenen Container.&lt;/p&gt;
&lt;p&gt;Indem wir die schwere LLM-Arbeit direkt auf der Hardware (Bare-Metal) erledigen und die Buchhaltung in Containern belassen, erreichen wir genau die Balance, an der die lokale KI-Entwicklung so oft scheitert: null Abhängigkeitskonflikte kombiniert mit maximaler KI-Performance.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id="erlebe-es-in-aktion"&gt;Erlebe es in Aktion&lt;/h3&gt;
&lt;p&gt;Das schließt unsere CogniVault-Architekturserie ab! Wenn du diesen zu 100% lokalen, datenschutzfreundlichen Lernbegleiter auf deiner eigenen Hardware ausführen möchtest:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Hol dir den Code:&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Schau dir das Walkthrough an:&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 id="anhang-abkürzungen-in-diesem-beitrag"&gt;Anhang: Abkürzungen in diesem Beitrag&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Abbreviation&lt;/th&gt;
&lt;th&gt;Full form&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;GPU&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Graphics Processing Unit&lt;/td&gt;
&lt;td&gt;Die Hardware, die lokale Modell-Inferenz schnell macht; Container haben Probleme, darauf zuzugreifen&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;LLM&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Large Language Model&lt;/td&gt;
&lt;td&gt;Ein auf riesigen Textmengen trainiertes neuronales Netzwerk, das Sprache lesen und erzeugen kann&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;AI&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Artificial Intelligence&lt;/td&gt;
&lt;td&gt;Software, die Aufgaben ausführt, für die normalerweise menschliche Intelligenz erforderlich ist&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;API&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Application Programming Interface&lt;/td&gt;
&lt;td&gt;Die URLs, die das Frontend aufruft, um mit dem Backend zu kommunizieren&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;HTTP&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;HyperText Transfer Protocol&lt;/td&gt;
&lt;td&gt;Das Protokoll, mit dem Browser und APIs Anfragen und Antworten austauschen&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;OS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Operating System&lt;/td&gt;
&lt;td&gt;macOS, Windows oder Linux — wo Ollama nativ läuft&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;DBOS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Database-Oriented Operating System&lt;/td&gt;
&lt;td&gt;Die Durable-Workflow-Bibliothek, deren Ledger im Postgres-Container liegt (siehe Teil 2)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SQL&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Structured Query Language&lt;/td&gt;
&lt;td&gt;Die Sprache relationaler Datenbanken wie PostgreSQL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;FAISS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Facebook AI Similarity Search&lt;/td&gt;
&lt;td&gt;Der In-Process-Vektorindex — absichtlich &lt;em&gt;kein&lt;/em&gt; separater Container&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;VM&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Virtual Machine&lt;/td&gt;
&lt;td&gt;Die versteckte Schicht, die Docker auf macOS nutzt — und der Grund, warum Mac-Container die GPU nicht erreichen können&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;</description></item><item><title>Teil 2 · CogniVault Architektur: Dauerhafte Ingestion mit DBOS</title><link>https://aretascodes.dev/de/blog/cognivault-ingestion-pipeline/</link><pubDate>Tue, 02 Jun 2026 00:00:00 +0000</pubDate><guid>https://aretascodes.dev/de/blog/cognivault-ingestion-pipeline/</guid><description>
&lt;blockquote class="border-l-4 border-neutral-300 dark:border-neutral-600 pl-4 italic text-neutral-600 dark:text-neutral-400 my-6"&gt;
&lt;p&gt;Alle Abkürzungen werden im Anhang am Ende der Seite ausführlich erklärt.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;In einem einfachen lokalen KI-Setup ist das Hinzufügen von Dokumenten zu deiner Datenbank normalerweise nur ein simples Python-Skript. Du öffnest ein PDF, zerhackst den Text in Chunks, verwandelst diese Chunks in Mathe (Embeddings) und speicherst sie.&lt;/p&gt;
&lt;p&gt;Das funktioniert super für ein fünfseitiges Essay. Aber was passiert, wenn du ein 1.000-seitiges technisches Handbuch einliest (Ingestion) und dein Laptop bei Seite 800 in den Ruhemodus geht?&lt;/p&gt;
&lt;p&gt;Das Skript stirbt. Wenn du deinen Laptop aufweckst, musst du wieder bei Seite 1 anfangen und verschwendest so Zeit und Rechenleistung. Ein einfaches Skript reichte für CogniVault nicht aus. Wir brauchten einen &lt;strong&gt;Durable Workflow&lt;/strong&gt; (dauerhaften Workflow).&lt;/p&gt;
&lt;h2 id="das-fabrikbuch-dbos"&gt;Das Fabrikbuch (DBOS)&lt;/h2&gt;
&lt;p&gt;Stell dir die Daten-Ingestion wie ein Fließband in einer Fabrik vor. Wenn der Strom ausfällt, sollten die Arbeiter nicht jedes Produkt von Grund auf neu bauen müssen. Sie sollten einfach in ein permanentes Kassenbuch (Ledger) schauen, genau sehen, welche Kiste sie gerade gepackt haben, als das Licht ausging, und dort weitermachen.&lt;/p&gt;
&lt;p&gt;CogniVault verwendet ein Framework namens &lt;strong&gt;DBOS (Database-Oriented Operating System)&lt;/strong&gt;, das von einer PostgreSQL-Datenbank gestützt wird, um als dieses Buch zu fungieren.&lt;/p&gt;
&lt;p&gt;Jeder Schritt des Ingestion-Prozesses protokolliert seinen Abschluss in Postgres. Wenn der Server mittendrin abstürzt, passiert im Moment nichts Dramatisches — die Magie entfaltet sich beim Neustart: DBOS liest das Buch, sieht, welche Schritte bereits abgeschlossen sind, spielt die aufgezeichneten Ergebnisse sofort ab und macht beim ersten unvollendeten Schritt weiter.&lt;/p&gt;
&lt;p&gt;Eine wichtige Grenze: Postgres enthält &lt;strong&gt;nur das Buch&lt;/strong&gt; — welche Schritte gelaufen sind und was sie zurückgegeben haben. Deine Dokumente, Chunks und Vektoren leben dort nie. Sie wandern in einen FAISS-Index plus eine JSON-Metadaten-Datei auf der Festplatte.&lt;/p&gt;
&lt;h2 id="sha-256-hashing-der-idempotenz-trick"&gt;SHA-256 Hashing: Der Idempotenz-Trick&lt;/h2&gt;
&lt;p&gt;Das System muss auch bei erneuten Uploads clever sein. Wenn du einen Tippfehler in einem riesigen Dokument behebst und es noch einmal hochlädst, willst du nicht, dass das System 10 Minuten verschwendet, um das Ganze neu einzubetten (re-embedding).&lt;/p&gt;
&lt;p&gt;CogniVault erreicht &lt;strong&gt;Idempotenz&lt;/strong&gt; (die Fähigkeit, dieselbe Operation mehrmals auszuführen, ohne das Ergebnis nach der ersten Anwendung zu verändern) mit dem allerersten Schritt des Workflows: Es scannt den &lt;code&gt;docs/&lt;/code&gt;-Ordner und generiert einen &lt;strong&gt;SHA-256-Hash&lt;/strong&gt; (einen einzigartigen digitalen Fingerabdruck) für jede Datei.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Wenn der Hash neu ist, wird die Datei verarbeitet.&lt;/li&gt;
&lt;li&gt;Wenn sich der Hash geändert hat (weil du die Datei bearbeitet hast), löscht es die alten Text-Chunks per &amp;ldquo;Soft-Delete&amp;rdquo; und bettet nur die neue Version neu ein.&lt;/li&gt;
&lt;li&gt;Wenn der Hash identisch ist, überspringt es die Datei komplett.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Hier können wir sehen, wie das logisch abläuft:&lt;/p&gt;
&lt;div class="mermaid"&gt;graph TD
Raw[📄 Hochgeladenes Dokument] --&gt; DBOS[🐘 DBOS Workflow startet]
subgraph DauerhaftePipeline ["Dauerhafte Ingestion-Pipeline"]
DBOS --&gt;|Schritt 1| Hash{Hash-Prüfung SHA-256}
Hash --&gt;|Unverändert| Skip[Verarbeitung überspringen]
Hash --&gt;|Neu / Geändert| Extract[✂️ Schritt 2: Text pro Dokument extrahieren]
Extract --&gt; Chunk[Chunking: 1000 Zeichen, 100 Überlappung]
Chunk --&gt;|Schritt 3, 5er-Batches| Embed[🔢 embeddinggemma Embeddings]
Embed --&gt;|Schritt 4| Save[(💾 FAISS Index + Metadaten JSON)]
end
Save --&gt;|Workflow abgeschlossen| Done[✅ Bereit für die Suche]
&lt;/div&gt;
&lt;p&gt;(Ein Detail für die Neugierigen: Die per Checkpoint gesicherten &lt;em&gt;Schritte&lt;/em&gt; sind der Scan, die Extraktion pro Dokument, jeder Embedding-Batch und das Speichern. Das Chunking dazwischen ist schnelle, reine Python-Arbeit, also läuft es einfach als Teil des Workflow-Körpers erneut — es mit einem Checkpoint zu versehen, würde mehr kosten, als es neu zu machen.)&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id="was-kommt-als-nächstes"&gt;Was kommt als Nächstes?&lt;/h3&gt;
&lt;p&gt;Indem wir die Ingestion-Pipeline in DBOS verpacken, verwandelt sich das System von einem anfälligen Skript in eine robuste Zustandsmaschine (State Machine) auf Produktionsniveau.&lt;/p&gt;
&lt;p&gt;Jetzt, da unsere Daten sicher eingelesen sind, wie deployen wir diese gesamte Pipeline, ohne die GPU unseres Laptops zum Schmelzen zu bringen?
&lt;strong&gt;Lies Teil 3: Warum wir Ollama nicht in Docker packen&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Du kannst die DBOS-Implementierung auch direkt in der Datei &lt;code&gt;backend/services/ingest.py&lt;/code&gt; im
erkunden.&lt;/em&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="anhang-abkürzungen-in-diesem-beitrag"&gt;Anhang: Abkürzungen in diesem Beitrag&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Abbreviation&lt;/th&gt;
&lt;th&gt;Full form&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;DBOS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Database-Oriented Operating System&lt;/td&gt;
&lt;td&gt;Eine Bibliothek, die Workflow-Schritte in einer Datenbank sichert, sodass abgestürzte Jobs fortgesetzt statt neu gestartet werden&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SHA-256&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Secure Hash Algorithm, 256-bit&lt;/td&gt;
&lt;td&gt;Eine Fingerabdruck-Funktion: Jede Datei wird auf einen einzigartigen 64-Zeichen-Hash abgebildet; änderst du ein Byte, ändert sich der Hash komplett&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;PDF&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Portable Document Format&lt;/td&gt;
&lt;td&gt;Das Dokumentenformat, dessen Text (und Scans) die Pipeline extrahiert&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;FAISS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Facebook AI Similarity Search&lt;/td&gt;
&lt;td&gt;Metas Vektorsuch-Bibliothek — wo die Embeddings tatsächlich leben&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;JSON&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;JavaScript Object Notation&lt;/td&gt;
&lt;td&gt;Das Textformat, das für die Chunk-Metadaten-Datei neben dem FAISS-Index verwendet wird&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;AI&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Artificial Intelligence&lt;/td&gt;
&lt;td&gt;Software, die Aufgaben ausführt, für die normalerweise menschliche Intelligenz erforderlich ist&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;GPU&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Graphics Processing Unit&lt;/td&gt;
&lt;td&gt;Die Hardware, die lokale Modell-Inferenz schnell macht — das Thema von Teil 3&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;</description></item><item><title>Teil 1 · CogniVault Architektur: Warum Standard-RAG nicht reicht (Hybride Suche)</title><link>https://aretascodes.dev/de/blog/cognivault-retrieval-loop/</link><pubDate>Mon, 01 Jun 2026 00:00:00 +0000</pubDate><guid>https://aretascodes.dev/de/blog/cognivault-retrieval-loop/</guid><description>
&lt;blockquote class="border-l-4 border-neutral-300 dark:border-neutral-600 pl-4 italic text-neutral-600 dark:text-neutral-400 my-6"&gt;
&lt;p&gt;Alle Abkürzungen werden im Anhang am Ende der Seite ausführlich erklärt.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Vektorsuche ist der Prozess, bei dem die ähnlichsten Elemente in einem Datensatz basierend auf ihren Vektor-Embeddings gefunden werden. So funktionieren RAG-Systeme normalerweise.
Aber was passiert, wenn du die ähnlichsten Elemente in einem Datensatz nicht nur aufgrund ihrer semantischen Bedeutung, sondern auch anhand des exakten Wortlauts der Suchanfrage finden musst?&lt;/p&gt;
&lt;p&gt;Das wird kritisch, wenn die Information, die du suchst, nicht nur inhaltlich verwandt sein soll, sondern genau mit einer bestimmten Zeichenkette oder einem bestimmten Schlüsselwort übereinstimmen muss.&lt;/p&gt;
&lt;h2 id="zwei-wege-ein-buch-zu-finden"&gt;Zwei Wege, ein Buch zu finden&lt;/h2&gt;
&lt;p&gt;Stell dir eine gute lokale Buchhandlung vor. Die Besitzerin hat alles gelesen und empfiehlt nach &lt;em&gt;Gefühl&lt;/em&gt;. Sag ihr, dass du &lt;em&gt;Der Marsianer&lt;/em&gt; geliebt hast, und sie gibt dir &lt;em&gt;Project Hail Mary&lt;/em&gt; — anderer Titel, andere Handlung, aber dieselbe DNA: ein einsamer Wissenschaftler, ein unmögliches Überlebensproblem, Witze unter Druck. Frag nach &amp;ldquo;sowas wie &lt;em&gt;Stolz und Vorurteil&lt;/em&gt;&amp;rdquo; und du gehst mit &lt;em&gt;Emma&lt;/em&gt; raus. Sie gleicht keine Wörter ab. Sie gleicht &lt;em&gt;Bedeutung&lt;/em&gt; ab.&lt;/p&gt;
&lt;p&gt;Nun stell ihr eine andere Art von Frage: &amp;ldquo;Ich brauche das Buch mit der ISBN 978-0-553-41802-6&amp;rdquo; oder &amp;ldquo;das Handbuch, auf dessen Cover der Fehlercode 404B erwähnt wird.&amp;rdquo; Ihre Superkraft ist hier nutzlos. Keine noch so große literarische Intuition findet einen exakten String. Dafür gehst du zur Kasse und schaust in den &lt;strong&gt;Katalog&lt;/strong&gt; — einen langweiligen, wörtlichen Index, der genau weiß, welches Regal welche Kennung enthält, und dem &amp;ldquo;Vibes&amp;rdquo; völlig egal sind.&lt;/p&gt;
&lt;p&gt;Eine gut geführte Buchhandlung braucht beides. Genauso wie ein gut geführtes RAG-System:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;FAISS — Facebook AI Similarity Search (die belesene Besitzerin):&lt;/strong&gt; ein Vektorindex, der Textabschnitte findet, deren &lt;em&gt;Bedeutung&lt;/em&gt; mathematisch nah an deinem Prompt liegt. Genial für &amp;ldquo;Wie ist die praktische Prüfung aufgebaut?&amp;rdquo;, aber blind für &amp;ldquo;§3 Absatz 2&amp;rdquo;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;BM25 — Best Match 25 (der Katalog):&lt;/strong&gt; ein klassischer Keyword-Scoring-Algorithmus, der exakte Worttreffer belohnt, gewichtet danach, wie selten und markant diese Wörter sind. Genial für Identifikatoren und zitierte Phrasen, aber blind für Umschreibungen (Paraphrasen).&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;CogniVault führt bei jeder Suche &lt;strong&gt;beide&lt;/strong&gt; Retriever aus — das nennt man &lt;strong&gt;Hybride Suche (Hybrid Search)&lt;/strong&gt; — und führt dann die beiden Ranglisten mit einer Formel namens &lt;strong&gt;Reciprocal Rank Fusion (RRF)&lt;/strong&gt; zusammen. RRF bewertet jeden Chunk rein nach seiner &lt;em&gt;Position&lt;/em&gt; in jeder Liste: Ein Chunk, der von einem der beiden Retriever hoch eingestuft wird, schneidet gut ab, und ein Chunk, bei dem sich beide Retriever einig sind, steigt nach ganz oben. Da nur Ränge verwendet werden, müssen die inkompatiblen Bewertungsskalen der beiden Retriever niemals in Einklang gebracht werden.&lt;/p&gt;
&lt;h2 id="der-agent-entscheidet-wann-gesucht-wird"&gt;Der Agent entscheidet, wann gesucht wird&lt;/h2&gt;
&lt;p&gt;Hier ist der Teil, den die meisten Diagramme verdrehen (meins in einem früheren Entwurf eingeschlossen): Das Retrieval (die Abfrage) passiert nicht, &lt;em&gt;bevor&lt;/em&gt; das Modell ins Spiel kommt. Es passiert &lt;em&gt;innerhalb&lt;/em&gt; des eigenen Loops des Modells.&lt;/p&gt;
&lt;p&gt;CogniVault verpackt Gemma im &lt;strong&gt;Strands Agents SDK&lt;/strong&gt;. Das Modell erhält deine Frage zusammen mit einer Reihe von &lt;strong&gt;Tools&lt;/strong&gt; (vorgeschriebene Python-Funktionen wie &lt;code&gt;search_knowledge_base&lt;/code&gt;, &lt;code&gt;calculator&lt;/code&gt; oder &lt;code&gt;compare_documents&lt;/code&gt;). Es denkt dann über die Frage nach und &lt;em&gt;entscheidet selbst&lt;/em&gt;, ob — und welche — Tools es aufruft. Bei den meisten Fragen zu Dokumenten ruft es &lt;code&gt;search_knowledge_base&lt;/code&gt; auf, liest die abgerufenen Chunks und schreibt erst dann seine Antwort, basierend auf dem, was es gefunden hat.&lt;/p&gt;
&lt;p&gt;Hier ist die Blaupause der Architektur dieses Loops:&lt;/p&gt;
&lt;div class="mermaid"&gt;graph TD
Client[📱 Nutzer-Anfrage] --&gt; App[🖥️ FastAPI Server]
subgraph AgentLoop["Der Strands Agent-Loop (powered by Gemma 4)"]
App --&gt; Agent[🧠 Agent analysiert die Frage]
Agent --&gt;|Entscheidet zu suchen| Search[search_knowledge_base]
subgraph HybrideSuche ["Hybride Such-Engine"]
Search --&gt;|Semantisch| FAISS[(FAISS Vektor)]
Search --&gt;|Exakter Treffer| BM25[(BM25 Keyword)]
FAISS --&gt; RRF{RRF Fusion}
BM25 --&gt; RRF
end
RRF --&gt;|Beste Chunks + Quellenangaben| Agent
Agent --&gt;|Fundierte Antwort| Answer[Gestreamte Antwort]
end
Answer --&gt; Client
&lt;/div&gt;
&lt;p&gt;Eine Feinheit, die erwähnenswert ist: Der Agent &lt;em&gt;ist&lt;/em&gt; Gemma. Es gibt am Ende kein separates &amp;ldquo;Formatierungsmodell&amp;rdquo; — dasselbe Modell, das sich für die Suche entschieden hat, schreibt auch die endgültige Antwort, nun mit den abgerufenen Chunks vor Augen.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id="was-kommt-als-nächstes"&gt;Was kommt als Nächstes?&lt;/h3&gt;
&lt;p&gt;Eine Spielzeug-RAG-App zu bauen ist einfach, aber eine zu bauen, die tatsächlich genau das Dokument abruft, das du brauchst, erfordert hybride Engines und einen Agenten, der weiß, wann er sie einsetzen muss.&lt;/p&gt;
&lt;p&gt;Willst du sehen, wie dieses System riesige Dokumente sicher einliest, ohne Arbeit zu verlieren, wenn mal etwas abstürzt?
&lt;strong&gt;Lies Teil 2: Dauerhafte Ingestion mit DBOS&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Oder, wenn du lieber direkt in den Code springen willst: Die hybride Suche befindet sich in &lt;code&gt;backend/services/vector_db.py&lt;/code&gt; des
.&lt;/em&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="anhang-abkürzungen-in-diesem-beitrag"&gt;Anhang: Abkürzungen in diesem Beitrag&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Abbreviation&lt;/th&gt;
&lt;th&gt;Full form&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;RAG&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Retrieval-Augmented Generation&lt;/td&gt;
&lt;td&gt;Rufe zuerst relevante Passagen aus deinen eigenen Dokumenten ab; lass das Modell daraus antworten anstatt aus dem Trainingsgedächtnis&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;FAISS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Facebook AI Similarity Search&lt;/td&gt;
&lt;td&gt;Metas Bibliothek zum Speichern von Vektoren und zum schnellen Finden der ähnlichsten&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;BM25&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Best Match 25&lt;/td&gt;
&lt;td&gt;Eine Keyword-Ranking-Formel — die 25. Ranking-Funktion, die im Okapi-Information-Retrieval-System entwickelt wurde&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;RRF&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Reciprocal Rank Fusion&lt;/td&gt;
&lt;td&gt;Eine Formel, die mehrere Ranglisten nur anhand des Rangs jedes Elements zusammenführt: &lt;code&gt;score = Σ 1/(k + rank)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;LLM&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Large Language Model&lt;/td&gt;
&lt;td&gt;Ein auf riesigen Textmengen trainiertes neuronales Netzwerk, das Sprache lesen und erzeugen kann&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SDK&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Software Development Kit&lt;/td&gt;
&lt;td&gt;Eine Bibliothek mit Bausteinen — hier Strands, was den Agent-Loop bereitstellt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;API&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Application Programming Interface&lt;/td&gt;
&lt;td&gt;Die URLs, die das Frontend aufruft, um mit dem Backend zu kommunizieren&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ISBN&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;International Standard Book Number&lt;/td&gt;
&lt;td&gt;Die eindeutige Kennung, die auf jedem veröffentlichten Buch gedruckt ist — der beste Freund des Katalogs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;</description></item><item><title>Gemma CogniVault</title><link>https://aretascodes.dev/de/projects/cognivault/</link><pubDate>Mon, 25 May 2026 00:00:00 +0000</pubDate><guid>https://aretascodes.dev/de/projects/cognivault/</guid><description>&lt;h2 id="überblick"&gt;Überblick&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Gemma CogniVault&lt;/strong&gt; ist ein zu 100 % lokaler, auf Privatsphäre ausgerichteter KI-Lernbegleiter. Deine Dokumente bleiben auf deiner Hardware. Die Inferenz läuft über Ollama auf &lt;code&gt;localhost&lt;/code&gt;. Keine Telemetrie, keine Embeddings an Dritte geschickt, keine Ausnahmen. Ein Live Privacy Vault Audit Panel bestätigt dir zur Laufzeit, dass es null externe Verbindungen gibt.&lt;/p&gt;
&lt;p&gt;Und es ist auch wirklich fähig — die volle Bandbreite von Gemma 4 (Completion, Vision, Tools, Reasoning) läuft auf deinem Laptop, verpackt in eine App, die deine Dokumente in &lt;strong&gt;Quizzes, Multi-Lektionen-Workshops, Karteikarten-Decks und visuelle Mindmaps&lt;/strong&gt; verwandelt, komplett mit einem Dashboard für deinen Lernfortschritt und 25 Achievement-Badges.&lt;/p&gt;
&lt;h2 id="was-drinsteckt"&gt;Was drinsteckt&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Schicht&lt;/th&gt;
&lt;th&gt;Technologie&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;LLM &amp;amp; Embeddings&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Ollama · &lt;code&gt;gemma4:e4b&lt;/code&gt; · &lt;code&gt;embeddinggemma&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Agent Framework&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Strands Agents SDK&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Backend&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;FastAPI · Python 3.10+ · Pydantic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Vector Search&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;FAISS IndexFlatIP + BM25Okapi · Reciprocal Rank Fusion&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Document Parsing&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;pypdf · python-docx · python-pptx · openpyxl · trafilatura&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;OCR&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;pytesseract · pymupdf · Pillow&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Audio&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;faster-whisper&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Workflow Engine&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;DBOS + PostgreSQL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Frontend&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;React 19 · TypeScript · Vite · Tailwind v4 · Framer Motion · TanStack Query&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 id="vier-bereiche"&gt;Vier Bereiche&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Bereich&lt;/th&gt;
&lt;th&gt;Wofür es da ist&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;💬 Chat&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Frag alles über deine Dokumente. Zitierte Antworten, Scope-Filter, Spracheingabe, Anhänge.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;📚 Knowledge Base&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Hochladen, kategorisieren und verwalten deiner Dokumente. SHA-256 Änderungserkennung beim erneuten Upload.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;🎓 Study Hub&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Vier KI-gestützte Lernmodi: Quiz · Workshop · Flashcards · Mindmaps.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;📊 Dashboard&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Gesamte Lernzeit, aktueller Streak, 25 Achievement-Badges, 90-Tage-Aktivitäts-Heatmap.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 id="highlights"&gt;Highlights&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;🧠 Thinking Mode&lt;/strong&gt; — ein ausklappbares Reasoning-Panel streamt Gemmas Chain-of-Thought vor der Antwort&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;🔍 Hybrid Retrieval&lt;/strong&gt; — FAISS dense + BM25 keyword kombiniert durch Reciprocal Rank Fusion&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;🖼️ Multimodal&lt;/strong&gt; — Bilder, PDFs und DOCX-Dateien direkt im Chat anhängen&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;🛟 Durable workflows&lt;/strong&gt; — DBOS-gesicherte Ingestion; crash-resistent und wiederaufnehmbar&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;🏆 25 Achievement-Badges&lt;/strong&gt; — automatisch getrackt in Chat, Quizzes, Workshops, Flashcards, Mindmaps&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;🔒 Vault Audit Panel&lt;/strong&gt; — Live-Indikator für &amp;ldquo;null externe Verbindungen&amp;rdquo;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="darüber-schreiben"&gt;Darüber schreiben&lt;/h2&gt;
&lt;p&gt;Ich veröffentliche eine Serie von Posts, die die technischen Entscheidungen hinter CogniVault auspacken — das Privacy-Framing, den Retrieval-Stack, die Agenten-Loop, die Langlebigkeit bei der Ingestion, wie man JSON aus einem lokalen Modell kriegt, wie man Mindmaps ohne Graph-Bibliothek zeichnet, den Gamification-Layer und wie die Test-Suite komplett ohne Infrastruktur auskommt.&lt;/p&gt;
&lt;blockquote class="border-l-4 border-neutral-300 dark:border-neutral-600 pl-4 italic text-neutral-600 dark:text-neutral-400 my-6"&gt;
&lt;p&gt;Sieh dir den
für die komplette Serie an.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 id="probier-es-aus"&gt;Probier es aus&lt;/h2&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;git clone https://github.com/ndimoforaretas/local-gemma-rag.git
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; local-gemma-rag
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;./scripts/setup.sh &lt;span class="c1"&gt;# one-time&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;./scripts/start.sh
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Dann öffne
.&lt;/p&gt;</description></item><item><title>Teil 8 · Eine lokale KI-App testen: 351 Tests, Null Infrastruktur</title><link>https://aretascodes.dev/de/blog/testing-local-ai-app/</link><pubDate>Mon, 25 May 2026 00:00:00 +0000</pubDate><guid>https://aretascodes.dev/de/blog/testing-local-ai-app/</guid><description>
&lt;blockquote class="border-l-4 border-neutral-300 dark:border-neutral-600 pl-4 italic text-neutral-600 dark:text-neutral-400 my-6"&gt;
&lt;p&gt;Teil einer Serie über den Aufbau von
. Zuvor:
.
Alle Abkürzungen werden vollständig im Anhang am Ende der Seite erklärt.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;CogniVault hat &lt;strong&gt;351 Tests verteilt auf 22 Dateien&lt;/strong&gt; (zum Zeitpunkt des Schreibens — die Suite wächst mit der App). Keiner davon benötigt Ollama. Keiner benötigt Postgres. Keiner braucht ein echtes PDF, ein Mikrofon oder eine Internetverbindung. Die gesamte Suite läuft in &lt;strong&gt;etwa drei Sekunden&lt;/strong&gt; auf meinem Laptop.&lt;/p&gt;
&lt;p&gt;Das liegt nicht daran, dass es nicht viel zu testen gäbe — die Oberfläche ist groß. Es liegt daran, dass die Test-Suite um ein einziges Prinzip herum aufgebaut ist: &lt;strong&gt;An den Rändern mocken, überall sonst echt.&lt;/strong&gt; In diesem Post geht es darum, was &amp;ldquo;der Rand&amp;rdquo; in einer lokalen KI-App bedeutet und wie man die Grenze so zieht, dass die Suite nützlich bleibt anstatt nur dekorativ zu sein.&lt;/p&gt;
&lt;h2 id="die-22-testdateien"&gt;Die 22 Testdateien&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Datei&lt;/th&gt;
&lt;th&gt;Was sie abdeckt&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;test_api.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Die HTTP-Endpoints (Upload, Ingest, RAG, Historie, KB-Browsing)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;test_tools.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Taschenrechner, Uhr, KB-Such-Tool&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;test_thinking.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Zwei-Phasen-Stream, Thinking-Tokens, Session-Isolation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;test_chat_attachments.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Multi-File-Attach, PDF/DOCX-Extraktion, Größenlimits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;test_chat_memory.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Budget für Session-Historie, Trimming, Restart-Rebuild&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;test_doc_scope_filter.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;ContextVar-Isolation pro Request, Suchfilterung&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;test_doc_tools.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;list_documents&lt;/code&gt;, &lt;code&gt;analyze_document&lt;/code&gt;, &lt;code&gt;compare_documents&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;test_edit_regenerate.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Historie zurückspulen, trim_history_to_turns-Validierung&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;test_structure_chunking.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Markdown-Header-Splits, CSV-Zeilen-Batches, Dokumenttypen&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;test_ocr_fallback.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;OCR-Trigger-Schwellenwert, Graceful Degradation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;test_new_formats.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;PPTX-, XLSX-, HTML-Extraktoren, Extension-Routing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;test_docx_url.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;DOCX-Ingestion und URL-Import (mit dem SSRF-Schutz)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;test_reingest.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;SHA-256-Änderungserkennung, Idempotenz&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;test_vector_db.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;BM25, FAISS, RRF-Fusion, Hybrid-Suche&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;test_audio.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Whisper-Transkriptions-Endpoint&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;test_progress.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Sessions, tägliche Aggregation, Achievement-Kriterien&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;test_prompts.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Der Prompt-Template-Loader und benutzerdefinierte Overrides&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;test_vault_stats.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Die Privacy Vault Audit-Zahlen&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;test_quiz.py&lt;/code&gt; / &lt;code&gt;test_workshop.py&lt;/code&gt; / &lt;code&gt;test_flashcards.py&lt;/code&gt; / &lt;code&gt;test_mindmaps.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Parsing pro Modus, Endpoints, Achievements&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Alles, was isoliert getestet werden &lt;em&gt;kann&lt;/em&gt;, wird isoliert getestet. Alles, was durch die FastAPI-Schicht getestet werden muss, wird dort getestet, aber die &lt;em&gt;einzigen&lt;/em&gt; gemockten Dinge sind die Aufrufe, die die Prozessgrenze überschreiten.&lt;/p&gt;
&lt;h2 id="was-gemockt-wird-was-nicht"&gt;Was gemockt wird, was nicht&lt;/h2&gt;
&lt;p&gt;Die mit Abstand wichtigste Frage in so einem Projekt: Wo setzt man den Stub an?&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;[ React frontend ] ←─ nicht im Scope für Backend-Tests
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; │
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ▼
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;[ FastAPI handlers ] ←─ direkt mit TestClient getestet
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; │
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ▼
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;[ services/ ] ←─ direkt getestet (vector_db, rag_agent, generators)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; │
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ├─► [ FAISS + BM25 ] ←─ echt, in-memory, schnell
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ├─► [ SQLite ] ←─ echt, gegen eine tmp_path-Datei
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ├─► [ DBOS ] ←─ gepatched (kein Start, kein Postgres)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ├─► [ Ollama ] ←─ gepatched am Import-Ort jedes Services
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; └─► [ Whisper ] ←─ als Stub (kein 145-MB-Modell-Laden)
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Als Faustregel gilt: &lt;strong&gt;Alles, was eine Prozess- oder Netzwerkgrenze überschreitet, wird gemockt. Alles In-Process läuft echt.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;FAISS und BM25 sind echt, weil es Bibliotheken sind, die wir in den Testprozess einbinden. SQLite ist echt, weil es eine Datei ist. DBOS ist gepatched, weil beim Starten eine Postgres-Verbindung erwartet wird, und das ist Netzwerk. Ollama ist gepatched, weil es HTTP ist. Whisper ist als Stub ausgeführt, weil das Laden eines 145 MB großen Modells in einem Unit-Test ziemlich albern ist.&lt;/p&gt;
&lt;p&gt;Dieses Prinzip hält die Test-Suite schnell (kein I/O, den das OS nicht in Millisekunden verarbeiten kann) und aussagekräftig (die echten Code-Pfade durch Retrieval, Chunking, Parsing und Scope-Filterung werden ausgeführt).&lt;/p&gt;
&lt;h2 id="ollama-mocken"&gt;Ollama mocken&lt;/h2&gt;
&lt;p&gt;Die meisten CogniVault-Tests brauchen &lt;em&gt;irgendeinen&lt;/em&gt; Modell-Output, aber es ist ihnen egal, welches Modell ihn produziert hat. Jeder Service importiert das &lt;code&gt;ollama&lt;/code&gt;-Modul direkt, daher patchen die Tests diese Referenz &lt;strong&gt;direkt am Import-Ort des Services&lt;/strong&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Real pattern from test_quiz.py&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="nn"&gt;unittest.mock&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;patch&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="nn"&gt;backend.services&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;quiz_generator&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_quiz_parses_questions&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;fake&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;message&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;content&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;questions&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;VALID_MCQ&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;})}}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;patch&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;object&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;quiz_generator&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;ollama&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;mock_ollama&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;mock_ollama&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chat&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;return_value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fake&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;quiz_generator&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;generate_quiz&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;difficulty&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;beginner&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;num_questions&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;question_types&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;mcq&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;questions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Eine Streaming-Variante füttert Chunk-Sequenzen anstelle einer einzelnen Antwort; dies wird für die RAG- und Thinking-Tests verwendet. Die wichtigste Eigenschaft: Ein &lt;code&gt;patch.object&lt;/code&gt; auf das Modul, das der Service tatsächlich benutzt. Keine tiefen Mock-Hierarchien, keine fragilen String-Pfade in Third-Party-Interna. Leicht in einem Code-Review zu lesen, leicht zu debuggen, wenn ein Test fehlschlägt.&lt;/p&gt;
&lt;h2 id="dbos-mocken"&gt;DBOS mocken&lt;/h2&gt;
&lt;p&gt;DBOS erwartet, dass sich &lt;code&gt;launch()&lt;/code&gt; mit Postgres verbindet. Die gemeinsam genutzte &lt;code&gt;client&lt;/code&gt;-Fixture in der &lt;code&gt;conftest.py&lt;/code&gt; patcht einfach die &lt;code&gt;dbos&lt;/code&gt;-Instanz, bevor die App ausgeführt wird:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Real pattern from conftest.py&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nd"&gt;@pytest.fixture&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;client&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;&amp;#34;&amp;#34;A FastAPI TestClient with DBOS launch mocked out — no Postgres needed.&amp;#34;&amp;#34;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;patch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;backend.services.ingest.dbos&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;mock_dbos&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;mock_dbos&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;launch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;MagicMock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="nn"&gt;backend.main&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;TestClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Die dekorierten Workflow-Schritte werden weiterhin als gewöhnliche Python-Funktionen ausgeführt — wir verlieren die Durability-Semantik, aber die Tests prüfen ja nicht Durability, sondern die &lt;em&gt;Geschäftslogik innerhalb der Schritte&lt;/em&gt; (Hash-Erkennung, Extraktion, Chunking). Die Durability-Schicht hat ihre eigenen Tests weiter oben, in der eigenen Suite von DBOS.&lt;/p&gt;
&lt;p&gt;Es gibt noch eine zweite Isolationsschicht, die &lt;strong&gt;jeden&lt;/strong&gt; Test automatisch durchläuft: Eine Autouse-Fixture richtet den Docs-Ordner, den FAISS-Index und die Metadaten-Datei über Umgebungsvariablen auf einen &lt;code&gt;tmp_path&lt;/code&gt; pro Test ein, sodass kein Test jemals echte Daten auf der Festplatte berühren kann.&lt;/p&gt;
&lt;h2 id="echtes-sqlite-mit-einem-override"&gt;Echtes SQLite, mit einem Override&lt;/h2&gt;
&lt;p&gt;Progress-Tracking, Achievements, Quiz-Speicherung, Deck-CRUD — alles SQLite. Der Progress-Tracker bietet eine einzige Test-Nahtstelle: Einen Pfad-Override auf Modulebene.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Real pattern from test_quiz.py&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nd"&gt;@pytest.fixture&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;autouse&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_isolate_progress_db&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tmp_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;monkeypatch&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;monkeypatch&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;setattr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;progress_tracker&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;_db_path_override&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tmp_path&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;progress_test.db&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Jeder Test bekommt eine frische Datenbankdatei; das Schema wird bei der ersten Nutzung automatisch erstellt. Kein Drama mit Connection-Pooling, kein durchgesickerter Status zwischen Tests, keine in-memory &lt;code&gt;:memory:&lt;/code&gt;-Gymnastik. Einfach eine Temp-Datei pro Test.&lt;/p&gt;
&lt;p&gt;Das ist die Art von Test, die Fehler aufdeckt, die ein Mock auf SQL-Ebene niemals sehen würde — ein fehlender Index, eine vermurkste Migration, ein Constraint, der nicht auslöst. SQLite ist auf jedem Rechner, den ich je besessen habe, so schnell, dass &amp;ldquo;die echte Datenbank nutzen&amp;rdquo; nicht mal ein Kompromiss ist.&lt;/p&gt;
&lt;h2 id="das-testclient-pattern"&gt;Das TestClient-Pattern&lt;/h2&gt;
&lt;p&gt;Für HTTP-Tests führt FastAPIs &lt;code&gt;TestClient&lt;/code&gt; die App in-process aus. Der Upload, die Validierung, das Chunking, das Vector-Store-Update, die Response-Serialisierung — jede Schicht läuft echt. Nur die Aufrufe, die den Prozess verlassen würden (der Ollama-Embedding-Aufruf in der Ingestion, der Modell-Aufruf in der Generierung), sind gepatched. Das ist genau die richtige Grenze: Der Test verifiziert die &lt;em&gt;Integration&lt;/em&gt; dieser Schichten, hängt aber nicht von einem externen Service ab.&lt;/p&gt;
&lt;p&gt;Die Streaming-Endpoint-Tests nutzen einen leicht anderen Stil — sie iterieren über den Response-Body und parsen jede &lt;strong&gt;NDJSON&lt;/strong&gt;-Zeile (ein JSON-Envelope pro Zeile, wie im
beschrieben) — aber das Prinzip ist identisch.&lt;/p&gt;
&lt;h2 id="lücken-in-der-abdeckung-die-ich-akzeptiere"&gt;Lücken in der Abdeckung, die ich akzeptiere&lt;/h2&gt;
&lt;p&gt;Drei Dinge, die die Test-Suite &lt;em&gt;nicht&lt;/em&gt; abdeckt:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Das Frontend.&lt;/strong&gt; Keine React-Tests in dieser Suite — das ist ein separates Anliegen. Die meisten Fehler zeigen sich ohnehin in API-Tests, da das Frontend ein Thin-Client über einer typisierten API ist.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Die tatsächliche Ollama-Prompt-Qualität.&lt;/strong&gt; Ob &lt;code&gt;gemma4:e4b&lt;/code&gt; wirklich &lt;em&gt;nützliche&lt;/em&gt; Quizfragen generiert, ist nichts, was Tests beantworten können. Das ist Evaluierung, kein Testing. Es gehört in eine separate Testumgebung, in der ein echtes Modell läuft.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Race Conditions über DBOS-Workflow-Restarts hinweg.&lt;/strong&gt; Der Resume-Pfad wird auf Logikebene geprüft, aber der volle Zustandsraum von &amp;ldquo;Was passiert, wenn Postgres in genau diesem Moment weg ist&amp;rdquo; ist zu groß, um ihn komplett durchzuspielen.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Das sind bewusste Lücken. Die Test-Suite ist dazu da, Regressionen in meinem Code zu fangen; sie ist kein Ersatz für Evaluierung, Integrationstests oder gar echtes Chaos-Engineering.&lt;/p&gt;
&lt;h2 id="wofür-die-suite-eigentlich-da-ist"&gt;Wofür die Suite eigentlich da ist&lt;/h2&gt;
&lt;p&gt;Zwei Dinge, in dieser Reihenfolge:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Vertrauen beim Refactoring.&lt;/strong&gt; Wenn ich die Agent-Loop rausreiße und eine neue einsetze, laufen die Tests dann immer noch grün durch? Wenn ja, haben sich die API-Verträge, die mir wichtig sind, nicht verschoben.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Absicherung für PR-Reviews.&lt;/strong&gt; Jeder PR lässt die Suite in der CI laufen. Ein grüner Durchlauf ist Voraussetzung für den Merge. Die Suite ist laut genug, dass eine echte Regression auch wirklich Lärm macht.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Beachte, wofür sie &lt;em&gt;nicht&lt;/em&gt; da ist: um zu beweisen, dass das Modell funktioniert. Das kann sie nicht. Tests können Verhalten festnageln, aber keine Qualität. Das ist ein anderer Muskel, und er gehört in eine andere Testumgebung.&lt;/p&gt;
&lt;h2 id="was-sich-zum-ausborgen-lohnt"&gt;Was sich zum Ausborgen lohnt&lt;/h2&gt;
&lt;p&gt;Wenn du eine lokale KI-App baust und deine Tests Ollama am Laufen haben müssen:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Patche das &lt;code&gt;ollama&lt;/code&gt;-Modul am Import-Ort jedes Services mit &lt;code&gt;patch.object(service_module, &amp;quot;ollama&amp;quot;)&lt;/code&gt; — eine Nahtstelle pro Service, keine Shims nötig.&lt;/li&gt;
&lt;li&gt;Gib deiner DB-Schicht einen Pfad-Override und lass sie gegen eine &lt;code&gt;tmp_path&lt;/code&gt;-SQLite-Datei laufen.&lt;/li&gt;
&lt;li&gt;Nutze eine Autouse-Fixture, um jedes On-Disk-Artefakt (Docs-Ordner, Indexdateien) auf &lt;code&gt;tmp_path&lt;/code&gt; umzuleiten, damit kein Test jemals versehentlich echte Daten berührt.&lt;/li&gt;
&lt;li&gt;Ziehe für jeden externen Service (Modell, Audio, Workflow-Engine) die Naht an der Prozessgrenze. Teste alles darüber mit echtem Code.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Das Ergebnis ist eine Suite, in der jeder Test in jeder Umgebung läuft, in Millisekunden fertig ist und die tatsächliche Integration jeder von dir geschriebenen Codezeile testet. 351 Tests in etwa drei Sekunden sind keine Optimierung, sondern ein Nebeneffekt davon, dass man nur an den Rändern mockt.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="anhang-abkürzungen-in-diesem-post"&gt;Anhang: Abkürzungen in diesem Post&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Abkürzung&lt;/th&gt;
&lt;th&gt;Volle Form&lt;/th&gt;
&lt;th&gt;Bedeutung&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;CI&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Continuous Integration&lt;/td&gt;
&lt;td&gt;Automatisches Ausführen der Test-Suite bei jedem Push/PR&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;PR&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Pull Request&lt;/td&gt;
&lt;td&gt;Eine vorgeschlagene Code-Änderung — wird nur gemerged, wenn die Suite grün ist&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;API&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Application Programming Interface&lt;/td&gt;
&lt;td&gt;Die HTTP-Oberfläche, die der TestClient in-process testet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;HTTP&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;HyperText Transfer Protocol&lt;/td&gt;
&lt;td&gt;Das Protokoll, das die (in-process) Endpoint-Tests sprechen&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;RAG&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Retrieval-Augmented Generation&lt;/td&gt;
&lt;td&gt;Die Retrieval-then-Answer-Pipeline, die getestet wird&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;KB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Knowledge Base&lt;/td&gt;
&lt;td&gt;Die indizierte Dokumentensammlung&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;FAISS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Facebook AI Similarity Search&lt;/td&gt;
&lt;td&gt;Echt in Tests — es ist eine In-Process-Bibliothek&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;BM25&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Best Match 25&lt;/td&gt;
&lt;td&gt;Der Keyword-Index — auch echt in Tests&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;RRF&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Reciprocal Rank Fusion&lt;/td&gt;
&lt;td&gt;Die Rank-Merging-Formel, die in &lt;code&gt;test_vector_db.py&lt;/code&gt; abgedeckt wird&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SQLite / SQL&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;(SQL = Structured Query Language)&lt;/td&gt;
&lt;td&gt;Die echte, dateibasierte Datenbank, gegen die jeder Progress-Test läuft&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;DBOS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Database-Oriented Operating System&lt;/td&gt;
&lt;td&gt;Die Durable-Workflow-Bibliothek — gepatched, sodass kein Postgres nötig ist&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;OCR&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Optical Character Recognition&lt;/td&gt;
&lt;td&gt;Der Fallback für eingescannte PDFs mit eigenen Trigger-Threshold-Tests&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SSRF&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Server-Side Request Forgery&lt;/td&gt;
&lt;td&gt;Die URL-Import-Angriffsklasse, die in &lt;code&gt;test_docx_url.py&lt;/code&gt; abgedeckt ist&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;NDJSON&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Newline-Delimited JSON&lt;/td&gt;
&lt;td&gt;Das Streaming-Format, das die Endpoint-Tests Zeile für Zeile parsen&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SHA-256&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Secure Hash Algorithm, 256-bit&lt;/td&gt;
&lt;td&gt;Der Content-Fingerprint hinter den Re-Ingest-Tests&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;CRUD&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Create, Read, Update, Delete&lt;/td&gt;
&lt;td&gt;Die grundlegenden Speicheroperationen für Decks, Quizzes und Maps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;PDF / DOCX / PPTX / XLSX / HTML&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Portable Document Format / Word / PowerPoint / Excel / HyperText Markup Language&lt;/td&gt;
&lt;td&gt;Die Extraktor-Formate mit dedizierten Tests&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;p&gt;Das war die Serie. Acht Posts über die Teile von
, auf die ich am stolzesten bin — und ein paar, die ich heute anders bauen würde. Wenn irgendetwas davon nützlich für dich war, der Code ist Open Source auf
zu finden und der
ist auf YouTube.&lt;/p&gt;
&lt;p&gt;Deine Daten. Deine Hardware. Deine KI. Dein Vault.&lt;/p&gt;</description></item><item><title>Teil 7 · Gamifying Learning: 25 Badges, Idle-Gap Sessions und eine 90-Tage-Heatmap</title><link>https://aretascodes.dev/de/blog/gamifying-learning-badges-heatmap/</link><pubDate>Wed, 20 May 2026 00:00:00 +0000</pubDate><guid>https://aretascodes.dev/de/blog/gamifying-learning-badges-heatmap/</guid><description>
&lt;blockquote class="border-l-4 border-neutral-300 dark:border-neutral-600 pl-4 italic text-neutral-600 dark:text-neutral-400 my-6"&gt;
&lt;p&gt;Teil einer Serie über die Entwicklung von
. Zuvor:
.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote class="border-l-4 border-neutral-300 dark:border-neutral-600 pl-4 italic text-neutral-600 dark:text-neutral-400 my-6"&gt;
&lt;p&gt;Alle Abkürzungen werden im Anhang unten auf der Seite vollständig erklärt.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Ich habe acht Jahre lang ICT an weiterführenden Schulen unterrichtet, bevor ich zum Full-Stack-Developer gewechselt bin. Die zuverlässigste Lektion aus dieser Zeit war unangenehm einfach: &lt;strong&gt;Schüler, die beständig da waren, haben gelernt. Die anderen nicht.&lt;/strong&gt; Talent, Vorwissen, sogar die Motivation an einem bestimmten Tag – all das war der bloßen Anwesenheit nachgelagert.&lt;/p&gt;
&lt;p&gt;Der &amp;ldquo;Dashboard&amp;rdquo;-Tab in CogniVault ist ein kleiner Versuch, genau das zu fördern. Es ist keine Duolingo-Streak-Panikmaschine. Es sind drei Dinge:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Hero-Statistiken&lt;/strong&gt; – gesamte Lernzeit, gesamte Sessions, aktueller Streak.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;25 Erfolgs-Badges&lt;/strong&gt; – automatisch erfasst über Chat, Quizzes, Workshops, Flashcards und Mindmaps hinweg.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Eine 90-Tage-Aktivitäts-Heatmap&lt;/strong&gt; – im GitHub-Stil, mit fünf lila Intensitätsstufen.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Das Ganze besteht nur aus einem kleinen Satz von SQLite-Tabellen und ein paar React-Komponenten. Der interessante Teil ist aber nicht der Code – es sind die Designentscheidungen.&lt;/p&gt;
&lt;h2 id="idle-gap-sessions"&gt;Idle-Gap Sessions&lt;/h2&gt;
&lt;p&gt;Die schwerste Frage klang eigentlich am einfachsten: &lt;strong&gt;Was zählt als eine Lern-Session?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Die naive Antwort ist: &amp;ldquo;Alles, was zwischen dem Öffnen und Schließen der App passiert.&amp;rdquo; Aber das ist falsch. Leute lassen Tabs offen. Leute gehen für eine Stunde weg und kommen dann wieder. Leute öffnen die App um 9 Uhr morgens, machen nichts und schauen um 14 Uhr wieder rein.&lt;/p&gt;
&lt;p&gt;Die Antwort, bei der ich gelandet bin: Eine Session endet, wenn du &lt;strong&gt;15 Minuten lang inaktiv&lt;/strong&gt; warst. Stellst du eine Frage und bist dann 16 Minuten inaktiv – das ist eine Session. Kommst du zurück und stellst eine weitere Frage – beginnt eine neue. Der Schwellenwert ist über &lt;code&gt;STUDY_SESSION_IDLE_GAP_SECONDS=900&lt;/code&gt; konfigurierbar.&lt;/p&gt;
&lt;p&gt;Die Uhr richtet sich nach den &lt;strong&gt;Chat-Nachrichten&lt;/strong&gt; – dem konversationellen Kern des Lernens in CogniVault. Jede Nachricht verlängert entweder die offene Session (indem sie den &lt;code&gt;ended_at&lt;/code&gt;-Zeitstempel und die Nachrichtenanzahl erhöht) oder, falls die Pause seit der letzten Aktivität den Schwellenwert überschreitet, schließt sie diese implizit ab und eröffnet eine neue:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Simplified from backend/services/progress_tracker.py&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;record_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;idle_gap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;last&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;most_recent_session&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;last&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;last&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ended_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;idle_gap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;extend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;last&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ended_at&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# same session continues&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;open_session&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;started_at&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ended_at&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# new session begins&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Zwei Schreibvorgänge pro Nachricht. Die Dauer einer Session ist &lt;code&gt;ended_at - started_at&lt;/code&gt;, was bedeutet, dass &amp;ldquo;Gesamtzeit&amp;rdquo; die &lt;em&gt;aktive&lt;/em&gt; Zeit widerspiegelt, nicht &amp;ldquo;hatte einen Tab offen&amp;rdquo;. Das ist die einzige Zahl, die wirklich etwas aussagt. (Aktionen im Study Hub – Quizversuche, Karteikarten umdrehen, Mindmap-Exporte – werden als eigene Events erfasst und fließen in die Badge-Metriken unten ein; die Session-Uhr selbst bleibt nachrichtengetrieben und ehrlich.)&lt;/p&gt;
&lt;h2 id="25-badges-nicht-250"&gt;25 Badges, nicht 250&lt;/h2&gt;
&lt;p&gt;Die meisten gamifizierten Apps überfluten dich regelrecht mit Erfolgen. Es gibt einen Grund dafür: mehr Badges, mehr Dopamin, mehr täglich aktive Nutzer. Der Preis dafür ist, dass jedes Badge weniger bedeutet – irgendwann wird die ganze Schicht nur noch zur Tapete.&lt;/p&gt;
&lt;p&gt;Ich habe CogniVault auf &lt;strong&gt;25&lt;/strong&gt; limitiert, aufgeteilt auf die fünf Aktivitätsbereiche:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;10 für &lt;strong&gt;Chat &amp;amp; Lerngewohnheiten&lt;/strong&gt; (erste Frage, 10 Nachrichten an einem Tag, 100 insgesamt, eine Stunde Gesamtlernzeit, 3- und 7-Tage-Streaks, eine 30-minütige Deep-Dive-Session, Night-Owl- und Early-Bird-Sessions, erste Nutzung des Scope-Filters)&lt;/li&gt;
&lt;li&gt;4 für &lt;strong&gt;Quizzes&lt;/strong&gt; (erstes Quiz, perfektes Ergebnis, Bestehen auf fortgeschrittenem Schwierigkeitsgrad, 10 Quizzes)&lt;/li&gt;
&lt;li&gt;4 für &lt;strong&gt;Workshops&lt;/strong&gt; (erste Outline, erste abgeschlossene Lektion, erster abgeschlossener Workshop, 5 abgeschlossen)&lt;/li&gt;
&lt;li&gt;4 für &lt;strong&gt;Flashcards&lt;/strong&gt; (erstes Deck, 50 umgedrehte Karten, ein Deck komplett gemeistert, 5 Decks)&lt;/li&gt;
&lt;li&gt;3 für &lt;strong&gt;Mindmaps&lt;/strong&gt; (erste Mindmap, erster Export, 5 Mindmaps)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Jedes Badge hat ein einzeiliges Freischaltkriterium, das bei relevanten Events automatisch ausgewertet wird. Nichts Manuelles, nichts, was der Nutzer &amp;ldquo;einfordern&amp;rdquo; muss. Sie tauchen einfach auf.&lt;/p&gt;
&lt;p&gt;Und die Definitionen sind gar kein Code – sie sind &lt;strong&gt;Daten&lt;/strong&gt;. Alle 25 leben in einer JSON-Datei. Jeder Eintrag benennt die Metrik, die er beobachtet, und das zu erreichende Ziel:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-json" data-lang="json"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;code&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;card_reviewer&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;name&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;Card Reviewer&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;icon&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;🃏&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;metric&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;total_card_flips&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;target&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Ein einzelner Evaluator liest die aktuellen Statistiken aus, vergleicht jede Definition mit ihrer Metrik, gleicht sie mit bereits verdienten Badges ab und fügt neue Freischaltungen in die &lt;code&gt;progress.db&lt;/code&gt; ein. Ein 26. Badge hinzuzufügen, bedeutet, einen JSON-Eintrag hinzuzufügen, nicht neue Logik zu schreiben. Mehrere Badges bilden Leitern – jedes weiß, welches Badge das &amp;ldquo;nächste Level&amp;rdquo; ist, was den Schubs in der Detailansicht zum nächsten Ziel antreibt.&lt;/p&gt;
&lt;h2 id="die-heatmap"&gt;Die Heatmap&lt;/h2&gt;
&lt;p&gt;Auf die 90-Tage-Heatmap bin ich am meisten stolz, und sie ist gleichzeitig das Einfachste. Es ist ein 13×7-Raster aus Zellen, eine pro Tag, farblich markiert nach der Gesamtlernzeit an diesem Tag.&lt;/p&gt;
&lt;p&gt;Fünf Intensitätsstufen:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;level 0 — no activity
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;level 1 — under 15 minutes (a quick check-in)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;level 2 — 15-60 minutes (a focused session)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;level 3 — 1-3 hours (substantial study)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;level 4 — 3+ hours (a marathon)
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Die Daten sind konzeptionell eine einzige Aggregation über die Sessions-Tabelle:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;started_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;day&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ended_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;started_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;seconds&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;study_sessions&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;started_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;now&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;-90 days&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;GROUP&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;BY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;day&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Das Backend füllt die fehlenden Tage mit Nullen auf, sodass das Frontend immer genau 90 Einträge erhält. Eine kleine clientseitige Funktion ordnet die Tagessummen in die fünf Level ein. Wenn du auf eine beliebige Zelle klickst, öffnet sich ein &lt;code&gt;DayDetailModal&lt;/code&gt; mit den Zahlen dieses Tages – Lernzeit, Sessions, Nachrichten – sowie allen Badges, die an diesem Tag verdient wurden.&lt;/p&gt;
&lt;p&gt;Der Grund, warum ich diese Komponente liebe: Sie macht die &lt;em&gt;Textur&lt;/em&gt; einer Lerngewohnheit sichtbar. Streaks sind toll, aber ein Streak ist nur eine Zahl. Eine Heatmap zeigt dir, dass du am Wochenende härter lernst, oder dass du den ganzen Monat über langsam abgebaut hast, oder dass die Lücke zwischen deinem letzten &amp;ldquo;Level 4-Tag&amp;rdquo; und heute größer ist, als du dachtest. Sie spiegelt etwas wider, worauf der Nutzer reagieren kann.&lt;/p&gt;
&lt;h2 id="was-ich-bewusst-weggelassen-habe"&gt;Was ich bewusst weggelassen habe&lt;/h2&gt;
&lt;p&gt;Drei Dinge, die du in den meisten gamifizierten Apps finden würdest, fehlen in CogniVault absichtlich:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Streak-Panik.&lt;/strong&gt; Kein &amp;ldquo;Dein Streak ist in Gefahr!&amp;quot;-Popup. Keine Regeln für Streak-Freezes. Keine gelben Ausrufezeichen. Der Streak wird angezeigt – das ist der gesamte Feedback-Loop. Wenn ein Nutzer seinen Streak bricht, dann bricht er ihn eben. Erwachsene brauchen keine Shaming-UX.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Leaderboards.&lt;/strong&gt; Das ist eine lokale Einzelnutzer-App. Es gibt keinen globalen Vergleich. (Und den sollte es auch nicht geben – Leaderboards optimieren beim Lernen das Falsche.)&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Konfetti, Fanfaren, Push-Benachrichtigungen.&lt;/strong&gt; Ein neu verdientes Badge taucht auf dem Quiz-Ergebnisbildschirm und im Dashboard-Raster auf. Das ist die ganze Feier. Alles, was größer ist, stiehlt dem Nutzer die Aufmerksamkeit zum Nutzen der App, nicht zu seinem eigenen.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Das allgemeine Prinzip: &lt;strong&gt;Miss, was wichtig ist, mach es sichtbar, aber ohne zu nerven.&lt;/strong&gt; Registriere, dass du wiedergekommen bist. Spiegele dir das wider. Tu nicht so, als ob es dich mehr interessiert, als es das tatsächlich tut.&lt;/p&gt;
&lt;h2 id="was-das-dashboard-nicht-zu-optimieren-versucht"&gt;Was das Dashboard &lt;em&gt;nicht&lt;/em&gt; zu optimieren versucht&lt;/h2&gt;
&lt;p&gt;Eine häufige Falle bei diesen Dashboards ist die umgekehrte Kausalität: Der Nutzer fängt an, die Metrik zu spielen, anstatt die eigentliche Sache zu tun. Ein täglicher Fragenzähler zum Beispiel führt dazu, dass Nutzer eine irrelevante Frage pro Tag stellen, um ihren Streak am Leben zu halten.&lt;/p&gt;
&lt;p&gt;Deshalb ist die Hürde absichtlich an genau einer Stelle sehr niedrig und überall sonst hoch. Es gibt &lt;em&gt;ein&lt;/em&gt; Badge, das keinen Aufwand erfordert – &amp;ldquo;Erste Frage&amp;rdquo;, das man für die allererste Nachricht bekommt –, weil jedes Spiel eine Einstiegsrampe braucht, die beweist, dass das System funktioniert. Danach werden die Metriken schwer zu manipulieren, ohne die eigentliche Arbeit zu tun:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Gesamtlernzeit&lt;/strong&gt; – sammelt sich nur während aktiver Nutzung an, mit Idle-Gap-Abschaltungen.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Sessions&lt;/strong&gt; – um mehr hinzuzufügen, muss man tatsächlich separate Arbeitsphasen starten.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Badges&lt;/strong&gt; – fast alle erfordern Tiefe (100 Nachrichten, ein Quiz meistern, ein Deck perfekt beherrschen, 5 Workshops abschließen), nicht nur oberflächliches Antippen.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Heatmap-Intensität&lt;/strong&gt; – erfordert anhaltendes Engagement an einem bestimmten Tag.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="implementierung-bewusst-klein"&gt;Implementierung: bewusst klein&lt;/h2&gt;
&lt;p&gt;Der Gamification-Kern besteht aus drei SQLite-Tabellen –&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;study_sessions&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;started_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ended_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;message_count&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;message_events&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;sent_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;session_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;had_scope_filter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;had_attachments&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;achievements_earned&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;earned_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;— plus die JSON-Badge-Definitionen, ein Evaluator-Modul und eine Handvoll React-Komponenten (&lt;code&gt;SummaryCards&lt;/code&gt;, &lt;code&gt;AchievementGrid&lt;/code&gt;, &lt;code&gt;ActivityHeatmap&lt;/code&gt;, &lt;code&gt;DayDetailModal&lt;/code&gt;). Die gleiche &lt;code&gt;progress.db&lt;/code&gt;-Datei hat mittlerweile weitere Tabellen für die gespeicherten Quizzes, Workshops, Decks und Mindmaps des Study Hubs bekommen – aber die Badge-und-Session-Maschinerie selbst ist nur ein paar hundert Zeilen lang geblieben.&lt;/p&gt;
&lt;p&gt;Daran ist nichts Ausgefallenes. Das Dashboard funktioniert, weil die &lt;em&gt;Designentscheidungen&lt;/em&gt; richtig sind, nicht weil die Implementierung raffiniert ist.&lt;/p&gt;
&lt;h2 id="fazit"&gt;Fazit&lt;/h2&gt;
&lt;p&gt;Wenn du ein Lern-Tool baust – oder ein beliebiges Tool, das von den Gewohnheiten der Nutzer lebt –, dann setze Gamification &lt;em&gt;bewusst&lt;/em&gt; ein. Wähle die Metriken, die das widerspiegeln, was du wirklich fördern willst. Begrenze die Anzahl der Erfolge. Verzichte auf die Streak-Panik-UX. Mach die Textur der Nutzung sichtbar, ohne dass die App verzweifelt wirkt.&lt;/p&gt;
&lt;p&gt;Oder direkter gesagt: Bau kein Duolingo. Bau ein Dashboard, auf das der Nutzer ab und zu schaut und das er dann wieder schließt, mit dem leichten Gefühl, weitermachen zu wollen. Das ist der ganze Job.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="anhang-abkürzungen-in-diesem-beitrag"&gt;Anhang: Abkürzungen in diesem Beitrag&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Abkürzung&lt;/th&gt;
&lt;th&gt;Vollform&lt;/th&gt;
&lt;th&gt;Bedeutung&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;UX&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;User Experience&lt;/td&gt;
&lt;td&gt;Wie sich das Produkt anfühlt – genau das, was durch Streak-Panik-Mechaniken geopfert wird&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ICT&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Information and Communications Technology&lt;/td&gt;
&lt;td&gt;Das Fach, das ich acht Jahre lang unterrichtet habe, bevor ich zum Full-Stack gewechselt bin&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SQLite&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;(SQL = Structured Query Language)&lt;/td&gt;
&lt;td&gt;Eine komplette relationale Datenbank in einer einzigen Datei, &lt;code&gt;progress.db&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;JSON&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;JavaScript Object Notation&lt;/td&gt;
&lt;td&gt;Das Datenformat, in dem die 25 Badge-Definitionen liegen&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;UI&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;User Interface&lt;/td&gt;
&lt;td&gt;Die Dashboard-Oberfläche: Statistiken, Raster, Heatmap&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Als Nächstes:&lt;/strong&gt;
.&lt;/p&gt;</description></item><item><title>Teil 6 · Der Mindmap-Renderer: Was mich das händische Bauen von SVG gelehrt hat (und warum v2 React Flow nutzt)</title><link>https://aretascodes.dev/de/blog/svg-mindmap-from-scratch/</link><pubDate>Fri, 15 May 2026 00:00:00 +0000</pubDate><guid>https://aretascodes.dev/de/blog/svg-mindmap-from-scratch/</guid><description>
&lt;blockquote class="border-l-4 border-neutral-300 dark:border-neutral-600 pl-4 italic text-neutral-600 dark:text-neutral-400 my-6"&gt;
&lt;p&gt;Teil einer Serie über die Entwicklung von
. Zuvor:
.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote class="border-l-4 border-neutral-300 dark:border-neutral-600 pl-4 italic text-neutral-600 dark:text-neutral-400 my-6"&gt;
&lt;p&gt;Alle Abkürzungen werden im Anhang unten auf der Seite vollständig erklärt.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Der Study Hub von CogniVault hat vier Modi. Drei davon – Quiz, Workshop, Flashcards – haben eine Listenform. Der vierte, &lt;strong&gt;Mindmap&lt;/strong&gt;, nicht. Es ist ein Baum von Konzepten, die von einem zentralen Thema ausgehen, und ich wollte, dass er:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Optisch so sauber ist, dass der Nutzer ihn sich tatsächlich gerne ansieht.&lt;/li&gt;
&lt;li&gt;Interaktiv ist: Verschieben, Zoomen, Erkunden.&lt;/li&gt;
&lt;li&gt;Hochauflösend als PNG und PDF exportiert werden kann.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Dieser Beitrag ist die ehrliche Version davon, wie dieser Renderer gebaut wurde – &lt;strong&gt;und zwar zweimal&lt;/strong&gt;. Version eins war reines, händisch gebautes SVG ohne Graphen-Bibliothek, und sie wurde veröffentlicht. Version zwei, die heute in der Codebasis steht, baut auf &lt;code&gt;@xyflow/react&lt;/code&gt; (React Flow) und einem dagre Auto-Layout auf. Ich glaube, beide Entscheidungen waren &lt;em&gt;zu dem Zeitpunkt, als sie getroffen wurden&lt;/em&gt;, richtig. Und der Weg dazwischen hat mir mehr über &amp;ldquo;Build vs. Buy&amp;rdquo; (Selber bauen oder einkaufen) beigebracht als jede der beiden Versionen für sich genommen.&lt;/p&gt;
&lt;h2 id="runde-eins-händisch-bauen"&gt;Runde eins: Händisch bauen&lt;/h2&gt;
&lt;p&gt;Mein erster Instinkt war, wie bei jedem anderen auch, sofort an Tag eins zu einer Bibliothek zu greifen. Ich habe widerstanden, und zwar aus guten Gründen: Das Standard-Styling hätte sowieso komplett angepasst werden müssen, das gewünschte Layout war simpel, für den Export hätte man ohnehin externe Abhängigkeiten gebraucht, und die Bundle-Größe ist auch nicht zu verachten. Für die kleinen Bäume, die Gemma generiert, schien SVG völlig ausreichend – es lässt sich mit dem &lt;code&gt;viewBox&lt;/code&gt;-Attribut verschieben und zoomen, zeichnet beliebige Formen, lässt sich als String serialisieren und sauber rastern.&lt;/p&gt;
&lt;p&gt;Also war v1 reines SVG. Und der Kern davon war wirklich klein.&lt;/p&gt;
&lt;h3 id="radial-layout-in-40-zeilen"&gt;Radial-Layout in 40 Zeilen&lt;/h3&gt;
&lt;p&gt;Ein radiales Layout platziert die Wurzel in der Mitte und ordnet die Kinder in konzentrischen Ringen an:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-ts" data-lang="ts"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Node&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;: &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;label&lt;/span&gt;: &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;children&lt;/span&gt;: &lt;span class="kt"&gt;Node&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Placed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Node&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;: &lt;span class="kt"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;: &lt;span class="kt"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;angle&lt;/span&gt;: &lt;span class="kt"&gt;number&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nx"&gt;layout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;root&lt;/span&gt;: &lt;span class="kt"&gt;Node&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;radiusStep&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;180&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Placed&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;placed&lt;/span&gt;: &lt;span class="kt"&gt;Placed&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nx"&gt;place&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;node&lt;/span&gt;: &lt;span class="kt"&gt;Node&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;depth&lt;/span&gt;: &lt;span class="kt"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;fromAngle&lt;/span&gt;: &lt;span class="kt"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;toAngle&lt;/span&gt;: &lt;span class="kt"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;placed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;: &lt;span class="kt"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;: &lt;span class="kt"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;angle&lt;/span&gt;: &lt;span class="kt"&gt;0&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;angle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fromAngle&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;toAngle&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;placed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;x&lt;/span&gt;: &lt;span class="kt"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;radiusStep&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;angle&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;y&lt;/span&gt;: &lt;span class="kt"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;radiusStep&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;angle&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;angle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;slice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;toAngle&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;fromAngle&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;child&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;place&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;child&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;fromAngle&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;fromAngle&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;),&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;place&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PI&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;placed&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Jede Ebene erbt ein Winkelsegment von ihrem Elternteil und unterteilt es unter ihren Kindern. Verschieben (Pan) und Zoomen waren reine &lt;code&gt;viewBox&lt;/code&gt;-Arithmetik – keine Transformationsmatrizen, keine Event-Bibliothek, nur Zahlen. Kanten waren quadratische Bézier-Kurven, die in Richtung Zentrum gezogen wurden. Es sah gut aus, war schnell, und der gesamte Renderer passte bequem in eine einzige Komponente.&lt;/p&gt;
&lt;h3 id="der-export-trick-den-man-immer-noch-kennen-sollte"&gt;Der Export-Trick, den man immer noch kennen sollte&lt;/h3&gt;
&lt;p&gt;Um ein SVG ohne Abhängigkeiten als PNG zu exportieren, lässt man den Browser die ganze Arbeit machen:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;SVG DOM ─► XMLSerializer ─► string ─► &amp;lt;img&amp;gt; ─► &amp;lt;canvas&amp;gt; ─► PNG blob
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Serialisiere das SVG zu einem String, lade es in ein &lt;code&gt;Image&lt;/code&gt;, zeichne dieses Bild auf ein skaliertes &lt;code&gt;&amp;lt;canvas&amp;gt;&lt;/code&gt; und frag das Canvas nach einem PNG. Schriftarten, Anti-Aliasing, &lt;code&gt;currentColor&lt;/code&gt; – der Browser löst das alles nativ auf. Wenn deine Grafik &lt;em&gt;ein&lt;/em&gt; SVG-Element ist, ist das immer noch die sauberste Export-Pipeline, die es gibt, und ich würde sie ohne Zögern wieder verwenden.&lt;/p&gt;
&lt;p&gt;Ein Detail von v1 hat bis heute komplett unverändert überlebt: der Speichern-Flow. Anstatt des klassischen &amp;ldquo;Direkt in den Downloads-Ordner&amp;rdquo;-Erlebnisses laufen Exporte über die &lt;strong&gt;File System Access API&lt;/strong&gt; (&lt;code&gt;showSaveFilePicker&lt;/code&gt;), wo der Browser das unterstützt, mit einem Anker-Tag-Download-Fallback für Firefox und Safari. Ein echter &amp;ldquo;Speichern unter&amp;hellip;&amp;quot;-Dialog, ganz ohne Electron. Dieser Helfer (&lt;code&gt;lib/saveBlob.ts&lt;/code&gt;) bedient jetzt auch die Quiz- und Workshop-Exporte.&lt;/p&gt;
&lt;h2 id="die-anforderungen-die-v1-gebrochen-haben"&gt;Die Anforderungen, die v1 gebrochen haben&lt;/h2&gt;
&lt;p&gt;Dann traf das Feature auf seine Nutzer (naja – auf mich, als ich es beim Lernen ernsthaft benutzte), und es tauchten drei Anforderungen auf, mit denen die elegante, handgemachte Version nicht gut zurechtkam:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&amp;ldquo;Lass mich diesen Knoten bewegen.&amp;rdquo;&lt;/strong&gt; Ein generiertes Layout ist ein Startpunkt; eine &lt;em&gt;nützliche&lt;/em&gt; Mindmap ist eine, die du so anordnest, dass sie deiner Denkweise entspricht. Die Knoten von v1 waren an ihren berechneten Positionen fixiert. Drag-and-Drop hinzuzufügen hätte bedeutet, Hit-Testing, Drag-State und Positions-Persistenz von Grund auf neu zu bauen – genau die unglamouröse Interaktions-Maschinerie, für die Graphen-Bibliotheken überhaupt existieren.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Text wollte HTML sein.&lt;/strong&gt; SVG &lt;code&gt;&amp;lt;text&amp;gt;&lt;/code&gt; bricht nicht um. Lange Konzept-Labels erforderten manuelles Umbrechen, Ausmessen und Abschneiden von Zeilen – ein ständiger Kampf. HTML-Knoten (echte &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt;s mit CSS) übernehmen Zeilenumbrüche, Ellipsen und Theming umsonst.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Radial war doch nicht das beste Lese-Layout.&lt;/strong&gt; Für die breiten und flachen Bäume, die Gemma tatsächlich generiert, liest sich ein Links-nach-Rechts- oder Von-Oben-nach-Unten-Baum (die Art, die eine Layout-Engine wie &lt;strong&gt;dagre&lt;/strong&gt; berechnet) besser als Ringe. Und als Layouts umschaltbar wurden, wurde &amp;ldquo;Auto-Layout plus gemerkte manuelle Anpassungen&amp;rdquo; das natürliche Modell.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Ich hätte all das auf der SVG-Basis bauen können. Aber schau dir die Liste an: Viewport-Management, Drag-and-Drop für Knoten, HTML-Knoten in einem Graphen-Canvas, austauschbare Layouts. Das ist &lt;em&gt;exakt&lt;/em&gt; das Feature-Set von React Flow. In v1 wäre die Bibliothek nur ein Wrapper um Dinge gewesen, die ich nicht brauchte. Für v2 waren meine Anforderungen genau zu den Dingen herangewachsen, die sie gut kann.&lt;/p&gt;
&lt;p&gt;Also habe ich meine Meinung geändert.&lt;/p&gt;
&lt;h2 id="runde-zwei-react-flow--dagre"&gt;Runde zwei: React Flow + dagre&lt;/h2&gt;
&lt;p&gt;Der heutige Renderer (&lt;code&gt;frontend/src/components/study/mindmaps/&lt;/code&gt;):&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;@xyflow/react&lt;/code&gt; (React Flow)&lt;/strong&gt; liefert das Canvas: natives Panning/Zoomen, ziehbare Knoten, einen Steuerungsblock im Minimap-Stil und Dark-Mode-Unterstützung via &lt;code&gt;colorMode&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;dagre&lt;/strong&gt; berechnet das automatische Layout, mit einem sichtbaren Schalter zwischen Links-nach-Rechts und Von-Oben-nach-Unten. Die Baum-zu-Graph-Konvertierung ist eine winzige Pure Function.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Benutzerdefinierte HTML-Knoten&lt;/strong&gt; tragen das Design-System: Die Wurzel bekommt einen Verlauf, Themen eine Tönung, Blätter bleiben dezent – und Text bricht um, wie Text umbrechen sollte.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Gezogene Positionen bleiben erhalten.&lt;/strong&gt; Das Bewegen eines Knotens löst einen Speicher-Vorgang aus; öffnest du die Map neu, wird deine Anordnung wiederhergestellt. Ein &amp;ldquo;Reset layout&amp;rdquo;-Button löscht die gespeicherten Positionen und kehrt zum dagre-Auto-Layout zurück. Die Layout-Wahl und die Positionen leben zusammen mit der Mindmap in SQLite.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Der Export wurde an die neue Realität angepasst.&lt;/strong&gt; Die Knoten sind jetzt HTML, also funktioniert der SVG-Serialisierungs-Trick aus v1 nicht mehr. Der PNG-Export nutzt &lt;code&gt;html-to-image&lt;/code&gt; über den React-Flow-Viewport, passgenau auf die Knotengrenzen zugeschnitten, unabhängig vom aktuellen Zoom. Das PDF bettet dieses PNG über ein lazy-geladenes &lt;code&gt;jsPDF&lt;/code&gt; ein. Der Markdown-Export ist ein rekursiver Durchlauf des Baums ohne externe Abhängigkeiten. Ja – v2 nutzt genau die Bibliothek (&lt;code&gt;html-to-image&lt;/code&gt;), auf deren Vermeidung ich in v1 noch stolz war. Die Anforderungen haben sich geändert; und damit auch der Trade-off.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="was-mich-die-reise-wirklich-gelehrt-hat"&gt;Was mich die Reise wirklich gelehrt hat&lt;/h2&gt;
&lt;p&gt;Ich habe hin- und herüberlegt, wie ich diesen Beitrag schreiben soll, denn die Geschichte von v1 (&amp;ldquo;Schau mal, wie wenig Code man braucht!&amp;rdquo;) ist schmeichelhafter. Aber die Wahrheit aus beiden Versionen ist die viel nützlichere Lektion:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Zuerst selber bauen war trotzdem richtig.&lt;/strong&gt; v1 war an einem Wochenende fertig, hat mir die wahre Form des Problems gezeigt (Layout, Viewport und Export sind getrennte Baustellen) und kostete mich nichts, es wegzuwerfen, weil es so klein war. Hätte ich mit React Flow angefangen, hätte ich eine Bibliothek konfiguriert, bevor ich das Problem überhaupt verstanden hatte.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Bibliotheken verdienen sich ihren Platz erst, wenn deine Anforderungen auf ihr Feature-Set zulaufen – nicht vorher.&lt;/strong&gt; In dem Moment, als &amp;ldquo;Zieh Knoten und merk dir, wo ich sie hingelegt habe&amp;rdquo; zur Anforderung wurde, drehte sich die Build-vs-Buy-Rechnung komplett um.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Einige Teile überleben das Rewrite.&lt;/strong&gt; Der Speicher-Dialog-Helfer, der Markdown-Durchlauf, der Instinkt, Exporte auf die Inhaltsgrenzen zuzuschneiden – all das wurde übernommen. Rewrites sind selten total.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Die nativen Pipelines des Browsers sollte man kennen, auch wenn man sie am Ende nicht nutzt.&lt;/strong&gt; SVG → Canvas → PNG ist immer noch der beste Export-Trick ohne externe Abhängigkeiten in der Frontend-Entwicklung. Er funktioniert nur nicht mehr an dem Tag, an dem deine Knoten HTML werden.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id="fazit"&gt;Fazit&lt;/h2&gt;
&lt;p&gt;&amp;ldquo;Build or buy&amp;rdquo; (Selber bauen oder kaufen) ist eine Funktion von Anforderungen – und Anforderungen bewegen sich. Bau selbst, solange das Problem klein ist und du noch lernst, wie es aussieht. Kauf ein, wenn deine Feature-Liste anfängt, sich wie die README der Bibliothek zu lesen. Und wenn du wechselst, schreib auf, warum, damit die nächste Person (oder dein zukünftiges Ich) weiß, dass es keine Unentschlossenheit war. Es war einfach der Plan, der erwachsen geworden ist.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="anhang-abkürzungen-in-diesem-beitrag"&gt;Anhang: Abkürzungen in diesem Beitrag&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Abkürzung&lt;/th&gt;
&lt;th&gt;Vollform&lt;/th&gt;
&lt;th&gt;Bedeutung&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SVG&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Scalable Vector Graphics&lt;/td&gt;
&lt;td&gt;Das eingebaute Vektor-Zeichenformat des Browsers – das komplette Fundament von v1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;PNG&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Portable Network Graphics&lt;/td&gt;
&lt;td&gt;Das Rasterbildformat, das bei Exporten herauskommt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;PDF&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Portable Document Format&lt;/td&gt;
&lt;td&gt;Der druckfertige Export, gebaut durch Einbetten des PNGs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;DOM&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Document Object Model&lt;/td&gt;
&lt;td&gt;Die Live-Repräsentation der Seite im Browser – das, was &lt;code&gt;html-to-image&lt;/code&gt; in v2 rastert&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;HTML / CSS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;HyperText Markup Language / Cascading Style Sheets&lt;/td&gt;
&lt;td&gt;Woraus die Knoten in v2 bestehen – und warum ihr Text automatisch umbricht&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;API&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Application Programming Interface&lt;/td&gt;
&lt;td&gt;Wie in File System Access API, die echte &amp;ldquo;Speichern unter&amp;hellip;&amp;quot;-Dialoge bereitstellt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;UI / UX&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;User Interface / User Experience&lt;/td&gt;
&lt;td&gt;Die Anforderung, einen Knoten ziehen zu können, die das Rewrite ausgelöst hat&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;JSON&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;JavaScript Object Notation&lt;/td&gt;
&lt;td&gt;Die Baumstruktur, die Gemma für jede Mindmap generiert (siehe vorheriger Beitrag)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SQLite&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;(SQL = Structured Query Language)&lt;/td&gt;
&lt;td&gt;Die Single-File-Datenbank, in der Layout-Entscheidungen und Knotenpositionen persistieren&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Als Nächstes:&lt;/strong&gt;
.&lt;/p&gt;</description></item><item><title>Teil 5 · Zuverlässiges JSON aus einem lokalen LLM bekommen</title><link>https://aretascodes.dev/de/blog/reliable-json-local-llm/</link><pubDate>Sun, 10 May 2026 00:00:00 +0000</pubDate><guid>https://aretascodes.dev/de/blog/reliable-json-local-llm/</guid><description>
&lt;blockquote class="border-l-4 border-neutral-300 dark:border-neutral-600 pl-4 italic text-neutral-600 dark:text-neutral-400 my-6"&gt;
&lt;p&gt;Teil einer Serie über die Entwicklung von
. Zuvor:
.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote class="border-l-4 border-neutral-300 dark:border-neutral-600 pl-4 italic text-neutral-600 dark:text-neutral-400 my-6"&gt;
&lt;p&gt;Alle Abkürzungen werden im Anhang unten auf der Seite vollständig erklärt.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Der Study Hub von CogniVault generiert aus deinen Dokumenten vier Arten von strukturierten Artefakten: Quizzes, mehrteilige Workshops, Flashcard-Decks und Mindmaps. Alle vier benötigen ein Modell, das strukturiertes JSON zurückgibt, keinen Fließtext. Alle vier laufen auf Gemma 4, das lokal über Ollama ausgeführt wird. Und alle vier würden viel zu oft scheitern, wenn ich darauf vertrauen würde, dass das Modell &amp;ldquo;einfach JSON zurückgibt&amp;rdquo;.&lt;/p&gt;
&lt;p&gt;Hier ist das defensive Muster, das diese Ausfallrate auf nahe null drückt – und was man mit den Fällen macht, die trotzdem noch durchrutschen.&lt;/p&gt;
&lt;h2 id="das-muster"&gt;Das Muster&lt;/h2&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-gdscript3" data-lang="gdscript3"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="mf"&gt;1.&lt;/span&gt; &lt;span class="n"&gt;Retrieve&lt;/span&gt; &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="n"&gt;hybrid&lt;/span&gt; &lt;span class="n"&gt;search&lt;/span&gt; &lt;span class="n"&gt;restricted&lt;/span&gt; &lt;span class="n"&gt;by&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;selected&lt;/span&gt; &lt;span class="n"&gt;scope&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="mf"&gt;2.&lt;/span&gt; &lt;span class="n"&gt;Prompt&lt;/span&gt; &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="n"&gt;strict&lt;/span&gt; &lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;by&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;example&lt;/span&gt; &lt;span class="n"&gt;with&lt;/span&gt; &lt;span class="n"&gt;explicit&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;shape&lt;/span&gt; &lt;span class="n"&gt;rules&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="mf"&gt;3.&lt;/span&gt; &lt;span class="n"&gt;Generate&lt;/span&gt; &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="n"&gt;ollama&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chat&lt;/span&gt; &lt;span class="n"&gt;with&lt;/span&gt; &lt;span class="n"&gt;format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;json&amp;#34;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;grammar&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;constrained&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="mf"&gt;4.&lt;/span&gt; &lt;span class="n"&gt;Parse&lt;/span&gt; &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tolerant&lt;/span&gt; &lt;span class="n"&gt;of&lt;/span&gt; &lt;span class="n"&gt;object&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;array&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;fenced&lt;/span&gt; &lt;span class="n"&gt;shapes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;with&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="n"&gt;trailing&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;comma&lt;/span&gt; &lt;span class="n"&gt;repair&lt;/span&gt; &lt;span class="k"&gt;pass&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="mf"&gt;5.&lt;/span&gt; &lt;span class="n"&gt;Validate&lt;/span&gt; &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="n"&gt;drop&lt;/span&gt; &lt;span class="n"&gt;malformed&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="n"&gt;rather&lt;/span&gt; &lt;span class="n"&gt;than&lt;/span&gt; &lt;span class="n"&gt;fail&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;whole&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="mf"&gt;6.&lt;/span&gt; &lt;span class="n"&gt;Retry&lt;/span&gt; &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;workshop&lt;/span&gt; &lt;span class="n"&gt;outline&lt;/span&gt; &lt;span class="n"&gt;retries&lt;/span&gt; &lt;span class="n"&gt;once&lt;/span&gt; &lt;span class="n"&gt;with&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="n"&gt;stronger&lt;/span&gt; &lt;span class="n"&gt;prompt&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="mf"&gt;7.&lt;/span&gt; &lt;span class="n"&gt;Persist&lt;/span&gt; &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="n"&gt;SQLite&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;progress&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;so&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="n"&gt;can&lt;/span&gt; &lt;span class="n"&gt;come&lt;/span&gt; &lt;span class="n"&gt;back&lt;/span&gt; &lt;span class="n"&gt;later&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Jeder Generator in CogniVault folgt diesem Ablauf. Die interessanten Schritte sind 2, 4 und 5.&lt;/p&gt;
&lt;h2 id="schritt-3-formatjson-leistet-echte-arbeit"&gt;Schritt 3: &lt;code&gt;format=&amp;quot;json&amp;quot;&lt;/code&gt; leistet echte Arbeit&lt;/h2&gt;
&lt;p&gt;Ollama bietet eine &lt;code&gt;format=&amp;quot;json&amp;quot;&lt;/code&gt;-Option, die dem Modell während des Samplings einen &lt;strong&gt;Grammatik-Constraint&lt;/strong&gt; (Einschränkung) auferlegt. Der Decoder gibt keine Tokens aus, die die Ausgabe zu ungültigem JSON machen würden. Das ist nicht perfekt – Schemata umfassen mehr als nur &amp;ldquo;gültiges JSON&amp;rdquo;, und das Modell kann immer noch wohlgeformten Müll produzieren – aber es eliminiert die gesamte Klasse von &amp;ldquo;Das Modell hat angefangen, Text vor der schließenden Klammer zu schreiben&amp;rdquo;-Fehlern.&lt;/p&gt;
&lt;p&gt;Wenn dein lokaler LLM-Stack eine Grammatik-Option unterstützt (Ollama, llama.cpp, vLLM usw.), schalte sie ein. Sie ist nicht umsonst (Sampling wird etwas langsamer), aber die Verbesserung bei den Fehlerarten ist enorm. Ohne sie wirst du dein gesamtes Fehlerbudget für abgeschnittene Objekte ausgeben.&lt;/p&gt;
&lt;h2 id="schritt-2-ein-schema-im-prompt-an-das-sich-das-modell-auch-halten-kann"&gt;Schritt 2: Ein Schema-im-Prompt, an das sich das Modell auch halten kann&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;format=&amp;quot;json&amp;quot;&lt;/code&gt; garantiert, dass die &lt;em&gt;Struktur&lt;/em&gt; der Ausgabe JSON ist. Es sagt nichts darüber aus, ob das JSON auch zu deinem Domain-Schema passt. Das ist die Aufgabe des Prompts.&lt;/p&gt;
&lt;p&gt;Das Muster, das für mich funktioniert: Anstatt ein formales JSON-Schema reinzuwerfen und zu sagen &amp;ldquo;halte dich daran&amp;rdquo;, baue ein &lt;strong&gt;ausgefülltes Beispiel&lt;/strong&gt; ein, das dem Modell die exakte Form und explizite Mengenangaben zeigt. Hier ist das Herzstück des echten Quiz-Templates von CogniVault (es liegt als bearbeitbare Markdown-Datei in &lt;code&gt;backend/prompts/quiz.md&lt;/code&gt;):&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Output ONLY a single JSON object — no prose, no markdown fences,
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;no text outside the JSON.
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;NUMBER OF QUESTIONS: EXACTLY $num_questions. This is a hard requirement.
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;OUTPUT SCHEMA:
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;{
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &amp;#34;questions&amp;#34;: [
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &amp;#34;type&amp;#34;: one of [$types_csv],
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &amp;#34;question&amp;#34;: the question text (string, no leading numbering),
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &amp;#34;options&amp;#34;: array of strings (length 4 for mcq, length 2 for true_false),
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &amp;#34;correct_index&amp;#34;: integer index into options (0-based),
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &amp;#34;explanation&amp;#34;: 1-2 sentence explanation of the correct answer
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; },
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ... exactly $num_questions entries
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ]
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Ein paar Entscheidungen, die wichtig sind:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Zeig die Form, beschreibe sie nicht.&lt;/strong&gt; &amp;ldquo;Jedes Item hat ein &lt;code&gt;type&lt;/code&gt;-Feld&amp;rdquo; wird viel öfter ignoriert als ein wörtliches Beispiel.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Lege die Anzahl fest.&lt;/strong&gt; &amp;ldquo;EXACTLY 10&amp;rdquo; – wiederholt, in Großbuchstaben, als harte Anforderung – ist viel zuverlässiger als &amp;ldquo;ungefähr 10&amp;rdquo;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Verwende Indizes, keine Wiederholungen.&lt;/strong&gt; Die richtige Antwort ist &lt;code&gt;correct_index&lt;/code&gt;, ein Integer, der auf die &lt;code&gt;options&lt;/code&gt; verweist – und nicht noch einmal der Antworttext. Wiederholter Text lädt zu Paraphrasierungen ein (&amp;ldquo;Paris&amp;rdquo; vs. &amp;ldquo;Paris, Frankreich&amp;rdquo;), und dann geht dein Grading-Vergleich kaputt.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Ein Artefakt pro Aufruf.&lt;/strong&gt; Ich habe versucht, einen kompletten Workshop (Outline + jede Lektion) in einem Aufruf zu generieren. Die Qualität des Modells nimmt rapide ab, je länger die Antwort wird. Die Aufteilung in &amp;ldquo;Outline zuerst, Lektionen nach Bedarf&amp;rdquo; ist die weiter unten beschriebene Zwei-Phasen-Strategie.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="schritt-4-tolerant-parsen"&gt;Schritt 4: Tolerant parsen&lt;/h2&gt;
&lt;p&gt;Selbst mit &lt;code&gt;format=&amp;quot;json&amp;quot;&lt;/code&gt; überleben in der Praxis zwei Parsing-Probleme.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Die Struktur-Überraschung.&lt;/strong&gt; Das hier hat mich in der Produktion erwischt: Ich war davon ausgegangen, dass das Modell ein reines JSON-Array von Fragen zurückgeben würde. Mit &lt;code&gt;format=&amp;quot;json&amp;quot;&lt;/code&gt; gibt Gemma aber beständig ein &lt;strong&gt;Objekt&lt;/strong&gt; zurück – &lt;code&gt;{&amp;quot;questions&amp;quot;: [...]}&lt;/code&gt; – und eine Zeit lang hat mein Parser nur das Array akzeptiert. Das Ergebnis: Ein 502-Fehler bei jeder Quiz-Generierung, bis ich es gefunden hatte. Die Lösung ist ein Parser, der dem Modell entgegenkommt:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Simplified from backend/services/quiz_generator.py&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;extract_items&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;candidate&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;extract_json_object&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;extract_json_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;candidate&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;continue&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;load_json_lenient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nb"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="c1"&gt;# bare array&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nb"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;questions&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# the expected object shape&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nb"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;strong&gt;Lexikalische Ausrutscher.&lt;/strong&gt; Manchmal rutscht ein nachgestelltes Komma durch. Die Reparatur ist absichtlich eng gefasst – ein Regex-Durchlauf, danach aufgeben:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;load_json_lenient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JSONDecodeError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;repaired&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;,(\s*[\]}])&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;\1&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# strip trailing commas&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;repaired&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JSONDecodeError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Ich versuche nicht, Klammern auszugleichen, abgeschnittene Strings zu vervollständigen oder fehlende Felder zu erraten. Entweder ist die Ausgabe mit einem Trailing-Comma-Pass und etwas Substring-Extraktion reparierbar, oder eben nicht – und dann gehen wir zu Schritt 5.&lt;/p&gt;
&lt;h2 id="schritt-5-fehlerhafte-items-verwerfen-nicht-den-ganzen-batch-scheitern-lassen"&gt;Schritt 5: Fehlerhafte Items verwerfen, nicht den ganzen Batch scheitern lassen&lt;/h2&gt;
&lt;p&gt;Das war die Entscheidung, mit der ich am längsten zu kämpfen hatte, bis ich meinen Frieden damit gemacht habe.&lt;/p&gt;
&lt;p&gt;Wenn das Modell 10 Quizfragen zurückgibt, aber bei Nummer 7 das &lt;code&gt;options&lt;/code&gt;-Feld fehlt, ist die Versuchung groß, einen Fehler auszuwerfen und den ganzen Batch neu zu generieren. &lt;em&gt;Tu das nicht&lt;/em&gt;. Validiere jedes Item einzeln und verwirf diejenigen, die fehlschlagen.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# CogniVault does this with explicit field checks into a dataclass;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# pydantic works just as well.&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;questions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;raw_item&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;parsed_items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;validate_item&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw_item&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;allowed_types&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# returns None if malformed&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;questions&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Der Nutzer bekommt 9 Fragen statt 10. Das fällt ihm nicht auf. Die gesamte Generierung neu zu starten, um Frage 7 zu reparieren, kostet 30 Sekunden und führt vielleicht zu &lt;em&gt;neuen&lt;/em&gt; Fehlern in den Fragen 1-6. Der &amp;ldquo;Dropped-Item&amp;rdquo;-Ansatz ist für die UX einfach streng genommen besser. (Das Modell schießt übrigens auch manchmal über das Ziel hinaus – die validierte Liste wird dann einfach auf die angeforderte Menge gekürzt.)&lt;/p&gt;
&lt;h2 id="schritt-6-die-outline-darf-es-einmal-neu-versuchen"&gt;Schritt 6: Die Outline darf es einmal neu versuchen&lt;/h2&gt;
&lt;p&gt;Workshops sind die Ausnahme, die die Regel bestätigt. Ein Workshop ist eine strukturierte Outline (Titel, Zusammenfassung, Lektionsliste) plus der Inhalt jeder Lektion. Die Outline &lt;em&gt;muss&lt;/em&gt; parsbar sein – bei einem Inhaltsverzeichnis gibt es keinen Teilerfolg. Deshalb löst ein Parsing-Fehler hier genau &lt;strong&gt;einen&lt;/strong&gt; Retry aus, bei dem der Prompt noch einmal mit einer strengen Erinnerung geschickt wird: &amp;ldquo;Your previous response was unparseable. Output ONLY a single valid JSON object.&amp;rdquo; Wenn der zweite Versuch auch fehlschlägt, bekommt der Nutzer eine klare Fehlermeldung mit dem Vorschlag, den Scope etwas einzuengen.&lt;/p&gt;
&lt;p&gt;Ein Retry, nicht drei. Drei Retries, wenn das Modell ohnehin verwirrt ist, verschwenden nur Sekunden und Strom.&lt;/p&gt;
&lt;p&gt;Die Lektionen selbst sind interessanterweise &lt;strong&gt;gar kein JSON&lt;/strong&gt;. Ein Lektionstext ist Fließtext – ihn in einen JSON-String zu zwingen, brächte nichts und würde nur Escaping-Kopfschmerzen verursachen. Lektionen werden als reines Markdown generiert und durchlaufen dann einen kleinen Cleanup-Pass, die Chat-Floskeln entfernt, die das Modell trotz gegenteiliger Anweisungen manchmal hinzufügt (&amp;ldquo;I hope this helps!&amp;rdquo;, &amp;ldquo;Let me know if…&amp;rdquo;). Andere Ausgabe, anderer Vertrag.&lt;/p&gt;
&lt;h2 id="zwei-phasen-ansatz-outline-zuerst-lektionen-nach-bedarf"&gt;Zwei-Phasen-Ansatz: Outline zuerst, Lektionen nach Bedarf&lt;/h2&gt;
&lt;p&gt;Workshops nutzen ein zweistufiges Generierungsmuster:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Pass 1 — generate outline: {&amp;#34;title&amp;#34;: ..., &amp;#34;lessons&amp;#34;: [{&amp;#34;title&amp;#34;: ...}, ...]} (cheap, JSON)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Pass 2 — for each lesson: a full Markdown lesson body (on demand)
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Die Outline ist schnell da und lässt den Nutzer sofort die Struktur des Workshops sehen. Jede Lektion wird erst generiert, wenn der Nutzer sie öffnet – was bedeutet, dass der Nutzer gerade Lektion 1 &lt;em&gt;liest&lt;/em&gt;, während er noch entscheidet, ob er Lektion 5 überhaupt haben möchte. Die Gesamt-Wartezeit bis zum &amp;ldquo;ersten nützlichen Inhalt&amp;rdquo; ist so selbst bei einem Workshop mit 10 Lektionen winzig.&lt;/p&gt;
&lt;p&gt;Das ist genau der gleiche architektonische Kniff, den die Chat-Seite mit dem
anwendet: Teile eine langsame Operation in einen winzigen schnellen Teil und einen größeren langsamen Teil auf, und gib dem Nutzer den schnellen Teil sofort.&lt;/p&gt;
&lt;h2 id="was-ich-bisher-beim-bauen-dieser-generatoren-gelernt-habe"&gt;Was ich bisher beim Bauen dieser Generatoren gelernt habe&lt;/h2&gt;
&lt;p&gt;Ein paar destillierte Prinzipien aus den vier Generatoren:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Nutze die Grammatik-Option in deinem Inference-Stack.&lt;/strong&gt; Versuch erst gar nicht, JSON aus einem frei formulierenden Decoder herauszulocken.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Nagel jeden Quantifikator im Prompt fest.&lt;/strong&gt; &amp;ldquo;Exactly 10&amp;rdquo;, &amp;ldquo;exactly 4 options&amp;rdquo;, &amp;ldquo;one or two sentences&amp;rdquo;. Vage Mengenangaben = inkonsistenter Output.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Verlass dich nicht auf die oberste Struktur-Ebene.&lt;/strong&gt; Grammatik-eingeschränktes Gemma mag Objekte; dein Code erwartet vielleicht Arrays. Akzeptiere beides – der Parser ist billiger, als sich darauf zu verlassen, dass das Modell die erwartete Struktur liefert.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Verwerfen, nicht scheitern lassen.&lt;/strong&gt; Ein verlustbehafteter Erfolg schlägt spröde Perfektion.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Ein Retry, nie mehr.&lt;/strong&gt; Wenn zwei Versuche kein gültiges Ergebnis liefern, ist der Prompt falsch, nicht das Modell.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Teile große Generierungen auf.&lt;/strong&gt; Outline + Lektionen. Skelett + Körper. Zwei kleine Aufrufe schlagen einen großen fast jedes Mal. Und wenn ein Teil der Ausgabe natürlicher Fließtext ist, lass ihn auch Fließtext &lt;em&gt;sein&lt;/em&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Lokale LLMs sind im Jahr 2026 gut genug, dass strukturierte Generierung für Features auf Produktionsniveau wirklich nutzbar ist. Sie sind allerdings nicht so gut, dass du auf das defensive Gerüst verzichten könntest. Das obige Gerüst macht insgesamt vielleicht 80 Zeilen Code in allen vier Generatoren aus, und das ist genau der Unterschied zwischen &amp;ldquo;Demo-Qualität&amp;rdquo; und &amp;ldquo;Ich vertraue dem genug, um es zu shippen.&amp;rdquo;&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="anhang-abkürzungen-in-diesem-beitrag"&gt;Anhang: Abkürzungen in diesem Beitrag&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Abkürzung&lt;/th&gt;
&lt;th&gt;Vollform&lt;/th&gt;
&lt;th&gt;Bedeutung&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;JSON&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;JavaScript Object Notation&lt;/td&gt;
&lt;td&gt;Das strukturierte Textformat, das die Generatoren produzieren müssen&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;LLM&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Large Language Model&lt;/td&gt;
&lt;td&gt;Ein neuronales Netz, das auf großen Textmengen trainiert wurde, um Sprache zu lesen und zu generieren&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;AI&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Artificial Intelligence&lt;/td&gt;
&lt;td&gt;Software, die Aufgaben ausführt, für die normalerweise menschliche Intelligenz erforderlich ist&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;MCQ&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Multiple-Choice Question&lt;/td&gt;
&lt;td&gt;Eine der zwei Arten von Quizfragen (die andere ist True/False)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;UX&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;User Experience&lt;/td&gt;
&lt;td&gt;Warum 9 gültige Fragen besser sind als ein Neu-Generierungs-Fehler&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SQLite&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;(SQL = Structured Query Language)&lt;/td&gt;
&lt;td&gt;Die Single-File-Datenbank, in der generierte Artefakte gespeichert werden&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;DBOS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Database-Oriented Operating System&lt;/td&gt;
&lt;td&gt;Die Bibliothek für dauerhafte Workflows aus dem vorherigen Beitrag&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;HTTP 502&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Bad Gateway (HyperText Transfer Protocol status code)&lt;/td&gt;
&lt;td&gt;Der Fehler, den mein reiner Array-Parser warf, bis ich Gemmas Objektform akzeptierte&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Als Nächstes:&lt;/strong&gt;
— was mich das händische Bauen eines SVG-Radial-Layouts gelehrt hat und warum Version zwei trotzdem React Flow nutzt.&lt;/p&gt;</description></item><item><title>Teil 4 · Crash-Resumable Ingestion: DBOS, SHA-256 und wie man ein kill -9 überlebt</title><link>https://aretascodes.dev/de/blog/crash-resumable-ingestion-dbos/</link><pubDate>Tue, 05 May 2026 00:00:00 +0000</pubDate><guid>https://aretascodes.dev/de/blog/crash-resumable-ingestion-dbos/</guid><description>
&lt;blockquote class="border-l-4 border-neutral-300 dark:border-neutral-600 pl-4 italic text-neutral-600 dark:text-neutral-400 my-6"&gt;
&lt;p&gt;Teil einer Serie über den Bau von
. Zuvor:
.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote class="border-l-4 border-neutral-300 dark:border-neutral-600 pl-4 italic text-neutral-600 dark:text-neutral-400 my-6"&gt;
&lt;p&gt;Alle Abkürzungen werden im Anhang am Ende der Seite ausführlich erklärt.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Es gibt zwei Dinge, die deine RAG-Ingestion-Pipeline auf keinen Fall tun sollte:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Ein 200-seitiges PDF neu einbetten, weil du einen Tippfehler auf Seite 12 korrigiert hast.&lt;/li&gt;
&lt;li&gt;Ihren Fortschritt verlieren, wenn du auf halber Strecke den Laptop zuklappst.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Das Erste verschwendet Zeit und Rechenressourcen. Das Zweite führt zu Misstrauen in das System. Beides hat denselben Ursprung: Die Ingestion wird wie eine Fire-and-Forget-Funktion behandelt, obwohl sie eigentlich eine lang laufende Pipeline ist, deren Zwischenzustände es wert sind, erhalten zu bleiben.&lt;/p&gt;
&lt;p&gt;CogniVault behandelt Ingestion als einen &lt;strong&gt;Durable Workflow&lt;/strong&gt;. Genauer gesagt als einen
-Workflow, der in Postgres mit Checkpoints versehen ist und Content-Hashing für inkrementelle Arbeit nutzt. In diesem Beitrag schauen wir uns beides an.&lt;/p&gt;
&lt;h2 id="die-pipeline"&gt;Die Pipeline&lt;/h2&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;1. Scan docs/ → SHA-256 hash per file
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ├── New file → queue for embedding
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ├── Changed file → soft-delete old chunks, re-embed
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; └── Unchanged → skip (idempotent)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;2. Extract text → per-format extractor (PDF/OCR, DOCX, PPTX, XLSX, MD, CSV, TXT, HTML)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;3. Chunk → RecursiveCharacterTextSplitter (1000 chars, 100 overlap)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;4. Embed → embeddinggemma via Ollama, batches of 5
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;5. Save → append to FAISS IndexFlatIP + JSON metadata on disk
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Die rechenintensiven Stufen laufen als DBOS-Schritte innerhalb eines übergeordneten Workflows und sind alle mit Checkpoints versehen: Wenn der Prozess zwischen den Schritten stirbt, macht der nächste Start genau beim letzten abgeschlossenen Schritt weiter.&lt;/p&gt;
&lt;h2 id="sha-256-als-einzige-quelle-der-wahrheit"&gt;SHA-256 als einzige Quelle der Wahrheit&lt;/h2&gt;
&lt;p&gt;Der naive Ansatz ist, die Ingestion anhand des Dateinamens zu verfolgen. Das geht genau dann schief, wenn jemand eine Datei direkt bearbeitet. Der Dateiname ist derselbe; der Inhalt nicht. Der Vector-Store schleppt dann klammheimlich veraltete Chunks mit sich herum.&lt;/p&gt;
&lt;p&gt;Die Lösung ist inhaltsadressiert: Hashe die Datei-Bytes und speichere den Hash zusammen mit den Chunks. Bei jedem Ingestion-Durchlauf passiert Folgendes:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;current_hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file_bytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;stored_hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;chunk_metadata_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;file_hash&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;stored_hash&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;schedule_ingest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# new file&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;stored_hash&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;current_hash&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;skip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# unchanged&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;soft_delete_chunks_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# changed&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;schedule_ingest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Das verleiht der Ingestion eine &lt;strong&gt;idempotente&lt;/strong&gt; Eigenschaft, die Gold wert ist: Die Pipeline zweimal hintereinander laufen zu lassen, bewirkt beim zweiten Mal fast nichts. Das ist nicht nur eine Optimierung — erst dadurch wird der nächste Abschnitt überhaupt möglich.&lt;/p&gt;
&lt;h2 id="dbos-workflows"&gt;DBOS-Workflows&lt;/h2&gt;
&lt;p&gt;
ist eine Python-Bibliothek, die normale Funktionen in Checkpoint-basierte Workflows verwandelt, die von Postgres gestützt werden. Das Modell ist kinderleicht: Dekoriere eine Funktion mit &lt;code&gt;@DBOS.workflow()&lt;/code&gt;, markiere jeden lang laufenden Aufruf darin als &lt;code&gt;@DBOS.step()&lt;/code&gt;, und DBOS speichert während der Ausführung für jeden Schritt Input, Output und Status in Postgres.&lt;/p&gt;
&lt;p&gt;Wenn der Workflow abstürzt — Prozess gekillt, OS-Reboot, Abbruch der Postgres-Verbindung — sieht der nächste Start, dass ein unvollendeter Workflow mit derselben ID existiert, spielt die &lt;em&gt;aufgezeichneten&lt;/em&gt; Schritt-Outputs aus Postgres ab (ohne sie neu auszuführen) und macht beim ersten unvollständigen Schritt weiter.&lt;/p&gt;
&lt;p&gt;Hier ist die eigentliche Schrittstruktur (leicht vereinfacht aus &lt;code&gt;backend/services/ingest.py&lt;/code&gt;):&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nd"&gt;@DBOS.workflow&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;ingest_workflow&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;filenames&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;list_document_files&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="c1"&gt;# @DBOS.step — scan + hash check&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;docs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;filenames&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;docs&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;process_single_document&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# @DBOS.step — extract text, one file each&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;chunks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;docs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# plain Python — fast, re-runs freely&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;embeddings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;batches_of_5&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;embeddings&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;embed_batch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# @DBOS.step — the slow one, retried on failure&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;save_vector_store&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;embeddings&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# @DBOS.step — append to FAISS + metadata&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Die Granularität von &lt;code&gt;@DBOS.step&lt;/code&gt; entspricht der Granularität der Crash-Recovery und wurde bewusst so gewählt. Die Extraktion ist ein Schritt &lt;strong&gt;pro Datei&lt;/strong&gt;, sodass bei einem Absturz während Datei 9 von 10 die ersten acht nicht neu gelesen werden. Embedding ist ein Schritt &lt;strong&gt;pro Batch von fünf Chunks&lt;/strong&gt;, und zwar aus einem bestimmten Grund: &lt;strong&gt;&lt;code&gt;embed_batch&lt;/code&gt; ist der langsame Part.&lt;/strong&gt; Wenn der Laptop während der Embeddings den Geist aufgibt, setzen wir den Embedding-Loop beim fehlgeschlagenen Batch fort, nicht bei der PDF-Extraktion.&lt;/p&gt;
&lt;p&gt;Fällt dir auf, was &lt;em&gt;kein&lt;/em&gt; Schritt ist? Das Chunking. Text aufzuteilen ist schnelle, reine Python-Arbeit — es mit Checkpoints zu versehen, würde mehr Buchhaltung im Ledger kosten, als es bei einer Fortsetzung einfach neu zu machen.&lt;/p&gt;
&lt;p&gt;In der Batch-Größe verbirgt sich noch ein kleiner Trick. DBOS speichert den Output jedes Schritts in Postgres, und &lt;code&gt;embed_batch&lt;/code&gt; gibt seine Vektoren zurück — also enthält jeder Ledger-Eintrag Float-Werte für fünf Embeddings. Kleine Batches halten jeden Checkpoint-Datensatz klein und jeden erneuten Versuch (Retry) günstig. Ein riesiger &amp;ldquo;Bette alles ein&amp;rdquo;-Schritt würde eine riesige Ledger-Zeile und null Resume-Granularität bedeuten.&lt;/p&gt;
&lt;h2 id="die-format-extraktoren"&gt;Die Format-Extraktoren&lt;/h2&gt;
&lt;p&gt;Schritt 2 (&lt;code&gt;process_single_document&lt;/code&gt;) ist eine Weiche basierend auf der Dateiendung. Jeder Extraktor ist klein und einleuchtend; die interessanten Entscheidungen liegen in der Chunking-Strategie, die jeder nachgelagert füttert.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Format&lt;/th&gt;
&lt;th&gt;Library&lt;/th&gt;
&lt;th&gt;Chunking note&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;PDF&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pypdf&lt;/code&gt; Seite für Seite; &lt;code&gt;pytesseract&lt;/code&gt; OCR-Fallback für Bild-Seiten&lt;/td&gt;
&lt;td&gt;Rekursiver Splitter, 1000/100&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;DOCX&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;python-docx&lt;/code&gt; (Absätze + Tabellenzeilen als Text verbunden)&lt;/td&gt;
&lt;td&gt;Rekursiver Splitter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;PPTX&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;python-pptx&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Ein Chunk pro Folie (Titel + Body-Text)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;XLSX&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;openpyxl&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Header + 20-Zeilen-Batches, pro Arbeitsblatt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;MD&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;MarkdownHeaderTextSplitter&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Ein Chunk pro H1/H2/H3-Abschnitt, Breadcrumbs davor&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;CSV&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Manueller Reader&lt;/td&gt;
&lt;td&gt;Header-Zeile + 20-Zeilen-Batches&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;TXT&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Rohes UTF-8 Lesen&lt;/td&gt;
&lt;td&gt;Rekursiver Splitter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;HTML&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;trafilatura&lt;/code&gt; sauberer Text&lt;/td&gt;
&lt;td&gt;Rekursiver Splitter&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Der OCR-Fallback ist es wert, kurz innezuhalten. PDFs gibt es in zwei Ausführungen: solche mit einer echten Textebene und solche, die im Grunde nur gescannte Bilder in einem PDF-Kostüm sind. &lt;code&gt;pypdf&lt;/code&gt; liefert für die zweite Sorte &lt;em&gt;nichts Brauchbares&lt;/em&gt; zurück, wirft aber auch keinen Fehler — es gibt einfach leere Strings zurück. Ohne ein Fallback lügt dich dein &amp;ldquo;Ingestion erfolgreich&amp;rdquo;-Log an.&lt;/p&gt;
&lt;p&gt;Der Detektor ist eine Heuristik: Wenn &lt;code&gt;pypdf&lt;/code&gt; weniger als 50 Zeichen für eine Seite zurückgibt, leite die Seite durch &lt;code&gt;pymupdf&lt;/code&gt; → &lt;code&gt;Pillow&lt;/code&gt; → &lt;code&gt;pytesseract&lt;/code&gt; OCR. Langsamer, aber es produziert immerhin Text. Der Schwellenwert ist so eingestellt, dass er sensibel genug ist, um gescannte Seiten abzufangen, ohne legitimerweise kurze Seiten (wie ein Kapitel-Deckblatt oder ein Impressum) zu bestrafen.&lt;/p&gt;
&lt;h2 id="soft-delete-nicht-hard-delete"&gt;Soft Delete, nicht Hard Delete&lt;/h2&gt;
&lt;p&gt;Wenn sich eine Datei ändert und wir sie neu einlesen, müssen die alten Chunks weg. Es ist verlockend, sie physisch aus dem FAISS-Index zu entfernen, aber FAISS &lt;code&gt;IndexFlatIP&lt;/code&gt; unterstützt kein effizientes Löschen — du müsstest ihn neu aufbauen.&lt;/p&gt;
&lt;p&gt;Stattdessen &lt;strong&gt;Soft Delete&lt;/strong&gt;: Bei geänderten Dateien werden die alten Chunks in den Metadaten mit einem &lt;code&gt;deleted: true&lt;/code&gt;-Flag markiert; neue Chunks werden ohne Flag angehängt. Bei einer Suchanfrage wird nach diesem Flag gefiltert, sodass veraltete Vektoren völlig harmlos im Index liegen bleiben. Wenn sich jemals genug totes Gewicht ansammelt, ist das Ventil offensichtlich — bau den Index nur mit aktiven Chunks neu auf —, aber in der Praxis habe ich das noch nie gebraucht.&lt;/p&gt;
&lt;p&gt;Das ist dasselbe Muster, das die meisten Append-only-Systeme verwenden. Es passt natürlich perfekt zum Content-Hashing — Markieren-und-Anhängen ist viel billiger als Entfernen-und-Neubauen. Eine Feinheit dabei: Der Keyword-Index muss mitziehen. CogniVaults &lt;code&gt;VectorDB.delete_by_source()&lt;/code&gt; setzt die Flags &lt;strong&gt;und baut BM25 neu auf&lt;/strong&gt;, und zwar über die verbleibenden aktiven Chunks, sodass sich die beiden Retriever nie uneinig darüber sind, was eigentlich existiert.&lt;/p&gt;
&lt;h2 id="was-der-user-sieht"&gt;Was der User sieht&lt;/h2&gt;
&lt;p&gt;Das Starten einer Ingestion (&lt;code&gt;POST /ingest&lt;/code&gt;) liefert eine &lt;code&gt;workflow_id&lt;/code&gt; zurück, und das Frontend fragt regelmäßig &lt;code&gt;GET /ingest/status/{workflow_id}&lt;/code&gt; ab, um eine Live-Timeline der Workflow-Schritte zu zeichnen — Scannen, Extraktion pro Datei (&amp;ldquo;Lese Seiten… 3 von 21&amp;rdquo;), Einbetten (&amp;ldquo;Kalibriere Batch 4 von 12&amp;rdquo;), Speichern. Wenn der User den Tab mitten in der Ingestion schließt, fünf Minuten später wiederkommt und ihn neu öffnet — der Workflow ist im Hintergrund sowieso fertig gelaufen. Der nächste Aufruf von &lt;code&gt;GET /api/vault/stats&lt;/code&gt; spiegelt die neue Chunk-Anzahl wider. Kein &amp;ldquo;Klicken zum Fortsetzen&amp;rdquo;-Button, kein manueller Recovery-Tanz.&lt;/p&gt;
&lt;p&gt;Als ich das erste Mal mitten im Einbetten den Deckel zugeklappt habe und dann beim Aufwecken zusehen konnte, wie der Workflow sich den nächsten Schritt geschnappt und einfach weitergemacht hat, war ich, ehrlich gesagt, ein bisschen stolz. Das ist genau die Eigenschaft, die ich wollte, und das mit überraschend wenig Code.&lt;/p&gt;
&lt;h2 id="fallstricke-und-randfälle"&gt;Fallstricke und Randfälle&lt;/h2&gt;
&lt;p&gt;Ein paar Dinge, die ich auf die harte Tour lernen musste:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Mach &lt;code&gt;embed_batch&lt;/code&gt; nicht zu groß.&lt;/strong&gt; Ollama ist nicht besonders gut im Umgang mit Backpressure. Batches von 5 sind ein Sweetspot für &lt;code&gt;embeddinggemma&lt;/code&gt; auf einer Maschine mit 16 GB RAM — größere Batches bleiben am Speicher hängen, kleinere verschwenden Overhead für die Round-Trips. (Und wie oben erwähnt: Die Batch-Größe bestimmt gleichzeitig die Größe deines Checkpoint-Datensatzes.)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Sei vorsichtig beim Löschen von Dateien.&lt;/strong&gt; Soft-gelöschte Chunks müssen auch aus dem Korpus von BM25 verschwinden, sonst liefert die Keyword-Suche weiterhin Text, den die Dense Search (Vektorsuche) gar nicht mehr sieht. Wenn du BM25 innerhalb von &lt;code&gt;delete_by_source()&lt;/code&gt; neu aufbaust, bleiben die beiden im Gleichschritt.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;OCR ist langsam.&lt;/strong&gt; Ein 50-seitiger Scan kann eine Minute oder länger dauern. Mach diese Wartezeit für den User sichtbar, sonst denken sie, das System hat sich aufgehängt.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="fazit"&gt;Fazit&lt;/h2&gt;
&lt;p&gt;Durable Workflows sind nicht nur etwas für verteilte Systeme. Eine lokale App für einen einzelnen Nutzer profitiert davon auf &lt;em&gt;genau die gleiche Weise&lt;/em&gt;: inkrementelle Arbeit, Crash-Recovery, idempotente Retries. DBOS macht die Einstiegskosten dafür extrem niedrig — dekoriere deine Funktion, lass Postgres lokal laufen, und du bekommst eine Pipeline, die das Zuklappen des Laptops, OS-Updates und dein eigenes &lt;code&gt;Ctrl-C&lt;/code&gt; überlebt.&lt;/p&gt;
&lt;p&gt;In Kombination mit inhaltsadressiertem Hashing ist die Ingestion nicht länger etwas, das du meidest, aus Angst, 20 Minuten warten zu müssen. Es wird zu etwas, das du einfach neu startest, wann immer du Lust dazu hast — denn ein Neustart kostet nichts, wenn sich nichts geändert hat.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="anhang-abkürzungen-in-diesem-beitrag"&gt;Anhang: Abkürzungen in diesem Beitrag&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Abbreviation&lt;/th&gt;
&lt;th&gt;Full form&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;DBOS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Database-Oriented Operating System&lt;/td&gt;
&lt;td&gt;Eine Bibliothek, die Workflow-Schritte in Postgres sichert, sodass abgestürzte Jobs fortgesetzt statt neu gestartet werden&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SHA-256&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Secure Hash Algorithm, 256-bit&lt;/td&gt;
&lt;td&gt;Ein Content-Fingerabdruck: Änderst du ein Byte einer Datei, ändert sich der Hash komplett&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;RAG&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Retrieval-Augmented Generation&lt;/td&gt;
&lt;td&gt;Rufe zuerst relevante Passagen aus deinen eigenen Dokumenten ab; lass das Modell daraus antworten&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;OCR&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Optical Character Recognition&lt;/td&gt;
&lt;td&gt;Das Umwandeln von Bildern von Text (gescannte Seiten) in maschinenlesbaren Text&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;FAISS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Facebook AI Similarity Search&lt;/td&gt;
&lt;td&gt;Der Vektorindex, an den die Embeddings angehängt werden&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;IP&lt;/strong&gt; (in &lt;code&gt;IndexFlatIP&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Inner Product&lt;/td&gt;
&lt;td&gt;FAISS&amp;rsquo;s Ähnlichkeitsmaß; entspricht der Cosinus-Ähnlichkeit bei normalisierten Vektoren&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;BM25&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Best Match 25&lt;/td&gt;
&lt;td&gt;Der Keyword-Index, der beim Löschen mit FAISS im Gleichschritt bleiben muss&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;PDF / DOCX / PPTX / XLSX / MD / CSV / TXT / HTML&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Portable Document Format / Word / PowerPoint / Excel / Markdown / Comma-Separated Values / plain text / HyperText Markup Language&lt;/td&gt;
&lt;td&gt;Die Formate, die von den entsprechenden Extraktoren verarbeitet werden&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;JSON&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;JavaScript Object Notation&lt;/td&gt;
&lt;td&gt;Das Format der Chunk-Metadaten-Datei neben dem FAISS-Index&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;UTF-8&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Unicode Transformation Format, 8-bit&lt;/td&gt;
&lt;td&gt;Die Textkodierung, die beim Lesen von Klartextdateien verwendet wird&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;OS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Operating System&lt;/td&gt;
&lt;td&gt;Das, was mitten in der Ingestion unter dir neu startet&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Als Nächstes:&lt;/strong&gt;
— was passiert, wenn Gemma 4 enthusiastisch &lt;code&gt;{&amp;quot;questions&amp;quot;: [{&amp;quot;text&amp;quot;: &amp;quot;...&amp;quot;},}]&lt;/code&gt; zurückgibt.&lt;/p&gt;</description></item><item><title>Teil 3 · Zwei-Phasen-Streaming: Zeigen, wie das Modell denkt, bevor es handelt</title><link>https://aretascodes.dev/de/blog/two-phase-streaming-strands-agents/</link><pubDate>Thu, 30 Apr 2026 00:00:00 +0000</pubDate><guid>https://aretascodes.dev/de/blog/two-phase-streaming-strands-agents/</guid><description>
&lt;blockquote class="border-l-4 border-neutral-300 dark:border-neutral-600 pl-4 italic text-neutral-600 dark:text-neutral-400 my-6"&gt;
&lt;p&gt;Teil einer Serie über den Aufbau von
. Zuvor:
.
Alle Abkürzungen werden vollständig im Anhang am Ende der Seite erklärt.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Als ich Gemma 4 zum ersten Mal mit
in CogniVault verkabelt habe, fühlte sich der Chat langsam an. Nicht laggy — langsam auf eine Art, die schlimmer ist als laggy. Der User tippt eine Frage ein. Der Cursor sitzt da und macht nichts. Dann, irgendwann, fällt eine Antwort aus dem Nichts.&lt;/p&gt;
&lt;p&gt;Das Modell war nicht untätig. Es hat &lt;em&gt;nachgedacht&lt;/em&gt;. Gemma 4 hat einen Chain-of-Thought-Modus, der einen (manchmal langen) Gedankengang produziert, bevor die finale Antwort kommt. Bei einem einphasigen Agenten-Stream passiert dieses ganze Nachdenken &lt;em&gt;innerhalb der Agenten-Loop&lt;/em&gt; — still und heimlich — bevor irgendwelche Tool-Aufrufe laufen oder irgendwelche Tokens an die UI gesendet werden.&lt;/p&gt;
&lt;p&gt;Also habe ich den Aufruf in zwei Phasen unterteilt.&lt;/p&gt;
&lt;h2 id="die-struktur"&gt;Die Struktur&lt;/h2&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;POST /rag
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; │
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ├── Phase 1 — Direkter Ollama-Aufruf, Thinking aktiviert
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; │ stream: {&amp;#34;type&amp;#34;:&amp;#34;thinking&amp;#34;,&amp;#34;data&amp;#34;:&amp;#34;...&amp;#34;} (Reasoning-Tokens)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; │
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; └── Phase 2 — Strands Agent (Thinking deaktiviert)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; stream: {&amp;#34;type&amp;#34;:&amp;#34;metadata&amp;#34;,&amp;#34;data&amp;#34;:{...}} (Quellenangaben, sobald die Suche läuft)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; stream: {&amp;#34;type&amp;#34;:&amp;#34;text&amp;#34;,&amp;#34;data&amp;#34;:&amp;#34;...&amp;#34;} (Antwort-Tokens)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; stream: {&amp;#34;type&amp;#34;:&amp;#34;memory&amp;#34;,&amp;#34;data&amp;#34;:{...}} (End-of-Stream: Speicherverbrauch der Session)
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Der Endpoint streamt &lt;strong&gt;Newline-Delimited JSON&lt;/strong&gt; (NDJSON): Jede Zeile im Response-Body ist ein eigenständiger JSON-Umschlag mit einem &lt;code&gt;type&lt;/code&gt; und einem &lt;code&gt;data&lt;/code&gt;. Das Frontend entscheidet anhand des &lt;code&gt;type&lt;/code&gt; und rendert entsprechend: ein &lt;strong&gt;ausklappbares Reasoning-Panel&lt;/strong&gt; für die Thinking-Tokens, die Hauptnachrichten-Blase für die Text-Tokens und eine Sidebar-Card pro Quelle.&lt;/p&gt;
&lt;p&gt;Der User sieht das Modell &lt;em&gt;sofort&lt;/em&gt; anfangen zu denken. Die Latenz bis zum ersten Byte sinkt von &amp;ldquo;lang genug, um sich zu fragen, ob es abgestürzt ist&amp;rdquo; zu &amp;ldquo;sofort&amp;rdquo;. Die Gesamtzeit bis zur finalen Antwort ändert sich nicht. Aber die gefühlte Geschwindigkeit schon.&lt;/p&gt;
&lt;h2 id="phase-1--nur-nachdenken"&gt;Phase 1 — Nur Nachdenken&lt;/h2&gt;
&lt;p&gt;Phase 1 ist ein einzelner direkter Aufruf an Ollama mit aktiviertem Thinking. Er bekommt exakt das, was auch Phase 2 sehen wird — denselben System-Prompt, die aktuelle Frage und alle angehängten Bilder —, sodass die Argumentation die Realität widerspiegelt. Nur die &lt;em&gt;Reasoning&lt;/em&gt;-Tokens werden konsumiert; was auch immer an Antworttext Phase 1 zu produzieren beginnt, wird verworfen, weil wir nicht wollen, dass eine halbfertige Antwort mit der echten konkurriert.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Simplified from backend/services/rag_agent.py&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ollama&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AsyncClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ollama_host&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;stream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;llm_model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;role&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;system&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;content&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;system_prompt&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;role&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;user&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;content&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;images&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;images&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;],&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;thinking&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;True&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;thinking&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;envelope&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;thinking&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;thinking&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Phase 1 ist absichtlich &lt;strong&gt;Best-Effort&lt;/strong&gt;: Jeder Fehler hier wird einfach geschluckt und geloggt, und der Stream geht direkt über zu Phase 2. Ein kaputtes Reasoning-Panel sollte den User niemals seine Antwort kosten.&lt;/p&gt;
&lt;h2 id="phase-2--agent-mit-tools"&gt;Phase 2 — Agent mit Tools&lt;/h2&gt;
&lt;p&gt;Phase 2 baut einen &lt;strong&gt;frischen Strands &lt;code&gt;Agent&lt;/code&gt; pro Request&lt;/strong&gt; auf — kein geteilter veränderlicher Zustand zwischen gleichzeitigen Chats —, stellt die Konversationshistorie der Session darin wieder her und führt die Tool-Loop mit sechs registrierten Tools aus:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Zweck&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;search_knowledge_base(query)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Hybride FAISS + BM25 Suche, Top-7, RRF Fusion. Scope-Filter-aware.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;list_documents()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Bestandsaufnahme jeder indizierten Datei mit Typ und Chunk-Anzahl.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;analyze_document(filename)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Innerer Gemma-Aufruf → strukturierte Zusammenfassung (Themen, Entitäten, Fakten).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;compare_documents(doc_a, doc_b, question)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Innerer Gemma-Aufruf, der dokumentübergreifend antwortet.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;calculator(expression)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Sicherer AST-Evaluator — kein &lt;code&gt;eval()&lt;/code&gt;, kein beliebiger Code.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;current_time()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Zeitstempel für zeitbewusste Fragen.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Der Agent entscheidet, welche Tools er in welcher Reihenfolge aufruft. Es gibt keinen hart codierten Router; der System-Prompt erklärt, was verfügbar ist, und Strands kümmert sich um die Schleife. Für die meisten Dokumentenfragen ist der Weg: &lt;code&gt;search_knowledge_base&lt;/code&gt; → Antwort. Für Vergleiche: &lt;code&gt;compare_documents&lt;/code&gt; → Antwort. Für &amp;ldquo;Welche Dateien habe ich?&amp;rdquo;: &lt;code&gt;list_documents&lt;/code&gt; → Antwort. Für Begrüßungen und einfache Mathematik sagt der System-Prompt dem Agenten, dass er die Suche komplett überspringen darf. Das Modell wählt selbst.&lt;/p&gt;
&lt;p&gt;Zwei Details, deren Debugging Zeit gekostet hat, um sie richtig hinzubekommen:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Phase 2 läuft mit explizit deaktiviertem Thinking.&lt;/strong&gt; Ohne dieses Flag kann Gemmas Standardverhalten &lt;code&gt;&amp;lt;think&amp;gt;…&amp;lt;/think&amp;gt;&lt;/code&gt;-Tags in die sichtbare Antwort durchsickern lassen, und alles vor dem schließenden Tag wird vom Markdown-Renderer verschluckt. Eine Modelloption — &lt;code&gt;options={&amp;quot;thinking&amp;quot;: False}&lt;/code&gt; — behob einen Bug mit &amp;ldquo;abgeschnittenen Antworten&amp;rdquo;, der viel unheimlicher aussah, als er tatsächlich war.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Zitate werden vor dem ersten Antwort-Token rausgeschrieben.&lt;/strong&gt; Tools laufen, bevor die Text-Deltas ankommen. Bis das erste sichtbare Token gestreamt wird, ist also jede Quelle, die die Suche gefunden hat, bereits in der Sidebar. Der Accumulator ist ein Request-lokaler &lt;code&gt;ContextVar&lt;/code&gt;, an den das Such-Tool anhängt.&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Simplified — the real loop reads Strands&amp;#39; raw event dicts&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stream_async&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_input&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;delta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;event&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;contentBlockDelta&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;delta&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;text&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;new_citations&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="c1"&gt;# drain the ContextVar accumulator&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;envelope&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;metadata&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;envelope&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;text&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h2 id="warum-das-wichtiger-ist-als-es-klingt"&gt;Warum das wichtiger ist, als es klingt&lt;/h2&gt;
&lt;p&gt;Du könntest ähnliches Verhalten mit einem einzigen Agenten-Aufruf implementieren, der &lt;code&gt;thinking&lt;/code&gt;-Events mit &lt;code&gt;text&lt;/code&gt;-Events verschränkt. Die Gründe, warum ich es trotzdem aufgeteilt habe:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Das Thinking-Modell und das Tool-Modell können unterschiedlich sein.&lt;/strong&gt; Aktuell sind beide &lt;code&gt;gemma4:e4b&lt;/code&gt;, aber die Architektur erlaubt es mir, ein kleineres, schnelleres Modell für das Reasoning in Phase 1 auszutauschen und das große für die Tool-Nutzung in Phase 2 zu behalten. Das mache ich noch nicht — aber ich will die Option haben.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Phase 1 streamt immer sofort.&lt;/strong&gt; Eine reine Agenten-Loop fängt erst an, Tokens zu produzieren, nachdem das Modell entschieden hat, was es sagen will. Das Zwei-Phasen-Modell garantiert, dass der User fast sofort nach Drücken der Enter-Taste eine Aktivität sieht, unabhängig davon, wie komplex die Tool-Arbeit in Phase 2 wird.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Fehler sind isoliert.&lt;/strong&gt; Wenn Phase 2 umfällt (Ollama Timeout, Tool Error), ist die Argumentation aus Phase 1 immer noch sichtbar — der User kann sehen, &lt;em&gt;was das Modell tun wollte&lt;/em&gt;, was den Fehler deutlich weniger frustrierend macht als ein blankes &amp;ldquo;irgendwas ist schiefgelaufen&amp;rdquo;.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id="contextvar-isolation-noch-einmal"&gt;ContextVar-Isolation, noch einmal&lt;/h2&gt;
&lt;p&gt;Der gleiche &lt;code&gt;ContextVar&lt;/code&gt;-Trick, der im
das Retrieval eingegrenzt hat, greift auch hier. Zu Beginn jedes &lt;code&gt;/rag&lt;/code&gt;-Streams setzt der Handler zwei Request-lokale Variablen: den &lt;strong&gt;Dokument-Scope-Filter&lt;/strong&gt; und den &lt;strong&gt;Zitier-Accumulator&lt;/strong&gt;. Die Tools des Agenten lesen und schreiben diese implizit. Die Konversationshistorie selbst lebt in einem Per-Session-Store, der durch Per-Session &lt;code&gt;asyncio&lt;/code&gt;-Locks geschützt ist. Zwei gleichzeitige Requests im selben Chat können sich also auch nicht gegenseitig korrumpieren.&lt;/p&gt;
&lt;p&gt;Getestet mit zwei offenen Browser-Tabs im selben Backend, mit Scope auf verschiedene Dokumentenkategorien, in denen gleichzeitig überlappende Queries gesendet wurden. Null Kreuzkontamination. Die Test-Suite deckt dies explizit in &lt;code&gt;test_thinking.py&lt;/code&gt; und &lt;code&gt;test_doc_scope_filter.py&lt;/code&gt; ab — schau dir den
für die ganze Geschichte an.&lt;/p&gt;
&lt;h2 id="die-frontend-seite-des-vertrags"&gt;Die Frontend-Seite des Vertrags&lt;/h2&gt;
&lt;p&gt;Ein Detail, das mich ins Straucheln gebracht hat: Das ist ein &lt;code&gt;POST&lt;/code&gt;-Endpoint, also scheidet die &lt;code&gt;EventSource&lt;/code&gt;-API des Browsers (die nur GET macht) aus. Das Frontend nutzt &lt;code&gt;fetch&lt;/code&gt; und liest den Response-Body inkrementell aus, splittet bei Newlines und parst jede Zeile als JSON:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-tsx" data-lang="tsx"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// Simplified from useRagStream.ts
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;/rag&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;method&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;POST&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;body&lt;/span&gt;: &lt;span class="kt"&gt;JSON.stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;reader&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;getReader&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;decoder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;TextDecoder&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;buffer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;done&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;reader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;read&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;done&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;buffer&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;decoder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt;: &lt;span class="kt"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lines&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;\n&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;buffer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// keep the trailing partial line
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="kr"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kr"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kr"&gt;type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;thinking&amp;#34;&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;appendThinking&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;text&amp;#34;&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;appendText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;metadata&amp;#34;&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;addCitation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;memory&amp;#34;&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nx"&gt;updateMemoryMeter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Das Reasoning-Panel startet &lt;strong&gt;zusammengeklappt&lt;/strong&gt;, mit einem kleinen pulsierenden Indikator, solange die Thinking-Tokens noch streamen — genug, um zu signalisieren &amp;ldquo;das Modell arbeitet&amp;rdquo;, ohne dem User gleich eine Wand aus Chain-of-Thought ins Gesicht zu drücken. Ein Klick klappt den vollen Text aus, während oder nach dem Stream.&lt;/p&gt;
&lt;h2 id="was-ich-mir-noch-mal-ansehen-würde"&gt;Was ich mir noch mal ansehen würde&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Phase 1 denkt auf eine volle Antwort hin, und wir werfen den Antwortteil weg.&lt;/strong&gt; Ein eigener &amp;ldquo;Plane dein Vorgehen, aber antworte noch nicht&amp;rdquo;-Prompt für Phase 1 würde den Argumentationsstrang kompakter und billiger machen. Heute teilt er sich den Haupt-System-Prompt — das ist simpler, aber die Argumentation kann abschweifen.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Noch kein Interrupt.&lt;/strong&gt; Sobald Phase 1 startet, läuft sie bis zum Ende durch. Wenn der User mitten im Stream eine Nachfrage tippt, lassen wir sie zu Ende laufen. Ein echter Cancel-Button würde bedeuten, ein Abort-Signal durch Ollamas HTTP-Client zu fädeln — machbar, aber noch nicht gemacht.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Phase 1 denkt manchmal zu viel nach.&lt;/strong&gt; Begrüßungen und triviale Fragen produzieren immer noch einen Absatz an Begründung. Ein &amp;ldquo;Sollte ich nachdenken?&amp;quot;-Gate (wahrscheinlich ein winziger Classifier oder einfach eine Heuristik basierend auf der Query-Länge) würde Phase 1 in diesen Fällen komplett überspringen.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="takeaway"&gt;Takeaway&lt;/h2&gt;
&lt;p&gt;Streaming ist &lt;em&gt;nicht&lt;/em&gt; einfach nur eine Optimierung. Es ist ein UX-Primitiv. Zwei-Phasen-Streaming kauft dir eine Eigenschaft gratis ein: Der &lt;em&gt;sichtbare&lt;/em&gt; Teil der Interaktion startet, bevor der &lt;em&gt;langsame&lt;/em&gt; Teil beginnt. Der User darf dem Modell beim Denken zusehen, was — ehrlich gesagt — interessanter ist, als einem Spinner zuzuschauen.&lt;/p&gt;
&lt;p&gt;Wenn sich deine Agenten-App langsam anfühlt, obwohl die Antworten schnell kommen, schau dir an, &lt;em&gt;wann&lt;/em&gt; die Tokens anfangen zu fließen. Der Fix ist oft nicht ein schnelleres Modell.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="anhang-abkürzungen-in-diesem-post"&gt;Anhang: Abkürzungen in diesem Post&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Abkürzung&lt;/th&gt;
&lt;th&gt;Volle Form&lt;/th&gt;
&lt;th&gt;Bedeutung&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;NDJSON&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Newline-Delimited JSON&lt;/td&gt;
&lt;td&gt;Ein Stream, in dem jede Zeile ihr eigenes komplettes JSON-Objekt ist — das, was &lt;code&gt;/rag&lt;/code&gt; ausgibt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;JSON&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;JavaScript Object Notation&lt;/td&gt;
&lt;td&gt;Das universelle Textformat für strukturierte Daten&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;UX&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;User Experience&lt;/td&gt;
&lt;td&gt;Wie sich das Produkt in der Nutzung anfühlt — der eigentliche Profiteur vom Zwei-Phasen-Streaming&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;UI&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;User Interface&lt;/td&gt;
&lt;td&gt;Die sichtbare Oberfläche, in die der Stream rendert&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;FAISS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Facebook AI Similarity Search&lt;/td&gt;
&lt;td&gt;Die dichte (dense) Hälfte des hybriden Retrievals (vorheriger Post)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;BM25&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Best Match 25&lt;/td&gt;
&lt;td&gt;Die Keyword-Hälfte des hybriden Retrievals (vorheriger Post)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;RRF&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Reciprocal Rank Fusion&lt;/td&gt;
&lt;td&gt;Die Rank-only-Formel, die die beiden Ergebnislisten zusammenführt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;AST&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Abstract Syntax Tree&lt;/td&gt;
&lt;td&gt;Die geparste Form eines Ausdrucks — wie der Taschenrechner Mathe ohne &lt;code&gt;eval()&lt;/code&gt; berechnet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;HTTP&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;HyperText Transfer Protocol&lt;/td&gt;
&lt;td&gt;Das Protokoll, das den Stream transportiert&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SSE&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Server-Sent Events&lt;/td&gt;
&lt;td&gt;Das eingebaute GET-only Streaming-Format des Browsers — hier &lt;em&gt;nicht&lt;/em&gt; nutzbar, weil &lt;code&gt;/rag&lt;/code&gt; ein POST ist&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;API&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Application Programming Interface&lt;/td&gt;
&lt;td&gt;Die Grenze, die das Frontend aufruft&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Als Nächstes:&lt;/strong&gt;
— wie CogniVault bearbeitete PDFs neu einliest, ohne alles neu zu embedden, und ein &lt;code&gt;kill -9&lt;/code&gt; mitten in der Pipeline überlebt.&lt;/p&gt;</description></item><item><title>Teil 2 · Hybrid Retrieval in der Praxis: FAISS + BM25, verschmolzen mit RRF</title><link>https://aretascodes.dev/de/blog/hybrid-retrieval-faiss-bm25-rrf/</link><pubDate>Sat, 25 Apr 2026 00:00:00 +0000</pubDate><guid>https://aretascodes.dev/de/blog/hybrid-retrieval-faiss-bm25-rrf/</guid><description>
&lt;blockquote class="border-l-4 border-neutral-300 dark:border-neutral-600 pl-4 italic text-neutral-600 dark:text-neutral-400 my-6"&gt;
&lt;p&gt;Teil einer Serie über die Entwicklung von
, einem vollständig lokalen KI-Lernbegleiter. Zuvor:
.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote class="border-l-4 border-neutral-300 dark:border-neutral-600 pl-4 italic text-neutral-600 dark:text-neutral-400 my-6"&gt;
&lt;p&gt;Alle Abkürzungen werden im Anhang unten auf der Seite vollständig erklärt.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Die erste Version von CogniVault nutzte reines Dense Retrieval – die Suchanfrage mit &lt;code&gt;embeddinggemma&lt;/code&gt; einbetten, in einem FAISS-Index suchen und die Top-7-Chunks an das Modell übergeben. Es funktionierte. Es funktionierte &lt;em&gt;hervorragend&lt;/em&gt; – bis ein Nutzer ein PDF mit deutschen Gesetzestexten hochlud und nach &amp;ldquo;§3 Absatz 2&amp;rdquo; fragte.&lt;/p&gt;
&lt;p&gt;Das Modell konnte es nicht finden.&lt;/p&gt;
&lt;p&gt;Der Chunk war &lt;em&gt;genau da&lt;/em&gt;. Das PDF war indiziert. Aber &amp;ldquo;§3 Absatz 2&amp;rdquo; lässt sich nicht in etwas Semantisch Sinnvolles einbetten – es ist ein Identifikator auf Token-Ebene, kein Konzept. Der dichte Vektor für die Suchanfrage landete nicht einmal in der Nähe des dichten Vektors für den Chunk, obwohl der Chunk exakt den String enthielt, nach dem der Nutzer gefragt hatte.&lt;/p&gt;
&lt;p&gt;Dieser Bug hat reines Dense Retrieval für mich erledigt. In diesem Beitrag geht es darum, womit ich es ersetzt habe.&lt;/p&gt;
&lt;h2 id="zwei-arten-von-ähnlich"&gt;Zwei Arten von &amp;ldquo;ähnlich&amp;rdquo;&lt;/h2&gt;
&lt;p&gt;Du nutzt bereits jeden Tag beide Arten der Suche. Wenn Spotify ein &amp;ldquo;Song Radio&amp;rdquo; basierend auf einem Track erstellt, den du magst, vergleicht es das &lt;em&gt;Gefühl&lt;/em&gt; – Tempo, Stimmung, Genre – und spielt dir gerne einen Song vor, dessen Titel kein einziges Wort mit dem Original gemeinsam hat. Aber wenn du &lt;code&gt;Bohemian Rhapsody remastered 2011&lt;/code&gt; in die Suchleiste tippst, willst du kein &lt;em&gt;Gefühl&lt;/em&gt;. Du willst genau diesen String, und &amp;ldquo;ein ähnliches opernhaftes Rock-Epos&amp;rdquo; ist die falsche Antwort.&lt;/p&gt;
&lt;p&gt;Suchsysteme formalisieren diese Unterscheidung in zwei Konzepte von Ähnlichkeit:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Lexikalische Ähnlichkeit&lt;/strong&gt; – &amp;ldquo;Teilen diese Strings seltene Wörter?&amp;rdquo; Das ist es, was TF-IDF und BM25 modellieren. Sie glänzen bei Identifikatoren, Namen, Code, Fachbegriffen und direkten Zitaten.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Semantische Ähnlichkeit&lt;/strong&gt; – &amp;ldquo;Sprechen diese Passagen über dieselbe Idee, auch wenn sie andere Wörter verwenden?&amp;rdquo; Das ist es, was Embeddings modellieren. Sie glänzen bei Paraphrasen, konzeptionellen Anfragen und natürlichsprachlichen Fragen.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Keines der beiden schließt das andere ein. Ein Nutzer, der fragt: &lt;em&gt;&amp;ldquo;Wie ist die praktische Prüfung aufgebaut?&amp;rdquo;&lt;/em&gt;, braucht die &lt;strong&gt;semantische&lt;/strong&gt; Suche – im Dokument steht nämlich nicht zwingend &amp;ldquo;Aufbau der praktischen Prüfung&amp;rdquo;. Ein Nutzer, der &lt;em&gt;&amp;quot;§3 Absatz 2&amp;quot;&lt;/em&gt; fragt, braucht die &lt;strong&gt;lexikalische&lt;/strong&gt; Suche – da gibt es kein Konzept zum Einbetten, nur einen wörtlichen String.&lt;/p&gt;
&lt;p&gt;Production-RAG muss beides können. CogniVault macht beides und führt die Ergebnislisten dann mit &lt;strong&gt;Reciprocal Rank Fusion (RRF)&lt;/strong&gt; zusammen.&lt;/p&gt;
&lt;h2 id="der-stack"&gt;Der Stack&lt;/h2&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Query
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; ├── embed via embeddinggemma ──► FAISS IndexFlatIP ──► top-K dense
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; └── tokenize + lowercase ──► BM25Okapi ──► top-K sparse
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; │
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Reciprocal Rank Fusion ◄──┘
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; │
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; top-7 fused chunks
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Beide Indizes liegen &lt;strong&gt;im Arbeitsspeicher&lt;/strong&gt;, davor sitzt ein &lt;code&gt;VectorDB&lt;/code&gt;-Singleton. FAISS führt eine Inner-Product-Suche über normalisierte Embeddings durch (das Skalarprodukt entspricht also dem Kosinus). BM25 ist &lt;code&gt;BM25Okapi&lt;/code&gt; aus &lt;code&gt;rank_bm25&lt;/code&gt;, gefüttert mit denselben Chunks, die durch einen einfachen Lowercase-und-Split-Tokenizer in Tokens zerlegt wurden.&lt;/p&gt;
&lt;p&gt;Die Korpora werden synchron gehalten: Wenn man die Chunks einer Datei weich löscht, löst das einen BM25-Rebuild über die verbleibenden aktiven Chunks aus, und das Singleton lädt beide Indizes aus &lt;code&gt;vector_store.faiss&lt;/code&gt; + &lt;code&gt;vector_store.json&lt;/code&gt; (Chunk-Metadaten + Rohtext) nach jedem Ingestion-Lauf und beim App-Start neu.&lt;/p&gt;
&lt;h2 id="warum-faiss-indexflatip-und-nicht-hnsw-oder-ivf"&gt;Warum FAISS &lt;code&gt;IndexFlatIP&lt;/code&gt; und nicht HNSW oder IVF?&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;IndexFlatIP&lt;/code&gt; ist eine exakte Brute-Force-Suche. Es scannt jeden Vektor für jede Anfrage. Bei zehntausenden Chunks ist das völlig in Ordnung – unter einer Millisekunde auf einem Laptop. CogniVault ist eine &lt;strong&gt;lokale Single-User&lt;/strong&gt;-App; der Index wird nie Milliarden von Vektoren haben. Um Recall für Geschwindigkeit über HNSW oder IVF einzutauschen, würde hier nichts bringen und nur die &amp;ldquo;Exakt&amp;rdquo;-Garantie kosten. Langweilig, korrekt, schnell genug.&lt;/p&gt;
&lt;p&gt;Wenn das Korpus so groß wird, dass Brute-Force zu zäh wird, ist der Wechsel nur eine Zeile Code. Bis dahin gewinnt der einfachste Index.&lt;/p&gt;
&lt;h2 id="reciprocal-rank-fusion"&gt;Reciprocal Rank Fusion&lt;/h2&gt;
&lt;p&gt;Der naive Weg, zwei geordnete Listen zu kombinieren, ist, sie zu scoren und zu addieren. Das klingt sinnvoll, bis du dich daran erinnerst, dass FAISS Inner-Product-Scores in einem begrenzten Bereich liefert und BM25 Scores in einem unbegrenzten – sie sind ohne Normalisierung nicht vergleichbar, und jede Normalisierung, die du wählst, ist irgendwie willkürlich.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;RRF umgeht das Problem komplett.&lt;/strong&gt; Es schaut sich nur &lt;em&gt;Ränge&lt;/em&gt; an, keine Scores. Für jede Ergebnisliste trägt ein Item auf Rang &lt;code&gt;r&lt;/code&gt; mit &lt;code&gt;1 / (k + r)&lt;/code&gt; zu seinem End-Score bei (mit &lt;code&gt;k = 60&lt;/code&gt; per Konvention – groß genug, um den Tail abzuflachen, klein genug, damit die Top-Items noch dominieren). Items, die in beiden Listen auftauchen, werden summiert.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Simplified — the real implementation also de-duplicates chunks&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# by (source, chunk_id, page) before scoring.&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;reciprocal_rank_fusion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result_lists&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;scores&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;defaultdict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;result_lists&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chunk_id&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nb"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;chunk_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;kv&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;kv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;reverse&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Das ist schon der ganze Algorithmus. Kein Tuning, keine Kalibrierung, keine Gewichte pro Korpus. Ein Chunk, der bei BM25 auf Platz 1 und bei FAISS auf Platz 4 liegt, schlägt problemlos einen Chunk, der nur in einer der Listen auf Platz 2 ist. Ein Chunk, bei dem sich &lt;em&gt;beide&lt;/em&gt; Indizes einig sind, steigt deterministisch an die Spitze.&lt;/p&gt;
&lt;p&gt;Das Ergebnis für die &amp;ldquo;§3 Absatz 2&amp;rdquo;-Anfrage: BM25 findet den exakten Treffer und platziert ihn auf Rang 1. FAISS findet nichts Brauchbares (seine Top-Treffer handeln allgemein von Prüfungsordnungen). RRF bringt den BM25-Treffer an die Spitze der fusionierten Liste. Problem gelöst.&lt;/p&gt;
&lt;h2 id="scope-filterung-mit-contextvar-isolierung"&gt;Scope-Filterung mit ContextVar-Isolierung&lt;/h2&gt;
&lt;p&gt;Ein Detail, das man leicht falsch macht: Der Retriever muss sich seines &lt;em&gt;Scopes&lt;/em&gt; bewusst sein. In CogniVault können Nutzer eine Frage auf eine einzelne Kategorie oder bestimmte Dateien beschränken. Der Scope wird durch den Request gesetzt, aber die Suche wird tief im Inneren des Strands-Agent-Loops aufgerufen, der wiederum von einem streamenden FastAPI-Handler aufgerufen wird – möglicherweise mit mehreren parallelen Requests pro Worker.&lt;/p&gt;
&lt;p&gt;Den Scope durch jeden Funktionsaufruf durchzureichen, wäre unschön. Eine globale Variable ist unsicher. Das richtige Mittel dafür ist Pythons
, das dir einen task-lokalen, isolierten State gibt, den sowohl &lt;code&gt;asyncio&lt;/code&gt; als auch Threads respektieren.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="nn"&gt;contextvars&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ContextVar&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;_doc_scope&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ContextVar&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;DocScope&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ContextVar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;doc_scope&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;set_doc_scope&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;DocScope&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;_doc_scope&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;current_doc_scope&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;DocScope&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;_doc_scope&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Der &lt;code&gt;/rag&lt;/code&gt;-Request-Handler setzt den Scope ganz am Anfang jeder Streaming-Antwort; das Such-Tool liest ihn; und weil der Wert task-lokal ist, stirbt er mit dem Request. Keine globalen Variablen, kein Durchbohren von Parametern, keine Race Conditions über gleichzeitige Nutzer hinweg.&lt;/p&gt;
&lt;p&gt;Das ist eine dieser Designentscheidungen, die nach Over-Engineering aussehen, bis du zwei Browser-Tabs offen hast und merkst, dass ohne sie der Scope-Filter von Tab A in die Frage von Tab B leaken würde.&lt;/p&gt;
&lt;h2 id="chunking-entscheidungen-die-sich-später-auszahlen"&gt;Chunking-Entscheidungen, die sich später auszahlen&lt;/h2&gt;
&lt;p&gt;Hybrid Retrieval ist nur so gut wie seine Chunks. CogniVault nutzt einen &lt;code&gt;RecursiveCharacterTextSplitter&lt;/code&gt; mit &lt;strong&gt;1.000 Zeichen und 100 Zeichen Overlap&lt;/strong&gt; für unstrukturierten Text – klein genug, um das Retrieval präzise zu halten, groß genug, um Kontext für das Modell zu liefern.&lt;/p&gt;
&lt;p&gt;Für strukturierte Formate ändert sich die Strategie:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Markdown&lt;/strong&gt; → &lt;code&gt;MarkdownHeaderTextSplitter&lt;/code&gt; liefert einen Chunk pro H1/H2/H3-Abschnitt, wobei die Überschriftenhierarchie als Brotkrümel vorangestellt wird (&amp;ldquo;Privacy &amp;gt; Vault Audit &amp;gt; Indicators&amp;rdquo;). BM25 liebt Brotkrümel – sie lassen Anfragen mit Überschriften-Keywords sauber matchen.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CSV&lt;/strong&gt; → Kopfzeile + 20 Zeilen pro Batch als Chunk, sodass eine Suche nach einem Spaltennamen im richtigen Block landet.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;PPTX&lt;/strong&gt; → ein Chunk pro Folie, Titel und Body-Text zusammen.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;XLSX&lt;/strong&gt; → Kopfzeile + Zeilen-Batches pro Sheet, mit einem &lt;code&gt;[Sheet: name]&lt;/code&gt; Präfix.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Winzige Fragmente werden gefiltert: Unstrukturierter Text braucht mindestens &lt;strong&gt;100 Zeichen&lt;/strong&gt;, um ein Chunk zu werden, während die strukturierten Formate die Messlatte auf &lt;strong&gt;20&lt;/strong&gt; senken – ein zweizeiliger Markdown-Abschnitt oder ein Sheet, das nur aus Überschriften besteht, ist zwar kurz, aber immer noch aussagekräftig. Der rekursive Splitter ist altbekanntes Terrain, aber die formatabhängigen Strategien sind viel wichtiger, als man ihnen oft zugesteht.&lt;/p&gt;
&lt;h2 id="was-ich-anders-machen-würde"&gt;Was ich anders machen würde&lt;/h2&gt;
&lt;p&gt;Ein paar Dinge, die ich noch einmal überdenken würde, wenn ich noch einmal von vorn anfangen würde:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Aufhören, für BM25 mit &lt;code&gt;str.split()&lt;/code&gt; zu tokenisieren.&lt;/strong&gt; Es ist okay, aber ein echter Tokenizer, der mit Satzzeichen und deutschen Komposita umgehen kann, würde den Recall bei den rechtlichen Dokumenten deutlich verbessern.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Einen kleinen Reranker hinzufügen.&lt;/strong&gt; RRF findet das richtige &lt;em&gt;Set&lt;/em&gt;, aber ein Cross-Encoder-Rerank auf den Top 20 würde die &lt;em&gt;Reihenfolge&lt;/em&gt; aufpolieren. Natürlich lokal gehostet – da gibt es mittlerweile gute kleine Modelle.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Query Expansion für dünne Anfragen.&lt;/strong&gt; Zwei-Wort-Fragen wie &amp;ldquo;§3 Prüfung&amp;rdquo; könnten vor dem Retrieval über einen schnellen &lt;code&gt;gemma4&lt;/code&gt;-Aufruf erweitert werden. Kostet Latenz, bringt aber Recall.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Nichts davon ist bisher an Bord. RRF über FAISS + BM25 ist schon so viel besser als jedes für sich allein, dass ich noch nicht den Drang gespürt habe, weiter zu optimieren.&lt;/p&gt;
&lt;h2 id="fazit"&gt;Fazit&lt;/h2&gt;
&lt;p&gt;Wenn dein Retrieval &amp;ldquo;embed + cosine + top-k&amp;rdquo; ist, wird es genau auf dieselbe Weise scheitern wie meins – bei Anfragen, die wortwörtliche Identifikatoren enthalten, für die dein Modell kein Embedding hat. Die Lösung ist kein besseres Embedding-Modell. Es ist ein zweiter Retriever, der nicht so tut, als wäre alles ein Konzept.&lt;/p&gt;
&lt;p&gt;FAISS für Ideen. BM25 für Strings. RRF entscheidet, wer heute Recht hat.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="anhang-abkürzungen-in-diesem-beitrag"&gt;Anhang: Abkürzungen in diesem Beitrag&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Abkürzung&lt;/th&gt;
&lt;th&gt;Vollform&lt;/th&gt;
&lt;th&gt;Bedeutung&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;RAG&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Retrieval-Augmented Generation&lt;/td&gt;
&lt;td&gt;Rufe zuerst relevante Passagen aus deinen eigenen Dokumenten ab; lass das Modell dann basierend darauf antworten&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;FAISS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Facebook AI Similarity Search&lt;/td&gt;
&lt;td&gt;Metas Bibliothek zum Speichern von Vektoren und zum schnellen Finden der ähnlichsten&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;BM25&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Best Match 25&lt;/td&gt;
&lt;td&gt;Eine Keyword-Ranking-Formel – die 25. Ranking-Funktion, die im Informationsretrieval-System Okapi entwickelt wurde&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;RRF&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Reciprocal Rank Fusion&lt;/td&gt;
&lt;td&gt;Führt geordnete Listen nur anhand der Ränge zusammen: Jedes Item punktet mit &lt;code&gt;Σ 1/(k + rank)&lt;/code&gt; über alle Listen hinweg&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;TF-IDF&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Term Frequency–Inverse Document Frequency&lt;/td&gt;
&lt;td&gt;Der Vorfahre von BM25: Bewertet Wörter danach, wie oft sie hier auftauchen vs. wie selten sie überall sonst sind&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;IP&lt;/strong&gt; (in &lt;code&gt;IndexFlatIP&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Inner Product&lt;/td&gt;
&lt;td&gt;Das Ähnlichkeitsmaß, das FAISS berechnet; bei normalisierten Vektoren entspricht es der Kosinus-Ähnlichkeit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;HNSW&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Hierarchical Navigable Small World&lt;/td&gt;
&lt;td&gt;Eine beliebte Struktur für &lt;em&gt;approximative&lt;/em&gt; Vektor-Indizes – hier bewusst nicht verwendet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;IVF&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Inverted File Index&lt;/td&gt;
&lt;td&gt;Ein weiterer approximativer FAISS-Indextyp – ebenfalls bewusst nicht verwendet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;AEVO&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Ausbildereignungsverordnung&lt;/td&gt;
&lt;td&gt;Das deutsche Gesetz, dessen Anfrage &amp;ldquo;§3 Absatz 2&amp;rdquo; das reine Dense Retrieval zum Scheitern brachte&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;CSV / PPTX / XLSX&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Comma-Separated Values / PowerPoint / Excel (Office Open XML)&lt;/td&gt;
&lt;td&gt;Strukturierte Formate mit ihren eigenen Chunking-Strategien&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;H1/H2/H3&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Heading levels 1–3&lt;/td&gt;
&lt;td&gt;Die Markdown-Überschriftenebenen, die zum Aufteilen von Abschnitten verwendet werden&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Als Nächstes:&lt;/strong&gt;
— wie der &lt;code&gt;/rag&lt;/code&gt;-Endpoint von CogniVault das &lt;em&gt;Denken&lt;/em&gt; von Gemma 4 streamt, bevor Tool-Aufrufe starten.&lt;/p&gt;</description></item><item><title>Teil 1 · Warum ich ein Local-First RAG gebaut habe</title><link>https://aretascodes.dev/de/blog/why-local-first-rag/</link><pubDate>Mon, 20 Apr 2026 00:00:00 +0000</pubDate><guid>https://aretascodes.dev/de/blog/why-local-first-rag/</guid><description>
&lt;blockquote class="border-l-4 border-neutral-300 dark:border-neutral-600 pl-4 italic text-neutral-600 dark:text-neutral-400 my-6"&gt;
&lt;p&gt;Alle Abkürzungen werden vollständig im Anhang am Ende der Seite erklärt.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Ich habe die letzten paar Jahre vor virtuellen Klassen voller Quereinsteiger in Deutschland verbracht und ihnen die Grundlagen des Programmierens, der Webentwicklung und Einführungskurse in KI nähergebracht. Ein Großteil der Informationen, mit denen wir zu tun haben, kann man problemlos in Cloud-basierte KI-Tools kopieren. Einiges davon aber definitiv nicht.&lt;/p&gt;
&lt;p&gt;Prüfungsmaterialien, die der Geheimhaltung unterliegen. Das Portfolio eines Trainees mit persönlichen Details. Andere private Dokumente, die niemals das Modell von jemand anderem trainieren sollten.&lt;/p&gt;
&lt;p&gt;Also habe ich
gebaut — ein komplett lokales KI-Lern- und Produktivitäts-Tool. Keine Cloud. Keine Telemetrie. Kein &amp;ldquo;Wir könnten diese Daten verwenden, um unseren Service zu verbessern&amp;rdquo;. Einfach nur Gemma 4, das auf Ollama auf meinem Laptop läuft und mit meinen Dateien spricht.&lt;/p&gt;
&lt;h2 id="die-undichte-abstraktion"&gt;Die undichte Abstraktion&lt;/h2&gt;
&lt;p&gt;Der Pitch für Cloud-KIs ist großartig: ein riesiges Modell, sofort verfügbar, abgerechnet nach Token. Das Kleingedruckte ist der Teil, an dem es unbequem wird:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Wo genau liegen die Daten physisch während der Inferenz?&lt;/li&gt;
&lt;li&gt;Welcher Gerichtsbarkeit unterliegt diese Hardware heute Nachmittag?&lt;/li&gt;
&lt;li&gt;Endet der &lt;em&gt;Audit Trail&lt;/em&gt; an der API-Grenze, oder kannst du wirklich nachverfolgen, was mit deinen Bytes passiert ist?&lt;/li&gt;
&lt;li&gt;Wenn du das Häkchen bei &amp;ldquo;Nicht mit meinen Daten trainieren&amp;rdquo; setzt, vertraust du dann auf ein technisches Kontrollsystem, einen Vertrag oder beides?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Für die meisten Consumer-Use-Cases kann man diese Fragen getrost wegwinken. Für &lt;strong&gt;Bildung, Gesundheitswesen, Finanzen, Recht, öffentliche Verwaltung&lt;/strong&gt; ist die Antwort &amp;ldquo;Vertrau uns&amp;rdquo; einfach keine Antwort.&lt;/p&gt;
&lt;h2 id="was-local-first-hier-tatsächlich-bedeutet"&gt;Was &amp;ldquo;Local-First&amp;rdquo; hier tatsächlich bedeutet&lt;/h2&gt;
&lt;p&gt;Viele Produkte nennen sich &amp;ldquo;privat&amp;rdquo;. Ich wollte drei handfeste Eigenschaften:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Das Modell lebt auf deiner Maschine.&lt;/strong&gt; Gemma 4 (&lt;code&gt;gemma4:e4b&lt;/code&gt;) und &lt;code&gt;embeddinggemma&lt;/code&gt; werden via Ollama gezogen. Die Inferenz ist ein lokaler HTTP-Aufruf auf localhost.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Deine Dokumente verlassen deinen Rechner niemals.&lt;/strong&gt; Vektoren, Chunks, Chat-Historie, Lernsessions, Achievements — alles bleibt auf der Festplatte deines Computers.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Du kannst es &lt;em&gt;überprüfen&lt;/em&gt;.&lt;/strong&gt; Gemma CogniVault bringt ein &lt;strong&gt;Privacy Audit Panel&lt;/strong&gt; mit, das live einen &amp;ldquo;Null externe Verbindungen&amp;rdquo;-Indikator neben der Dokumentenanzahl und dem Ollama-Host anzeigt. Das ist kein Versprechen — das ist ein Statuslämpchen.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Wenn ein zukünftiger Build von Gemma CogniVault jemals einen ausgehenden Anruf nach Hause machen würde, würde dieses Panel als erstes Alarm schlagen.&lt;/p&gt;
&lt;h2 id="was-du-dafür-bekommst"&gt;Was du dafür bekommst&lt;/h2&gt;
&lt;p&gt;Auf lokal zu wechseln klingt nach einem Kompromiss — verliert man nicht die Magie der gigantischen Frontier-Modelle? In der Praxis hast du mit &lt;strong&gt;Gemma 4&lt;/strong&gt; mehr als genug:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Thinking-Modus&lt;/strong&gt; — Die Chain-of-Thought von Gemma 4 streamt in ein ausklappbares Panel, bevor die Antwort kommt. Dem Modell beim Nachdenken über deine Dokumente zuzusehen, ist ein wirklich nützliches Lehrmittel.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tool-Nutzung&lt;/strong&gt; — Über das
entscheidet das Modell, wann es die Knowledge Base durchsuchen, ein Dokument zusammenfassen, zwei Dateien vergleichen oder die Uhrzeit checken soll.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Vision&lt;/strong&gt; — Hänge Bilder und PDFs direkt in den Chat an.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Generierung, die wirklich strukturiert ist&lt;/strong&gt; — Quizzes, Multi-Lektionen-Workshops, Karteikarten-Decks und interaktive Mindmaps, die mit &lt;code&gt;format=&amp;quot;json&amp;quot;&lt;/code&gt; generiert werden, sodass der Output zuverlässig geparst werden kann.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Cognivault versucht nicht, ein riesiges Ökosystem zu sein. Es ist ein Single-Purpose-Tool, das eine Sache richtig gut macht: deine eigenen Dokumente mit einem fähigen lokalen Modell in einer privaten Umgebung nutzen. Ich muss zugeben, dass es stark von
inspiriert wurde, was ich unglaublich nützlich, aber für meine Zwecke einfach nicht privat genug fand.&lt;/p&gt;
&lt;h2 id="der-aufbau-der-app"&gt;Der Aufbau der App&lt;/h2&gt;
&lt;p&gt;CogniVault ist in vier Bereiche unterteilt, die abbilden, wie ich tatsächlich mit Informationen auf Cloud-basierten KI-Tools arbeite:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Bereich&lt;/th&gt;
&lt;th&gt;Wofür es da ist&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Chat&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Frag alles über deine Dokumente. Zitierte Antworten, Scope-Filter, Spracheingabe.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Knowledge Base&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Hochladen, kategorisieren, verwalten. SHA-256 erkennt Bearbeitungen beim erneuten Upload.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Study Hub&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Quiz · Workshop · Flashcards · Mindmaps — vier Wege, tiefer in die Quelle einzusteigen.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Dashboard&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Gesamte Lernzeit, Streak, 25 Badges, GitHub-Style 90-Tage-Heatmap.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Alles ist über eine Sidebar erreichbar, die sich merkt, wo du aufgehört hast, auf einem Tech-Stack, der in deinen &lt;code&gt;~/Documents&lt;/code&gt;-Ordner passt.&lt;/p&gt;
&lt;h2 id="was-als-nächstes-kommt"&gt;Was als Nächstes kommt&lt;/h2&gt;
&lt;p&gt;Das hier ist der Start einer kurzen Serie. In den nächsten Posts werde ich genauer auf die Teile eingehen, auf die ich am stolzesten bin — und ein paar, die ich beim nächsten Mal anders bauen würde:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Hybrides Retrieval&lt;/strong&gt; — Warum FAISS &lt;em&gt;und&lt;/em&gt; BM25, zusammengeführt mit Reciprocal Rank Fusion&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Zwei-Phasen-Streaming&lt;/strong&gt; mit Gemma 4 und Strands Agents&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Crash-resistente Ingestion&lt;/strong&gt; mit DBOS, Hash-bewusster Re-Ingest, OCR-Fallback&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Zuverlässiges JSON&lt;/strong&gt; aus einem lokalen LLM bekommen (und was man tut, wenn es fehlschlägt)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Der Mindmap-Renderer&lt;/strong&gt; — Was ich beim handgeschriebenen SVG gelernt habe und warum v2 React Flow nutzt&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Lernen gamifizieren&lt;/strong&gt; — 25 Badges, Idle-Gap-Sessions, 90-Tage-Heatmap&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Eine lokale KI-App testen&lt;/strong&gt; mit über 350 Tests und komplett ohne Infrastruktur&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Wenn du schon mal reinschauen willst, der Code ist Open Source auf
, und es gibt einen
.&lt;/p&gt;
&lt;p&gt;Deine Daten. Deine Hardware. Deine KI. Dein Vault.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="anhang-abkürzungen-in-diesem-post"&gt;Anhang: Abkürzungen in diesem Post&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Abkürzung&lt;/th&gt;
&lt;th&gt;Volle Form&lt;/th&gt;
&lt;th&gt;Bedeutung&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;RAG&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Retrieval-Augmented Generation&lt;/td&gt;
&lt;td&gt;Relevante Passagen aus deinen Dokumenten abrufen; das Modell antwortet basierend darauf statt aus dem Trainingsgedächtnis&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;AI&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Artificial Intelligence&lt;/td&gt;
&lt;td&gt;Software, die Aufgaben ausführt, für die normalerweise menschliche Intelligenz erforderlich ist&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;LLM&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Large Language Model&lt;/td&gt;
&lt;td&gt;Ein neuronales Netz, das mit riesigen Mengen an Text trainiert wurde und Sprache lesen sowie generieren kann&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;HTTP&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;HyperText Transfer Protocol&lt;/td&gt;
&lt;td&gt;Das Protokoll, das Browser und APIs nutzen, um Requests und Responses auszutauschen&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;API&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Application Programming Interface&lt;/td&gt;
&lt;td&gt;Die Grenze, an der du Software von jemand anderem aufrufst — und an der Cloud-Audit-Trails enden&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;IHK&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Industrie- und Handelskammer&lt;/td&gt;
&lt;td&gt;Die Institution, die in Deutschland unter anderem die Ausbildereignungsprüfung durchführt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;AEVO&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Ausbildereignungsverordnung&lt;/td&gt;
&lt;td&gt;Das Prüfungsmaterial in Deutschland, das den Anstoß für dieses Projekt gab&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;FAISS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Facebook AI Similarity Search&lt;/td&gt;
&lt;td&gt;Metas Vektorsuch-Bibliothek (Thema im nächsten Post)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;BM25&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Best Match 25&lt;/td&gt;
&lt;td&gt;Eine klassische Keyword-Ranking-Formel (ebenfalls im nächsten Post)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SDK&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Software Development Kit&lt;/td&gt;
&lt;td&gt;Eine Sammlung von Bausteinen — hier Strands, das die Agenten-Loop bereitstellt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;JSON&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;JavaScript Object Notation&lt;/td&gt;
&lt;td&gt;Das universelle Textformat für strukturierte Daten&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;PDF&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Portable Document Format&lt;/td&gt;
&lt;td&gt;Eines der über acht Dateiformate, die CogniVault verarbeitet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SHA-256&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Secure Hash Algorithm, 256-bit&lt;/td&gt;
&lt;td&gt;Ein inhaltlicher Fingerabdruck, um bearbeitete Dateien beim erneuten Upload zu erkennen&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;OCR&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Optical Character Recognition&lt;/td&gt;
&lt;td&gt;Bilder von Text (Scans) in maschinenlesbaren Text verwandeln&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;DBOS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Database-Oriented Operating System&lt;/td&gt;
&lt;td&gt;Die Bibliothek für durable Workflows, die hinter der crash-resistenten Ingestion steckt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SVG&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Scalable Vector Graphics&lt;/td&gt;
&lt;td&gt;Das im Browser eingebaute Format fürs Vektorzeichnen&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;</description></item></channel></rss>