67 % weniger Tokenverbrauch bei RAG durch Multivektoren und Satzisolation in Qdrant.
Ein RAG-Pipeline für rechtliche Recherche, der mithilfe von ColBERT in Qdrant neu rankt, den passenden Satz isoliert und etwas Latenz gegen eine überprüfte Reduzierung der Tokenanzahl um 67,1 % eintauscht.
Quellen: How Qdrant Reduced RAG Token Costs by 67% with Native ColBERT Reranking
Tokenkosten sind zu einem zentralen Problem für Teams, die retrieval-augmented Generation einsetzen. Große Unternehmen suchen nach Wegen, den Verbrauch zu senken, und viele Teams stellen fest, dass ein unoptimierter LLM-Pipeline mehr kostet als die Entwicklungszeit, die sie eigentlich einsparen sollte. Die Prüfung von Rechtsdokumenten eignet sich gut als Testumfeld, denn die Dokumente sind lang, die Antworten kurz und die Menge groß.
Die Seite, die niemand brauchte
Stellen Sie sich einen Anwalt drei Wochen nach Beginn der Beweisaufnahme in einem Vertragsstreit vor, der mit Stapeln von Master-Servicevereinbarungen und Unternehmensdokumenten konfrontiert ist. Die Frage ist spezifisch: Wie hoch ist die Haftungsgrenze, wenn eine Dritte Partei auf eine Urheberrechtsverletzung pocht? Eine einzige Zeile in einem vierzigseitigen Vertrag gibt darauf die Antwort.
Ein standardmäßiges RAG-System liefert die drei besten Abschnitte, jeweils mit 500 bis 1.000 Token, und es handelt sich dabei meist um Ausführungen, Definitionen, Zahlungsbedingungen sowie Rechtsvorschriften, die nichts mit IP-Ansprüchen zu tun haben. Der einzige nützliche Satz befindet sich in der Mitte. Das stört den Leser, und in großem Umfang ist es außerdem teuer: Bei Tausenden von Unterlagen in einer Rechtsstreitprüfung wird der Anbieter des LLM dafür bezahlt, enorme Mengen an überflüssigem Standardtext zu verarbeiten.
Daraus ergeben sich zwei Fragen: Wie viel vom abgerufenen Text ist tatsächlich notwendig? Und kann der genaue Satz ohne einen aufwändigen zweiten Service zur Neubewertung isoliert werden?
Gar nichts an diesem Problem ist spezifisch für das Recht. Jeder Datensatz aus langen, standardisierten Dokumenten – wie Versicherungspolicen, Lieferverträgen oder Konformitätsanleitungen – weist dieselbe Struktur auf: eine kurze Antwort, umgeben von Seiten voller Text, die bei jeder Abfrage in die Anfrage einbezogen werden. Die Kosten steigen mit der Größe der abgerufenen Textabschnitte und nicht mit der Größe der Antwort; daher ist entscheidend, wie viel von jedem Abschnitt beim Modell ankommt.
Warum wird innerhalb der Vektordatenbank neu gerankt?
Der übliche zweistufige Ansatz führt zunächst eine schnelle, dichte Suche durch, um etwa die 100 besten Kandidaten zu ermitteln, und führt anschließend einen zweiten Netzwerkaufruf an einen externen Neuranker wie Cohere, SageMaker oder einen benutzerdefinierten Container durch, um diese zu sortieren. Dies funktioniert, weist aber drei Nachteile auf.
- Zwei Netzwerkaufrufe sind an zwei verschiedene Anbieter notwendig.
- Fällt der Neuranker aus, scheitert auch die Suche.
Qdrant bietet eine Alternative. Seit Version 1.10 unterstützt es native Mehrvektorfelder mit einem MAX_SIM-Comparator, dem Mechanismus hinter der späten Interaktion im Stil von ColBERT. Der Vergleich von Token zu Token erfolgt innerhalb der Datenbank in einer einzigen Abfrage, sodass die Genauigkeit einer separaten Neubewertungsmaschine ohne zusätzlichen Schritt erreicht wird. In Kombination mit der Satzisolierung reduzierte diese Konfiguration die Tokenkosten um 67 %.
Die Speichereinstellungen vervollständigen das Profil.
- Binäre Quantisierung im dichten Index. Dichte Vektoren werden auf ein Bit pro Dimension reduziert, und diese kleinen Vektoren bleiben im RAM (always_ram=True), um ein sehr schnelles Vorausladen von Kandidaten zu ermöglichen.
- ColBERT-Matrizen auf der Festplatte. Die großen Token-Matrizen werden auf der Festplatte gespeichert (on_disk=True) und nur für die obersten Kandidatenmengen – vermutlich 50 bis 100 Blöcke – während der Neubewertung geladen, wodurch der RAM-Verbrauch gering bleibt.
Der Ablauf
Die Architektur umfasst sechs Schritte mit einem einzigen Datenbankaufruf und keiner externen Neurangierungs-API.
- Trennen Sie die Vereinbarungen nach Abschnitten, damit kein Block einen rechtlichen Klausel in zwei Teile teilt.
- Embedden Sie jeden Block zweimal – einmal als standardmäßiger dichter Vektor für die schnelle Prüfung und einmal als ColBERT-Multi-Vektor-Matrix für die Neubewertung auf Token-Ebene.
- Laden Sie die oberen N Kandidaten mithilfe binär quantisierter dichter Vektoren vor.
- Neubewerten Sie diese Kandidaten mit ColBERT MAX_SIM innerhalb von Qdrant.
- Nehmen Sie die besten Blöcke und bewerten Sie deren einzelnen Sätze lokal im Vergleich zum ColBERT-Abfragesatz.
- Senden Sie nur die passenden Sätze an den LLM.
Der Preis für den Benchmark wurde mit 3,00 US-Dollar pro Million Eingabetoken angesetzt, was mit einem mittelklasseigenen kommerziellen Modell übereinstimmt.
Sammelschema
Das untenstehende Konfigurationsskript stellt standardmäßige dichte Embeddings sowie ColBERT-Multi-Vektoren nebeneinander bereit, wobei FastEmbed-Modelle lokal auf der CPU geladen werden.
from qdrant_client import QdrantClient, models
# ADDED: Load FastEmbed models locally on CPU
from fastembed import TextEmbedding, LateInteractionTextEmbedding
COLLECTION_NAME = "legal_discovery"
DENSE_DIM = 384 # BAAI/bge-small-en-v1.5
COLBERT_DIM = 128 # colbert-ir/colbertv2.0
# ADDED: Instantiate the vector models
dense_model = TextEmbedding("BAAI/bge-small-en-v1.5")
colbert_model = LateInteractionTextEmbedding("colbert-ir/colbertv2.0")
client = QdrantClient("<http://localhost:6333>")
client.create_collection(
collection_name=COLLECTION_NAME,
vectors_config={
"dense": models.VectorParams(
size=DENSE_DIM,
distance=models.Distance.COSINE,
quantization_config=models.BinaryQuantization(
binary=models.BinaryQuantizationConfig(always_ram=True),
),
),
"colbert": models.VectorParams(
size=COLBERT_DIM,
distance=models.Distance.COSINE,
multivector_config=models.MultiVectorConfig(
comparator=models.MultiVectorComparator.MAX_SIM
),
on_disk=True,
hnsw_config=models.HnswConfigDiff(m=0),
),
},
)
Eine Einstellung fällt bei den Vektorparametern von ColBERT auf: eine HNSW-Konfiguration mit m auf 0 gesetzt. Da ColBERT nur zur Neubewertung einer vorher gefilterten Menge verwendet wird, ist kein HNSW-Graph dafür notwendig. Das Auslassen des Graphen spart viel Zeit beim Erstellen des Indexes sowie Speicherplatz.
Ein Hin- und Rückweg für Vorausladeung und Neubewertung
Qdrants universelle Query-API ermöglicht es, die Vorausladeung in der ersten Phase sowie die Neubewertung in der zweiten Phase als einzige Operation auszuführen. Es sind weder Abfragen, ID-Parser noch ein zweiter Schritt zu einem Neubewertungsalgorithmus erforderlich; der gesamte Ablauf wird in einem einzigen Datenbank-Hin- und Rückweg abgewickelt.
# ADDED: Generate query embeddings (ColBERT uses query_embed to add prefix padding)
dense_query = next(dense_model.query_embed(query)).tolist()
colbert_query = next(colbert_model.query_embed(query)).tolist()
# Run the two-stage query in one network round-trip
results = client.query_points(
collection_name=COLLECTION_NAME,
prefetch=models.Prefetch(
query=dense_query,
using="dense",
limit=prefetch_limit,
params=models.SearchParams(
quantization=models.QuantizationSearchParams(rescore=False),
),
),
query=colbert_query,
using="colbert",
limit=top_k,
with_payload=True,
)
Die Flagge rescore=False innerhalb des prefetch-Blocks verdient Aufmerksamkeit. Bei binärer Quantisierung würde Qdrant normalerweise Kandidaten mit den quantisierten Vektoren abrufen und anschließend die vollpräzisen float32-Dichtevektoren laden, um sie neu zu bewerten. Da die ColBERT-Ebene ohnehin eine genauere Bewertung durchführt, vermeidet das Deaktivieren der dichten Neubewertung das Lesen von vollpräzisen Vektoren von der Festplatte und verringert somit die Datenbanklast.
Von Blöcken zu Sätzen
MAX_SIM gibt für jeden Block eine aggregierte Bewertung an, ohne anzugeben, welche Token dazu beigetragen haben. Um nur relevante Texte auszuwählen, teilt ein abschließender lokaler Schritt die gewinnenden Blöcke in Sätze auf und berechnet für jeden von ihnen die ColBERT-Scores für späte Interaktionen. Da dieser Vorgang nur auf den letzten wenigen Blöcken durchgeführt wird, in denen der Text klein ist, tritt kein Skalierungsproblem der Datenbank auf, und die Präzision auf Satzebene kann kostengünstig auf der CPU erreicht werden.
import re
import numpy as np
# ADDED: Basic sentence splitter regex
SENTENCE_SPLIT = re.compile(r"(?<=[.;])\s+(?=[A-Z])")
def max_sim(query_vecs: np.ndarray, doc_vecs: np.ndarray) -> float:
# Compute token-to-token similarity matrix
sims = query_vecs @ doc_vecs.T # (num_query_tokens, num_doc_tokens)
# Sum the maximum similarity scores along the document axis
return float(sims.max(axis=1).sum())
def isolate_sentences(chunk_text: str, query_vecs: np.ndarray, colbert_model, top_n: int = 1):
# ADDED: Split chunk text into candidate sentences
sentences = [s.strip() for s in SENTENCE_SPLIT.split(chunk_text) if len(s.strip()) > 15]
if not sentences:
return [(chunk_text, 0.0)]
# Embed each sentence locally using ColBERT
sentence_vecs = list(colbert_model.embed(sentences))
scored = [(sentences[i], max_sim(query_vecs, sentence_vecs[i])) for i in range(len(sentences))]
scored.sort(key=lambda pair: pair[1], reverse=True)
return scored[:top_n]
Die lokale Berechnung ist schnell, da sie nur die drei oder fünf obersten Abschnitte betrifft. Sobald die besten Sätze identifiziert sind, wird der Rest jedes Abschnitts weggelassen und die Prompt-Datei zusammengestellt.
def build_optimized_prompt(query: str, chunk_texts: list[str], colbert_model) -> str:
query_vecs = next(colbert_model.query_embed(query))
context_parts = []
for i, text in enumerate(chunk_texts):
top_sentences = isolate_sentences(text, query_vecs, colbert_model, top_n=1)
isolated_text = " ".join(s for s, _ in top_sentences)
context_parts.append(f"[Source Chunk {i+1}]: {isolated_text}")
context_str = "\n\n".join(context_parts)
return f"Context:\n{context_str}\n\nQuestion: {query}\nAnswer:"
Chunk-Größe und die „ehrliche“ Zahl
Allein die Reduzierung der Token ist ein verlockender Indikator. Wenn zehn bis zwölf rechtliche Abschnitte in einen 2.000-Wörter- großen Chunk gepackt und anschließend die Satzisolation angewandt wird, lässt sich theoretisch eine Reduzierung von über 85 % behaupten. In einem produktiven Rechtssystem schadet dieser Ansatz jedoch der Genauigkeit der Suche. Ein großer Chunk übertönt das Signal einer bestimmten Klausel – beispielsweise einer Obergrenze für die IP-Haftung Dritter – durch umliegende Standardformulierungen im dichten Vektorraum. Abfragen an solche trüben, mehrklauseligen Chunks liefern oft unverwandte Abschnitte wie Garantien, da der dichte Suchalgorithmus das Ziel nicht isolieren kann.
Die Lösung besteht darin, eine strenge Grenze von genau einem rechtlichen Klausel pro Abschnitt einzuhalten, wobei jeder Abschnitt genau einen eindeutigen Bereich enthält und die Klausellängen realistisch sind. Nach diesem Schema ergab der Benchmark eine nachgewiesene Reduzierung der Prompt-Tokens um 67,1 %. Manuelle Überprüfungen von Fragen zur Haftung und IP-Rechtsstellung bestätigten, dass die richtigen Klauseln gefunden wurden, obwohl die angegebene Zahl von einem theoretischen 87,3 % auf ein tatsächliches 67,1 % sank. Saubere Abschnittsgrenzen sind wichtiger als ein höheres Prozentsatz.
Der Kompromiss bei der Latenz
Die binäre Quantisierung deutet darauf hin, dass der zweistufige Ansatz in Bezug auf die Geschwindigkeit der herkömmlichen dichten Suche überlegen sein könnte. Das ist jedoch nicht der Fall. Bei wiederholten Durchläufen war die zweistufige Suche stets zwei bis vier Mal langsamer, je nach Maschine und Cache-Zustand: 39,5 ms gegenüber 20,4 ms. Der Grund ist einfach. Die reine dichte Suche beinhaltet nur einen einzigen Embedding-Aufruf, während die zweistufige Variante sowohl die Query-Encoder, nämlich den dichten Encoder sowie ColBERT, verwendet und innerhalb der Datenbank eine Token-Vergleichsmatrix erstellt. Ein separater Testlauf mit reiner dichter Suche ergab im Durchschnitt 7,4 ms.
Eine Latenzmessung, bei der die Vorladebegrenzung von 10 auf 500 Kandidaten erhöht wurde, änderte kaum etwas, und die Latenz blieb im selben Bereich. Für die zusätzliche Zeit ist die zweistufige Struktur selbst verantwortlich, nicht die Größe des Vorladefensters.
Insgesamt wird die Latenz bei end-to-end RAG vor allem durch die Generierung durch Large Language Models bestimmt und nicht durch Datenbankabfragen. Wenn pro Abfrage mehr als 400 Token eingespart werden, verkürzt sich die nachfolgende Verarbeitung und Generierung um Hunderte von Millisekunden, wodurch die gesamte Anwendung deutlich schneller wird.
Welchen Wert die Ersparnisse haben
Bei 3,00 US-Dollar pro Million Eingabetoken führt eine Reduzierung der Prompt-Tokens um 67,1 % zu erheblichen Einsparungen. Bei einer Recherchearbeit mit 500.000 Abfragen bedeutet das mehr als 600 US-Dollar Ersparnis pro Fallprüfung. Kürzere Prompts verringern außerdem das sogenannte „Lost-in-the-Middle“-Phänomen, bei dem ein Modell relevante Texte, die in einem langen Kontext versteckt sind, übersehen kann.
Was vor der Einführung überprüft werden sollte
Drei Messgrößen entscheiden darüber, ob das Design zu einer bestimmten Arbeitslast passt. Die erste ist die Richtigkeit der Antworten auf einem manuell überprüften Abfragesatz, denn Kosteneinsparungen durch weniger Tokens sind bedeutungslos, wenn die richtige Klausel fehlt. Die zweite ist die Latenz der gesamten Anfrage einschließlich der Generierung, da die zusätzliche Zeit im Datenbanksystem im Vergleich zur Modellaufrufzeit unbedeutend ist. Die dritte ist die Anzahl der Tokens pro Prompt vor und nach der Satzisolation, gemessen unter Verwendung der aktuellen Preiskalkulation. Ergebnisse aus einem Dokumenttyp gelten möglicherweise nicht für einen anderen, daher sollte der Benchmark am Zielkorpus wiederholt werden.
Lektionen für die Produktion
- Bewahren Sie das Neuranking innerhalb der Datenbank. Externe Neuranking-APIs führen zu zusätzlichen Fehlerquellen und Netzwerkübertragungen, während ein eingebauter MAX_SIM-Operator die Arbeit in einer einzigen Übertragung erledigt.
- Deaktivieren Sie HNSW für Felder, bei denen nur Neubewertung erfolgt. Die Einstellung von m=0 in mehrvektoralen Konfigurationen vermeidet einen aufwändigen Indexierungsschritt, der für ColBERT-Matrizen keinen Vorteil bei der Suche bringt.
- Seien Sie vorsichtig mit sehr großen Abschnitten. Aggressive Größen können zwar auf dem Papier eine beeindruckende Reduzierung der Token zeigen, verschlechtern jedoch die semantische Darstellung und beeinträchtigen die Genauigkeit.
- Verwenden Sie einen echten Satztokenisator. Eine einfache Regex reicht für eine Demo aus, doch Produktionscode sollte eine Bibliothek wie NLTK oder spaCy verwenden, die mit rechtlichen Abkürzungen wie Inc., Sec. und Exhibit A umgehen kann, ohne Sätze falsch zu trennen.
Die native mehrvektoriale Vergleichsfunktion in Kombination mit einer lokalen Satzebene-Bewertung kann einen präzisen und kosteneffizienten Prozess ermöglichen, der den gewünschten Satzteil des Benutzers liefert, ohne die monatliche API-Rechnung in die Höhe zu treiben.
Verwandte Literatur
- RAG mit LangChain von Grund auf: Lader, Chunking, Embeddings, Bewertung — Erstellen Sie schrittweise ein Dokumenten-FAQ-System: Laden von Dateien, Chunking, Embeddings, die Phasen der Indexierung und Abfrage sowie wie Sie die Qualität der Abfragen und Antworten bewerten.
- Claude Agent SDK gegen OpenAI Agents SDK gegen Google ADK: Ein Agent, drei Implementierungen — Derselbe Nährwertetiketten-Agent wird mit Claude Agent SDK, OpenAI Agents SDK und Google ADK erstellt, wobei klar dargestellt wird, was jedes Tool übernimmt und was Sie noch selbst umsetzen müssen.