Startseite / Artikel

Entwicklung eines zitierten Q&A-Systems für PDFs von Zentralbanken mithilfe von RAG

Die praktische Erstellung eines RAG-Systems auf der Grundlage vierteljährlicher Zentralbankberichte: Dateneingabe, suchfunktionale Abfrage berücksichtigt die Aktualität, zitierte Antworten sowie die im Laufe des Prozesses aufgetretenen praktischen Fehler.

Verlag: REACTAPP.TOP8 Min. Lesezeit1827 Wörter

Quellen: I Learned RAG by Building a Q&A System

Die meisten RAG-Tutorials arbeiten mit ordentlich strukturierten, künstlich erstellten Daten. Reale Dokumente verhalten sich anders, und die Kluft zwischen dem Verständnis von retrieval-augmented Generation und der tatsächlichen Umsetzung zeigt sich erst dann, wenn jemand das gesamte System auf einem unstrukturierten Korpus entwickelt. Das war die Motivation hinter bspQA, einem Frage-Beantwortungssystem für die vierteljährlichen Geldpolitikberichte der Bangko Sentral ng Pilipinas, der Zentralbank der Philippinen.

Das Korpus ist ein gutes Lernziel. Jeder Bericht umfasst 60 bis 70 Seiten mit dichter wirtschaftlicher Analyse, wobei siebzehn davon mehrere Jahre zurückreichen und alle öffentlich zugänglich sind. Die Sprache ist fachspezifisch, es gibt viele Dateien, und das Suchproblem ist ernsthaft, da niemand siebzehn Berichte in ein Kontextfenster kopieren kann. Das Ziel war bescheiden: Eine Frage wie „Wie sah die Inflationsprognose für 2026 aus?“ zu stellen und innerhalb von Sekunden eine fundierte, mit Quellen belegte Antwort zu erhalten, anstatt durch einen Haufen PDFs mit Ctrl+F zu suchen.

Muster in einem Absatz

RAG verknüpft die Antworten eines LLM mit einer ausgewählten Dokumentensammlung statt mit dem, was es während des Trainings aufgenommen hat. Die Dokumente werden im Voraus in Vektor-Embeddings umgewandelt. Wenn eine Frage eingeht, werden die dem Frageinhalt am nächsten liegenden Abschnitte abgerufen, dem Modell als Kontext übergeben, und dem Modell wird gesagt, dass es ausschließlich auf dieser Grundlage antworten soll. Der Vorteil ist ein System, das sich weigert, Dinge zu erfinden, die in den Dokumenten nicht enthalten sind.

Zwei Prozessschritte, getrennt voneinander

Die Konzeption trennt bewusst zwei Aufgaben voneinander.

  • Die Aufnahme erfolgt offline, einmal pro neues Dokument. Dabei werden PDF-Dateien heruntergeladen, in reinen Text umgewandelt, der Text in Abschnitte aufgeteilt, Embeddings erstellt und alles in Qdrant gespeichert.
  • Die Abfrage wird bei jeder Anfrage in Echtzeit durchgeführt. Dabei wird die Frage in ein Embedding umgewandelt, relevante Abschnitte abgerufen und die Antwort generiert. Rohdaten in PDF-Formaten werden in dieser Phase nie berührt.

Entwicklung der Aufnahmefunktion

Herunterlade-Schritt

A manifest.json file is the source of truth. Each report gets an entry holding its URL, a period label and its publication date. The URLs were pasted in by hand, which is as unglamorous as it sounds.

{
  "doc_id": "bsp-mpr-2023-q3",
  "title": "Monetary Policy Report Q3 2023",
  "period_label": "Q3 2023",
  "publication_date": "2023-08-17",
  "url": "https://www.bsp.gov.ph/..."
}

Der Abrufmechanismus überspringt bereits auf der Festplatte vorhandene Dateien, daher ist ein erneuter Ausführung völlig unbedenklich, und das Hinzufügen eines Berichts bedeutet lediglich das Einfügen eines weiteren Eintrags in die Dokumentliste.

Parse-Schritt

Microsofts MarkItDown wandelt die PDFs in Text um. Bei dokumentenreichen Richtlinienberichten leistet es eine gute Arbeit: Überschriften bleiben erhalten, Absatzzeilen werden sauber wiedergegeben, und Tabellen überstehen nur knapp die Umwandlung.

MarkItDown ist außerdem eine aufwändige Abhängigkeit, die viele Pakete mit sich bringt. Da die Parse-Aktion nur während des Einlesens stattfindet und niemals bei der Beantwortung von Anfragen, befindet sie sich in einer optionalen Abhängigkeitsgruppe, sodass der auf Render gehostete Backend nicht unter diesem Lastaufwand leidet. Es lohnt sich, frühzeitig eine klare Regelung zu treffen: Man sollte entscheiden, welche Pakete zum Einlesumfeld und welche zum Bereitstellumfeld gehören, da diese beiden Bereiche nicht unbedingt übereinstimmen müssen. Zwischen dem Parse-Vorgang und der Aufteilung in Blöcke gibt es außerdem einen Reinigungs-Schritt, dessen Grund im Folgenden erläutert wird.

Chunking, Embedding und Upserting

Die eigentlichen Entscheidungen beginnen hier. Die Chunks stammen aus einem 512-Token-Sliding-Window mit 64 Token Überschneidung, gezählt mit tiktoken.

def chunk_text_by_tokens(text, enc, chunk_size=512, overlap=64):
    tokens = enc.encode(text)
    stride = chunk_size - overlap
    chunks = []
    start = 0
    while start < len(tokens):
        window = tokens[start : start + chunk_size]
        chunks.append(enc.decode(window))
        if start + chunk_size >= len(tokens):
            break
        start += stride
    return chunks

Die Überlappung stellt sicher, dass ein an einer Grenze zwischen Blöcken abgeschnittener Satz dennoch in mindestens einem Block vollständig erscheint.

Die Embeddings stammen von OpenAI’s text-embedding-3-small, das günstig und zuverlässig für dichte Prosa verwendet werden kann. Das gesamte Projekt kostete etwa zehn Dollar an Credits, obwohl die Mindestaufladung zu dieser Zeit fünf Dollar betrug – was für ein kleines Projekt ausreicht.

Jeder Vektor erhält einen UUID5-Identifikator, der aus dem Quell-Dateinamen und der Blocknummer abgeleitet wird. Dadurch ist eine erneute Einpflege sicher, da ein zweiter Durchlauf die vorhandenen Einträge überschreibt, anstatt Duplikate anzuhäufen.

Jeder gespeicherte Datensatz enthält die Quelldatei, den Chunk-Index, den Text, das Einbettungsmodell, die Zeitraumkennung, eine normalisierte Zeitraumkennung wie z. B. q3_2023 sowie das Veröffentlichungsdatum im ISO 8601-Format. Qdrant führt Schlüsselwortindizes für die Quelldatei und die Zeitraumkennung sowie einen Datumszeit-Index für das Veröffentlichungsdatum, wodurch das Filtern von Metadaten bei Abfragen schnell erfolgen kann.

Auswertung und die Falle der Aktualität

Eine naive Implementierung gibt die K besten Ergebnisse anhand der Kosinusähnlichkeit zurück. Das funktioniert, solange das Korpus mehrere Jahre umfasst und jemand nach der aktuellen Inflationsprognose fragt. Wenn die Bank im ersten Quartal 2022 und im dritten Quartal 2024 nahezu identische Formulierungen zur Inflation verwendet hat, könnte der Chunk aus 2022 aufgrund leichterer Formulierungen eine höhere Bewertung erhalten, obwohl der Leser fast sicherlich die neuesten Informationen haben möchte.

Die Lösung besteht darin, mehr Kandidaten abzurufen und diese erneut zu bewerten. Das System lädt dreimal so viele Kandidaten wie angefordert herunter und bewertet anschließend jeden mit einer Kombination aus kosinusbasierter Relevanz und einem normalisierten Aktualitätswert.

RECENCY_WEIGHT = 0.85

def combined_score(hit):
    relevance = hit.score / max_score
    recency = (pub_date - min_date).days / date_span_days  # 0.0 to 1.0
    return (1 - RECENCY_WEIGHT) * relevance + RECENCY_WEIGHT * recency

Ein Aktualitätsgewicht von 0,85 bedeutet, dass neuere Dokumente stark bevorzugt werden, es sei denn, ein älteres Dokument ist weitaus relevanter. In einem Richtlinienkorpus, in dem jedes Bericht erst die vorherigen ersetzt, erscheint dieses Abwägen angemessen.

Optionale Filter ermöglichen es dem Benutzer, die Suche auf eine bestimmte Periode oder auf einen Zeitraum einzuschränken:

# scope to Q1 2023 only
retrieve_chunks(query, period_label_key="q1_2023")

# scope to a date range
retrieve_chunks(query, publication_date_from="2023-01-01", publication_date_to="2023-12-31")

Ohne Filter verhält sich das System wie ein „letzter Anblick“-Assistent. Mit Filtern wird es zu einem Werkzeug für historische Forschung.

Erstellung

Der Generator ist absichtlich schlicht gestaltet. Die abgerufenen Teile werden als nummerierte Quellen formatiert und an gpt-4o-mini über einen Systemprompt weitergeleitet, der im Grunde besagt: Verwende ausschließlich die bereitgestellten Quellen, ziehe keine externen Kenntnisse heran, zitiere jede faktische Aussage direkt und gebe an, wenn die Quellen nicht ausreichen.

Dieser letzte Punkt erfordert besondere Aufmerksamkeit. Ein streng begrenzter Prompt ist der praktischste Schutz gegen Halluzinationen. Wenn die Abfrage nichts Relevantes liefert, antwortet das System mit einer festgelegten Standardnachricht, anstatt improvisieren zu müssen.

Die API-Antworten enthalten den Antworttext zusammen mit einer strukturierten Liste der Quellen, wobei jede Quelle den Zeitraum angibt, zu dem sie gehört, wann sie veröffentlicht wurde und wo sich die PDF-Quelle befindet. Im Frontend werden daraus klickbare Zitate, wodurch jede Aussage nachvollziehbar bleibt.

Probleme, die in der Praxis auftraten

Seitenelemente, die Datenblöcke verunreinigen

Jede Seite jedes Berichts weist einen Überschriftsbereich mit dem Text „Klassifizierung: ALLGEMEIN“ sowie einen Fußbereich in der Form „Geldpolitikbericht – 3. Quartal 2023 | 47“ auf. Nach der Analyse erscheinen diese Zeilen als gewöhnlicher Text und werden in die Datenblöcke aufgenommen, wodurch Ressourcen durch unnötigen Inhalt verschwendet werden. Die Seitenzahl in diesem Fußbereich könnte tatsächlich präzisere Zitate ermöglichen – dieser Gedanke kam jedoch erst spät auf.

Die Lösung besteht darin, vor der Erstellung der Datenblöcke eine Reinigungsphase durchzuführen:

def _clean_parsed_text(text):
    text = text.replace("\x0c", "\n\n")
    text = re.sub(r"Classification:\s*GENERAL\s*\n", "", text)
    text = re.sub(r"Monetary Policy Report\s*[--][^\n]+\|\s*\d+\s*\n", "", text)
    text = re.sub(r"\n{3,}", "\n\n", text)
    return text.strip()

Im Nachhinein ist das offensichtlich, doch man kann es leicht übersehen, wenn der analysierte Text vor der Einbettung nie überprüft wird. Das Symptom war seltsam: Bei der Testung der Antworten schien der Retriever auf andere Textabschnitte statt auf den am besten bewerteten zurückzugreifen. Die Fußzeilen teilten Absätze mitten im Satz, wodurch der Kontext, den jeder Abschnitt darstellte, heimlich verändert wurde.

Ein versehentlicher Löschvorgang

Qdrant bietet eine Flagge „recreate-collection“, mit der die gesamte Sammlung gelöscht und neu erstellt wird. Das ist in der Entwicklung nützlich, in der Produktion jedoch katastrophal. Mittlerweile ignoriert ein Schutzmechanismus diese Flagge, sobald die Umgebung wie die Produktion aussieht:

def _should_recreate_qdrant_collection():
    if _is_production_environment():
        return False
    explicit = os.environ.get("QDRANT_RECREATE_COLLECTION", "").lower()
    if explicit in ("1", "true", "yes"):
        return True
    app_env = (os.environ.get("ENVIRONMENT") or "").lower()
    return app_env in ("development", "dev", "local")

Bei einem Projekt mit einem einzigen Benutzer ist der Schutzmechanismus wohl überdimensioniert, kostet aber fast nichts.

Duplikate nach erneuter Einbeziehung

Nach der Bearbeitung eines Manifest-Eintrags und erneuter Durchführung der Einbeziehung existierten für dasselbe PDF zwei Datensatz-Gruppen. Die Lösung besteht darin, die vorhandenen Datensätze einer Quelldatei zu löschen, bevor die neuen hinzugefügt werden:

client.delete(
    collection_name=collection,
    points_selector=models.Filter(must=[
        models.FieldCondition(
            key="source_file",
            match=models.MatchValue(value=source_file)
        )
    ]),
)

Zusammen mit den stabilen UUID5-Identifikatoren ist die erneute Einreichung nun idempotent.

Kalte Starts auf der kostenlosen Ebene

Der Backend-Server befindet sich auf Render’s kostenlosem Plan, der bei Inaktivität heruntergefahren wird. Der erste Anfragen nach einem kalten Start kann 30 bis 60 Sekunden in Anspruch nehmen – eine Zeit, die ausreicht, damit ein Besucher annimmt, die App sei defekt. Ein einfacher /health-Endpunkt löst dieses Problem größtenteils: Die Frontend-Anwendung ruft diesen bei Laden der Seite auf, wodurch der Server bereits vor dem Absenden der ersten Anfrage wieder aktiviert wird. Das bedeutet eine zusätzliche HTTP-Anfrage pro Sitzung. Die Benutzeroberfläche enthält außerdem eine kurze Anmerkung, die erklärt, dass die erste Anfrage während des Wachwerdens des Backends etwa eine halbe Minute bis eine Minute dauern kann.

Wahl der Datenmenge pro Teil

Der Wert von 512 Token wurde durch Experimente ermittelt. Blöcke mit 128 bis 256 Token waren präziser, verloren jedoch oft den Kontext, der allein zur Interpretation notwendig war. Blöcke mit 1024 Token oder mehr zogen unverwandten umliegenden Text mit sich. Die 64-Token-Überlappung mildert die harten Schnitte an den Grenzen und rechtfertigt den zusätzlichen Speicherbedarf.

Was beim nächsten Mal geändert werden sollte

  • Zuerst ein Bewertungssatz erstellen. Es existiert zwar ein Verzeichnis namens evals, doch es wurde vor der Anpassung des Aktualitätsgewichts oder der Blockgröße kein „goldener“ Fragebogensatz erstellt, sodass beide Entscheidungen auf Schätzungen beruhen. Ein echter Bewertungssatz würde diese Entscheidungen fundieren.
  • Einen Neurankierer hinzufügen. Die Einbeziehung der Aktualität in die Bewertung ist lediglich eine Heuristik. Ein Cross-Encoder wie Cohere Rerank oder ein lokales Modell könnte Abfrage und Block gemeinsam bewerten und die Relevanz zuverlässiger einstufen.
  • Probieren Sie die hybride Suche aus. Dichte Vektoren können genaue Begriffe übersehen. Ein Ausdruck wie eine 7,00%-ige umgekehrte Repurchase-Rate ist schwer auffindbar, wenn die zugehörigen Schlüsselwörter anders formuliert werden. Die Kombination der BM25-Schlüsselwortabgleich-Methode mit der Vektor Suche würde diese Lücke schließen, und Qdrant bietet dies bereits standardmäßig an.
  • Streame Antworten. gpt-4o-mini benötigt zwei bis fünf Sekunden, um zu antworten. Das Streamen der Tokens, sobald sie eintreffen, verleiht der Benutzeroberfläche das Gefühl von Geschwindigkeit, selbst wenn die Gesamtzeit unverändert bleibt.

Wiederverwendung des Designs

Die Architektur ist universell einsetzbar. Ersetzen Sie das Manifest durch ein anderes Dokumentenkorpus, aktualisieren Sie die Domän Beschreibung im Systemprompt – und es entsteht ein funktionsfähiges Q&A-System für beliebige PDF-Dateien. Die schwierigeren Aspekte wie sauberes Aufteilen in Abschnitte, suchintelligente Abfrage basierend auf Aktualität, Filterung von Metadaten sowie sichere Wiedereinbringung der Daten werden bereits abgedeckt.

Weitere Literatur

  • Hybride Bildsuche in großem Maßstab: SigLIP, SPLADE, YOLOv8 und Gemini Captions — Entwurf eines Suchsystems für eine Million Bilder, das dichte und spärliche Embeddings mit Objekterkennung, Entitätserkennung, sicherer Suche sowie von LLMs verfassten Beschreibungen kombiniert.