Chunking und Ähnlichkeitssuche

Track KI · M2 RAG, Bausteine 02 und 03 · ca. 50 Min.

Worum es geht

Ein RAG-System (Retrieval-Augmented Generation) gibt dem Modell vor der Antwort passende Textausschnitte aus deinem eigenen Korpus mit. Zwei Schritte entscheiden, ob die richtigen Ausschnitte ankommen: Chunking (den Text sinnvoll zerschneiden) und Ähnlichkeitssuche (zur Frage die ähnlichsten Chunks finden). In dieser Lektion baust du beides selbst, klein und im Browser. Es gibt keine echten Embedding-Aufrufe: Die Vektoren sind winzig und von Hand vorgegeben, damit du jede Zahl nachrechnen kannst. Das Prinzip ist identisch zu echten Systemen, nur sind dort die Vektoren 1024 bis 3072 Zahlen lang (Quelle).

Einordnung: Das ist M2 im Lernplan (RAG, Chunking, Embeddings, pgvector, Hybrid Search, Reranking). Hier lernst du die Bausteine 02 (Chunking) und 03 (Embeddings und Kosinus-Ähnlichkeit). pgvector und Hybrid Search folgen später.

Von JS/TS her gedacht

Idee JS/TS Python
Text schneiden text.slice(start, start + size) text[start:start + size]
Skalarprodukt (dot product) a.reduce((s, x, i) => s + x * b[i], 0) a @ b (numpy) oder np.dot(a, b)
Länge eines Vektors (Norm) Math.sqrt(a.reduce((s, x) => s + x * x, 0)) np.linalg.norm(a)
Alle Zeilen auf einmal matrix.map(row => ...) (Schleife) matrix @ vektor (eine Zeile, ohne Schleife)
Sortieren nach Wert, Indizes behalten idx.sort((i, j) => s[j] - s[i]) np.argsort(s)[::-1]

Wichtig: np.argsort sortiert aufsteigend und gibt die Indizes zurück, nicht die Werte. [::-1] dreht die Reihenfolge um. Ein JS-sort mit Vergleichsfunktion kennst du, hier steckt die Richtung dagegen im Slice.

Konzept

Schritt 1: Chunking mit Overlap

Ein Dokument passt selten sinnvoll in einen Prompt. Also schneidest du es in Stücke (Chunks). Die einfachste Variante schneidet nach Zeichenzahl (size). Damit eine Aussage nicht genau an der Schnittstelle zerrissen wird, überlappen sich benachbarte Chunks um overlap Zeichen. Die Quelle nennt als Beispiel chunk_size=800, chunk_overlap=120 mit dem RecursiveCharacterTextSplitter aus langchain_text_splitters. Wir bauen die Grundidee in reinem Python nach:

Der Text hat 123 Zeichen. Ohne Overlap wird “Messwert” in “Mess” und “wert” zerrissen. Jetzt mit 10 Zeichen Overlap:

Chunk 2 beginnt mit . Der Messwert: Das Wort ist jetzt komplett in einem Chunk. Dafür steht jedes Stück Text bis zu zweimal im Index. Das ist der Preis des Overlaps.

Bei normaler Prosa ist der Schnitt an Satzgrenzen oft besser als der Schnitt nach Zeichen. Die Quelle nennt dafür separators=["\n\n", "\n", ". ", " "]: Der Splitter versucht zuerst Absätze, dann Zeilen, dann Sätze, dann Wörter. Eine Mini-Version für Sätze:

Bei stark strukturierten Dokumenten (z. B. nummerierte Abschnitte in einer Spezifikation) schlägt Chunking entlang der eigenen Gliederung reines Zeichen-Chunking meist deutlich (Quelle).

Schritt 2: Vektoren und Kosinus-Ähnlichkeit

Ein Embedding macht aus einem Text einen Vektor fester Länge. Ähnliche Bedeutung heißt: die Vektoren zeigen in ähnliche Richtung. Gemessen wird das mit der Kosinus-Ähnlichkeit (cosine similarity): 1 heißt identisch, 0 heißt unabhängig (Quelle). Mathematisch (nicht aus der Quelle) liegt der Wert zwischen -1 und 1, und -1 heißt entgegengesetzt.

Formel: Skalarprodukt geteilt durch das Produkt der Längen.

\[\cos(a, b) = \frac{a \cdot b}{\lVert a \rVert \, \lVert b \rVert}\]

Merke: b ist doppelt so lang wie a, die Kosinus-Ähnlichkeit ist trotzdem genau 1.0. Das reine Skalarprodukt (28.0) wächst dagegen mit der Länge. Genau deshalb teilst du durch die Längen: Es zählt die Richtung, nicht die Größe.

Schritt 3: Retrieval über Beispielchunks

Jetzt ein kleines Retrieval. Jeder Chunk hat einen Vektor mit 4 Zahlen. Die Achsen haben hier eine von Hand gewählte Bedeutung: Zähler/Messung, Rechnung/Preis, Vertrag/Kündigung, Störung/Fehler. Echte Embedding-Achsen sind nicht so lesbar, aber das Prinzip stimmt. Der letzte Chunk ist ein “Allgemeine Hinweise”-Text und hat deshalb große Werte auf allen Achsen.

Zwei Dinge sind passiert. Erstens: axis=1, keepdims=True bildet die Länge pro Zeile und hält die Form (6, 1), damit die Division jede Zeile durch ihre eigene Länge teilt. Zweitens: Nach dem Normieren ist die Kosinus-Ähnlichkeit für alle Chunks gleichzeitig nur noch eine Matrix-Vektor-Multiplikation M_norm @ q_norm. Das ist der Kern jeder Vektorsuche, die du später in pgvector (ORDER BY embedding <=> ...) von der Datenbank erledigen lässt.

Ergebnis: Chunk 1 (“Messwert … Netzbetreiber”) liegt vorn, dann der Zählerstand. Der Allgemein-Chunk mit den großen Zahlen landet nur auf Platz 3, obwohl er “groß” ist.

Falle

Falle 1: Skalarprodukt statt Kosinus. Der lange Allgemein-Chunk gewinnt dann gegen alles:

Platz 1 ist Chunk 5 (“Allgemeine Hinweise”), also genau der wenig hilfreiche Text. Die Länge des Vektors sagt nichts über die Passung.

Falle 2: falsche Achse beim Normieren. Bei np.linalg.norm(M, axis=...) entscheidet die Achse, worüber die Länge gebildet wird. Eine falsche Achse wirft hier keinen Fehler (numpy passt die Formen per Broadcasting an), liefert aber falsche Rangfolgen. Einen solchen stillen Fehler suchst du in Übung 4.

Falle 3: Chunk-Größe. Zu kleine Chunks verlieren Kontext (eine Tabellenzeile ohne Tabellenkopf ergibt nichts). Zu große Chunks verwässern die Treffergenauigkeit. Es gibt keine universelle richtige Größe, nur eine, die zu deinem Korpus passt: ausprobieren und an echten Fragen prüfen (Quelle).

Falle 4: Modelle mischen. Das Embedding-Modell für Fragen und Dokumente muss dasselbe sein (oder ein explizit dafür trainiertes Paar). Vektoren aus verschiedenen Modellen sind nicht vergleichbar (Quelle). Übung 5 dazu.

Übungen

Übung 1: Code vorhersagen (leicht)

chunk_text aus der Lektion steht dir schon zur Verfügung. Ein Text aus 120 Zeichen ("abcdefghij" * 12) wird mit size=50 und overlap=20 zerlegt. Trage ein Tupel (anzahl_chunks, startposition_des_letzten_chunks) ein. Rechne erst im Kopf (Schrittweite!), prüfe dann.

Die Schrittweite ist size - overlap. Die Chunks starten bei 0, dann jeweils eine Schrittweite weiter. Der letzte Chunk ist der, dessen Ende (start + size) den Text erreicht oder überschreitet.

antwort = (4, 90)
antwort

Übung 2: Lücke füllen (leicht bis mittel)

Fülle die beiden Lücken im Chunker: die Schrittweite und die Abbruchbedingung (der aktuelle Chunk erreicht das Textende, dann gibt es keinen weiteren Chunk, der nur aus Overlap bestünde).

Das Fenster soll pro Schritt um size - overlap vorrücken, damit overlap Zeichen doppelt vorkommen. Der aktuelle Chunk endet bei start + size.

def chunk_text(text, size, overlap):
    if overlap >= size:
        raise ValueError("overlap muss kleiner als size sein")
    step = size - overlap
    chunks = []
    start = 0
    while start < len(text):
        chunks.append(text[start:start + size])
        if start + size >= len(text):
            break
        start += step
    return chunks
chunk_text

Übung 3: Selbst schreiben (mittel)

Schreibe cosine_similarity(a, b). Eingaben sind Listen oder numpy-Arrays gleicher Länge. Rückgabe: ein float. Ist einer der beiden Vektoren der Nullvektor, gib 0.0 zurück (sonst teilst du durch 0).

Wandle die Eingaben mit np.asarray(a, dtype=float) um, damit auch Listen funktionieren. Dann a @ b / (norm(a) * norm(b)). Den Nullvektor-Fall vorher abfangen.

def cosine_similarity(a, b):
    a = np.asarray(a, dtype=float)
    b = np.asarray(b, dtype=float)
    na = np.linalg.norm(a)
    nb = np.linalg.norm(b)
    if na == 0 or nb == 0:
        return 0.0
    return float(a @ b / (na * nb))
cosine_similarity

Übung 4: Fehler finden (mittel bis schwer)

Die Funktion retrieve soll zu einer Frage die k ähnlichsten Chunks als Liste von Texten zurückgeben (beste zuerst). Sie läuft ohne Fehlermeldung, liefert aber falsche Rangfolgen. Die Daten chunks und M sind dieselben wie in Schritt 3. Finde den Fehler und repariere ihn.

Gib dir testweise np.linalg.norm(M, axis=0).shape aus und vergleiche mit der Anzahl Chunks. Zu welchem Objekt gehört eine Länge, die du normieren willst: zu einer Spalte oder zu einem Chunk? Schritt 3 zeigt den Aufruf, nach dem Division zeilenweise passt.

def retrieve(frage_vec, k=3):
    M_norm = M / np.linalg.norm(M, axis=1, keepdims=True)
    q_norm = frage_vec / np.linalg.norm(frage_vec)
    scores = M_norm @ q_norm
    beste = np.argsort(scores)[::-1][:k]
    return [chunks[i] for i in beste]

retrieve

Übung 5: Multiple Choice mit Begründung (leicht)

Dein RAG-System hat alle Dokument-Chunks mit Embedding-Modell A in die Datenbank gelegt. Ein Kollege stellt für die Fragen versehentlich auf Modell B um (neue Fragen, alte Dokument-Vektoren). Beide Modelle liefern zufällig Vektoren derselben Länge. Was passiert?

  • a) Es kommt immer eine Fehlermeldung, weil die Modelle verschieden sind.
  • b) Es läuft ohne Fehler, aber die Ähnlichkeitswerte sind bedeutungslos, die Treffer sind schlecht.
  • c) Es läuft normal, nur etwas langsamer.
  • d) Es läuft normal, die Treffer sind nur geringfügig anders.

Trage den Buchstaben als String ein.

Ob eine Rechnung technisch läuft (gleiche Länge) und ob ihr Ergebnis sinnvoll ist, sind zwei verschiedene Fragen. Die Achsen von Modell A haben in Modell B eine andere Bedeutung.

antwort = "b"
antwort

Merksatz

Chunking entscheidet, was findbar ist, Kosinus-Ähnlichkeit entscheidet, was gefunden wird: Beides prüfst du an echten Fragen, und Fragen und Dokumente brauchen dasselbe Embedding-Modell.

Prüfstein

Du hast 1000 Chunks und merkst, dass eine Frage zwar den richtigen Chunk findet, die Antwort des Modells aber trotzdem keinen Sinn ergibt. Nenne zwei mögliche Ursachen im Chunking und erkläre, wie du jede mit einer Stichprobe von 5 Chunks prüfen würdest.


Quelle: quellen/kursbuch-lerninhalte.md, Modul M2, Bausteine “02 Chunking” und “03 Embeddings”; Einordnung: quellen/lernplan-technisch.md, Tabelle der Module (M2). Die Kosinus-Formel und der Wertebereich -1 bis 1 sind allgemeines Mathematikwissen, nicht aus der Quelle. Die Vektoren in dieser Lektion sind von Hand gewählt und keine echten Embeddings.