Übergabenotiz · Tuning des RAG-Korpus

Erst Retrieval, dann Generierung

Eine Arbeitsanleitung zum Tuning der local_rag-Pipeline für ein zweiteiliges Korpus aus literarischen Primärtexten und wissenschaftlicher Sekundärliteratur. Was sich zu probieren lohnt, in welcher Reihenfolge — und woran sich erkennen lässt, ob es etwas gebracht hat.

Projekt: local_rag Stack: LangChain · FAISS · MiniLM · Ollama/OpenAI Zielgruppe: wer die Pipeline als Nächstes betreut

Die eine Regel: von vorne nach hinten arbeiten und nach jedem Schritt messen. Probleme der Retrieval-Qualität zeigen sich immer am Ende der Kette — eine schwache Antwort, eine erfundene Behauptung, ein unpassendes Zitat. Verursacht werden sie fast immer weiter vorne: dabei, wie aus einem PDF Text wird und wie dieser Text zerteilt und eingebettet wird.

Praktische Konsequenz: der Versuchung widerstehen, zuerst den Prompt anzupassen. Wer den Antwort-Prompt erweitert, obwohl die abgerufenen Passagen schlecht sind, bringt dem Modell lediglich bei, elegant um schlechte Belege herumzuschreiben — und die scheinbare Verbesserung verdeckt die eigentliche Ursache für alle, die später hinsehen.

Extraktion→Chunking→Embedding→Retrieval→Reranking→Prompt→Generierung   türkis = in dieser Notiz behandelt
≡

Kurzes Glossar

Die Begriffe, die unten vorausgesetzt werden

Wer nicht täglich mit solchen Systemen arbeitet, stolpert weiter unten über ein knappes Dutzend Fachwörter. Sie sind hier einmal in Alltagssprache erklärt; das genügt, um die ganze Notiz zu verstehen.

Chunk
Ein Textabschnitt. Dokumente werden vor dem Einlesen in solche Stücke zerschnitten, weil immer nur einzelne Abschnitte gesucht und an das Sprachmodell weitergereicht werden, nie ganze Bücher.
Embedding
Eine lange Zahlenreihe, die den Inhalt eines Textstücks ausdrückt. Texte mit ähnlicher Bedeutung bekommen ähnliche Zahlenreihen — dadurch lässt sich Ähnlichkeit rechnen statt nur Wörter vergleichen.
Vektor
Das mathematische Wort für ebendiese Zahlenreihe. „Zwei Vektoren liegen nah beieinander" heißt: die beiden Texte ähneln sich inhaltlich.
Vektorspeicher, Index
Die durchsuchbare Ablage aller Chunks samt ihren Zahlenreihen. Im Projekt ist das der Ordner vectorstore. Er wird erzeugt, nicht von Hand gepflegt, und kann jederzeit neu gebaut werden.
Retrieval
Das Heraussuchen der passenden Chunks zu einer Frage. Der Schritt, um den es in dieser Notiz fast ausschließlich geht — noch bevor ein Sprachmodell überhaupt beteiligt ist.
Token
Die Recheneinheit für Text, ungefähr ein Wortteil. Ein Modell mit einer Grenze von 256 Token kann also nur etwa 150 bis 200 deutsche Wörter auf einmal verarbeiten.
dense / sparse
Zwei Suchprinzipien. Dense sucht nach Bedeutungsähnlichkeit und findet auch Umschreibungen; sparse sucht nach den tatsächlichen Wörtern und ist bei Eigennamen und Zitaten überlegen. Beides zusammen heißt hybride Suche.
Reranking
Ein zweiter, genauerer Sortierdurchgang: Man holt bewusst viele Kandidaten und lässt sie von einem präziseren Verfahren neu ordnen, bevor die besten weitergegeben werden.
Prompt
Der Text, den das Sprachmodell tatsächlich zu sehen bekommt — Frage, Gesprächsverlauf und die gefundenen Chunks in einem Stück. Steht in local_rag.py.
Repository
Der Projektordner unter Versionsverwaltung (Git). „Gehört ins Repository" heißt schlicht: als Datei im Projekt ablegen, damit die Änderung nachvollziehbar bleibt.
∴

Vom Symptom ausgehen

Wo zu suchen ist, wenn etwas nicht stimmt

Wer bereits konkrete Probleme identifiziert hat, findet hier die Phase, die dafür am wahrscheinlichsten verantwortlich ist. Bei allgemeinem Tuning, ohne einem Symptom nachzugehen, werden die Phasen einfach der Reihe nach durchgearbeitet.

BeobachtungWahrscheinliche UrsacheSiehe
Chunks enthalten Fußnoten, Literaturangaben oder Kolumnentitel mitten im Fließtext Flache Textextraktion, die die Seitenstruktur ignoriert Phase 02
Chunks brechen mitten im Satz ab; Zitate kommen unvollständig an Zeichenbasiertes Splitting ohne Rücksicht auf Absatzgrenzen Phase 03
Eine Passage, von der bekannt ist, dass es sie gibt, wird nie gefunden — egal wie die Frage formuliert wird Trunkierung durch das Embedding-Modell oder eine Sprachdiskrepanz Phase 01, 03
Suchen nach Eigennamen, Kunstwörtern oder Werktiteln laufen ins Leere Kein lexikalischer Abgleich — rein dense Retrieval rät semantisch Phase 04
Es kommen ungefähr passende Passagen zurück, aber nie die beste zuerst Es fehlt eine Reranking-Stufe Phase 04
Die abgerufenen Passagen überzeugen beim Lesen, die Antwort bleibt trotzdem schwach Tatsächlich ein Generierungsproblem — der einzige Fall, in dem der Prompt die richtige Stellschraube ist Nach Phase 04
00

Einen Maßstab schaffen, bevor irgendetwas geändert wird

Ein halber Tag · Voraussetzung für alles Weitere

Jede Änderung hinterlässt den Eindruck, es sei besser oder schlechter geworden. Dies ist der Versuch, diese Veränderungen etwas strukturierter auf ihre Qualität zu überprüfen. Diese Phase lohnt sich selbst dann, wenn sonst nichts geändert wird.

Einen Satz von 25–40 Referenzfragen anlegen

Echte Fragen aus der tatsächlichen Forschungsarbeit, verteilt über beide Korpora und über verschiedene Fragetypen: eine Faktenfrage an einen Primärtext, eine Frage zur Deutung einer Passage, eine „wer vertritt welche These zu X"-Frage an die Sekundärliteratur, dazu zwei bis drei Fragen, deren Antwort beide Korpora berührt. Zu jeder Frage wird festgehalten, in welcher Datei und auf welcher Seite die Antwort tatsächlich steht.

# eval/goldset.csv
frage,korpus,erwartete_quelle,erwartete_seite,anmerkung
"Wie wird Odradek beschrieben?",primaer,hausvater.pdf,3,"Beschreibung, nicht Deutung"
"Welche Rolle spielt das Gesetz in Vor dem Gesetz?",primaer,prozess.pdf,182,
"Wer liest die Verwandlung als Familienkonflikt?",sekundaer,mueller_2003.pdf,44,"Argument im Fließtext"

Das Retrieval für sich bewerten, ohne LLM in der Schleife

Die Bewertung des Retrievals braucht keine Generierung und ist deshalb kostenlos, deterministisch und in Sekunden statt Minuten durch. Ein kurzes Skript lädt den Vektorspeicher, stellt jede Referenzfrage und berechnet Recall@k — taucht die erwartete Quelle mit der erwarteten Seite unter den ersten k Treffern auf? — für k = 5, 10 und 20, dazu den Mean Reciprocal Rank.

Dieser Ausgangswert gehört als schlichte Textdatei ins Repository. Jede spätere Änderung wird damit zur Differenz gegenüber einer Zahl, und eine Änderung, die diese Zahl nicht bewegt, lässt sich guten Gewissens zurücknehmen.

Das Skript liegt bereit: eval_retrieval.py

Es ist absichtlich schlicht gehalten und lässt sich ohne Programmierkenntnisse bedienen. Alle Einstellungen stehen als Großbuchstaben-Zeilen oben in der Datei; geändert wird dort und sonst nirgends.

  1. Eigene Fragen eintrageneval/goldset.csv in Excel, Numbers oder LibreOffice öffnen und die drei Beispielzeilen durch echte Fragen ersetzen. frage: die Frage selbst. korpus: primaer oder sekundaer, damit beide Hälften getrennt ausgewertet werden. erwartete_quelle: der Dateiname, in dem die Antwort steht — ohne Pfad, Groß- und Kleinschreibung egal. erwartete_seite: die Seitenzahl, die der PDF-Betrachter anzeigt; im Zweifel leer lassen, dann zählt nur die Datei. anmerkung: freies Feld für Notizen. Als CSV speichern — ob das Tabellenprogramm Kommas oder Semikolons schreibt, erkennt das Skript selbst.
  2. Skript startenIm Terminal, im Projektordner. Der erste Befehl schaltet die für dieses Projekt eingerichtete Python-Umgebung ein — das ist nötig, weil dort die passenden Zusatzpakete liegen; ohne ihn bricht der zweite Befehl mit einer Fehlermeldung über fehlende Module ab. Ollama muss an dieser Stelle nicht laufen, ein OpenAI-Schlüssel wird nicht gebraucht.
  3. Zahlen lesenRecall@5 ist der Anteil der Fragen, deren Fundstelle unter den ersten fünf Treffern steht: 0.60 heißt drei von fünf Fragen. MRR beschreibt, wie weit oben die Treffer stehen — 1.00 wäre immer Platz eins, 0.50 im Schnitt Platz zwei. In der Liste der Einzelfragen bedeutet ein Strich statt einer Rangzahl, dass die Stelle unter den ersten 20 Treffern gar nicht auftauchte; diese Fragen sind die interessantesten.
  4. Bericht aufhebenDas Skript schreibt eval/baseline.txt. Diese Datei ins Repository legen und bei jeder späteren Messung mit einer kurzen Notiz versehen, was zwischenzeitlich geändert wurde. Genau dieser Vergleich ist der Zweck der Übung.
  5. Gegenprobe zum ZusatztextOben im Skript steht USE_QUERY_INSTRUCTIONS. Auf True gesetzt hängt das Skript den Zusatztext aus local_rag.py an jede Frage an. Ein Lauf mit True und einer mit False beantworten die Frage aus Phase 01, ob dieser Text tatsächlich hilft.
conda activate local_rag
python eval_retrieval.py

Was das Skript nicht tut, und was schiefgehen kann

Es bewertet ausschließlich, ob die richtige Stelle gefunden wird — nicht, ob die Antwort gut formuliert ist. Das ist Absicht: Antwortqualität lässt sich erst sinnvoll beurteilen, wenn die Belege stimmen.

Wenn plötzlich alle Fragen ohne Treffer bleiben, liegt es fast immer an einer von drei Kleinigkeiten: Der Dateiname in der CSV weicht von dem im Index ab; der Index wurde seit dem letzten Umbau nicht neu erzeugt; oder die Seitenzahl stimmt nicht. Beim Letzten hilft das Skript bereits mit — es rechnet die menschliche Zählung ab 1 in die maschinelle ab 0 um und lässt eine Seite Abweichung zu, weil ein Chunk über einen Seitenumbruch reichen kann. Wer ganz sicher gehen will, lässt die Seitenspalte zunächst leer und ergänzt sie später.

Zwanzig zufällige Chunks selbst lesen

Zwanzig Chunks direkt aus dem Speicher exportieren und als Mensch lesen. Beachtenswert sind: Sätze, die mitten im Teilsatz abbrechen; Anmerkungen, die in den Fließtext hineinragen; Seitenzahlen und Spaltentitel mitten im Absatz; Trennstriche, die vom Zeilenumbruch übrig geblieben sind; Literaturverzeichniszeilen, die wie Fließtext indexiert wurden. Grob schätzen, welcher Anteil eines Chunks Material ist, auf das man eine Antwort wirklich stützen möchte. Dieser Anteil ist der zweite Ausgangswert — und meist der aufschlussreichere von beiden.

01

Konfigurationsfallen, die noch geprüft werden sollten

Schnell geprüft · unverhältnismäßig große Wirkung

Vier Eigenschaften eines Setups aus LangChain, FAISS und sentence-transformers, die das Retrieval unbemerkt verschlechtern und leicht zu übersehen sind.

Zuerst prüfen Chunk-Größe über dem Kontextfenster des Embedding-Modells

Jedes sentence-transformer-Modell hat eine maximale Sequenzlänge, und Text darüber hinaus wird stillschweigend verworfen, bevor der Vektor berechnet wird — ohne Warnung, ohne Fehler. Liegt die Chunk-Größe über diesem Fenster, ist das Ende jedes Chunks nicht durchsuchbar: es kann nie auf eine Frage passen, landet aber trotzdem im Kontext des LLM, sobald der verbleibende Anfang passt.

Bei den üblichen Voreinstellungen fällt das deutlich ins Gewicht. Das Modell all-MiniLM-L6-v2 endet bei 256 Token — das sind rund 900 bis 1.100 Zeichen deutscher Prosa — während CHUNK_SIZE in local_rag.py die Länge der Textstücke in Zeichen angibt und oft auf 1.000 bis 2.000 steht. Beide Zahlen müssen zusammenpassen.

Nachsehen lässt sich die Modellgrenze mit drei Zeilen: eine Datei namens check.py im Projektordner anlegen, den folgenden Text hineinkopieren und sie mit python check.py starten. Die ausgegebene Zahl ist die Obergrenze in Token; liegt CHUNK_SIZE in Zeichen deutlich über dem Dreifachen davon, wird abgeschnitten. Abhilfe: entweder die Chunks verkleinern oder auf ein Modell mit größerem Fenster wechseln (Phase 03).

from local_rag import LocalRAG
rag = LocalRAG()
print(rag.embeddings.client.max_seq_length)

Zuerst prüfen Natürlichsprachliche Anweisungen in der Embedding-Query

Es liegt nahe, das Retrieval zu steuern, indem man der Suchanfrage einen Satz anhängt — etwa „bevorzuge argumentierenden Fließtext, keine Literaturlisten". Embedding-Modelle lesen jedoch keine Anweisungen und besitzen keine Repräsentation von Verneinung: Der Satz wird schlicht in einen Vektor überführt und in die Anfrage eingemittelt, was jede Suche in Richtung von Text zieht, der über Literaturangaben und Argumente spricht. Er zieht das gemeinte Material also ebenso wahrscheinlich an, wie er es abstößt.

Retrieval steuert man über die Ingestion (Phase 02) und über Metadatenfilter (Phase 03), nicht über die Formulierung der Anfrage. Ist eine solche Zeichenkette im Einsatz, sollte Recall@k mit und ohne sie gemessen werden — man muss wissen, in welche Richtung sie tatsächlich gewirkt hat, bevor man entscheidet.

Hygiene Rohe L2-Distanz auf nicht normalisierten Vektoren

Es gibt zwei gängige Arten, den Abstand zweier Zahlenreihen zu messen. Die eine achtet nur auf die Richtung (Kosinus-Ähnlichkeit), die andere zusätzlich auf die Länge der Reihe (L2-Distanz). Die bisher verwendeten Modelle sind auf die erste Art trainiert. Misst man trotzdem nach der zweiten, entscheidet die Länge mit — und die hängt an Umfang und Wiederholungsgrad eines Chunks, weshalb lange, nichtssagende Textbausteine bevorzugt werden.

Die Umstellung ist eine einzelne Zeile: beim Laden und Erzeugen des Speichers in local_rag.py zusätzlich distance_strategy=DistanceStrategy.COSINE übergeben. Nebeneffekt: Die Ähnlichkeitswerte im Protokoll werden dadurch untereinander vergleichbar.

Hygiene Ein Index, der nicht mehr zum Code passt, der ihn liest

Der Vektorspeicher ist ein Erzeugnis, kein Dokument — vergleichbar mit einem Registerband, der zu genau einer Auflage gehört: Er gilt nur für das Embedding-Modell, die Chunk-Einstellungen und das Extraktionswerkzeug, die ihn hervorgebracht haben. Ändert sich eines der drei, entspricht der Index auf der Platte stillschweigend nicht mehr den Einstellungen im Quelltext. Diese drei Werte also neben jedem Index festhalten, bei jeder Änderung neu bauen und vor jedem Benchmark-Lauf ohnehin — sonst misst man ein Artefakt der Vergangenheit.

02

Was die beiden Ingestion-Pipelines brauchen

Größter Hebel bei verrauschtem Extraktionstext

Die beiden Pipelines verlangen von der Extraktion Verschiedenes: Bei den Primärtexten ist es vor allem eine Frage der Textbeschaffung, bei der Sekundärliteratur entscheidet sich die Extraktionsqualität. Bevor man eine der beiden für erledigt erklärt, lohnt der Blick darauf, was ein allgemeiner Loader eigentlich tut — PyPDFLoader und Verwandte — ein Loader ist das Stück Programm, das ein PDF einliest und in reinen Text verwandelt — behandeln eine Seite als einen flachen Zeichenstrom, also als eine einzige lange Buchstabenkette ohne Wissen darüber, was Überschrift, Fließtext oder Anmerkung war.

Die Primärtext-Pipeline

Erzählungen und Romane sind vergleichsweise einfach — kein Literaturverzeichnis, kein zweispaltiger Satz. Eine weitere Bearbeitung des Ausgangsmaterials könnte in erster Linie Seitenzahlen, Anmerkungen o.ä. entfernen.

Die Sekundär-Pipeline: ein Parser, der Dokumentstruktur erkennt

Hier liegt bei einer Vorverarbeitung viel Potenzial. Idealerweise verwenden wir einen Extraktor, der typisierte Elemente statt eines Zeichenstroms zurückgibt: Er liefert also nicht nur den Text, sondern zu jedem Stück auch die Angabe, worum es sich handelt — Überschrift, Fließtext, Fußnote, Literaturangabe. Damit wird aus einem mühsamen Textbereinigungsproblem eine schlichte Entscheidung pro Sorte: diese behalten, jene weglassen.

In der Regel als Fließtext indexieren

  • Abstract
  • Zwischenüberschriften (als Chunk-Kontext)
  • Argumentierender Fließtext
  • Blockzitate aus Primärtexten
  • Tabellen- und Abbildungslegenden

In der Regel abtrennen

  • Fuß- und Endnoten
  • Literaturverzeichnis
  • Kolumnentitel, Seitenzahlen
  • Zeitschriften-Boilerplate, Copyright-Zeilen
  • Affiliationen, Danksagungen

Es empfiehlt sich, Fußnoten/Anmerkungen mit element_type: footnote in den Metadaten zu indexieren. Standardmäßig könnte man sie vom Retrieval ausschließen und bei Bedarf wieder zuschalten. Löschen wäre unumkehrbar.

Werkzeuge, grob nach Aufwand geordnet

WerkzeugWas es liefertAufwand / Passung
GROBID Eigens für wissenschaftliche PDFs gebaut. Gibt TEI-XML aus, in dem Fließtext, Anmerkungen und Literaturverzeichnis getrennt ausgezeichnet sind, dazu geparste Zitationsmetadaten. Kostenlos, läuft im Haus. Wird als Docker-Paket gestartet — ein Fertigpaket, das man startet, ohne die Bestandteile einzeln installieren zu müssen. Die stärkste Passung für ein Sekundärliteratur-Korpus — für genau diesen Dokumenttyp entwickelt.
Docling Wandelt PDFs in strukturiertes Markdown/JSON mit korrekter Lesereihenfolge, Überschriften, Tabellen und Anmerkungen als eigenen Elementen. Kostenlos, lokal, aktiv entwickelt. Gute Allzweckwahl und leichter einzuführen als GROBID.
unstructured Zerlegt in typisierte Elemente (NarrativeText, Title, Footer, ListItem); das Filtern nach Typ sind wenige Zeilen. Open-Source-Bibliothek plus gehostete API; LangChain-Anbindung existiert bereits. Der schnellste Weg von einem Standard-Setup aus.
PyMuPDF Text-Spans mit Koordinaten und Schriftgrößen — genug für eine eigene Heuristik, etwa: Spans unter ~85 % der häufigsten Grundschriftgröße im unteren ~20 % der Seite verwerfen. Kostenlos, schnell, keine neue Infrastruktur. Empfindlich gegenüber wechselnden Layouts; gut, solange ein Korpus typografisch einheitlich ist.

Den Extraktor prüfen

Unabhängig von der Wahl: fünf PDFs mit den unübersichtlichsten Layouts des Korpus nehmen und den extrahierten Fließtext mit dem vergleichen, was man von Hand ausgezeichnet hätte. Die Extraktionsqualität schwankt zwischen Verlagen erheblich, und ein Werkzeug, das beim zweispaltigen Satz der einen Zeitschrift hervorragend ist, kann bei der nächsten scheitern.

03

Chunking und Embeddings gemeinsam wählen

Für sich genommen bedeutet keine der beiden Einstellungen etwas

Alles in dieser Phase spielt sich in einer einzigen Datei ab: local_rag.py. Zur Orientierung vorab, welche Änderung wo ansetzt und was danach fällig wird. Die Zeilennummern sind Stand heute und verschieben sich beim Bearbeiten — verlässlich ist der genannte Ort.

ÄnderungStelle in local_rag.pyDanach nötig
Embedding-ModellIn LocalRAG.__init__, der Aufruf HuggingFaceEmbeddings(...) — Zeile 48Index neu bauen
Chunk-GrößeDie Konstanten CHUNK_SIZE und CHUNK_OVERLAP ganz oben — Zeile 16/17Index neu bauen
TrennlogikDer RecursiveCharacterTextSplitter in __init__ — Zeile 53Index neu bauen
MetadatenIn add_documents, zwischen loader.load() und split_documents(...) — Zeile 114/115Index neu bauen
Klein suchen, groß liefernIn query_with_history, der Aufruf similarity_search_with_score — Zeile 251Größerer Umbau, siehe unten

„Index neu bauen" heißt jedes Mal dasselbe: add_pdfs.py mit CLEAR_VECTORSTORE_FIRST = True laufen lassen. Der alte Index passt nach solchen Änderungen nicht mehr und würde stillschweigend Unsinn liefern. Direkt danach eval_retrieval.py starten und die neue Zahl mit der alten vergleichen.

Das Embedding-Modell kommt zuerst

Bei einem deutschsprachigen Korpus könnte ein englisch trainiertes Modell mit kurzem Kontextfenster problematisch sein. Kandidaten, alle lokal lauffähig, sind:

  • BAAI/bge-m3 — mehrsprachig, verkraftet lange Eingaben und liefert aus einem Modell dense und sparse Repräsentationen, womit hybride Suche (Phase 04) fast geschenkt ist. Hier eine gute Voreinstellung.
  • intfloat/multilingual-e5-large — starke mehrsprachige Qualität, 512-Token-Fenster. Achtung: Dieses Modell erwartet, dass jedem Text ein Kennwort vorangestellt wird — query: vor der Frage, passage: vor dem Textabschnitt —, damit es beide unterschiedlich behandelt. Wird das vergessen, verschlechtern sich die Ergebnisse messbar, ohne dass eine Fehlermeldung darauf hinweist.
  • jinaai/jina-embeddings-v3 — mehrsprachig, langer Kontext, aufgabenspezifische Adapter.

Im Code steht der Modellname genau einmal, in LocalRAG.__init__. Ein Wechsel ist wörtlich ein Austausch dieser Zeichenkette; das Modell wird beim ersten Start automatisch heruntergeladen, was einige Minuten dauern kann:

# local_rag.py, in LocalRAG.__init__ (derzeit Zeile 48)
self.embeddings = HuggingFaceEmbeddings(
    model_name="BAAI/bge-m3"        # vorher: sentence-transformers/all-MiniLM-L6-v2
)

Ein Hinweis, der bei der Modellwahl den Ausschlag geben kann: Bei bge-m3 genügt tatsächlich diese eine Zeile. Bei multilingual-e5-large reicht sie nicht, weil das Modell die oben erwähnten Kennwörter erwartet — Fragen und Textabschnitte müssten an zwei weiteren Stellen unterschiedlich behandelt werden. Wer den Aufwand klein halten will, nimmt deshalb bge-m3.

Wenn eine API in Frage kommt, sind gehostete Embedding-Modelle (Voyage, Cohere, OpenAI) bei mehrsprachigem Material meist noch einmal besser, zu Kosten, die bei dieser Korpusgröße vernachlässigbar sind — vorher ist allerdings zu klären, ob Institutsmaterial überhaupt an einen externen Dienst übergeben werden darf — das entscheidet der Datenschutz, nicht die Pipeline.

Danach die Chunk-Größe auf das setzen, was das Modell tatsächlich liest

Die Größe steht als Konstante ganz oben in der Datei, gut sichtbar und ohne Umwege änderbar. Die Werte sind Zeichen, nicht Token:

# local_rag.py, Zeile 16/17
CHUNK_SIZE = 2000
CHUNK_OVERLAP = 400   # wie viel benachbarte Chunks einander überlappen

Die Überlappung sorgt dafür, dass ein Gedanke, der genau auf einer Schnittkante liegt, nicht verloren geht — er steht dann am Ende des einen und am Anfang des nächsten Chunks. Als Faustregel liegt sie bei etwa einem Fünftel der Chunk-Größe.

Steht das Modell fest, wollen die beiden Korpora unterschiedlich geteilt werden:

  • Primärtexte: an Absatz- und Szenengrenzen trennen statt nach fester Zeichenzahl. Erzählte Bedeutung übersteht Schnitte mitten im Satz nicht, und literaturwissenschaftliche Fragen hängen oft an der Gestalt einer Passage.
  • Sekundärtexte: an Abschnittsgrenzen trennen, mit einer Obergrenze — und erwägen, jedem Chunk vor dem Embedding die Zwischenüberschrift und einen kurzen bibliografischen Nachweis voranzustellen, sodass er „Müller 2003, 3.2 Familienstruktur" mit sich führt. Ein isolierter Absatz aus der Mitte einer Argumentation ist sonst kontextlos; bei wissenschaftlichen Korpora hilft das zuverlässig.

Womit an welchen Stellen getrennt wird, steuert der Parameter separators. Die Liste wird von oben nach unten abgearbeitet: Erst wird an doppelten Zeilenumbrüchen — also Absätzen — geteilt, und nur wenn ein Stück dann immer noch zu groß ist, greift der nächste Eintrag. So bleiben Absätze ganz, solange es geht:

# local_rag.py, in LocalRAG.__init__ (derzeit Zeile 53)
self.text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=CHUNK_SIZE,
    chunk_overlap=CHUNK_OVERLAP,
    separators=["\n\n", "\n", ". ", " ", ""],   # Absatz, Zeile, Satz, Wort, Notfall
)

Wer für die beiden Korpora unterschiedlich trennen will, findet dafür genau eine Stelle. In add_documents wird jedes geladene Dokument durch denselben Splitter geschickt — hier würde eine Fallunterscheidung ansetzen, etwa anhand des Ordners, aus dem die Datei stammt:

# local_rag.py, in add_documents (derzeit Zeile 114/115)
docs = loader.load()
chunks = self.text_splitter.split_documents(docs)   # <- der eine Verzweigungspunkt
all_docs.extend(chunks)

Klein suchen, groß liefern

Kleine Chunks finden präzise, große Chunks antworten gut. Man muss sich nicht entscheiden: kleine Chunks (ca. 400–800 Zeichen) für den Abgleich indexieren, dem LLM aber deren übergeordneten Abschnitt übergeben. LangChain — die Programmbibliothek, auf der das Projekt aufbaut — bringt dafür eine fertige Komponente namens ParentDocumentRetriever mit, die genau diese Verdopplung von Such- und Liefereinheit übernimmt. Es ist das übliche Mittel, wenn ein kurzes Fragment über wenige passende Wörter einen Trefferplatz gewinnt und dabei viel unverbundenes Umgebungsmaterial in den Kontext zieht.

Ehrlicherweise ist das der größte Eingriff dieser Phase und anders als die bisherigen keine Einzeiler-Sache. Das Projekt greift derzeit unmittelbar auf den Vektorspeicher zu:

# local_rag.py, in query_with_history (derzeit Zeile 251)
docs_with_scores = self.vectorstore.similarity_search_with_score(retrieval_query, k=k)

Für „klein suchen, groß liefern" müsste dieser Aufruf durch den Retriever ersetzt und zusätzlich eine zweite Ablage für die großen Abschnitte angelegt werden — FAISS speichert nur die kleinen Stücke. Vorübergehend entfallen dadurch auch die Ähnlichkeitswerte in der Protokollausgabe. Deshalb steht dieser Punkt hinten: erst die billigen Änderungen messen, dann entscheiden, ob der Umbau überhaupt noch nötig ist.

Metadaten (falls vorhanden)

Metadaten sind Zusatzangaben, die an jedem Chunk mitgeführt werden — vergleichbar mit den Feldern eines Bibliothekskatalogs. Sie werden nicht durchsucht, aber man kann nach ihnen filtern. Falls sie durch eine Vorverarbeitung existieren, sollten mindestens mitgeführt werden: element_type (Fließtext / Anmerkung / Literaturverzeichnis), work bzw. author_year, section und page. Erst damit lassen sich Anmerkungen von einer Suche ausschließen oder eine Suche auf ein einzelnes Werk beschränken, ohne irgendetwas neu zu bauen.

source und page setzt der Loader bereits von allein. Eigene Angaben kommen an derselben Stelle dazu, an der auch getrennt wird — wichtig ist allein die Reihenfolge: vor dem Aufteilen, denn die Chunks erben die Metadaten des Dokuments, aus dem sie stammen:

# local_rag.py, in add_documents — zwischen Laden und Aufteilen
docs = loader.load()

for doc in docs:                                    # neu
    doc.metadata["korpus"] = "sekundaer"            # z. B. aus dem Ordnernamen
    doc.metadata["element_type"] = "body"

chunks = self.text_splitter.split_documents(docs)   # erbt die Angaben von oben

Gefiltert wird später bei der Suche, indem dem Aufruf ein filter mitgegeben wird — etwa filter={"element_type": "body"}, um Anmerkungen auszublenden. Ob es etwas gebracht hat, zeigt eval_retrieval.py unmittelbar danach.

04

Hybride Suche, danach Reranking

Mögliche Ansätze bei der Optimierung der Suche

Stichwortsuche neben die Vektoren stellen

Reines "Dense Retrieval" ist genau bei dem schwach, wovon der vorhandene Korpus voll ist: Eigennamen, Kunstwörter, Werktitel, wörtliche Zitate. Eine Anfrage nach Odradek oder Gregor Samsa will einen lexikalischen Treffer, keine semantische Näherung. Also BM25 parallel zur Vektorsuche laufen lassen: BM25 ist die klassische Stichwortsuche, wie man sie aus Bibliothekskatalogen kennt — sie zählt, welche Suchwörter wie häufig und wie selten im Text vorkommen, und findet damit genau das Wörtliche, das die Bedeutungssuche übersieht. Die beiden Trefferlisten werden anschließend zu einer verrechnet; das Standardverfahren dafür heißt Reciprocal Rank Fusion und gewichtet einen Treffer danach, wie weit oben er in den einzelnen Listen stand. In LangChain sind dafür EnsembleRetriever und BM25Retriever vorgesehen, es bleibt also eine kurze Änderung. Im literaturwissenschaftlichen Retrieval ist das oft ein großer und sofort sichtbarer Gewinn.

Danach mit einem Cross-Encoder umsortieren

Die Form des Retrievals ändert sich: statt 5 werden 30 bis 50 Kandidaten geholt, von einem Cross-Encoder gegen die Anfrage bewertet und auf die besten 5 bis 8 eingedampft. Ein Cross-Encoder ist ein zweites, kleineres Modell mit einer anderen Arbeitsweise: Die normale Suche berechnet für Frage und Textabschnitt getrennt je eine Zahlenreihe und vergleicht diese; der Cross-Encoder bekommt beides zusammen vorgelegt und beurteilt direkt, ob der Abschnitt zur Frage passt. Das ist zu langsam, um damit hunderttausende Chunks zu durchsuchen, aber genau richtig für eine Handvoll Kandidaten — und deutlich treffsicherer — und er ist besonders gut darin, zitatlastige, inhaltsarme Passagen nach hinten zu schieben.

  • BAAI/bge-reranker-v2-m3 — mehrsprachig, lokal lauffähig; auf GPU bequem, auf CPU machbar, aber langsam.
  • Cohere Rerank — API, starke mehrsprachige Qualität, minimaler Integrationsaufwand.

Nach Phase 02 und 03 ist das typischerweise der größte verbleibende Sprung.

Erst jetzt die Prompt-Datei öffnen

Wenn Recall@k sich nicht mehr verbessert und die abgerufenen Passagen beim Lesen überzeugen, sind die verbleibenden Fehler Generierungsfehler — und dann sind Antwort-Prompt, Rephrase-Schritt, Modellwahl und Zitationsformat die richtigen Stellschrauben. Früher danach zu greifen kostet nicht nur Zeit; es überdeckt die eigentliche Ursache und erschwert die nächste Diagnose.

✓

Kurzfassung

Eine Arbeitsreihenfolge · nach jedem Schritt messen

  1. Referenzfragen anlegen und Ausgangswert festhalten25–40 echte Fragen mit bekannter Fundstelle; Recall@5/10/20 ohne LLM. Zahlen ins Repository.
  2. Zwanzig Chunks von Hand lesenAbschätzen, welcher Anteil Apparat und Layout-Rauschen ist. Meist die aufschlussreichste halbe Stunde.
  3. Chunk-Größe gegen das Modellfenster prüfenAlles jenseits des Fensters wird verworfen, bevor der Vektor berechnet wird.
  4. Prüfen, was Anweisungstext in der Anfrage bewirktRecall mit und ohne messen, statt anzunehmen, dass er hilft.
  5. Auf ein mehrsprachiges Modell mit langem Kontext wechselnbge-m3 als Voreinstellung; Chunk-Größe passend nachziehen. Neu bauen, neu messen.
  6. Sekundärkorpus mit einem strukturbewussten Parser neu extrahierenGROBID oder Docling. Anmerkungen und Literaturverzeichnis in Metadaten auszeichnen, nicht löschen.
  7. Falls möglich: Saubere digitale Editionen für die Primärtexte beschaffenPDF-Extraktion umgehen, wo es schon eine TEI- oder Plaintext-Ausgabe gibt.
  8. Falls die Dokumente geparst wurden und nicht mehr direkt auf PDFs gearbeitet wird: element_type als Metadatum ergänzen, mit FilterAnmerkungen standardmäßig ausschließen, aber zuschaltbar halten, damit beides testbar bleibt.
  9. BM25 neben die Vektoren stellenEigennamen und wörtliche Zitate brauchen lexikalischen Abgleich.
  10. 30–50 Kandidaten holen und auf 5–8 umsortierenMeist der größte verbleibende Gewinn, und er setzt sich hinter einen unveränderten Index.
  11. Erst jetzt Antwort-Prompt und Modell überarbeitenAb hier sind die verbleibenden Fehler tatsächlich Generierungsfehler.