Hybrid-RAG für Rechtstexte: Chunking, BM25, BGE und Reranking
Ein rechtlich einwandfreier RAG-Pipeline für die indische Verfassung: Eltern-Kind-Chunking, BM25 in Kombination mit BGE zur Informationsauswahl, Rangfusion, erneutes Rangbestimmen mittels Cross-Encoder sowie Abfragedehnung – getestet an Benchmarks.
Quellen: Beyond Basic RAG: Building a Production-Style Legal Research Assistant (Part-I)
Juristischer Text stellt eine große Herausforderung für jedes Suchsystem dar. Nehmen wir die Verfassung Indiens: Sie umfasst Hunderte von Artikeln und Klauseln sowie Zusätze, Anhänge, Änderungen, Tabellen und ein Netzwerk von Verweisen. Der Abschnitt, der eine Frage beantwortet, besteht vielleicht nur aus einem Satz, doch seine korrekte Interpretation hängt vom umliegenden Artikel, einer verwandten Ausnahme oder einer anderen Bestimmung ab, auf die er verweist. Ein nützliches System muss daher Präzision und Kontext in Einklang bringen, indem es den genauen Abschnitt findet, ohne ihn vom Text zu trennen, der ihm Bedeutung verleiht.
Viele Einführungen zu Retrieval-augmented Generation (RAG)-Systemen folgen einem einfachen Ansatz: Der Text wird aus einem PDF extrahiert, in Blöcke fester Größe aufgeteilt, eingebettet und die am nächsten liegenden Ergebnisse zurückgegeben. Das ist ein guter Ausgangspunkt, doch Produktivsysteme benötigen in der Regel mehr. Sie müssen die Dokumentstruktur beibehalten, sowohl exakte als auch semantische Suchfunktionen unterstützen, die Kandidaten neu ranken, den umliegenden Kontext wiederherstellen, jedes Ergebnis auf seine Quelle zurückverfolgen und prüfen, ob tatsächlich die benötigten Belege abgerufen wurden.
Dieser Text beschreibt ein Forschungsprojekt, das einen solchen Arbeitsablauf im Produktionsumfeld in einer Form nachstellt, die Schritt für Schritt überprüft werden kann. Dabei wird die Verfassung Indiens als erstes Korpus verwendet, da ihre Struktur viele Schwächen der herkömmlichen Vektorsuche aufzeigt. Es handelt sich dabei nicht um ein fertiges Produkt, sondern um einen Versuch, zu untersuchen, wie die üblichen Komponenten eines RAG-Pipelines zusammenwirken, an welchen Stellen sie versagen und wie Bewertungen die nächsten Entwurfsentscheidungen beeinflussen können.
1. Eingabe: von rechtlichen PDFs zu strukturiertem Inhalt
Bevor mit dem Chunking, den Embeddings oder der Abrufverarbeitung begonnen werden kann, muss das Ausgangs-PDF in Inhalte umgewandelt werden, die von Software zuverlässig verarbeitet werden können. Ein PDF speichert lediglich das Aussehen eines Dokuments, nicht unbedingt die Art und Weise, wie ein Programm es lesen sollte. Überschriften, Absätze, Fußnoten, Listen, Tabellen sowie Seitenbegrenzungen mögen für einen Menschen offensichtlich sein, erscheinen bei der grundlegenden Extraktion jedoch als getrennter Text. Verliert man diese Struktur bereits bei der Eingabe, entstehen in späteren Schritten Chunks mit falscher Lesereihenfolge, abgetrennten Überschriften oder beschädigten Tabellen.
Im Projekt wurde MinerU verwendet, um die Verfassung in Markdown umzuwandeln und dabei wichtige Layoutinformationen beizubehalten. Es wurde ausgewählt, weil es laut Vergleich des Autors Tabellen und Listen zuverlässiger verarbeitet hat als die andere versuchte Extraktionsmethode. Das Ziel bestand nicht nur darin, jedes Wort zu sammeln, sondern auch die Beziehungen zwischen den Wörtern beizubehalten. Der extrahierte Inhalt behielt folgendes bei:
- wie Überschriften verschachtelt sind,
- wo jeder Absatz und Satz beginnt und endet,
- Punktelisten und Nummerierungslisten,
- Tabellen,
- von welcher Seite jedes Element stammt, und
- Fußnoten zusammen mit der Struktur um sie herum.
Markdown eignet sich als Zwischendateiformat für diese Aufgabe. Man kann es bei manueller Überprüfung lesen, und es enthält strukturelle Marker, die von Python verarbeitet werden können. Eine Überschrift wie ## Grundrechte kann später bei der Erstellung übergeordneter Blöcke helfen, während Listen und Tabellen erkannt und als separate Blöcke behandelt werden können.
Die Extraktion wurde in Google Colab durchgeführt und nicht auf einem lokalen Rechner. OCR sowie die Analyse des Layouts können ressourcenintensiv sein, insbesondere bei langen Dokumenten mit vielen Tabellen und komplexer Formatierung; ein Laptop mit begrenzten Hardware-Ressourcen hatte Schwierigkeiten, die Aufgabe zu bewältigen. Colab bot eine besser geeignete, isolierte Umgebung mit stärkerer Rechenleistung, weshalb sie für diese Phase in Betracht gezogen werden sollte, falls die lokalen Hardware-Ressourcen begrenzt sind. Der in dem Notebook verwendete Befehl lautete:
!mineru -p c.pdf -o output --dump-content-list -b pipeline -l en
2. Chunking
Bei der Strukturierung von Verfassungstexten in Markdown stellte sich die nächste Frage, wie diese für die Suche aufgeteilt werden sollten. Die gängige Lösung ist das Chunking mit fester Größe: Der Text wird nach einer bestimmten Anzahl von Zeichen oder Tokenen geteilt, wobei oft eine gewisse Überlappung vorhanden ist. Dieser Ansatz ist einfach und funktioniert für einheitlichen Prosa-Text einigermaßen gut, doch Rechtsdokumente sind nach Bedeutung und nicht nach Länge organisiert. Feste Grenzen können dazu führen, dass:
- ein Überschriftstext vom darunterstehenden Artikel getrennt wird,
- eine Klausel von der Bedingung, die sie einschränkt, abgeschnitten wird,
- eine Liste mitten in ihren Einträgen geteilt wird,
- ein Tabellenblatt in unvollständige Fragmente zerfällt und
- unzusammenhängende Artikel einfach deshalb zusammengefasst werden, weil sie in ein einziges Token-Fenster passen.
Größere Chunks beinhalten mehr Kontext, führen aber zu unklarereren Suchergebnissen. Kleinere Chunks passen besser, doch wichtige umliegende Informationen können dabei verloren gehen. Deshalb wendete das Projekt eine zweistufige Strategie an:
Structured document
↓
Heading-aware parent chunks
↓
Semantic child chunks
Warum zwei Ebenen helfen
Die Suche erfolgt über die kleinen Kinder, da ihr begrenzter Inhalt BM25 sauberere Signale für die Suche liefert. Nachdem ein Kind ausgewählt wurde, weist der mitgeführte parent_id auf den größeren Elternabschnitt hin:
Query
↓
Retrieve focused child
↓
Read parent_id
↓
Return complete parent context
Das teilt zwei Verantwortlichkeiten auf. Die Unter-Chunks sorgen für Präzision bei der Abrufung, während die Über-Chunks die Vollständigkeit des Kontexts gewährleisten.
Über-Chunks, die Überschriften berücksichtigen
Der Algorithmus zur Erstellung von Über-Chunks stützt sich auf zwei während der Eingabe erzeugte Elemente:
data/processed/cr.md
data/extracted/auto/blockpage.json
Die Markdown-Datei enthält den bereinigten Text sowie seine sichtbare Struktur. Die blockpage.json-Datei enthält die während der Extraktion erzeugten, geordneten Layout-Blöcke einschließlich Informationen zu den Überschriftenstufen. Markdown bleibt die Hauptquelle für den übergeordneten Text, während das JSON als geordnete strukturelle Referenz dient, um die Markdown-Überschriften mit dem extrahierten Dokument abzugleichen. Die Implementierung befindet sich in einem einzigen Skript:
code/chunking_parent.py
Der Algorithmus arbeitet in einer festgelegten Reihenfolge.
Schritt 1: Beide Dateien laden. Die Skript liest den bereinigten Markdown sowie die JSON-Datei mit den Layout-Blöcken ein:
md_text = MARKDOWN_PATH.read_text(encoding="utf-8")
with BLOCKPAGE_PATH.open("r", encoding="utf-8") as file:
blockpage = json.load(file)
Durch die Verwendung beider Elemente wird verhindert, dass der Chunker den Markdown als gewöhnlichen, unstrukturierten Text behandelt. Der Markdown liefert den Inhalt, und das JSON hilft dabei, die Reihenfolge der während der Extraktion gefundenen Überschriften beizubehalten.
Schritt 2: Teilen Sie den Markdown in Blöcke auf. Leere Zeilen dienen als Trenner:
for raw_block in md_text.split("\n\n"):
Jeder Block wird anschließend in einen der verschiedenen Typen eingeteilt:
heading
paragraph
list
table
superscript
blank
Texte, die mit #, ## oder einer anderen Überschriftsebene beginnen, werden beispielsweise als Überschriften erkannt:
HEADING_RE = re.compile(r"^(#{1,6})\s+")
Listen, Tabellen und Über-schriftenblöcke haben ihre eigenen Erkennungsregeln, wodurch sie mit dem Abschnitt verbunden bleiben, zu dem sie gehören.
Schritt 3: Aktive Überschrift verfolgen. Wenn eine Überschrift erscheint, wird ihr Text dem aktuellen heading_path hinzugefügt. Die Skript prüft außerdem nach der entsprechenden extrahierten Überschrift in blockpage.json, indem es sequenziell durch das JSON geht und nicht jedes Mal von vorne scannt:
def find_heading(heading_text: str):
nonlocal search_pos
for index in range(search_pos, len(blockpage)):
block = blockpage[index] if (
block.get("type") == "text"
and block.get("text") == heading_text
and "text_level" in block
):
search_pos = index + 1
return block
Das Vorwärtsscannen behält die Überschriften in ihrer ursprünglichen Reihenfolge bei und verhindert einen Treffer auf eine frühere, identische Überschrift.
Schritt 4: Zusammenfassung des verwandten Inhalts. Absätze, Listen, Tabellen und ähnliche Blöcke werden dem aktuellen Elternstapel hinzugefügt. Wenn nach der Erfassung des Textinhalts eine neue Überschrift auftaucht, wird der aktuelle Elternstapel geschlossen und ein neuer beginnt. Konzeptionell sieht das so aus:
Heading
├── Paragraph
├── Clause
├── List
└── Table
↓
Parent chunk
Schritt 5: Zuweisen stabiler Metadaten. Jeder fertige Elterneintrag wird mit einer fortlaufenden ID, einer Dokument-ID, dem Überschriftenpfad sowie dem vollständigen Text gespeichert:
{
"parent_id": f"parent_{len(chunks):04d}",
"doc_id": DOC_ID,
"heading_path": heading_path.copy(),
"text": "\n\n".join(stack),
}
Ein vereinfachter Elternteil sieht so aus:
{
"parent_id": "parent_0164",
"doc_id": "constitution_of_india",
"heading_path": ["PART XII"],
"text": "# PART XII\n\nFINANCE, PROPERTY, CONTRACTS AND SUITS..."
}
Ein Alleinerziehender kann mehrere verwandte Vorschriften in Anspruch nehmen, beispielsweise:
PART XII — Finance
Article 264 — Interpretation
Article 265 — Taxes not to be imposed without authority of law
Article 266 — Consolidated Funds and public accounts
Article 267 — Contingency Fund
Der Prozess erzeugte 333 Eltern-Blöcke.
Semantische Kind-Blöcke
Die Eltern-Blöcke bewahren den Kontext bei, doch einige sind zu umfangreich für eine präzise Abgleichung, weshalb die größeren in kleinere semantische Kind-Blöcke aufgeteilt werden. Die entsprechenden Arbeiten befinden sich in einem Notizbuch:
code/semantic-children.ipynb
Kurze Elterntexte, die nach dem groben Schwellenwert von 400 Token im Notizbuch beurteilt werden, bleiben als ein einziges Kind erhalten, da eine Aufteilung von bereits kompakten Texten, die als fokussierte Einheit dienen können, keinen Vorteil bringt. Längere Texte werden von einem Chunker verarbeitet, der die Struktur berücksichtigt und die Bedeutung vergleicht.
Schritt 1: Den Elterntext in Blöcke aufteilen. Der Elterntext wird in Überschriften, Absätze, Listen und Tabellen unterteilt. HTML-Tabellen werden anhand ihres eigenen Musters erkannt:
table_pattern = re.compile(
r"(<table[\s\S]*?</table>)",
re.IGNORECASE
)
Normaler Text wird anhand von leeren Zeilen geteilt und durch Markdown-Überschriften sowie -Listenzeichen klassifiziert, sodass Prosa, Listen und Tabellen jeweils auf eigene Weise verarbeitet werden können, anstatt davon auszugehen, dass eine Struktur für alles geeignet ist.
Schritt 2: Fügen Sie Überschriften ihrem Inhalt hinzu. Eine Überschrift sollte niemals allein als Untereinheit auftreten. Der Aufteilungsalgorithmus speichert jede Überschrift vorübergehend und fügt sie vor den folgenden Block ein:
if pending_heading:
block.text = pending_heading + "\n\n" + block.text
pending_heading = None
Ein abgerufenes Kind behält somit die Überschrift, die seinen Inhalt identifiziert. Aufeinanderfolgende Listeblöcke werden ebenfalls vor der semantischen Vergleichung gruppiert, was dazu beiträgt, die logische Reihenfolge der Klauseln und aufgelisteten Elemente beizubehalten.
Schritt 3: Vergleichen Sie benachbarte Absätze. Für angrenzende Absatzblöcke erzeugt das Notizbuch BGE-Embeddings:
embedder = SentenceTransformer(
"BAAI/bge-base-en-v1.5"
)
Das Notebook berechnet anschließend die Kosinusähnlichkeit zwischen jedem Absatz und dem vorherigen:
similarity = cosine_similarity(
emb1,
emb2
)[0][0]
Falls zwei Blöcke in ihrer Bedeutung eng genug beieinander liegen, können sie im selben Kind-Element verbleiben, und ein Rückgang der Ähnlichkeit deutet darauf hin, dass der nächste Block ein neues Thema einleitet. Der konfigurierte Schwellenwert ist:
Similarity threshold: 0.40
Aufgrund der Ähnlichkeit ist dies jedoch nur eine einzige Eingabegröße – auch der Blocktyp und die Größe werden berücksichtigt.
Schritt 4: Anwendung von Größen- und Strukturregeln. Die Standardeinstellungen des semantischen Chunkers lauten:
Preferred minimum size: 800 characters
Maximum combined size: 1,600 characters
Tiny-block threshold: 150 characters
Ein Block kann zum aktuellen Kind hinzugefügt werden, wenn das Kind noch unter der gewünschten Mindestlänge liegt, wenn die benachbarten Absätze ähnlich genug sind, wenn einer der Blöcke sehr klein ist und nicht allein stehen sollte, oder wenn aufeinanderfolgende Blöcke zur gleichen Liste oder Tabelle gehören. Das kombinierte Kind muss in der Regel unter 1.600 Zeichen bleiben. Eine Tabelle wird nicht einfach an normale Prosa angehängt, nur um die Mindestgröße zu erreichen:
if (
current_type == BlockType.TABLE
and block.type != BlockType.TABLE
) or (
block.type == BlockType.TABLE
and current_type != BlockType.TABLE
):
is_under_min = False
Eine überdimensionale Liste kann an ihren Elementmarkierungen geteilt werden, während ein einzelner überdimensionaler, nicht-listiger Block als eine einzige Struktureneinheit beibehalten wird und nicht willkürlich zerschnitten wird. Das Ergebnis ist weder rein semantisch noch rein auf die Größe ausgerichtet; es kombiniert beides:
Document structure
+
Semantic similarity
+
Minimum and maximum sizes
Schritt 5: Den Elternlink beibehalten. Jedes generierte Kind erhält seine eigene ID und behält die ID seines Elternteils bei:
{
"parent_id": parent["parent_id"],
"child_id": f"{parent['parent_id']}_child_{index}",
"doc_id": parent.get("doc_id", ""),
"heading_path": parent.get("heading_path", []),
"text": chunk["text"],
"type": chunk.get("type", "mixed"),
"embedding": encode_text(chunk["text"])
}
Verwendung des Finanz-Elternteils als Beispielfall:
parent_0164
│
├── parent_0164_child_1
│ Article 264 and introductory Finance context
│
├── parent_0164_child_2
│ Articles 265 and 266 concerning taxation and public funds
│
└── parent_0164_child_3
Article 267 concerning the Contingency Fund
Sie alle drei behalten bei:
{
"parent_id": "parent_0164"
}
Ein gespeichertes Kind sieht so aus:
{
"child_id": "parent_0164_child_2",
"parent_id": "parent_0164",
"doc_id": "constitution_of_india",
"heading_path": ["PART XII"],
"type": "list",
"text": "265. Taxes not to be imposed save by authority of law...",
"embedding": [0.012, -0.034, 0.021]
}
Am Ende umfasst das Korpus 871 Kinder, die mit 333 Eltern in Verbindung stehen.
3. Hybride Suche – von der Abfrage zum Elternkontext
Sobald die 871 Kinder vorliegen, stellt sich die Frage, wie man den richtigen Beleg für eine Benutzerabfrage findet. Die Suche erfolgt anhand von Kinderteilen und nicht anhand der Eltern, da kurze Abschnitte leichter mit einem bestimmten Artikel, Satzteil oder Konzept übereinstimmen. Die größeren Eltern werden erst dann ermittelt, wenn die stärksten Kinderteile bekannt sind.
Warum eine einzige Methode nicht ausreicht
Rechtliche Fragen treten in unterschiedlichen Formen auf. Eine Anfrage wie „Was besagt Artikel 23?“ enthält eine Artikelnummer, die auch im Quelltext vorkommt, weshalb eine Schlüsselwortsuche hier gut geeignet ist. Ein Benutzer könnte dasselbe auf andere Weise fragen: „Kann der Staat von jemandem verlangen, einen Pflichtdienst zu leisten?“ In dieser Formulierung wird Artikel 23 nicht genannt, sodass das System „von jemandem verlangen, einen Dienst zu leisten“ mit dem Konzept des Pflichtdienstes zu einem öffentlichen Zweck in Verbindung bringen muss. Die beiden Fälle erfordern unterschiedliche Fähigkeiten:
- eine wörtliche Abgleichung für Artikelnummern, rechtliche Formulierungen, Namen und ungewöhnliche Begriffe sowie
- einen bedeutungsbasierten Abgleich, wenn die Formulierung der Frage vom Text abweicht.
Anstatt eine Wahl vorzunehmen, führt das System dasselbe Unterkorpus über zwei separate Verfahren durch: die BM25-sparse-Suche und die BGE-dichte Suche.
Der spärliche Zweig: BM25
BM25 ordnet nach den Wörtern, die eine Abfrage mit jedem Kind teilt. Zum Indexieren werden die Überschriftspfade sowie der Kindertext in Kleinbuchstaben umgewandelt und tokenisiert. Der Index protokolliert, welche Kinder jeweils welches Wort enthalten, wie oft dieses Wort vorkommt und wie selten es im gesamten Korpus ist. Drei Faktoren sind entscheidend: Wie oft ein Abfragedatum in einem Kind vorkommt, wie ungewöhnlich dieses Datum im Korpus ist und die Länge des Kindes im Vergleich zum Durchschnitt. Selten verwendete Wörter haben mehr Gewicht als häufige Wörter, wodurch BM25 besonders gut für Artikelnummern und spezifische juristische Ausdrücke geeignet ist.
Eine Suche nach Article 23 compulsory service bevorzugt beispielsweise stark Kinder, die 23, compulsory und service enthalten. BM25 muss den umfassenderen Sinn nicht erfassen, da das direkte Vorkommen der Begriffe bereits ein starker Indikator ist. Seine Schwäche liegt darin, dass relevante Abschnitte schlecht bewertet werden können, wenn die Abfrage und der Quelltext unterschiedliche Vokabulare verwenden.
Der dichte Zweig: BGE und FAISS
Der dichte Zweig durchsucht nach Bedeutung und nicht nur nach Wortüberschneidungen. Jedes Kind wird durch seinen Überschriftspfad zusammen mit seinem Text dargestellt:
embedding_text = heading_path + child_text
Durch Hinzufügen des Überschriftentextes entsteht Kontext, der dabei hilft, dass verwandte Abschnitte und Abfragen im Embedding-Raum in der Nähe voneinander landen.
Auswahl des Embedding-Modells mittels Benchmark
Die erste Version des Projekts verwendete nomic-ai/nomic-embed-text-v1.5, doch der Autor wollte bestätigen, ob dieses Modell am besten zu diesem Korpus passt. Ein kontrollierter Benchmark für dichte Suche verglich vier Konfigurationen: Nomic ohne Präfixe, Nomic mit den empfohlenen Präfixen für Abfragen und Dokumente, BAAI/bge-base-en-v1.5 sowie intfloat/e5-base-v2.
Jedes Modell durchsuchte dieselbe Menge von 871 festgelegten Unterblöcken sowie 383 Bewertungsfragen, die automatisch zugeordnet worden waren.
- MRR: gewährt mehr Punkte, wenn der erste relevante Unterblock weiter oben liegt.
BGE zeigte die besten Werte bei MRR, Recall@10, Recall@20 sowie Mean coverage@10. Der Wert MRR stieg von 0,624 bei Nomic auf 0,652, während Recall@20 von 0,877 auf 0,911 anstieg. Das war wichtig, da späterer Fusion- und Neuranking-Prozess nur über einen begrenzten Pool an Kandidaten verfügt, und ein relevantes Kind, das bei der dichten Suche fehlt, kann danach nicht wieder gefunden werden.
Nomic erzielte einen leicht höheren Wert bei All-recall@10 (0,347 gegenüber 0,334), was bedeutet, dass es bei der Wiederherstellung aller gelabelten Kinder in Fragen mit mehreren Kindern geringfügig besser abschnitt. Dennoch zählte es mehr, bereits früher mindestens ein starkes Kind zu finden, um den Elternkontext wiederherzustellen. Die Abfrageverzögerung war fast identisch. Die gespeicherten Nomic-Kind-Embeddings wurden daher durch BGE-Embeddings ersetzt, wobei die bestehenden semantischen Grenzen beibehalten wurden, sodass der Embedding-Wechsel unabhängig von früheren Chunking-Entscheidungen blieb.
Kinder werden als reiner Text ohne Anweisungspräfix eingebettet. Abfragen hingegen erhalten die von BGE empfohlene Wiederherstellungsanweisung:
query_text = (
"Represent this sentence for searching relevant passages: "
+ user_query
)
Derselbe BGE-Modell enthält Abfragen sowie Kindvektoren. Die Vektoren sind L2-normalisiert, sodass das Skalarprodukt wie eine Kosinusähnlichkeit wirkt – höhere Werte deuten auf größere Ähnlichkeit hin.
Der FAISS-Index
Die normalisierten Kindvektoren werden in einen FAISS Hierarchical Navigable Small World-Index eingespeist:
index = faiss.IndexHNSWFlat(
embedding_dimension,
32,
faiss.METRIC_INNER_PRODUCT,
)
index.hnsw.efConstruction = 200
index.hnsw.efSearch = 64
index.add(embeddings)
HNSW ordnet die Embeddings als navigierbaren Graphen an, sodass eine Suche durch vielversprechende Nachbarn erfolgt und der Abfragesatz nicht mit jedem Vektor verglichen wird. Ein ausführlicher Vergleich wäre auch für 871 Kinder noch handhabbar. HNSW wurde gewählt, damit das Design weiterhin funktioniert, wenn weitere Gesetze, Vorschriften, Urteile und ähnliche Dokumente zum Korpus hinzukommen. Die Kinder wurden mit code/reembed_children_bge.py erneut eingebettet, und der Index wurde mit code/rebuild_dense_index.py neu aufgebaut. Die Vorbereitung des Benchmarks, die Modelldefinitionen, Abfragen und Ergebnisse befinden sich unter code/dense_benchmark/v2/.
Kombination der beiden Ranglisten
BM25 und dichte Retrieval-Methoden erzeugen getrennte Ranglisten. Ein Dokument kann in beiden eine hohe Platzierung erreichen, stark nur in einer oder in keiner der Listen vorkommen. Rohwerte können nicht einfach addiert werden, da die BM25-Werte und die Vektorähnlichkeit auf unterschiedlichen Skalen liegen. Die beiden Ranglisten werden mithilfe der Reciprocal Rank Fusion zusammengeführt:
RRF score = Σ 1 / (k + rank + 1)
Der Pipeline verwendet k = 60. Jedes Kind erhält Punkte aufgrund seiner Position in jeder Liste und nicht aufgrund seines ursprünglichen Scores. Ein Kind, das in beiden Zweigen eine hohe Platzierung erreicht, sammelt Beiträge aus beiden und rückt in der Regel nach oben, während ein Kind, das in einem einzigen Zweig stark ist, weiterhin im Kandidatenpool bleiben kann. Die vereinfachte Fusionsschicht:
sparse_results = bm25_search(query)
dense_results = dense_search(query)
fused_candidates = reciprocal_rank_fusion(
sparse_results,
dense_results,
)
RRF erzeugt einen umfangreichen Pool an Kandidaten. Dadurch steigen die Chancen, dass nützliche Beweise erhalten bleiben, doch es wird die gesamte Abfrage nicht im Detail mit jedem Abschnitt verglichen. Das ist die Aufgabe des Rerankers.
Cross-Encoder-Reranking
Dichte Suche ist kostengünstig, da Abfrage und die darin enthaltenen Elemente getrennt kodiert werden, sodass die Embeddings der Elemente einmal berechnet und für jede Abfrage wiederverwendet werden können. Der Nachteil ist, dass die unabhängige Kodierung begrenzt, inwieweit das Modell die Wörter gegeneinander abwägen kann. Ein Cross-Encoder verfolgt einen anderen Ansatz und erhält sowohl die Abfrage als auch ein Kandidaten-Element zusammen:
pairs = [
(query, child_text),
...
]
scores = reranker.predict(pairs)
Da die Abfrage und der Text als einzige Eingabe übergeben werden, kann das Modell direkt erkennen, wie sich ihre Token zueinander verhalten, was im Allgemeinen ein präziseres Relevanzurteil liefert als die Ergebnisse aus BM25 oder dichter Suche.
candidates = fused_candidates[:30]
reranked_children = cross_encoder.rank(query, candidates)
Der Neubewertungsmechanismus bewertet jedes Element erneut und sortiert die Kandidaten wieder. Die früheren BM25-, Dichte- und RRF-Werte erstellen lediglich die Auswahlmöglichkeiten, während der Score des Kreuzcodierers die endgültige Reihenfolge bestimmt. Anschließend betrachtet das System die ersten 20 neu bewerteten Elemente und behält jene bei, deren Score mindestens 0,30 beträgt:
selected_children = [
child
for child in reranked_children[:20]
if child.score >= 0.30
]
Diese Schwelle stellt eine Filterpolitik für dieses spezielle Modell und die zugehörige Pipeline dar. Sie sollte nicht als universelle Relevanzwahrscheinlichkeit aufgefasst werden, und ein Wechsel der Neubewertungsalgorithmen würde eine erneute Bewertung und Kalibrierung erforderlich machen. Tabellen erfordern besondere Sorgfalt, da einige die Eingabegrenze von 512 Token des Neubewertungsalgorithmus überschreiten. Vor der Tokenisierung markiert der Workflow Tabelleneinheiten, die zu lang sind, sodass weder Indexierungsfehler entstehen noch diese Einheiten stillschweigend verschwinden.
Ausdehnung von Kindeneinheiten in den Elternkontext
Der Neubewertungsalgorithmus arbeitet weiterhin mit Kindeneinheiten, da diese fokussierte Vergleichseinheiten bilden. Die Rückgabe nur dieser Kindeneinheiten könnte jedoch umliegende Klauseln, Bedingungen, Ausnahmen oder verwandte Artikel ausschließen. Jede Kindeneinheit behält daher die während der Aufteilung zugewiesene parent_id bei:
{
"child_id": "parent_0082_child_1",
"parent_id": "parent_0082",
"text": "23. Prohibition of traffic in human beings and forced labour..."
}
Sobald die Filterung nach Schwellenwert abgeschlossen ist, nimmt das System den parent_id jedes ausgewählten Kindelements und sucht das gesamte Elternelement in der Sammlung parent-chunk auf:
parent_id = selected_child["parent_id"]
parent = parent_lookup[parent_id]
Mehrere ausgewählte Kinder können denselben Elternteil teilen, und dessen wiederholte Anzeige würde den Kontext duplizieren; daher werden die Kinder nach parent_id gruppiert. Jeder einzigartige Elternteil wird zusammen mit den zu ihm gehörenden Kindern einmal angezeigt, wobei die höchste Kindbewertung als Bewertung dieses Elternteils gilt:
Selected child 1 ──┐
Selected child 2 ──┼── parent_0082
Selected child 3 ──┘
Ein kleines Kind, das die relevanten Formulierungen aus Artikel 23 enthält, kann parent_0082 aufrufen, wobei der zugehörige Elternteil dann den umfassenderen Abschnitt „Recht gegen Ausbeutung“ einschließlich der zugehörigen Klauseln sowie des Inhalts von Artikel 24 bereitstellt. Dadurch bleibt die Aufteilung der Aufgaben erhalten: Die Kind-Blöcke entscheiden, welche Beweise relevant sind, während die Elternteil-Blöcke den Kontext liefern, in dem diese auftauchen.
Die zurückgegebene Elternteil-Quelle behält außerdem ihre Dokument-ID sowie den Überschriftenpfad bei, ebenso die IDs der abgeglichenen Kinder-Blöcke, ihre Blocktypen und Reranker-Werte. So kann eine zurückgegebene Quelle durch den gesamten Prozess nachverfolgt werden, um zu verstehen, warum sie ausgewählt wurde. Das hybride Suchexperiment befindet sich in code/hybridrag.ipynb.
4. Abfragedehnung und mehrstufige Fragen: Überbrückung des Wortschatzdefizits
Der hybride Ansatz funktionierte gut, wenn die Formulierung des Benutzers stark der Verfassung entsprach. Eine direkte Frage wie „Was ist Artikel 21?“ enthält eine genaue Verweisung, die BM25 sowie dem dichten Suchverfahren ein starkes Signal liefert.
Konzeptionelle Fragen waren schwieriger. Benutzer beschreiben oft ein Prinzip mit Begriffen aus Urteilen, Lehrbüchern oder Rechtskommentaren und nicht in der Sprache der Verfassung selbst. Eine Frage zur „Grundstrukturlehre“ kann Artikel 13 und 368 erfordern, obwohl der Begriff „Grundstruktur“ in keiner dieser Artikel vorkommt. Mit anderen Worten: Der Benutzer hat die rechtliche Idee richtig verstanden, während das Dokument sie anders bezeichnet.
Was die Bewertung zeigte
Das Problem wurde in einem Kaggle-Notebook getestet, das direkte Fragen sowie eine schwierigere Reihe von konzeptionellen Fragen umfasste, die auf erwartete verfassungsrechtliche Artikel abgestimmt waren. Das Experiment fand vor der Migration zu BGE statt. Zu diesem Zeitpunkt verwendeten die 871 Kinder Embeddings aus nomic-ai/nomic-embed-text-v1.5, und die Abfragen wurden mit demselben Modell kodiert, ohne Rückholpräfixe. Dieser Detail ist wichtig, weil das Experiment darauf abzielte herauszufinden, welche Auswirkungen eine Umformulierung der Abfrage hat – und nicht, Embedding-Modelle zu bewerten – weshalb derselbe auf Nomic basierende hybride Prozess beide Versionen jeder konzeptionellen Frage verarbeitete.
Eine Frage gilt als bestanden, nur wenn der abgerufene übergeordnete Kontext alle damit verbundenen Zielartikel enthielt; daher misst der angegebene Prozentsatz die Vollständigkeit der Zielartikelabrufung und nicht die faktische Genauigkeit einer von einem LLM verfassten Antwort. Der aussagekräftige Vergleich erfolgt zwischen den beiden konzeptionellen Mengen. Durch die Abfragerweiterung stieg die Bestehensrate der Abfrageabrufung von 56,0 % auf 74,5 %, was einem Zuwachs von 18,5 Prozentpunkten entspricht. Dadurch konnten für 37 weitere Fragen alle Zielartikel abgerufen werden, und die Fehlerzahl sank von 88 auf 51.
Das Ergebnis deutete darauf hin, dass viele Fehler nicht durch fehlende Dokumente oder schlechte Chunk-Grenzen verursacht wurden. Die entsprechenden Belege existierten im Korpus, doch die ursprüngliche Abfrage lieferte der Abfrageabrufung nicht das notwendige Vokabular, um sie zu finden.
Erweiterung ohne Änderung der Absicht
Die Erweiterung formuliert eine konzeptionelle Frage in Begriffen, die im Quelltext wahrscheinlicher vorkommen. Angenommen, die Frage lautet, was das Parlament daran hindert, die wesentliche Identität der Verfassung zu ändern. Die erweiterte Form könnte die Doktrin der grundlegenden Struktur einbeziehen – den Kern der Verfassung, der nicht geändert werden kann – Gesetze, die mit den Grundrechten im Widerspruch stehen, Artikel 13 sowie die Änderungsmacht in Artikel 368. Was der Benutzer möchte, hat sich nicht geändert, doch der längere Text fügt Suchsignale wie folgende hinzu:
- den gängigen Namen der Doktrin,
- verwandte verfassungsrechtliche Begriffe,
- alternative Ausdrucksweisen derselben Idee,
- relevante institutionelle oder strukturelle Begriffe sowie
- explizite Artikelnummern, sofern sie zuverlässig abgeleitet werden können.
Die gleiche Behandlung umfasste die Trennbarkeit, das Wesentliche und den Kerninhalt, den juristischen Aktivismus sowie die Beziehung der Grundrechte zu den Leitprinzipien.
In den Kaggle-Experimenten wurden die erweiterten Abfragen im Voraus formuliert und sogar mögliche Artikel aufgeführt, weshalb sie als kontrolliertes Experiment zur Umformulierung von Abfragen und nicht als Bewertung eines automatischen Erweiterungsmodells angesehen werden sollten. Es zeigt, wie sehr sich die Ergebnisse verbessern können, wenn die Abfrage eine mit den Quellen übereinstimmende Sprache enthält, doch ein echter Abfragen-Planer würde eine separate Bewertung ohne Zugang zu den Zieletiketten benötigen. Der spätere dichte Benchmark ersetzte Nomic durch BGE, da BGE im festgelegten Kindkorpus bessere Ergebnisse erzielte; dennoch prägte das Kaggle-Ergebnis weiterhin die Gestaltung: Ein besseres Embedding-Modell verbesserte die semantische Suche, während die Abfragenerweiterung ein anderes Problem adressierte – den Wortschatzunterschied zwischen Benutzer und Dokument.
Erweiterung versus Unterabfragen
Die Erweiterung hilft, wenn ein Informationsbedarf in unvertrauten oder indirekten Begriffen formuliert ist, und führt zu einer umfassenderen Abfrage. Mehrschrittige Fragen sind anders, da sie mehrere Informationsbedürfnisse enthalten können, die auf unterschiedliche Teile des Dokuments verweisen. Betrachten wir einen Antrag, bei dem die unter den Artikeln 32 und 226 vorgesehenen Abhilfemöglichkeiten verglichen werden sollen. Wenn dies als eine einzige lange Abfrage gesucht wird, konkurrieren die Ergebnisse: Ein hervorragendes Ergebnis für Artikel 32 kann die Liste der möglichen Ergebnisse überlagern, während Artikel 226 zu wenig Berücksichtigung findet, und eine Neubewertung der kombinierten Ergebnisse garantiert nicht, dass beide Seiten überleben.
Eine mehrschrittige Frage kann stattdessen in gezielte Unterabfragen aufgeteilt werden:
- Welche Abhilfemöglichkeiten und Zuständigkeiten sieht Artikel 32 vor?
- Welche Befugnisse und Zuständigkeiten sieht Artikel 226 vor?
- Inwiefern unterscheiden sich Artikel 32 und 226 verfassungsrechtlich?
Jede Unterabfrage durchläuft den gesamten Abrufprozess (BM25 und dichte Suche, anschließend Rangfusion, dann Neurangierung mit Cross-Encoder, danach Erweiterung der Elternelemente), und die resultierenden Elternelemente werden zusammengeführt und entdupliziert zu kombinierten Beweismitteln. Dadurch wird verhindert, dass ein Teil einer komplexen Frage die Kapazitäten für einen anderen Teil aufbraucht, und die Suche wird leichter überprüfbar, da jeder Zweig einer bestimmten Anforderung entspricht.
Die resultierende Routierungsstrategie
Die Bewertung zeigte, dass keine einzige Abfragenumwandlung für jede Frage geeignet ist. Direkte Fragen enthalten bereits starke Signale und sollten unverändert bleiben. Konzeptionelle Fragen profitieren von einer Erweiterung des Wortschatzes, und mehrstufige Fragen müssen aufgeteilt werden, damit jede Anforderung ihren eigenen Zweig erhält:
- Direkte faktenbasierte oder artikelbezogene Fragen: Suche mit der ursprünglichen Abfrage durchführen.
Diese Routierung vermeidet unnötige Änderungen an einfachen Abfragen und gibt komplexeren Abfragen die benötigte Struktur. Die ursprüngliche Kaggle-Bewertung ist in code/hybrid_eval.ipynb implementiert, während die späteren Erweiterungs- und Ausbreitungsversuche in code/hybridrag.ipynb zu finden sind.
Lektionen aus dem Projekt
Die wichtigste Erkenntnis ist, dass eine bessere RAG-Technologie selten von einem einzigen perfekten Modell abhängt. Die Qualität der Informationsabrufung ergibt sich aus zusammenhängenden Entscheidungen bezüglich der Extraktion, des Aufteilens in Blöcke, der Embeddings, der Kombination von Kandidaten, der Neubewertung sowie der Wiederherstellung des Kontexts. Bei der Bewertung änderten sich im Laufe der Zeit mehrere Entscheidungen. Der dichte Benchmark ersetzte Nomic durch BGE, behielt dabei jedoch die semantischen Grenzen bei; das Kaggle-Experiment zeigte, dass konzeptionelle Fragen von einer Terminologie profitieren, die der des Quelltextes näher kommt, und Fehlschläge bei mehrstufigen Abfragen wiesen auf fokussierte Unterabfragen sowie unabhängige Informationsabrufwege hin.
Zu den natürlichen nächsten Schritten gehört es, die Pipeline in eine Chatbot-Anwendung einzubetten, die über einen strukturierten Backend- sowie Frontend-Bereich zur Stellung von Fragen und zum Prüfen von Quellen verfügt. Anschließend soll sie mit Docker verpackt und auf einer Cloud-Plattform bereitgestellt werden, wo Themen wie Modellladung, Speichernutzung, Latenzzeit, persistente Indizes, Protokollierung, Skalierbarkeit und Kosten eine Rolle spielen. Das System entwickelt sich weiterhin, und einige Fehler sind noch ungelöst – genau das ist der Zweck dieser Übung: Jede Phase muss überprüfbar bleiben, um herauszufinden, wo Beweismittel verloren gehen, und die Bewertung soll die weitere Verbesserung leiten.