Skip to content

Divisão de Texto por Tokens para Pipelines RAG em PHP

Introdução

Você está construindo um pipeline RAG em PHP. Um usuário faz upload de um PDF de 40 páginas, e você precisa dividir o texto em chunks que caibam dentro do limite de tokens do seu modelo de embeddings. A abordagem ingênua — cortar a cada N caracteres — divide no meio de frases, quebra o contexto e produz chunks com baixa qualidade de recuperação.

Contagem de caracteres não mapeia diretamente para contagem de tokens. A frase "O preço é R$3,14 por unidade" tem 30 caracteres, mas apenas 11 tokens. Uma URL como https://example.com/api/v2/users?page=1&limit=50 tem 49 caracteres, mas mais de 20 tokens. Dividir por caracteres significa desperdiçar orçamento de tokens ou estourar o limite.

O que você precisa é de um chunker que fale tokens — que divida nas fronteiras de frases, respeite um limite rígido de tokens por chunk e lide com casos extremos como frases maiores que o próprio limite. Neste artigo, vamos construir exatamente isso.

A Estratégia em Três Camadas

Nosso chunker usa uma abordagem hierárquica de divisão:

Texto de entrada


┌───────────────────────┐
│ Camada 1: Frases       │  Divide nas fronteiras de frases
│ (baseada em regex)     │  usando pontuação + espaço
└──────────┬────────────┘

┌───────────────────────┐
│ Camada 2: Empacotamento│  Empacota frases em chunks
│ (bin-packing guloso)   │  que cabem no limite de tokens
└──────────┬────────────┘

┌───────────────────────┐
│ Camada 3: Fallback     │  Divisão por caractere para
│ (nível de caractere)   │  frases que excedem o limite
└───────────────────────┘

Cada camada lida com uma granularidade diferente. A maioria do texto passa pelas camadas 1 e 2. A camada 3 só é ativada em casos patológicos — um blob base64, um bloco JSON minificado ou um parágrafo cheio de URLs sem fronteiras de frase.

Camada 1: Divisão por Frases

Dividir por frases parece simples até você encontrar "O preço é R$3,14. Próximo item." — uma divisão ingênua no . seguido de espaço quebraria depois do 3., produzindo um fragmento.

Esta regex lida com os casos comuns:

php
const SPLIT_SENTENCE_REGEX =
    '/(?<!\b[0-9]\.)(?<![0-9])(?<=[.!?。?!])(?!\d)\s+/u';

Detalhamento:

FragmentoPropósito
(?<!\b[0-9]\.)Não divide após decimal como 3.
(?<![0-9])Não divide quando precedido por dígito
(?<=[.!?。?!])Divide após pontuação final de frase
(?!\d)Não divide se um dígito segue (ex.: v2.0 é...)
\s+Consome o espaço entre frases
/uModo Unicode para pontuação CJK (。?!)

Os lookbehind assertions previnem divisões falsas em números decimais, strings de versão e abreviações, enquanto a flag Unicode lida com chinês, japonês e outros idiomas que usam pontuação de largura total.

php
$sentences = preg_split(
    self::SPLIT_SENTENCE_REGEX,
    $text,
    -1,
    PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY
);

TIP

A flag PREG_SPLIT_NO_EMPTY filtra strings vazias que preg_split às vezes produz nas fronteiras. Sem ela, você precisaria filtrar manualmente os espaços em branco no loop de empacotamento.

Camada 2: Empacotamento Guloso com Contagem de Tokens

Com as frases extraídas, empacotamos em chunks usando um algoritmo guloso: adiciona frases ao chunk atual até que a próxima exceda o limite de tokens, então inicia um novo chunk.

php
public static function splitTextIntoTokenChunks(
    string $text,
    int $token_limit_per_chunk
): array {
    $chunks = [];
    $current_chunk = '';

    $sentences = preg_split(
        self::SPLIT_SENTENCE_REGEX,
        $text,
        -1,
        PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY
    );

    if (empty($sentences)) {
        if (TokenizerX::count($text) <= $token_limit_per_chunk) {
            return $text ? [$text] : [];
        }
        return self::splitLongWord($text, $token_limit_per_chunk);
    }

    foreach ($sentences as $sentence) {
        $sentence = trim($sentence);
        if (empty($sentence)) {
            continue;
        }

        $sentence_token_count = TokenizerX::count($sentence);

        // Gatilho da camada 3: frase excede o limite sozinha
        if ($sentence_token_count > $token_limit_per_chunk) {
            if ($current_chunk) {
                $chunks[] = $current_chunk;
            }
            $sub_chunks = self::splitLongWord(
                $sentence,
                $token_limit_per_chunk
            );
            $chunks = array_merge($chunks, $sub_chunks);
            $current_chunk = '';
            continue;
        }

        // Tenta adicionar a frase ao chunk atual
        $test_chunk = $current_chunk
            ? $current_chunk . ' ' . $sentence
            : $sentence;
        $test_chunk_tokens = TokenizerX::count($test_chunk);

        if ($test_chunk_tokens <= $token_limit_per_chunk) {
            $current_chunk = $test_chunk;
        } else {
            // Não cabe — finaliza chunk atual, inicia novo
            if ($current_chunk) {
                $chunks[] = $current_chunk;
            }
            $current_chunk = $sentence;
        }
    }

    if ($current_chunk) {
        $chunks[] = $current_chunk;
    }

    return $chunks;
}

O detalhe chave é a abordagem do $test_chunk — contamos tokens na string combinada (chunk atual + espaço + nova frase), não na frase isolada. Isso importa porque tokenizadores não produzem contagens aditivas: tokens("A B") ≠ tokens("A") + tokens("B"). O espaço entre frases pode se fundir com caracteres adjacentes em um único token, ou as fronteiras de palavras podem mudar. Contando a string combinada, obtemos o custo real de tokens.

WARNING

Evite a tentação de manter um contador $current_chunk_tokens e somar $sentence_token_count. Contagens de tokens não são aditivas na concatenação. Sempre re-conte a string combinada.

Camada 3: Fallback por Caractere

Quando uma única frase excede o limite de tokens — pense em um blob JSON minificado, uma string base64 ou uma lista longa de URLs — fazemos fallback para divisão caractere por caractere:

php
private static function splitLongWord(
    string $word,
    int $token_limit
): array {
    $chunks = [];
    $current_chunk = '';
    $current_chunk_tokens = 0;

    for ($i = 0; $i < mb_strlen($word); $i++) {
        $char = mb_substr($word, $i, 1);
        $char_token_count = TokenizerX::count($char);

        if ($current_chunk_tokens + $char_token_count <= $token_limit) {
            $current_chunk .= $char;
            $current_chunk_tokens += $char_token_count;
        } else {
            $chunks[] = $current_chunk;
            $current_chunk = $char;
            $current_chunk_tokens = $char_token_count;
        }
    }

    if ($current_chunk) {
        $chunks[] = $current_chunk;
    }

    return $chunks;
}

Este método usa mb_substr para segurança Unicode — um caractere chinês que ocupa 3 bytes e 1 token não será dividido em sequências de bytes inválidas. A contagem de tokens por caractere é uma aproximação aceitável aqui: para caracteres individuais, a saída do tokenizador é determinística, então a contagem aditiva funciona (diferente da concatenação de frases).

TIP

Este fallback é intencionalmente conservador. Ele sacrifica legibilidade (divide no meio de palavras) pela correção (nunca excede o limite de tokens). Na prática, raramente é ativado — a maioria dos textos em linguagem natural tem fronteiras de frase dentro de qualquer limite razoável de tokens.

Adicionando Sobreposição para Melhor Recuperação

Chunks que terminam abruptamente podem perder contexto que atravessa uma fronteira. Se um usuário pergunta "Qual é a política de devolução para eletrônicos?" e a resposta começa no final do chunk 3 e continua no chunk 4, nenhum chunk sozinho recupera bem.

A sobreposição resolve isso repetindo uma porção dos chunks adjacentes:

php
public static function overlapChunks(
    array $chunks,
    float $overlap_percentage = 0.2
): array {
    $overlapped_chunks = [];

    foreach ($chunks as $index => $chunk) {
        // Adiciona final do chunk anterior
        if (isset($chunks[$index - 1])) {
            $previous = $chunks[$index - 1];
            $overlap_chars = (int) (strlen($previous) * $overlap_percentage);
            $chunk = substr($previous, -$overlap_chars) . $chunk;
        }

        // Adiciona início do próximo chunk
        if (isset($chunks[$index + 1])) {
            $next = $chunks[$index + 1];
            $overlap_chars = (int) (strlen($next) * $overlap_percentage);
            $chunk .= substr($next, 0, $overlap_chars);
        }

        $overlapped_chunks[] = $chunk;
    }

    return $overlapped_chunks;
}

Com 20% de sobreposição, um chunk de 1536 tokens recebe aproximadamente 300 tokens do chunk anterior e 300 do próximo. Isso cria redundância que melhora o recall da recuperação ao custo de ~40% mais armazenamento.

Chunk 1:  [==========]
Chunk 2:       [==========]      ← sobrepõe com 1 e 3
Chunk 3:            [==========]

WARNING

A sobreposição acontece no nível de caractere, não de token. Isso é uma escolha deliberada — sobreposição baseada em caracteres é muito mais rápida e produz resultados "bons o suficiente" para recuperação. Se você precisa de sobreposição precisa por token, seria necessário re-tokenizar e cortar, o que adiciona complexidade significativa.

Juntando Tudo: Pipeline de Parsing de Documentos

Veja como essas peças se conectam em um pipeline RAG real que processa documentos enviados:

php
use App\Utils\TextUtil;

class DocumentParser
{
    const TOKENS_PER_CHUNK = 1536;

    public function parse(string $text, string $source): array
    {
        // Passo 1: Divide em chunks limitados por tokens
        $chunks = TextUtil::splitTextIntoTokenChunks(
            text: $text,
            token_limit_per_chunk: self::TOKENS_PER_CHUNK,
        );

        // Passo 2: Adiciona sobreposição para melhor recuperação
        $chunks = TextUtil::overlapChunks($chunks, 0.2);

        // Passo 3: Prepara para embedding
        return array_map(fn (string $chunk, int $i) => [
            'content' => $chunk,
            'source' => $source,
            'chunk_index' => $i,
            'token_count' => TokenizerX::count($chunk),
        ], $chunks, array_keys($chunks));
    }
}

O valor TOKENS_PER_CHUNK = 1536 é escolhido para caber bem dentro dos limites comuns de modelos de embedding (a maioria aceita até 8192 tokens) sendo granular o suficiente para recuperação precisa. Chunks menores (512–1024 tokens) melhoram a precisão; chunks maiores (2048–4096) melhoram o contexto. 1536 é um meio-termo prático.

Por Que 1536 Tokens?

O tamanho do chunk afeta diretamente a qualidade da recuperação:

Tamanho do ChunkPrecisãoContextoMelhor Para
256–512AltaBaixoFAQ, respostas curtas
1024–1536EquilibradaEquilibradoDocumentos gerais
2048–4096BaixaAltoAnálise longa

Chunks menores correspondem melhor a consultas específicas (maior precisão) mas podem perder contexto ao redor. Chunks maiores preservam contexto mas diluem o embedding — um chunk de 4000 tokens sobre cinco tópicos não corresponderá a nenhum tópico tão bem quanto um chunk de 1000 tokens sobre um tópico.

1536 tokens ≈ 1.100 palavras ≈ 2–3 parágrafos. Isso geralmente captura um pensamento ou seção completa sem misturar conteúdo não relacionado.

Contagem de Tokens em PHP

Esta implementação usa o pacote rajentrivedi/tokenizer-x para contagem de tokens:

bash
composer require rajentrivedi/tokenizer-x
php
use Rajentrivedi\TokenizerX\TokenizerX;

// Conta tokens em uma string
$count = TokenizerX::count("Olá, mundo!");  // 5

// Conta com encoding específico
$count = TokenizerX::count("Olá, mundo!", "cl100k_base");

O encoding padrão (cl100k_base) é compatível com GPT-4 e a maioria dos modelos modernos de embedding. Se você está usando uma família de modelos diferente, verifique qual tokenizador ela usa e configure adequadamente.

TIP

Se você não precisa de contagens exatas de tokens, uma aproximação rápida é ceil(mb_strlen($text) / 4) — textos em inglês têm em média cerca de 4 caracteres por token. Isso não funciona para texto CJK (onde cada caractere é tipicamente 1–2 tokens) ou para código (onde tokens são menos previsíveis), mas é útil para estimativas rápidas e casos de teste.

Conclusão

Divisão de texto por tokens para RAG em PHP se resume a três ideias:

  1. Divida por frases primeiro — Uma regex que lida com decimais, strings de versão e pontuação multilíngue fornece fronteiras naturais.
  2. Empacote de forma gulosa com contagens reais de tokens — Não aproxime. Conte tokens na string combinada porque tokenização não é aditiva.
  3. Faça fallback gracioso — Quando o texto não tem fronteiras de frase, divisão por caractere garante que o limite de tokens nunca seja excedido.

A etapa de sobreposição é opcional, mas recomendada para pipelines de recuperação. 20% de sobreposição é um bom ponto de partida — aumente se estiver vendo falhas na fronteira de contexto, diminua se custos de armazenamento importam.

Uma coisa que esta implementação intencionalmente não faz: chunking semântico (usando embeddings para detectar fronteiras de tópico). Essa abordagem pode melhorar a recuperação em 15–25%, mas custa 3–5x mais em computação e adiciona complexidade significativa. Comece com chunking por tokens e frases — funciona bem para a maioria dos casos e é determinístico, rápido e fácil de debugar. Evolua para chunking semântico apenas quando a qualidade da recuperação exigir.