Pular para o conteúdo

Trilha de RAG · parte 7 de 12

Chunking: cortando documentos em pedaços que fazem sentido

Como cortar documentos grandes em pedaços que cabem no contexto e ainda fazem sentido sozinhos, e como medir se o corte funciona.

Publicada em · 56 min de leitura

Tamanho da fonte:
Largura da coluna:

Chunking é o corte de um documento em pedaços que cabem no modelo e fazem sentido sozinhos. Cada pedaço ganha o seu próprio vetor e é a menor unidade que a busca consegue achar, então a decisão de onde cortar pesa mais na qualidade final do que a escolha do banco ou do modelo de embedding. Ao terminar, você deve entender por que um documento longo não é indexado inteiro, como escolher o tamanho e a sobreposição dos pedaços, os cortes por tamanho, por estrutura e semânticos, e como medir se um corte funciona.

A aula parte do zero, mas pressupõe o que é um embedding. A escada vai de uma analogia com páginas de livro até um experimento de 56 perguntas sobre 9 documentos, passando por seis estratégias de corte. Todo o código das seções 4 a 16 foi executado por mim, numa máquina com macOS e Python 3.12, e as saídas coladas são as que o programa imprimiu. O que muda com o tempo vem da documentação consultada em 15 de outubro de 2026, e o que eu não consegui verificar está declarado na seção de fontes. As figuras são animadas e foram geradas com o ArchFlow.

Mapa da aula

ParteSeçõesPergunta que responde
1. Do documento ao pedaço1 a 3O que é um pedaço, por que cortar e que forma ele tem em cada tipo de documento?
2. O tamanho4 a 7O que acontece com a busca quando o pedaço é pequeno ou grande, e como medir isso?
3. Estratégias de corte8 a 13Onde passar a tesoura: por tamanho, por separadores, por estrutura, por frases, com sobreposição ou por significado?
4. Além do corte14 a 16Como devolver mais do que o vetor achou, dar contexto ao pedaço e tratar tabelas e código?
5. Escolher e seguir17 a 20Como decidir para os seus documentos, que erros evitar e para ir além?

Pré-requisito: Banco de dados vetorial. Os diagramas seguem um mapa de cores fixo, com uma cor a mais: roxo para o corte (o chunking e as estratégias), laranja para o que a pessoa pergunta, azul para vetores, índices e decisões, verde para pedaços e resultados bons, cinza para o que é neutro e rosa para o que dá errado, custa caro ou ainda não foi ensinado (a ingestão aparece em rosa tracejado na primeira figura).

Parte 1: Do documento ao pedaço

1. Páginas que fazem sentido sozinhas

Imagine um livro de receitas de trezentas páginas e a pergunta “quanto tempo de forno leva o bolo de cenoura?”. Quem sabe a resposta abre o livro na receita certa e lê o trecho do forno. Ninguém entrega o livro inteiro ao vizinho que perguntou, e ninguém recorta o livro com uma tesoura a cada dez centímetros, porque o recorte cairia no meio de “asse por” e a página resultante diria só “40 minutos”, sem dizer de quê. O que funciona é uma página que traga o nome da receita, os ingredientes e o tempo, ou seja, um pedaço de livro que se explica sozinho.

Chunking é o nome técnico dessa tesoura. Um chunk (em português, pedaço ou trecho) é a unidade de texto que o RAG indexa e devolve: cada pedaço ganha o seu próprio vetor, entra no banco como um registro e, na hora da pergunta, é o pedaço inteiro que volta para o prompt. Chunking é, portanto, a decisão sobre o que será a menor coisa que a busca consegue achar. Escolher mal significa que o fato existe no acervo e a busca não consegue entregá-lo.

onde entra
figura 1 · onde entra

A figura mostra o caminho inteiro de uma só vez. Em cima, o caminho de escrita: documentos (cinza) passam pela ingestão (rosa tracejado, que lê o arquivo e entrega texto limpo), chegam ao chunking (roxo, pulsando, porque é o assunto de hoje), viram vetores no embedding (azul) e entram no banco (cilindro verde). Os três quadrados roxos embaixo do chunking são as decisões que ele toma: tamanho, limites e contexto. Embaixo, o caminho de leitura: a pergunta laranja é embutida pelo mesmo modelo, busca no banco, e o que volta (setas verdes) são os pedaços, que o modelo vai ler. Repare que o laço dos dois caminhos se fecha no banco: o que a leitura acha foi decidido pela escrita.

2. Por que cortar

Existiam documentos longos antes do RAG, e a pergunta natural é por que não indexar cada documento como está. Quatro razões respondem, e cada uma aparece sozinha em algum cenário real.

A primeira é o limite de entrada do modelo de embedding. Todo modelo aceita um número máximo de tokens (um token é um pedaço de palavra, e o limite de cada modelo de embedding foi medido em Embeddings). O que passa do limite não entra no vetor: o modelo pequeno desta aula lê 128 tokens, e outro que usei lê 512. Um manual de cinco mil tokens ficaria reduzido ao começo.

A segunda é a diluição. Um vetor tem tamanho fixo (384 números nos modelos que usei) e precisa resumir tudo o que o texto diz. Quando o texto trata de um assunto, o vetor aponta para ele. Quando trata de nove, o vetor vira uma média que não aponta com força para nenhum. A seção 5 mede isso.

A terceira é a precisão da recuperação. A busca devolve unidades inteiras. Se a unidade é o manual, a busca acertou o manual e deixou ao modelo a tarefa de achar a frase dentro dele, e quanto mais texto irrelevante em volta, maior a chance de o modelo se distrair ou de a resposta sair genérica. Se a unidade é a seção sobre defeitos, o que chega ao prompt já é quase só o que interessa.

A quarta é o custo de contexto. A janela de contexto (LLMs) é finita e cada token entregue é processado e cobrado. Três pedaços de cinco mil tokens enchem a janela e custam cem vezes mais que três pedaços de cinquenta. Nas minhas medidas, a mesma pergunta cobrava em média 1.716 tokens de contexto quando a unidade era o documento inteiro e 314 quando era um pedaço de até 128 tokens.

por que cortar
figura 2 · por que cortar

O manual cinza, à esquerda, tem 5.000 tokens e nove assuntos misturados. O caminho de cima (rosa, “Sem cortar”) leva o texto inteiro ao modelo e a primeira seta é barrada com ✕ no limite de entrada: só o começo passa (seta pontilhada “só o começo”) e vira um vetor único que é a média de nove assuntos. Dele saem as outras três dores, em pontilhado: diluição, precisão e custo. O caminho de baixo (roxo, “Cortando”) passa pelo chunking e gera três pedaços verdes, cada um com um assunto e um vetor próprio. Quando a pergunta laranja (“prazo de um produto com defeito?”) chega, a seta grossa vai direto ao pedaço certo, o do defeito, e os outros dois nem entram na conversa.

Falta um aviso de equilíbrio: cortar tem preço. Um pedaço pequeno demais perde o contexto de que precisa para fazer sentido, e a próxima parte da aula mostra onde está o ponto de equilíbrio.

3. O pedaço em cada tipo de documento

Não existe um tamanho universal, porque a unidade natural muda com o documento. Uma boa pergunta para fazer antes de qualquer configuração é: qual é a menor parte deste texto que ainda se sustenta sozinha?

Numa FAQ, é o par pergunta e resposta. Cortar no meio da resposta destrói o par, e juntar dez perguntas num pedaço só dilui. Num contrato, é a cláusula, com o número dela (“cláusula 7.2”) porque as remissões de uma cláusula a outra dependem disso. Numa ata de reunião, é um tópico de pauta com as decisões tomadas, e a data e os participantes são metadados que valem para todos os pedaços da ata. Num manual como o do corpus desta aula, é a seção sob um título de segundo nível, com o título junto. Em código-fonte, é a função ou a classe, e pela mesma razão que um parágrafo não pode ser cortado no meio de uma frase, uma função não pode ser cortada no meio de uma linha. Numa tabela, é a linha acompanhada do cabeçalho, porque “6 a 9 dias úteis” sozinho não diz de que região nem de que modalidade se trata.

Esses seis casos têm em comum uma ideia que atravessa a aula toda: o chunking é uma decisão de modelagem dos seus documentos, e a biblioteca apenas a executa. Os números (100 tokens, 20% de sobreposição) vêm depois, e as estratégias das seções 8 a 13 são formas diferentes de adivinhar onde fica a unidade natural quando ninguém a marcou no texto.

Parte 2: O tamanho

4. Tokens e o limite de entrada

O tamanho de um pedaço se conta em tokens, e não em caracteres ou palavras, porque são os tokens que o modelo de embedding conta no limite e que o LLM conta na janela. Para o tokenizador dos modelos que usei, o corpus desta aula (3.619 palavras) tem 5.027 tokens, uma razão de cerca de 1,4 token por palavra em português. Medir em caracteres ou palavras e torcer para dar certo é um erro comum, e a seção 18 volta a ele.

Os dois modelos de embedding que uso nos experimentos têm limites bem diferentes. O código abaixo lê o limite de cada um e a saída é a que o programa imprimiu:

print("modelo", {n: modelo(n).max_seq_length for n in MODELOS})
modelo {'minilm': 128, 'e5': 512}

O primeiro é o paraphrase-multilingual-MiniLM-L12-v2 (o mesmo usado em Banco de dados vetorial, 384 dimensões), que lê 128 tokens. O segundo é o multilingual-e5-small, também de 384 dimensões, que lê 512. Conferi que os dois usam o mesmo tokenizador para o corpus: os identificadores de tokens dos nove documentos saíram idênticos nos dois, então o tamanho em tokens vale para ambos. O sentence-transformers corta em silêncio o que passa de max_seq_length, e é isso que a figura seguinte desenha.

limite truncamento
figura 3 · limite truncamento

À esquerda, um pedaço de 256 tokens com dois fatos, A no começo e B no fim. Ele se divide em duas metades: os tokens 1 a 128 (verde, “cabe”) seguem para o modelo MiniLM, que gira porque está calculando, e viram um vetor de 384 números que só descreve o fato A. Os tokens 129 a 256 (rosa tracejado, “passa do limite”) chegam ao modelo e são barrados com ✕. O fato B existe no texto que o RAG guarda e devolve, mas não existe no vetor, de modo que uma pergunta sobre ele nunca encontra esse pedaço (segunda seta barrada, em baixo). A caixa inferior traz a medida: no MiniLM, o corte recursivo em pedaços de 64 tokens acertou o pedaço certo em primeiro lugar em 71% das perguntas, e em pedaços de 256 tokens, em 50%.

O ponto prático é que o limite do modelo é o teto do tamanho do pedaço, e o tamanho ideal costuma ficar bem abaixo dele. A seção 7 mostra os dois números lado a lado.

5. Diluição: um fato afogado em texto alheio

Antes dos testes de recuperação, vale isolar a diluição num experimento pequeno. Para cada uma das 56 perguntas do corpus, peguei a frase que responde (a frase-resposta) e medi se ela vence uma isca: a frase mais parecida com a pergunta, em todo o corpus, que não é a resposta. Depois repeti o teste com a frase-resposta no meio de 2, 5, 10 e 20 frases sorteadas de outros documentos. Se a diluição existe, a frase-resposta vai ganhando cada vez menos da isca à medida que o texto em volta cresce. O código, completo:

import random, numpy as np
from avaliar import *
from chunkers import *

# frases candidatas de todo o corpus (sem títulos, tabelas e código)
frases = [(d, DOCS[d][a:b]) for d in DOCS for a, b in unidades(DOCS[d]) if not DOCS[d][a:b].startswith(("#", "|", "`"))]
for nome in ("e5", "minilm"):
    F = embute(nome, [s for _, s in frases], "passage")
    Q = embute(nome, [q for _, q, _ in QS], "query")
    # "isca": a frase mais parecida com a pergunta que NÃO é a resposta
    isca = []
    for i, (d, q, ouro) in enumerate(QS):
        sims = F @ Q[i]
        for j in np.argsort(-sims):
            if ws(frases[j][1]) != ouro and ouro not in ws(frases[j][1]): isca.append((frases[j][1], float(sims[j]))); break
    print(f"== {nome} (limite de entrada {modelo(nome).max_seq_length} tokens)")
    for k in (0, 2, 5, 10, 20):
        random.seed(7); vence = 0; cs = []; tk = []
        for i, (d, q, ouro) in enumerate(QS):
            ruido = random.sample([s for dd, s in frases if dd != d and s != isca[i][0]], k)
            texto = " ".join(ruido[: k // 2] + [ouro] + ruido[k // 2:])
            c = float(Q[i] @ embute(nome, [texto], "passage")[0]); cs.append(c); tk.append(ntok(texto))
            vence += c > isca[i][1]
        print(f"k={k:2d} tokens={np.mean(tk):5.0f}  cosseno médio da resposta diluída={np.mean(cs):.3f}  vence a isca em {vence}/{len(QS)}")

A saída, tal como o programa imprimiu:

== e5 (limite de entrada 512 tokens)
k= 0 tokens=   21  cosseno médio da resposta diluída=0.859  vence a isca em 28/56
k= 2 tokens=   57  cosseno médio da resposta diluída=0.865  vence a isca em 34/56
k= 5 tokens=  111  cosseno médio da resposta diluída=0.855  vence a isca em 27/56
k=10 tokens=  200  cosseno médio da resposta diluída=0.846  vence a isca em 16/56
k=20 tokens=  386  cosseno médio da resposta diluída=0.843  vence a isca em 15/56
== minilm (limite de entrada 128 tokens)
k= 0 tokens=   21  cosseno médio da resposta diluída=0.573  vence a isca em 29/56
k= 2 tokens=   57  cosseno médio da resposta diluída=0.507  vence a isca em 17/56
k= 5 tokens=  109  cosseno médio da resposta diluída=0.499  vence a isca em 14/56
k=10 tokens=  199  cosseno médio da resposta diluída=0.441  vence a isca em 7/56
k=20 tokens=  384  cosseno médio da resposta diluída=0.383  vence a isca em 4/56

Com o e5, a frase-resposta sozinha (21 tokens) vence a isca em 28 das 56 perguntas. A metade que perde são perguntas em que outra frase do corpus fala do mesmo assunto de modo parecido, o que faz da isca um adversário duro para qualquer modelo. Com 200 tokens de ruído, a vitória cai para 16, e com 386 tokens, para 15. O MiniLM cai mais (29, 7 e 4), mas ele mistura dois efeitos: além de diluir, o limite de 128 tokens passa a esconder a frase-resposta (que fica no meio do texto) à medida que o texto cresce. Há ainda um ponto fora da curva (34 vitórias com 57 tokens no e5), e um conjunto de 56 perguntas tem ruído suficiente para isso acontecer, então leia a tendência e não o valor de cada linha.

Um aviso de leitura: o cosseno médio do e5 só variou de 0,859 para 0,843 do menor para o maior texto, uma queda aparentemente minúscula. A razão é que os cossenos do e5 vivem numa faixa estreita (nas medidas deste corpus, de 0,83 a 0,95 entre frases vizinhas e em torno de 0,85 entre pergunta e resposta), e o que pesa no ranking é a diferença entre candidatos. Os números absolutos de similaridade não devem ser comparados entre modelos.

diluicao
figura 4 · diluicao

A pergunta laranja se compara com três textos que contêm o mesmo fato: sozinho (verde, 21 tokens), com 10 frases alheias (cinza, 200 tokens) e com 20 (rosa, 386 tokens). O losango “Vence a isca?” recebe o cosseno de cada um e o da isca rosa, e a espessura e a velocidade das setas mostram o quanto cada versão pesa: grossa e rápida para o texto curto, fina e lenta para o diluído. As três caixas à direita trazem o placar de cada versão nos dois modelos, e a última seta é barrada com ✕: com 386 tokens, na maioria das perguntas o texto com a resposta perde para a isca.

6. Como medir: o corpus, as perguntas e o hit rate

Para comparar tamanhos e estratégias sem confiar na intuição, montei um mini-corpus. São 9 documentos em Markdown de um e-commerce fictício (trocas, entregas, pagamentos, conta, fidelidade, assinatura, vendedores, suporte e API), com 3.619 palavras e 5.027 tokens. Eles têm títulos, uma tabela de prazos, uma tabela de planos de suporte e um bloco de código, justamente para exercitar os casos difíceis. Escrevi os documentos de modo que várias seções se pareçam (quase todos falam de “prazo”, por exemplo), o que obriga o pedaço a carregar o assunto para ser achado.

Escrevi também 56 perguntas, em linguagem diferente da dos documentos (“até quando posso trocar uma geladeira que chegou com defeito?” para uma frase que fala em “90 dias corridos para itens duráveis”). Cada pergunta traz uma frase-chave, e o código encontra no documento a frase inteira (ou a linha da tabela) que a contém, que passa a ser a frase-resposta:

def ws(s):
    return re.sub(r"\s+", " ", s).strip()


def unidade(doc, frase):
    """Frase (ou linha de tabela) do documento que contém a frase-chave."""
    t = DOCS[doc]
    assert t.count(frase) == 1, (doc, frase, t.count(frase))
    i = t.index(frase); j = i + len(frase)
    a = max(t.rfind(". ", 0, i) + 2, t.rfind("\n", 0, i) + 1)
    cands = [x for x in (t.find(". ", j) + 1 if t.find(". ", j) >= 0 else 10**9,
                         t.find("\n", j) if t.find("\n", j) >= 0 else 10**9, len(t)) ]
    b = min(cands)
    return ws(t[a:b])


QS = [(d, q, unidade(d, f)) for d, q, f in PERGUNTAS]

Com isso, a métrica fica simples de definir. Um pedaço acerta quando contém a frase-resposta inteira, e não metade dela, porque meia resposta não serve ao modelo. O hit rate em k (hit@k) é a fração das perguntas para as quais algum dos k primeiros pedaços devolvidos acerta. O MRR é a média do inverso da posição do primeiro acerto. A cobertura (cob) é a fração das perguntas cuja frase-resposta existe inteira em algum pedaço, quer a busca o ache ou não, e serve para separar dois problemas: se a cobertura é baixa, o corte destruiu a resposta, e nenhuma busca vai achá-la. Por fim, ctx3 é o número médio de tokens que os três primeiros resultados entregariam ao modelo, o custo da seção 2. O código que faz tudo isso:

class Pedaco:
    """emb = texto que vira vetor; ret = texto que o RAG entregaria ao modelo."""
    def __init__(self, doc, emb, ret=None):
        self.doc, self.emb, self.ret = doc, emb, emb if ret is None else ret


def avaliar(nome, pedacos, ks=(1, 3, 5), dedup=True):
    E = embute(nome, [p.emb for p in pedacos], "passage")
    Q = embute(nome, [q for _, q, _ in QS], "query")
    ordem = np.argsort(-(Q @ E.T), axis=1)
    hits = {k: 0 for k in ks}; rr = 0; ctx = []; h1 = []; h3 = []
    textos = {}
    for p in pedacos: textos.setdefault(p.doc, set()).add(ws(p.ret))
    cob = sum(any(ouro in t for t in textos[doc]) for doc, _, ouro in QS) / len(QS)
    for i, (doc, _, ouro) in enumerate(QS):
        vistos, topo = set(), []
        for j in ordem[i]:
            chave = pedacos[j].ret if dedup else j
            if chave in vistos: continue
            vistos.add(chave); topo.append(pedacos[j])
            if len(topo) == max(ks): break
        acertos = [ouro in ws(p.ret) for p in topo]
        for k in ks: hits[k] += any(acertos[:k])
        h1.append(bool(acertos[0])); h3.append(any(acertos[:3]))
        rr += next((1 / (r + 1) for r, a in enumerate(acertos) if a), 0)
        ctx.append(sum(ntok(p.ret) for p in topo[:3]))
    n = len(QS)
    out = {f"hit@{k}": hits[k] / n for k in ks}
    out.update(cob=cob, h1=h1, h3=h3, mrr=rr / n, n=len(pedacos), tok=float(np.mean([ntok(p.ret) for p in pedacos])), ctx3=float(np.mean(ctx)))
    return out

Duas ressalvas de honestidade pesam sobre todos os resultados. A primeira é o tamanho do teste: com 56 perguntas, o erro de um hit rate em torno de 0,9 é de cerca de 8 pontos percentuais para mais ou para menos (intervalo de 95%, pela aproximação binomial), e perto de 0,5 chega a 13. Diferenças menores que isso, entre configurações, são ruído, a menos que o teste pareado (que olha pergunta a pergunta) diga o contrário, e usarei os dois. A segunda é o tamanho do corpus: com só 9 documentos, achar o documento certo já resolve boa parte do problema, o que favorece pedaços grandes. Por isso olho sempre o hit@1 junto do custo em tokens, e não apenas o hit@3.

7. O tamanho, medido

O primeiro experimento varia o tamanho do pedaço nos dois modelos. Para cada tamanho, corto o corpus de duas formas (tamanho fixo e recursivo, que a seção 9 explica) e meço tudo. O script:

from avaliar import *
from chunkers import fixo, recursivo
import sys
print("modelo", {n: modelo(n).max_seq_length for n in MODELOS})
for nome in ("minilm", "e5"):
    print(f"\n== {nome}: tamanho fixo, sem sobreposição")
    for tam in (16, 32, 64, 96, 128, 192, 256, 384, 512):
        r = avaliar(nome, construir(fixo, tamanho=tam))
        print(linha(f"fixo {tam} tokens", r))
    r = avaliar(nome, [Pedaco(d, DOCS[d]) for d in DOCS])
    print(linha("documento inteiro (1 por doc)", r))
    print(f"-- {nome}: recursivo")
    for tam in (32, 64, 128, 256, 512):
        r = avaliar(nome, construir(recursivo, tamanho=tam))
        print(linha(f"recursivo {tam} tokens", r))

E a saída, sem cortes:

modelo {'minilm': 128, 'e5': 512}

== minilm: tamanho fixo, sem sobreposição
fixo 16 tokens                     n= 317 tok=  16.0  hit@1=0.02 hit@3=0.04 hit@5=0.04 cob=0.04 mrr=0.02 ctx3=    48
fixo 32 tokens                     n= 160 tok=  31.6  hit@1=0.38 hit@3=0.43 hit@5=0.45 cob=0.48 mrr=0.40 ctx3=    96
fixo 64 tokens                     n=  82 tok=  61.5  hit@1=0.48 hit@3=0.57 hit@5=0.61 cob=0.71 mrr=0.53 ctx3=   187
fixo 96 tokens                     n=  56 tok=  90.0  hit@1=0.45 hit@3=0.64 hit@5=0.73 cob=0.82 mrr=0.55 ctx3=   281
fixo 128 tokens                    n=  44 tok= 114.5  hit@1=0.39 hit@3=0.70 hit@5=0.75 cob=0.88 mrr=0.54 ctx3=   343
fixo 192 tokens                    n=  31 tok= 162.3  hit@1=0.54 hit@3=0.77 hit@5=0.82 cob=0.89 mrr=0.64 ctx3=   519
fixo 256 tokens                    n=  24 tok= 209.7  hit@1=0.54 hit@3=0.71 hit@5=0.80 cob=0.95 mrr=0.64 ctx3=   627
fixo 384 tokens                    n=  19 tok= 264.7  hit@1=0.43 hit@3=0.66 hit@5=0.79 cob=0.96 mrr=0.57 ctx3=   725
fixo 512 tokens                    n=  14 tok= 359.3  hit@1=0.55 hit@3=0.80 hit@5=0.89 cob=1.00 mrr=0.69 ctx3=  1137
documento inteiro (1 por doc)      n=   9 tok= 558.6  hit@1=0.64 hit@3=0.77 hit@5=0.93 cob=1.00 mrr=0.74 ctx3=  1677
-- minilm: recursivo
recursivo 32 tokens                n= 219 tok=  23.0  hit@1=0.50 hit@3=0.73 hit@5=0.80 cob=0.95 mrr=0.62 ctx3=    74
recursivo 64 tokens                n= 105 tok=  47.9  hit@1=0.71 hit@3=0.82 hit@5=0.88 cob=1.00 mrr=0.77 ctx3=   160
recursivo 128 tokens               n=  51 tok=  98.6  hit@1=0.64 hit@3=0.80 hit@5=0.86 cob=1.00 mrr=0.72 ctx3=   311
recursivo 256 tokens               n=  25 tok= 201.1  hit@1=0.50 hit@3=0.77 hit@5=0.82 cob=1.00 mrr=0.64 ctx3=   638
recursivo 512 tokens               n=  14 tok= 359.1  hit@1=0.52 hit@3=0.84 hit@5=0.91 cob=1.00 mrr=0.68 ctx3=  1110

== e5: tamanho fixo, sem sobreposição
fixo 16 tokens                     n= 317 tok=  16.0  hit@1=0.02 hit@3=0.04 hit@5=0.04 cob=0.04 mrr=0.03 ctx3=    49
fixo 32 tokens                     n= 160 tok=  31.6  hit@1=0.39 hit@3=0.45 hit@5=0.48 cob=0.48 mrr=0.42 ctx3=    97
fixo 64 tokens                     n=  82 tok=  61.5  hit@1=0.57 hit@3=0.64 hit@5=0.66 cob=0.71 mrr=0.61 ctx3=   188
fixo 96 tokens                     n=  56 tok=  90.0  hit@1=0.62 hit@3=0.79 hit@5=0.80 cob=0.82 mrr=0.70 ctx3=   281
fixo 128 tokens                    n=  44 tok= 114.5  hit@1=0.70 hit@3=0.88 hit@5=0.88 cob=0.88 mrr=0.78 ctx3=   345
fixo 192 tokens                    n=  31 tok= 162.3  hit@1=0.80 hit@3=0.88 hit@5=0.89 cob=0.89 mrr=0.84 ctx3=   506
fixo 256 tokens                    n=  24 tok= 209.7  hit@1=0.71 hit@3=0.91 hit@5=0.93 cob=0.95 mrr=0.81 ctx3=   638
fixo 384 tokens                    n=  19 tok= 264.7  hit@1=0.77 hit@3=0.95 hit@5=0.96 cob=0.96 mrr=0.86 ctx3=   865
fixo 512 tokens                    n=  14 tok= 359.3  hit@1=0.73 hit@3=0.98 hit@5=1.00 cob=1.00 mrr=0.85 ctx3=  1008
documento inteiro (1 por doc)      n=   9 tok= 558.6  hit@1=0.91 hit@3=0.96 hit@5=0.98 cob=1.00 mrr=0.94 ctx3=  1716
-- e5: recursivo
recursivo 32 tokens                n= 219 tok=  23.0  hit@1=0.50 hit@3=0.68 hit@5=0.75 cob=0.95 mrr=0.59 ctx3=    77
recursivo 64 tokens                n= 105 tok=  47.9  hit@1=0.86 hit@3=0.95 hit@5=0.98 cob=1.00 mrr=0.91 ctx3=   158
recursivo 128 tokens               n=  51 tok=  98.6  hit@1=0.89 hit@3=1.00 hit@5=1.00 cob=1.00 mrr=0.94 ctx3=   314
recursivo 256 tokens               n=  25 tok= 201.1  hit@1=0.88 hit@3=0.96 hit@5=1.00 cob=1.00 mrr=0.93 ctx3=   632
recursivo 512 tokens               n=  14 tok= 359.1  hit@1=0.79 hit@3=0.98 hit@5=1.00 cob=1.00 mrr=0.89 ctx3=  1040

Leia a saída em três passos. O primeiro é a cobertura do corte fixo: com pedaços de 16 tokens, a cobertura é de 0,04 (só 4% das frases-resposta aparecem inteiras em algum pedaço, porque uma frase típica tem mais de 16 tokens e o corte cai no meio), com 64 tokens é 0,71, com 256 chega a 0,95 e só em 512 alcança 1,00. O corte recursivo, que respeita frases, tem cobertura 1,00 a partir de 64 tokens. Boa parte do que parece “pedaço pequeno demais” é corte cego demais.

O segundo passo é o efeito do tamanho sobre o acerto. No e5 (limite de 512), o corte recursivo chega ao máximo em 128 tokens (hit@1 de 0,89 e hit@3 de 1,00), 64 tokens fica muito perto (0,86 e 0,95) com metade do custo (158 contra 314 tokens de contexto), e em 512 tokens o hit@1 cai para 0,79 enquanto o custo salta para 1.040. No MiniLM (limite de 128), o máximo está em 64 tokens (hit@1 de 0,71), e acima do limite de entrada o desempenho desaba (0,50 com 256 tokens), o que é o truncamento da figura anterior. Pedaços de 32 tokens perdem nos dois, porque cada pedaço traz tão pouco contexto que o vetor não distingue um assunto de outro.

O terceiro passo é desconfiar do que parece bom demais. A linha “documento inteiro” tem hit@1 de 0,91 no e5, o melhor de todos, mas isso acontece porque o corpus tem só nove documentos e o gabarito é “contém a frase”. O custo mostra a verdade: 1.716 tokens por consulta contra 314 do recursivo de 128. Esse é o motivo de a tabela ter a coluna ctx3.

tamanho medido
figura 5 · tamanho medido

A figura reúne o corte recursivo nos dois modelos. A fileira de cima é o e5-small e a de baixo é o MiniLM, cada caixa com o hit@1 e o custo em tokens de contexto. As cores marcam o veredito: rosa para o que falha (32 tokens, 512 tokens), azul para o aceitável, verde pulsando para o melhor equilíbrio de cada modelo (128 tokens no e5, 64 no MiniLM). Nas setas, os pacotes verdes e rápidos mostram um ganho, os cinzas e lentos mostram que o custo dobra sem ganho, e os rosas, lentíssimos e finos, mostram piora. Na fileira de baixo, a seta entre 128 e 256 tokens termina com ✕: é aí que o pedaço passa do que o modelo lê.

Para ver como os cortes cegos destroem o sentido, olhe os casos concretos. Com tamanho fixo de 100 tokens, 6 das 56 frases-resposta ficaram cortadas ao meio (cobertura 0,89). A saída do script que listou as perdas:

CORTADA: prime | O titular pode incluir até 4 dependentes, que usam o frete grátis na própria conta.
CORTADA: entregas | | Centro-Oeste | 5 a 8 dias úteis | 2 dias úteis |
CORTADA: suporte | P1 significa loja fora do ar ou impossibilidade de vender, e é tratado em qualquer horário para o plano Busine
CORTADA: api | O parâmetro limite define quantos itens vêm por página, com máximo de 200 e padrão de 50.
CORTADA: api | Cada aviso traz o cabeçalho X-Aurora-Assinatura, que é um HMAC SHA-256 do corpo calculado com o segredo do web

E há o problema do título órfão, que não aparece em nenhuma métrica mas aparece na leitura. O primeiro pedaço do documento de trocas, cortado em 64 tokens, termina assim:

'# Trocas e devoluções\n\nEsta página explica como o Mercado Aurora trata trocas e devoluções de compras feitas no site e no aplicativo. Cada situação tem prazo e regra próprios, descritos nas seções abaixo. Em caso de dúvida, o atendimento responde pelo chat todos os dias.\n\n## Arrependimento'

O título ”## Arrependimento” ficou no fim do pedaço anterior, e o pedaço seguinte, que fala dos 7 dias de arrependimento, começa sem o assunto que o título daria.

cortes ruins
figura 6 · cortes ruins

No centro, o corte cego por tamanho (roxo, tremendo, porque é o que dá errado). Dele saem quatro setas rosa para os quatro tipos de dano, cada um com um exemplo real do corpus e uma medida no rótulo de baixo: a frase partida (“…impedida de pagar com cartão | por 24 horas.”), o título órfão, a linha de tabela sem cabeçalho (“qual coluna é a expressa?”) e o bloco de código de 146 tokens espalhado em três pedaços no corte fixo de 64. A caixa laranja mostra o efeito na busca: o fato some, ou aparece sem o contexto de que o leitor precisa.

Parte 3: Estratégias de corte

8. O panorama e o corte de tamanho fixo

As estratégias se agrupam por onde olham para decidir o corte. Algumas só olham o tamanho (tamanho fixo e janela deslizante), outras procuram limites naturais já marcados no texto (separadores, frases e estrutura), e uma tenta ler o significado (corte semântico). Cada uma resolve um defeito da anterior, e cada uma tem um custo.

estrategias
figura 7 · estrategias

O documento cinza, à esquerda, alimenta as seis estratégias em leque (os pacotes saem defasados, um de cada vez). O selo de cada caixa diz a família (tamanho, limites ou significado), e o rótulo embaixo traz o hit@1 medido no e5 com pedaços de até 100 tokens (a seção 13 tem a tabela completa). Destaca-se a caixa de estrutura (seta roxa grossa para o troféu verde, “melhor hit@1”). A seta rosa pontilhada que parte do corte semântico leva a uma observação que a seção 12 explica: 21 dos 103 pedaços tinham menos de 20 tokens.

O corte de tamanho fixo é o mais simples e o mais ingênuo: a cada N tokens, corta. Não há separador a procurar, nada a decidir, e por isso é o ponto de partida de qualquer comparação. O código usa o tokenizador para achar os limites dos tokens (return_offsets_mapping=True devolve, para cada token, o trecho do texto original que ele cobre), de modo que o corte é em tokens, mas o texto devolvido é o original, sem reconstrução:

"""Chunkers. Todos devolvem spans (inicio, fim) em caracteres sobre o texto do documento."""
import re
from functools import lru_cache
from transformers import AutoTokenizer

tok = AutoTokenizer.from_pretrained("intfloat/multilingual-e5-small")


@lru_cache(maxsize=None)
def ntok(s):
    return len(tok(s, add_special_tokens=False)["input_ids"])


def limpa(texto, spans):
    """Tira espaços das pontas de cada span e descarta os vazios."""
    out = []
    for a, b in spans:
        while a < b and texto[a].isspace(): a += 1
        while b > a and texto[b - 1].isspace(): b -= 1
        if b > a: out.append((a, b))
    return out


# 1. tamanho fixo, em tokens, com sobreposição opcional
def fixo(texto, tamanho, sobreposicao=0):
    offs = tok(texto, add_special_tokens=False, return_offsets_mapping=True)["offset_mapping"]
    passo = tamanho - sobreposicao
    spans = []
    for i in range(0, len(offs), passo):
        j = min(i + tamanho, len(offs))
        spans.append((offs[i][0], offs[j - 1][1]))
        if j == len(offs): break
    return limpa(texto, spans)

A função aceita também uma sobreposição, que a seção 11 explica. O resultado é o que a seção anterior já mostrou: com 100 tokens e sem sobreposição, hit@1 de 0,75 no e5 e de 0,48 no MiniLM, com 6 respostas cortadas ao meio. O corte fixo serve para texto sem estrutura nenhuma (uma transcrição corrida, um log) e como linha de base. Para qualquer texto que tenha frases e parágrafos, existe algo melhor.

9. Corte recursivo: parágrafo, linha, frase, palavra

A ideia do corte recursivo é tentar os separadores do maior para o menor. Primeiro divide em parágrafos (\n\n). Se algum parágrafo ainda passa do tamanho, divide esse em linhas, depois em frases (. ), depois em palavras. Em cada nível, vizinhos pequenos são juntados de volta enquanto couberem no tamanho. O resultado é um pedaço que quase sempre termina onde um parágrafo, uma linha ou uma frase termina. É a estratégia que costuma ser o padrão das bibliotecas: na LangChain, a lista padrão de separadores do RecursiveCharacterTextSplitter é ["\n\n", "\n", " ", ""], pelo código-fonte que li hoje. O relatório técnico da Chroma sobre chunking observou que a lista padrão produzia pedaços muito curtos e acrescentou ., ? e !; no meu código acrescentei . pelo mesmo motivo.

# 2. recursivo: tenta separadores do maior para o menor (parágrafo, linha, frase, palavra)
SEPS = ["\n\n", "\n", ". ", " "]

def _partes(texto, a, b, sep):
    cortes, i = [], a
    while True:
        j = texto.find(sep, i, b)
        if j < 0: break
        cortes.append(j + len(sep)); i = j + len(sep)
    pts = [a] + [c for c in cortes if c < b] + [b]
    return list(zip(pts[:-1], pts[1:]))

def recursivo(texto, tamanho, a=0, b=None, seps=SEPS):
    b = len(texto) if b is None else b
    if ntok(texto[a:b]) <= tamanho: return [(a, b)]
    if not seps: return [(a + x, a + y) for x, y in fixo(texto[a:b], tamanho)]
    partes = _partes(texto, a, b, seps[0])
    if len(partes) == 1: return recursivo(texto, tamanho, a, b, seps[1:])
    pedacos = []
    for pa, pb in partes:
        pedacos += [(pa, pb)] if ntok(texto[pa:pb]) <= tamanho else recursivo(texto, tamanho, pa, pb, seps[1:])
    out, atual = [], pedacos[0]
    for pa, pb in pedacos[1:]:                       # junta vizinhos enquanto cabem
        if ntok(texto[atual[0]:pb]) <= tamanho: atual = (atual[0], pb)
        else: out.append(atual); atual = (pa, pb)
    return out + [atual]
recursivo por dentro
figura 8 · recursivo por dentro

A figura é o fluxograma do algoritmo (de cima para baixo). O texto cinza chega ao losango “Cabe em N tokens?”. Se sim, vira um pedaço (verde) sem cortar nada. Se não, divide-se no primeiro separador (roxo) e o losango seguinte pergunta se alguma das partes ainda passa de N. Se sim, a parte grande volta ao mesmo processo com o próximo separador (o quadrado roxo que gira: a recursão); se não, juntam-se as partes vizinhas enquanto a soma couber em N, e saem os pedaços de até N tokens (verde). A figura usa o sentido de cima para baixo porque o desenho horizontal ficava ilegível.

Em ação, com 64 tokens, o documento de trocas (832 tokens) virou 16 pedaços com estes tamanhos em tokens: [64, 49, 45, 64, 60, 60, 49, 64, 26, 46, 49, 49, 58, 48, 61, 40]. Nenhum passa de 64 e a maioria termina onde uma frase termina. Compare com o corte fixo de 64 no mesmo documento, que gera 13 pedaços de 63 a 65 tokens, todos cortando onde caíssem. Nas medidas, recursivo com 100 tokens deu hit@1 de 0,88 no e5 e 0,75 no MiniLM, contra 0,75 e 0,48 do corte fixo. No teste pareado, o recursivo acertou em primeiro lugar 10 perguntas em que o fixo errou, e o fixo acertou 3 em que o recursivo errou, no e5; no MiniLM, 18 contra 3. Esse resultado é consistente nos dois modelos e vai além do ruído.

Um limite do recursivo é que ele não entende o documento. Como mostrou o exemplo do título órfão, ele pode deixar um título no fim de um pedaço e o corpo da seção no seguinte. Se o texto tem uma estrutura marcada, vale usá-la.

10. Corte por estrutura: títulos e seções

Em Markdown, HTML e documentos bem formatados, o autor já marcou onde cada assunto começa: são os títulos. O corte por estrutura usa essas marcas. No código, uma seção é o trecho entre dois títulos de segundo nível (## ), e uma seção grande demais é cortada de novo pelo recursivo:

# 3. por estrutura: uma seção por título "##" (e recursivo dentro das seções grandes)
def secoes(texto):
    cab = [m.start() for m in re.finditer(r"^## ", texto, flags=re.M)]
    pts = [0] + cab + [len(texto)]
    return limpa(texto, list(zip(pts[:-1], pts[1:])))

def por_estrutura(texto, tamanho):
    out = []
    for a, b in secoes(texto):
        out += limpa(texto, recursivo(texto, tamanho, a, b)) if ntok(texto[a:b]) > tamanho else [(a, b)]
    return out

No corpus, com limite de 100 tokens, esse corte deu 83 pedaços de 61 tokens em média e foi o melhor de todas as estratégias: hit@1 de 0,95 no e5 e 0,77 no MiniLM, com cobertura de 1,00. Pelo teste pareado, ele acertou 12 perguntas em primeiro lugar em que o corte fixo errou e errou 1 em que o fixo acertou (e5), e 18 contra 2 no MiniLM. O motivo é que o título está dentro do pedaço: “Produtos com defeito” é parte do texto que vira vetor, e o vetor sabe de que assunto se trata. Num documento com seções claras, esse é o ponto de partida natural.

A estratégia tem uma condição: precisa que a estrutura exista no texto que chega ao chunking. Um PDF extraído sem cuidado devolve tudo como parágrafos soltos, sem títulos, e o corte por estrutura não tem em que se apoiar. A recuperação da estrutura (títulos, listas, tabelas) é o trabalho da ingestão, tema de Ingestão de documentos. E há o caso oposto, de seções enormes (um capítulo de oitenta páginas debaixo de um título): aí o corte por estrutura deve cair para o recursivo dentro da seção, como o código acima faz.

11. Frases, sobreposição e janela deslizante

O corte por frases agrupa frases inteiras até o limite de tamanho. Ele nunca corta no meio de uma frase, e por isso a cobertura quase não perde respostas (0,98). A função unidades separa o texto em frases, títulos, linhas de tabela e blocos de código inteiros, e agrupa junta unidades vizinhas enquanto couberem:

def agrupa(texto, unids, tamanho):
    out, atual = [], unids[0]
    for ua, ub in unids[1:]:
        if ntok(texto[atual[0]:ub]) <= tamanho: atual = (atual[0], ub)
        else: out.append(atual); atual = (ua, ub)
    return out + [atual]

def por_frases(texto, tamanho):
    return agrupa(texto, unidades(texto), tamanho)

Mas o agrupamento é guloso e cego à estrutura: o título ”## Arrependimento” é uma unidade como qualquer outra e entra no pedaço que estiver aberto, quase sempre o da seção anterior. O resultado é o mesmo título órfão. Nas medidas, o hit@1 foi de 0,77 no e5 e 0,48 no MiniLM, abaixo do recursivo e igual ao do corte fixo no MiniLM.

A sobreposição (overlap) é um remendo contra o corte cego. Em vez de começar cada pedaço onde o anterior terminou, o pedaço novo repete os últimos tokens do anterior. Assim, uma frase que caiu na fronteira aparece inteira em pelo menos um dos dois pedaços. Quando a sobreposição é aplicada de forma contínua (a janela anda de N em N tokens, cada janela de tamanho maior que N), ela recebe o nome de janela deslizante. A função fixo da seção 8 já aceita sobreposicao.

sobreposicao
figura 9 · sobreposicao

Em cima, o documento aparece em seis blocos de 50 tokens. Cada bloco alimenta o pedaço em que está, e os dois blocos rosa tracejados (3 e 5) alimentam dois pedaços cada: o bloco 3 é o fim do pedaço A e o começo do B, e o bloco 5 é o fim do B e o começo do C. É isso que sobreposição significa, e o rosa marca o texto repetido, o custo da técnica. Embaixo, as medidas com o corte fixo de 100 tokens no e5: sem sobreposição, 54 pedaços e hit@3 de 0,88; com 20 tokens, 65 pedaços (20% a mais) e 0,96; com 50 tokens, 96 pedaços (78% a mais) e 0,98.

A sobreposição funciona nas medidas, mas o ganho vem de reduzir as frases cortadas (a cobertura foi de 0,89 para 0,98 e para 1,00), e esse efeito se obtém com mais facilidade cortando em limites naturais, como o recursivo faz, que já tem cobertura 1,00 sem repetir texto. O relatório da Chroma observou o outro lado: reduzir a sobreposição melhora uma métrica que penaliza tokens redundantes (IoU), e a configuração padrão do assistente da OpenAI nesse relatório, de 800 tokens com 400 de sobreposição, ficou entre as piores em precisão. Vale usar sobreposição quando o corte não respeita limites naturais, e vale medir o aumento do índice.

12. Corte semântico: onde o assunto muda

A ideia é mais ambiciosa: em vez de procurar marcas, medir o significado. O algoritmo embute cada frase, calcula a similaridade entre frases vizinhas e corta onde ela cai (um vale na similaridade indica troca de assunto). Há um limiar, escolhido como um percentil das similaridades do documento, e um tamanho máximo.

# 5. semântico: corta onde a similaridade entre frases vizinhas cai (usa o modelo de embedding)
def semantico(texto, embute, tamanho, percentil=25):
    import numpy as np
    un = unidades(texto)
    E = embute([texto[a:b] for a, b in un])
    sim = (E[:-1] * E[1:]).sum(axis=1)                 # cosseno entre frase i e i+1
    limiar = np.percentile(sim, percentil)
    out, atual = [], un[0]
    for k in range(1, len(un)):
        ua, ub = un[k]
        if sim[k - 1] < limiar or ntok(texto[atual[0]:ub]) > tamanho: out.append(atual); atual = (ua, ub)
        else: atual = (atual[0], ub)
    return out + [atual]

Rodei o corte semântico com o limiar no percentil 25 e tamanho máximo de 100 tokens. No documento de trocas, as similaridades entre as frases vizinhas do início da seção de arrependimento foram as da figura:

semantico
figura 10 · semantico

A cadeia de cima para baixo é a sequência real de unidades do documento de trocas. Entre cada par, a seta traz a similaridade medida pelo e5 e a decisão: cortar (✕, quando fica abaixo do limiar de 0,864) ou juntar (pacotes verdes, quando fica acima). Um corte aconteceu entre a frase 3 e o título (0,83), outro entre o título e a frase 5 (0,84), outro entre as frases 5 e 6 (0,86), e as frases 6 e 7 foram juntadas (0,89). O resultado é o título ”## Arrependimento” isolado num pedaço de 4 tokens, o que está marcado em rosa na figura.

O problema ilustrado é geral. O corte por percentil corta sempre uma fração das fronteiras, mesmo em trechos que tratam de um assunto só, porque o percentil 25 sempre deixa 25% das fronteiras abaixo do limiar, não importa o quão parecidas as frases sejam. No e5, as similaridades entre frases vizinhas ficaram numa faixa estreita (de 0,83 a 0,95 nesse trecho), e as diferenças que o algoritmo usa são pequenas. No corpus inteiro, 21 dos 103 pedaços semânticos tinham menos de 20 tokens. As medidas foram hit@1 de 0,79 no e5 e 0,62 no MiniLM, abaixo do recursivo e da estrutura, com custo de contexto baixo (cerca de 180 tokens), porque os pedaços são pequenos.

A minha versão é simples e tem fraquezas conhecidas: faltam um tamanho mínimo e um limiar calibrado. A documentação do LlamaIndex mostra o SemanticSplitterNodeParser com buffer_size=1 e breakpoint_percentile_threshold=95 num exemplo e avisa que “você pode ter de ajustar o limiar de percentil” (tradução minha). O relatório da Chroma testou um corte semântico por agrupamento (cluster) e encontrou, com pedaços de 200 tokens, a maior precisão do conjunto, mas é um resultado deles, em corpora deles. O corte semântico custa um embedding por frase só para decidir os cortes, e esse custo costuma superar o ganho nos documentos que já têm estrutura.

13. Todas as estratégias lado a lado

A comparação completa, com o tamanho máximo de 100 tokens para todos (cabe nos dois modelos), as 56 perguntas e os dois modelos. As configurações com ”+ contexto” e “pequeno-para-grande” são as das seções 14 e 15, e as janelas de frases também. O script:

CONFIG = [
 ("fixo 100",                      simples(fixo, tamanho=CAP)),
 ("fixo 100, sobreposição 20",     simples(fixo, tamanho=CAP, sobreposicao=20)),
 ("fixo 100, sobreposição 50",     simples(fixo, tamanho=CAP, sobreposicao=50)),
 ("recursivo 100",                 simples(recursivo, tamanho=CAP)),
 ("por frases 100",                simples(por_frases, tamanho=CAP)),
 ("por estrutura (seções) 100",    simples(por_estrutura, tamanho=CAP)),
 ("semântico (p25) 100",           semantico_f),
 ("recursivo 100 + contexto",      com_contexto(recursivo, tamanho=CAP)),
 ("por estrutura 100 + contexto",  com_contexto(por_estrutura, tamanho=CAP)),
 ("pequeno-para-grande (40 -> seção)", pequeno_para_grande(40)),
 ("janela de frases ±1",           janela_de_frases(1)),
 ("janela de frases ±2",           janela_de_frases(2)),
]
if __name__ == "__main__":
    for nome in ("e5", "minilm"):
        print(f"\n== {nome}")
        for rot, f in CONFIG:
            print(linha(rot, avaliar(nome, f(nome))))

E a saída:


== e5
fixo 100                           n=  54 tok=  93.2  hit@1=0.75 hit@3=0.88 hit@5=0.89 cob=0.89 mrr=0.81 ctx3=   290
fixo 100, sobreposição 20          n=  65 tok=  94.7  hit@1=0.73 hit@3=0.96 hit@5=0.98 cob=0.98 mrr=0.84 ctx3=   288
fixo 100, sobreposição 50          n=  96 tok=  97.9  hit@1=0.84 hit@3=0.98 hit@5=1.00 cob=1.00 mrr=0.91 ctx3=   297
recursivo 100                      n=  74 tok=  67.9  hit@1=0.88 hit@3=1.00 hit@5=1.00 cob=1.00 mrr=0.93 ctx3=   216
por frases 100                     n=  58 tok=  86.3  hit@1=0.77 hit@3=0.91 hit@5=0.95 cob=0.98 mrr=0.84 ctx3=   266
por estrutura (seções) 100         n=  83 tok=  60.6  hit@1=0.95 hit@3=0.98 hit@5=1.00 cob=1.00 mrr=0.97 ctx3=   182
semântico (p25) 100                n= 103 tok=  48.5  hit@1=0.79 hit@3=0.98 hit@5=0.98 cob=0.98 mrr=0.87 ctx3=   182
recursivo 100 + contexto           n=  74 tok=  76.7  hit@1=0.93 hit@3=1.00 hit@5=1.00 cob=1.00 mrr=0.96 ctx3=   247
por estrutura 100 + contexto       n=  83 tok=  69.5  hit@1=0.96 hit@3=1.00 hit@5=1.00 cob=1.00 mrr=0.98 ctx3=   217
pequeno-para-grande (40 -> seção)  n= 185 tok=  79.9  hit@1=0.84 hit@3=0.96 hit@5=0.98 cob=1.00 mrr=0.90 ctx3=   247
janela de frases ±1                n= 247 tok=  48.2  hit@1=0.61 hit@3=0.84 hit@5=0.91 cob=1.00 mrr=0.72 ctx3=   155
janela de frases ±2                n= 247 tok=  75.7  hit@1=0.66 hit@3=0.89 hit@5=0.95 cob=1.00 mrr=0.77 ctx3=   246

== minilm
fixo 100                           n=  54 tok=  93.2  hit@1=0.48 hit@3=0.73 hit@5=0.80 cob=0.89 mrr=0.61 ctx3=   290
fixo 100, sobreposição 20          n=  65 tok=  94.7  hit@1=0.55 hit@3=0.77 hit@5=0.84 cob=0.98 mrr=0.66 ctx3=   288
fixo 100, sobreposição 50          n=  96 tok=  97.9  hit@1=0.70 hit@3=0.82 hit@5=0.86 cob=1.00 mrr=0.76 ctx3=   294
recursivo 100                      n=  74 tok=  67.9  hit@1=0.75 hit@3=0.82 hit@5=0.88 cob=1.00 mrr=0.80 ctx3=   211
por frases 100                     n=  58 tok=  86.3  hit@1=0.48 hit@3=0.77 hit@5=0.84 cob=0.98 mrr=0.63 ctx3=   264
por estrutura (seções) 100         n=  83 tok=  60.6  hit@1=0.77 hit@3=0.89 hit@5=0.93 cob=1.00 mrr=0.83 ctx3=   184
semântico (p25) 100                n= 104 tok=  48.2  hit@1=0.62 hit@3=0.80 hit@5=0.82 cob=1.00 mrr=0.70 ctx3=   178
recursivo 100 + contexto           n=  74 tok=  76.7  hit@1=0.70 hit@3=0.86 hit@5=0.93 cob=1.00 mrr=0.79 ctx3=   247
por estrutura 100 + contexto       n=  83 tok=  69.5  hit@1=0.75 hit@3=0.88 hit@5=0.96 cob=1.00 mrr=0.83 ctx3=   218
pequeno-para-grande (40 -> seção)  n= 185 tok=  79.9  hit@1=0.79 hit@3=0.89 hit@5=0.93 cob=1.00 mrr=0.84 ctx3=   232
janela de frases ±1                n= 247 tok=  48.2  hit@1=0.61 hit@3=0.79 hit@5=0.86 cob=1.00 mrr=0.71 ctx3=   150
janela de frases ±2                n= 247 tok=  75.7  hit@1=0.66 hit@3=0.82 hit@5=0.89 cob=1.00 mrr=0.75 ctx3=   229

Para ler a tabela, vale seguir uma ordem. A cobertura (cob) separa quem destrói respostas de quem não destrói: o corte fixo sem sobreposição perde 11% das frases-resposta (cob 0,89) e todos os cortes que respeitam limites ficam em 0,98 ou 1,00. O hit@1 separa quem acha a resposta em primeiro lugar. No e5, a estrutura tem 0,95 e o recursivo 0,88, seguidos de uma faixa de 0,73 a 0,84 (fixo, com sobreposição, por frases, semântico, pequeno-para-grande), com as janelas de frases em 0,61 a 0,66 e as versões com prefixo de contexto em 0,92 e 0,96. O hit@3 do e5 fica em 0,84 a 1,00 para todos, o que é um sintoma de teto: com 9 documentos e uma pergunta por fato, três tentativas bastam quase sempre. O MiniLM, mais fraco, distribui melhor as diferenças.

O que a tabela permite afirmar, descontado o ruído de 8 a 13 pontos: o corte por estrutura e o recursivo vencem o corte fixo nos dois modelos (testes pareados em hit@1: estrutura contra fixo, 12 a 1 no e5 e 18 a 2 no MiniLM; recursivo contra fixo, 10 a 3 e 18 a 3). Todo o resto, inclusive a diferença entre estrutura e recursivo, a vantagem do semântico sobre o fixo e o efeito do contexto, está dentro do que 56 perguntas conseguem distinguir, e eu não afirmo nada sobre eles a partir desta tabela.

Parte 4: Além do corte

14. Pequeno para grande: achar com um, entregar com outro

O corte tem um dilema de dois lados. Para achar bem, o pedaço deve ser pequeno e focado, porque o vetor de um texto com um assunto só aponta com mais força. Para o modelo responder bem, o pedaço deve ser grande, porque a resposta costuma precisar da frase de antes e da de depois. O padrão “pequeno para grande” (small-to-big, ou parent-document retrieval) resolve o dilema separando as duas funções: o índice guarda pedaços pequenos (os filhos), e quando um filho é achado, o que vai para o prompt é o texto maior de onde ele veio (o pai).

pequeno para grande
figura 11 · pequeno para grande

O grupo da esquerda é a seção “Produtos com defeito”, de 81 tokens, o pai, com três filhos azuis. Só os filhos têm vetor no índice (cilindro azul). A pergunta laranja (“até quando troco uma geladeira com defeito?”) vai ao índice pela seta grossa, e o índice acha o filho 2 (pulsando, “O prazo é de 90 dias corridos…”). O que sobe ao modelo, porém, é o documento verde: a seção inteira, o pai. A caixa da direita traz a medida deste corpus.

No código, cada filho é um pedaço de 40 tokens cujo texto para o embedding é o próprio filho e cujo texto devolvido (ret) é a seção inteira. A busca deduplica pais, porque dois filhos da mesma seção não devem ocupar duas vagas do top-k:

def pequeno_para_grande(filho, pai="secao"):
    def f(nome):
        out = []
        for d in DOCS:
            t = DOCS[d]
            for sa, sb in secoes(t):
                ret = t[sa:sb]
                for a, b in (recursivo(t, filho, sa, sb)):
                    out.append(Pedaco(d, t[a:b].strip(), ret))
        return out
    return f

Neste corpus, o resultado foi neutro: hit@1 de 0,84 no e5 contra 0,88 do recursivo de 100 tokens, e 0,79 contra 0,75 no MiniLM, com custo de contexto de 247 contra 216 tokens. A explicação é que as seções do corpus são curtas (cerca de 60 a 125 tokens), de modo que o pai é pouco maior que o filho e o padrão tem pouco a oferecer. O ganho aparece quando o pai é muito maior do que o vetor consegue representar bem, por exemplo uma seção de 2.000 tokens (que o vetor de uma só peça diluiria) com filhos de 128. O LlamaIndex documenta duas variantes. A hierárquica (HierarchicalNodeParser, com chunk_sizes=[2048, 512, 128] no exemplo da documentação, sem padrão declarado) guarda pais e filhos em níveis, e o AutoMergingRetriever troca os filhos achados pelo pai quando a maior parte dos filhos de um pai aparece nos resultados. A de janela de frases (SentenceWindowNodeParser, com window_size=3 no exemplo) indexa uma frase por vez e devolve as vizinhas.

Medi também a janela de frases com 1 e com 2 frases de cada lado. O hit@1 no e5 foi de 0,61 e 0,66, abaixo das outras, porque uma frase sozinha (sem o título) é um vetor pobre, e a janela só ajuda depois de achar. Janelas pequenas servem para respostas que dependem do vizinho, mas a frase isolada precisaria de contexto para ser achada, o que leva à próxima seção.

15. Pedaço com contexto

Um pedaço arrancado do documento perde o que o documento sabia sobre ele. “O prazo é de 90 dias corridos para itens duráveis” é uma frase clara, mas num corpus em que “O prazo é de…” abre dezenas de frases, o vetor desse pedaço fica parecido com o de todos os outros. O assunto (devolução de produto com defeito) estava no título e foi embora com o corte. A correção é devolvê-lo: acrescentar ao pedaço um prefixo com o nome do documento e a seção, antes de embutir e também antes de enviar ao modelo.

# contexto: "Documento > Seção" de uma posição do texto
def contexto(texto, pos):
    h1 = re.match(r"# (.+)", texto).group(1)
    h2 = [m for m in re.finditer(r"^## (.+)$", texto, flags=re.M) if m.start() <= pos]
    return f"{h1} > {h2[-1].group(1)}" if h2 else h1

E o uso, que monta o pedaço com o prefixo (com_contexto, em exp2_estrategias.py):

def com_contexto(chunker, **kw):
    def f(nome):
        out = []
        for d in DOCS:
            t = DOCS[d]
            for a, b in chunker(t, **kw):
                c = contexto(t, a) + "\n" + t[a:b]
                out.append(Pedaco(d, c))
        return out
    return f
contexto
figura 12 · contexto

A parte de cima mostra o problema: três pedaços de documentos diferentes (trocas, assinatura e entregas) começam com “O prazo é de…”. Sem prefixo, os três viram vetores quase iguais (a caixa rosa degradada), e a pergunta laranja não tem como dizer qual deles é o seu: a seta é barrada com ✕ (“qual deles?”). A parte de baixo mostra a solução: o prefixo roxo (“Trocas e devoluções > Produtos com defeito”) é somado ao pedaço, o pedaço com prefixo verde vira um vetor azul que sabe de que seção veio, e a seta laranja grossa da pergunta o encontra. (O desenho é uma ilustração do mecanismo, não uma medida de vetores.) A caixa rosa tracejada mostra a versão cara da técnica, o prefixo escrito por um LLM, e a verde traz a medida.

Com o corte recursivo em 64 tokens, o prefixo simples (documento e seção) subiu o hit@1 de 0,86 para 0,93 no e5. Os quatro casos que mudaram de erro para acerto foram “Quanto de desconto ganho pagando no Pix?”, “Quanto tempo os pontos duram?”, “Quanto custa o Prime por ano?” e “Quanto tempo leva para resolver um problema no plano Pro?”, todas perguntas cuja resposta é uma frase sem o nome do assunto. Com o recursivo em 100 tokens, o efeito é de 4 acertos ganhos contra 1 perdido no e5 e de 1 ganho contra 4 perdidos no MiniLM, que o teste pareado não separa de ruído. Meu resultado é que o prefixo ajudou o e5 e foi neutro ou levemente negativo no MiniLM, e que a amostra é pequena demais para dizer mais. Um custo certo é o aumento de tokens (de 67,9 para 76,7 por pedaço no recursivo de 100).

Há três formas de dar contexto, e elas se combinam. O prefixo no texto, como acima, afeta o vetor. Os metadados no payload (Banco de dados vetorial) permitem filtrar e citar a origem sem mexer no vetor: documento, seção, data, versão. E o prefixo escrito por um LLM (a técnica que a Anthropic chamou de Contextual Retrieval, publicada em 19 de setembro de 2024) pede a um modelo que escreva, para cada pedaço, uma frase de 50 a 100 tokens situando-o no documento, e a prepende antes de embutir. O artigo reporta que a taxa de falha na recuperação dos 20 primeiros pedaços caiu 35% só com os embeddings contextuais (de 5,7% para 3,7%), 49% quando combinados com BM25 contextual e 67% com reranking, e estima um custo único de US$ 1,02 por milhão de tokens de documento com prompt caching, com a premissa de pedaços de 800 tokens e documentos de 8 mil. São números do artigo, em corpora dele, que eu não reproduzi: não rodei esse método.

16. Tabelas e código

Tabelas e código são os dois formatos em que cortar por tamanho faz mais estrago, porque o significado de uma linha depende de outra linha (o cabeçalho) e o de uma instrução depende da função em que está. A regra é tratar a unidade natural como indivisível: uma linha de tabela (com o cabeçalho junto) e uma função inteira.

No corpus, a tabela de prazos de entrega tem 5 regiões e a de planos de suporte tem 3 planos, e há 8 perguntas cuja resposta é uma linha de tabela. O corte por linha com cabeçalho cria um pedaço por linha, repetindo o cabeçalho e o contexto em cada um:

# --- tabelas: uma linha por pedaço, com o cabeçalho junto
def tabela_por_linha(nome):
    out = []
    for d in DOCS:
        t = DOCS[d]; pos = 0
        for m in re.finditer(r"(?:^\|.*\n?)+", t, flags=re.M):
            for a, b in recursivo(t, CAP, pos, m.start()):
                out.append(Pedaco(d, contexto(t, a) + "\n" + t[a:b].strip()))
            linhas = m.group(0).strip().split("\n")
            cab = linhas[0]
            for ln in linhas[2:]:
                out.append(Pedaco(d, contexto(t, m.start()) + "\n" + cab + "\n" + ln))
            pos = m.end()
        for a, b in recursivo(t, CAP, pos, len(t)):
            out.append(Pedaco(d, contexto(t, a) + "\n" + t[a:b].strip()))
    return out

O resultado nas 8 perguntas de tabela: no e5, o hit@1 foi de 6 em 8 tanto para o recursivo de 100 quanto para o corte por linha, e o hit@3 de 8 em 8 nos dois. No MiniLM, o hit@1 foi de 2 em 8 nos dois, e o hit@3 subiu de 2 em 8 para 5 em 8 com o corte por linha. Ou seja, para recuperação o ganho foi pequeno e só apareceu no modelo mais fraco. O benefício que não aparece nessa métrica é o da leitura: com a linha isolada, o LLM recebe ”| Nordeste | 6 a 9 dias úteis | 3 dias úteis |” e precisa adivinhar qual coluna é a expressa, enquanto o pedaço com cabeçalho diz. (A comparação geral de hit@1 de 0,88 para 0,93 no e5 que o script imprime não é prova do corte de tabela: esse script também põe o prefixo de contexto em todos os pedaços, como na seção anterior.)

Para o código, o bloco de exemplo da API tem 146 tokens. O corte fixo de 64 o espalha por três pedaços e o recursivo de 64, por quatro, e em nenhum caso há um pedaço com a função inteira. A saída do script:

== bloco de código da API: 146 tokens
fixo 64: bloco toca 3 pedaço(s); inteiro em algum: False
recursivo 64: bloco toca 4 pedaço(s); inteiro em algum: False
por frases 150 (bloco inteiro): bloco toca 1 pedaço(s); inteiro em algum: True

Um dos pedaços do corte fixo de 64, o de número 4, abre a função e termina na metade de uma URL:

--- fixo 64, pedaço 4 (64 tokens)
aceita até 120 caracteres, e cada produto pode ter até 10 imagens. O exemplo abaixo cria um produto e imprime o identificador devolvido:

```python
import requests

def criar_produto(token, titulo, preco):
    resposta = requests.post(
        "https://api.aur

O corte por frases com limite de 150 tokens, que trata o bloco de código como uma unidade, o manteve inteiro num único pedaço. Para código real, a ferramenta natural é cortar por funções e classes (com um analisador sintático, como o que as bibliotecas de chunking de código trazem), e cabe dizer que não testei nenhuma delas.

tabela e codigo
figura 13 · tabela e codigo

A metade de cima trata da tabela “entregas” (os três campos aparecem como colunas). O corte por tamanho (seta rosa) gera a linha solta de Nordeste (caixa rosa, “qual coluna é a expressa?”), e o corte por linha com cabeçalho (seta verde grossa) gera a linha com os nomes das colunas (caixa verde), cuja medida está na caixa à direita. A metade de baixo trata do bloco de código de 146 tokens: no corte fixo de 64, três setas rosa levam a três pedaços (abre a função, meio que termina em timeout=, fecha e imprime), e a seta verde grossa, o bloco atômico, leva a um pedaço de 146 tokens.

Parte 5: Escolher e seguir

17. Como escolher e avaliar para os seus documentos

Os resultados desta aula valem para este corpus e estes modelos. O que vale para qualquer projeto é o método, que cabe em seis passos.

O primeiro é montar um conjunto de perguntas com a resposta marcada, a parte mais trabalhosa e a que mais compensa. As melhores perguntas vêm de usuários reais (o histórico de um chat, os chamados de suporte). Quando elas ainda não existem, dá para escrever algumas dezenas à mão ou pedir a um LLM que gere perguntas a partir de trechos e revisá-las, e a revisão importa, porque perguntas geradas costumam copiar as palavras do trecho e favorecem a busca por palavra. O segundo é definir a resposta como o trecho que contém a informação, como na seção 6, e não como o pedaço que o seu corte produziu: assim, o gabarito vale para qualquer configuração. O terceiro é variar uma coisa de cada vez (primeiro a estratégia, depois o tamanho, depois a sobreposição e o prefixo). O quarto é medir pelo menos hit@1, hit@3, cobertura e tokens de contexto. O quinto é decidir levando em conta o ruído, e não o primeiro lugar da tabela: com poucas dezenas de perguntas, diferenças de menos de 10 pontos não distinguem configurações, e nesse caso a regra é ficar com a mais simples e barata. O sexto é guardar o conjunto de perguntas e repetir o experimento quando o modelo de embedding, os documentos ou o tamanho do banco mudarem.

experimento
figura 14 · experimento

Da esquerda para a direita. O corpus (cinza) e as configurações (roxo: estratégia, tamanho, sobreposição e prefixo) alimentam o passo “Corta”, que gera um índice por configuração, e a seta grossa roxa leva os pedaços ao passo “Embute e busca” (azul), que devolve um ranking com os cinco primeiros por pergunta. Em paralelo, as perguntas com a resposta marcada (laranja) fornecem o gabarito ao passo “Mede” (verde), que calcula hit rate, MRR, cobertura e custo. O losango decide: se a diferença é maior que o ruído (cerca de 8 pontos com 56 perguntas), adota-se a melhor configuração e guarda-se o conjunto de perguntas (seta verde grossa); se não, fica-se com a mais simples e barata. Na ponta esquerda, a seta pontilhada laranja lembra que as perguntas reais dos usuários devem realimentar o conjunto.

Antes de rodar o experimento, um roteiro curto ajuda a escolher o ponto de partida.

decisao
figura 15 · decisao

O fluxograma desce com perguntas sobre o documento. Se ele tem títulos e seções, comece pelo corte por estrutura (seta azul grossa, caixa roxa). Se não tem, mas é texto corrido, use o recursivo com 100 a 200 tokens e sem sobreposição. Se tem tabela ou código, trate a linha com cabeçalho e a função inteira como blocos indivisíveis. Se a resposta depende da frase vizinha, use pequeno para grande ou o prefixo com título e seção (verde). E, no fim, o caminho laranja leva ao experimento da seção anterior, porque nenhuma árvore de decisão substitui a medida com os seus dados. Os valores (100 a 200 tokens) são ponto de partida meu a partir do que medi, e não regras.

18. Erros comuns e boas práticas

Os erros de chunking são traiçoeiros porque não geram exceção: o RAG roda e responde, só que pior. Por isso vale conhecê-los pelo nome.

O primeiro é medir o tamanho em caracteres ou palavras e ignorar o limite do modelo de embedding. O limite é em tokens, e o que passa dele é cortado em silêncio (seção 4). A boa prática é ler max_seq_length (ou a documentação do provedor), contar com o tokenizador do modelo e deixar uma folga, como o 100 que usei para um limite de 128.

O segundo é cortar sem olhar a estrutura. O título órfão, a linha de tabela sem cabeçalho e a função cortada ao meio não aparecem em nenhuma métrica agregada. A prática é abrir uma dúzia de pedaços ao acaso e ler: se um deles não faz sentido para você, também não vai fazer para o modelo.

O terceiro é usar um único tamanho para todos os documentos. Um manual com seções, um log e um contrato pedem cortes diferentes, e é comum ter um corte por tipo de documento, escolhido na ingestão (Ingestão de documentos).

O quarto é tratar a sobreposição como remédio universal. Ela reduz as frases cortadas, mas aumenta o índice (no meu teste, 78% mais pedaços com 50 tokens de 100) e repete texto no contexto. A prática é primeiro cortar em limites naturais e só depois medir se a sobreposição ainda ajuda.

O quinto é decidir com três exemplos bonitos. A tabela da seção 13 mostra que, com 56 perguntas, boa parte das diferenças desaparece no ruído. A prática é ter um conjunto de perguntas, medir, e usar o teste pareado quando duas configurações estiverem próximas.

O sexto é um pedaço pequeno sem contexto. Frases isoladas viram vetores pobres (as janelas de frases da seção 14 foram as piores nas medidas). O remédio é o prefixo, os metadados ou o padrão pequeno para grande.

O sétimo é mudar o chunking sem reindexar com cuidado. Os ids dos registros (Banco de dados vetorial) costumam nascer do arquivo e do número do pedaço, e se o corte muda, o pedaço 7 deixa de ser o mesmo texto. A prática é tratar o chunking como a dimensão e a métrica de uma coleção: parte da identidade dela. Uma mudança de corte é uma coleção nova (faq_v2), indexada por inteiro, conferida e só então promovida.

O oitavo é esquecer de guardar a origem. Cada pedaço deve levar, no payload, o documento, a seção e a posição de onde veio, para citar a fonte, filtrar e reconstruir o pai.

O nono é deixar o corte semântico gerar micro-pedaços. Se usar, defina um tamanho mínimo (os 21 pedaços com menos de 20 tokens da seção 12 teriam sido juntados aos vizinhos).

Falta responder de onde vem o texto que entra no chunking. Neste corpus ele já chegou limpo, em Markdown, com títulos e tabelas nos lugares certos. Em um projeto real, ele vem de PDFs, páginas web, planilhas e notas, em que a estrutura que o corte por estrutura e o corte de tabelas exigem pode ter se perdido na extração, e em que o texto muda e precisa ser atualizado sem duplicar pedaços. É disso que trata Ingestão de documentos.

19. Exercícios

Os exercícios 1 a 3 são contas e raciocínio. Os demais rodam em CPU e sem chave de API; para eles você precisa do sentence-transformers e de qualquer conjunto de documentos Markdown seus, com 5 a 10 arquivos.

  1. Um documento tem 10.000 tokens e um modelo de embedding lê 512. Se você cortar em pedaços de 400 tokens sem sobreposição, quantos pedaços saem? E com sobreposição de 100 tokens? (Resposta: sem sobreposição, 10.000 ÷ 400 = 25 pedaços. Com sobreposição, a janela avança 300 tokens por vez: depois da primeira janela, restam 10.000 menos 400 = 9.600 tokens, que pedem 32 avanços, e o total é 33 pedaços, 32% a mais, na ordem dos 20% e 78% que medi para 20 e 50 tokens de sobreposição num corte de 100.)

  2. Uma consulta devolve 3 pedaços. Calcule o custo de contexto em tokens com pedaços de 150 tokens e com pedaços de 1.500 tokens, e o custo mensal, a 20.000 consultas por mês, se o preço da entrada for US$ 3 por milhão de tokens. (Resposta: 450 contra 4.500 tokens por consulta, ou 9 milhões contra 90 milhões de tokens por mês, US$ 27 contra US$ 270. O preço é um exemplo seu, troque pelo do seu provedor.)

  3. Uma tabela de preços tem cabeçalho de 40 tokens e 200 linhas de 25 tokens. Compare, em número de pedaços e tokens indexados, o corte por tamanho fixo de 100 tokens e o corte de uma linha por pedaço com o cabeçalho repetido. (Resposta: o fixo gera (40 + 200 × 25) ÷ 100 = 50,4, ou seja, 51 pedaços e 5.040 tokens. Uma linha por pedaço gera 200 pedaços de 65 tokens, 13.000 tokens indexados, 2,6 vezes mais, em troca de o cabeçalho acompanhar cada linha.)

  4. Rode o chunkers.py sobre um documento seu e imprima os 5 primeiros pedaços de fixo, de recursivo e de por_estrutura com o mesmo tamanho. Anote o que está errado em cada um (frase partida, título órfão) antes de olhar qualquer métrica.

  5. Meça o seu limite: carregue o modelo de embedding que você usa e imprima max_seq_length. Depois codifique um texto de 3 vezes esse limite, cuja informação importante está só no fim, e verifique se uma pergunta sobre essa informação encontra o texto.

  6. Monte 20 perguntas com a resposta marcada sobre os seus documentos, com a frase-chave, no formato da seção 6. Reproduza o experimento de tamanho (16 a 512) e ache o ponto em que o hit@1 deixa de subir. Compare com o ponto em que ctx3 dobra.

  7. Repita o experimento de sobreposição com o corte fixo de 100 tokens e sobreposição de 0, 10, 20 e 50. Meça o hit@3, a cobertura e o número de pedaços. A cobertura sobe? O hit@3 sobe junto?

  8. Acrescente à sua configuração preferida o prefixo “Título do documento > seção” e compare o hit@1 com o teste pareado (conte as perguntas que só uma das configurações acerta). O resultado passa do que o acaso explica com 20 perguntas?

  9. Implemente o pequeno para grande com filhos de 40 tokens e pai de 300 tokens (em vez da seção) e compare o custo de contexto e o hit@1 com o recursivo de 100. Para quais documentos o padrão ajuda?

  10. Pegue uma tabela e uma função suas. Corte-as com o corte por tamanho fixo e depois com blocos atômicos, e escreva, para cada uma, uma pergunta que só a versão atômica consegue responder inteira. (Relaciona-se com Ingestão de documentos.)

20. Glossário

TermoSignificado
Chunk (pedaço, trecho)Unidade de texto que o RAG indexa, embute e devolve
ChunkingDecisão e processo de cortar documentos em pedaços
TokenPedaço de palavra que o modelo lê; é a unidade do tamanho de um pedaço e do limite do modelo
Limite de entrada (max_seq_length)Número máximo de tokens que o modelo de embedding lê; o excedente é cortado
DiluiçãoPerda de foco de um vetor quando o texto trata de muitos assuntos
Hit rate (hit@k)Fração das perguntas em que o pedaço com a resposta aparece entre os k primeiros resultados
MRRMédia do inverso da posição do primeiro resultado correto
CoberturaFração das respostas que existem inteiras em algum pedaço, achadas ou não
Corte recursivoDivide por separadores do maior para o menor (parágrafo, linha, frase, palavra) e junta vizinhos que couberem
Corte por estruturaCorta nas marcas do documento, como títulos e seções
Corte semânticoCorta onde a similaridade entre frases vizinhas cai
Sobreposição (overlap)Repetição do fim de um pedaço no começo do seguinte
Janela deslizanteCorte em que cada janela avança menos que o seu tamanho, criando sobreposição
Título órfãoTítulo que ficou no fim de um pedaço, longe do texto que ele introduz
Pequeno para grande (small-to-big)Indexar pedaços pequenos (filhos) e entregar ao modelo o texto maior de onde vieram (pai)
Prefixo contextualTexto acrescentado ao pedaço (documento, seção ou uma frase de um LLM) antes de embutir
Contexto do pedaçoInformação sobre o pedaço que o corte retirou do documento: título, seção, origem
Bloco atômicoUnidade que não se corta: uma linha de tabela com cabeçalho, uma função
IscaNeste texto, a frase mais parecida com a pergunta que não é a resposta

Fontes

Consultadas em 15 de outubro de 2026.

  • Anthropic: Introducing Contextual Retrieval (publicado em 19 de setembro de 2024): prefixo de contexto gerado por um LLM para cada pedaço, normalmente de 50 a 100 tokens; redução da taxa de falha nos 20 primeiros pedaços de 35% (5,7% para 3,7%) com embeddings contextuais, 49% com BM25 contextual e 67% com reranking; custo único de US$ 1,02 por milhão de tokens de documento com prompt caching, para pedaços de 800 tokens e documentos de 8 mil.
  • Chroma: Evaluating Chunking Strategies for Retrieval (Smith e Troynikov, 3 de julho de 2024): comparação de cortes (recursivo, por tokens, semânticos); pedaços menores elevam precisão e IoU, mas não de forma consistente o recall; reduzir a sobreposição melhora o IoU; separadores padrão do recursivo produziam pedaços curtos e os autores acrescentaram ., ? e !; o corte recursivo de 200 tokens sem sobreposição teve desempenho consistentemente alto; a configuração padrão de 800 tokens com 400 de sobreposição teve as piores precisões; corte semântico por agrupamento de 200 tokens com a maior precisão. Resultados deles, com outros modelos e corpora.
  • LlamaIndex: node parsers: SentenceWindowNodeParser (window_size=3 no exemplo), HierarchicalNodeParser (chunk_sizes=[2048, 512, 128] no exemplo) com AutoMergingRetriever, SemanticSplitterNodeParser (buffer_size=1, breakpoint_percentile_threshold=95 no exemplo e o aviso de ajustar o limiar). A página não declara valores padrão.
  • LangChain: character.py (código-fonte): lista padrão de separadores do RecursiveCharacterTextSplitter, ["\n\n", "\n", " ", ""]. Não consultei os valores padrão de tamanho e sobreposição.
  • Embeddings, LLMs e Banco de dados vetorial: limites de entrada dos modelos (128 do MiniLM medido em Embeddings, 8.192 da OpenAI e 32.000 da Voyage segundo a documentação consultada lá), truncamento silencioso, ids determinísticos e coleções versionadas.

Medido por mim (ambiente descartável no scratchpad da sessão, macOS, Python 3.12, sentence-transformers 6.1.0, torch 2.14.1, transformers 5.20.0 e numpy 2.5.4, nenhum instalado fora dele, e nenhuma biblioteca nova instalada para esta aula): o corpus de 9 documentos e as 56 perguntas, escritos por mim para a aula; a contagem de 5.027 tokens (a mesma nos dois tokenizadores); o max_seq_length dos dois modelos (128 e 512); todos os resultados das seções 5, 7, 13, 14, 15 e 16 (tamanho, estratégias, sobreposição, pequeno para grande, janela de frases, prefixo, tabelas, código e diluição); os testes pareados; os pedaços impressos. Os modelos são o paraphrase-multilingual-MiniLM-L12-v2 e o multilingual-e5-small, do cache local do Hugging Face, com o prefixo query: e passage: aplicado à mão no e5.

Sem execução ou sem fonte: o corpus é pequeno e inventado, e as perguntas foram escritas por mim, que conhecia as respostas, o que pode favorecer a busca mais do que um conjunto de perguntas de usuários reais. Com 56 perguntas, diferenças menores que 8 a 13 pontos percentuais são ruído, e só apresento como achado o que os testes pareados sustentam (estrutura e recursivo contra o corte fixo). Medi só dois modelos pequenos e só em português; os melhores tamanhos podem ser outros para modelos maiores, outras línguas e corpora maiores. A seção 5 usa frases de outros documentos sorteadas com semente fixa, e há um ponto fora da curva no e5 que não investiguei. Não rodei o Contextual Retrieval, o HierarchicalNodeParser, o AutoMergingRetriever, o SentenceWindowNodeParser, os cortadores de código por sintaxe nem as bibliotecas LangChain e LlamaIndex: o que a aula diz delas vem só da documentação, e minhas implementações dos cortes são simples e escritas para a aula. O corte semântico testado é uma versão simples com percentil e sem tamanho mínimo, e a conclusão vale para ela. Não confirmei os valores padrão de tamanho e sobreposição do RecursiveCharacterTextSplitter, nem as afirmações do relatório da Chroma além do que a página resume. A frase sobre cláusulas de contrato e remissões, e as sugestões de gerar perguntas com um LLM, são prática geral e não foram testadas aqui. Os valores do roteiro de escolha (100 a 200 tokens) e o exercício de custo com preço de exemplo são julgamento meu. As 15 figuras foram renderizadas e examinadas em pelo menos uma rodada, e as principais (1, 2, 6 e 7) em duas ou mais.

Relacionados

↑ Voltar ao topo

Atalhos de teclado

Abrir busca
/
Alternar tema claro/escuro
t
Aula anterior na série
←
Próxima aula na série
→
Lista de atalhos
?