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é.
Query
- String et FixedString,
- Array(String) et Array(FixedString),
- Map, soit sur la colonne
Mapà l’aide du tokenizerkeyValuePairs, soit uniquement sur les clés ou les valeurs de la map à l’aide de la fonction mapKeys ou mapValues, et - JSON (à l’aide des fonctions JSONAllPaths et
JSONAllValues).
Array(Nullable(String or FixedString)).
Autrement, pour ajouter un index de texte à une table existante :
Query
Query
Query
tokenizer précise le tokenizer :
splitByNonAlphadivise 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éparatricesSdéfinies par l’utilisateur (voir la fonction splitByString). Les séparateurs peuvent être spécifiés à l’aide d’un paramètre facultatif, par exempletokenizer = 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 exempletokenizer = splitByString), est un seul espace[' '].splitByRegexp(regexp[, match_tokens])divise les chaînes selon une expression régulièreregexpdéfinie par l’utilisateur. L’argumentregexpest obligatoire, par exempletokenizer = splitByRegexp('[a-zA-Z]+[0-9]'). L’argument facultatifmatch_tokens(falsepar défaut ;0/1sont également acceptés) détermine ce que représenteregexp:- avec
match_tokens = false(la valeur par défaut),regexpreprésente des séparateurs : les tokens sont les portions de texte (non vides) situées entre deux correspondances successives (même sémantique que la fonction splitByRegexp). - avec
match_tokens = true,regexpfait au contraire l’objet d’une correspondance directe : chaque correspondance produit au plus un token — son premier groupe de capture, ou la correspondance entière siregexpne comporte aucun groupe de capture — et tout ce qui se trouve en dehors des correspondances est ignoré. Par exemple,tokenizer = splitByRegexp('tag:(\w+)', true)indexehelloetworldà partir detag:hello tag:world, en supprimant le préfixetag:. La correspondance s’appuie sur RE2, le même moteur que celui utilisé par toutes les fonctions fondées sur les expressions régulières dans ClickHouse ; seul le premier groupe de capture est utilisé, les groupes suivants ne font donc que restreindre ce qui correspond. Un groupe de capture n’ayant pas participé à la correspondance, ou ayant correspondu à une chaîne vide, ne produit aucun token, et le parcours reprend toujours après la correspondance complète plutôt qu’après le span capturé (les correspondances ne se chevauchent donc jamais). Avecmatch_tokens = true, notez que les needles de recherche des fonctions de recherche de texte sont tokenisés avec le tokenizer propre à l’index : un needle sous forme de chaîne simple qui ne correspond pas lui-même àregexpne produit donc aucun token, et par conséquent aucun résultat. Les needles transmis sous forme de tableaux aux fonctions de recherche de texte ne sont pas tokenisés ; leur utilisation est donc recommandée avec les tokenizers regexp.
- avec
asciiCJKdivise 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.chinese[(granularity)]segmente le texte chinois en mots à l’aide d’un dictionnaire et d’un modèle de Markov caché (l’algorithme suit jieba ; les données du dictionnaire et du modèle intégrés sont dérivées de cppjieba). Contrairement àasciiCJK, qui traite chaque caractère non ASCII comme un token d’un seul caractère,chineseregroupe les caractères chinois consécutifs en mots (par exemple,北京大学devient un seul token北京大学plutôt que quatre tokens d’un seul caractère). Cela produit des tokens plus significatifs pour le texte chinois et améliore la qualité de recherche. Pour le texte général ou mixte, utilisezasciiCJK; pour le texte exclusivement chinois, utilisezchinese. L’argument facultatifgranularityest soit'coarse_grained'(la valeur par défaut s’il n’est pas spécifié), soit'fine_grained'. Ce dernier répertorie également les sous-mots qui se chevauchent ; par exemple,北京邮电大学produit également北京,邮电,大学. La tokenisation fine améliore le rappel au prix d’un index plus volumineux. Recherchez dans un index de textechineseavec hasAnyTokens / hasAllTokens (qui tokenisent le needle avec le tokenizerchinese), et non avechasToken(qui divise uniquement selon les séparateurs ASCII).icu(locale)divise les chaînes en tokens de mots à l’aide de la segmentation des mots Unicode (UAX #29) de la bibliothèque ICU. Pour les écritures qui ne séparent pas les mots par des espaces (p. ex. le chinois, le japonais, le thaï), ICU applique une segmentation fondée sur un dictionnaire : contrairement àasciiCJK, ce texte est donc divisé en mots significatifs de plusieurs caractères plutôt qu’en caractères uniques. Ici, « dictionnaire » désigne les listes de mots intégrées d’ICU pour ces écritures (voirbrkitr/dictionaries) ; ICU choisit le découpage le plus probable parmi ces mots.localecorrespond aux paramètres régionaux ICU transmis au segmenteur ; la segmentation est principalement déterminée par l’écriture et le dictionnaire, et les paramètres régionaux sélectionnent les adaptations propres à ICU. Il s’agit d’un paramètre obligatoire, par exempletokenizer = icu('ja')outokenizer = icu('zh'). Les paramètres régionaux disponibles peuvent être répertoriés avecSELECT * FROM system.collations.japanesedivise le texte japonais en mots à l’aide de l’analyseur morphologique MeCab. Contrairement àasciiCJK, qui produit des tokens d’un seul caractère pour les entrées CJK, ce tokenizer effectue une segmentation correcte des mots. Il nécessite un dictionnaire chargé à l’exécution à partir de la configuration du serveur (voir Dictionnaire du tokenizer japonais).ngrams(N)divise les chaînes enN-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 exempletokenizer = ngrams(3). La taille des n-grammes par défaut, si elle n’est pas explicitement spécifiée (par exempletokenizer = ngrams), est de 3.sparseGrams(min_length, max_length, min_cutoff_length)divise les chaînes en n-grammes de longueur variable d’au moinsmin_lengthet d’au plusmax_lengthcaractères (bornes incluses) (voir la fonction sparseGrams). Sauf indication explicite,min_lengthetmax_lengthvalent par défaut 3 et 100. Si le paramètremin_cutoff_lengthest fourni, seuls les n-grammes dont la longueur est supérieure ou égale àmin_cutoff_lengthsont renvoyés. Comparé àngrams(N), le tokenizersparseGramsproduit 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.arrayne réalise aucune tokenisation, c.-à-d. que chaque valeur de ligne constitue un token (voir la fonction array). Pour assurer la compatibilité avec d’autres systèmes,keywordest disponible comme alias dearray.keyValuePairscombine les key-value pairs d’une colonneMapen un seul token. Cela permet d’effectuer des lookups combinés clé-valeur tels queWHERE map['key'] = 'value'(voir le tokenizerkeyValuePairs).
Dictionnaire du tokenizer japonais
Le tokenizerjapanese nécessite un dictionnaire MeCab, qui n’est pas fourni avec ClickHouse. Ajoutez-en un dans la configuration du serveur :
dictionary_locationcorrespond à l’emplacement d’une archive d’un dictionnaire MeCab compilé. Tout dictionnaire officiel convient, comme IPADIC ou UniDic. L’emplacement doit se terminer par une extension d’archive prise en charge (par exemple.tar.gz,.tar.zstou.zip) — le type d’archive est déterminé à partir de celle-ci —, donc une URL sans une telle extension (p. ex.https://example.com/download) est rejetée. Emplacements pris en charge :- un chemin local
file://; - une URL
http(s)://, récupérée comme téléchargement simple (utilisez-la pour un objet public ou pré-signé) ; - un stockage d’objets compatible S3 — AWS S3, GCS, MinIO, sur site, etc. (pas uniquement AWS) — adressé sous la forme
s3:///gs:///oss://ou d’une URL complètehttp(s)://endpoint/bucket/key. Pour un bucket privé, fournissez les informations d’identification S3 en tant qu’éléments enfants de<japanese>(voir l’exemple ci-dessous).
- un chemin local
dictionary_shacorrespond au SHA-256 de cette archive. Il est vérifié avant le chargement du dictionnaire ; en cas de non-correspondance, le dictionnaire n’est pas chargé et une erreur est générée.
dictionary_sha doit être configuré sur toutes les répliques.
Pour lire depuis un bucket privé compatible S3, ajoutez les paramètres S3 en tant qu’éléments enfants de <japanese>, à côté de dictionary_location et dictionary_sha :
access_key_id, secret_access_key, region, no_sign_request, use_environment_credentials, …) sont les mêmes paramètres d’authentification S3 utilisés ailleurs dans ClickHouse. Leur présence entraîne également le téléchargement d’une URL http(s):// via le client S3 (avec signature des requêtes), plutôt que comme un téléchargement standard.
Une fois le dictionnaire configuré, le tokenizer japanese peut être utilisé comme suit :
Query
Response
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.Query
Response
asciiCJK est recommandé, car il gère correctement les limites de mots Unicode, y compris les caractères CJK.
Pour les langues qui ne séparent pas les mots par des espaces (par exemple le chinois, le japonais ou le thaï), le tokenizer icu(locale) produit des tokens de mots significatifs comportant plusieurs caractères grâce à la segmentation des mots d’ICU basée sur un dictionnaire.
Pour le japonais en particulier, le tokenizer japanese (MeCab) segmente le texte en mots plutôt qu’en caractères uniques et donne généralement de meilleurs résultats de recherche.
Pour le chinois en particulier, le tokenizer chinese (jieba) segmente le texte en mots plutôt qu’en caractères uniques et donne généralement de meilleurs résultats de recherche.
Argument de préprocesseur (facultatif). Le préprocesseur désigne une expression appliquée à la chaîne d’entrée avant la tokenisation.
Les cas d’utilisation typiques de l’argument de préprocesseur incluent
- Conversion en minuscules/majuscules, ou normalisation de la casse pour permettre une correspondance insensible à la casse, par ex., lower, lowerUTF8, caseFoldUTF8.
- Normalisation UTF-8, par ex. normalizeUTF8NFC, normalizeUTF8NFD, normalizeUTF8NFKC, normalizeUTF8NFKD, normalizeUTF8NFKCCasefold, toValidUTF8.
- Suppression ou transformation de caractères ou de sous-chaînes indésirables, comme les accents, par ex. extractTextFromHTML, substring, idnaEncode, translate, removeDiacriticsUTF8.
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)))
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))
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.SETTINGS use_skip_indexes = 0).
Par exemple,
Query
Query
Query
Query
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 :
- 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) - 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 utilisezparseDateTimeOrNullpour 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, utilisezparseDateTimeBestEffortOrNull(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é commeERRORouINFO).
- Approche avec postprocesseur : utilisez le tokenizer
- 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 requête sur l’une de ces variantes correspond à toutes.
ClickHouse fournit une fonction stem intégrée pour plusieurs langues.
Exemple :
stem(str, 'en') - 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.
Array(String), le postprocesseur agit toujours 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 création 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,hasAnyTokensethasPhrase(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 exemple, une recherche insensible à la casse). PourhasPhrase, les tokens post-traités sont positionnés de manière dense : un token supprimé par le postprocesseur ne laisse donc aucun écart de position et la phrase correspond toujours à travers celui-ci — par exemple, avec un postprocesseur de stop words qui supprimethe,hasPhrase(col, 'see cat')correspond à un documentsee the cat. La seule exception esthasPhrasesur un indexsplitByRegexp, qui ne prend pas en charge de postprocesseur (la combinaison est rejetée avec une exception). - Pour toutes les autres fonctions (
=,IN,has,hasAny,hasAll,mapContains*) : seul le needle de recherche est post-traité pour la recherche par indication d’index ; le prédicat au niveau des lignes compare toujours les valeurs de colonne d’origine.
- Supprimez les stop words à l’aide d’une expression de postprocesseur :
- Supprimez les timestamps à l’aide d’une expression de postprocesseur :
- Supprimez les horodatages à l’aide d’une expression de préprocesseur :
- 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 postprocesseur :
=, 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 l’expression 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 l’expression. Avec support_phrase_search = 1, hasPhrase utilise des lectures directes exactes (tout en appliquant le postprocesseur, le cas échéant). Cette prise en charge du postprocesseur ne s’étend pas au tokenizer splitByRegexp : hasPhrase sur un index splitByRegexp associé à un postprocesseur est rejeté (voir la note de bas de page ³ ci-dessous).
Les tokens de recherche que le postprocesseur transforme en chaîne vide sont ignorés, c’est-à-dire traités comme absents de l’expression de recherche.
¹
LIKE et match utilisent la lecture directe comme indication pour les tokenizers listés ; sinon, ils reviennent à un balayage exhaustif.
LIKE prend en outre en charge l’évaluation par dictionary scan (activée via use_text_index_like_evaluation_by_dictionary_scan) pour les tokenizers splitByNonAlpha et array, sans préprocesseur ni postprocesseur, voir la section Requêtes LIKE/ILIKE ci-dessous.
² ILIKE est uniquement pris en charge via l’évaluation par dictionary scan (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 indication pour les patterns que le dictionary scan ne prend pas en charge : 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.
Certains needles ne sont en outre pas éligibles, voir Requêtes LIKE/ILIKE.
³ hasPhrase sur un index de texte splitByRegexp ne prend pas en charge de postprocesseur : la combinaison est rejetée avec une exception, car la réécriture row-level du postprocesseur suppose des tokens de style splitByNonAlpha découpés sur les espaces. Sans postprocesseur, splitByRegexp est entièrement pris en charge par hasPhrase.
⁴ startsWith et endsWith recherchent les tokens complets du needle, et le token situé à l’extrémité ouverte du needle est incomplet car la valeur s’y poursuit : startsWith(col, 'ClickHouse is') recherche le token ClickHouse, tandis que startsWith(col, 'ClickHouse') n’a aucun token complet à rechercher.
Ce dernier est alors évalué par un dictionary scan (use_text_index_like_evaluation_by_dictionary_scan = 1, tokenizer splitByNonAlpha ou array, sans préprocesseur ni postprocesseur), qui est également le chemin emprunté par col LIKE 'ClickHouse%', car la passe de l’analyzer optimize_rewrite_like_perfect_affix la réécrit en startsWith.
Voir requêtes LIKE/ILIKE.
⁵ La colonne tokenizers compatibles ignore le tokenizer keyValuePairs, qui est un tokenizer spécialisé pour les colonnes Map.
Expérimental : argument de prise en charge de la phrase search (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.
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
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 clauseWHERE 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.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 :
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 :
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 :
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 :
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.hasAnyTokens et hasAllTokens, 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 d’index.
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, splitByRegexp, ngrams, asciiCJK ou icu.
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 est utilisé s’il emploie un tokenizer keyValuePairs au-dessus de la colonne Map, ou s’il est construit sur une expression d’index mapKeys(map) ou mapValues(map).
Dans le premier cas (tokenizer keyValuePairs), la lecture directe peut être utilisée ; sinon, il s’agit d’une lecture directe avec hint.
Exemple :
Array(T) et Map(K, V) avec l’index de texte.
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 :clickhouse) nécessite de parcourir toutes les lignes :
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 :Recherches clé-valeur combinées avec le tokenizer keyValuePairs
Utilisez le tokenizer keyValuePairs directement sur la colonne Map lorsque vous recherchez une clé précise contenant une valeur précise :
(key, value) d’une ligne en un seul token, de sorte que l’index sait quelle valeur appartient à quelle clé.
Les index mapKeys et mapValues décrits ci-dessous ne peuvent pas répondre à une telle requête, car ils indexent les clés et les valeurs indépendamment : ils peuvent indiquer qu’un attribut d’une ligne a la valeur error, mais pas qu’il s’agissait de l’attribut level.
Une paire est stockée sous la forme du token key ‖ value ‖ length(key).
La longueur placée à la fin lève toute ambiguïté sur la frontière entre la clé et la valeur, si bien que l’une comme l’autre peuvent contenir des octets arbitraires : contrairement à une concaténation au moyen d’un séparateur tel que key=value, une clé ou une valeur contenant le séparateur ne peut pas produire de faux appariement.
La fonction de table mergeTreeTextIndex renvoie les parties décodées de chaque token dans les colonnes token_key et token_value.
Les restrictions suivantes s’appliquent :
- L’index doit être créé sur un
Mapdont les clés et les valeurs sont de typeStringouLowCardinality(String).FixedStringest rejeté car la colonne stocke les octets de complétion alors que la constante recherchée ne les contient pas, ce qui conduirait à ignorer silencieusement des lignes.Nullableest rejeté car l’encodage ne permet pas de distinguer une valeur vide deNULL. - Les arguments
preprocessor,postprocessoretsupport_phrase_searchsont rejetés à la création de la table. - Seul
=sur un élément de map est pour l’instant résolu à partir de l’index. Les autres recherches sur map, telles quemapContainsKey,mapContainsValue, leurs variantes*LikeetIN, basculent vers un balayage exhaustif. map['key']renvoie la valeur par défaut du type de la valeur lorsque la clé est absente, si bien quemap['key'] = ''vaut égalementtruepour les lignes qui ne contiennent pas la clé et n’ont donc aucun token. Un tel prédicat bascule vers un balayage exhaustif.- Si une ligne contient plusieurs fois la même clé,
map['key']correspond à la valeur de sa première occurrence, et l’index apparie cette occurrence.
Recherche de clés et de valeurs avec mapKeys et mapValues
Utilisez mapKeys pour créer un index de texte lorsque vous devez retrouver des logs à partir des noms de champ ou des types d’attribut :
Indexation des colonnes JSON
Les index de texte peuvent être utilisés avec les colonnesJSON de trois façons :
- 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.
- 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. - 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.
Query
Query
Response
Query
Response
Index basés sur les chemins avec JSONAllPaths
Comme pour les colonnesMap, 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
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
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 fonctionJSONAllValues.
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 colonnesString, ainsi que la fonction equals pour toutes les colonnes.
Accès aux sous-colonnes :
CAST explicite :
IN :
Recherche d’expressions
Une recherche classique dans un index de texte, par exempleWhile 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,
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 listes de postings 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, splitByRegexp, ngrams, asciiCJK et icu.
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
'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 :- Le paramètre query_plan_direct_read_from_text_index (
truepar défaut) indique si la lecture directe est activée de manière générale. - Le paramètre use_skip_indexes_on_data_read était un prérequis pour la lecture directe dans les versions de ClickHouse < 26.4.
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.
Si l’index de texte est défini avec un tokenizer keyValuePairs, la lecture directe est prise en charge pour equals sur un élément de map (map['key'] = 'value').
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
query_plan_direct_read_from_text_index = 1
__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 en tant qu’indice
La lecture directe en tant qu’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 en tant qu’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 :
query_plan_text_index_add_hint = 1
__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
Lorsqu’un pattern de requête LIKE/ILIKE est%<alpha-numeric-characters-without-spaces>%, <alpha-numeric-characters-without-spaces>% ou %<alpha-numeric-characters-without-spaces> et que le tokenizer du index de texte est splitByNonAlpha ou array, ClickHouse s’appuie sur l’inverted index pour accélérer considérablement les requêtes LIKE/ILIKE. Pour ce faire, ClickHouse parcourt le dictionnaire de l’inverted index au lieu d’effectuer un parcours complet de la table afin de trouver le pattern correspondant.
L’utilisation du résultat du dictionary parcours dépend de l’endroit où le needle est ancré :
%value%correspond à une ligne si et seulement si elle correspond à l’un des tokens de la ligne ; l’index tranche donc à lui seul la requête : une lecture directe (sans indice) qui supprime le prédicat d’origine.value%et%valueancrent le needle sur la valeur entière, alors que le dictionary parcours ne peut l’ancrer que sur un token ; le parcours renvoie donc un superset des lignes correspondantes et sert de lecture directe en tant qu’indice.
startsWith(col, 'value') et endsWith(col, 'value') lorsque le needle ne contient aucun token complet à rechercher.
C’est le chemin qu’empruntent en réalité la plupart des patterns value% et %value, car la passe de l’analyzer optimize_rewrite_like_perfect_affix (activée par défaut) réécrit col LIKE 'value%' en startsWith(col, 'value') et col LIKE '%value' en endsWith(col, 'value').
Les needles qui s’étendent sur plusieurs tokens continuent d’utiliser les tokens complets du needle et n’ont pas besoin d’un dictionary parcours.
Cas particulier : si le tokenizer de l’index est array, alors tout pattern est éligible à l’optimisation : ancres, ponctuation, wildcards _, plusieurs needles séparés par % et metacharacters échappés.
Lorsque l’optimisation est activée, les requêtes LIKE/ILIKE devraient être nettement plus rapides qu’un parcours complet de la table. En revanche, lorsque le pattern correspond à la majorité des tokens du dictionnaire, les performances peuvent être moins bonnes qu’avec un parcours complet de la table. Heureusement, un mécanisme de fallback permet d’éviter cela.
Le gain de performance d’un pattern value% ou %value provient du skipping de granules ; il dépend donc de la répartition des lignes correspondantes. Les needles qui correspondent à des lignes dans chaque granule n’en éliminent aucun, et la requête paie alors le coût du dictionary parcours en plus du parcours complet qu’elle aurait effectué de toute façon. Désactivez use_text_index_like_evaluation_by_dictionary_scan pour de tels workloads.
L’optimisation est contrôlée par un paramètre :
Le mécanisme de fallback est contrôlé par deux paramètres :
Cette optimisation ne prend en charge que les fonctions like, ilike, startsWith et endsWith.
Elle nécessite généralement un index sans preprocessor ni postprocessor (ilike accepte également lower ou upper comme fonction de preprocessor).
Pour ilike, la recherche bascule vers un parcours complet de la table dans deux cas particuliers, aussi bien avec le tokenizer splitByNonAlpha qu’avec le tokenizer array :
- Le needle contient la lettre
k.iliketraiteU+212A KELVIN SIGNcomme unk, mais le dictionary parcours compare des octets et manquerait une ligne écrite de cette façon.U+212Aest le seul caractère queilikereplie sur une lettre ou un chiffre ASCII, c’est donc le seul caractère de needle concerné. - L’expression de preprocessor de l’index contient
lowerUTF8ouupperUTF8. Celles-ci réécrivent les caractères non ASCII en lettres ASCII (ßenSS,ſenS), ce qui amènerait l’index à signaler des lignes qui ne correspondent pas àilike.
Requêtes de comptage simples
Requête qui compte uniquement les lignes correspondant à un prédicat de recherche textuellehasAnyTokens) ou les intersecter (hasAllTokens). Comme un index de texte couvre l’intégralité de la part, le décompte est exact et reste rapide même pour de très grandes parts.
L’optimisation s’applique à un simple count() filtré par un unique prédicat hasToken, hasAnyTokens ou hasAllTokens (ou un équivalent Array/Map). Des prédicats combinés avec AND/OR/NOT, un filtre supplémentaire (par ex. ... AND id > 10), un prédicat en mode indice tel que m['key'] = 'value', la sélection d’un élément autre que le décompte, ou une recherche d’expression ou par LIKE/motif obligent la requête à lire les lignes. Les parts sans index matérialisé sont toujours comptées correctement en lisant leurs lignes.
Pour confirmer que l’optimisation est activée, vérifiez la présence de ReadFromTextIndexCount dans le plan de requête :
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. La mise en cache des tokens absents d’une data part est activée par défaut et peut être contrôlée indépendamment avecuse_text_index_negative_tokens_cache.
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 tokens
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).
Notes de mise à niveau
La version du format sur disque des index de texte est contrôlée par le paramètre de tabletext_index_serialization_version (par défaut : v2_with_positions).
Ce paramètre constitue une préférence plutôt qu’une contrainte stricte : si la version configurée ne permet pas de représenter un index, une version plus récente qui le permet est automatiquement sélectionnée. Ainsi, l’écriture d’un index de texte n’échoue jamais à cause de ce paramètre.
Lors d’une mise à niveau progressive, verrouillez le format à l’aide du paramètre compatibility sur les serveurs déjà mis à niveau : lorsqu’il est défini sur une version antérieure à celle ayant introduit le format correspondant, text_index_serialization_version reprend automatiquement une valeur antérieure et les serveurs plus récents continuent d’écrire dans un format que les serveurs plus anciens peuvent toujours lire.
Le codec de liste de postings n’est pas régi par cette version : une partie de données écrite avec posting_list_codec = 'pfor' (ou le paramètre text_index_posting_list_codec) ne peut pas être lue par des serveurs antérieurs à ce codec, et ni text_index_serialization_version ni compatibility n’empêchent son utilisation, car un codec désigné pour l’index prend le pas sur la préférence de version, de la même manière que support_phrase_search. N’activez pas pfor tant que chaque serveur susceptible de lire la table n’a pas été mis à niveau.
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’indexbloom_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).
- 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).
- 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é.
- Ils fournissent la tokenisation et le prétraitement
- Ils prennent efficacement en charge
hasAllTokens,LIKE,matchet 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.
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 :hackernews :
ALTER TABLE pour ajouter un index de texte sur la colonne comment, puis le matérialiser :
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 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)
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.
4. Recherche composée : OR, AND, NOT, …
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)hasAnyTokens(comment, ['ClickHouse', 'clickhouse']) serait la syntaxe à privilégier, car plus efficace.
Contenu connexe
- Blog : Annonce de la disponibilité générale de la recherche en texte intégral dans ClickHouse
- Blog : Concevoir une recherche en texte intégral haute performance pour le stockage objet
- Vidéo : Introduction à la recherche en texte intégral dans ClickHouse
- Vidéo : Sous le capot : la recherche en texte intégral à l’échelle et à la vitesse de ClickHouse
- Présentation : Dans les coulisses de la recherche en texte intégral de ClickHouse : rapide, native et columnaire
- Présentation : Index inversés de base de données : pourquoi, quoi et comment, FOSDEM 2026