Zum Inhalt

0001 — LLM-Citations server-seitig binden statt prompt-seitig

Status accepted
Datum 2026-04-10
Refs Issue #60, Commits eb045d0/ed64399/db3ada9/6ced7ae, Sub-D Tests

Kontext

Der GWÖ-Antragsprüfer lässt ein LLM (Qwen Plus via DashScope) parlamentarische Anträge bewerten. Pro Antrag werden via Embedding-Retrieval relevante Chunks aus den Wahl- und Grundsatzprogrammen der involvierten Parteien zugespielt. Das LLM gibt im JSON-Output wahlprogramm_scores[*].wahlprogramm.zitate[*] mit jeweils text, quelle (Source-Label, z.B. "GRÜNE NRW Wahlprogramm 2022, S. 58") und url zurück.

Beim ersten Live-Lauf des Sub-D Citation-Property-Tests (siehe ADR 0003) gegen die Prod-DB wurden drei Halluzinations-Cases gefunden: das LLM hatte Snippets erfunden und unter realen Source-Labels zitiert. Issue #60 wurde geöffnet.

Optionen

Drei strukturell unterschiedliche Wege, das LLM zu binden:

Option A — Schärferer Prompt

Die ZITATEREGEL im User-Prompt wird verschärft. Konkret:

  1. Jeder retrievte Chunk wird mit einer ENUM-ID [Q1], [Q2], … getaggt.
  2. Das LLM wird angewiesen, jedes Zitat per [Qn]-Tag an einen Chunk zu binden, den text wörtlich aus diesem Chunk zu kopieren und das Source-Label exakt zu übernehmen.
  3. Top-K wird von 2 → 5 erhöht, damit die "richtige" Seite häufiger im Retrieval-Window landet.

Vorteile: prompt-only, kleiner Diff, keine Server-Pipeline-Änderung.

Nachteile: ENUM-Anker im Prompt ist ein weicher Hint. Das LLM darf zwar den Snippet aus Chunk Qn nehmen, aber es gibt nichts, was es daran hindert, die Seite aus Chunk Qm in quelle zu schreiben. Cross-Mix bleibt möglich.

Option B — Server-seitige Quellen-Rekonstruktion

Nach dem LLM-Call werden die emittierten Zitate Server-seitig nachgearbeitet:

  1. Für jedes Zitat im Output-JSON wird der text per Substring oder 5-Wort-Anker gegen alle retrievten Chunks gematcht.
  2. Bei Match: quelle und url werden aus dem matchenden Chunk konstruiert und der LLM-Output für diese Felder verworfen.
  3. Bei kein-Match: das ganze Zitat wird verworfen.

Vorteile: strukturell — der LLM hat keine Möglichkeit mehr, eine falsche Quelle anzugeben. Die einzigen zwei Outcomes sind "korrekt zitiert" oder "verworfen".

Nachteile: zusätzlicher Server-seitiger Schritt nach json.loads und vor Pydantic-Validation. Match-Logik muss konsistent mit Sub-D bleiben.

Option C — Schema-Änderung mit quote_id-Feld

Statt quelle direkt zu emittieren, soll das LLM ein neues Feld quote_id (z.B. "Q3") liefern, aus dem der Server quelle/url rekonstruiert.

Vorteile: explizit schema-modelliert, keine Heuristik.

Nachteile: invasiv (Pydantic-Modell, JSON-Schema, Frontend, DB-Migration); verlässt sich darauf, dass das LLM die ID korrekt setzt — wenn es lügt, ist die Bindung futsch.

Entscheidung

A + B kombiniert. Option A liefert die Vorab-Härtung im Prompt — sie fängt den Großteil der Cases (etwa 80%). Option B ist die strukturelle Backstop, die die restlichen 20% (echter Snippet aus Qn, falsche Seite aus Qm) verlässlich abdeckt. Option C wurde verworfen, weil der Schema- Eingriff den Aufwand nicht rechtfertigt, solange B die LLM-Eingabe ohnehin ignoriert.

Konkret in dieser Reihenfolge implementiert:

  1. db3ada9 — Option A: ENUM-Anker [Q1]/[Q2]/… im Prompt + Top-K 2→5. Live-Verifikation der drei Original-Cases aus #60: 13/13 ok.
  2. Sub-D Live-Run gegen Prod-DB: 45/46 grün, ein neuer Case (BB 8/673) gefunden mit "echter Snippet, falsche Seite". Beweis dass A allein nicht reicht.
  3. 6ced7ae — Option B: embeddings.reconstruct_zitate(data, semantic_quotes) nach json.loads und vor Pydantic-Validation. Helpers find_chunk_for_text und _normalize_for_match mit identischer Logik wie Sub-D. Bei Match wird _chunk_source_label und _chunk_pdf_url angewendet, bei No-Match wird das Zitat gedroppt.
  4. Sub-D Re-Run nach B: 52/52 grün über NRW/LSA/BE/MV/BB/BUND.

Konsequenzen

Positiv

  • Strukturelle Garantie: nach reconstruct_zitate kann ein im DB gespeichertes Zitat nur noch eines von zwei Dingen sein: korrekt zitiert aus einem real retrievten Chunk, oder gar nicht da.
  • Sub-D als CI-Gate: das Property-Test-Pattern fängt jede künftige Regression dieser Bug-Klasse.
  • Server vertraut LLM nicht mehr für quelle/url. Künftige Modell- Wechsel oder Prompt-Drift können die Quellen-Korrektheit nicht mehr brechen.

Negativ

  • Fehlende Match-Heuristik = stilles Verschwinden: Wenn der LLM einen echten Chunk leicht paraphrasiert (z.B. Komma anders gesetzt), verwirft Option B das Zitat. Die 5-Wort-Anker-Fallback fängt das in den meisten Fällen, aber Edge-Cases bleiben. Mitigation: doctest-style Sub-D-Run pro Deploy + Monitor der wahlprogramm_scores[*].zitate-Counts pro Assessment.
  • Code-Pfad-Komplexität: drei Schichten (Prompt-ENUM, Server-Postprocess, Sub-D-Test) statt einer. Dafür ist jede Schicht isoliert testbar.
  • embeddings.py hat jetzt mehr Verantwortung als nur Embedding-Retrieval — auch Match-Helpers und Source-Label-Konstruktion. Akzeptabel solange das Modul thematisch geschlossen bleibt; bei weiterem Wachstum als citations.py extrahieren.

Folgen für andere ADRs

  • ADR 0003 (Sub-D Citation-Property-Tests) ist die Test-Säule dieser Entscheidung — wenn 0003 jemals supersedet wird, bricht 0001 ohne den Test-Backstop möglicherweise still.
  • Neue Adapter (zukünftige Bundesländer) müssen sich nicht extra um Citation-Korrektheit kümmern; der Server-Postprocess ist adapter-agnostisch.