Skip to content

División de Texto por Tokens para Pipelines RAG en PHP

Introducción

Estás construyendo un pipeline RAG en PHP. Un usuario sube un PDF de 40 páginas, y necesitas dividir el texto en chunks que quepan dentro del límite de tokens de tu modelo de embeddings. El enfoque ingenuo — cortar cada N caracteres — divide a mitad de oración, rompe el contexto y produce chunks con baja calidad de recuperación.

La cantidad de caracteres no mapea directamente a la cantidad de tokens. La oración "El precio es $3.14 por unidad" tiene 31 caracteres pero solo 10 tokens. Una URL como https://example.com/api/v2/users?page=1&limit=50 tiene 49 caracteres pero más de 20 tokens. Dividir por caracteres significa desperdiciar presupuesto de tokens o exceder el límite.

Lo que necesitas es un chunker que hable tokens — que divida en las fronteras de oraciones, respete un límite estricto de tokens por chunk y maneje casos extremos como oraciones más largas que el límite mismo. En este artículo, construiremos exactamente eso.

La Estrategia de Tres Capas

Nuestro chunker usa un enfoque jerárquico de división:

Texto de entrada


┌───────────────────────┐
│ Capa 1: Oraciones      │  Divide en fronteras de oraciones
│ (basada en regex)      │  usando puntuación + espacio
└──────────┬────────────┘

┌───────────────────────┐
│ Capa 2: Empaquetado    │  Empaqueta oraciones en chunks
│ (bin-packing voraz)    │  que caben en el límite de tokens
└──────────┬────────────┘

┌───────────────────────┐
│ Capa 3: Fallback       │  División por carácter para
│ (nivel de carácter)    │  oraciones que exceden el límite
└───────────────────────┘

Cada capa maneja una granularidad diferente. La mayoría del texto pasa por las capas 1 y 2. La capa 3 solo se activa en casos patológicos — un blob base64, un bloque JSON minificado o un párrafo lleno de URLs sin fronteras de oración.

Capa 1: División por Oraciones

Dividir por oraciones suena simple hasta que encuentras "El precio es $3.14. Siguiente artículo." — una división ingenua en . seguido de espacio cortaría después de 3., produciendo un fragmento.

Esta regex maneja los casos comunes:

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

Desglose:

FragmentoPropósito
(?<!\b[0-9]\.)No divide después de decimal como 3.
(?<![0-9])No divide cuando está precedido por dígito
(?<=[.!?。?!])Divide después de puntuación final
(?!\d)No divide si sigue un dígito (ej.: v2.0 es...)
\s+Consume el espacio entre oraciones
/uModo Unicode para puntuación CJK (。?!)

Los lookbehind assertions previenen divisiones falsas en números decimales, strings de versión y abreviaturas, mientras la flag Unicode maneja chino, japonés y otros idiomas que usan puntuación de ancho completo.

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

TIP

La flag PREG_SPLIT_NO_EMPTY filtra strings vacíos que preg_split a veces produce en las fronteras. Sin ella, necesitarías filtrar manualmente los espacios en el loop de empaquetado.

Capa 2: Empaquetado Voraz con Conteo de Tokens

Con las oraciones extraídas, las empaquetamos en chunks usando un algoritmo voraz: agrega oraciones al chunk actual hasta que la siguiente exceda el límite de tokens, luego inicia un nuevo 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);

        // Disparador de capa 3: la oración excede el límite sola
        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;
        }

        // Intenta agregar la oración al chunk actual
        $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 {
            // No cabe — finaliza chunk actual, inicia nuevo
            if ($current_chunk) {
                $chunks[] = $current_chunk;
            }
            $current_chunk = $sentence;
        }
    }

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

    return $chunks;
}

El detalle clave es el enfoque de $test_chunk — contamos tokens en la string combinada (chunk actual + espacio + nueva oración), no en la oración sola. Esto importa porque los tokenizadores no producen conteos aditivos: tokens("A B") ≠ tokens("A") + tokens("B"). El espacio entre oraciones puede fusionarse con caracteres adyacentes en un solo token, o las fronteras de palabras pueden cambiar. Contando la string combinada, obtenemos el costo real de tokens.

WARNING

Evita la tentación de mantener un contador $current_chunk_tokens y sumar $sentence_token_count. Los conteos de tokens no son aditivos en la concatenación. Siempre re-cuenta la string combinada.

Capa 3: Fallback por Carácter

Cuando una sola oración excede el límite de tokens — piensa en un blob JSON minificado, una string base64 o una lista larga de URLs — hacemos fallback a división carácter por carácter:

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 seguridad Unicode — un carácter chino que ocupa 3 bytes y 1 token no será dividido en secuencias de bytes inválidas. El conteo de tokens por carácter es una aproximación aceptable aquí: para caracteres individuales, la salida del tokenizador es determinística, así que el conteo aditivo funciona (a diferencia de la concatenación de oraciones).

TIP

Este fallback es intencionalmente conservador. Sacrifica legibilidad (divide a mitad de palabra) por corrección (nunca excede el límite de tokens). En la práctica, raramente se activa — la mayoría del texto en lenguaje natural tiene fronteras de oración dentro de cualquier límite razonable de tokens.

Agregando Solapamiento para Mejor Recuperación

Los chunks que terminan abruptamente pueden perder contexto que cruza una frontera. Si un usuario pregunta "¿Cuál es la política de devolución para electrónicos?" y la respuesta comienza al final del chunk 3 y continúa en el chunk 4, ningún chunk por sí solo recupera bien.

El solapamiento resuelve esto repitiendo una porción de los chunks adyacentes:

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

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

        // Agrega inicio del siguiente 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;
}

Con 20% de solapamiento, un chunk de 1536 tokens recibe aproximadamente 300 tokens del chunk anterior y 300 del siguiente. Esto crea redundancia que mejora el recall de recuperación a costa de ~40% más almacenamiento.

Chunk 1:  [==========]
Chunk 2:       [==========]      ← se solapa con 1 y 3
Chunk 3:            [==========]

WARNING

El solapamiento ocurre a nivel de carácter, no de token. Esta es una decisión deliberada — el solapamiento basado en caracteres es mucho más rápido y produce resultados "suficientemente buenos" para recuperación. Si necesitas solapamiento preciso por token, tendrías que re-tokenizar y recortar, lo que agrega complejidad significativa.

Uniendo Todo: Pipeline de Parsing de Documentos

Así es como estas piezas se conectan en un pipeline RAG real que procesa documentos subidos:

php
use App\Utils\TextUtil;

class DocumentParser
{
    const TOKENS_PER_CHUNK = 1536;

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

        // Paso 2: Agrega solapamiento para mejor recuperación
        $chunks = TextUtil::overlapChunks($chunks, 0.2);

        // Paso 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));
    }
}

El valor TOKENS_PER_CHUNK = 1536 se eligió para caber bien dentro de los límites comunes de modelos de embedding (la mayoría acepta hasta 8192 tokens) siendo lo suficientemente granular para recuperación precisa. Chunks más pequeños (512–1024 tokens) mejoran la precisión; chunks más grandes (2048–4096) mejoran el contexto. 1536 es un punto medio práctico.

¿Por Qué 1536 Tokens?

El tamaño del chunk afecta directamente la calidad de recuperación:

Tamaño del ChunkPrecisiónContextoMejor Para
256–512AltaBajoFAQ, respuestas cortas
1024–1536EquilibradaEquilibradoDocumentos generales
2048–4096BajaAltoAnálisis extenso

Chunks más pequeños coinciden mejor con consultas específicas (mayor precisión) pero pueden perder contexto circundante. Chunks más grandes preservan contexto pero diluyen el embedding — un chunk de 4000 tokens sobre cinco temas no coincidirá con ningún tema tan fuertemente como un chunk de 1000 tokens sobre un tema.

1536 tokens ≈ 1,100 palabras ≈ 2–3 párrafos. Esto generalmente captura un pensamiento o sección completa sin mezclar contenido no relacionado.

Conteo de Tokens en PHP

Esta implementación usa el paquete rajentrivedi/tokenizer-x para conteo de tokens:

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

// Cuenta tokens en un string
$count = TokenizerX::count("Hola, mundo!");  // 5

// Cuenta con encoding específico
$count = TokenizerX::count("Hola, mundo!", "cl100k_base");

El encoding por defecto (cl100k_base) es compatible con GPT-4 y la mayoría de los modelos modernos de embedding. Si estás usando una familia de modelos diferente, verifica qué tokenizador usa y configura adecuadamente.

TIP

Si no necesitas conteos exactos de tokens, una aproximación rápida es ceil(mb_strlen($text) / 4) — el texto en inglés promedia aproximadamente 4 caracteres por token. Esto no funciona para texto CJK (donde cada carácter es típicamente 1–2 tokens) o para código (donde los tokens son menos predecibles), pero es útil para estimaciones rápidas y casos de prueba.

Conclusión

La división de texto por tokens para RAG en PHP se reduce a tres ideas:

  1. Divide por oraciones primero — Una regex que maneja decimales, strings de versión y puntuación multilingüe proporciona fronteras naturales.
  2. Empaqueta de forma voraz con conteos reales de tokens — No aproximes. Cuenta tokens en la string combinada porque la tokenización no es aditiva.
  3. Haz fallback con gracia — Cuando el texto no tiene fronteras de oración, la división por carácter garantiza que el límite de tokens nunca se exceda.

El paso de solapamiento es opcional pero recomendado para pipelines de recuperación. 20% de solapamiento es un buen punto de partida — auméntalo si estás viendo fallos en la frontera de contexto, disminúyelo si los costos de almacenamiento importan.

Una cosa que esta implementación intencionalmente no hace: chunking semántico (usando embeddings para detectar fronteras de tema). Ese enfoque puede mejorar la recuperación en 15–25% pero cuesta 3–5x más en cómputo y agrega complejidad significativa. Comienza con chunking por tokens y oraciones — funciona bien para la mayoría de los casos y es determinístico, rápido y fácil de depurar. Evoluciona a chunking semántico solo cuando la calidad de recuperación lo exija.