Eine Wissensbasis aus 2000 Bruchstücken
Eine Wissensbasis aus 2000 Bruchstücken
Inhaltsverzeichnis
- Ein Bot, der auf alles mit demselben Link antwortet
- Die Zahl, die seit jeher null war
- 31 Zeichen pro Wissensfetzen
- Ein Vorgabewert, der still das Falsche tut
- Die Schutzfunktion, die den Schaden anrichtete
- Ein Kontextfenster, das sich nicht beschwert
- Warum es dazu noch langsam war
- Der Wächter zählte die falsche Größe
- Nachtrag: der Bericht als Fehlerquelle
Ein Bot, der auf alles mit demselben Link antwortet
Der Chatbot auf dieser Seite beantwortet Fragen zu den Blogbeiträgen. Er sucht die passenden Textstellen in einem Vektor-Index und lässt ein lokales Sprachmodell daraus eine Antwort formulieren. Seit Monaten lief er, ohne aufzufallen. Genauer gesagt: ohne dass jemand hinsah.
Ein Gesprächsverlauf machte deutlich, wie schlecht er tatsächlich war:
> hallo
Hallo! Wie kann ich Ihnen heute helfen? Für technische Details zu
Chatbot-Implementierungen, Docker-Problemen oder n8n-Workflows finden Sie
weitere Informationen auf https://matzka.cloud/blog
> was gibt es neues
Neue Services-Seiten erklären Cloud-Dienste verständlich:
https://matzka.cloud/blog Ein neues Design-System wurde eingeführt:
https://matzka.cloud/blog
> zeig mir den neuesten blogpost
Der neueste Blogbeitrag ist vom 9. Januar 2026 und beschreibt die
Infrastruktur und Ziele von Matzka Cloud. Sie finden ihn hier:
https://matzka.cloud/blog
Jede Antwort endet auf derselben Übersichtsseite. Der genannte Beitrag ist der älteste, nicht der neueste. Und die Begrüßung dauerte 24 Sekunden.
Die naheliegende Erklärung wäre: das Modell ist zu klein, die Hardware zu schwach. Beides war falsch. Es waren vier voneinander unabhängige Fehlkonfigurationen, von denen keine eine Fehlermeldung erzeugt hat.
Die Zahl, die seit jeher null war
Der Chatbot schreibt zu jeder Anfrage eine Protokollzeile: Frage, Antwort, ob überhaupt Quellen gefunden wurden und wie viele davon in die Antwort eingingen. Genau dieses Zählfeld stand in jeder Zeile auf null. Nicht seit gestern, sondern seit die Tabelle existiert.
Das ist ein nützlicher Befund, weil er eine ganze Klasse von Verdächtigen ausschließt. Die Suche fand durchaus Treffer, das Protokoll wies sie aus. Aber keiner dieser Treffer brachte eine Quellenangabe mit. Wenn eine Zahl seit Beginn konstant null ist, liegt der Fehler selten im Betrieb und meistens im Aufbau.
31 Zeichen pro Wissensfetzen
Der Vektor-Index meldete rund 2000 Einträge und war damit prall gefüllt. Erst eine Stichprobe zeigte, woraus er bestand. Die mittlere Länge eines Abschnitts lag bei 31 Zeichen. Die Einträge sahen so aus:
"blog_post"
"Infrastructure"
"2026-08-13T00:00:00.513Z"
"Reinhard Matzka"
Das sind keine Textabschnitte, sondern die Feldwerte der Datensätze. Der Index enthielt statt der Blogtexte deren Metadaten, jeden Wert einzeln als eigenen Eintrag. Ein Suchtreffer auf „Infrastructure" beantwortet keine Frage.
Was fehlte, war ebenso aufschlussreich: Titel und Adresse der Beiträge kamen in den gespeicherten Daten überhaupt nicht vor. Damit war die Ursache der Null aus dem vorigen Abschnitt gefunden. Wo keine Adresse gespeichert ist, kann keine Quelle genannt werden.
Ein Vorgabewert, der still das Falsche tut
Die Aufbereitung läuft in n8n. Ein Schritt holt die Beiträge aus dem CMS, entfernt HTML und Emojis und baut daraus saubere Dokumente mit Fließtext und Metadaten. Dieser Schritt funktionierte einwandfrei. Der nächste Schritt, der die Dokumente in Abschnitte zerlegen und einbetten soll, war schlicht leer konfiguriert.
Der zuständige Baustein heißt in n8n „Default Data Loader". Ohne Konfiguration nimmt er die Voreinstellung, das komplette Eingabeobjekt zu verarbeiten. Er serialisiert also den ganzen Datensatz als JSON und zerlegt ihn entlang der Blattwerte. Der vorbereitete Fließtext wird dabei zu einem Feld unter vielen, gleichrangig mit dem Autorennamen und dem Zeitstempel.
Die Korrektur ist klein. Der Loader muss wissen, welches Feld den Text enthält, welche Felder als Metadaten mitwandern sollen und wie zerlegt wird. Danach hatte jeder gespeicherte Abschnitt Titel und Adresse, und die mittlere Abschnittslänge stieg von 31 auf 996 Zeichen.
Die Schutzfunktion, die den Schaden anrichtete
Bleibt die Frage, warum ausgerechnet die Blog-Übersicht in jeder Antwort auftauchte. Die Erklärung ist eine Schutzfunktion, die genau umgekehrt wirkte als gedacht.
Sprachmodelle erfinden Links. Deshalb prüft ein nachgelagerter Schritt jede Adresse in der Antwort gegen eine Liste erlaubter Adressen und ersetzt alles Unbekannte durch einen sicheren Verweis auf die Blog-Übersicht. Vernünftig, solange die Liste gefüllt ist. Diese Liste wurde aber aus den Quellenangaben der Treffer gebaut, und die waren aus dem oben beschriebenen Grund immer leer.
Damit galt jede Adresse als erfunden. Auch die richtigen. Das Modell nannte durchaus den passenden Beitrag, und die Schutzfunktion ersetzte den korrekten Link kurz vor der Auslieferung durch die Übersichtsseite. Was nach einem ahnungslosen Bot aussah, war zu einem guten Teil ein Filter, der die richtige Antwort unkenntlich machte.
Zwei Änderungen daran. Unbekannte Adressen werden jetzt entfernt statt ersetzt, sodass ein leerer Filter zu einem Satz ohne Link führt und nicht zu einem falschen Link. Und die Liste der erlaubten Adressen enthält zusätzlich alle Adressen, die im Volltext der Treffer stehen, nicht nur die in den Metadaten.
Ein Kontextfenster, das sich nicht beschwert
Der dritte Fehler saß im Prompt. Das Modell arbeitet mit einem Kontextfenster von 4096 Token. Hineingereicht wurden 15 Textabschnitte, bis zu 16 Nachrichten Gesprächsverlauf und 800 reservierte Token für die Antwort. Das passt nicht.
Ollama meldet das nicht als Fehler. Es kürzt den Prompt und zwar von vorn. Vorn stand die Wissensdatenbank. Das Modell bekam also die Anweisungen und den Gesprächsverlauf, aber nicht die Quellen, aus denen es antworten sollte. Es tat daraufhin das Vernünftigste, was ihm möglich war, und verwies auf den Blog.
Das Kontextfenster ließ sich nicht einfach vergrößern. Ein 14B-Modell belegt in 4-Bit-Quantisierung bereits 11,4 von 12 GB Grafikspeicher. Mehr Kontext bedeutet mehr Zwischenspeicher für die Aufmerksamkeit, und der verdrängt Modellgewichte in den Arbeitsspeicher, was alles deutlich langsamer macht. Der Prompt musste also kleiner werden: drei Quellen zu je 700 Zeichen, Gesprächsverlauf auf drei Runden begrenzt, Antwortlänge auf 500 Token. Statt 15 angerissener Fundstellen jetzt drei vollständige.
Warum es dazu noch langsam war
Die Langsamkeit hatte mit alldem nichts zu tun. Die Laufzeiten der einzelnen Schritte einer Anfrage sind eindeutig: Einbettung der Frage 0,9 Sekunden, Vektorsuche 0,1 Sekunden, sämtliche Datenbankzugriffe und Aufbereitungsschritte zusammen unter einer Zehntelsekunde. Der Aufruf des Sprachmodells: 13 Sekunden.
Das eingesetzte Modell ist ein Reasoning-Modell. Ohne gegenteilige Anweisung denkt es erst sichtbar nach und erzeugt hunderte Token, die niemand zu sehen bekommt, bevor die eigentliche Antwort beginnt. Die Anweisung dagegen ist ein einzelnes Schlüsselwort im System-Prompt. In der Kette gab es zwei Modellaufrufe, und nur der unwichtigere von beiden hatte es.
Mit dem Schlüsselwort im Antwort-Aufruf fiel die Zeit von 13 auf 3,3 Sekunden. Dazu kommt, dass der erste Modellaufruf, der eine Rückfrage in eine eigenständige Suchanfrage umschreibt, bei jeder Nachricht lief. Auch bei „hallo". Er läuft jetzt nur noch, wenn es einen Gesprächsverlauf gibt und die Frage sich erkennbar darauf bezieht.
Der Wächter zählte die falsche Größe
Für die Wissensbasis gibt es seit einem früheren Ausfall eine Überwachung. Sie prüft, ob der Index leer ist und ob die nächtliche Aktualisierung noch läuft. Beides war in Ordnung. Der Index enthielt 2000 Einträge und wurde jede Nacht neu gebaut.
Genau das ist das Problem. Die Überwachung entstand nach einem Ausfall, bei dem die Wissensbasis leer war, und sie misst seither zuverlässig die Menge. Gegen eine gefüllte, aber wertlose Wissensbasis ist sie blind. Der Defekt lief monatelang, und gefunden hat ihn am Ende ein Mensch, der einen Gesprächsverlauf gelesen hat.
Die Prüfung bekommt deshalb drei zusätzliche Kriterien: den Anteil der Einträge mit hinterlegter Quellenadresse, die mittlere Abschnittslänge über eine Stichprobe und die Frage, ob das Übersichtsdokument existiert. Alle drei hätten diesen Fehler am ersten Tag gemeldet.
Ein Nachtrag zur Aktualität. Auf „was gibt es Neues" konnte der Bot prinzipiell nicht antworten, weil die Beiträge im CMS kein Veröffentlichungsdatum haben. Eine Ähnlichkeitssuche kann Aktualität nicht ermitteln, sie kennt nur Bedeutungsnähe. Die Aufbereitung legt jetzt zusätzlich ein kurzes Übersichtsdokument an, das die sieben jüngsten Beiträge mit Titel und Adresse in der richtigen Reihenfolge auflistet. Das Dokument bleibt bewusst unter einer Abschnittslänge, damit es nicht zerteilt wird und das Bruchstück ohne Einleitung obenauf landet.
Vorher und nachher
| Messgröße | vorher | nachher |
|---|---|---|
| Antwortzeit (Median) | 12,3 s | 4,7 s |
| Genannte Quellen je Antwort | 0 | 3 |
| Mittlere Abschnittslänge im Index | 31 Zeichen | 996 Zeichen |
| Einträge mit Quellenadresse | keine | alle |
Auf „zeig mir den neuesten Blogpost" nennt der Bot jetzt in drei Sekunden den richtigen Titel mit direktem Link. Verändert wurde dafür kein Modell und keine Hardware, sondern die Konfiguration von vier Verarbeitungsschritten.
Nachtrag: der Bericht als Fehlerquelle
Noch am selben Tag stellte sich heraus, dass eine Frage weiterhin falsch beantwortet wurde. Auf „zeig mir den neuesten Blogpost" nannte der Bot einen vier Tage älteren Beitrag und datierte ihn auf den 9. Januar 2026. Die Quellenangabe verwies auf einen dritten Beitrag, der mit der Auskunft nichts zu tun hatte.
Der Index war zu diesem Zeitpunkt bereits repariert: 833 Abschnitte, im Mittel knapp 1000 Zeichen, jeder mit Titel und Adresse. Auch das Übersichtsdokument stand darin, und seine Reihenfolge stimmte. Der Bot hatte die richtige Information im Kontext und gab trotzdem eine falsche Antwort.
Ein Feld, das es nie gab
Die Aufbereitung setzte das Veröffentlichungsdatum aus dem Feld
date_published. Die Sammlung blog_posts führt aber nur
id, title, slug, content,
excerpt und published. Ein Datumsfeld gibt es nicht. Der
Ausdruck lieferte also in jedem Durchlauf null, und zwar lautlos: Ein
nicht vorhandenes Feld ist in JavaScript kein Fehler, sondern undefined.
In allen 833 Einträgen stand als Datum nichts.
Warum ausgerechnet der 9. Januar
Ohne Datum in den Metadaten bleibt dem Modell nur, eines aus dem Fließtext zu nehmen. Im Index kam „9. Januar 2026" zwölfmal vor, und sieben dieser Stellen gehören zu drei Beiträgen, die an diesem Tag erschienen sind. Vier allein entfallen auf den ältesten Beitrag überhaupt, dessen Beschreibung sich wörtlich in der falschen Antwort wiederfindet. Der Bot hat also nicht geraten, sondern das häufigste Datum genommen, das er finden konnte.
Der eigene Fehlerbericht im Index
Eine der zwölf Fundstellen steht in diesem Beitrag. Der Gesprächsverlauf ganz oben, der die falschen Antworten dokumentiert, enthält die Zeile mit dem 9. Januar im Klartext. Beim nächsten Durchlauf wurde er eingelesen, zerteilt und liegt seither als gewöhnlicher Wissensfetzen im Index, ohne Kennzeichnung als Zitat.
Damit beantwortet der Bot eine Frage nach dem neuesten Beitrag mit einem Text, der genau diese Antwort als Fehler ausweist. Wer über Fehler seines eigenen Systems schreibt und den Text anschließend indexiert, füttert es mit seinen eigenen Falschaussagen.
Das Datum steht im Text
Die Sammlung führt kein Datum, die Beiträge selbst schon: In der Kopfzeile jedes
Artikels steht es als „Datum 14. August 2026". 67 der 77 Beiträge haben es dort in
einheitlicher Form. Die Aufbereitung liest es jetzt mit einem Ausdruck aus dem Text,
normalisiert es und hängt es als published_date an jeden Abschnitt. Das
Übersichtsdokument weist es zusätzlich im Klartext aus:
1. Eine Wissensbasis aus 2000 Bruchstücken (veroeffentlicht am 14. August 2026)
2. Der Fenster-Sommelier kommt im Maßanzug (veroeffentlicht am 10. August 2026)
3. Wir haben eine Firma gegründet, die nichts verkauft (veroeffentlicht am 10. August 2026)
Sortiert wird weiterhin nach der fortlaufenden Nummer und nicht nach dem gelesenen Datum. Die Nummer haben alle 77 Beiträge, das Datum nur 67, und beide Reihenfolgen unterscheiden sich lediglich bei zwei Beiträgen desselben Tages. Die unsichere Angabe bestimmt also die Anzeige, die sichere die Reihenfolge.
| Messgröße | vorher | nachher |
|---|---|---|
| Abschnitte mit Veröffentlichungsdatum | 0 von 833 | 763 von 834 |
| Datum im Übersichtsdokument | nicht enthalten | bei jedem Eintrag |
| Antwort auf „zeig mir den neuesten Blogpost" | falscher Beitrag, 9. Januar 2026 | richtiger Beitrag, 14. August 2026 |
Der Bot antwortet seither: „Der neueste Blogbeitrag lautet ‚Eine Wissensbasis aus 2000 Bruchstücken' und wurde am 14. August 2026 veröffentlicht." Mit passender Quelle und richtigem Link.