Skip to main content
Les index de texte (également appelés index inversés) permettent d’effectuer rapidement des recherches en texte intégral dans des données textuelles. Un index de texte stocke une association entre les tokens et les numéros de ligne qui contiennent chaque token. Les tokens sont générés par un processus appelé tokenisation. Par exemple, le tokenizer par défaut de ClickHouse convertit la phrase anglaise “The cat likes mice.” en tokens [“The”, “cat”, “likes”, “mice”]. Par exemple, supposons une table avec une seule colonne et trois lignes
Les tokens correspondants sont :
En général, nous préférons effectuer des recherches sans distinction entre majuscules et minuscules, c’est pourquoi nous mettons les tokens en minuscules :
Nous supprimerons également les mots vides tels que “I”, “the” et “and”, car ils apparaissent dans presque toutes les lignes :
Un index de texte contient alors (en théorie) les informations suivantes :
À partir d’un token de recherche, cette structure d’index permet de retrouver rapidement toutes les lignes correspondantes.

Création d’un index de texte

Les index de texte sont disponibles de façon générale (GA) à partir de ClickHouse version 26.2. Dans ces versions, aucun paramètre particulier n’est nécessaire pour utiliser l’index de texte. Nous recommandons vivement d’utiliser ClickHouse version 26.2 ou ultérieure pour les cas d’usage en production.
Les index de texte peuvent être utilisés avec n’importe quelle version de ClickHouse >= 26.2, quel que soit le paramètre de compatibilité.
Pour créer un index de texte, utilisez la syntaxe suivante :
Query
Les index de texte peuvent être définis sur des colonnes des types suivants : Les colonnes de type Nullable(T) et LowCardinality() sont également prises en charge, y compris Array(Nullable(String or FixedString)). Autrement, pour ajouter un index de texte à une table existante :
Query
Si vous ajoutez un index à une table existante, nous vous recommandons de matérialiser l’index pour les parts de la table existantes (sinon, la recherche sur les parts sans index reviendra à des balayages exhaustifs lents).
Query
Pour supprimer un index de texte, exécutez
Query
Argument du tokenizer (obligatoire). L’argument tokenizer précise le tokenizer :
  • splitByNonAlpha divise les chaînes selon les caractères ASCII non alphanumériques (voir la fonction splitByNonAlpha).
  • splitByString(S) divise les chaînes à l’aide de certaines chaînes séparatrices S définies par l’utilisateur (voir la fonction splitByString). Les séparateurs peuvent être spécifiés à l’aide d’un paramètre facultatif, par exemple tokenizer = splitByString([', ', '; ', '\n', '\\']). Notez que chaque chaîne peut être composée de plusieurs caractères (', ' dans l’exemple). La liste de séparateurs par défaut, si elle n’est pas explicitement spécifiée (par exemple tokenizer = splitByString), est un seul espace [' '].
  • asciiCJK divise les chaînes en tokens selon les règles de délimitation des mots Unicode (comme dans Unicode Text Segmentation (UAX #29)). Les caractères ASCII alphanumériques et les traits de soulignement forment des tokens avec des connecteurs (ASCII : pour les lettres, . et ' pour les caractères de même type). Les caractères Unicode non ASCII, y compris les caractères CJK, deviennent des tokens d’un seul caractère.
  • ngrams(N) divise les chaînes en N-grammes de taille identique (voir la fonction ngrams). La longueur des n-grammes peut être spécifiée à l’aide d’un paramètre entier facultatif compris entre 1 et 8, par exemple tokenizer = ngrams(3). La taille des n-grammes par défaut, si elle n’est pas explicitement spécifiée (par exemple tokenizer = ngrams), est de 3.
  • sparseGrams(min_length, max_length, min_cutoff_length) divise les chaînes en n-grammes de longueur variable d’au moins min_length et d’au plus max_length caractères (bornes incluses) (voir la fonction sparseGrams). Sauf indication explicite, min_length et max_length valent par défaut 3 et 100. Si le paramètre min_cutoff_length est fourni, seuls les n-grammes dont la longueur est supérieure ou égale à min_cutoff_length sont renvoyés. Comparé à ngrams(N), le tokenizer sparseGrams produit des N-grammes de longueur variable, ce qui permet une représentation plus souple du texte d’origine. Par exemple, tokenizer = sparseGrams(3, 5, 4) génère en interne des 3-, 4- et 5-grammes à partir de la chaîne d’entrée, mais seuls les 4- et 5-grammes sont renvoyés.
  • array ne réalise aucune tokenisation, c.-à-d. que chaque valeur de ligne constitue un token (voir la fonction array).
Tous les tokenizers disponibles sont répertoriés dans system.tokenizers.
Le tokenizer splitByString applique les séparateurs de découpage de gauche à droite. Cela peut créer des ambiguïtés. Par exemple, les chaînes séparatrices ['%21', '%'] feront que %21abc sera tokenisé en ['abc'], tandis qu’en inversant l’ordre des deux chaînes séparatrices ['%', '%21'], on obtiendra ['21abc']. Dans la plupart des cas, vous souhaiterez que la correspondance privilégie d’abord les séparateurs les plus longs. Cela peut généralement se faire en passant les chaînes séparatrices par ordre décroissant de longueur. Si les chaînes séparatrices forment un prefix code, elles peuvent être passées dans un ordre arbitraire.
Pour comprendre comment un tokenizer découpe la chaîne d’entrée, vous pouvez utiliser les fonctions tokens et tokensForLikePattern : Exemple :
Query
Response
Utilisation de données d’entrée non ASCII. Les index de texte peuvent être créés à partir de données textuelles dans n’importe quelle langue et avec n’importe quel jeu de caractères. Pour le texte non ASCII, le tokenizer asciiCJK est recommandé, car il gère correctement les limites de mots Unicode, y compris pour les caractères CJK. Argument de préprocesseur (facultatif). Le préprocesseur correspond à une expression appliquée à la chaîne d’entrée avant la tokenisation. Les cas d’usage typiques de l’argument de préprocesseur incluent
  1. Conversion en minuscules/majuscules, ou normalisation de la casse pour permettre une correspondance insensible à la casse, par ex., lower, lowerUTF8, caseFoldUTF8.
  2. Normalisation UTF-8, par ex. normalizeUTF8NFC, normalizeUTF8NFD, normalizeUTF8NFKC, normalizeUTF8NFKD, normalizeUTF8NFKCCasefold, toValidUTF8.
  3. Suppression ou transformation de caractères ou de sous-chaînes indésirables, comme les accents, par ex. extractTextFromHTML, substring, idnaEncode, translate, removeDiacriticsUTF8.
L’expression de préprocesseur doit transformer une valeur d’entrée de type String ou FixedString en une valeur du même type. Si l’index de texte a été construit sur une colonne de type Nullable(T) ou LowCardinality(T), alors l’expression de préprocesseur doit accepter des valeurs nullables ou à faible cardinalité (c.-à-d. ne pas lever d’exception). Exemples :
  • INDEX idx col TYPE text(tokenizer = 'splitByNonAlpha', preprocessor = lower(col))
  • INDEX idx col TYPE text(tokenizer = 'splitByNonAlpha', preprocessor = substringIndex(col, '\n', 1))
  • INDEX idx col TYPE text(tokenizer = 'splitByNonAlpha', preprocessor = lower(extractTextFromHTML(col)))
  • INDEX idx col TYPE text(tokenizer = 'splitByNonAlpha', preprocessor = removeDiacriticsUTF8(caseFoldUTF8(col)))
De plus, l’expression de préprocesseur doit uniquement référencer la colonne ou l’expression sur laquelle l’index de texte est défini. Exemples :
  • INDEX idx lower(col) TYPE text(tokenizer = 'splitByNonAlpha', preprocessor = upper(lower(col)))
  • INDEX idx lower(col) TYPE text(tokenizer = 'splitByNonAlpha', preprocessor = concat(lower(col), lower(col)))
  • Non autorisé : INDEX idx lower(col) TYPE text(tokenizer = 'splitByNonAlpha', preprocessor = concat(col, col))
L’usage de fonctions non déterministes est interdit.
Les préprocesseurs sont en principe équivalents à l’encapsulation de la colonne ou de l’expression indexée dans l’expression de préprocesseur. Par exemple, le préprocesseur lower dans INDEX idx col TYPE text(tokenizer = 'splitByNonAlpha', preprocessor = lower(col)) peut être émulé par INDEX idx lower(col) TYPE text(tokenizer = 'splitByNonAlpha'). Cette dernière forme présente l’inconvénient que le préprocesseur émulé n’est appliqué que s’il correspond à la condition de filtrage dans la clause WHERE. Par exemple, WHERE hasAllTokens(lower(col), [...]) correspond, tandis que WHERE hasAllTokens(col, [...]) ne correspond pas. Pour une expérience utilisateur optimale, nous recommandons donc d’utiliser des expressions de préprocesseur.
Les fonctions hasToken, hasAllTokens, hasAnyTokens et hasPhrase utilisent le préprocesseur pour d’abord transformer le terme de recherche avant de le découper en tokens. Notez que, comme le préprocesseur n’est appliqué que sur le chemin de l’index de texte, les résultats de ces fonctions peuvent différer entre les requêtes qui utilisent l’index de texte et celles qui ne l’utilisent pas (par ex. SETTINGS use_skip_indexes = 0). Par exemple,
Query
est équivalent à :
Query
Dans ce cas, l’expression du préprocesseur transforme individuellement les éléments du tableau. Exemple :
Query
Pour définir un préprocesseur dans un index de texte sur des colonnes de type Map à la création, les utilisateurs doivent déterminer si l’index est créé sur les clés ou sur les valeurs du type Map. Exemple :
Query
Argument postprocessor (facultatif). Le postprocesseur désigne une expression appliquée à chaque token de sortie après la tokenisation. Contrairement au préprocesseur, qui transforme l’intégralité de la chaîne d’entrée avant que le tokenizer ne la découpe en tokens, le postprocesseur agit directement sur les tokens, un par un. C’est l’endroit idéal pour les transformations qui s’appliquent intrinsèquement au niveau du token. Les cas d’usage typiques de l’argument postprocessor incluent :
  1. Filtrage des stop words (tokens extrêmement fréquents). Les tokens très courants tels que “the”, “a” et “is” ont peu de pertinence pour la recherche et alourdissent l’index. Vous pouvez utiliser le postprocesseur pour les éliminer en les convertissant en tokens vides — les tokens vides sont ignorés, c’est-à-dire qu’ils ne sont pas ajoutés à l’index. Exemple : if(str IN ('the', 'a', 'an', 'of', 'in', 'is', 'it'), '', str)
  2. Suppression des timestamps. Les lignes de log commencent souvent par un timestamp structuré tel que 2024-01-15T10:23:45, ou en contiennent un. L’indexation des tokens de timestamp gonfle l’index avec des chaînes qui n’ont aucune pertinence pour la recherche. Il existe deux approches complémentaires pour ignorer les timestamps :
    • Approche avec postprocesseur : utilisez le tokenizer splitByString (découpage par espaces) afin que le timestamp entier devienne un seul token, puis utilisez parseDateTimeOrNull pour le détecter et le supprimer. Exemple : if(isNull(parseDateTimeOrNull(str, '%Y-%m-%dT%H:%i:%S')), str, '') Pour les timestamps avec décalages de fuseau horaire ou secondes fractionnaires, utilisez parseDateTimeBestEffortOrNull(str) sans chaîne de format explicite.
    • Approche avec préprocesseur : retirez le timestamp de la ligne de log complète avant la tokenisation à l’aide d’une regular expression. Exemple : replaceRegexpAll(str, '^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2} ', '') Cela fonctionne avec n’importe quel tokenizer et est plus efficace, car les caractères du timestamp ne sont jamais tokenized. Les deux approches peuvent être combinées : le préprocesseur retire le timestamp tandis que le postprocesseur normalise ou filtre les tokens restants (par exemple, passage en minuscules + suppression des mots de sévérité comme ERROR ou INFO).
  3. Racinisation. Ramener chaque token à sa racine améliore le rappel de recherche en faisant correspondre des variantes morphologiques qui partagent la même racine. Par exemple, avec la racinisation anglaise, “running”, “runs” et “run” sont tous ramenés à “run”, de sorte qu’une query sur l’une de ces variantes correspond à toutes. ClickHouse fournit une fonction stem intégrée pour plusieurs langues. Exemple : stem(str, 'en')
  4. Normalisation de la casse. Conversion des tokens en minuscules ou en majuscules pour permettre une correspondance insensible à la casse, par exemple lower, lowerUTF8. Pour la conversion en minuscules et en majuscules, nous recommandons un préprocesseur plutôt qu’un postprocesseur.
L’expression de posttraitement transforme des tokens de type String en tokens du même type. De plus, l’expression de posttraitement ne doit référencer que la colonne ou l’expression sur laquelle le text index est défini. Lorsque la colonne est de type Array(String), le postprocesseur continue d’opérer sur les tokens individuels en tant que valeurs String simples. L’utilisation de fonctions non déterministes est interdite. Le postprocesseur est appliqué à chaque token généré lors de la construction de l’index (pour le tokenizer array, chaque élément du tableau est un token). Au moment de l’exécution de la requête, le comportement dépend de la fonction :
  • Pour hasToken, hasAllTokens, hasAnyTokens et hasPhrase (avec n’importe quel tokenizer pris en charge) : le postprocesseur est appliqué à la fois aux tokens du haystack et au needle de recherche, ce qui permet une correspondance entièrement normalisée (par ex. une recherche insensible à la casse). Pour hasPhrase, les tokens posttraités sont positionnés de façon compacte : ainsi, si le postprocesseur supprime un token, cela ne crée aucun écart de position et l’expression continue de correspondre malgré tout — par ex., avec un postprocesseur de stop words qui supprime the, hasPhrase(col, 'see cat') correspond à un document see the cat.
  • Pour toutes les autres fonctions (=, IN, has, hasAny, hasAll, mapContains*) : seul le needle de recherche est posttraité pour la recherche avec indication d’index ; le prédicat au niveau des lignes continue, lui, de comparer les valeurs d’origine de la colonne.
Exemples :
  • Supprimez les stop words à l’aide d’une expression de posttraitement :
  • Supprimez les horodatages à l’aide d’une expression de post-traitement :
  • Supprimez les horodatages à l’aide d’une expression de prétraitement :
  • Supprimez les horodatages au moyen d’une expression combinant un préprocesseur et un postprocesseur :
  • Réduisez les tokens à leur racine à l’aide d’une expression de post-traitement :
Prise en charge des fonctions. Pour les prédicats qui consultent l’index de texte, le préprocesseur et le postprocesseur sont appliqués à la valeur recherchée avant la vérification au niveau du granule, afin que la recherche dans l’index utilise les mêmes tokens que ceux stockés lors de la création de l’index. Pour la plupart des fonctions (=, IN, startsWith, endsWith, LIKE, mapContains*), l’index de texte sert uniquement à ignorer les blocs de données non pertinents ; ClickHouse vérifie ensuite chaque ligne conservée à l’aide du prédicat d’origine sur les données de la colonne d’origine. Pour les fonctions de recherche de tokens (hasToken, hasAllTokens, hasAnyTokens), l’index de texte constitue le principal mécanisme d’évaluation : ClickHouse normalise le needle à l’aide du même préprocesseur, tokenizer et postprocesseur que ceux appliqués lors de la création de l’index, puis utilise cette forme normalisée aussi bien pour les parts de la table indexées que non indexées. En présence d’un postprocesseur, les tokens du haystack sont également normalisés au moment de la requête (pour n’importe quel tokenizer, et pas seulement array), de sorte que les deux côtés de la comparaison sont transformés de manière cohérente et que le résultat ne dépend ni d’une lecture directe de l’index (paramètre query_plan_direct_read_from_text_index), ni du fait qu’une part donnée dispose d’un index matérialisé — par exemple, pour activer une correspondance insensible à la casse pour hasAllTokens(col, ['FOO']) avec un postprocesseur lower. Sans support_phrase_search, hasPhrase utilise l’index uniquement comme indication et vérifie chaque ligne conservée à l’aide du prédicat d’origine ; un postprocesseur normalise en outre la phrase et les tokens du haystack de la même manière, afin que le résultat soit indépendant du chemin de lecture, et les tokens supprimés par le postprocesseur ne rompent pas l’adjacence de la phrase. Avec support_phrase_search = 1, hasPhrase utilise des lectures directes exactes (tout en appliquant le postprocesseur, le cas échéant). Les tokens de recherche que le postprocesseur transforme en chaîne vide sont ignorés, c’est-à-dire traités comme absents de la phrase de recherche. ¹ LIKE et match utilisent la lecture directe comme indice pour les tokenizers listés ; sinon, ils reviennent à un balayage exhaustif. LIKE prend en outre en charge une lecture directe (sans indice) (activée via use_text_index_like_evaluation_by_dictionary_scan) pour les tokenizers splitByNonAlpha et array, sans préprocesseur ni postprocesseur. ² ILIKE est uniquement pris en charge via la lecture directe (sans indice) (use_text_index_like_evaluation_by_dictionary_scan = 1, tokenizer splitByNonAlpha ou array). Il n’y a pas de solution de repli consistant à utiliser l’index comme indice : si le paramètre est désactivé ou si le tokenizer ne fait pas partie de l’ensemble pris en charge, l’index n’est pas utilisé pour ILIKE. Le préprocesseur, s’il est présent, doit être lower ou upper ; les postprocesseurs ne sont pas pris en charge. Expérimental : argument de prise en charge de la recherche de phrase (facultatif). Le paramètre expérimental support_phrase_search (par défaut : 0) contrôle si l’index stocke les positions des tokens. Lorsqu’il est défini sur 1, l’index stocke également des données de position (dans un fichier .pos), ce qui permet une correspondance exacte des expressions via des lectures directes pour la fonction hasPhrase. Le stockage des positions augmente la taille de l’index sur disque et le coût d’écriture ; il est donc optionnel. Le format sur disque n’est pas encore stable ; ce paramètre est donc expérimental et pourrait changer dans une prochaine version. La création d’un index avec support_phrase_search = 1 nécessite donc que le paramètre MergeTree allow_experimental_text_index_phrase_search soit activé. Définissez support_phrase_search = 0 (la valeur par défaut) pour conserver un stockage reposant uniquement sur des posting lists ; les index de texte créés sans cet argument restent sans positions.
Cet argument est expérimental et ne doit être utilisé que pour des tests. Activez le paramètre MergeTree allow_experimental_text_index_phrase_search pour autoriser le stockage des positions.
Granularité de l’index. Les index de texte sont implémentés dans ClickHouse comme un type de skip indexes. Cependant, contrairement aux autres skip indexes, les index de texte utilisent une granularité infinie (100 millions). Cela est visible dans la définition de table d’un index de texte. Exemple :
Query
Response
La granularité d’index très élevée garantit que l’index de texte intégral est créé pour l’intégralité de la partie de données. Toute granularité d’index explicitement spécifiée est ignorée.

Utiliser un index de texte

L’utilisation d’un index de texte dans les requêtes SELECT est simple, car les fonctions courantes de recherche dans les chaînes exploitent automatiquement l’index. Si aucun index n’existe sur une colonne ou une part de la table, les fonctions de recherche dans les chaînes se rabattent sur de lents parcours exhaustifs.
Nous recommandons d’utiliser les fonctions hasAnyTokens et hasAllTokens pour interroger l’index de texte ; voir ci-dessous. Ces fonctions fonctionnent avec tous les tokenizers disponibles et toutes les expressions possibles de préprocesseur et de postprocesseur. Comme les autres fonctions prises en charge sont apparues avant l’index de texte, elles ont dû conserver leur comportement historique dans de nombreux cas (par exemple, sans prise en charge du préprocesseur ou du postprocesseur).

Fonctions prises en charge

L’index de texte intégral peut être utilisé lorsque des fonctions textuelles sont employées dans la clause WHERE ou les clauses PREWHERE :
= (equals) correspond à l’intégralité du terme de recherche donné. Exemple :

IN

IN (in) est similaire à equals, mais correspond à l’ensemble des termes de recherche. Exemple :
NOT IN (notIn) n’est pas pris en charge par l’index de texte.

LIKE et match

Ces fonctions utilisent actuellement l’index de texte pour le filtrage uniquement si le tokenizer de l’index est splitByNonAlpha, ngrams ou sparseGrams.
NOT LIKE (notLike) n’est pas pris en charge par l’index de texte.
Pour utiliser LIKE (like) et la fonction match avec des index de texte, ClickHouse doit pouvoir extraire des tokens complets à partir du terme recherché. Pour un index utilisant le tokenizer ngrams, c’est le cas si la longueur des chaînes recherchées entre les jokers est égale ou supérieure à la longueur du ngram. Exemple pour l’index de texte avec le tokenizer splitByNonAlpha :
support dans l’exemple pourrait correspondre à support, supports, supporting, etc. Ce type de requête est une recherche par sous-chaîne et ne peut pas être accéléré par un index de texte. Pour qu’un index de texte puisse être utilisé avec des requêtes LIKE, le motif LIKE doit être réécrit comme suit :
Les espaces à gauche et à droite de support garantissent que le terme peut être extrait en tant que token. Heureusement, il existe un cas particulier où ClickHouse peut exploiter l’index inversé pour accélérer considérablement les requêtes LIKE. Consultez la section sur l’optimisation des performances des requêtes LIKE/ILIKE pour plus de détails.

multiSearchAny and multiMatchAny

multiSearchAny et sa variante UTF-8 multiSearchAnyUTF8 vérifient si l’une de plusieurs sous-chaînes littérales est présente dans la chaîne à analyser, et multiMatchAny vérifie si l’une de plusieurs expressions régulières correspond. Ces fonctions utilisent l’index de texte intégral dans les mêmes conditions que LIKE et match (voir ci-dessus) : ClickHouse doit pouvoir extraire des tokens complets de chaque motif recherché, et la liste des motifs doit être constante. Une granule est lue si l’un des motifs peut y être présent. Pour multiMatchAny, si un seul motif ne peut pas être ramené à une contrainte sur les tokens (par exemple .*, qui correspond à n’importe quel document), l’index de texte intégral ne peut pas être utilisé et la requête bascule sur une analyse complète. Comme pour LIKE et match, la recherche par sous-chaîne et par expression régulière fonctionne mieux avec les tokenizers ngrams et sparseGrams. Ces tokenizers indexent des n-grams de caractères qui se chevauchent, de sorte qu’un motif recherché est décomposé en n-grams présents dans l’index partout où il apparaît comme sous-chaîne, qu’il commence ou se termine au milieu d’un mot ou non. Un motif recherché peut donc être utilisé tel quel, à condition qu’il soit au moins aussi long que la taille du n-gram. Example pour l’index de texte intégral avec le tokenizer ngrams :
Le tokenizer splitByNonAlpha, en revanche, n’indexe que des tokens complets (des mots entiers). Comme un motif peut commencer ou se terminer au milieu d’un mot, ClickHouse supprime les tokens de tête et de fin de chaque motif, de sorte que l’index ne puisse écarter des granules qu’en s’appuyant sur des tokens complets. Pour que la recherche par sous-chaîne et par expression régulière utilise l’index avec splitByNonAlpha, entourez chaque motif de caractères séparateurs (comme des espaces) afin qu’il forme un ou plusieurs tokens complets. Exemple d’index de texte avec le tokenizer splitByNonAlpha :

startsWith et endsWith

Comme LIKE, les fonctions startsWith et endsWith ne peuvent utiliser un index de texte que si des tokens complets peuvent être extraits du terme recherché. Pour un index avec le tokenizer ngrams, c’est le cas si la longueur des chaînes recherchées entre les wildcards est égale ou supérieure à celle du ngram. Lorsqu’un index de texte utilise un postprocesseur, ces fonctions peuvent toujours utiliser l’index en mode Hint si les tokens d’indice extraits restent non vides après normalisation. Si la normalisation supprime tous les tokens d’indice, l’index n’est pas utilisé pour ce prédicat. Exemple d’index de texte avec le tokenizer splitByNonAlpha :
Dans cet exemple, seul clickhouse est considéré comme un token. support n’est pas un token, car il peut correspondre à support, supports, supporting, etc. Pour trouver toutes les rows qui commencent par clickhouse supports, veuillez terminer le motif de recherche par un espace à la fin :
De même, endsWith doit être utilisé avec un espace au début :

hasToken

hasToken comporte certains pièges lorsqu’elle est utilisée pour des recherches dans des index de texte avec des tokenizers autres que splitByNonAlpha et/ou des expressions de prétraitement/post-traitement. Nous recommandons plutôt d’utiliser hasAnyTokens et hasAllTokens.Les variantes insensibles à la casse hasTokenCaseInsensitive et hasTokenCaseInsensitiveOrNull ne tiennent pas compte des index de texte — elles s’exécutent toujours comme une analyse complète de la table, même sur des colonnes indexées en texte. Pour une correspondance insensible à la casse, utilisez un préprocesseur ou postprocesseur lower(...) et combinez-le avec hasToken / hasAllTokens / hasAnyTokens.
La fonction hasToken effectue une correspondance avec un seul token donné. Contrairement aux fonctions mentionnées précédemment, elle ne tokenise pas le terme recherché (elle suppose que l’entrée correspond à un seul token). Exemple :

hasAnyTokens and hasAllTokens

Les fonctions hasAnyTokens et hasAllTokens établissent une correspondance avec un ou l’ensemble des tokens fournis. Ces deux fonctions acceptent les tokens de recherche soit sous forme de chaîne, qui sera découpée en tokens à l’aide du même tokenizer que celui utilisé pour la colonne d’index, soit sous forme de tableau de tokens déjà traités, auquel aucune tokenization ne sera appliquée avant la recherche. Consultez la documentation de la fonction pour en savoir plus. Exemple :

hasPhrase

La fonction hasPhrase vérifie la présence d’une expression : tous les tokens doivent apparaître de façon consécutive et dans le même ordre que dans la chaîne de recherche. Contrairement à hasAllTokens, qui exige seulement que tous les tokens soient présents quelque part, hasPhrase exige qu’ils apparaissent sous la forme d’une séquence consécutive. L’expression de recherche est tokenisée à l’aide du même tokenizer configuré pour la colonne indexée. Lorsque l’index de texte utilise un postprocesseur, l’expression de recherche est également normalisée avant la recherche dans l’index. Notez que la fonction nécessite l’un des tokenizers splitByNonAlpha, splitByString, ngrams ou asciiCJK. Exemple :

has

La fonction de tableau has permet de rechercher un seul token dans un tableau de chaînes. Exemple :

hasAny et hasAll

Les fonctions sur les tableaux hasAny et hasAll vérifient si la colonne de tableau indexée contient une partie ou la totalité d’un ensemble constant de chaînes recherchées. Exemple :

mapContains

La fonction mapContains (alias de mapContainsKey) fait correspondre aux clés d’une map les tokens extraits de la chaîne recherchée. Le comportement est similaire à celui de la fonction equals avec une colonne String. L’index textuel n’est utilisé que s’il a été créé sur une expression mapKeys(map). Exemple :

mapContainsValue

La fonction mapContainsValue établit une correspondance entre les tokens extraits de la chaîne recherchée et les valeurs d’une map. Le comportement est similaire à celui de la fonction equals sur une colonne String. L’index de texte n’est utilisé que s’il a été créé sur une expression mapValues(map). Exemple :

mapContainsKeyLike et mapContainsValueLike

Les fonctions mapContainsKeyLike et mapContainsValueLike appliquent un motif à toutes les clés ou à toutes les valeurs (respectivement) d’une map. Exemple :

operator[]

L’operator[] d’accès peut être utilisé avec l’index de texte pour filtrer les clés et les valeurs. L’index de texte n’est utilisé que s’il est créé sur les expressions mapKeys(map) ou mapValues(map), ou sur les deux. Exemple :
Voir les exemples suivants pour savoir comment utiliser des colonnes de type Array(T) et Map(K, V) avec l’index de texte intégral.

Indexation des colonnes Array(String)

Imaginez une plateforme de blog où les auteurs classent leurs articles à l’aide de mots-clés. Nous voulons que les utilisateurs puissent découvrir des contenus connexes en recherchant des thèmes ou en cliquant dessus. Considérez cette définition de table :
Sans index de texte, trouver des posts contenant un mot-clé donné (par ex. clickhouse) nécessite de parcourir toutes les lignes :
À mesure que la plateforme grandit, cela devient de plus en plus lent, car la requête doit examiner le tableau keywords de chaque ligne. Pour remédier à ce problème de performances, nous définissons un index de texte intégral pour la colonne keywords :

Indexation des colonnes de type Map

Dans de nombreux cas d’usage en observabilité, les messages de log sont découpés en “composants” et stockés dans les types de données appropriés, par exemple une date-heure pour le timestamp, un enum pour le niveau de log, etc. Les champs de métriques sont de préférence stockés sous forme de paires clé-valeur. Les équipes d’exploitation doivent pouvoir rechercher efficacement dans les logs à des fins de débogage, d’investigation d’incidents de sécurité et de supervision. Considérez cette table de logs :
Sans index de texte, la recherche dans des données Map nécessite de parcourir l’intégralité de la table :
À mesure que le volume de logs augmente, ces requêtes ralentissent. La solution consiste à créer un index de texte intégral sur les clés et les valeurs de Map. Utilisez mapKeys pour créer un index de texte intégral lorsque vous devez retrouver des logs à partir des noms de champ ou des types d’attribut :
Utilisez mapValues pour créer un index de texte intégral lorsque vous devez rechercher dans le contenu même des attributs :
Exemples de requêtes :

Indexation des colonnes JSON

Les index de texte peuvent être utilisés avec les colonnes JSON de trois façons :
  1. Index sur des sous-colonnes spécifiques — créez un index de texte sur un chemin JSON connu, comme pour une colonne classique. Cela indexe les valeurs de ce chemin.
  2. Index basés sur les chemins avec JSONAllPaths — indexent tous les chemins présents dans chaque granule afin d’ignorer les granules qui ne peuvent pas contenir le chemin recherché. Comme pour les colonnes Map.
  3. Index basés sur les valeurs avec JSONAllValues — indexent toutes les valeurs de tous les chemins JSON afin d’accélérer la recherche en texte intégral sur n’importe quelle sous-colonne JSON avec un seul index.

Index sur des sous-colonnes spécifiques

Vous pouvez créer un skip index sur n’importe quelle sous-colonne JSON en utilisant la même syntaxe que pour les colonnes classiques. Il existe deux façons de référencer une sous-colonne JSON dans une expression d’index :
  • Chemin typé déclaré dans l’indication de type JSON — accès direct par son nom : json.a.
  • Chemin dynamique avec conversion de type explicite — utilisez la syntaxe de cast :: : json.b::String.
Exemple de définition d’index :
Query
Exemple de requête :
Query
Response
Exemple de requête :
Query
Response

Index basés sur les chemins avec JSONAllPaths

Comme pour les colonnes Map, des index de texte peuvent être créés sur des colonnes JSON à l’aide de JSONAllPaths. L’index stocke l’ensemble des chemins JSON présents dans chaque granule et les utilise pour sauter les granules où le chemin recherché est absent. Exemple de définition d’index :
Query
Vous pouvez utiliser EXPLAIN indexes = 1 pour vérifier que le skip index est utilisé. Lorsqu’un chemin n’existe que dans une seule part, l’index permet d’ignorer l’autre part. Exemple :
Query
Response
Lorsqu’un chemin n’existe dans aucune part, toutes les parts et toutes les granules sont ignorées. Exemple :
Query
Response
IS NOT NULL utilise également l’index — il ignore les granules où le chemin est absent (puisque la valeur serait NULL) : Exemple :
Query
Response

Index basés sur les valeurs avec JSONAllValues

Les index de texte peuvent être utilisés pour accélérer les recherches dans les colonnes JSON via la fonction JSONAllValues. JSONAllValues renvoie toutes les valeurs d’une colonne JSON sous forme de Array(String). Les valeurs de types de données non textuels (par exemple, les entiers et les tableaux) sont converties en représentation textuelle. Un index de texte construit avec JSONAllValues indexe ces représentations textuelles sur tous les chemins JSON de chaque ligne. Cet index peut ensuite accélérer les requêtes qui filtrent sur des sous-colonnes JSON individuelles. Lorsqu’une requête filtre sur une sous-colonne spécifique (par exemple, data.user_name = 'alice'), l’index de texte peut rapidement ignorer les lignes (et les granules) qui ne contiennent pas les tokens recherchés dans leurs valeurs JSON.
L’index peut produire des faux positifs lorsque différents chemins JSON contiennent les mêmes tokens. Par exemple, si la ligne 1 contient {"a": "hello", "b": "world"} et qu’une requête recherche data.a = 'world', l’index de texte ne peut pas distinguer que world appartient au chemin b et non à a. Dans ce cas, l’index n’ignorera pas la ligne, et le filtre sur les données réelles de la colonne se chargera de l’évaluation finale. Le comportement est le même que dans les autres cas d’usage des index de texte, où l’index sert de préfiltre rapide.
Création de l’index
Exemple de définition d’un index :
Types de requêtes pris en charge
Une fois l’index créé, il peut accélérer les requêtes sur les sous-colonnes JSON en utilisant les mêmes fonctions que pour les colonnes String, ainsi que la fonction equals pour toutes les colonnes. Accès aux sous-colonnes :
Accès à la sous-colonne via un CAST explicite :
opérateur IN :
Une recherche classique dans un index de texte, par exemple
correspond à toutes les lignes qui contiennent les tokens donnés, dans n’importe quel ordre. Dans l’exemple, la ligne While she stayed in Tokyo, the weather was great. correspond au filtre. À l’inverse, une recherche d’expression consiste à faire correspondre les tokens dans l’ordre indiqué. Par exemple,
correspond à toute ligne contenant la séquence de tokens weather in Tokyo, comme How is the weather in Tokyo? ? L’index de texte accélère la recherche d’expressions en faisant l’intersection des posting lists de tous les tokens de l’expression afin d’identifier les granules candidates. Dans ces granules, ClickHouse vérifie ensuite que les tokens sont exactement adjacents. Ce processus est relativement coûteux et plus lent que les requêtes de recherche textuelle classiques. Pour accélérer les requêtes de recherche d’expressions, veuillez activer le stockage des positions dans l’index de texte (voir Optional parameters ci-dessus). hasPhrase peut être utilisé avec les tokenizers splitByNonAlpha, splitByString, ngrams et asciiCJK. La chaîne correspondant à l’expression fournie est tokenisée à l’aide du tokenizer de l’index. Les caractères séparateurs de l’expression sont ignorés : hasPhrase(text, 'quick+brown') équivaut à hasPhrase(text, 'quick brown'), à condition que splitByNonAlpha soit utilisé comme tokenizer.

Exemple

Query
Response
La ligne 2 ('New weather in York') ne correspond pas, car les tokens ne sont pas dans le bon ordre. La ligne 3 ('weather in New Orleans') ne correspond pas, car elle ne contient pas le token 'York'.

Optimisation des performances

lecture directe

Certaines requêtes textuelles peuvent être considérablement accélérées grâce à une optimisation appelée “lecture directe”. Exemple :
L’optimisation de lecture directe répond à la requête en s’appuyant exclusivement sur l’index de texte (c’est-à-dire sur des consultations de l’index de texte), sans accéder à la colonne de texte sous-jacente. Les consultations de l’index de texte lisent relativement peu de données et sont donc bien plus rapides que les skip indexes habituels dans ClickHouse (qui effectuent une consultation du skip index, puis chargent et filtrent les granules restantes). La lecture directe est contrôlée par deux paramètres : Fonctions prises en charge L’optimisation de lecture directe prend en charge les fonctions hasToken, hasAllTokens et hasAnyTokens. Si l’index de texte est défini avec un tokenizer array, la lecture directe est également prise en charge pour les fonctions equals, has, hasAny, hasAll, mapContainsKey et mapContainsValue. Ces fonctions peuvent également être combinées avec les opérateurs AND, OR et NOT. Les clauses WHERE ou PREWHERE peuvent également contenir des filtres supplémentaires autres que les fonctions de recherche textuelle (sur des colonnes de texte ou d’autres colonnes) - dans ce cas, l’optimisation de lecture directe sera tout de même utilisée, mais sera moins efficace (elle s’applique uniquement aux fonctions de recherche textuelle prises en charge). Pour vérifier qu’une requête utilise la lecture directe, exécutez-la avec EXPLAIN PLAN actions = 1. À titre d’exemple, une requête avec la lecture directe désactivée
renvoie
alors que la même requête, exécutée avec query_plan_direct_read_from_text_index = 1
renvoie
La seconde sortie d’EXPLAIN PLAN contient une colonne virtuelle __text_index_<index_name>_<function_name>_<id>. Si cette colonne est présente, la lecture directe est utilisée. Si la clause WHERE ne contient que des fonctions de recherche textuelle, la requête peut éviter complètement de lire les données de la colonne et tirer le plus grand bénéfice en termes de performances de la lecture directe. Cependant, même si la colonne de texte est utilisée ailleurs dans la requête, la lecture directe apportera tout de même un gain de performances. Lecture directe comme indice La lecture directe comme indice repose sur les mêmes principes que la lecture directe normale, mais ajoute en plus un filtre supplémentaire construit à partir des données de l’index de texte, sans éliminer la colonne de texte sous-jacente. Elle est utilisée pour les fonctions pour lesquelles une lecture uniquement depuis l’index de texte produirait des faux positifs. Les fonctions prises en charge sont : like, startsWith, endsWith, equals, has, hasPhrase, mapContainsKey et mapContainsValue. Le filtre supplémentaire peut apporter une sélectivité supplémentaire pour restreindre davantage le jeu de résultats en combinaison avec d’autres filtres, ce qui aide à réduire la quantité de données lues depuis d’autres colonnes. La lecture directe comme indice est contrôlée par le paramètre query_plan_text_index_add_hint (activé par défaut). Exemple de requête sans indice :
renvoie
alors que la même requête est exécutée avec query_plan_text_index_add_hint = 1
renvoie
Dans la sortie du second EXPLAIN PLAN, vous pouvez voir qu’une conjonction supplémentaire (__text_index_...) a été ajoutée à la condition de filtrage. Grâce à l’optimisation PREWHERE, la condition de filtrage est décomposée en trois conjonctions distinctes, appliquées par ordre croissant de complexité de calcul. Pour cette requête, l’ordre d’application est __text_index_..., puis greaterOrEquals(...), et enfin like(...). Cet ordre permet d’ignorer encore plus de granules de données que l’index de texte et le filtre d’origine, avant de lire les colonnes volumineuses utilisées dans la requête après la clause WHERE, ce qui réduit encore la quantité de données à lire.

Requêtes LIKE/ILIKE

Lorsque le motif d’une requête LIKE/ILIKE est %<alpha-numeric-characters-without-spaces>% et que le tokenizer de l’index de texte est splitByNonAlpha ou array, ClickHouse exploite l’index inversé pour accélérer considérablement les requêtes LIKE/ILIKE. Pour cela, ClickHouse parcourt le dictionnaire de l’index inversé au lieu d’effectuer un scan complet de la table afin de trouver le motif correspondant. Lorsque l’optimisation est activée, les requêtes LIKE/ILIKE devraient être nettement plus rapides qu’un scan complet de la table. Cependant, si le motif correspond à la majorité des tokens du dictionnaire, les performances peuvent être moins bonnes qu’avec un scan complet de la table. Heureusement, un mécanisme de repli permet d’éviter cela. L’optimisation est contrôlée par un paramètre : Le mécanisme de repli est contrôlé par deux paramètres : Cette optimisation ne prend en charge que les fonctions like et ilike.

Mise en cache

Il existe différents caches à l’échelle du serveur pour conserver en mémoire certaines parties de l’index de texte (voir la section Détails d’implémentation) : À l’heure actuelle, il existe des caches pour les en-têtes désérialisés, les tokens et les listes de postings de l’index de texte afin de réduire les opérations d’E/S. Utilisez les paramètres use_text_index_header_cache, use_text_index_tokens_cache et use_text_index_postings_cache pour désactiver, pour les requêtes, la lecture et l’écriture dans les caches individuels. Pour vider les caches, utilisez l’instruction SYSTEM CLEAR TEXT INDEX CACHES Reportez-vous aux paramètres du serveur ci-dessous pour configurer les caches.

Paramètres du cache des jetons

Paramètres du cache des en-têtes

Paramètres du cache des listes de postings

Limites

L’index de texte présente actuellement les limitations suivantes :
  • La matérialisation d’index de texte comportant un grand nombre de tokens (par ex. 10 milliards de tokens) peut consommer une quantité importante de mémoire. La matérialisation d’un index de texte peut se produire directement (ALTER TABLE <table> MATERIALIZE INDEX <index>) ou indirectement lors des fusions de parts.
  • Il n’est pas possible de matérialiser des index de texte sur des parts de plus de 4.294.967.296 (= 2^32 = env. 4,2 milliards) lignes. Sans index de texte matérialisé, les requêtes reviennent à une recherche brute-force lente dans la part. Dans le pire des cas, supposons qu’une part contienne une seule colonne de type String et que le paramètre MergeTree max_bytes_to_merge_at_max_space_in_pool (par défaut : 150 GB) n’ait pas été modifié. Dans ce cas, cela se produit si la colonne contient en moyenne moins de 29,5 caractères par ligne. En pratique, les tables contiennent aussi d’autres colonnes et le seuil est alors plusieurs fois plus faible (selon le nombre, le type et la taille des autres colonnes).

Index de texte vs index basés sur des filtres de Bloom

Les prédicats String peuvent être accélérés à l’aide d’index de texte et d’index basés sur des filtres de Bloom (types d’index bloom_filter, ngrambf_v1, tokenbf_v1, sparse_grams), mais ces deux types d’index diffèrent fondamentalement par leur conception et leurs cas d’usage visés : Index à filtre de Bloom
  • Reposent sur des structures de données probabilistes qui peuvent produire des faux positifs.
  • Peuvent uniquement répondre à des questions d’appartenance à un ensemble, c.-à-d. déterminer si la colonne peut contenir le token X ou si elle ne contient certainement pas X.
  • Stockent des informations au niveau des granules afin de permettre d’ignorer de larges plages lors de l’exécution d’une requête.
  • Sont difficiles à paramétrer correctement (voir ici pour un exemple).
  • Sont relativement compacts (quelques kilo-octets ou mégaoctets par part).
Index de texte
  • Construisent un index inversé déterministe sur des tokens. L’index lui-même ne peut pas produire de faux positifs.
  • Sont spécifiquement optimisés pour les charges de travail de recherche textuelle.
  • Stockent des informations au niveau des lignes, ce qui permet une recherche de termes efficace.
  • Sont relativement volumineux (de dizaines à des centaines de mégaoctets par part).
Les index basés sur des filtres de Bloom ne prennent en charge la recherche en texte intégral qu’en tant qu’« effet secondaire » :
  • Ils ne prennent pas en charge la tokenisation ni le prétraitement avancés.
  • Ils ne prennent pas en charge la recherche sur plusieurs tokens.
  • Ils n’offrent pas les performances attendues d’un index inversé.
Les index de texte, en revanche, sont conçus spécifiquement pour la recherche en texte intégral :
  • Ils fournissent la tokenisation et le prétraitement
  • Ils prennent efficacement en charge hasAllTokens, LIKE, match et des fonctions de recherche textuelle similaires.
  • Ils offrent une bien meilleure capacité de passage à l’échelle pour les grands corpus textuels.

Détails d’implémentation

Chaque index de texte se compose de deux structures de données (abstraites) :
  • un dictionnaire qui associe chaque token à une liste de postings, et
  • un ensemble de listes de postings, chacune représentant un ensemble de numéros de ligne.
L’index de texte est construit pour l’ensemble de la part. Contrairement aux autres skip indexes, l’index de texte peut être fusionné au lieu d’être reconstruit lors de la fusion des data parts (voir ci-dessous). Lors de la création de l’index, trois fichiers sont créés (par part) : Fichier des blocs de dictionnaire (.dct) Les tokens de l’index de texte sont triés et stockés dans des blocs de dictionnaire de 512 tokens chacun (la taille du bloc est configurable via le paramètre dictionary_block_size). Un fichier de blocs de dictionnaire (.dct) contient tous les blocs de dictionnaire de toutes les index granules d’une part. Fichier d’en-tête d’index (.idx) Le fichier d’en-tête d’index contient, pour chaque bloc de dictionnaire, le premier token du bloc et son décalage relatif dans le fichier des blocs de dictionnaire. Cette structure d’index sparse est similaire à l’index de clé primaire sparse) de ClickHouse. Fichier des listes de postings (.pst) Les listes de postings de tous les tokens sont disposées séquentiellement dans le fichier des listes de postings. Pour économiser de l’espace tout en permettant des opérations rapides d’intersection et d’union, les listes de postings sont stockées sous forme de bitmaps Roaring. Si la liste de postings dépasse posting_list_block_size, elle est divisée en plusieurs blocs, stockés séquentiellement dans le fichier des listes de postings. Fichier des positions (.pos) Facultatif, uniquement si l’argument d’index support_phrase_search = 1. Il stocke les positions des tokens dans les lignes correspondantes. Fusion des index de texte Lorsque des data parts sont fusionnées, l’index de texte n’a pas besoin d’être reconstruit à partir de zéro ; il peut au contraire être fusionné efficacement dans une étape distincte du processus de fusion. Au cours de cette étape, les dictionnaires triés des index de texte de chaque part d’entrée sont lus et combinés en un nouveau dictionnaire unifié. Les numéros de ligne dans les listes de postings sont également recalculés afin de refléter leurs nouvelles positions dans la data part fusionnée, à l’aide d’une correspondance entre anciens et nouveaux numéros de ligne créée pendant la phase initiale de fusion. Cette méthode de fusion des index de texte est similaire à la manière dont les projections avec la colonne _part_offset sont fusionnées. Si l’index n’est pas matérialisé dans la part source, il est construit, écrit dans un fichier temporaire, puis fusionné avec les index des autres parts et ceux des autres fichiers d’index temporaires. Débogage La table function mergeTreeTextIndex peut être utilisée pour inspecter les index de texte.

Exemple : jeu de données Hacker News

Examinons les gains de performances des index de texte sur un vaste jeu de données contenant beaucoup de texte. Nous utiliserons 28,7 M de lignes de commentaires du site populaire Hacker News. Voici la table sans index de texte :
Les 28,7 M de lignes se trouvent dans un fichier Parquet sur S3 - insérons-les dans la table hackernews :
Nous utiliserons ALTER TABLE pour ajouter un index de texte sur la colonne comment, puis le matérialiser :
Maintenant, exécutons des requêtes à l’aide des fonctions hasToken, hasAnyTokens et hasAllTokens. Les exemples suivants illustrent l’écart de performances spectaculaire entre un balayage d’index standard et l’optimisation de lecture directe.

1. Utilisation de hasToken

hasToken vérifie si le texte contient un token précis. Nous rechercherons le token sensible à la casse ‘ClickHouse’. Lecture directe désactivée (scan standard) Par défaut, ClickHouse utilise le skip index pour filtrer les granules, puis lit les données de colonne de ces granules. Nous pouvons simuler ce comportement en désactivant la lecture directe.
Lecture directe activée (lecture rapide de l’index) Nous exécutons maintenant la même requête avec la lecture directe activée (par défaut).
La requête lecture directe est plus de 45 fois plus rapide (0.362s contre 0.008s) et traite nettement moins de données (9.51 GB contre 3.15 MB) en lisant uniquement l’index.

2. Utilisation de hasAnyTokens

hasAnyTokens vérifie si le texte contient au moins un des tokens fournis. Nous allons rechercher des commentaires contenant soit ‘love’, soit ‘ClickHouse’. Lecture directe désactivée (Standard scan)
Lecture directe activée (lecture rapide depuis l’index)
L’accélération est encore plus spectaculaire pour cette recherche courante avec l’opérateur “OR”. La requête est près de 89 fois plus rapide (1.329s vs 0.015s) en évitant le parcours complet de la colonne.

3. Utilisation de hasAllTokens

hasAllTokens vérifie si le texte contient tous les tokens fournis. Nous allons rechercher des commentaires contenant à la fois ‘love’ et ‘ClickHouse’. Lecture directe désactivée (scan standard) Même avec la lecture directe désactivée, le skip index standard reste efficace. Il ramène les 28.7M lignes à seulement 147.46K lignes, mais il doit toujours lire 57.03 MB dans la colonne.
Lecture directe activée (lecture rapide de l’index) La lecture directe répond à la requête en s’appuyant sur les données de l’index et ne lit que 147.46 KB.
Pour cette recherche “AND”, l’optimisation de lecture directe est plus de 26 fois plus rapide (0.184s contre 0.007s) que le scan standard du skip index. L’optimisation de lecture directe s’applique également aux expressions booléennes composées. Ici, nous allons effectuer une recherche insensible à la casse de ‘ClickHouse’ OR ‘clickhouse’. Lecture directe désactivée (Standard scan)
Lecture directe activée (lecture rapide de l’index)
En combinant les résultats de l’index, la requête en lecture directe est 34 fois plus rapide (0,450 s contre 0,013 s) et évite de lire 9,58 Go de données de colonne. Pour ce cas précis, hasAnyTokens(comment, ['ClickHouse', 'clickhouse']) serait la syntaxe à privilégier, car plus efficace. Contenu obsolète
Dernière modification le 23 juillet 2026