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:
- O modelo responde "não encontrei essa informação" para algo que está na base.
- O modelo responde com o prazo ou a regra de outro produto, com toda a confiança.
- 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:
- Por seção, usando os títulos
#,##e###, e guardando o caminho de títulos (o "breadcrumb", tipoEstornos > Cartão de crédito > Prazos). - 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:
- 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.
- 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. - Uso um modelo pequeno, como o Haiku, configurado em
services.anthropic.context_model. A tarefa é resumir onde o trecho está, não raciocinar. - 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.contexteheading_pathentram 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_versionregistra 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_hashevita gerar contexto e embedding de novo para chunks que não mudaram quando o documento é reeditado.positionpermite 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:
- Rodar
rag:evalna configuração atual e anotar o número e a lista de falhas. - Reindexar numa tabela ou schema separado com a nova configuração (tamanho, overlap, breadcrumb, contexto).
- Rodar de novo e comparar, olhando também quais perguntas passaram a falhar, não só o total.
- 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?
- Cortar por número fixo de caracteres, sem olhar parágrafo ou título.
- Gerar embedding do chunk sem o título da seção, deixando trechos sem sujeito.
- Overlap grande "por segurança", que enche o top-k de duplicatas.
- Usar a mesma regra para todo tipo de documento: política corrida, FAQ e tabela de preços pedem tratamentos diferentes.
- 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.
- 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.