Chunking é a etapa do RAG que mais decide se a busca acha o trecho certo. O que funciona na prática é dividir o documento pela estrutura dele (seções e parágrafos), guardar o caminho de títulos junto de cada pedaço e, quando o conteúdo é ambíguo, pedir a um LLM barato uma frase de contexto para cada chunk antes de gerar o embedding. Mais importante que qualquer receita é medir: um conjunto pequeno de perguntas com a resposta esperada mostra se a mudança no chunking melhorou ou piorou a recuperação.

Já mostrei o fluxo completo em como fazer RAG com pgvector no Laravel. Aqui o foco é só a parte que costuma ser feita com um str_split e esquecida: como o texto vira pedaços.

O que é chunking e por que ele quebra o RAG?

Chunking é dividir um documento em trechos menores (chunks) que vão virar, cada um, um embedding, ou seja, um vetor numérico que representa o significado do texto. A busca vetorial compara o vetor da pergunta com o vetor de cada chunk e devolve os mais próximos.

O problema é que o embedding representa o chunk inteiro como um ponto só. Se o chunk mistura três assuntos, o vetor fica no meio do caminho e não fica perto de nenhuma pergunta específica. Se o chunk é pequeno demais, ele perde o sujeito da frase. Um exemplo típico de base de conhecimento de suporte:

O prazo é de até 2 dias úteis após a solicitação.

Prazo de quê? Antecipação, estorno, saque, troca de maquininha? O parágrafo anterior dizia, mas o chunk não carrega isso. A pergunta "quanto tempo demora o estorno?" pode não trazer esse trecho, ou pior, trazer o trecho de outro prazo que por acaso ficou mais parecido.

Os três sintomas que aparecem quando o chunking está ruim:

  1. O modelo responde "não encontrei essa informação" para algo que está na base.
  2. O modelo responde com o prazo ou a regra de outro produto, com toda a confiança.
  3. A resposta vem certa mas incompleta, porque a regra estava dividida em dois chunks e só um voltou.

Qual o tamanho ideal de chunk para RAG?

Não existe um número certo, existe um trade-off. Como não tenho um tokenizer confiável do lado do PHP, trabalho com caracteres e deixo margem. Em português a relação caractere/token é pior que em inglês, então trato qualquer estimativa como aproximada.

Tamanho do chunk Ganha Perde
Pequeno (algumas frases) Vetor focado, busca precisa Perde o sujeito, a regra fica espalhada em vários chunks
Médio (um ou dois parágrafos, uma subseção) Equilíbrio entre foco e contexto Ainda depende da estrutura do documento ser boa
Grande (seção inteira) Contexto completo para o LLM Vetor diluído, mais tokens no prompt, mais custo por pergunta

Meu ponto de partida costuma ser algo como 1.500 a 2.000 caracteres por chunk, respeitando parágrafo. Mas eu uso esse número só para começar o experimento, não como verdade. O que manda é a avaliação que mostro no fim.

Um detalhe que pesa: o chunk que volta da busca vai inteiro para o prompt do Claude. Se você recupera 8 chunks de 2.000 caracteres, são 16 mil caracteres de contexto em toda pergunta. Chunk grande também é decisão de custo.

Como dividir um documento respeitando a estrutura?

O pior chunker é o que corta a cada N caracteres. Ele quebra frase no meio, separa título do conteúdo e junta o fim de uma seção com o começo de outra. Quase todo conteúdo de base de conhecimento já tem estrutura: títulos, parágrafos, listas. Eu converto tudo para Markdown na ingestão (HTML do help center, texto extraído de PDF) e divido em duas etapas:

  1. Por seção, usando os títulos #, ## e ###, e guardando o caminho de títulos (o "breadcrumb", tipo Estornos > Cartão de crédito > Prazos).
  2. Dentro de cada seção grande demais, por parágrafo, acumulando até o limite.
final class MarkdownChunker
{
    public function __construct(
        private int $maxChars = 2000,
        private int $overlapChars = 200,
    ) {}

    /** @return array<int, array{heading_path: string, content: string}> */
    public function chunk(string $markdown): array
    {
        $chunks = [];

        foreach ($this->splitBySections($markdown) as $section) {
            foreach ($this->splitSection($section['content']) as $piece) {
                $chunks[] = [
                    'heading_path' => $section['heading_path'],
                    'content' => $piece,
                ];
            }
        }

        return $chunks;
    }

    private function splitBySections(string $markdown): array
    {
        $path = [];
        $buffer = [];
        $sections = [];

        $flush = function () use (&$buffer, &$sections, &$path) {
            $text = trim(implode("\n", $buffer));
            if ($text !== '') {
                $sections[] = ['heading_path' => implode(' > ', $path), 'content' => $text];
            }
            $buffer = [];
        };

        foreach (preg_split('/\R/', $markdown) as $line) {
            if (preg_match('/^(#{1,3})\s+(.+)$/', $line, $m)) {
                $flush();
                $level = strlen($m[1]);
                $path = array_filter($path, fn ($k) => $k < $level, ARRAY_FILTER_USE_KEY);
                $path[$level] = trim($m[2]);
                continue;
            }
            $buffer[] = $line;
        }

        $flush();

        return $sections;
    }

    private function splitSection(string $text): array
    {
        if (mb_strlen($text) <= $this->maxChars) {
            return [$text];
        }

        $pieces = [];
        $current = '';

        foreach (preg_split('/\n{2,}/', $text) as $paragraph) {
            if ($current !== '' && mb_strlen($current) + mb_strlen($paragraph) > $this->maxChars) {
                $pieces[] = $current;
                $current = $this->tail($current);
            }
            $current = ltrim($current . "\n\n" . $paragraph);
        }

        if (trim($current) !== '') {
            $pieces[] = $current;
        }

        return $pieces;
    }

    private function tail(string $text): string
    {
        $tail = mb_substr($text, -$this->overlapChars);
        $pos = mb_strpos($tail, '. ');

        return $pos === false ? $tail : mb_substr($tail, $pos + 2);
    }
}

Algumas decisões que estão nesse código e valem explicitar:

  • O título não entra no corpo do chunk, vai para heading_path. Isso me deixa decidir depois se ele entra no texto do embedding (entra) e no texto mostrado ao LLM (também, mas formatado).
  • Paragrafo gigante (tabela convertida, lista longa) passa do limite sozinho. Nesse caso eu aceito o chunk maior ou divido por linha de tabela repetindo o cabeçalho. Cortar tabela no meio sem cabeçalho gera chunk inútil.
  • Código e listas numeradas eu evito quebrar no meio. Passo 3 sem os passos 1 e 2 raramente ajuda alguém.

Overlap entre chunks ajuda ou só duplica?

Overlap é repetir o final de um chunk no começo do próximo, para que uma ideia que cruza a fronteira apareça inteira em pelo menos um deles. Ajuda quando o texto é corrido, como contrato, política ou termo de uso, em que a regra começa num parágrafo e a exceção vem no seguinte.

O custo é real: mais chunks, mais embeddings, e a busca passa a devolver dois chunks quase iguais ocupando duas das cinco vagas do top-k. Por isso, no código acima, o overlap só acontece quando uma seção precisa ser dividida, e é pequeno, cortado no começo de uma frase. Entre seções diferentes não faz sentido: o título já marca a mudança de assunto.

Se você notar chunks vizinhos aparecendo juntos no resultado com frequência, uma alternativa melhor que aumentar o overlap é recuperar o chunk e expandir para os vizinhos na hora de montar o prompt, usando a coluna de posição. A busca fica precisa e o LLM recebe o contexto ao redor.

Como manter o contexto do documento dentro de cada chunk?

O jeito mais barato é o breadcrumb. Em vez de gerar o embedding só do conteúdo, eu gero de um texto montado assim:

$embeddedText = implode("\n", array_filter([
    "Documento: {$document->title}",
    $chunk['heading_path'] ? "Seção: {$chunk['heading_path']}" : null,
    $chunk['content'],
]));

Só isso já resolve boa parte do caso "o prazo é de até 2 dias úteis": o chunk passa a dizer Seção: Estornos > Cartão de crédito. Funciona bem quando o documento tem títulos bons. Quando não tem (PDF escaneado, texto corrido, FAQ mal organizado), entra a técnica seguinte.

O que é contextual retrieval e como fazer com Claude no Laravel?

Contextual retrieval é uma técnica publicada pela Anthropic: antes de gerar o embedding, você manda o documento inteiro mais o chunk para um LLM e pede uma ou duas frases que situem aquele trecho dentro do documento. Essa frase é prefixada ao chunk, tanto no embedding quanto no índice de busca textual.

Parece caro, porque o documento inteiro vai em toda chamada. O que torna viável é o prompt caching, recurso da API do Claude que guarda o prefixo repetido da requisição e cobra bem menos pelas leituras seguintes. O documento vai num bloco marcado com cache_control e só o chunk muda entre as chamadas. Detalhei o funcionamento do cache em prompt caching e Batch API do Claude no Laravel.

Chamo a API direto com o Http:: do Laravel:

final class ContextualizeDocumentChunks implements ShouldQueue
{
    use Queueable;

    public int $timeout = 600;

    public function __construct(public int $documentId) {}

    public function handle(): void
    {
        $document = Document::with('chunks')->findOrFail($this->documentId);

        foreach ($document->chunks()->whereNull('context')->orderBy('position')->cursor() as $chunk) {
            $response = Http::withHeaders([
                'x-api-key' => config('services.anthropic.key'),
                'anthropic-version' => '2023-06-01',
            ])->timeout(60)->retry(2, 1000)->post('https://api.anthropic.com/v1/messages', [
                'model' => config('services.anthropic.context_model'),
                'max_tokens' => 150,
                'system' => 'Você situa trechos de documentos para melhorar a busca. Responda só com o contexto, em português, sem preâmbulo.',
                'messages' => [[
                    'role' => 'user',
                    'content' => [
                        [
                            'type' => 'text',
                            'text' => "<documento>\n{$document->content}\n</documento>",
                            'cache_control' => ['type' => 'ephemeral'],
                        ],
                        [
                            'type' => 'text',
                            'text' => "<trecho>\n{$chunk->content}\n</trecho>\n\nEscreva 1 ou 2 frases curtas dizendo de que produto, processo ou regra este trecho trata dentro do documento.",
                        ],
                    ],
                ]],
            ]);

            $chunk->update(['context' => trim($response->json('content.0.text'))]);
        }

        GenerateChunkEmbeddings::dispatch($document->id);
    }
}

O que importa nesse job:

  1. Todos os chunks do mesmo documento são processados em sequência no mesmo job. O cache do Claude tem vida curta, então espalhar os chunks de um documento por vários workers em momentos diferentes desperdiça o cache.
  2. O whereNull('context') torna o job retomável. Se ele cair no chunk 40, a próxima tentativa começa do 41 e não paga de novo os 40 primeiros.
  3. Uso um modelo pequeno, como o Haiku, configurado em services.anthropic.context_model. A tarefa é resumir onde o trecho está, não raciocinar.
  4. O cache tem tamanho mínimo de prompt; documento curto demais não é cacheado. Para documento curto, o breadcrumb costuma bastar e eu pulo essa etapa.

Antes de ligar isso para a base inteira, vale estimar: número de documentos vezes tamanho médio vezes número de chunks, com e sem cache. A calculadora de custo de IA faz essa conta por modelo. Como reindexação de base inteira não é urgente, ela também é boa candidata para a Batch API.

O contexto gerado também é texto que sai de um LLM. Eu salvo, mas não confio cegamente: limito o tamanho, descarto se vier vazio ou se repetir o trecho inteiro, e nunca mostro esse contexto ao usuário final como se fosse parte do documento original.

Como guardar os chunks no PostgreSQL com pgvector?

Separo o que é mostrado ao LLM do que é usado para buscar. A tabela fica assim:

Schema::create('document_chunks', function (Blueprint $table) {
    $table->id();
    $table->foreignId('document_id')->constrained()->cascadeOnDelete();
    $table->unsignedInteger('position');
    $table->string('heading_path', 500)->nullable();
    $table->text('content');
    $table->text('context')->nullable();
    $table->string('chunker_version', 20);
    $table->char('content_hash', 64);
    $table->timestamps();

    $table->unique(['document_id', 'position']);
});

DB::statement('ALTER TABLE document_chunks ADD COLUMN embedding vector(1536)');
  • content é o trecho original, que vai para o prompt do Claude na hora de responder.
  • context e heading_path entram no texto do embedding e no índice de full-text search. Se você combina as duas buscas, como em busca híbrida com pgvector e full-text search do PostgreSQL, o contexto ajuda as duas.
  • chunker_version registra com qual configuração aquele chunk foi gerado. Quando mudo tamanho ou regra de divisão, subo a versão e reprocesso só o que está na versão antiga, em vez de apagar tudo de uma vez.
  • content_hash evita gerar contexto e embedding de novo para chunks que não mudaram quando o documento é reeditado.
  • position permite buscar os vizinhos de um chunk para expandir o contexto no prompt.

O 1536 é a dimensão do text-embedding-3-small da OpenAI. Se usar outro modelo de embedding, ajuste.

Como saber se o chunking melhorou a busca do RAG?

Sem medir, cada ajuste vira opinião. Eu mantenho um arquivo com algumas dezenas de perguntas reais (tiradas do atendimento, reescritas sem dado pessoal) e, para cada uma, o documento e um trecho de texto que precisa aparecer nos resultados. Uso trecho de texto em vez de ID do chunk porque o ID muda toda vez que eu recorto diferente.

// tests/rag/retrieval.json
[
  {"question": "Quanto tempo demora o estorno no cartão?", "document_id": 12, "must_contain": "2 dias úteis"},
  {"question": "Posso antecipar só parte das vendas?", "document_id": 7, "must_contain": "antecipação parcial"}
]
Artisan::command('rag:eval {--k=5}', function (Retriever $retriever) {
    $cases = json_decode(file_get_contents(base_path('tests/rag/retrieval.json')), true);
    $hits = 0;

    foreach ($cases as $case) {
        $found = collect($retriever->search($case['question'], (int) $this->option('k')))
            ->contains(fn ($chunk) => $chunk->document_id === $case['document_id']
                && str_contains($chunk->content, $case['must_contain']));

        $found ? $hits++ : $this->warn("Falhou: {$case['question']}");
    }

    $this->info(sprintf('recall@%d: %.1f%%', $this->option('k'), 100 * $hits / count($cases)));
});

Isso mede recall@k: a fração de perguntas em que o trecho certo apareceu entre os k primeiros resultados. É uma métrica só da recuperação, separada da qualidade da resposta final. Se o trecho certo não volta da busca, nenhum prompt conserta.

O fluxo de experimento fica:

  1. Rodar rag:eval na configuração atual e anotar o número e a lista de falhas.
  2. Reindexar numa tabela ou schema separado com a nova configuração (tamanho, overlap, breadcrumb, contexto).
  3. Rodar de novo e comparar, olhando também quais perguntas passaram a falhar, não só o total.
  4. Só então trocar em produção, subindo o chunker_version.

A lista de falhas costuma ensinar mais que o percentual. Geralmente ela aponta um tipo de documento problemático, como FAQ sem títulos ou tabela de tarifas, e a correção é específica para ele.

Quais são os erros mais comuns de chunking?

  1. Cortar por número fixo de caracteres, sem olhar parágrafo ou título.
  2. Gerar embedding do chunk sem o título da seção, deixando trechos sem sujeito.
  3. Overlap grande "por segurança", que enche o top-k de duplicatas.
  4. Usar a mesma regra para todo tipo de documento: política corrida, FAQ e tabela de preços pedem tratamentos diferentes.
  5. Apagar e reindexar tudo a cada ajuste, sem versão e sem hash, pagando embedding e contexto de novo para o que não mudou.
  6. Mexer no chunking sem um conjunto de perguntas para comparar antes e depois.

Conclusão: por onde começar

Se o seu RAG hoje erra o trecho, a ordem que eu seguiria é: primeiro montar o arquivo de perguntas e rodar o recall@k, para ter um número de partida. Depois trocar o corte fixo por divisão por seção e parágrafo, com o breadcrumb no texto do embedding. Isso é barato, não depende de LLM e costuma resolver a maior parte dos casos em documentação bem estruturada. Só para os documentos que continuarem falhando, como PDFs sem título e textos corridos, ligue o contextual retrieval com um modelo pequeno e prompt caching, processando chunk a chunk por documento num job retomável. Guarde versão e hash em cada chunk desde o primeiro dia: é isso que permite testar uma nova configuração sem medo de jogar fora a base inteira.