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:
const SPLIT_SENTENCE_REGEX =
'/(?<!\b[0-9]\.)(?<![0-9])(?<=[.!?。?!])(?!\d)\s+/u';Detalhamento:
| Fragmento | Propó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 |
/u | Modo 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.
$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.
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:
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:
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:
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 Chunk | Precisão | Contexto | Melhor Para |
|---|---|---|---|
| 256–512 | Alta | Baixo | FAQ, respostas curtas |
| 1024–1536 | Equilibrada | Equilibrado | Documentos gerais |
| 2048–4096 | Baixa | Alto | Aná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:
composer require rajentrivedi/tokenizer-xuse 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:
- Divida por frases primeiro — Uma regex que lida com decimais, strings de versão e pontuação multilíngue fornece fronteiras naturais.
- Empacote de forma gulosa com contagens reais de tokens — Não aproxime. Conte tokens na string combinada porque tokenização não é aditiva.
- 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.
