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.