178 lines
8.4 KiB
Markdown
178 lines
8.4 KiB
Markdown
# 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](https://git.bnd.wtf/irrlicht/swp-01-ingest)
|
||
voraus und schreibt ausschließlich nach `codings`. `raw_items` bleibt unberührt.
|
||
|
||
Spezifikation: [`phase_02_spec.md`](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
|
||
|
||
```sh
|
||
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`:
|
||
|
||
```sh
|
||
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:
|
||
|
||
```sh
|
||
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
|
||
|
||
```sh
|
||
.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.
|