Files
swp-02-aufbereitung/embeddings.py
T
irrlichtandClaude Opus 5 cf89e012b2 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
2026-09-08 07:41:27 +02:00

374 lines
15 KiB
Python

#!/usr/bin/env python3
"""Phase 2, Modul 5: Satzvektoren (ebene = 'embedding').
Ein Encoder bildet Titel und Teaser auf einen Vektor ab. Zwei Meldungen ueber
denselben Vorgang liegen dann nah beieinander, auch wenn kein Wort uebereinstimmt
- genau das, was das Dublettenmodul konstruktionsbedingt nicht kann.
python3 embeddings.py --backfill
python3 embeddings.py --slot 2026-09-07T08:00
python3 embeddings.py --backfill --index # HNSW-Index danach anlegen
python3 embeddings.py --nachweis # Determinismus messen
Phase 2 clustert nicht. Der Vektor ist Vorfilter fuer die
Kandidatengenerierung in Phase 3b, kein Selbstzweck (Abschnitt 7 der
Spezifikation).
Kein LLM: multilingual-e5-large ist ein Encoder. Text hinein, Vektor heraus -
kein Prompt, keine Sampling-Temperatur, keine Textausgabe, feste Gewichte,
kein Netzzugriff nach dem ersten Laden.
"""
import argparse
import hashlib
import os
import struct
import sys
import time
import normalisierung
import version as versionsmodul
from db import verbindung
EBENE = "embedding"
MODELL = "intfloat/multilingual-e5-large"
DIMENSIONEN = 1024
# e5 erwartet ein Praefix. Die Modellkarte unterscheidet "query: " und
# "passage: " fuer asymmetrische Suche - Frage gegen Dokument. Hier werden
# Meldungen mit Meldungen verglichen, also symmetrisch, und dafuer nennt
# dieselbe Karte "query: " auf beiden Seiten.
PRAEFIX = "query: "
# Feste Laenge fuer *jede* Zeile, nicht nur je Stapel.
#
# Das ist der Kern der Reproduzierbarkeit. Ein Encoder rechnet stapelweise;
# fuellt man nur bis zur laengsten Zeile des Stapels auf, aendert sich die
# Form der Matrizen mit der Zusammensetzung des Stapels, und damit die
# Reihenfolge der Gleitkommaadditionen. Ein Slot-Lauf ueber 31 Items lieferte
# dann andere letzte Stellen als ein Backfill ueber 3407 - und die Zusage
# "Live-Lauf gleich Neuaufbau" waere hin.
#
# Bei fester Laenge hat jeder Stapel dieselbe Form, und eine Zeile haengt
# nicht mehr von ihren Nachbarn ab.
#
# 128 Token: Titel und Teaser zusammen liegen bei etwa 31 Woertern, im
# Bestand ueberschreitet keine Zeile die Grenze (nachgeprueft beim Lauf,
# siehe `gekuerzt` im Bericht).
MAX_TOKEN = 128
STAPEL = 32
# Threadzahl. Sie bestimmt, wie die Summen aufgeteilt werden, und gehoert
# darum in den Versions-Hash. Der Container setzt sie ueber OMP_NUM_THREADS.
THREADS = int(os.environ.get("OMP_NUM_THREADS", "4"))
def einstellungen(revision=None, torch_version=None):
"""Was in die coder_version eingeht.
Auch die Bibliotheksversion: ein anderer torch kann andere Kernel waehlen
und damit andere letzte Stellen liefern. Das entwertet die Vektoren
nicht, aber es macht sie zu einem anderen Bestand, und genau das soll der
Hash sagen.
"""
return {
"modell": MODELL,
"modell_revision": revision,
"dimensionen": DIMENSIONEN,
"praefix": PRAEFIX,
"basis": "titel+teaser",
"text": "bereinigt",
"max_token": MAX_TOKEN,
"fuellung": "feste_laenge",
"pooling": "mittel_maskiert",
"l2_normiert": True,
"dtype": "float32",
"stapel": STAPEL,
"threads": THREADS,
"torch": torch_version,
}
# --------------------------------------------------------------------------
# Modell
# --------------------------------------------------------------------------
def lade_modell():
"""(tokenizer, modell, revision, torch_version).
Der Import steht hier und nicht oben: die uebrigen Module dieses Repos
laufen ohne torch, und `python3 -m unittest` soll nicht 2 GB laden
muessen, um die Normalisierung zu pruefen.
"""
import torch
from transformers import AutoModel, AutoTokenizer
torch.set_num_threads(THREADS)
torch.set_grad_enabled(False)
tokenizer = AutoTokenizer.from_pretrained(MODELL)
modell = AutoModel.from_pretrained(MODELL, dtype=torch.float32)
modell.eval()
# Die aufgeloeste Revision aus dem Cache. Ohne sie sagt "e5-large" nur,
# welches Modell gemeint war, nicht welche Gewichte gerechnet haben.
revision = getattr(getattr(modell, "config", None), "_commit_hash", None)
return tokenizer, modell, revision, torch.__version__
def kodiere(tokenizer, modell, texte, stapel=STAPEL):
"""Vektoren zu den Texten, in derselben Reihenfolge.
Mittelwert ueber die nicht aufgefuellten Positionen, dann L2-normiert -
das Verfahren der e5-Modellkarte. Nach der Normierung ist das
Skalarprodukt die Kosinus-Aehnlichkeit, und pgvector kann mit
vector_cosine_ops direkt darauf suchen.
"""
import torch
alle = []
for anfang in range(0, len(texte), stapel):
teil = texte[anfang:anfang + stapel]
eingabe = tokenizer(teil, padding="max_length", truncation=True,
max_length=MAX_TOKEN, return_tensors="pt")
ausgabe = modell(**eingabe).last_hidden_state
maske = eingabe["attention_mask"].unsqueeze(-1).to(ausgabe.dtype)
mittel = (ausgabe * maske).sum(1) / maske.sum(1).clamp(min=1e-9)
alle.append(torch.nn.functional.normalize(mittel, p=2, dim=1))
return torch.cat(alle) if alle else torch.empty(0, DIMENSIONEN)
def zu_lang(tokenizer, texte):
"""Wieviele Texte die Tokengrenze reissen. Sollte 0 sein."""
return sum(1 for t in texte
if len(tokenizer(t, truncation=False)["input_ids"]) > MAX_TOKEN)
# --------------------------------------------------------------------------
# Laden und Schreiben
# --------------------------------------------------------------------------
_SPALTEN = "select id, quelle, titel, teaser from raw_items"
_ORDNUNG = " order by id"
def lade(conn, slot=None):
if slot is None:
zeilen = conn.execute(_SPALTEN + _ORDNUNG).fetchall()
else:
zeilen = conn.execute(_SPALTEN + " where slot = %s" + _ORDNUNG, (slot,)).fetchall()
return zeilen
def text(titel, teaser):
"""Was kodiert wird.
bereinige() statt normalisiere(): der Encoder ist auf natuerlichem Text
trainiert. Grossschreibung unterscheidet im Deutschen Wortarten, und die
Anfuehrungszeichen um ein Zitat sind Bedeutung, kein Rauschen.
"""
return PRAEFIX + normalisierung.bereinige(titel, teaser)
def vektor_hash(werte):
"""Kennung des Vektors, damit sich ohne Fliesskommavergleich sagen laesst,
ob sich etwas geaendert hat - und damit der Determinismus pruefbar ist.
struct statt numpy: dieselben Bytes (float32, little endian), aber ohne
Abhaengigkeit. Die Funktion laesst sich damit auch dort pruefen, wo kein
torch installiert ist.
"""
liste = werte.tolist() if hasattr(werte, "tolist") else list(werte)
roh = struct.pack(f"<{len(liste)}f", *liste)
return hashlib.blake2b(roh, digest_size=8).hexdigest()
def als_vector(werte):
"""pgvector-Literal. Spart die Abhaengigkeit auf das Adapterpaket."""
return "[" + ",".join(repr(float(w)) for w in werte) + "]"
def schreibe(conn, zeilen, vektoren, coder_version, revision, trocken=False):
"""Vektor nach item_embeddings, Kennung nach codings.
Zwei Tabellen, weil ein Vektor in JSONB weder indizierbar noch
platzsparend waere (siehe p2-002). Die Coding-Zeile ist der Nachweis,
dass ein Item verarbeitet wurde, und traegt, womit.
"""
from psycopg.types.json import Jsonb
vorhanden = {
r[0]: r[1]
for r in conn.execute(
"select raw_item_id, nutzlast from codings"
" where coder_version = %s and ebene = %s", (coder_version, EBENE))
}
neu = geaendert = unveraendert = 0
vektorstapel, codingstapel = [], []
for (item_id, _quelle, titel, teaser), v in zip(zeilen, vektoren):
last = {
"modell": MODELL,
"modell_revision": revision,
"dimensionen": DIMENSIONEN,
"basis": "titel+teaser",
"vektor_hash": vektor_hash(v),
"woerter": len(normalisierung.bereinige(titel, teaser).split()),
}
alt = vorhanden.get(item_id)
if alt == last:
unveraendert += 1
continue
if alt is None:
neu += 1
else:
geaendert += 1
vektorstapel.append((item_id, coder_version, als_vector(v)))
codingstapel.append((item_id, coder_version, EBENE, Jsonb(last)))
if codingstapel and not trocken:
with conn.cursor() as cur:
cur.executemany(
"""insert into item_embeddings (raw_item_id, coder_version, vektor)
values (%s, %s, %s::vector)
on conflict (raw_item_id, coder_version)
do update set vektor = excluded.vektor, erzeugt_am = now()""",
vektorstapel)
cur.executemany(
"""insert into codings (raw_item_id, coder_version, ebene, nutzlast)
values (%s, %s, %s, %s)
on conflict (raw_item_id, coder_version, ebene)
do update set nutzlast = excluded.nutzlast, kodiert_am = now()""",
codingstapel)
return {"neu": neu, "geaendert": geaendert, "unveraendert": unveraendert}
def protokolliere(conn, slot, coder_version, dauer_ms, gesehen, kodiert, fehler=None):
conn.execute(
"""insert into p2_laeufe (slot, modul, coder_version, dauer_ms,
items_gesehen, items_kodiert, items_fehler, fehler)
values (%s, %s, %s, %s, %s, %s, %s, %s)""",
(slot, EBENE, coder_version, dauer_ms, gesehen, kodiert,
0 if not fehler else 1, fehler))
# --------------------------------------------------------------------------
# Nachweis
# --------------------------------------------------------------------------
def nachweis(tokenizer, modell, zeilen, stapel=STAPEL):
"""Haengt ein Vektor von der Zusammensetzung seines Stapels ab?
Die Zusage aus Invariante 2 lautet, dass ein Slot-Lauf dasselbe liefert
wie ein Neuaufbau. Bei den regelbasierten Modulen folgt das aus dem
Verfahren; hier muss es gemessen werden.
Geprueft wird das Aeusserste, was im Betrieb vorkommt: dieselben Zeilen
einmal einzeln, einmal im vollen Stapel, einmal in umgekehrter
Reihenfolge.
"""
texte = [text(t, s) for _, _, t, s in zeilen]
voll = kodiere(tokenizer, modell, texte, stapel=stapel)
einzeln = kodiere(tokenizer, modell, texte, stapel=1)
rueckwaerts = kodiere(tokenizer, modell, texte[::-1], stapel=stapel).flip(0)
gleich_einzeln = sum(vektor_hash(a) == vektor_hash(b)
for a, b in zip(voll, einzeln))
gleich_rueck = sum(vektor_hash(a) == vektor_hash(b)
for a, b in zip(voll, rueckwaerts))
abstand_einzeln = float((voll - einzeln).abs().max())
abstand_rueck = float((voll - rueckwaerts).abs().max())
# Bei bitgleichen Vektoren liegt dieser Wert knapp unter 1 - nicht weil
# sie sich unterscheiden, sondern weil die Summe von 1024 Quadraten in
# float32 nicht exakt auf 1 faellt. Aussagekraft hat er nur, wenn die
# Zeilen darueber eine Abweichung melden.
kosinus = float((voll * einzeln).sum(1).min())
print(f" Zeilen geprueft: {len(texte)}")
print(f" bitgleich einzeln: {gleich_einzeln}/{len(texte)}"
f" groesste Abweichung {abstand_einzeln:.2e}")
print(f" bitgleich rueckwaerts:{gleich_rueck}/{len(texte)}"
f" groesste Abweichung {abstand_rueck:.2e}")
print(f" kleinster Kosinus zwischen den Fassungen: {kosinus:.9f}"
+ (" (Rundung der Quadratsumme, keine Abweichung)"
if abstand_einzeln == 0.0 and abstand_rueck == 0.0 else ""))
return gleich_einzeln == len(texte) and gleich_rueck == len(texte)
def main(argv=None):
p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
p.add_argument("--slot", help="ein Slot, z.B. 2026-09-07T08:00")
p.add_argument("--backfill", action="store_true", help="Gesamtbestand")
p.add_argument("--nachweis", type=int, nargs="?", const=64, metavar="N",
help="Determinismus an N Zeilen messen, nichts schreiben")
p.add_argument("--index", action="store_true",
help="HNSW-Index fuer diese Version anlegen")
p.add_argument("--trocken", action="store_true", help="rechnen, nichts schreiben")
a = p.parse_args(argv)
if not (a.backfill or a.slot or a.nachweis):
p.error("--backfill, --slot oder --nachweis angeben")
import datetime as dt
slot = dt.datetime.fromisoformat(a.slot) if a.slot else None
begonnen = time.monotonic()
print(f"Modell {MODELL} laedt, {THREADS} Threads ...")
tokenizer, modell, revision, torch_version = lade_modell()
print(f" Revision {revision} torch {torch_version}"
f" ({int(time.monotonic() - begonnen)} s)")
with verbindung() as conn:
if a.nachweis:
zeilen = conn.execute(_SPALTEN + _ORDNUNG + " limit %s",
(a.nachweis,)).fetchall()
return 0 if nachweis(tokenizer, modell, zeilen) else 1
v = versionsmodul.eintragen(
conn, komponenten=versionsmodul.komponenten(
embedding=einstellungen(revision, torch_version)))
print(f"coder_version {v}{' (trocken)' if a.trocken else ''}")
zeilen = lade(conn, slot=slot)
if not zeilen:
print(" nichts zu tun")
return 0
texte = [text(t, s) for _, _, t, s in zeilen]
lang = zu_lang(tokenizer, texte)
gestartet = time.monotonic()
vektoren = kodiere(tokenizer, modell, texte)
dauer_kodierung = time.monotonic() - gestartet
print(f" Items: {len(zeilen)}")
# Ein paar gekuerzte Zeilen sind erwartbar (Sendungsablaeufe wie
# "tagesschau 20:00 Uhr" listen alle Themen auf). Gewarnt wird erst,
# wenn es mehr als ein Prozent trifft - dann stimmt die Grenze nicht.
print(f" ueber {MAX_TOKEN} Token: {lang}"
+ (" <- Grenze pruefen" if lang > len(zeilen) / 100 else ""))
print(f" Kodierung: {dauer_kodierung:.1f} s"
f" ({len(zeilen) / max(dauer_kodierung, 1e-9):.0f} Items/s)")
zahlen = schreibe(conn, zeilen, vektoren, v, revision, trocken=a.trocken)
dauer = int((time.monotonic() - begonnen) * 1000)
print(f" Vektoren: neu {zahlen['neu']},"
f" geaendert {zahlen['geaendert']},"
f" unveraendert {zahlen['unveraendert']}")
if a.index and not a.trocken:
name = conn.execute("select p2_embedding_index(%s)", (v,)).fetchone()[0]
print(f" Index: {name}")
print(f" Dauer gesamt: {dauer} ms")
if not a.trocken:
protokolliere(conn, slot, v, dauer, len(zeilen),
zahlen["neu"] + zahlen["geaendert"])
conn.commit()
else:
conn.rollback()
return 0
if __name__ == "__main__":
sys.exit(main())