Blockgröße und Überschneidung in RAG: Schrittweite, Token-Limits und Schnittstellen
Wie man ein langes Buch für RAG in Teile aufteilt: Größe und Überschneidung auswählen, „Stride“ verstehen, unter der Begrenzung der Embedding-Token bleiben, Metadaten speichern und die Überschneidung bei einer tatsächlichen Aufteilung betrachten.
Nehmen Sie Benjamins Franklins Autobiografie von Project Gutenberg. Das Einfügen des gesamten Buches in einen Prompt ist verschwenderisch, selbst wenn das Kontextfenster es aufnehmen könnte. Auf eine Frage wie warum Franklin wollte, dass Großbritannien Kanada behält, gibt es vermutlich die Antwort in zwei oder drei Abschnitten; die übrigen Seiten würden den Kontext aufbrauchen und die Beweise verdecken. Für solche Fälle existiert die retrieval-augmented generation (RAG). Das System findet die Abschnitte, die zur Frage passen, und übermittelt nur diese an das Sprachmodell. Das Buch bleibt im Speicher, und die Abfrage wählt die entsprechenden Beweise aus.
Embeddings und warum Text in Teile zerlegt wird
RAG beginnt damit, Textabschnitte in Embeddings umzuwandeln – numerische Repräsentationen der Bedeutung. Abschnitte mit ähnlicher Bedeutung liegen selbst bei unterschiedlicher Formulierung in einem hochdimensionalen Vektorraum nahe beieinander. Wenn eine Frage eingeht, wird auch sie in ein Embedding umgewandelt, die nächstgelegenen gespeicherten Vektoren werden gefunden und die entsprechenden Quelltextabschnitte zurückgegeben. Der Vektor lokalisiert den Text, und dieser ist es, was das Modell liest.
Ein einzelnes Embedding kann kein ganzes Buch darstellen. Ein einziger Vektor für Franklins Leben würde seine Kindheit in Boston, seine Druckexperimente, Elektrizitätsversuche, „Poor Richard“ sowie seine Aussagen vor dem Parlament zu etwas verschmelzen, das keines dieser Aspekte gut beschreibt. Deshalb muss der Text vor der Embedding-Generierung in Teile aufgeteilt werden.
Größe, Überlappung und Schrittweite
Ein vernünftiger Ausgangspunkt:
DEFAULT_CHUNK_SIZE = 800 # characters
DEFAULT_CHUNK_OVERLAP = 150 # characters
Menschen betrachten diese oft als zwei unabhängige Einstellräder. Tatsächlich definieren sie etwas Einziges – nämlich, wie weit das Fenster bei jedem Schritt nach vorne bewegt wird:
stride = chunk_size − chunk_overlap
= 800 − 150
= 650 characters
Dieser Schritt gibt an, wie viel neuer Text jeder neue Teil hinzufügt.
Betrachten Sie die Größe der Teile als Obergrenze und nicht als Ziel. Die Trennfunktionen bevorzugen es, an natürlichen Grenzen zu schneiden – sei es ein Absatz, eine Zeile, ein Satz oder ein Wort – wodurch eine Anforderung von 800 Zeichen zu Teilen mit 640, 731 oder genau 800 Zeichen führen kann.
Überschneidungen sind Texte, die von benachbarten Teilen geteilt werden, und sie erfüllen eine wichtige Funktion. Ohne Überschneidungen kann ein Schnitt einen solchen verlorenen Fragment hinterlassen:
"…but left school at the age of ten."
Allein sagt diese Zeile nicht viel aus. Durch Überschneidung behält der folgende Abschnitt genügend früheren Kontext bei, um lesbar und auffindbar zu bleiben. Das ist besonders wichtig für lange Sätze und eng miteinander verbundene Argumente. Franklins Brief über Kanada enthält eine lange Kette von Klauseln, die eine Grenze überschreiten könnten, wodurch ein Abschnitt ohne Schluss und der nächste ohne Ausgangspunkt bleibt. Eine moderate Überschneidung bewahrt die Verbindung in beiden Einträgen.
Der Preis dafür ist die Duplikation. Nachbarn mit identischem Wortlaut können zusammen abgerufen werden, was normal ist. Wenn Duplikate bessere Beweise verdrängen, hilft eine auf Vielfalt ausgerichtete Methode wie die maximale marginale Relevanz (MMR).
Was jeder gespeicherte Abschnitt enthält
Jeder Abschnitt enthält vier Felder:
id
document
embedding
metadata
Ein Beispiel-Eintrag:
id 7c2a1e90-4b11-4d3e-9f08-12a6c0e84b21
document "His boyhood in Boston was a stern beginning of the habit
of hard work and rigid economy which marked the man. For
a year he went to the Latin Grammar School on School
Street, but left off at the age of ten to help his father
in making soap and candles."
embedding 384 floats — [-0.0412, 0.0187, 0.0621, -0.0094, 0.0330, …]
norm = 1.0
metadata {
book: "Franklin's Autobiography",
source: "https://www.gutenberg.org/cache/epub/36151/pg36151-images.html",
gutenberg_id: 36151,
page_label: "5",
chunk_index: 2
}
Die Einbettung dient der Suche, während der ursprüngliche Text zum Lesen, Zitieren und Verweisen verwendet wird. Auch Metadaten erfordern Aufmerksamkeit. Wenn die Ausgabe Seitenbezeichnungen wie [Pg 5] enthält, sollten diese beibehalten werden, damit eine Quelle auf eine Seite verweist, die Leser in ihrer Ausgabe finden können.
Vor der Indizierung sollten weniger wertvolle Inhalte entfernt werden: Titelseiten, Standardtexte, Lizenzbedingungen, wiederholte Überschriften sowie Fußnotenansammlungen. Diese erzeugen schwache Einbettungen und beeinträchtigen die Suchleistung.
Normalisierte Vektoren
Embedding-Vektoren werden in der Regel L2-normalisiert, wodurch jeder Vektor auf eine Länge von 1 skaliert wird:
‖v‖ = √(v₁² + v₂² + … + vₙ²)
Die Kosinusähnlichkeit vergleicht zwei Vektoren wie folgt:
cos θ = (a · b) / (‖a‖ ‖b‖)
Wenn beide eine Einheitslänge haben, reduziert sich das auf ein einfaches Skalarprodukt:
If ‖a‖ = ‖b‖ = 1:
cos θ = a · b
Die Rangfolge spiegelt daher wider, inwieweit zwei Bedeutungen in dieselbe Richtung deuten, wobei die reine Vektorgröße keine Rolle spielt – dadurch bleibt die Abfrage schnell und sinnvoll.
Tokens statt Zeichen
Einstellungen in Bezug auf Zeichen können eine harte Grenze verbergen, da Embedding-Modelle Tokens zählen. Das all-MiniLM-L6-v2-Modell akzeptiert maximal 256 Tokens. Ein längeres Stück kann ohne Vorwarnung abgeschnitten werden. Die Datenbank enthält zwar den vollständigen Text, doch sein Embedding umfasst nur den Anfangsteil; der abgeschnittene Rest ist schwer oder gar nicht mehr auffindbar. Eine vorsichtige Schätzung:
maximum safe chunk size in characters
≈ model token limit × 4
Ein Modell mit 256 Token ergibt in etwa 1.000 Zeichen. Wenn pro Token vier Zeichen verwendet werden, bleibt Pufferraum, da Dialoge, Abkürzungen, Satzzeichen und formatierter Text dichter tokenisiert werden können als Prosa.
Jüngere Modelle verarbeiten deutlich längere Eingaben, doch das Chunking ist weiterhin wichtig. Ein größeres Fenster löst das Problem der Informationsabrufung nicht, da übermäßig große Blöcke unverwandte Themen in einen einzigen verschwommenen Vektor mischen.
Durch den Prozessschritt für Schritt
Zuerst werden die geladenen Seiten in Blöcke aufgeteilt:
from rag_qa.gutenberg import load_pages
from rag_qa.chunk import chunk_documents
from rag_qa.config import load_settings
s = load_settings()
print(s.summary())
# {
# 'chunk_size_chars': 800,
# 'chunk_overlap_chars': 150,
# 'stride_chars': 650, # size − overlap; this sets chunk count
# 'model': 'all-MiniLM-L6-v2',
# 'model_max_tokens': 256, # hard cap; overflow is silent
# 'embedding_dims': 384,
# 'max_safe_chunk_chars': 1024, # 256 × 4
# 'book': "Franklin's Autobiography",
# 'gutenberg_id': 36151,
# }
pages = load_pages()
chunks = chunk_documents(pages, s.chunk_size, s.chunk_overlap)
print(len(pages), len(chunks), s.stride)
Zweitens prüfen Sie jeden Teilabschnitt hinsichtlich der Token-Limit des Modells:
from sentence_transformers import SentenceTransformer
from rag_qa.tokens import check_chunk
m = SentenceTransformer("all-MiniLM-L6-v2")
print(m.max_seq_length) # 256
for c in chunks:
r = check_chunk(c)
if r.truncated:
print("silent truncate:", r.n_chars, "chars /", r.n_tokens, "tokens")
Die Skript gibt die maximale Länge des Modells aus und listet anschließend nur die Abschnitte auf, die stillschweigend entfernt werden würden. Bei einem normalen Ausführungsvorgang wird lediglich die Obergrenze angezeigt:
256
Wenn alles hineinpasst, ist das alles, was man sieht. Wenn einige Teile darüber hinausgehen, entstehen zusätzliche Zeilen:
256
silent truncate: 1820 chars / 412 tokens
silent truncate: 960 chars / 301 tokens
Wie man diese Zeilen liest:
Drittens: Schauen Sie sich an, was letztendlich gespeichert wird:
from rag_qa.store import ingest, open_store
store = open_store()
ingest(chunks, store)
row = store.get(limit=1)
print(row["ids"][0])
print(row["documents"][0][:200])
print(len(row["embeddings"][0]), row["metadatas"][0])
# 384 floats, norm 1.0, metadata.page_label == printed [Pg N]
Dieser Auszug zeigt nach der Verarbeitung einen einzigen Teil an und gibt drei Zeilen aus:
doc-12-p3-c0
She left school at the age of ten. The next sentence continues on the same page…
384 {'page_label': '3', 'source': 'notes.pdf'}
Viertens, fragen Sie danach:
from rag_qa.retrieve import search, search_mmr
q = "why did Franklin want Britain to keep Canada"
for hit in search(q, k=5):
print(f"{hit['score']:.3f} p.{hit['metadata']['page_label']} {hit['document'][:120]}")
# overlap makes near-duplicate hits; MMR trades a little score for diversity
for hit in search_mmr(q, k=5):
print(hit["metadata"]["page_label"], hit["score"])
Ein Beispiel für die Ergebnisse:
0.812 p.7 I have long been of opinion that the foundations of the future grandeur and stability of the British empire lie in America
0.781 p.7 they are, nevertheless, broad and strong enough to support the greatest political structure that human wisdom ever yet
0.744 p.7 I am, therefore, by no means for restoring Canada. If we keep it all the country from the St. Lawrence to the Mississippi
0.691 p.8 I left England about the end of August, 1762, in company with ten sail of merchant ships
0.640 p.6 In this Autobiography Franklin tells of his own life to the year 1757, when he went to England
Die wichtigsten Treffer stammen oft von derselben Seite. Das ist Überschneidung im Spiel, da benachbarte Abschnitte derselben Aussage übereinstimmen.
Überschneidung anhand eines kleinen Beispiels
Nehmen Sie einen kurzen Abschnitt von etwa 190 Zeichen aus der Einleitung des Buches, der die Kindheit des Autors in Boston beschreibt: frühzeitige Gewohnheiten harter Arbeit und Sparsamkeit, ein Jahr an der Lateinschule in School Street sowie der Weggang im Alter von zehn Jahren. Teilen Sie ihn dabei in Abschnitte mit einer Größe von jeweils nur 60 Zeichen.
Falls es keine Überschneidung gibt, schneidet der Trenner genau an der Grenze.
[0] 59 His boyhood in Boston was a stern beginning of the habit of
[1] 55 hard work and rigid economy which marked the man. For a
[2] 58 year he went to the Latin Grammar School on School Street,
[3] 31 but left off at the age of ten.
Blöck 0 endet bei „the habit of“ und Blöck 1 beginnt bei „hard work“, wobei nichts sie miteinander verbindet. Der Ausdruck „habit of hard work“ kommt niemals vollständig in einem Blöck vor. Wenn die Trennung mitten im Satz erfolgt, kodiert jeder Vektor nur die Hälfte der Idee, und eine Abfrage, die den vollständigen Satz benötigt, passt schlecht zu beiden Teilen.
Bei einer Überlappung von 15 Zeichen:
[0] 59 His boyhood in Boston was a stern beginning of the habit of
[1] 57 ⟦the habit of⟧ hard work and rigid economy which marked the
[2] 55 ⟦marked the⟧ man. For a year he went to the Latin Grammar
[3] 58 ⟦Latin Grammar⟧ School on School Street, but left off at the
[4] 22 ⟦off at the⟧ age of ten.
Der Marker unten zeigt an, welcher Text aus dem vorherigen Abschnitt wiederholt wird:
⟦…⟧ = text repeated from the previous chunk
Kein Teil erreicht 60 Zeichen, was erneut zeigt, dass die Größe lediglich eine Obergrenze ist. Das Überspringen von Überlappungen führt zu einem kleineren Index und vermeidet doppelte Treffer, doch alles, was zufällig durch den Trenner geschnitten wird, geht verloren. Überlappungen sichern diese Verbindungen und dienen nicht ausschließlich dem Zweck, zusätzlichen Kontext zu liefern.
Die vier Aufgaben und die dazu gehörenden Tools
RAG umfasst vier Aufgaben: Text laden, ihn aufteilen, einbetten sowie die Vektoren speichern und suchen. Es gibt keine einzige Bibliothek, die als „RAG“ bezeichnet werden kann. Tools für das Aufteilen und die Koordination:
Einbettungsmodelle:
Beliebte Vektordatenbanken vervollständigen das Ökosystem.