Das Problem mit reinem Vector-RAG
Vektor-Datenbanken sind stark, wenn es um semantische Ähnlichkeit und langen Fließtext geht. Sie sind aber notorisch unzuverlässig bei atomaren Fakten, Zahlen und strikten Entity-Beziehungen. Ein klassisches Beispiel aus dem Machine-Learning-Mastery-Tutorial von Matthew Mayo: Fragt man ein Vector-RAG, für welches Team ein bestimmter Basketballspieler aktuell spielt, landen im Retrieval-Top-k leicht mehrere Teams, weil alle im latenten Raum nahe am Spielernamen liegen. Das Modell hat dann die Wahl – und halluziniert gelegentlich die falsche Beziehung.
Die Ursache ist strukturell: Embeddings sind verlustbehaftet, Nähe ist kein Wahrheitsbegriff. Wer harte Fakten retrieven will, braucht Strukturen, die Fakten als Fakten behandeln – also einen Graphen.
Die drei Tier im Überblick
Das Tutorial baut eine föderierte Architektur mit drei Retrieval-Quellen, die alle gleichzeitig angefragt werden. Die Ergebnisse wandern parallel in den Kontext, und ein promptbasiertes Regelwerk zwingt das Modell, Konflikte deterministisch aufzulösen.
- Priority 1 – Absolute Graph-Fakten: Ein Quad-Store mit verifizierten, unveränderlichen Ground-Truth-Tripeln im SPOC-Format (Subject, Predicate, Object, Context). Das ist die absolute Wahrheit.
- Priority 2 – Statistische Graph-Daten: Ein zweiter Quad-Store, der aggregierte Statistiken oder historische Daten enthält. Bei Konflikten überschreibt Priority 1 immer.
- Priority 3 – Vektor-Dokumente: Eine klassische dense Vector-DB (ChromaDB) für unstrukturierte Texte. Nur als Fallback, wenn die Graphen nichts liefern.
Das Raffinierte: Es gibt keinen algorithmischen Router, der entscheidet, welche Quelle gefragt wird. Alle drei Quellen werden immer abgefragt, die Ergebnisse als [PRIORITY 1], [PRIORITY 2], [PRIORITY 3] in den System-Prompt gestanzt, und das LM bekommt eine strikte Hierarchie vorgeschrieben.
Tier 1 – Der Fakten-Graph
Statt Neo4j oder ArangoDB nutzt das Tutorial einen selbstgebauten, in-memory QuadStore (etwa 200 Zeilen Python, verfügbar im zugehörigen GitHub-Repo). Intern mappt er Strings auf Integer-IDs und hält vier Indizes (spoc, pocs, ocsp, cspo) für Konstantzeit-Lookups über beliebige Dimensionen. Die API ist bewusst minimal:
from quadstore import QuadStore
facts_qs = QuadStore()
facts_qs.add("LeBron James", "played_for", "Ottawa Beavers", "NBA_2023_regular_season")
facts_qs.add("Ottawa Beavers", "based_in", "downtown Ottawa", "NBA_trivia")
facts_qs.add("LeBron James", "average_mpg", "12.0", "NBA_2023_regular_season")
Die Fakten werden manuell gepflegt – das ist der Preis für Determinismus. Der context-Slot im Quad erlaubt es, denselben Sachverhalt in verschiedenen Zeiträumen zu tracken, ohne dass sich die Tripel gegenseitig überschreiben.
Tier 2 – Der Statistik-Graph
Strukturell identisch zu Tier 1, aber befüllt aus einer JSONL-Datei, die zuvor aus einem CSV-Datensatz (im Beispiel: NBA-Saison-Statistiken) per Skript in Quads konvertiert wurde. Hier leben aggregierte Kennzahlen und breitere historische Daten. Die Trennung zu Tier 1 ist wichtig, weil Tier 2 oft mit Abkürzungen, veralteten Zuordnungen und Mehrdeutigkeiten behaftet ist. Der Prompt deklariert Tier 2 explizit als „supplementary background only".
Tier 3 – Vektor-Dokumente
ChromaDB als persistente Collection, befüllt mit unstrukturierten Textchunks. Nichts Besonderes – Standard-Retrieval per String-Ähnlichkeit zur Query:
chroma_client = chromadb.PersistentClient(path="./chroma_db")
collection = chroma_client.get_or_create_collection(name="basketball")
collection.upsert(documents=[doc1, doc2], ids=["doc1", "doc2"])
Dieser Layer liefert den Fließtext-Kontext, der in den Graphen naturgemäß fehlt: Hintergrundgeschichten, Verletzungsverläufe, Spielberichte.
Routing per NER statt per Router
Der Schritt, der Graph und Vektor verklammert, ist Named Entity Recognition mit spaCy. Aus dem User-Prompt werden Entities extrahiert und anschließend als strikte Lookups gegen beide Quad-Stores gefeuert, parallel dazu läuft die Vektor-Suche über den Original-Prompt:
nlp = spacy.load("en_core_web_sm")
def extract_entities(text):
doc = nlp(text)
return list({ent.text for ent in doc.ents})
def get_facts(qs, entities):
facts = []
for entity in entities:
facts.extend(qs.query(subject=entity))
facts.extend(qs.query(object=entity))
return list({tuple(f) for f in facts})
Kein komplexes Query-Rewriting, kein Klassifikator – Entity-Extraktion reicht, weil die Graphen eh auf Entities indiziert sind.
Prompt-enforced Conflict Resolution
Der Clou ist der System-Prompt. Statt Reciprocal Rank Fusion oder gelernte Re-Ranker bekommt das Modell alle drei Streams gelabelt in den Kontext und eine harte Regel dazu:
- Wenn Priority 1 eine direkte Antwort enthält, benutze ausschließlich diese.
- Priority 2 kann wie ein Widerspruch aussehen – es ist es nicht. Ignoriere Team-Abkürzungen aus P2, wenn P1 ein Team nennt.
- P2 nur, wenn P1 auf das gefragte Attribut keine Antwort hat.
- P3 als ergänzender Fließtext-Kontext.
- Wenn nichts passt: „I do not have enough information." Kein Raten.
Das ist im Kern ein Adjudicator-Pattern, das im Prompt selbst lebt. Es funktioniert, weil die Hierarchie explizit und lokal ist – das Modell muss sie nicht erlernen, sondern nur befolgen. Im Tutorial reicht dafür bereits llama3.2:3b via Ollama.
Wann Graph-RAG die richtige Wahl ist
- Domänen mit harten Entity-Beziehungen, die sich ändern: wer hält aktuell welche Rolle, welches Produkt gehört zu welcher Firma, welche Version hat welche Breaking Changes.
- Wenn Halluzinationen teuer sind: medizinische Dosierungen, rechtliche Zuordnungen, Compliance-Aussagen, Finanzdaten.
- Wenn wiederkehrende Konflikte zwischen frischen Fakten und älterem Trainings-/Dokumenten-Wissen zu erwarten sind.
- Wenn die Fakten-Menge überschaubar und pflegbar ist – ein Quad-Store mit hundert bis wenigen zehntausend Fakten lässt sich manuell kuratieren, Millionen nicht.
Für reines Q&A über lange, explorative Dokumente bleibt klassisches Vector-RAG die pragmatischere Wahl.
Pitfalls und Trade-offs
- Token-Overhead: Alle drei Quellen landen ungefiltert im Kontext. Das ist teurer als jedes Router-Setup. Bei großen Graphen muss man doch wieder filtern.
- Manuelle Pflege: Priority 1 lebt und stirbt mit der Datenqualität. Wer den Graph nicht aktiv pflegt, hat nur eine langsamer alternde Halluzinationsquelle.
- Entity-Extraction als Flaschenhals: Wenn spaCy die Entity nicht findet, landet auch kein Fakt im Kontext. Für Fachdomänen braucht es ein angepasstes NER-Modell oder regelbasiertes Matching.
- Instruction-Compliance des Modells: Kleine Modelle halten sich nicht immer an die Prioritätsregeln und fallen in Trainings-Weights zurück. Das Pattern braucht mindestens instruct-getunte Modelle, idealerweise mit belastbarem Constrained-Prompting-Verhalten.
- Keine Garantie: „Deterministisch" ist ein Designziel, kein Beweis. Solange das LM die Regeln interpretiert, bleibt Restrisiko. Für echte Determinismus-Anforderungen muss man den Lookup-Pfad rein algorithmisch fahren und das LM nur formulieren lassen.
Tooling
- Graph-Layer: Für Prototypen reicht ein in-memory Quad-Store wie im Tutorial oder
rdflib. Für Produktion eher Neo4j, ArangoDB oder Oxigraph – dann aber mit dem Overhead einer echten Graph-Query-Language. - Vektor-Layer: ChromaDB für lokal, Qdrant oder pgvector für Produktion.
- NER: spaCy als Einstieg, für Fachdomänen GLiNER oder ein finegetuntes Transformer-NER.
- Inference: Ollama mit einem instruct-Modell für lokales Testing, in Produktion jedes Modell mit stabiler System-Prompt-Compliance.
Das Kern-Pattern – mehrere gelabelte Retrieval-Streams plus harte Prompt-Hierarchie – lässt sich aus dem Basketball-Spielzeug des Tutorials gut in ernsthafte Domänen übertragen. Der Aufwand rechnet sich dort, wo ein einziger falscher Entity-Link mehr kostet als die Graph-Pflege.
Quellen
- Matthew Mayo: Beyond Vector Search: Building a Deterministic 3-Tiered Graph-RAG System, Machine Learning Mastery, 11. April 2026
- ChromaDB
- spaCy
- Ollama