Strukturierte Daten von Hand pflegt kaum eine Redaktion durch. Ein Sprachmodell kann sie aus jedem Text erzeugen, macht dabei aber typische Fehler. Was wir aus einem Prototyp im Echtbetrieb gelernt haben, warum wir den Prompt gezielt gegen das Vorwissen des Modells geschrieben haben und wie man das Ganze in Europa oder lokal betreibt.
Strukturierte Daten sind der Teil einer Webseite, den nur Maschinen lesen. Ein kleiner Block im Format JSON-LD sagt Suchmaschinen und KI-Systemen: Das hier ist eine Pressemitteilung, das ein Veranstaltungshinweis mit Datum und Ort, das eine Stellenanzeige. Wer das sauber auszeichnet, wird besser verstanden, bekommt reichere Suchergebnisse und taucht eher in KI-Antworten auf. Warum das so ist, habe ich im Artikel über JSON-LD, Wikidata und Entitäten beschrieben, und wie sehr das KI-Antworten prägt, am Beispiel des coolsten Typs in Bayreuth ausprobiert.
Das Problem ist nicht das Wissen, sondern die Arbeit. Eine Redaktion, die jede Woche zwanzig Meldungen veröffentlicht, schreibt kein JSON-LD von Hand. Also haben wir es eine KI machen lassen.
Der Prototyp: Text rein, JSON-LD raus
Für eine Organisation mit großer Redaktion habe ich einen kleinen Generator gebaut. Die Oberfläche ist schlicht: ein Textfeld, ein Knopf „MarkUp erstellen“, darunter das fertige <script type="application/ld+json">. Ein Doppelklick kopiert den Block in die Zwischenablage, die Redaktion fügt ihn ins Redaktionssystem ein.
Dahinter steckt ein einziger Aufruf eines Sprachmodells, in PHP über die verbreitete OpenAI-Bibliothek:
$result = $client->chat()->create([
'model' => $modell,
'messages' => [
['role' => 'system', 'content' => $briefing],
['role' => 'user', 'content' => $seitentext],
],
'temperature' => 0,
]);
echo "<script type='application/ld+json'>"
. $result->choices[0]->message->content . "</script>";
Temperatur 0, weil wir keine Kreativität wollen, sondern jedes Mal dasselbe Ergebnis für denselben Text. Die eigentliche Arbeit steckt im Briefing, also im Systemprompt. Das ist dieselbe Disziplin wie beim Copywriter-Briefing, das zum Prompt wird. Die erste Fassung hatte fünf Regeln: den spezifischsten Typ wählen, nur Informationen aus dem Text verwenden, gültiges JSON liefern, keine Markdown-Codeblöcke, saubere Verschachtelung.
Der Generator ist seit einigen Monaten im Einsatz. Und genau dadurch kamen die Fehler ans Licht.
Was im Echtbetrieb schiefging
Die Rückmeldungen kamen aus der Redaktion und aus der Google Search Console, die strukturierte Daten prüft und Fehler meldet. Vier Muster tauchten immer wieder auf:
1. Erfundene Angaben. Ein Veranstaltungshinweis nannte ein Datum, aber keine Uhrzeit. Das Modell ergänzte trotzdem eine. Plausibel, aber erfunden. 2. Falscher Typ bei Pressemeldungen. Berichtete eine Meldung über das Produkt eines Unternehmens, wurde daraus ein Product. Die Search Console bemängelte dann prompt fehlende Pflichtangaben wie den Preis, den es in einer Pressemeldung natürlich nicht gibt. 3. Gehalt ohne Gehalt. Bei Stellenanzeigen ohne Gehaltsangabe setzte das Modell trotzdem ein baseSalary. 4. Schwankende Typen. Pressemeldungen wurden mal als Article, mal als NewsArticle ausgezeichnet.
Der erste Punkt ist der wichtigste, und er ist typisch für Sprachmodelle. Sie kennen die Struktur eines Event so gut, dass ihnen ein leeres Feld falsch vorkommt. Also füllen sie es, mit dem, was am wahrscheinlichsten klingt. Bei strukturierten Daten ist das schlimmer als bei Fließtext, weil Maschinen die Angabe ungeprüft übernehmen.
Erste Korrektur: Regeln schärfen
Wir haben den Code nicht angefasst, sondern das Briefing umgebaut. Zuerst mit drei Mitteln, die für jedes Modell gelten.
Mindestbedingungen je Typ. Ein Event gibt es nur, wenn Titel und Datum im Text stehen. Ein Product nur bei einem klaren Kaufangebot, mit einer ausdrücklichen Negativregel für Pressemeldungen über Produkte. Neu dazu kam JobPosting für Stellenanzeigen.
Ein fester Erkennungsweg. Erst FAQ, dann die Spezialtypen, dann die Frage, ob es eine Pressemeldung ist. Wenn ja, immer NewsArticle, nie Article, und diese Regel hat Vorrang. Als Basis kommt immer ein Artikel dazu. Alles landet in einem @graph, jeder Knoten bekommt eine eigene @id, damit Maschinen die Teile verknüpfen können.
Belegpflicht für heikle Felder. Das ist der Kern:
Die folgenden Felder dürfen NUR gesetzt werden, wenn der Wert ausdrücklich und
vollständig im Text steht. Andernfalls lässt du das Feld komplett weg, auch
wenn schema.org oder Google es empfiehlt:
- Datum und Uhrzeit: startDate, endDate, datePublished, dateModified,
datePosted, validThrough (keine Uhrzeit ergänzen, kein Jahr raten)
- Gehalt: baseSalary, salaryCurrency, estimatedSalary
- Preis und Angebot: price, priceCurrency, offers, availability
- Kontakt und Ort, Öffnungszeiten, Bewertungen
Der Nachsatz „auch wenn schema.org oder Google es empfiehlt“ ist wichtig. Früher stand bei fehlenden Angaben gern „N/A“ im Ergebnis, weil das Modell ein empfohlenes Feld nicht leer lassen wollte. Ein fehlendes Feld ist ehrlich. Ein „N/A“ ist für die Search Console ein Fehler und für eine KI eine falsche Angabe.
Zweite Korrektur: den Prompt gegen das Pretraining schreiben
Für den Betrieb außerhalb der USA soll ein offenes Modell den Dienst übernehmen. Dafür haben wir den Prompt noch einmal gezielt angepasst. Der Grund liegt darin, was ein Sprachmodell im Pretraining gelernt hat.
Ein großes Modell hat Millionen von JSON-LD-Blöcken gesehen. Es weiß, wie ein vollständiges Event aussieht: Name, Startzeit mit Uhrzeit und Zeitzone, Ort mit Adresse, Veranstalter, Angebot mit Preis. Dieses Bild ist so stark, dass es sich gegen den konkreten Text durchsetzt. Das Modell vervollständigt nicht den Text, sondern das Muster, das es aus dem Training kennt. Genau daher kamen die erfundene Uhrzeit und das geratene Gehalt.
Dazu kamen drei Beobachtungen, die bei offenen Modellen dieser Klasse stärker ins Gewicht fallen als bei den großen kommerziellen Diensten:
- Lange Regellisten werden unzuverlässiger befolgt. Je weiter hinten eine Regel steht, desto eher geht sie unter.
- Beispiele wirken stärker als Regeln. Ein gezeigtes Falsch und Richtig prägt die Antwort mehr als ein weiterer Spiegelstrich.
- Ein Prüfschritt am Ende wird gern übersprungen. „Prüfe vor der Ausgabe“ als letzter Satz ist für das Modell eher ein Wunsch als ein Arbeitsschritt.
Daraus haben wir vier Änderungen abgeleitet. Das Ziel: bessere Ergebnisse und vor allem berechenbare. Derselbe Text soll jedes Mal dasselbe Markup ergeben, und dieses Markup soll nur enthalten, was im Text steht.
1. Das Vorwissen ausdrücklich abschalten
Weit oben im Prompt, noch vor der Erkennungslogik, steht ein eigener Abschnitt, der das Problem beim Namen nennt:
### WICHTIG: Kein Modellwissen zum Auffüllen
Du weißt, wie vollständige schema.org-Objekte aussehen. Dieses Wissen darfst du
NICHT nutzen, um fehlende Felder zu ergänzen. Fülle ausschließlich Felder, die
du wörtlich im bereitgestellten Text belegen kannst. Jedes Feld, das du aus
deinem Modellwissen ergänzt statt aus dem Text, ist ein Fehler. Ein
unvollständiges, aber korrektes Markup ist immer besser als ein vollständiges
mit erfundenen Angaben.
Das wirkt auf den ersten Blick banal. Der Trick ist, dass die Anweisung nicht nur „erfinde nichts“ sagt, sondern dem Modell sein eigenes Verhalten beschreibt. Es soll erkennen, dass gerade das Wissen, das es für die Aufgabe qualifiziert, hier der Fehler ist. Und der letzte Satz dreht die Bewertung um: Unvollständig ist nicht mehr schlecht, sondern das gewünschte Ergebnis.
Die Position ist kein Zufall. Steht der Abschnitt vorn, gilt er für alles, was danach kommt. Am Ende einer langen Liste wäre er die Regel, die am ehesten untergeht.
2. Ein Gegenbeispiel statt einer weiteren Regel
Der schwierigste Fall war die Pressemeldung über ein Produkt. Statt die Regel ein drittes Mal anders zu formulieren, zeigt der Prompt den Fall. Eine erfundene Meldung über die „MusterAG“ und ihre Software „DataTool Pro“, dazu zwei falsche Lösungen mit Begründung und eine richtige:
Falsch:
{"@type": "Product", "name": "DataTool Pro", "offers": {"price": "0"}}
Grund: Kein Kaufangebot und kein Preis im Text. Product ist nicht zulässig.
Falsch:
{"@type": "NewsArticle", "datePublished": "2025-10-15T09:00:00+02:00"}
Grund: Jahr und Uhrzeit stehen nicht im Text. Das Feld muss entfallen.
Die richtige Lösung ist ein NewsArticle mit Überschrift und Zusammenfassung, verknüpft über about mit einem Knoten für die Organisation. Kein Produkt, kein Datum.
Beide Falschbeispiele sind genau die Ausgaben, die das Pretraining nahelegt. Die Meldung nennt „15. Oktober“, das Modell kennt das Format von datePublished und ergänzt Jahr, Uhrzeit und Zeitzone. Die Meldung nennt eine Software, das Modell kennt Product und baut ein Angebot dazu. Indem der Prompt diese naheliegende Antwort vorwegnimmt und als falsch markiert, verliert sie ihren Reiz. Die Begründung hinter jedem „Falsch“ ist dabei wichtiger als das Beispiel selbst: Sie überträgt sich auf Fälle, die im Prompt nicht vorkommen.
3. Die Prüfung in die Denkphase verlegen
Aktuelle offene Modelle denken vor der Antwort in einem eigenen Kanal nach, der nicht in der Ausgabe landet. Diesen Kanal nutzen wir. Statt eines Prüfsatzes am Ende gibt es einen Pflichtschritt mit vier Fragen:
### Pflichtschritt vor der Ausgabe
Beantworte in deinem Denkprozess (NICHT in der Ausgabe):
a) Welche Typen treffen laut Erkennungslogik zu? Begründe jeden Typ in einem
Satz. Verwirf Typen, deren Mindestbedingungen nicht erfüllt sind.
b) Ist der Text eine Pressemeldung? Wenn ja: NewsArticle statt Article.
c) Für jeden Knoten und jedes Feld: Welche Textstelle belegt den Wert? Felder
ohne wörtliche Textgrundlage streichen.
d) Ist das JSON gültig, sind alle @id-Verweise auflösbar und gibt es keine
leeren Werte oder widersprüchlichen Knoten?
Erst danach gibst du das JSON aus.
Frage c) ist die eigentliche Belegpflicht, nur als Handlung statt als Verbot. Das Modell muss zu jedem Feld die Textstelle suchen. Findet es keine, fällt das Feld weg. Eine erfundene Uhrzeit hat keine Textstelle, also kommt sie hier nicht mehr durch. Frage b) steht bewusst noch einmal einzeln da, obwohl sie schon in der Erkennungslogik vorkommt. Sie war die Ursache für das Schwanken zwischen Article und NewsArticle, und eine ausdrückliche Ja-Nein-Frage lässt weniger Spielraum als eine Regel in einer Liste.
4. Die Einbindung passend einstellen
Drei Einstellungen gehören zum Prompt dazu:
- Rollen trennen. Das Briefing geht als System- oder Developer-Nachricht ans Modell, der Seitentext als User-Nachricht. Offene Modelle dieser Generation gewichten Anweisungen aus der Developer-Rolle höher als Inhalte aus der User-Rolle. Steht im Seitentext zufällig etwas, das wie eine Anweisung klingt, bleibt das Briefing trotzdem maßgeblich. Das ist auch ein Schutz gegen Prompt Injection, denn gerade Pressemeldungen stammen oft von Dritten.
- Denkstufe mittel oder hoch. Bei niedriger Stufe denkt das Modell kaum nach, und der Pflichtschritt fällt flach. Mittel reicht in der Regel, hoch lohnt sich bei langen Texten mit mehreren Typen.
- Temperatur 0 bis 0,2. Je niedriger, desto reproduzierbarer die Typentscheidung. Gerade die Frage
ArticleoderNewsArticlekippte vorher je nach Durchlauf.
Ehrlich gesagt: Ob die Änderungen alle vier Probleme beheben, ist noch nicht gemessen. Das kommt nach einigen Wochen Betrieb, mit einem Blick in die Search Console vorher und nachher.
Wo das Modell läuft
Der Prototyp ruft ein Modell bei OpenAI auf. Das funktioniert, hat aber zwei Haken. Jeder Seitentext geht an einen US-Anbieter, bevor er veröffentlicht ist. Und man hängt an Preis und Verfügbarkeit eines einzigen Dienstes.
Ein offenes Modell lässt sich dagegen in Europa betreiben, etwa über die AI Endpoints von OVHcloud, oder ganz lokal auf eigener Hardware. Beide Wege bieten eine OpenAI-kompatible Schnittstelle. Für den Code heißt das: Es ändert sich fast nichts. Adresse, Schlüssel und Modellname kommen aus Umgebungsvariablen und nie in den Code:
$client = OpenAI::factory()
->withApiKey(getenv('LLM_API_KEY'))
->withBaseUri(getenv('LLM_BASE_URL')) // OVHcloud oder eigener Server
->make();
$modell = getenv('LLM_MODEL');
Welcher Weg passt, ist eine Frage von Datenschutz und Betrieb. Bei OVHcloud bleibt der Text in der EU, und man kümmert sich um nichts. Lokal verlässt der Text das Haus gar nicht, dafür braucht es eigene Hardware und jemanden, der sie betreibt. Am Prompt ändert sich für beide Wege nichts.
Vom Kopieren zum Prozess
Der größte Hebel liegt aber nicht beim Modell, sondern im Ablauf. Solange die Redaktion den Block von Hand kopiert, wird er vergessen, veraltet oder landet im falschen Feld. So würde ich den nächsten Schritt bauen:
1. Beim Speichern auslösen. Das Redaktionssystem schickt den Text beim Speichern automatisch an den Generator, in WordPress etwa über einen Hook auf das Speichern eines Beitrags. 2. Prüfen, bevor gespeichert wird. Ist es gültiges JSON? Stehen nur erlaubte Typen darin? Fehlen Datumsfelder, die der Text gar nicht nennt? Was die Prüfung nicht besteht, wird nicht ausgespielt, sondern der Redaktion zur Durchsicht gemeldet. 3. Getrennt ablegen. Das Ergebnis gehört in ein eigenes Feld, nicht in den Text. Bei jeder Änderung am Artikel wird es neu erzeugt. 4. Regelmäßig gegenprüfen. Die Search Console und der Schema Markup Validator zeigen, ob die Auszeichnung ankommt. Fehler fließen zurück ins Briefing.
Wo der Endpunkt es unterstützt, lässt sich zusätzlich ein festes JSON-Schema für die Antwort vorgeben. Dann kann das Modell gar kein ungültiges JSON mehr liefern. Die Belegpflicht ersetzt das nicht: Ein syntaktisch perfektes JSON mit erfundener Uhrzeit ist immer noch falsch.
Was ich daraus mitnehme
Strukturierte Daten sind ein Paradebeispiel für eine Aufgabe, die eine KI gut kann und trotzdem nicht unbeaufsichtigt machen sollte. Das Modell kennt schema.org besser als die meisten Redaktionen. Gerade deshalb füllt es Lücken, die es nicht füllen soll.
Der Prompt muss deshalb nicht nur sagen, was das Modell tun soll, sondern auch, wogegen es arbeiten muss. Am meisten gebracht haben: das Vorwissen ausdrücklich abschalten, ein Gegenbeispiel mit Begründung statt einer weiteren Regel, und die Prüfung als Pflichtfrage in die Denkphase legen statt als Bitte ans Ende. Ob das Modell dann bei OVHcloud oder im eigenen Keller läuft, ist eine Frage von Datenschutz und Betrieb. Am Prompt ändert es nichts.
Weiterlesen: Maschinenlesbar oder unsichtbar: JSON-LD und Wikidata · Generative Engine Optimization · GEO am lebenden Objekt · Prompt Injection: Was KMU aus 460.000 Angriffen lernen können · Die KI-Lüge der deutschen Wirtschaft · Vibe Coding mit Sicherheitsnetz: eine CI/CD-Pipeline
Der vollständige Prompt
Zum Nachbauen hier das komplette Briefing. {SITE_URL} ersetzt der Code vor dem Aufruf durch die Adresse der Seite.
Du bist ein Experte für SEO und strukturierte Daten. Deine Aufgabe ist es, den
bereitgestellten Text zu analysieren und passendes Schema.org-Markup im
JSON-LD-Format zu erstellen.
### Kontext
Aktuelle Seiten-URL:
{SITE_URL}
### Grundprinzip
Ein Text kann mehrere Schema-Typen gleichzeitig rechtfertigen. Wähle NICHT nur
einen Typ aus. Erkenne alle zutreffenden Typen und gib sie gemeinsam in einem
@graph aus.
### WICHTIG: Kein Modellwissen zum Auffüllen
Du weißt, wie vollständige schema.org-Objekte aussehen. Dieses Wissen darfst du
NICHT nutzen, um fehlende Felder zu ergänzen. Fülle ausschließlich Felder, die
du wörtlich im bereitgestellten Text belegen kannst. Jedes Feld, das du aus
deinem Modellwissen ergänzt statt aus dem Text, ist ein Fehler. Ein
unvollständiges, aber korrektes Markup ist immer besser als ein vollständiges
mit erfundenen Angaben.
### Erkennungslogik (in dieser Reihenfolge prüfen)
1. FAQPage
Enthält der Text Fragen mit Antworten, explizit als Q&A oder implizit als
"Was ist ...", "Wie funktioniert ...", "Warum ..."?
Dann erzeuge eine FAQPage mit mainEntity (Question + acceptedAnswer).
Übernimm Frage und Antwort wortgleich aus dem Text.
2. Spezifische Objekttypen
Erzeuge einen Typ nur, wenn ALLE genannten Mindestbedingungen im Text
erfüllt sind:
- Event: Der Text kündigt eine konkrete Veranstaltung an, die mit Titel UND
Datum im Text belegt ist.
- LocalBusiness: Der Text beschreibt einen physischen Standort oder ein
ortsgebundenes Unternehmen mit Adresse oder Öffnungszeiten im Text.
- Product: Der Text enthält ein klares Kaufangebot für ein Produkt (Preis,
Bestellmöglichkeit oder Shop-Verweis). NICHT verwenden, wenn ein Text nur
über ein Unternehmen oder dessen Produkt berichtet, etwa in einer
Pressemeldung oder Unternehmensnachricht.
- Person: Der Text stellt eine konkrete, namentlich genannte Person vor.
- HowTo: Der Text enthält eine explizite Schritt-für-Schritt-Anleitung mit
mindestens zwei benannten Schritten.
- JobPosting: Der Text ist eine Stellenanzeige mit Berufsbezeichnung und
Arbeitgeber im Text.
3. Pressemeldungen
Ist der Text eine Pressemeldung? Erkennbar an Begriffen wie
"Pressemitteilung" oder "Pressemeldung", an einer Ort-Datum-Zeile im
Vorspann (z. B. "Musterstadt, 12. März 2026") oder an Verteiler- und
Pressekontakthinweisen am Ende.
Dann verwende ausschließlich NewsArticle, niemals Article.
Diese Regel hat Vorrang vor Schritt 4.
4. Article als Basis
Erzeuge IMMER zusätzlich ein Article (bzw. NewsArticle gemäß Schritt 3),
das den Text inhaltlich zusammenfasst. Auch dann, wenn bereits eine FAQPage
oder ein anderer Typ erzeugt wurde.
- headline: kurze, präzise Beschreibung des Hauptinhalts.
- description: keine Wiederholung der headline, sondern die wichtigsten
Kernaussagen als eigenständige Zusammenfassung.
- Bei längeren Texten zusätzlich articleBody oder abstract, jeweils nur aus
dem Textinhalt.
### Beispiel: Pressemeldung mit Produkterwähnung
Eingabe (Auszug):
"München, 15. Oktober. Die MusterAG gibt heute die Markteinführung ihrer neuen
Software 'DataTool Pro' bekannt. Das Tool richtet sich an mittelständische
Unternehmen und soll ab November verfügbar sein."
Falsch:
{"@type": "Product", "name": "DataTool Pro", "offers": {"price": "0"}}
Grund: Kein Kaufangebot und kein Preis im Text. Product ist nicht zulässig.
Falsch:
{"@type": "NewsArticle", "datePublished": "2025-10-15T09:00:00+02:00"}
Grund: Jahr und Uhrzeit stehen nicht im Text. Das Feld muss entfallen.
Richtig:
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "NewsArticle",
"@id": "{SITE_URL}#article",
"mainEntityOfPage": "{SITE_URL}",
"headline": "MusterAG kündigt Software DataTool Pro an",
"description": "Die MusterAG bringt mit DataTool Pro eine neue Software für mittelständische Unternehmen auf den Markt, die ab November verfügbar sein soll.",
"about": {"@id": "{SITE_URL}#org-musterag"}
},
{
"@type": "Organization",
"@id": "{SITE_URL}#org-musterag",
"name": "MusterAG"
}
]
}
### Felder mit Belegpflicht
Die folgenden Felder dürfen NUR gesetzt werden, wenn der Wert ausdrücklich und
vollständig im Text steht. Andernfalls lässt du das Feld komplett weg, auch
wenn schema.org oder Google es empfiehlt:
- Datum und Uhrzeit: startDate, endDate, datePublished, dateModified,
datePosted, validThrough (keine Uhrzeit ergänzen, kein Jahr raten)
- Gehalt: baseSalary, salaryCurrency, estimatedSalary
- Preis und Angebot: price, priceCurrency, offers, availability
- Kontakt und Ort: telephone, email, address, geo
- Öffnungszeiten: openingHours, openingHoursSpecification
- Bewertungen: aggregateRating, review
### Pflichtschritt vor der Ausgabe
Beantworte in deinem Denkprozess (NICHT in der Ausgabe):
a) Welche Typen treffen laut Erkennungslogik zu? Begründe jeden Typ in einem
Satz. Verwirf Typen, deren Mindestbedingungen nicht erfüllt sind.
b) Ist der Text eine Pressemeldung? Wenn ja: NewsArticle statt Article.
c) Für jeden Knoten und jedes Feld: Welche Textstelle belegt den Wert? Felder
ohne wörtliche Textgrundlage streichen.
d) Ist das JSON gültig, sind alle @id-Verweise auflösbar und gibt es keine
leeren Werte oder widersprüchlichen Knoten?
Erst danach gibst du das JSON aus.
### Ausgabeformat
- Genau ein JSON-LD-Objekt mit "@context": "https://schema.org" und einem
"@graph"-Array, das alle erzeugten Knoten enthält.
- Jeder Knoten erhält eine "@id" auf Basis von {SITE_URL} (z. B.
"{SITE_URL}#article", "{SITE_URL}#faq").
- Verknüpfe Knoten sinnvoll: "about" oder "mainEntityOfPage" auf
FAQPage/Spezialobjekt, "author" auf Person, "publisher" auf Organization.
- Enthält der Text nur Fließtext ohne erkennbares Spezialobjekt, gib
ausschließlich das Article bzw. NewsArticle aus.
- Setze "N/A" nur dort, wo schema.org ein Feld zwingend verlangt und der Wert
im Text fehlt.
- Falls kein sinnvoller Knoten erzeugt werden kann, gib
{"@context":"https://schema.org","@graph":[]} zurück.
Gib keinen einleitenden Text, keine Erklärung, keine Kommentare und keine
Markdown-Code-Blöcke (```json) aus. Ausschließlich das reine JSON-Objekt.