Files
localRAG/Retrieval-Tuning-local_rag.html
T
Philipp MockandClaude Opus 5.5 d54b3e5b8b Add Docling corpus pipeline, retrieval evaluation and hybrid search with reranking
- prepare_corpus.py: split double-page scans (split_spreads.py), re-OCR with
  OCRmyPDF/Tesseract only where the existing text layer is worse, Docling
  layout extraction with heading hierarchy, QA report per PDF
- docling_loader.py: cleanup (margin noise, split paragraphs, footnote marks,
  OCR comma/period repair), page mapping back to the original PDF, chunk
  headers with author, title and section
- local_rag.py: bge-m3 embeddings, configurable chunking and loader, index
  settings check (index_info.json), hybrid/union BM25 search with Reciprocal
  Rank Fusion and bge-reranker-v2-m3 cross-encoder; query instruction text
  removed after it lowered recall
- eval_retrieval.py: CLI options, same retrieval path as the chat, tolerant
  file name matching; goldsets and evaluation reports for the Kafka corpus
- grobid_test.py, grobid_qa.py, extract_docling.py, compare_extractors.py:
  extractor tests and quality checks
- .gitignore: corpus PDFs, vector stores and extractor output stay local
- server.py: uses ./vectorstore_docling_hdr

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:48:12 +02:00

849 lines
52 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<title>Erst Retrieval, dann Generierung</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,600;1,6..72,400&family=IBM+Plex+Mono:wght@400;600&family=IBM+Plex+Sans:ital,wght@0,400;0,500;0,600;1,400&display=swap">
<style>
html { hyphens: auto; }
:root {
--paper: #eff1f3;
--surface: #ffffff;
--surface-2: #e6e9ed;
--ink: #14161a;
--ink-2: #4d545f;
--ink-3: #79818e;
--rule: #d3d8de;
--accent: #146f68;
--accent-soft:#d5e8e6;
--cut: #a3302a;
--cut-soft: #f2dedc;
--mark: #f0e2b4;
--shadow: 0 1px 2px rgba(20,22,26,.06), 0 8px 24px -16px rgba(20,22,26,.28);
--serif: "Newsreader", Georgia, "Times New Roman", serif;
--sans: "IBM Plex Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--mono: "IBM Plex Mono", ui-monospace, "SF Mono", Menlo, monospace;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--paper: #131519;
--surface: #1a1d23;
--surface-2: #22262d;
--ink: #e7e9ed;
--ink-2: #a2aab6;
--ink-3: #7b838f;
--rule: #2d323b;
--accent: #57b8ae;
--accent-soft:#173b39;
--cut: #e2796f;
--cut-soft: #3a201e;
--mark: #453a1a;
--shadow: 0 1px 2px rgba(0,0,0,.4), 0 8px 24px -16px rgba(0,0,0,.8);
}
}
:root[data-theme="dark"] {
--paper: #131519;
--surface: #1a1d23;
--surface-2: #22262d;
--ink: #e7e9ed;
--ink-2: #a2aab6;
--ink-3: #7b838f;
--rule: #2d323b;
--accent: #57b8ae;
--accent-soft:#173b39;
--cut: #e2796f;
--cut-soft: #3a201e;
--mark: #453a1a;
--shadow: 0 1px 2px rgba(0,0,0,.4), 0 8px 24px -16px rgba(0,0,0,.8);
}
* { box-sizing: border-box; }
body {
background: var(--paper);
color: var(--ink);
font-family: var(--sans);
font-size: 16px;
line-height: 1.62;
-webkit-font-smoothing: antialiased;
}
.wrap {
max-width: 50rem;
margin: 0 auto;
padding: 3.5rem 1.5rem 6rem;
}
/* ---------- masthead ---------- */
.masthead { border-bottom: 2px solid var(--ink); padding-bottom: 1.75rem; }
.eyebrow {
font-family: var(--mono);
font-size: .72rem;
letter-spacing: .13em;
text-transform: uppercase;
color: var(--accent);
margin: 0 0 1rem;
}
h1 {
font-family: var(--serif);
font-weight: 600;
font-size: clamp(2.4rem, 6vw, 3.6rem);
line-height: 1.04;
letter-spacing: -.02em;
margin: 0 0 1rem;
text-wrap: balance;
}
.standfirst {
font-family: var(--serif);
font-size: 1.24rem;
line-height: 1.5;
color: var(--ink-2);
margin: 0;
max-width: 40rem;
}
.masthead-meta {
display: flex;
flex-wrap: wrap;
gap: .5rem 1.5rem;
margin-top: 1.5rem;
font-family: var(--mono);
font-size: .74rem;
color: var(--ink-3);
}
.masthead-meta span { white-space: nowrap; }
/* ---------- the rule callout ---------- */
.thesis {
margin: 2.5rem 0 0;
padding: 1.5rem 1.75rem;
background: var(--surface);
border-left: 3px solid var(--accent);
box-shadow: var(--shadow);
}
.thesis p { margin: 0 0 .9rem; }
.thesis p:last-child { margin-bottom: 0; }
.chain {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: .4rem .55rem;
margin: 1.25rem 0 0;
font-family: var(--mono);
font-size: .78rem;
}
.chain b {
font-weight: 400;
background: var(--surface-2);
border: 1px solid var(--rule);
padding: .22rem .55rem;
color: var(--ink-2);
}
.chain b.live { background: var(--accent-soft); border-color: var(--accent); color: var(--ink); }
.chain i { font-style: normal; color: var(--ink-3); }
/* ---------- phases ---------- */
.phase { margin-top: 3.5rem; }
.phase-head {
display: grid;
grid-template-columns: 3.25rem 1fr;
gap: 0 1rem;
align-items: baseline;
border-top: 1px solid var(--rule);
padding-top: 1.1rem;
}
.num {
font-family: var(--mono);
font-size: .95rem;
font-weight: 600;
color: var(--accent);
letter-spacing: .04em;
}
h2 {
font-family: var(--serif);
font-weight: 600;
font-size: 1.85rem;
line-height: 1.15;
letter-spacing: -.015em;
margin: 0;
text-wrap: balance;
}
.phase-sub {
grid-column: 2;
font-family: var(--mono);
font-size: .73rem;
letter-spacing: .07em;
text-transform: uppercase;
color: var(--ink-3);
margin: .5rem 0 0;
}
.phase-body { margin-left: 0; }
@media (min-width: 40rem) { .phase-body { margin-left: 4.25rem; } }
h3 {
font-family: var(--sans);
font-weight: 600;
font-size: 1rem;
letter-spacing: -.005em;
margin: 2rem 0 .6rem;
}
p { margin: 0 0 1rem; max-width: 42rem; }
a { color: var(--accent); text-decoration-thickness: 1px; text-underline-offset: 2px; }
a:focus-visible, summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
ul, ol { margin: 0 0 1rem; padding-left: 1.2rem; max-width: 42rem; }
li { margin-bottom: .5rem; }
li::marker { color: var(--ink-3); }
strong { font-weight: 600; }
em.term { font-style: normal; background: var(--mark); padding: 0 .2em; }
code {
font-family: var(--mono);
font-size: .855em;
background: var(--surface-2);
padding: .1em .35em;
border-radius: 2px;
overflow-wrap: break-word;
}
pre {
font-family: var(--mono);
font-size: .8rem;
line-height: 1.62;
background: var(--surface);
border: 1px solid var(--rule);
border-left: 3px solid var(--ink-3);
padding: 1rem 1.15rem;
overflow-x: auto;
margin: 0 0 1.25rem;
}
pre code { background: none; padding: 0; font-size: 1em; }
pre .c { color: var(--ink-3); }
/* ---------- defect list ---------- */
.defects { display: grid; gap: 1px; background: var(--rule); border: 1px solid var(--rule); margin-bottom: 1.5rem; }
.defect { background: var(--surface); padding: 1.1rem 1.25rem; }
.defect h4 {
font-family: var(--sans);
font-size: .95rem;
font-weight: 600;
margin: 0 0 .4rem;
display: flex;
gap: .6rem;
align-items: baseline;
}
.defect h4 .sev {
font-family: var(--mono);
font-size: .64rem;
letter-spacing: .1em;
text-transform: uppercase;
padding: .12rem .4rem;
white-space: nowrap;
border: 1px solid currentColor;
}
.sev.high { color: var(--accent); background: var(--accent-soft); }
.sev.med { color: var(--ink-3); background: var(--surface-2); }
.defect p { margin: 0; font-size: .93rem; color: var(--ink-2); max-width: none; }
.defect p + p { margin-top: .55rem; }
/* ---------- Glossar ---------- */
.gloss {
display: grid;
gap: 1px;
background: var(--rule);
border: 1px solid var(--rule);
margin: 0 0 1.5rem;
}
.gloss > div {
background: var(--surface);
padding: .75rem 1.1rem;
display: grid;
grid-template-columns: 1fr;
gap: .1rem;
}
@media (min-width: 34rem) {
.gloss > div { grid-template-columns: 11rem 1fr; gap: 0 1.25rem; }
}
.gloss dt { font-weight: 600; font-size: .93rem; }
.gloss dd { margin: 0; font-size: .92rem; color: var(--ink-2); }
/* ---------- keep / cut ---------- */
.kc { display: grid; grid-template-columns: 1fr; gap: 1px; background: var(--rule); border: 1px solid var(--rule); margin-bottom: 1.5rem; }
@media (min-width: 34rem) { .kc { grid-template-columns: 1fr 1fr; } }
.kc-col { background: var(--surface); padding: 1.1rem 1.25rem; }
.kc-col h4 {
font-family: var(--mono);
font-size: .7rem;
letter-spacing: .12em;
text-transform: uppercase;
margin: 0 0 .75rem;
}
.kc-keep h4 { color: var(--accent); }
.kc-cut h4 { color: var(--cut); }
.kc-col ul { list-style: none; padding: 0; margin: 0; font-size: .9rem; }
.kc-col li { margin-bottom: .45rem; color: var(--ink-2); padding-left: 1rem; position: relative; }
.kc-col li::before { position: absolute; left: 0; font-family: var(--mono); }
.kc-keep li::before { content: "+"; color: var(--accent); }
.kc-cut li::before { content: "\2212"; color: var(--cut); }
/* ---------- tables ---------- */
.tw { overflow-x: auto; margin: 0 0 1.5rem; border: 1px solid var(--rule); background: var(--surface); }
table { border-collapse: collapse; width: 100%; font-size: .88rem; min-width: 34rem; }
th, td { text-align: left; padding: .7rem .9rem; vertical-align: top; border-bottom: 1px solid var(--rule); }
thead th {
font-family: var(--mono);
font-size: .68rem;
letter-spacing: .09em;
text-transform: uppercase;
color: var(--ink-3);
font-weight: 400;
background: var(--surface-2);
white-space: nowrap;
}
tbody tr:last-child td { border-bottom: none; }
/* Fließtextzellen (.note) umbrechen; kurze Bezeichner bleiben einzeilig. */
td:first-child:not(.note) { font-weight: 600; }
td:not(.note) { white-space: nowrap; }
td.note { color: var(--ink-2); }
/* ---------- callout ---------- */
.aside {
border: 1px solid var(--rule);
border-top: 2px solid var(--cut);
background: var(--surface);
padding: 1.1rem 1.25rem;
margin: 0 0 1.5rem;
}
.aside h4 {
font-family: var(--mono);
font-size: .7rem;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--cut);
margin: 0 0 .6rem;
}
.aside p { margin: 0; font-size: .93rem; color: var(--ink-2); }
.aside p + p { margin-top: .6rem; }
/* ---------- checklist ---------- */
.checklist { list-style: none; padding: 0; margin: 0; counter-reset: ck; max-width: none; }
.checklist li {
counter-increment: ck;
display: grid;
grid-template-columns: 2rem 1fr;
gap: .75rem;
padding: .8rem 0;
border-bottom: 1px solid var(--rule);
margin: 0;
}
.checklist li::before {
content: counter(ck, decimal-leading-zero);
font-family: var(--mono);
font-size: .78rem;
color: var(--ink-3);
padding-top: .15rem;
}
.checklist li > b,
.checklist li > span { grid-column: 2; }
.checklist b { display: block; font-weight: 600; }
.checklist span { color: var(--ink-2); font-size: .92rem; }
footer {
margin-top: 4rem;
padding-top: 1.25rem;
border-top: 2px solid var(--ink);
font-family: var(--mono);
font-size: .74rem;
color: var(--ink-3);
}
@media (prefers-reduced-motion: reduce) {
* { animation: none !important; transition: none !important; }
}
/* ---------- print / PDF ---------- */
@page {
size: A4;
margin: 16mm 15mm 18mm;
}
@media print {
/* Always print the light palette, whatever the OS is set to. */
:root, :root[data-theme="dark"], :root:not([data-theme="light"]) {
--paper: #ffffff;
--surface: #ffffff;
--surface-2: #eef0f3;
--ink: #14161a;
--ink-2: #434954;
--ink-3: #6d7581;
--rule: #c8ced6;
--accent: #10605a;
--accent-soft:#dcecea;
--cut: #932b25;
--cut-soft: #f5e3e1;
--mark: #f2e6bd;
--shadow: none;
}
* { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
body { font-size: 10.2pt; line-height: 1.5; background: #fff; }
.wrap { max-width: none; padding: 0; }
/* Let tables use the full measure instead of scrolling. */
.tw { overflow: visible; }
table { min-width: 0; font-size: 8.8pt; }
th, td { padding: .42rem .5rem; }
td:first-child { white-space: normal; }
p, ul, ol { max-width: none; }
pre { font-size: 8.2pt; white-space: pre-wrap; word-break: break-word; }
h1 { font-size: 30pt; }
h2 { font-size: 16pt; }
h3 { font-size: 10.8pt; margin-top: 1.15rem; }
.masthead { padding-bottom: 1.1rem; }
.thesis { margin-top: 1.5rem; padding: 1rem 1.2rem; }
.phase { margin-top: 1.9rem; }
.phase-body { margin-left: 3.4rem; }
/* Keep composed blocks whole across page boundaries. */
.thesis, .defect, .kc-col, .aside, .tw, pre, .chain, .gloss > div,
.checklist li, .phase-head { break-inside: avoid; page-break-inside: avoid; }
.phase-head, h3 { break-after: avoid; page-break-after: avoid; }
a { color: var(--ink); text-decoration: none; }
footer { margin-top: 2rem; }
}
</style>
<div class="wrap" lang="de">
<header class="masthead">
<p class="eyebrow">Übergabenotiz · Tuning des RAG-Korpus</p>
<h1>Erst Retrieval, dann Generierung</h1>
<p class="standfirst">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.</p>
<div class="masthead-meta">
<span>Projekt: local_rag</span>
<span>Stack: LangChain · FAISS · MiniLM · Ollama/OpenAI</span>
<span>Zielgruppe: wer die Pipeline als Nächstes betreut</span>
</div>
</header>
<div class="thesis">
<p><strong>Die eine Regel: von vorne nach hinten arbeiten und nach jedem Schritt messen.</strong> Probleme der Retrieval-Qualität <em>zeigen</em> sich immer am Ende der Kette — eine schwache Antwort, eine erfundene Behauptung, ein unpassendes Zitat. <em>Verursacht</em> werden sie fast immer weiter vorne: dabei, wie aus einem PDF Text wird und wie dieser Text zerteilt und eingebettet wird.</p>
<p>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.</p>
<div class="chain">
<b class="live">Extraktion</b><i>&rarr;</i><b class="live">Chunking</b><i>&rarr;</i><b class="live">Embedding</b><i>&rarr;</i><b class="live">Retrieval</b><i>&rarr;</i><b class="live">Reranking</b><i>&rarr;</i><b>Prompt</b><i>&rarr;</i><b>Generierung</b>
<i>&nbsp;&nbsp;türkis = in dieser Notiz behandelt</i>
</div>
</div>
<!-- ===================== GLOSSAR ===================== -->
<section class="phase">
<div class="phase-head">
<div class="num">&#8801;</div>
<h2>Kurzes Glossar</h2>
<p class="phase-sub">Die Begriffe, die unten vorausgesetzt werden</p>
</div>
<div class="phase-body">
<p>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.</p>
<dl class="gloss">
<div><dt>Chunk</dt><dd>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.</dd></div>
<div><dt>Embedding</dt><dd>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.</dd></div>
<div><dt>Vektor</dt><dd>Das mathematische Wort für ebendiese Zahlenreihe. „Zwei Vektoren liegen nah beieinander" heißt: die beiden Texte ähneln sich inhaltlich.</dd></div>
<div><dt>Vektorspeicher, Index</dt><dd>Die durchsuchbare Ablage aller Chunks samt ihren Zahlenreihen. Im Projekt ist das der Ordner <code>vectorstore</code>. Er wird erzeugt, nicht von Hand gepflegt, und kann jederzeit neu gebaut werden.</dd></div>
<div><dt>Retrieval</dt><dd>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.</dd></div>
<div><dt>Token</dt><dd>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.</dd></div>
<div><dt>dense / sparse</dt><dd>Zwei Suchprinzipien. <em>Dense</em> sucht nach Bedeutungsähnlichkeit und findet auch Umschreibungen; <em>sparse</em> sucht nach den tatsächlichen Wörtern und ist bei Eigennamen und Zitaten überlegen. Beides zusammen heißt hybride Suche.</dd></div>
<div><dt>Reranking</dt><dd>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.</dd></div>
<div><dt>Prompt</dt><dd>Der Text, den das Sprachmodell tatsächlich zu sehen bekommt — Frage, Gesprächsverlauf und die gefundenen Chunks in einem Stück. Steht in <code>local_rag.py</code>.</dd></div>
<div><dt>Repository</dt><dd>Der Projektordner unter Versionsverwaltung (Git). „Gehört ins Repository" heißt schlicht: als Datei im Projekt ablegen, damit die Änderung nachvollziehbar bleibt.</dd></div>
</dl>
</div>
</section>
<!-- ===================== SYMPTOME ===================== -->
<section class="phase">
<div class="phase-head">
<div class="num">&there4;</div>
<h2>Vom Symptom ausgehen</h2>
<p class="phase-sub">Wo zu suchen ist, wenn etwas nicht stimmt</p>
</div>
<div class="phase-body">
<p>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.</p>
<div class="tw">
<table>
<thead>
<tr><th>Beobachtung</th><th>Wahrscheinliche Ursache</th><th>Siehe</th></tr>
</thead>
<tbody>
<tr>
<td class="note">Chunks enthalten Fußnoten, Literaturangaben oder Kolumnentitel mitten im Fließtext</td>
<td class="note">Flache Textextraktion, die die Seitenstruktur ignoriert</td>
<td>Phase 02</td>
</tr>
<tr>
<td class="note">Chunks brechen mitten im Satz ab; Zitate kommen unvollständig an</td>
<td class="note">Zeichenbasiertes Splitting ohne Rücksicht auf Absatzgrenzen</td>
<td>Phase 03</td>
</tr>
<tr>
<td class="note">Eine Passage, von der bekannt ist, dass es sie gibt, wird nie gefunden — egal wie die Frage formuliert wird</td>
<td class="note">Trunkierung durch das Embedding-Modell oder eine Sprachdiskrepanz</td>
<td>Phase 01, 03</td>
</tr>
<tr>
<td class="note">Suchen nach Eigennamen, Kunstwörtern oder Werktiteln laufen ins Leere</td>
<td class="note">Kein lexikalischer Abgleich — rein dense Retrieval rät semantisch</td>
<td>Phase 04</td>
</tr>
<tr>
<td class="note">Es kommen ungefähr passende Passagen zurück, aber nie die <em>beste</em> zuerst</td>
<td class="note">Es fehlt eine Reranking-Stufe</td>
<td>Phase 04</td>
</tr>
<tr>
<td class="note">Die abgerufenen Passagen überzeugen beim Lesen, die Antwort bleibt trotzdem schwach</td>
<td class="note">Tatsächlich ein Generierungsproblem — der einzige Fall, in dem der Prompt die richtige Stellschraube ist</td>
<td>Nach Phase 04</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- ===================== 00 ===================== -->
<section class="phase">
<div class="phase-head">
<div class="num">00</div>
<h2>Einen Maßstab schaffen, bevor irgendetwas geändert wird</h2>
<p class="phase-sub">Ein halber Tag · Voraussetzung für alles Weitere</p>
</div>
<div class="phase-body">
<p>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.</p>
<h3>Einen Satz von 25–40 Referenzfragen anlegen</h3>
<p>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.</p>
<pre><code><span class="c"># eval/goldset.csv</span>
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"</code></pre>
<h3>Das Retrieval für sich bewerten, ohne LLM in der Schleife</h3>
<p>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 <strong>Recall@k</strong> — taucht die erwartete Quelle mit der erwarteten Seite unter den ersten <em>k</em> Treffern auf? — für k = 5, 10 und 20, dazu den Mean Reciprocal Rank.</p>
<p>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.</p>
<h3>Das Skript liegt bereit: <code>eval_retrieval.py</code></h3>
<p>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.</p>
<ol class="checklist">
<li><b>Eigene Fragen eintragen</b><span><code>eval/goldset.csv</code> in Excel, Numbers oder LibreOffice öffnen und die drei Beispielzeilen durch echte Fragen ersetzen. <em>frage</em>: die Frage selbst. <em>korpus</em>: primaer oder sekundaer, damit beide Hälften getrennt ausgewertet werden. <em>erwartete_quelle</em>: der Dateiname, in dem die Antwort steht — ohne Pfad, Groß- und Kleinschreibung egal. <em>erwartete_seite</em>: die Seitenzahl, die der PDF-Betrachter anzeigt; im Zweifel leer lassen, dann zählt nur die Datei. <em>anmerkung</em>: freies Feld für Notizen. Als CSV speichern — ob das Tabellenprogramm Kommas oder Semikolons schreibt, erkennt das Skript selbst.</span></li>
<li><b>Skript starten</b><span>Im 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.</span></li>
<li><b>Zahlen lesen</b><span><em>Recall@5</em> ist der Anteil der Fragen, deren Fundstelle unter den ersten fünf Treffern steht: 0.60 heißt drei von fünf Fragen. <em>MRR</em> 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.</span></li>
<li><b>Bericht aufheben</b><span>Das Skript schreibt <code>eval/baseline.txt</code>. 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.</span></li>
<li><b>Gegenprobe zum Zusatztext</b><span>Oben im Skript steht <code>USE_QUERY_INSTRUCTIONS</code>. Auf <code>True</code> gesetzt hängt das Skript den Zusatztext aus <code>local_rag.py</code> an jede Frage an. Ein Lauf mit <code>True</code> und einer mit <code>False</code> beantworten die Frage aus Phase 01, ob dieser Text tatsächlich hilft.</span></li>
</ol>
<pre><code>conda activate local_rag
python eval_retrieval.py</code></pre>
<div class="aside">
<h4>Was das Skript nicht tut, und was schiefgehen kann</h4>
<p>Es bewertet ausschließlich, <em>ob die richtige Stelle gefunden wird</em> — nicht, ob die Antwort gut formuliert ist. Das ist Absicht: Antwortqualität lässt sich erst sinnvoll beurteilen, wenn die Belege stimmen.</p>
<p>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.</p>
</div>
<h3>Zwanzig zufällige Chunks selbst lesen</h3>
<p>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.</p>
</div>
</section>
<!-- ===================== 01 ===================== -->
<section class="phase">
<div class="phase-head">
<div class="num">01</div>
<h2>Konfigurationsfallen, die noch geprüft werden sollten</h2>
<p class="phase-sub">Schnell geprüft · unverhältnismäßig große Wirkung</p>
</div>
<div class="phase-body">
<p>Vier Eigenschaften eines Setups aus LangChain, FAISS und sentence-transformers, die das Retrieval unbemerkt verschlechtern und leicht zu übersehen sind.</p>
<div class="defects">
<div class="defect">
<h4><span class="sev high">Zuerst prüfen</span> Chunk-Größe über dem Kontextfenster des Embedding-Modells</h4>
<p>Jedes sentence-transformer-Modell hat eine maximale Sequenzlänge, und Text darüber hinaus wird <strong>stillschweigend verworfen, bevor der Vektor berechnet wird</strong> — 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.</p>
<p>Bei den üblichen Voreinstellungen fällt das deutlich ins Gewicht. Das Modell <code>all-MiniLM-L6-v2</code> endet bei 256 Token — das sind rund 900 bis 1.100 Zeichen deutscher Prosa — während <code>CHUNK_SIZE</code> in <code>local_rag.py</code> die Länge der Textstücke in Zeichen angibt und oft auf 1.000 bis 2.000 steht. Beide Zahlen müssen zusammenpassen.</p>
<p>Nachsehen lässt sich die Modellgrenze mit drei Zeilen: eine Datei namens <code>check.py</code> im Projektordner anlegen, den folgenden Text hineinkopieren und sie mit <code>python check.py</code> starten. Die ausgegebene Zahl ist die Obergrenze in Token; liegt <code>CHUNK_SIZE</code> 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).</p>
<pre><code>from local_rag import LocalRAG
rag = LocalRAG()
print(rag.embeddings.client.max_seq_length)</code></pre>
</div>
<div class="defect">
<h4><span class="sev high">Zuerst prüfen</span> Natürlichsprachliche Anweisungen in der Embedding-Query</h4>
<p>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 <em>über</em> Literaturangaben und Argumente spricht. Er zieht das gemeinte Material also ebenso wahrscheinlich an, wie er es abstößt.</p>
<p>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.</p>
</div>
<div class="defect">
<h4><span class="sev med">Hygiene</span> Rohe L2-Distanz auf nicht normalisierten Vektoren</h4>
<p>Es gibt zwei gängige Arten, den Abstand zweier Zahlenreihen zu messen. Die eine achtet nur auf die <em>Richtung</em> (Kosinus-Ähnlichkeit), die andere zusätzlich auf die <em>Länge</em> 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.</p>
<p>Die Umstellung ist eine einzelne Zeile: beim Laden und Erzeugen des Speichers in <code>local_rag.py</code> zusätzlich <code>distance_strategy=DistanceStrategy.COSINE</code> übergeben. Nebeneffekt: Die Ähnlichkeitswerte im Protokoll werden dadurch untereinander vergleichbar.</p>
</div>
<div class="defect">
<h4><span class="sev med">Hygiene</span> Ein Index, der nicht mehr zum Code passt, der ihn liest</h4>
<p>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.</p>
</div>
</div>
</div>
</section>
<!-- ===================== 02 ===================== -->
<section class="phase">
<div class="phase-head">
<div class="num">02</div>
<h2>Was die beiden Ingestion-Pipelines brauchen</h2>
<p class="phase-sub">Größter Hebel bei verrauschtem Extraktionstext</p>
</div>
<div class="phase-body">
<p>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 — <code>PyPDFLoader</code> und Verwandte — ein <em>Loader</em> 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.</p>
<h3>Die Primärtext-Pipeline</h3>
<p>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.</p>
<h3>Die Sekundär-Pipeline: ein Parser, der Dokumentstruktur erkennt</h3>
<p>Hier liegt bei einer Vorverarbeitung viel Potenzial. Idealerweise verwenden wir einen Extraktor, der <em>typisierte Elemente</em> 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.</p>
<div class="kc">
<div class="kc-col kc-keep">
<h4>In der Regel als Fließtext indexieren</h4>
<ul>
<li>Abstract</li>
<li>Zwischenüberschriften (als Chunk-Kontext)</li>
<li>Argumentierender Fließtext</li>
<li>Blockzitate aus Primärtexten</li>
<li>Tabellen- und Abbildungslegenden</li>
</ul>
</div>
<div class="kc-col kc-cut">
<h4>In der Regel abtrennen</h4>
<ul>
<li>Fuß- und Endnoten</li>
<li>Literaturverzeichnis</li>
<li>Kolumnentitel, Seitenzahlen</li>
<li>Zeitschriften-Boilerplate, Copyright-Zeilen</li>
<li>Affiliationen, Danksagungen</li>
</ul>
</div>
</div>
<p>Es empfiehlt sich, Fußnoten/Anmerkungen mit <code>element_type: footnote</code> 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.</p>
<h3>Werkzeuge, grob nach Aufwand geordnet</h3>
<div class="tw">
<table>
<thead>
<tr><th>Werkzeug</th><th>Was es liefert</th><th>Aufwand / Passung</th></tr>
</thead>
<tbody>
<tr>
<td>GROBID</td>
<td class="note">Eigens für wissenschaftliche PDFs gebaut. Gibt TEI-XML aus, in dem Fließtext, Anmerkungen und Literaturverzeichnis getrennt ausgezeichnet sind, dazu geparste Zitationsmetadaten.</td>
<td class="note">Kostenlos, läuft im Haus. Wird als Docker-Paket gestartet — ein Fertigpaket, das man startet, ohne die Bestandteile einzeln installieren zu müssen. <strong>Die stärkste Passung für ein Sekundärliteratur-Korpus</strong> — für genau diesen Dokumenttyp entwickelt.</td>
</tr>
<tr>
<td>Docling</td>
<td class="note">Wandelt PDFs in strukturiertes Markdown/JSON mit korrekter Lesereihenfolge, Überschriften, Tabellen und Anmerkungen als eigenen Elementen.</td>
<td class="note">Kostenlos, lokal, aktiv entwickelt. Gute Allzweckwahl und leichter einzuführen als GROBID.</td>
</tr>
<tr>
<td>unstructured</td>
<td class="note">Zerlegt in typisierte Elemente (<code>NarrativeText</code>, <code>Title</code>, <code>Footer</code>, <code>ListItem</code>); das Filtern nach Typ sind wenige Zeilen.</td>
<td class="note">Open-Source-Bibliothek plus gehostete API; LangChain-Anbindung existiert bereits. Der schnellste Weg von einem Standard-Setup aus.</td>
</tr>
<tr>
<td>PyMuPDF</td>
<td class="note">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.</td>
<td class="note">Kostenlos, schnell, keine neue Infrastruktur. Empfindlich gegenüber wechselnden Layouts; gut, solange ein Korpus typografisch einheitlich ist.</td>
</tr>
</tbody>
</table>
</div>
<div class="aside">
<h4>Den Extraktor prüfen</h4>
<p>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.</p>
</div>
</div>
</section>
<!-- ===================== 03 ===================== -->
<section class="phase">
<div class="phase-head">
<div class="num">03</div>
<h2>Chunking und Embeddings gemeinsam wählen</h2>
<p class="phase-sub">Für sich genommen bedeutet keine der beiden Einstellungen etwas</p>
</div>
<div class="phase-body">
<p>Alles in dieser Phase spielt sich in einer einzigen Datei ab: <code>local_rag.py</code>. 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.</p>
<div class="tw">
<table>
<thead>
<tr><th>Änderung</th><th>Stelle in <code>local_rag.py</code></th><th>Danach nötig</th></tr>
</thead>
<tbody>
<tr><td>Embedding-Modell</td><td class="note">In <code>LocalRAG.__init__</code>, der Aufruf <code>HuggingFaceEmbeddings(...)</code> — Zeile 48</td><td class="note">Index neu bauen</td></tr>
<tr><td>Chunk-Größe</td><td class="note">Die Konstanten <code>CHUNK_SIZE</code> und <code>CHUNK_OVERLAP</code> ganz oben — Zeile 16/17</td><td class="note">Index neu bauen</td></tr>
<tr><td>Trennlogik</td><td class="note">Der <code>RecursiveCharacterTextSplitter</code> in <code>__init__</code> — Zeile 53</td><td class="note">Index neu bauen</td></tr>
<tr><td>Metadaten</td><td class="note">In <code>add_documents</code>, zwischen <code>loader.load()</code> und <code>split_documents(...)</code> — Zeile 114/115</td><td class="note">Index neu bauen</td></tr>
<tr><td>Klein suchen, groß liefern</td><td class="note">In <code>query_with_history</code>, der Aufruf <code>similarity_search_with_score</code> — Zeile 251</td><td class="note">Größerer Umbau, siehe unten</td></tr>
</tbody>
</table>
</div>
<p>„Index neu bauen" heißt jedes Mal dasselbe: <code>add_pdfs.py</code> mit <code>CLEAR_VECTORSTORE_FIRST = True</code> laufen lassen. Der alte Index passt nach solchen Änderungen nicht mehr und würde stillschweigend Unsinn liefern. Direkt danach <code>eval_retrieval.py</code> starten und die neue Zahl mit der alten vergleichen.</p>
<h3>Das Embedding-Modell kommt zuerst</h3>
<p>Bei einem deutschsprachigen Korpus könnte ein englisch trainiertes Modell mit kurzem Kontextfenster problematisch sein. Kandidaten, alle lokal lauffähig, sind:</p>
<ul>
<li><strong>BAAI/bge-m3</strong> — mehrsprachig, verkraftet lange Eingaben und liefert aus einem Modell dense <em>und</em> sparse Repräsentationen, womit hybride Suche (Phase 04) fast geschenkt ist. Hier eine gute Voreinstellung.</li>
<li><strong>intfloat/multilingual-e5-large</strong> — starke mehrsprachige Qualität, 512-Token-Fenster. Achtung: Dieses Modell erwartet, dass jedem Text ein Kennwort vorangestellt wird — <code>query:</code> vor der Frage, <code>passage:</code> vor dem Textabschnitt —, damit es beide unterschiedlich behandelt. Wird das vergessen, verschlechtern sich die Ergebnisse messbar, ohne dass eine Fehlermeldung darauf hinweist.</li>
<li><strong>jinaai/jina-embeddings-v3</strong> — mehrsprachig, langer Kontext, aufgabenspezifische Adapter.</li>
</ul>
<p>Im Code steht der Modellname genau einmal, in <code>LocalRAG.__init__</code>. Ein Wechsel ist wörtlich ein Austausch dieser Zeichenkette; das Modell wird beim ersten Start automatisch heruntergeladen, was einige Minuten dauern kann:</p>
<pre><code><span class="c"># local_rag.py, in LocalRAG.__init__ (derzeit Zeile 48)</span>
self.embeddings = HuggingFaceEmbeddings(
model_name="BAAI/bge-m3" <span class="c"># vorher: sentence-transformers/all-MiniLM-L6-v2</span>
)</code></pre>
<p>Ein Hinweis, der bei der Modellwahl den Ausschlag geben kann: Bei <code>bge-m3</code> genügt tatsächlich diese eine Zeile. Bei <code>multilingual-e5-large</code> reicht sie <em>nicht</em>, 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.</p>
<p>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.</p>
<h3>Danach die Chunk-Größe auf das setzen, was das Modell tatsächlich liest</h3>
<p>Die Größe steht als Konstante ganz oben in der Datei, gut sichtbar und ohne Umwege änderbar. Die Werte sind Zeichen, nicht Token:</p>
<pre><code><span class="c"># local_rag.py, Zeile 16/17</span>
CHUNK_SIZE = 2000
CHUNK_OVERLAP = 400 <span class="c"># wie viel benachbarte Chunks einander überlappen</span></code></pre>
<p>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.</p>
<p>Steht das Modell fest, wollen die beiden Korpora unterschiedlich geteilt werden:</p>
<ul>
<li><strong>Primärtexte:</strong> 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.</li>
<li><strong>Sekundärtexte:</strong> an Abschnittsgrenzen trennen, mit einer Obergrenze — und erwägen, <strong>jedem Chunk vor dem Embedding die Zwischenüberschrift und einen kurzen bibliografischen Nachweis voranzustellen</strong>, 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.</li>
</ul>
<p>Womit an welchen Stellen getrennt wird, steuert der Parameter <code>separators</code>. 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:</p>
<pre><code><span class="c"># local_rag.py, in LocalRAG.__init__ (derzeit Zeile 53)</span>
self.text_splitter = RecursiveCharacterTextSplitter(
chunk_size=CHUNK_SIZE,
chunk_overlap=CHUNK_OVERLAP,
separators=["\n\n", "\n", ". ", " ", ""], <span class="c"># Absatz, Zeile, Satz, Wort, Notfall</span>
)</code></pre>
<p>Wer für die beiden Korpora unterschiedlich trennen will, findet dafür genau eine Stelle. In <code>add_documents</code> wird jedes geladene Dokument durch denselben Splitter geschickt — hier würde eine Fallunterscheidung ansetzen, etwa anhand des Ordners, aus dem die Datei stammt:</p>
<pre><code><span class="c"># local_rag.py, in add_documents (derzeit Zeile 114/115)</span>
docs = loader.load()
chunks = self.text_splitter.split_documents(docs) <span class="c"># &lt;- der eine Verzweigungspunkt</span>
all_docs.extend(chunks)</code></pre>
<h3>Klein suchen, groß liefern</h3>
<p>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 <em>übergeordneten</em> Abschnitt übergeben. LangChain — die Programmbibliothek, auf der das Projekt aufbaut — bringt dafür eine fertige Komponente namens <code>ParentDocumentRetriever</code> 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.</p>
<p>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:</p>
<pre><code><span class="c"># local_rag.py, in query_with_history (derzeit Zeile 251)</span>
docs_with_scores = self.vectorstore.similarity_search_with_score(retrieval_query, k=k)</code></pre>
<p>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.</p>
<h3>Metadaten (falls vorhanden)</h3>
<p>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: <code>element_type</code> (Fließtext / Anmerkung / Literaturverzeichnis), <code>work</code> bzw. <code>author_year</code>, <code>section</code> und <code>page</code>. Erst damit lassen sich Anmerkungen von einer Suche ausschließen oder eine Suche auf ein einzelnes Werk beschränken, ohne irgendetwas neu zu bauen.</p>
<p><code>source</code> und <code>page</code> setzt der Loader bereits von allein. Eigene Angaben kommen an derselben Stelle dazu, an der auch getrennt wird — wichtig ist allein die Reihenfolge: <strong>vor</strong> dem Aufteilen, denn die Chunks erben die Metadaten des Dokuments, aus dem sie stammen:</p>
<pre><code><span class="c"># local_rag.py, in add_documents — zwischen Laden und Aufteilen</span>
docs = loader.load()
for doc in docs: <span class="c"># neu</span>
doc.metadata["korpus"] = "sekundaer" <span class="c"># z. B. aus dem Ordnernamen</span>
doc.metadata["element_type"] = "body"
chunks = self.text_splitter.split_documents(docs) <span class="c"># erbt die Angaben von oben</span></code></pre>
<p>Gefiltert wird später bei der Suche, indem dem Aufruf ein <code>filter</code> mitgegeben wird — etwa <code>filter={"element_type": "body"}</code>, um Anmerkungen auszublenden. Ob es etwas gebracht hat, zeigt <code>eval_retrieval.py</code> unmittelbar danach.</p>
</div>
</section>
<!-- ===================== 04 ===================== -->
<section class="phase">
<div class="phase-head">
<div class="num">04</div>
<h2>Hybride Suche, danach Reranking</h2>
<p class="phase-sub">Mögliche Ansätze bei der Optimierung der Suche</p>
</div>
<div class="phase-body">
<h3>Stichwortsuche neben die Vektoren stellen</h3>
<p>Reines "Dense Retrieval" ist genau bei dem schwach, wovon der vorhandene Korpus voll ist: Eigennamen, Kunstwörter, Werktitel, wörtliche Zitate. Eine Anfrage nach <em>Odradek</em> oder <em>Gregor Samsa</em> 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 <code>EnsembleRetriever</code> und <code>BM25Retriever</code> vorgesehen, es bleibt also eine kurze Änderung. Im literaturwissenschaftlichen Retrieval ist das oft ein großer und sofort sichtbarer Gewinn.</p>
<h3>Danach mit einem Cross-Encoder umsortieren</h3>
<p>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 <em>zusammen</em> 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.</p>
<ul>
<li><strong>BAAI/bge-reranker-v2-m3</strong> — mehrsprachig, lokal lauffähig; auf GPU bequem, auf CPU machbar, aber langsam.</li>
<li><strong>Cohere Rerank</strong> — API, starke mehrsprachige Qualität, minimaler Integrationsaufwand.</li>
</ul>
<p>Nach Phase 02 und 03 ist das typischerweise der größte verbleibende Sprung.</p>
<div class="aside">
<h4>Erst jetzt die Prompt-Datei öffnen</h4>
<p>Wenn Recall@k sich nicht mehr verbessert und die abgerufenen Passagen beim Lesen überzeugen, sind die verbleibenden Fehler Generierungsfehler — und <em>dann</em> 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.</p>
</div>
</div>
</section>
<!-- ===================== KURZFASSUNG ===================== -->
<section class="phase">
<div class="phase-head">
<div class="num">&#10003;</div>
<h2>Kurzfassung</h2>
<p class="phase-sub">Eine Arbeitsreihenfolge · nach jedem Schritt messen</p>
</div>
<div class="phase-body">
<ol class="checklist">
<li><b>Referenzfragen anlegen und Ausgangswert festhalten</b><span>25–40 echte Fragen mit bekannter Fundstelle; Recall@5/10/20 ohne LLM. Zahlen ins Repository.</span></li>
<li><b>Zwanzig Chunks von Hand lesen</b><span>Abschätzen, welcher Anteil Apparat und Layout-Rauschen ist. Meist die aufschlussreichste halbe Stunde.</span></li>
<li><b>Chunk-Größe gegen das Modellfenster prüfen</b><span>Alles jenseits des Fensters wird verworfen, bevor der Vektor berechnet wird.</span></li>
<li><b>Prüfen, was Anweisungstext in der Anfrage bewirkt</b><span>Recall mit und ohne messen, statt anzunehmen, dass er hilft.</span></li>
<li><b>Auf ein mehrsprachiges Modell mit langem Kontext wechseln</b><span>bge-m3 als Voreinstellung; Chunk-Größe passend nachziehen. Neu bauen, neu messen.</span></li>
<li><b>Sekundärkorpus mit einem strukturbewussten Parser neu extrahieren</b><span>GROBID oder Docling. Anmerkungen und Literaturverzeichnis in Metadaten auszeichnen, nicht löschen.</span></li>
<li><b>Falls möglich: Saubere digitale Editionen für die Primärtexte beschaffen</b><span>PDF-Extraktion umgehen, wo es schon eine TEI- oder Plaintext-Ausgabe gibt.</span></li>
<li><b>Falls die Dokumente geparst wurden und nicht mehr direkt auf PDFs gearbeitet wird: element_type als Metadatum ergänzen, mit Filter</b><span>Anmerkungen standardmäßig ausschließen, aber zuschaltbar halten, damit beides testbar bleibt.</span></li>
<li><b>BM25 neben die Vektoren stellen</b><span>Eigennamen und wörtliche Zitate brauchen lexikalischen Abgleich.</span></li>
<li><b>30–50 Kandidaten holen und auf 5–8 umsortieren</b><span>Meist der größte verbleibende Gewinn, und er setzt sich hinter einen unveränderten Index.</span></li>
<li><b>Erst jetzt Antwort-Prompt und Modell überarbeiten</b><span>Ab hier sind die verbleibenden Fehler tatsächlich Generierungsfehler.</span></li>
</ol>
</div>
</section>
<footer>
Erst Retrieval, dann Generierung · Arbeitsnotizen zur local_rag-Pipeline
</footer>
</div>