8.4 KiB
Wurzelwerk — Phase 2: Aufbereitung
Reichert raw_items deterministisch an: Dubletten, Akteure, Orte, Tonalität,
Embeddings. Kein LLM, kein Sampling, keine Netzabfrage zur Laufzeit — das
Ergebnis hängt allein an der Eingabe und der coder_version.
Setzt das Ingest-Schema aus swp-01-ingest
voraus und schreibt ausschließlich nach codings. raw_items bleibt unberührt.
Spezifikation: phase_02_spec.md.
Dateien
| Datei | Zweck |
|---|---|
phase_02_spec.md |
Spezifikation der fünf Module und ihrer Abnahmekriterien |
migrations/ |
Schemaänderungen, aufsteigend anzuwenden |
db.py |
Auflösung von DATABASE_URL, Verbindung |
check_db.py |
Rauchtest: Anmeldung, Bestand, fehlende Voraussetzungen |
normalisierung.py |
Textnormalisierung und Trigramme, gemeinsam für alle Module |
version.py |
coder_version aus den Komponenten |
dubletten.py |
Modul 1: exakte Übernahmen |
test_dubletten.py |
Testfälle dazu, ohne Datenbank |
Einrichtung
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
cp .env.example .env && chmod 600 .env && $EDITOR .env
.venv/bin/python check_db.py
Migrationen
Aufsteigend anwenden, auf Dev und Prod dieselben Dateien in derselben
Reihenfolge. Welche schon liefen, steht in schema_migrationen:
psql "$ADMIN_DATABASE_URL" -v ON_ERROR_STOP=1 -f migrations/p2-000-migrationsbuch.sql
psql "$ADMIN_DATABASE_URL" -v ON_ERROR_STOP=1 -f migrations/p2-001-codings-mehrschichtig.sql
# Stand abfragen
psql "$DATABASE_URL" -c 'select name, angewandt_am from schema_migrationen order by name'
Die Zählung trägt das Präfix p2-, weil die Ingest-Migrationen eine eigene
haben und beide auf dieselbe Datenbank laufen.
Zwei Migrationen brauchen einen Superuser, weil sie Rechte vergeben, die sie
selbst noch nicht haben: p2-002 (create extension vector — pgvector ist
nicht als trusted gekennzeichnet) und p2-003 (Besitzerwechsel). Im
Dev-Container über den lokalen Socket:
podman exec -i postgres psql -U postgres -d wurzelwerk -v ON_ERROR_STOP=1 -f - < migrations/p2-003-eigentuemer.sql
Alle übrigen laufen über DATABASE_URL. Seit p2-003 gehören die Tabellen
der Rolle wurzelwerk statt postgres — vorher scheiterte jedes alter table
mit must be owner of table codings, und die Datenbank war damit der einzige
Ausreißer auf der Instanz: gitea und hedgedoc besitzen ihre Tabellen
vollständig. Die Erweiterungen selbst (pg_trgm, vector) bleiben beim
Superuser, wo sie hingehören.
Voraussetzungen an die Datenbank
Das Embedding-Modul braucht pgvector. Im offiziellen Image postgres:latest
ist die Erweiterung nicht enthalten; der Postgres-Container läuft deshalb auf
docker.io/pgvector/pgvector:pg18-trixie — derselbe Build wie das offizielle
Image (18.6-1.pgdg13+2, Debian 13, glibc 2.41), nur mit pgvector zusätzlich.
Der Tag ist mit Bedacht gewählt. Auf derselben Instanz liegen weitere Dienste;
ein Wechsel der Hauptversion oder der Distribution wäre ein Ausfall, ein
Wechsel der glibc ein Collation-Problem in den Textindizes aller Datenbanken
(datlocprovider = 'c'). pg18-trixie schließt beides aus und zieht nur
Minor-Updates nach. Die versionierten Tags (0.8.4-pg18-trixie) taugen dafür
nicht: sie hängen der Postgres-Minor-Version hinterher und hätten 18.6 auf
18.4 zurückgedreht.
Das Anlegen der Erweiterung in wurzelwerk ist davon getrennt und geschieht
in p2-002, nicht beim Containerstart.
Ein Vektor belegt 4100 Byte. Bei den derzeit rund 1400 Items pro Tag sind das
etwa 2 GB im Jahr, mit HNSW-Index eher das Doppelte — tragbar. Die in der
Spezifikation angenommenen 200–400 Items je Slot wären rund 38.000 am Tag und
damit etwa 55 GB im Jahr, je coder_version. Ein Modellwechsel verdoppelt
den Bedarf, bis der alte Bestand gelöscht ist. Sollte es eng werden, halbiert
halfvec(1024) den Platz.
Grenzen des Datensatzes
Volltext liefert nur ein Teil der Quellen — Ingest liest RSS, und viele Häuser
geben dort keinen content:encoded heraus (Stand 7.9.2026: faz 311/489,
heise 174/196, aber derstandard, welt, dlf, taz, sz jeweils 0). Das ist keine
Lücke, die sich schließen lässt, sondern eine Eigenschaft der Quellen. Module
müssen auf Titel und Teaser allein tragfähig bleiben und dürfen die
Vergleichsbasis nicht davon abhängig machen, ob ein Item zufällig Volltext hat.
Modul 1: Dubletten
.venv/bin/python dubletten.py --backfill # Gesamtbestand
.venv/bin/python dubletten.py --slot 2026-09-07T08:00 # ein Poll-Slot
.venv/bin/python dubletten.py --backfill --trocken --stichprobe 200
.venv/bin/python dubletten.py --backfill --basis titel+teaser # die andere Basis
.venv/bin/python -m unittest test_dubletten
Normalisierung, SimHash über Wort-Trigramme, Kandidaten über gemeinsame
SimHash-Bänder, Bestätigung über Jaccard, Union-Find. Jedes verarbeitete Item
bekommt eine Zeile, auch ein Einzelstück — sonst ließe sich „keine Dublette"
nicht von „nicht verarbeitet" unterscheiden. Unveränderte Zeilen werden nicht
angefasst, kodiert_am bleibt also stehen, wenn sich nichts geändert hat.
Gesamtbestand (3407 Items) 0,2 s, ein Slot 0,4 s. Das Zeitbudget von 3 Minuten je Slot ist für dieses Modul kein Thema.
Ergebnis auf dem Bestand vom 4.–7.9.2026: 135 Gruppen mit 290 Mitgliedern, davon 63 Gruppen mit 143 Items über mehrere Häuser hinweg — das ist der eigentliche Zweck. Größte Gruppe: fünf Häuser mit derselben Zeile über den Start der Isar-Aerospace-Rakete.
Vier Abweichungen von der Spezifikation
Vergleichsbasis ist der Titel, nicht Titel + Teaser. Gemessen wurde beides.
Über Titel + Teaser findet das Verfahren auf diesem Bestand keine einzige
hausübergreifende Übernahme; über den Titel allein 63 Gruppen. Grund: die
Häuser übernehmen die Agenturüberschrift wörtlich und schreiben den Teaser
selbst, der Teaser verdünnt also genau das Signal, das trägt. Die Zahlen
stehen in NOTES.md. --basis titel+teaser schaltet auf die alte Basis
zurück; sie ergibt eine eigene coder_version und überschreibt nichts.
Das Ressortkürzel vor dem Titel wird abgetrennt. Raumfahrt: Deutsche Rakete … und Deutsche Rakete … sind dieselbe Meldung, Notfälle: und
Flugverkehr: stehen vor derselben dpa-Zeile. Die Regel ist eng gefasst:
höchstens vier Wörter vor dem Doppelpunkt, mindestens fünf danach — sonst
verlöre Habeck: Wir haben uns geirrt seine Aussage.
Die Gruppen-UUID kommt vom Leitartikel, nicht aus der Mitgliedermenge. Sonst änderte ein später eintreffendes Mitglied die Kennung einer Gruppe, die Phase 3 bereits kodiert hat. Der Leitartikel ist das älteste Mitglied; ein neues Mitglied ist zwangsläufig jünger und verschiebt das Minimum nicht.
Dasselbe Haus an verschiedenen Tagen wird nicht gepaart. Wiederkehrende Formate — „tagesschau in 100 Sekunden", „Wetter", „Die Nachrichten" — sind zeichengleich, aber jeweils ein eigener Vorgang. Ohne diese Regel wuchsen sie transitiv zu einer Gruppe aus 19 Sendungen über drei Tage zusammen.
Warum ein Slot-Lauf seinen Rand wachsen lässt
Die Items eines Slots brauchen ihre Partner im Datumsfenster, diese Partner ihrerseits ihr eigenes Fenster. Ein fester Rand genügt trotzdem nicht: Übernahmen bilden Ketten — dieselbe Schlagzeile erscheint am Montag bei einem, am Dienstag beim zweiten, am Mittwoch beim dritten Haus. Wird eine solche Kette am geladenen Rand abgeschnitten, fällt die Gruppe anders aus als im Backfill.
Der Lauf lädt deshalb, gruppiert, und lädt erneut, solange eine Gruppe bis an den Rand reicht. Geschrieben wird nur der innere Bereich, in dem jedes Item seine vollständige Nachbarschaft gesehen hat.
Nachgewiesen: nach einem Backfill ändern beliebig viele Slot-Läufe nichts mehr
(neu 0, geändert 0), und ein anschließender Backfill ebenso wenig. Live-Lauf
und Neuaufbau liefern dasselbe Ergebnis — Invariante 2 und 4.
Was bleibt
Auf dem gemessenen Tag findet das Verfahren 42 von 45 hausübergreifenden Paaren. Die drei fehlenden gehen am SimHash-Bandvorfilter verloren, nicht an den Schwellen — bei sechs bis acht Trigrammen je Titel ist SimHash grob. Ein Trigramm-Index als zweiter Kandidatenweg würde sie einfangen; das ist nicht gebaut, weil die Spezifikation Präzision über Recall stellt.
Die Stichprobe (--stichprobe 200) schreibt die Paare zum Prüfen von Hand
heraus, die zweifelhaftesten zuerst. Sie enthält Titel im Klartext und ist
deshalb nicht eingecheckt.