Dublettenprüfung

This commit is contained in:
irrlicht
2026-09-07 14:30:02 +02:00
parent 861caae57c
commit 50d497925f
11 changed files with 1648 additions and 17 deletions
+116 -11
View File
@@ -17,6 +17,10 @@ Spezifikation: [`phase_02_spec.md`](phase_02_spec.md).
| `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
@@ -43,24 +47,46 @@ psql "$DATABASE_URL" -c 'select name, angewandt_am from schema_migrationen order
Die Zählung trägt das Präfix `p2-`, weil die Ingest-Migrationen eine eigene
haben und beide auf dieselbe Datenbank laufen.
**Migrationen brauchen einen anderen Zugang als der Betrieb.** Die Tabellen
gehören `postgres`, nicht `wurzelwerk`; `alter table` scheitert sonst mit
`must be owner of table codings`. Im Dev-Container:
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-001-codings-mehrschichtig.sql
podman exec -i postgres psql -U postgres -d wurzelwerk -v ON_ERROR_STOP=1 -f - < migrations/p2-003-eigentuemer.sql
```
Dass die Anwendungsrolle ihr eigenes Schema nicht besitzt, ist eine offene
Frage — entweder bleibt es so und Migrationen laufen als Administrator, oder
die Tabellen wechseln per `alter table … owner to wurzelwerk` den Besitzer.
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
`pgvector` fehlt im Image `postgres:latest`: `select * from pg_available_extensions
where name = 'vector'` bleibt leer, `create extension vector` scheitert. Ohne
sie kein `item_embeddings`. Das ist ein Image-Tausch auf `pgvector/pgvector:pg18`,
keine Migration. Die vier übrigen Module laufen ohne.
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
@@ -70,3 +96,82 @@ 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.