Übergabenotiz · Tuning des RAG-Korpus
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.
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.
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.
vectorstore. Er wird erzeugt, nicht von Hand gepflegt, und kann jederzeit neu gebaut werden.local_rag.py.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.
| Beobachtung | Wahrscheinliche Ursache | Siehe |
|---|---|---|
| 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 |
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.
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"
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.
eval_retrieval.pyEs 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.
eval/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.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.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
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 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.
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.
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)
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.
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.
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.
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.
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.
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.
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.
| Werkzeug | Was es liefert | Aufwand / 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. |
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.
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.
| Änderung | Stelle in local_rag.py | Danach nötig |
|---|---|---|
| Embedding-Modell | In LocalRAG.__init__, der Aufruf HuggingFaceEmbeddings(...) — Zeile 48 | Index neu bauen |
| Chunk-Größe | Die Konstanten CHUNK_SIZE und CHUNK_OVERLAP ganz oben — Zeile 16/17 | Index neu bauen |
| Trennlogik | Der RecursiveCharacterTextSplitter in __init__ — Zeile 53 | Index neu bauen |
| Metadaten | In add_documents, zwischen loader.load() und split_documents(...) — Zeile 114/115 | Index neu bauen |
| Klein suchen, groß liefern | In query_with_history, der Aufruf similarity_search_with_score — Zeile 251 | Größ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.
Bei einem deutschsprachigen Korpus könnte ein englisch trainiertes Modell mit kurzem Kontextfenster problematisch sein. Kandidaten, alle lokal lauffähig, sind:
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.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.
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:
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)
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 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.
Mögliche Ansätze bei der Optimierung der Suche
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.
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.
Nach Phase 02 und 03 ist das typischerweise der größte verbleibende Sprung.
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.
Eine Arbeitsreihenfolge · nach jedem Schritt messen