<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>AI Engineering |</title><link>https://aretascodes.dev/de/categories/ai-engineering/</link><atom:link href="https://aretascodes.dev/de/categories/ai-engineering/index.xml" rel="self" type="application/rss+xml"/><description>AI Engineering</description><generator>HugoBlox Kit (https://hugoblox.com)</generator><language>de-DE</language><lastBuildDate>Sun, 10 May 2026 00:00:00 +0000</lastBuildDate><image><url>https://aretascodes.dev/media/icon_hu_2ab4f4763b27c75b.png</url><title>AI Engineering</title><link>https://aretascodes.dev/de/categories/ai-engineering/</link></image><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>