Todo tutorial de RAG chega no chunking e diz a mesma coisa: "experimente valores diferentes". Ninguém mostra os valores. Então dividimos o mesmo corpus de seis jeitos, indexamos os seis, e rodamos as mesmas 300 perguntas contra cada um.
O corpus é o Vantek: 1.240 documentos internos de uma empresa fictícia que a gente montou pra ter algo com a bagunça de um corpus corporativo real — políticas de RH, atas de reunião, tickets de suporte, PDFs de contrato exportados torto. As 300 perguntas foram escritas à mão com o trecho-resposta marcado no documento de origem.
As seis estratégias
| estratégia | tamanho | overlap | chunks | tempo de indexação |
|---|---|---|---|---|
| fixo 256 | 256 tok | 0 | 8.412 | 41 s |
| fixo 512 | 512 tok | 0 | 4.190 | 38 s |
| recursivo 512 | 512 tok | 0 | 4.377 | 44 s |
| recursivo 512/15% | 512 tok | 77 tok | 5.031 | 47 s |
| recursivo 1024/15% | 1024 tok | 154 tok | 2.588 | 43 s |
| semântico (bge-m3) | variável | — | 3.904 | 31 min |
O semântico usa o próprio modelo de embedding pra achar as quebras: embeda frase a frase e corta onde a similaridade entre frases consecutivas cai abaixo de um percentil. É a estratégia que soa mais inteligente e é a que custa 40x mais pra indexar.
Como medimos
A métrica principal é recall@k — a fração de perguntas em que o trecho-resposta anotado apareceu entre os chunks recuperados:
Recall@5 porque cinco chunks é o que cabe confortavelmente na janela junto com o prompt e o histórico, no tamanho de modelo que a gente consegue rodar local.
def recall_at_k(retriever, questions, k=5):
hits = 0
for q in questions:
chunks = retriever.search(q.text, k=k)
# O trecho anotado precisa estar contido inteiro em algum chunk.
# Match parcial não conta: se a resposta foi cortada ao meio,
# o gerador não tem como respondê-la, e é isso que importa.
if any(q.answer_span in c.text for c in chunks):
hits += 1
return hits / len(questions)Resultados
Recall@5 por estratégia de chunking
recall@5 · corpus Vantek · n=300 perguntas
ver como tabela
| recall@5 | recall@5 |
|---|---|
| fixo 256 | 0.61 |
| fixo 512 | 0.68 |
| recursivo 512 | 0.72 |
| recursivo 512/15% | 0.79 |
| recursivo 1024/15% | 0.74 |
| semântico | 0.69 |
O recursivo com 15% de overlap ganhou de todos, inclusive do semântico, por 10 pontos. E a explicação é chata: a maioria das respostas perdidas estava cortada na fronteira do chunk. Overlap não é uma otimização elegante, é um curativo — e o curativo funciona.
O semântico corta em fronteiras que fazem sentido pro modelo de embedding, que não é a mesma coisa que fazer sentido pra pergunta. Ele produziu chunks lindos de ler e perdeu exatamente onde a resposta cruzava dois assuntos.
O overlap tem um teto
Subir o overlap continua ajudando até parar:
Recall@5 conforme o overlap, em chunk de 512 tokens
overlap em % do chunk
ver como tabela
| overlap em % do chunk | recall@5 |
|---|---|
| 0% | 0.72 |
| 10% | 0.77 |
| 15% | 0.79 |
| 25% | 0.79 |
| 40% | 0.78 |
Depois de 15% o ganho some e o índice só engorda. A 40% de overlap você paga 1,6x mais chunks pra ter o mesmo recall de 15%.
O que isso custa
| estratégia | recall@5 | chunks | tokens/consulta | custo relativo de indexação |
|---|---|---|---|---|
| recursivo 512/15% | 0.79 | 5.031 | 2.560 | 1.0x |
| recursivo 1024/15% | 0.74 | 2.588 | 5.120 | 0.9x |
| semântico | 0.69 | 3.904 | 3.140 | 40.2x |
Chunk de 1024 recupera menos e gasta o dobro de token por consulta. Chunk grande parece seguro porque "cabe mais contexto", mas cada chunk grande que entra no top-5 desloca um chunk que talvez tivesse a resposta.
O que a gente ainda não sabe
Isso é um corpus, num idioma, com um modelo de embedding. O bge-m3 é multilíngue e forte em português; um modelo só de inglês provavelmente muda a ordem das estratégias, e principalmente muda o semântico, que depende inteiramente da qualidade do embedding pra achar as quebras.
O próximo é rodar o mesmo protocolo com chunking guiado por estrutura — cortar em cabeçalho de markdown, cláusula de contrato, linha de ata. A intuição é que o ganho do overlap some quando as fronteiras são de verdade, e aí o número interessante passa a ser quanto do corpus tem estrutura recuperável.
Se você rodar isso no seu corpus e der diferente, escreve pra gente. Resultado que não replica é o dado mais útil que existe.