Accueil / Articles

Budgets de tokens contre limites sémantiques : comment effectuer correctement la segmentation des chunks dans RAG

Apprenez en quoi la tokenisation et le chunking diffèrent, quelle est leur place dans un pipeline RAG, ainsi que comment diviser les documents selon leur sens tout en mesurant leur taille à l’aide du tokeniseur réel.

Éditeur : REACTAPP.TOP11 min de lecture2515 mots

Sources: Chunking vs Tokenization: The Two Splits Every AI Engineer Keeps Confusing

Même si un pipeline RAG semble fonctionner correctement à chaque niveau visible, avec des embeddings raisonnables, des scores de similarité plausibles et un stockage vectoriel bien rempli, il peut tout de même fournir au modèle des fragments de texte interrompus en milieu de phrase. Dans de nombreux cas, le défaut réside dans le code d’ingestion, où une routine de segmentation est basée sur une hypothèse erronée concernant la manière dont le texte se transforme en tokens. Ce guide distingue clairement ces deux concepts, indique où chacun se situe dans le pipeline, et présente une méthode de segmentation qui privilégie d’abord le sens tout en maintenant un contrôle strict sur les budgets de tokens.

Les ingénieurs disent fréquemment des choses comme « diviser en 512 tokens » ou « segmentation basée sur les tokens », et cette formulation fusionne deux opérations distinctes en une seule. Elles résolvent des problèmes différents, s’exécutent à des étapes différentes et échouent de manières variées. Connaître précisément où l’une s’arrête et l’autre commence permet de comprendre un système de récupération plutôt que de modifier des paramètres jusqu’à ce qu’un score d’évaluation s’améliore.

Deux opérations, deux responsables

Les définitions les plus courtes et utiles sont les suivantes :

  • Tokenisation transforme le texte en unités entières discrètes que le modèle consomme réellement. Elle relève du modèle.
  • Chunking divise les documents en parties autonomes qui peuvent être intégrées, indexées et récupérées. Elle fait partie de votre pipeline et de vos données.

La tokenisation s’exécute chaque fois qu’une chaîne de caractères atteint un modèle, que vous y pensiez ou non. Le chunking est une décision que vous prenez à l’avance concernant la manière de structurer un corpus avant même que le modèle ne le voie. La première est fixe et pour la plupart invisible ; la seconde est quelque chose que vous concevez, mesurez et affinez au fil des semaines.

Ces deux processus interagissent constamment. Un séparateur qui ignore le comportement du tokeniseur produira silencieusement des chunks de mauvaise qualité. Néanmoins, il s’agit de étapes distinctes, et les considérer comme identiques est à l’origine d’un nombre surprenant de bugs subtils liés à la récupération des données.

Comment un modèle lit du texte

Aucun LLM, que ce soit GPT-4o, Claude, Llama ou un autre, utilisé en production ne lit des mots. Il lit plutôt une séquence d’entiers, chacun faisant référence à un vocabulaire fixe appris pendant l’entraînement. Le tokeniseur est l’algorithme déterministe qui convertit le texte brut en cette séquence. Il est choisi une fois, lors de l’entraînement du modèle, et fourni en tant que partie des artefacts du modèle. Il n’est pas possible de le remplacer à chaque demande.

Vocabulaires submots : BPE, WordPiece, SentencePiece

La plupart des modèles actuels reposent sur la tokenisation submots, généralement issue des familles BPE, WordPiece ou SentencePiece. Le principe commun consiste à partir de caractères ou de bytes bruts et à fusionner répétitivement les paires voisines les plus fréquentes pour créer de nouvelles entrées dans le vocabulaire, jusqu’à atteindre une taille cible de vocabulaire, généralement comprise entre 32 k et 128 k d’entrées ou plus.

Cela signifie en pratique au quotidien :

  • Les mots anglais fréquents tels que the, is ou model correspondent généralement à un token chacun.
  • Les mots moins fréquents se divisent en sous-mots, de sorte que la tokenisation peut se transformer en token suivi de ization.
  • Les chaînes inhabituelles, y compris les identifiants de code, les écritures non latines, les emojis et les noms de produits inventés, peuvent donner naissance à de nombreux tokens par mot, voire parfois presque un token par caractère.

C’est pourquoi une phrase en hindi ou en tamoul peut nécessiter deux à trois fois plus de tokens qu’une phrase en anglais ayant le même sens, et pourquoi un identifiant comme xgboost_lstm_ensemble_v2 peut consommer environ huit tokens alors que l’on en prévoyait deux. Les ratios exacts dépendent du tokenizer, et les vocabulaires plus récents ont réduit l’écart pour certaines langues ; il convient donc d’utiliser le modèle spécifique que l’on emploie pour mesurer. Pour tout produit destiné aux marchés non anglophones, par exemple une couche LLM qui résume les documents des bourses indiennes ou les journaux d’ordres des courtiers, il s’agit là d’un coût réel et d’un élément à prendre en compte dans le budget, et non d’une simple curiosité.

Pourquoi le nombre de tokens compte au-delà du coût

Le nombre de tokens a un impact qui dépasse simplement la détermination du prix d’une requête. Il influence trois aspects :

  1. Quelle partie de la fenêtre de contexte vous utilisez. Une fenêtre de 128 k est mesurée en tokens, et non en mots ou en caractères. Un bloc de 1 000 mots n’a pas de coût fixe ; son nombre de tokens varie en fonction du vocabulaire, de la langue et du type de contenu, car le code, la prose et les chiffres se tokenisent de manière très différente.
  2. Sur quoi porte l’attention. Les Transformers prennent en compte les positions des tokens, de sorte que la manière dont un texte est divisé en tokens influence le degré de cohérence avec lequel le modèle peut relier ses différentes parties. Des divisions problématiques qui entravent la compréhension sont rares, mais certaines encodages peuvent les provoquer.
  3. Où a lieu la troncature. Lorsque l’entrée dépasse les limites de la fenêtre, elle est coupée au niveau des tokens, et non à la frontière d’une phrase ou d’un bloc, sauf si vous gérez cela vous-même. Une troncature négligente peut laisser un mot inachevé.

Examinez vos propres chaînes de caractères une fois

Si vous n’avez jamais fait passer vos chaînes de production réelles par le tiktoken d’OpenAI ou le tokenizer d’Anthropic, il vaut la peine de le faire au moins une fois. Voir votre propre modèle de prompt décomposé en fragments inattendus transforme une abstraction en quelque chose de concret. La syntaxe Markdown, les accolades JSON et les balises de code sont particulièrement problématiques : ils tokenisent moins efficacement que le texte ordinaire.

Le fragment ci-dessous charge l’encodage cl100k_base et compte les tokens dans une commande de shell typique. Remarquez comment les flags et les extensions de fichiers occupent chacun généralement deux ou trois tokens, ce qui est précisément le type de contenu qui fait augmenter le budget sans que personne ne s’en aperçoive.

import tiktoken

enc = tiktoken.get_encoding("cl100k_base")
tokens = enc.encode("pip-compile requirements.in --output-file requirements.txt")
print(len(tokens), tokens)
# CLI flags and file extensions often split into 2-3 tokens each —
# this is exactly the kind of string that silently inflates your token budget

Comment déterminer ce que le modèle peut voir

Le découpage s’effectue à un niveau supérieur, au sein du pipeline plutôt que dans le modèle. Il prend un document volumineux, tel qu’un PDF, une page Confluence, un dépôt de code ou un circulaire réglementaire de 40 pages, et le divise en unités plus petites qui peuvent être intégrées, indexées et récupérées de manière indépendante. Le chunk constitue la monnaie de l’étage de récupération.

Deux contraintes rendent le découpage nécessaire, et aucune d’elles ne concerne réellement la tokenisation :

  1. Les embeddings longs deviennent flous. Si l’on entasse trop d’idées non liées dans un seul vecteur, celui-ci devient une moyenne de toutes ces idées, ne correspondant à rien avec précision. Un bon chunk contient une idée cohérente, et non tout un document.
  • Le contexte reste fini et coûteux. Même avec des fenêtres de 128 k tokens ou plus, désormais courantes, intégrer toute la base de connaissances dans chaque requête ne constitue pas une récupération d’informations. Il s’agit d’une imitation médiocre de l’inférence à contexte complet, entraînant plus de latence, plus de coûts et une précision moindre par rapport à une véritable étape de récupération.
  • Stratégies de segmentation et points faibles de chacune

    Fenêtres de taille fixe

    La méthode la plus simple consiste à diviser le texte tous les N tokens ou caractères, généralement avec une certaine superposition entre les segments voisins. La plupart des équipes commencent par là car cela ne nécessite que quelques lignes de code. La fonction ci-dessous encode tout le texte, divise la liste des tokens en fenêtres de taille chunk_size, décode chaque fenêtre pour la transformer en texte, puis avance de chunk_size - overlap afin que les segments consécutifs partagent une marge.

    def fixed_chunk(text, chunk_size=512, overlap=50):
        tokens = enc.encode(text)
        chunks = []
        start = 0
        while start < len(tokens):
            end = start + chunk_size
            chunk_tokens = tokens[start:end]
            chunks.append(enc.decode(chunk_tokens))
            start += chunk_size - overlap
        return chunks
    

    La faiblesse réside dans le fait que cette fonction ne prend pas en compte la notion de phrase. Elle place une limite au milieu d’une proposition, séparant ainsi une affirmation de l’élément qui la soutient, ou un exemple de code de la phrase qui le décrit. Ces fragments de phrases incomplètes sont intégrés dans des vecteurs situés entre les sujets, ce qui entraîne une mauvaise correspondance, ce qui est précisément le symptôme décrit au début. Deux autres points à noter : overlap doit rester inférieur à chunk_size, sinon la boucle ne progressera jamais, et la décodage d’un segment de token arbitraire peut diviser un caractère multibyte en deux, ce qui génère un caractère de remplacement au bord d’un chunk.

    Découpage récursif selon des limites naturelles

    RecursiveCharacterTextSplitter de LangChain ainsi que les analyseurs de nœuds dans LlamaIndex suivent ce modèle. Pour des corpus riches en prose, c’est un paramètre par défaut bien supérieur, car la structure propre du document est respectée avant toute considération liée à la limite de taille.

    Chunking sémantique

    Chunking tenant compte de la structure

    Le contenu doté d’une structure intégrée, telle que le Markdown avec des en-têtes, les fichiers sources, les spécifications API ou la documentation de schéma, doit être divisé en fonction de cette structure. Dans le code, la définition d’une fonction constitue une frontière évidente. Dans une base de connaissances Markdown, une section ## en est une autre. Pour quelque chose comme le README d’un système multi-agents, diviser le texte selon les en-têtes de section permet de conserver bien plus de sens facilement consultable que ne le ferait un simple comptage de tokens. Une analyse plus approfondie sur la manière de regrouper les preuves d’appui se trouve dans chunking that preserves evidence.

    Chunking piloté par les LLM

    Au niveau le plus coûteux, on peut demander à un LLM de lire le document et de proposer lui-même les limites des segments, en visant à obtenir des parties autonomes capables de répondre à une question pertinente. Cette méthode est lente et coûteuse, mais elle s’avère utile pour des corpus de grande valeur tels que les documents réglementaires ou les contrats, où une erreur de récupération a des conséquences réelles.

    Où les deux approches entrent en conflit

    Le point intéressant réside au point de rencontre de ces concepts, et on peut l’exprimer en une phrase : la taille des segments est généralement exprimée en tokens, mais les limites de ces segments doivent suivre le sens du texte, et ces deux objectifs s’opposent mutuellement.

    En se limitant strictement au seuil de 512 tokens avec chunk_size=512, on respecte le budget alloué mais au détriment du sens. Diviser uniquement aux points de rupture de paragraphe sans limite maximale respecte le sens, mais on ignore le fait qu’un paragraphe puisse compter 40 tokens tandis que l’autre en compte 4 000. Ce dernier peut dépasser la longueur maximale de séquence autorisée par le modèle d’incorporation, ce qui rend l’équilibre de l’information contenue dans chaque morceau extrêmement inégal.

    Un diviseur fiable gère donc ces deux problèmes dans un ordre fixe :

    1. D’abord, identifier les frontières sémantiques : sections, paragraphes, phrases, blocs de code.
    2. Combiner les petites unités voisines tant que l’ajout de la suivante ne dépasse pas le budget de tokens.
  • Divisez une seule unité en parties séparées uniquement lorsque sa taille dépasse à elle seule le budget alloué, et dans ce cas, tokenisez-la vous-même afin de décider où effectuer la coupure, plutôt que de compter sur une estimation basée sur le nombre de caractères qui ne tient pas compte des limites entre tokens.
  • La fonction ci-dessous met en œuvre cette logique. Elle attend des sections qui ont déjà été divisées en paragraphes ou en titres. Pour chacune d’elles, elle mesure la véritable longueur du token ; si son ajout provoquerait un dépassement de la partie actuelle, les sections accumulées sont envoyées en tant que nouvelle partie. Une section qui dépasse seule la valeur de max_tokens est divisée en fonction des limites entre tokens ; tout le reste est ajouté à la partie en cours. Ce qui reste à la fin constitue la partie finale.

    def chunk_by_tokens_with_semantic_bias(sections, max_tokens=512):
        chunks, current, current_len = [], [], 0
        for section in sections:  # pre-split by paragraph/header
            section_len = len(enc.encode(section))
            if current_len + section_len > max_tokens and current:
                chunks.append("\n".join(current))
                current, current_len = [], 0
            if section_len > max_tokens:
                # single section too big — hard split on token boundaries, not chars
                section_tokens = enc.encode(section)
                for i in range(0, len(section_tokens), max_tokens):
                    chunks.append(enc.decode(section_tokens[i:i+max_tokens]))
            else:
                current.append(section)
                current_len += section_len
        if current:
            chunks.append("\n".join(current))
        return chunks
    

    La décision de conception clé consiste à mesurer la taille à l’aide du tokenizer du modèle d’incorporation cible, et non en se basant sur le nombre de caractères, et à ce que toute séparation forcée ne puisse avoir lieu qu’à une frontière de token contrôlée par le code. Une amélioration à considérer : la saut de ligne utilisé dans "\n".join ajoute un token ou deux par section qui n’est pas pris en compte dans le comptage en cours, il convient donc de laisser un peu d’espace supplémentaire en dessous de la limite stricte du modèle.

    Position de chaque étape dans le pipeline

    D’un point de vue spatial, le chunking relève d’une étape préalable. Il a lieu lors de l’ingestion, avant même que tout modèle ne soit impliqué, et son résultat est stocké. La tokenisation, quant à elle, relève d’une étape ultérieure. Elle a lieu dans la couche d’entrée du modèle à chaque requête, y compris lors des appels d’embedding effectués pendant l’ingestion et des appels de génération au moment de la requête. Garder cela à l’esprit vous indiquera quel code ouvrir en cas de problème.

    Distinguer les deux modes de défaillance

    Lorsque vous analysez un mauvais résultat de recherche, deux symptômes peuvent sembler similaires mais renvoient à des étapes différentes :

    • Le chunk récupéré est tronqué ou se termine au milieu d’une pensée. Il s’agit d’une erreur de chunking. La logique de délimitation a divisé une unité sémantique, et la correction doit être apportée dans le module de séparation des chunks, et non dans le choix du modèle d’embedding.
  • Le fragment semble complet, mais la réponse en ignore la moitié, ou l’instruction est inopinément tronquée. Il s’agit d’une erreur de tokenisation et de gestion du budget contextuel. Le comptage des tokens est incorrect quelque part, souvent parce que les tokens ont été estimés avec len(text) / 4 alors que le nombre réel est plus élevé.
  • Ces problèmes sont fréquemment imputés à un "modèle d’embedding défectueux". Ce n’est que rarement la cause réelle ; c’est presque toujours une logique de délimitation ou un problème de comptage des tokens.

    Liste de contrôle pour la production

    Lorsque vous développez ou examinez un pipeline RAG ou d’agent, vérifiez les points suivants :

    • Comptez les tokens à l’aide du tokenizer réel du modèle que vous utilisez. Utilisez tiktoken pour les modèles OpenAI et le tokenizer spécifique au modèle pour les modèles open-source. Ne jamais utiliser len(text) // 4 comme méthode de comptage en production.
    • Mesurez la taille des blocs en fonction des tokens du modèle d’incorporation. Les modèles d’incorporation utilisent souvent un tokeniseur différent de celui du modèle de génération et imposent une longueur maximale de séquence plus stricte.
    • Prévoyez explicitement le chevauchement. Le chevauchement consomme une partie de la fenêtre effective, il faut donc l’inclure dans les calculs relatifs à la limite d’entrée du modèle d’incorporation, plutôt que de l’apprendre à partir d’une alerte de troncature.
    • Mettez la structure avant la taille. Préférez une segmentation consciente de la structure ou récursive, et réservez des fenêtres de taille fixe aux textes véritablement non structurés.
    • Enregistrez le nombre de tokens et les décisions relatives aux limites lors de l’ingestion. Lorsque la qualité diminue, vous voulez savoir immédiatement si un bloc a été coupé en raison de sa taille ou de sa structure.
    • Re-tokeniser après avoir changé de modèle d’incorporation. Une stratégie ajustée pour un vocabulaire donné ne se transpose pas facilement ; 512 tokens couvrent une quantité de texte différente avec un tokeniseur différent.

    En résumé

    La tokenisation répond à la question du modèle sur la manière de traiter une chaîne de caractères. Le découpage en blocs répond à la question de savoir quelle est la plus petite unité autonome digne d’être récupérée. La première est déterminée dès que vous choisissez un modèle ; la seconde relève entièrement de votre contrôle en tant que concepteur, et c’est généralement là que réside le véritable avantage technique dans un système RAG.

    Ces deux types d’erreurs se manifestent également différemment. Les erreurs de comptage des tokens apparaissent sous forme de troncations inhabituelles et de dépassements de budget, ce qui est au moins visible. Les erreurs de segmentation génèrent un système qui fonctionne sans aucun erreur mais qui récupère malgré tout la mauvaise moitié de la phrase correcte, ce qui est bien plus difficile à détecter. Mesurez avec le tokeniseur réel, divisez d’abord en fonction du sens, et notez pourquoi chaque limite a été placée là où elle l’a été.