Réduire de 67 % la consommation de tokens RAG grâce aux multivecteurs de Qdrant et à l’isolation des phrases.
Un pipeline RAG pour la découverte juridique qui réordonne les résultats à l’aide de ColBERT au sein de Qdrant, isole la phrase correspondante, et sacrifie une partie de la latence pour une réduction vérifiée de 67,1 % des tokens.
Sources: How Qdrant Reduced RAG Token Costs by 67% with Native ColBERT Reranking
Les factures liées aux tokens sont devenues une source majeure d’inquiétude pour les équipes qui utilisent la génération augmentée par récupération d’informations. Les grandes entreprises cherchent des moyens de réduire la consommation, et de nombreuses équipes constatent qu’un pipeline d’LLM non optimisé peut coûter plus cher que le temps d’ingénierie qu’il était censé économiser. L’examen de documents juridiques constitue un bon terrain d’essai, car ces documents sont longs, les réponses courtes et le volume élevé.
La page dont personne n’avait besoin
Imaginez un avocat, trois semaines après le début de la phase de découverte dans le cadre d’un litige contractuel, confronté à des piles d’accords de services principaux et de documents d’entreprise à examiner. La question est précise : jusqu’où s’élève la limite d’indemnisation lorsque un tiers allègue une violation de brevet ? Une seule phrase quelque part dans un contrat de quarante pages y répond.
Un système RAG standard renvoie les trois meilleurs extraits, chacun comptant de 500 à 1 000 tokens, et il s’agit principalement de textes explicatifs, de définitions, de conditions de paiement et de clauses relatives au droit applicable, qui n’ont aucun rapport avec les revendications en propriété intellectuelle. La seule phrase utile se trouve au milieu. Cela est agaçant pour le lecteur, et à grande échelle, c’est coûteux : lors d’un audit de milliers de dossiers judiciaires, le fournisseur de LLM est rémunéré pour traiter d’énormes quantités de textes redondants et standardisés.
Deux questions se posent alors. Quelle partie du texte récupéré est réellement nécessaire ? Et la phrase exacte pourrait-elle être isolée sans avoir recours à un service supplémentaire coûteux chargé du réclassement ?
Rien dans ce contexte de gaspillage n’est spécifique au droit. Tout corpus de documents longs et standardisés, tels que des polices d’assurance, des contrats avec des fournisseurs ou des manuels de conformité, présente la même structure : une réponse courte entourée de pages de texte que chaque recherche fait figurer dans la requête. Le coût augmente en fonction de la taille des extraits récupérés et non de la taille de la réponse, donc l’élément à optimiser est la quantité d’informations provenant de chaque extrait qui parvient au modèle.
Pourquoi le réclassement dans la base de données vectorielle ?
La méthode habituelle en deux étapes consiste d’abord à effectuer une recherche dense rapide pour sélectionner approximativement les 100 meilleurs candidats, puis à effectuer une deuxième appel réseau vers un outil de réclassement externe comme Cohere, SageMaker ou un conteneur personnalisé afin de les trier. Cette approche fonctionne, mais présente trois inconvénients.
- Deux appels réseau sont nécessaires vers deux fournisseurs différents.
- Si l’outil de réclassement tombe en panne, la recherche échoue également.
Qdrant propose une alternative. Depuis la version 1.10, il prend en charge des champs multi-vectoriels natifs avec un comparateur MAX_SIM, qui est à l’origine de l’interaction tardive de type ColBERT. La comparaison token par token s’effectue directement dans la base de données au sein d’une seule requête, ce qui permet d’obtenir la précision de l’interaction tardive sans avoir recours à un réclassificateur distinct. Associé à l’isolation des phrases, cette configuration réduit les coûts liés aux tokens de 67 %.
Les paramètres de mémoire complètent ce profil.
- Quantification binaire sur l’index dense. Les vecteurs denses sont réduits à un bit par dimension, et ces petits vecteurs restent en RAM (always_ram=True) afin d’assurer un préchargement très rapide des candidats.
- Matrices ColBERT sur disque. Les matrices lourdes des tokens sont stockées sur disque (on_disk=True) et ne sont chargées que pour l’ensemble des meilleurs candidats, soit environ 50 à 100 fragments, lors du réévaluation des scores, ce qui permet de maintenir un faible usage de la RAM.
Le pipeline
La conception comprend six étapes, avec une seule requête à la base de données et sans API externe de réclassement.
- Séparer les accords par section afin qu’aucun fragment ne coupe une clause juridique en deux.
- Incorporer chaque fragment deux fois : une fois sous forme de vecteur dense standard pour une première analyse rapide, et une fois sous forme de matrice multi-vecteur ColBERT pour une réévaluation au niveau des tokens.
- Précharger les N meilleurs candidats à l’aide de vecteurs denses quantifiés en binaire.
- Réévaluer ces candidats à l’aide de ColBERT MAX_SIM au sein de Qdrant.
- Prendre les meilleurs fragments et évaluer leurs phrases individuelles localement en comparaison avec le vecteur de requête ColBERT.
- Envoyer uniquement les phrases correspondantes à l’LLM.
Le prix de référence prévu est de 3,00 $ par million de tokens d’entrée, ce qui correspond à un modèle commercial de niveau intermédiaire.
Schéma de collecte
Le script de configuration ci-dessous configure des embeddings denses standards et des multi-vecteurs ColBERT côte à côte, avec des modèles FastEmbed chargés localement sur le CPU.
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),
),
},
)
Un paramètre se distingue parmi ceux des vecteurs ColBERT : une configuration HNSW avec m égal à 0. Comme ColBERT est utilisé uniquement pour réévaluer un ensemble de données préfiltrées, aucun graphe HNSW n’est nécessaire. L’omission de ce graphe permet d’économiser beaucoup de temps de construction de l’index ainsi que d’espace de stockage.
Une seule requête pour le préchargement et la réévaluation
L’API de requête universelle de Qdrant permet au préchargement en première étape et à la réévaluation en deuxième étape d’être exécutés en une seule opération. Aucune requête, aucune analyse des identifiants, et aucun second passage par un réordonneur ne sont nécessaires ; tout le processus est géré en une seule requête vers la base de données.
# 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,
)
Le paramètre rescore=False dans le bloc prefetch mérite une attention particulière. Avec la quantification binaire, Qdrant récupérerait normalement des candidats utilisant des vecteurs quantifiés, puis chargerait des vecteurs dense float32 à précision complète pour les réévaluer. Comme l’étape ColBERT effectue de toute façon une évaluation plus précise, désactiver la réévaluation dense évite de lire des vecteurs à précision complète depuis le disque et réduit la charge sur la base de données.
De l’ensemble de tokens à la phrase
MAX_SIM fournit un score global par ensemble de tokens sans indiquer quels tokens l’ont déterminé. Afin de ne conserver que le texte pertinent, une étape locale finale divise les ensembles gagnants en phrases et calcule pour chacune d’elles les scores de ColBERT basés sur des interactions tardives. Comme cette opération s’exécute uniquement sur les derniers ensembles de tokens, où le texte est de petite taille, aucun goulot d’étranglement lié à l’échelle de la base de données n’apparaît, et la précision au niveau de la phrase peut être obtenue facilement sur le CPU.
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]
Le calcul local est rapide car il ne prend en compte que les trois ou cinq premiers blocs. Une fois les meilleures phrases identifiées, le reste de chaque bloc est ignoré et la charge utile de la demande est assemblée.
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:"
Taille des blocs et nombre honnête
La réduction des tokens seule constitue une métrique tentante. En regroupant dix à douze sections juridiques légitimes en un bloc de 2 000 mots puis en appliquant l’isolation des phrases, on peut théoriquement affirmer une réduction supérieure à 85 %. Cependant, dans un système juridique en production, cette approche nuit à la précision des recherches. Un bloc trop grand noie le signal d’une clause spécifique, comme une limite d’indemnisation pour les droits de propriété intellectuelle des tiers, au milieu du texte standard qui l’entoure dans l’espace vectoriel dense. Les requêtes adressées à ces blocs confus contenant plusieurs clauses ont tendance à retourner des sections non pertinentes, telles que des garanties, car le moteur de recherche dense ne parvient pas à isoler l’élément cible.
La solution consiste en une limite stricte imposant une seule clause juridique par bloc, chaque bloc ne contenant qu’une seule section distincte et des longueurs de clauses réalistes. Selon ce système, le test de référence a montré une réduction vérifiée de 67,1 % des tokens de requête. Les vérifications manuelles des demandes relatives à l’indemnisation et à la propriété intellectuelle ont confirmé que les clauses appropriées avaient été trouvées, même si le chiffre global est passé d’un taux théorique de 87,3 % à 67,1 % dans la réalité. Des limites claires au niveau des sections sont plus importantes qu’un pourcentage plus élevé.
Le compromis lié à la latence
La quantification binaire suggère que le pipeline en deux étapes pourrait surpasser la recherche dense classique en termes de vitesse. Ce n’est pas le cas. Lors de plusieurs essais répétés, la recherche en deux étapes s’est avérée deux à quatre fois plus lente, en fonction de la machine et du niveau d’utilisation de la mémoire cache : 39,5 ms contre 20,4 ms. La raison est simple. La recherche purement dense ne nécessite qu’une seule recherche d’embedding, tandis que la version en deux étapes exécute les deux encodeurs de requête, le dense et ColBERT, et effectue une matrice de comparaison des tokens au sein de la base de données. Un essai séparant uniquement la recherche dense a donné en moyenne 7,4 ms.
Une augmentation de la limite de préchargement, de 10 candidats à 500, n’a pratiquement rien changé, la latence restant dans la même fourchette. C’est la structure en deux étapes elle-même, et non la taille de la fenêtre de préchargement, qui est responsable du temps supplémentaire.
En gros, la latence globale des systèmes RAG est principalement déterminée par la génération par le LLM, et non par les requêtes de base de données. En économisant plus de 400 tokens par requête, on réduit le traitement et la génération suivants de plusieurs centaines de millisecondes, ce qui rend l’application dans son ensemble nettement plus rapide.
La valeur des économies réalisées
Avec un coût de 3,00 $ par million de tokens d’entrée, une réduction de 67,1 % des tokens de prompt représente une économie significative. Pour une opération de recherche juridique comprenant 500 000 requêtes, cela correspond à plus de 600 $ d’économies par audit de dossier. De plus, des prompts plus courts diminuent l’effet « perdu au milieu », où le modèle néglige des textes pertinents enfouis dans un contexte long.
Ce qu’il faut vérifier avant de l’adopter
Trois mesures permettent de déterminer si la conception convient à une charge de travail donnée. La première concerne la justesse des réponses sur un ensemble de requêtes vérifiées manuellement, car l’économie de tokens a peu d’importance si la clause appropriée fait défaut. La deuxième est la latence de toute la requête, y compris la génération, car le temps supplémentaire passé dans la base de données est négligeable par rapport au temps de traitement du modèle. La troisième est le nombre de tokens par prompt avant et après l’isolation des phrases, mesuré selon le tarif en vigueur. Les résultats obtenus pour un type de document ne sont pas nécessairement transférables à un autre, il convient donc de répéter le test sur le corpus cible.
Leçons pour la production
- Privilégiez le réclassement directement au sein de la base de données. Les API de réclassement externes ajoutent des points de défaillance ainsi que des allers-retours réseau, tandis qu’un opérateur MAX_SIM natif gère la tâche en une seule opération.
- Désactivez HNSW pour les champs destinés uniquement à la réévaluation des scores. En définissant m=0 dans les configurations multi-vectorielles, on évite une étape d’indexation coûteuse qui ne procure aucun avantage pour la récupération des matrices ColBERT.
- Faites preuve de prudence avec des blocs très grands. Des tailles excessives peuvent donner l’impression d’une réduction impressionnante du nombre de tokens sur papier, mais elles affaiblissent la représentation sémantique et nuisent à la précision.
- Utilisez un tokeniseur de phrases réel. Une expression régulière brute convient pour une démonstration, mais le code en production devrait utiliser une bibliothèque comme NLTK ou spaCy capable de gérer des abréviations juridiques telles que Inc., Sec. et Exhibit A. sans séparer incorrectement les phrases.
La comparaison multi-vectorielle native associée à une évaluation locale au niveau des phrases permet de créer un processus précis et économique qui restitue la clause recherchée par l’utilisateur sans augmenter inutilement la facture mensuelle de l’API.
Lectures complémentaires
- RAG avec LangChain de zéro : chargeurs, morceaux de texte, embeddings, évaluation — Construire pas à pas un système de questions-réponses pour des documents : chargement des fichiers, segmentation en morceaux, embeddings, phases d’indexation et de récupération, ainsi que la manière d’évaluer la qualité des réponses obtenues.
- Claude Agent SDK contre OpenAI Agents SDK contre Google ADK : un même agent, trois façons de le créer — Le même agent pour les étiquettes nutritionnelles développé avec Claude Agent SDK, OpenAI Agents SDK et Google ADK, afin de voir clairement ce que chaque outil gère et ce que vous devez encore créer vous-même.