Revisionsmodul, Embeddings und Container
Drei Schritte, die zusammen Phase 2 lauffaehig machen.
Revisionen (migrations/p2-004, revisionen.py)
Ingest schreibt jede geaenderte Fassung eines Items nach
raw_item_versionen fort. Daraus faellt ein Befund ab, den die
Spezifikation nicht vorsehen konnte: welches Haus seine Schlagzeile
nachtraeglich umschreibt, und wie. 206 von 3407 Items, acht
Einstufungen von 'formal' bis 'neufassung'. Kein Modell, kein Lexikon,
kein Netzzugriff - nur ein Wortvergleich zweier Fassungen.
Fortschreibungen sind ueber ticker_markiert und kopf_stabil abziehbar,
absichtlich als zwei Merkmale statt einer Kategorie: das eine ist eine
Selbstauskunft des Hauses, das andere eine Beobachtung am Satzbau.
Embeddings (embeddings.py)
multilingual-e5-large, 1024 Dimensionen, MIT. Jede Zeile wird auf feste
128 Token aufgefuellt, nicht nur bis zur laengsten des Stapels - sonst
haengt ein Vektor davon ab, mit welchen Nachbarn er kodiert wurde, und
ein Slot-Lauf liefert andere letzte Stellen als ein Backfill.
Nachgewiesen: 64/64 bitgleich einzeln wie im vollen Stapel, groesste
Abweichung 0.00e+00, und im Betrieb 26 Slot-Vektoren unveraendert
gegen den 3407er-Backfill.
Darum transformers statt sentence-transformers: letzteres kapselt genau
diese Stelle weg.
Container (Containerfile, phase2.py, deploy/)
Debian trixie wie der Postgres-Container, torch aus dem CPU-Index.
Die regelbasierten Module liefern darin fuer alle 3407 Items bitgleich
dieselbe Nutzlast wie auf der Entwicklungsmaschine - Portabilitaet
gemessen, nicht behauptet.
version.py bekommt den Schluessel 'revision'. Weil der Hash ueber den ganzen
Komponentensatz laeuft, aendert das auch die Version des Dublettenmoduls,
obwohl an dessen Verfahren nichts anders ist. Das ist die in version.py
beschriebene Semantik der Spezifikation, kein Versehen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NupWEBPzsyohVPG47HsTz8
This commit is contained in:
co-authored by
Claude Opus 5
parent
50d497925f
commit
cf89e012b2
@@ -21,6 +21,12 @@ Spezifikation: [`phase_02_spec.md`](phase_02_spec.md).
|
||||
| `version.py` | `coder_version` aus den Komponenten |
|
||||
| `dubletten.py` | Modul 1: exakte Übernahmen |
|
||||
| `test_dubletten.py` | Testfälle dazu, ohne Datenbank |
|
||||
| `revisionen.py` | Modul: nachträglich geänderte Schlagzeilen |
|
||||
| `test_revisionen.py` | Testfälle dazu, ohne Datenbank |
|
||||
| `embeddings.py` | Modul 5: Satzvektoren |
|
||||
| `phase2.py` | Läufer: alle Module in fester Reihenfolge |
|
||||
| `Containerfile` | Laufzeitumgebung, identisch auf Dev und Prod |
|
||||
| `deploy/` | systemd-Units und Umgebungsbeispiel für Prod |
|
||||
|
||||
## Einrichtung
|
||||
|
||||
@@ -175,3 +181,252 @@ 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.
|
||||
|
||||
## Modul: Revisionen
|
||||
|
||||
Nicht in der Spezifikation. Der Befund fällt aus einer Eigenschaft der
|
||||
Phase-1-Umsetzung ab: Ingest schreibt jede geänderte Fassung eines Items nach
|
||||
`raw_item_versionen` fort, statt sie zu überschreiben. Damit steht in der
|
||||
Datenbank, was sonst nur mit eigener Erhebung zu bekommen ist — welches Haus
|
||||
seine Schlagzeile nachträglich umschreibt, und wie.
|
||||
|
||||
```bash
|
||||
python3 revisionen.py --backfill
|
||||
python3 revisionen.py --slot 2026-09-07T08:00
|
||||
python3 revisionen.py --backfill --trocken --stichprobe 140
|
||||
```
|
||||
|
||||
Das Modul braucht kein Modell, kein Lexikon und keinen Netzzugriff — nur einen
|
||||
Wortvergleich zweier Fassungen desselben Artikels (`difflib`).
|
||||
|
||||
### Die acht Einstufungen
|
||||
|
||||
Zwischen zwei aufeinanderfolgenden Fassungen, von der engsten Prüfung zur
|
||||
weitesten:
|
||||
|
||||
| Einstufung | Bedeutung |
|
||||
|---|---|
|
||||
| `unveraendert` | nichts geändert |
|
||||
| `formal` | nur Zeichensetzung, Anführungszeichen, Schreibung |
|
||||
| `tippfehler` | ein Wort korrigiert (»Waldesaster« → »Wahldesaster«) |
|
||||
| `kopfwechsel` | nur der Teil vor dem Doppelpunkt |
|
||||
| `erweiterung` | nur ergänzt — meist eine neue Tatsache |
|
||||
| `kuerzung` | nur gestrichen |
|
||||
| `umformulierung` | beides, die Aussage steht noch |
|
||||
| `neufassung` | die Schlagzeile ist ausgetauscht |
|
||||
|
||||
Dazu `zurueckgenommen`, das nur auf Item-Ebene vorkommt: zwischendurch geändert,
|
||||
am Ende steht wieder die erste Fassung. Genau ein Fall im Bestand — die FAZ
|
||||
setzte ein »dessen« ein und strich es wieder.
|
||||
|
||||
`art` und `aehnlichkeit` vergleichen die **erste mit der letzten** Fassung —
|
||||
was ist aus der Schlagzeile geworden. `arten` hält jeden einzelnen Schritt
|
||||
fest — auf welchem Weg.
|
||||
|
||||
### Fortschreibungen müssen abziehbar sein
|
||||
|
||||
Ein Liveticker wechselt seine Überschrift stündlich, ohne dass eine Redaktion
|
||||
ihre Darstellung revidiert hätte. Als »Neufassung« gezählt überdeckt er genau
|
||||
den Befund, um den es geht. Zwei Merkmale in der Nutzlast machen ihn abziehbar:
|
||||
|
||||
* `ticker_markiert` — der Titel nennt sich selbst so (»Liveticker«,
|
||||
»Newsblog«, »Liveblog«, »Livestream«, »Live:«). Trifft 10 Items.
|
||||
* `kopf_stabil` — der Text vor dem ersten Doppelpunkt blieb über alle Fassungen
|
||||
derselbe. Zusammen mit `art = neufassung` ist das die Bauform einer
|
||||
Fortschreibung: feste Rubrik, ausgetauschter Stand. Trifft 33 Items.
|
||||
|
||||
Absichtlich zwei Merkmale statt einer Kategorie »Ticker«: das erste ist eine
|
||||
Selbstauskunft des Hauses, das zweite eine Beobachtung am Satzbau. Sie zu
|
||||
einem Urteil zu verschmelzen würde eine Sicherheit behaupten, die keins von
|
||||
beiden hat.
|
||||
|
||||
Auf `+++`-Kästen wird bewusst nicht geprüft: das Handelsblatt setzt
|
||||
»+++ USA +++:« als Ressortmarke, nicht als Tickerkennzeichen.
|
||||
|
||||
### Zwei Kopfbegriffe, mit Absicht
|
||||
|
||||
`normalisierung.ressort_teilen()` prüft, ob der Kopf höchstens vier Wörter
|
||||
misst und dahinter noch ein vollständiger Satz steht. Diese Vorsicht ist nötig,
|
||||
wo der Kopf *abgeschnitten* wird — »Habeck: Wir haben uns geirrt« darf seinen
|
||||
nicht verlieren.
|
||||
|
||||
`revisionen.teilen()` verzichtet darauf, weil hier nichts abgeschnitten, nur
|
||||
verglichen wird. Die Wortgrenze schadete dort: »CDU-Desaster in Sachsen-Anhalt:«
|
||||
hat drei Wörter, »CDU-Desaster bei Wahl in Sachsen-Anhalt:« fünf, und der
|
||||
Wechsel zwischen beiden fände sonst nicht statt.
|
||||
|
||||
`kopfwechsel` ist damit eine Aussage über den Satzbau, nicht über das Gewicht:
|
||||
bei »Fußball-Bundesliga: X« → »X« wechselt eine Rubrik, bei »Habeck: X« →
|
||||
»Scholz: X« der Sprecher. Welcher Fall vorliegt, steht in `hinzu` und
|
||||
`entfernt`.
|
||||
|
||||
### Kein Rand nötig
|
||||
|
||||
Anders als beim Dublettenmodul hängt eine Zeile allein an den Fassungen ihres
|
||||
eigenen Items — keine Nachbarschaft, kein Datumsfenster, kein mitzuladender
|
||||
Rand. Ein Slot-Lauf lädt die Items, von denen im Slot eine neue Fassung
|
||||
eintraf, samt ihrer *älteren* Fassungen, und rechnet für sie dasselbe wie ein
|
||||
Backfill.
|
||||
|
||||
Nachgewiesen über den ganzen Bestand: 106 Slot-Läufe nacheinander erzeugen für
|
||||
alle 3407 Items bitgleich dieselbe Nutzlast wie ein Backfill — 0 fehlend,
|
||||
0 überzählig, 0 abweichend (Invariante 2 und 4).
|
||||
|
||||
### `konfidenz` bleibt leer
|
||||
|
||||
Die Einstufung ist regelbasiert und trifft zu oder nicht; eine Zahl daneben
|
||||
behauptete eine Wahrscheinlichkeit, die das Verfahren nicht kennt. Das Maß der
|
||||
Änderung steht als `aehnlichkeit` in der Nutzlast, wo es hingehört.
|
||||
|
||||
### Wortlisten in der Nutzlast
|
||||
|
||||
`hinzu` und `entfernt` halten die geänderten Wörter fest, auf je zwölf gekappt
|
||||
(`gekappt` sagt, ob gekürzt wurde). Ohne sie wäre die Angabe »umformulierung«
|
||||
nicht nachprüfbar; aus zwei Listen von je einem Dutzend Wörtern lässt sich
|
||||
keine Schlagzeile zurückbauen. Der Constraint `codings_kein_volltext` bleibt
|
||||
gewahrt — ein Testfall prüft es mit.
|
||||
|
||||
## Container
|
||||
|
||||
Phase 2 läuft auf Prod in einem rootless Podman-Container, der selbst rootless
|
||||
liegt. Für die Module ist das folgenlos: Encoder-Inferenz ist reine
|
||||
Userspace-Rechnung — keine Geräteknoten, keine Kernelmodule, keine
|
||||
privilegierten Syscalls, kein GPU-Durchgriff.
|
||||
|
||||
```bash
|
||||
podman build -t wurzelwerk-p2 .
|
||||
podman volume create wurzelwerk-modelle
|
||||
|
||||
podman run --rm --network=pasta \
|
||||
--volume wurzelwerk-modelle:/cache:U \
|
||||
--memory=8g --shm-size=1g \
|
||||
--env DATABASE_URL="postgresql://wurzelwerk:…@host.containers.internal:5432/wurzelwerk" \
|
||||
localhost/wurzelwerk-p2 phase2.py --aufholen
|
||||
```
|
||||
|
||||
`host.containers.internal` zeigt aus dem Container auf den Host, wo Postgres
|
||||
seinen Port veröffentlicht. Das `:U` am Volume ist nicht optional — der
|
||||
Container läuft als UID 10001, ein frisch angelegtes Volume gehört root im
|
||||
User-Namespace, und ohne die Umschreibung scheitert der erste Schreibzugriff
|
||||
auf den Modell-Cache.
|
||||
|
||||
### Vier Dinge, die der Container ausdrücklich braucht
|
||||
|
||||
* **`OMP_NUM_THREADS`.** PyTorch liest die CPU-Zahl am Host ab, nicht die
|
||||
cgroup-Quota; ohne feste Zahl startet es mehr Threads als es Kontingent hat.
|
||||
Wichtiger noch: die Threadzahl bestimmt die Reihenfolge der
|
||||
Gleitkommaadditionen und damit die letzten Stellen jedes Vektors. Sie steht
|
||||
deshalb in der `coder_version`.
|
||||
* **Ein Volume für `/cache`.** 2,2 GB Modellgewichte im Container-Layer wären
|
||||
bei jedem Neubau weg.
|
||||
* **`--memory`.** Damit ein Ausrutscher den Host nicht mitnimmt.
|
||||
* **`--shm-size`.** Podmans Vorgabe von 64 MB reicht nicht, sobald je
|
||||
Worker-Prozesse dazukommen.
|
||||
|
||||
### torch aus dem CPU-Index
|
||||
|
||||
`pip install torch` von PyPI zieht mehrere Gigabyte CUDA-Bibliotheken nach, die
|
||||
auf dem Server nichts ausrichten können — der SM750 ist ein Anzeigechip. Das
|
||||
Containerfile installiert deshalb ausdrücklich aus
|
||||
`https://download.pytorch.org/whl/cpu`, in einer eigenen Schicht vor dem
|
||||
Kopieren der Anforderungsdateien, damit 196 MB nicht bei jeder Änderung neu
|
||||
geladen werden.
|
||||
|
||||
### Nachgewiesene Portabilität
|
||||
|
||||
Die regelbasierten Module liefern im Container (Debian trixie, Python 3.13)
|
||||
für alle 3407 Items bitgleich dieselbe Nutzlast wie auf der
|
||||
Entwicklungsmaschine (Fedora, Python 3.14) — `neu 0, geändert 0, unverändert
|
||||
3407` bei Dubletten wie Revisionen.
|
||||
|
||||
### Betrieb
|
||||
|
||||
`deploy/` enthält `wurzelwerk-p2.service` und `.timer` als Vorlage. Kein
|
||||
Quadlet: Phase 2 ist kein Dienst, der läuft, sondern ein Auftrag, der fällig
|
||||
wird. Der Timer feuert versetzt zum 15-Minuten-Raster von Phase 1
|
||||
(`*:03,18,33,48`), damit der Slot vollständig in `raw_items` steht, bevor
|
||||
daraus kodiert wird. `phase2.py --aufholen` arbeitet alle Slots nach, denen
|
||||
eine Kodierung fehlt — ein ausgefallener Lauf holt sich beim nächsten Mal
|
||||
selbst ein.
|
||||
|
||||
Zugangsdaten stehen in `~/.config/wurzelwerk/p2.env` (chmod 600), nicht im
|
||||
Repo und nicht in der Unit. Vorlage: `deploy/p2.env.beispiel`.
|
||||
|
||||
## Modul 5: Embeddings
|
||||
|
||||
```bash
|
||||
python3 embeddings.py --backfill --index
|
||||
python3 embeddings.py --slot 2026-09-07T08:00
|
||||
python3 embeddings.py --nachweis 64 # Determinismus messen, nichts schreiben
|
||||
```
|
||||
|
||||
`intfloat/multilingual-e5-large`, 1024 Dimensionen, MIT-Lizenz. Der Vektor
|
||||
liegt in `item_embeddings`, die Coding-Zeile der Schicht `embedding` hält nur
|
||||
die Kennung — ein Vektor in JSONB wäre weder indizierbar noch platzsparend.
|
||||
|
||||
Kein LLM: ein Encoder. Text hinein, Vektor heraus — kein Prompt, keine
|
||||
Sampling-Temperatur, keine Textausgabe, feste Gewichte, nach dem ersten Laden
|
||||
kein Netzzugriff (`HF_HUB_OFFLINE=1`, Modell lädt in 2 s aus dem Volume).
|
||||
|
||||
### Warum feste Tokenlänge das Kernstück ist
|
||||
|
||||
Ein Encoder rechnet stapelweise. Füllt man nur bis zur längsten Zeile des
|
||||
Stapels auf — was jede Bibliothek von sich aus tut —, ändert sich die Form der
|
||||
Matrizen mit der Zusammensetzung des Stapels und damit die Reihenfolge der
|
||||
Gleitkommaadditionen. Ein Slot-Lauf über 31 Items lieferte dann andere letzte
|
||||
Stellen als ein Backfill über 3407, und die Zusage »Live-Lauf gleich Neuaufbau«
|
||||
wäre hin.
|
||||
|
||||
`embeddings.py` füllt deshalb **jede** Zeile auf feste 128 Token auf. Jeder
|
||||
Stapel hat dieselbe Form, eine Zeile hängt nicht mehr von ihren Nachbarn ab.
|
||||
|
||||
Gemessen (`--nachweis 64`, im Container):
|
||||
|
||||
```
|
||||
bitgleich einzeln: 64/64 groesste Abweichung 0.00e+00
|
||||
bitgleich rueckwaerts:64/64 groesste Abweichung 0.00e+00
|
||||
```
|
||||
|
||||
Dieselben Zeilen einzeln kodiert, im vollen Stapel und in umgekehrter
|
||||
Reihenfolge — bitidentisch. Deshalb wird `transformers` direkt benutzt und
|
||||
nicht `sentence-transformers`: letzteres kapselt genau diese Stelle weg (und
|
||||
zöge scipy und scikit-learn nach, die hier nichts tun).
|
||||
|
||||
128 Token ist gemessen, nicht geraten: Median 62, p95 90, p99 107. Drei von
|
||||
3407 Items reißen die Grenze, davon zwei Sendungsabläufe (»tagesschau 20:00
|
||||
Uhr«), die alle Themen der Sendung auflisten. Der Lauf meldet die Zahl und
|
||||
warnt erst oberhalb von einem Prozent.
|
||||
|
||||
### Was in die `coder_version` eingeht
|
||||
|
||||
Modell **und Modellrevision** (`3d7cfbda…`), Tokenlänge, Füllart, Pooling,
|
||||
Normierung, dtype, Stapelgröße, **Threadzahl** und **torch-Version**. Die
|
||||
letzten beiden, weil sie die Reihenfolge der Summen bzw. die gewählten Kernel
|
||||
bestimmen. Der Hash sagt damit nicht nur, welches Modell gemeint war, sondern
|
||||
welche Gewichte in welcher Umgebung gerechnet haben.
|
||||
|
||||
### Was die Vektoren leisten
|
||||
|
||||
Sie finden dieselbe Geschichte in anderer Formulierung — genau das, was das
|
||||
Dublettenmodul nicht kann. Paare über 0.94 Kosinus, die Modul 1 nicht
|
||||
zusammengeführt hat:
|
||||
|
||||
```
|
||||
0.992 [zeit] Michael Mendl: Schauspieler Michael Mendl mit 82 Jahren gestorben
|
||||
[faz] Im Alter von 82 Jahren: Charakterdarsteller Michael Mendl gestorben
|
||||
0.991 [tagesschau] Friedensbemühungen - Trump-Gesandte reisen nach Moskau und Kiew
|
||||
[welt] Trump-Gesandte in Moskau und Kiew erwartet
|
||||
```
|
||||
|
||||
Phase 2 clustert daraus nichts. Der Vektor ist Vorfilter für die
|
||||
Kandidatengenerierung in Phase 3b (Abschnitt 7 der Spezifikation).
|
||||
|
||||
### Laufzeit und Platz, gemessen
|
||||
|
||||
3407 Items in **683 s** bei 4 Threads (5 Items/s). Beim jetzigen Aufkommen von
|
||||
durchschnittlich 32 Items je Slot sind das ~7 s, beim größten Slot des
|
||||
Bestands (812 Items) ~160 s — bei einem 15-Minuten-Raster unkritisch.
|
||||
|
||||
`item_embeddings` belegt 19 MB Heap für 3407 Vektoren, der HNSW-Index weitere
|
||||
26 MB. Der Index kostet also mehr als die Daten; die Faustregel »mit HNSW eher
|
||||
das Doppelte« aus dem Abschnitt zum Platzbedarf ist damit bestätigt.
|
||||
|
||||
Reference in New Issue
Block a user