FMFelipe MiillerNotes on software & systems
HomeBlogAbout
GitHub

Keep building.

Felipe Miiller · © 2026

MailGitHubGitHubLinkedinGitHub
View source on GitHub
Back to blog

Chunking: como cortar um documento sem perder a resposta

04/10/2026
26 min de leitura
7580 palavras
RAGPythonAnálise de Dados
  • 1. O corte decide se a resposta existe
  • 2. A tentativa curta: contar caracteres e pronto
  • 3. O que um trecho precisa ter
  • 4. Corte estrutural: perguntar ao documento onde ele quer ser cortado
  • 5. Small-to-big: o filho acha, o pai alimenta
  • A busca casa o filho (curto, específico) ...
  • ... e o que entra na conversa é o pai (com o cabeçalho que dá sentido ao número).
  • 6. Corte semântico: cortar onde o assunto muda
  • 7. A tabela: o caso que nenhum cortador salva sozinho
  • 8. A experiência: o ranking quase não mexe, o conteúdo mexe
  • 9. O prefixo de contexto: o caminho do trecho, colado nele
  • 10. A decisão, por tipo de documento
  • 11. Como medir se o corte prestou
  • TL;DR
  • Referências

Lede. O seu sistema de busca não recupera documento, ele recupera trecho — e quem decide onde cada trecho começa e termina é um código seu, com um parâmetro que você escolheu numa tarde e nunca mais revisitou. Neste artigo você corta o mesmo documento de quatro maneiras, mede as quatro, e descobre que duas delas dão o mesmo resultado no ranking enquanto o conteúdo do que elas entregam é completamente diferente. Todo o código roda offline, sem chave de API. Onde você está na linha. Este é o passo 2 de 13 — cortar o documento. Antes dele: 01 · Anatomia do pipeline, que definiu o Chunk e mostrou a ordem de montagem. Depois dele: 03 · Embeddings e bancos vetoriais e 04 · Busca híbrida. Se você leu o 01, pode pular direto para a seção 3.


1. O corte decide se a resposta existe

Comece com a afirmação que decide o resto do artigo: o que o modelo não lê, o modelo não tem. Não existe modelo que responda sobre um trecho que não entrou na conversa. Se

o corte partiu a resposta ao meio, a resposta não está errada por falta de modelo — ela

não existia para o sistema.

A unidade que a busca devolve e o modelo lê tem nome: chunk. A analogia mais honesta

não é "fatia do documento", é a ficha de catálogo de uma biblioteca: você não guarda o

livro, guarda o verbete que diz do que ele fala, e é no verbete que a busca acontece. A

ficha tem que ser autossuficiente — quem lê só ela, sem o livro ao lado, tem que

entender do que se trata.

Por que isso aparece como problema de prompt e não de corte? Porque o sintoma é

enfeitado. A resposta sai curta, genérica, correta em tom e vazia de conteúdo. Você abre o

log, vê que a busca devolveu trechos, e conclui que o modelo entendeu errado. Trocar o

modelo não conserta. Ajustar o prompt tampouco. A informação nunca chegou inteira.

⚠️ "Resposta genérica" é o sintoma de corte ruim, não de prompt fraco
O corte defeituoso produz uma citação que parece correta: o trecho existe, tem o

assunto certo, e está incompleto. Nenhum teste de sanidade do modelo acusaria isso. Se

você suspeita do prompt antes de suspeitar do corte, o próximo passo é ler o trecho

recuperado — não reescrever a instrução.


2. A tentativa curta: contar caracteres e pronto

O padrão de toda biblioteca de RAG é o mesmo: um número de caracteres, um número de

sobreposição, e a certeza de que alguém já testou isso na sua base. Vamos fazer.

O documento de teste é um manual de manutenção com quatro seções e uma tabela de

tolerância:

DOC = """MANUAL DE OPERAÇÕES

1. Escopo
Este manual descreve o procedimento padrão de manutenção. Ele não substitui a
instrução específica de cada equipamento.

2. Frequência
A inspeção visual ocorre a cada 30 dias. A medição de vibração ocorre a cada 90 dias.
Uma inspeção fora do prazo exige aprovação do responsável técnico.

3. Tabela de tolerância
| Medida | Unidade | Limite | Ação |
|---|---|---|---|
| Vibração | mm/s | 4,5 | Parar o equipamento |
| Temperatura | C | 85 | Reduzir a carga |
| Ruído | dB | 92 | Revisar o isolamento |
| Desalinhamento | mm | 0,08 | Realignar o eixo |

4. Registro
Cada medição deve ser registrada com data, responsável e valor obtido. Registros
incompletos não são aceitos pelo sistema de auditoria.
"""


def split_by_size(text: str, size: int = 200, overlap: int = 40) -> list[str]:
    """A tentativa mínima: conta `size` caracteres e avança `size - overlap`."""
    partes: list[str] = []
    inicio = 0
    while inicio < len(text):
        partes.append(text[inicio : inicio + size])
        # Avança menos que `size` para que o final do trecho anterior repita
        # no começo do próximo. É o que se chama de sobreposição.
        inicio += size - overlap
    return partes


for i, p in enumerate(split_by_size(DOC)):
    print(f"[{i}] {p[:60]!r}")

Cinco trechos, e três deles começam no meio de uma linha. O primeiro problema não é

de tabela, é mais elementar que isso:

[0] 'MANUAL DE OPERAÇÕES\n\n1. Escopo\nEste manual descreve o proced'
[1] 'ência\nA inspeção visual ocorre a cada 30 dias. A medição de '
[2] '3. Tabela de tolerância\n| Medida | Unidade | Limite | Ação |'
[3] 'a carga |\n| Ruído | dB | 92 | Revisar o isolamento |\n| Desal'
[4] 'esponsável e valor obtido. Registros\nincompletos não são ace'

O trecho [3] é o que mata a conversa. Ele começa em a carga | — a cauda de uma linha

da tabela — e traz dois valores numéricos. Para quem lê, 92 é um número sem nome: o

cabeçalho com a palavra Limite está no trecho [2], que a busca talvez nem devolva.

O sistema tem o dado e não consegue usá-lo.

Falha 1 — o corte não conhece a linha. Você pediu 200 caracteres e o código entregou

200 caracteres, no meio de uma frase se for preciso.

Falha 2 — a sobreposição não é contexto. A sobreposição repete as últimas palavras do

trecho anterior. Isso salva a frase partida, e só isso. Ela não repete o título da seção,

não repete o cabeçalho da tabela e não repete a unidade. E aumenta o custo: cada trecho

passa a carregar texto que já estava no vizinho.

💡 A sobreposição resolve um problema só, e cria um custo
Use para palavra de fronteira: a última frase de um trecho e a primeira do

seguinte são a mesma ideia, e uma delas pode sair do ranking enquanto a resposta está

na outra. Não use como substituto de contexto estrutural. Comece com 10% a 15% do

tamanho do trecho e dobre só se as medições mandarem.


3. O que um trecho precisa ter

Depois de ver a falha, dá para listar o que um Chunk tem que carregar. São três

requisitos, e cada um vira campo no contrato.

Autossuficiente. Lido sozinho, sem o documento ao lado, o trecho precisa responder à

pergunta. Se ele só vale com o trecho vizinho na conversa, ele não é autossuficiente — e

a busca pode devolver um e não o outro.

Com origem. De qual documento veio, em que posição está, e qual é o caminho de seções

que ele percorre. Sem origem não há citação; e resposta sem fonte é opinião.

Tamanho coerente. O limite é medido em token — o pedaço que o modelo realmente

lê — e não em caractere. Um token não é um caractere: em português, acento e pontuação

quebram a palavra em mais de um token. Um trecho de 200 caracteres de prosa tem umas 60

palavras; os mesmos 200 caracteres de código são 200 palavras. O mesmo parâmetro produz

trechos de tamanhos muito diferentes conforme o tipo de documento, e é por isso que a

tabela de decisão da seção 10 existe. O que é token e por que ele não é letra está

explicado no guia de vetores da OpenAI.

Os contratos são os mesmos da série inteira, e aparecem aqui completos:

from __future__ import annotations

import hashlib
from typing import Any

from pydantic import BaseModel, ConfigDict, Field


class Contract(BaseModel):
    """Base de todas as fichas.

    extra="forbid"  = campo que não existe é erro, não é ignorado.
    frozen=True     = ninguém muda a ficha depois que ela foi criada.
    """

    model_config = ConfigDict(extra="forbid", frozen=True)


class Document(Contract):
    """A unidade de origem: um arquivo, um ticket, uma norma, um contrato."""

    doc_id: str
    uri: str                                # de onde veio
    title: str
    text: str
    metadata: dict[str, Any] = Field(default_factory=dict)

    @property
    def content_hash(self) -> str:
        """Impressão digital do texto, para saber se o documento mudou."""
        return hashlib.blake2b(
            self.text.encode("utf-8"), digest_size=16
        ).hexdigest()


class Chunk(Contract):
    """A unidade indexada: o trecho que a busca devolve.

    `parent_id` existe porque quase todo documento tem hierarquia
    (capítulo > seção > parágrafo). Guardar o pai desde o começo permite
    buscar no trecho pequeno e entregar o trecho grande, sem reindexar.
    """

    chunk_id: str
    doc_id: str
    parent_id: str | None                  # None = é a raiz
    ordinal: int                            # posição dentro do pai
    text: str
    token_count: int                        # contagem de token, não de letra
    metadata: dict[str, Any] = Field(default_factory=dict)

Repare em parent_id. Ele é o que torna possível a estratégia da seção 5, e ele precisa

existir desde o primeiro dia: acrescentar o campo depois significa reindexar a base

inteira para preenchê-lo. É o mesmo custo de trocar o modelo de vetor, tratado na

seção 9 do artigo 03.

Agora a mesma tentativa de dez linhas, com contrato em vez de lista de texto:

def count_tokens(text: str) -> int:
    """Aproximação por palavra, para rodar sem tokenizador carregado.

    O tokenizador real do modelo conta diferente — e a diferença importa
    quando o trecho vai virar orçamento de contexto (artigo 09).
    """
    return len(text.split())


class FixedSizeChunker:
    """Conta caracteres e avança. A sobreposição salva a frase de fronteira."""

    def __init__(self, size: int = 200, overlap: int = 40) -> None:
        self.size = size
        self.overlap = overlap

    def split(self, doc: Document) -> list[Chunk]:
        """Devolve `Chunk` com origem, ordem e contagem de token."""
        partes: list[str] = []
        inicio = 0
        while inicio < len(doc.text):
            partes.append(doc.text[inicio : inicio + self.size])
            if inicio + self.size >= len(doc.text):
                break
            inicio += self.size - self.overlap

        return [
            Chunk(
                chunk_id=f"{doc.doc_id}#{i}",
                doc_id=doc.doc_id,
                parent_id=None,                       # ainda não existe pai
                ordinal=i,
                text=texto,
                token_count=count_tokens(texto),
                metadata={"tenant": doc.metadata.get("tenant", "default")},
            )
            for i, texto in enumerate(partes)
            if texto.strip()
        ]


from typing import Protocol, runtime_checkable


@runtime_checkable
class Chunker(Protocol):
    """O formato que o resto do sistema espera de um cortador.

    Um `Protocol` descreve o formato esperado sem escrever implementação —
    é o que permite trocar o cortador sem mexer no resto do pipeline.
    """

    def split(self, doc: Document) -> list[Chunk]: ...


DOCUMENTO = Document(
    doc_id="manual-01",
    uri="urn:doc:manual-01",
    title="Manual de Operações",
    text=DOC,
    metadata={"tenant": "empresa-a", "tipo": "manual"},
)

trechos = FixedSizeChunker().split(DOCUMENTO)
print(len(trechos), [t.token_count for t in trechos])
print(repr(trechos[3].text[:40]))

A saída mostra o tamanho em token de cada um dos cinco trechos — 31, 36, 45, 40 e 13 —

mesmo tendo todos exatamente o limite de caractere. É a primeira prova de que

tamanho_em_caráctere não é tamanho: o quinto tem 13 palavras e o terceiro tem 45, e os

dois custam os mesmos 200 caracteres para o modelo.

O Protocol é o contrato de troca: FixedSizeChunker, o cortador estrutural da seção 4

e o semântico da seção 6 atendem ao mesmo formato, e o ingest do

artigo 01 não percebe a troca.


4. Corte estrutural: perguntar ao documento onde ele quer ser cortado

O corte por estrutura inverte a pergunta. Em vez de "onde cai o próximo corte de 200

caracteres?", ele pergunta "onde este texto termina uma unidade?" — e a resposta está

nos separadores que o próprio documento usa: linha em branco, título de seção, fim de

parágrafo.

A implementação é uma lista de separadores em ordem de preferência, aplicada da maior

unidade para a menor. É a estratégia da

divisão recursiva por caractere do LangChain,

que é a implementação de referência da categoria.

import re
from collections.abc import Sequence

SEPARADORES = ["\n\n", "\n", ". ", " "]


def pack_lines(linhas: Sequence[str], size: int, atoms: bool = False) -> list[str]:
    """Junta linhas em pedaços de até `size` sem quebrar nenhuma ao meio.

    `atoms=True` cola cada linha de tabela à linha seguinte. É o passo que
    falta depois de linearizar a tabela, e a seção 7 mostra por quê.
    """
    grupos: list[str] = []
    i = 0
    while i < len(linhas):
        if atoms and linhas[i].strip().startswith("|") and i + 1 < len(linhas):
            grupos.append(linhas[i] + "\n" + linhas[i + 1])
            i += 2
        else:
            grupos.append(linhas[i])
            i += 1

    partes: list[str] = []
    atual = ""
    for grupo in grupos:
        if atual and len(atual) + len(grupo) + 1 > size:
            partes.append(atual)
            atual = grupo
        else:
            atual = f"{atual}\n{grupo}" if atual else grupo
    if atual:
        partes.append(atual)
    return partes


def split_by_structure(
    doc: Document, size: int = 200, repack: bool = False
) -> list[Chunk]:
    """Corta por separador, do maior para o menor, dentro de cada bloco.

    A ideia: nenhum pedaço começa nem termina no meio de uma unidade do
    documento. Se o bloco é grande demais, ele é subdividido pelo próximo
    separador, e assim por diante, até caber.

    `repack=True` reagrupa pedaços vizinhos que saíram do mesmo bloco, até
    `size`. Sem essa opção, uma tabela de seis linhas vira seis trechos de
    uma linha cada — que é exatamente o que a seção 8 mede.
    """
    pedacos: list[str] = []
    for bloco in [b.strip() for b in doc.text.split("\n\n") if b.strip()]:
        if len(bloco) <= size:
            pedacos.append(bloco)
            continue

        atual = [bloco]
        for separador in SEPARADORES:
            novos: list[str] = []
            for pedaco in atual:
                if len(pedaco) <= size:
                    novos.append(pedaco)
                    continue
                novos.extend(p for p in pedaco.split(separador) if p.strip())
            atual = novos
            if all(len(p) <= size for p in atual):
                break
        # Reagrupa ou não: a decisão é uma linha de código e muda tudo.
        pedacos.extend(pack_lines(atual, size) if repack else atual)

    return [
        Chunk(
            chunk_id=f"{doc.doc_id}#{i}",
            doc_id=doc.doc_id,
            parent_id=None,
            ordinal=i,
            text=p.strip(),
            token_count=count_tokens(p),
            metadata={"tenant": doc.metadata.get("tenant", "default")},
        )
        for i, p in enumerate(pedacos)
        if p.strip()
    ]


estruturais = split_by_structure(DOCUMENTO)
print(len(estruturais))
for t in estruturais[3:8]:
    print(f"  ({t.token_count:>2} tok) {t.text[:56]!r}")

O resultado são onze trechos, e nenhum deles começa no meio de uma linha. O corte

estrutural elimina a falha 1 da seção 2 por completo. E ao mesmo tempo ele produz o

pior resultado da seção 8, porque cada linha da tabela virou um trecho:

( 4 tok) '3. Tabela de tolerância'
( 9 tok) '| Medida | Unidade | Limite | Ação |'
( 1 tok) '|---|---|---|---|'
( 7 tok) '| Vibração | mm/s | 4,5 | Parar o equipamento |'
( 6 tok) '| Temperatura | C | 85 | Reduzir a carga |'

Cinco trechos para responder uma pergunta de tabela, e cada um deles isolado. Agora

ligue repack=True:

reempacotados = split_by_structure(DOCUMENTO, repack=True)
print(len(reempacotados))
for t in reempacotados[3:5]:
    print(f"  ({t.token_count:>2} tok) {t.text[:56]!r}")

Seis trechos, e o quarto sai assim:

'3. Tabela de tolerância\n| Medida | Unidade | Limite | Ação |\n|---|---'
'| Ruído | dB | 92 | Revisar o isolamento |\n| Desalinhamento | mm | 0,0'

⚠️ "Corte estrutural" não é uma estratégia: é um espaço de implementação
As duas variantes acima usam o mesmo algoritmo e produzem 11 trechos ou 6, com

resultados opostos. O que decide não é o nome da técnica, é o que o seu código faz > com o pedaço depois de achar a fronteira. Nenhuma biblioteca vai adivinhar o que a

sua base precisa: o reagrupamento é seu, e ele é uma linha de código.

Nenhuma das duas resolve o cabeçalho da tabela: no trecho empacotado, o cabeçalho ficou no

pedaço 4 e a linha do desalinhamento no pedaço 5. É o problema que a seção 7 ataca.


5. Small-to-big: o filho acha, o pai alimenta

Até aqui o trecho é a unidade de busca e a unidade de contexto. Isso prende o

sistema num tamanho só, e o tamanho certo para achar não é o tamanho certo para ler.

O embate concreto: um parágrafo de 60 palavras é perfeito para a busca, porque é

específico e não disputa similaridade com outros parágrafos. Mas a resposta para "qual o

limite de vibração?" é a linha da tabela mais o cabeçalho mais o título da seção — e isso

sozinho é curto demais para ser um bom alvo de busca. Se você engorda o trecho até ele ter

contexto, ele passa a competir com quinze outros trechos do mesmo manual.

A saída é quebrar a recuperação em duas etapas com dois tamanhos. O filho (pequeno,

específico) é indexado e é o que a busca devolve. O pai (maior, com contexto) é

guardado ao lado e é o que entra na conversa:

A busca devolve o filho, o código troca pelo pai, e o modelo recebe o contexto. O ganho é

que o alvo de similaridade fica pequeno e preciso enquanto o material entregue fica

completo. É a mesma ideia do

small-to-big: a busca é feita na granularidade fina, e

a leitura acontece na grossa.

class ParentChildIndex:
    """Indexa os filhos e guarda os pais. O filho acha; o pai alimenta."""

    def __init__(self) -> None:
        self._children: dict[str, Chunk] = {}
        self._parents: dict[str, Chunk] = {}

    def upsert(self, parent: Chunk, children: Sequence[Chunk]) -> None:
        """Grava o pai uma vez e os filhos com `parent_id` apontando para ele."""
        self._parents[parent.chunk_id] = parent
        for child in children:
            self._children[child.chunk_id] = child

    def expand(self, child_id: str) -> Chunk:
        """Troca o filho recuperado pelo pai que dá contexto a ele."""
        child = self._children[child_id]
        return self._parents[child.parent_id] if child.parent_id else child

    def child_containing(self, needle: str) -> Chunk | None:
        """Atalho da demonstração: o primeiro filho que contém `needle`."""
        for child in self._children.values():
            if needle in child.text:
                return child
        return None


def big_blocks(text: str, size: int) -> list[str]:
    """Fatia o documento em blocos; só corta por caractere se um bloco
    sozinho for maior que `size`."""
    blocos = [b.strip() for b in text.split("\n\n") if b.strip()]
    if all(len(b) <= size for b in blocos):
        return blocos
    return [text[i : i + size] for i in range(0, len(text), size)]


def index_parents_and_children(
    doc: Document, index: ParentChildIndex, parent_size: int = 800, child_size: int = 200
) -> None:
    """Corta em duas escalas e liga filho -> pai pelo campo `parent_id`."""
    for i, parent_text in enumerate(big_blocks(doc.text, parent_size)):
        parent = Chunk(
            chunk_id=f"{doc.doc_id}#p{i}", doc_id=doc.doc_id, parent_id=None,
            ordinal=i, text=parent_text, token_count=count_tokens(parent_text),
            metadata={"tenant": doc.metadata.get("tenant", "default")},
        )
        # O pai é também um `Document`: os mesmos cortadores servem nos dois
        # níveis, e só muda o tamanho.
        child_doc = Document(
            doc_id=parent.chunk_id, uri=doc.uri, title=doc.title,
            text=parent_text, metadata=doc.metadata,
        )
        children = [
            c.model_copy(update={"parent_id": parent.chunk_id})
            for c in split_by_structure(child_doc, child_size, repack=True)
        ]
        index.upsert(parent, children)


index_pc = ParentChildIndex()
index_parents_and_children(DOCUMENTO, index_pc)

# A busca casa o filho (curto, específico) ...
hit = index_pc.child_containing("0,08")
print(repr(hit.text))
# ... e o que entra na conversa é o pai (com o cabeçalho que dá sentido ao número).
print(repr(index_pc.expand(hit.chunk_id).text[:70]))

A saída é o resumo do benefício em duas linhas:

'| Ruído | dB | 92 | Revisar o isolamento |\n| Desalinhamento | mm | 0,08 | Reali'
'3. Tabela de tolerância\n| Medida | Unidade | Limite | Ação |\n|---|---|---|'

O filho que a busca recupera é o pedaço órfão da seção 4 — número sem cabeçalho. O pai que

entrega ao modelo é a tabela inteira, com cabeçalho e título. Você não precisou ensinar

nenhum corte a adivinhar onde a resposta estava: deixou os dois na base e trocou um pelo

outro no momento da consulta.

Custo: o mesmo texto é indexado duas vezes, uma em escala pequena e outra em escala

grande, e a indexação custa o dobro. Você paga isso em todos os documentos e só gasta o

ganho nas consultas em que o filho realmente foi recuperado. Em base grande e com

reindexação frequente, calcule antes de assumir que cabe.


6. Corte semântico: cortar onde o assunto muda

As duas estratégias anteriores cortam por forma: contam caractere ou acham separador. O

corte semântico corta por conteúdo: ele vetoriza as frases, compara cada frase com a

seguinte, e abre um trecho novo onde a semelhança despenca. A ideia é que a fronteira

natural do assunto é o ponto onde o texto muda de tema — e esse ponto não é um separador

visível, é uma queda de similaridade.

O código depende de um modelo de vetor. Como este artigo precisa rodar sem rede, ele usa

o mesmo vetorizador de contagem de palavras do artigo 01,

que não é semântico — ele serve para exercitar o encadeamento, e o ponto de queda que

ele produz é praticamente arbitrário:

import math


class FakeEmbedder:
    """Contagem de palavras projetada num vetor. NÃO é semântico.

    Serve para rodar o encadeamento sem chave de API. Para corte semântico
    de verdade, o vetor precisa vir do modelo do artigo 03.
    """

    def __init__(self, dim: int = 64) -> None:
        self.dim = dim

    def _vector(self, text: str) -> list[float]:
        vector = [0.0] * self.dim
        for word in text.lower().split():
            vector[hash(word) % self.dim] += 1.0
        norm = math.sqrt(sum(v * v for v in vector)) or 1.0
        return [v / norm for v in vector]   # normalizado: norma = 1

    def embed_documents(self, texts: Sequence[str]) -> list[list[float]]:
        return [self._vector(t) for t in texts]

    def embed_query(self, text: str) -> list[float]:
        return self._vector(text)


def cosine(a: Sequence[float], b: Sequence[float]) -> float:
    """Vetores normalizados => o produto interno já É o cosseno."""
    return sum(x * y for x, y in zip(a, b, strict=True))


def split_by_semantic_break(texto: str, dim: int = 64, queda: float = 0.6) -> list[str]:
    """Abre um trecho novo onde a semelhança entre linhas cai abaixo de `queda`."""
    embedder = FakeEmbedder(dim)
    unidades = [linha for linha in texto.splitlines() if linha.strip()]

    grupos: list[list[str]] = [[unidades[0]]]
    for anterior, seguinte in zip(unidades, unidades[1:]):
        sim = cosine(embedder.embed_query(anterior), embedder.embed_query(seguinte))
        if sim < queda:
            grupos.append([seguinte])       # queda: o assunto mudou
        else:
            grupos[-1].append(seguinte)     # continuação do mesmo assunto
    return ["\n".join(grupo) for grupo in grupos]


for p in split_by_semantic_break(DOC)[:6]:
    print(f"  {p[:56]!r}")

Repare no que acontece com a tabela. As linhas | Medida | Unidade | Limite | Ação | e

|---|---|---|---| não têm nenhuma palavra em comum, então a semelhança entre elas é

zero, e o corte cai exatamente entre o cabeçalho e os dados. É o pior dos dois mundos: o

corte semântico produz um pedaço menor que o estrutural e ainda destrói a tabela.

Quando o corte semântico não compensa, e vale dizer antes de você implementá-lo:

  • Custa uma chamada de vetorização por documento, no momento da indexação. Se o seu

    ingest já é o gargalo, isto piora.

  • O ponto de corte deixa de ser determinístico. Dois documentos quase iguais podem

    receber cortes diferentes, e a versão indexada não é a versão que você testou. A

    reprodutibilidade do corte estrutural vem de graça; a do semântico depende do modelo.

  • Ele é sensível ao modelo. Trocar o vetorizador muda o ponto de queda — que é o mesmo

    custo de reindexar descrito na

    seção 9 do artigo 03.

  • Ele não resolve tabela, como o código acima mostra.

O que ele faz bem: documento de prosa corrida, sem tabela, sem código e sem lista, onde o

assunto muda no meio de um parágrafo e o separador visível chega tarde. Se a sua base é

majoritariamente esse tipo, vale o custo. Se ela tem tabela, o caminho mais barato é o da

seção 7.


7. A tabela: o caso que nenhum cortador salva sozinho

Aqui está o achado que muda a ordem das decisões.

O corte por estrutura respeita parágrafo e seção. O corte semântico respeita assunto.

Nenhum dos dois respeita cabeçalho de coluna, porque cabeçalho de coluna não é um

conceito que o texto carregue: é uma convenção da ferramenta que renderizou a tabela. No

Markdown, o cabeçalho é a primeira linha do bloco e o separador é a segunda. Qualquer

regra que olhe para parágrafo, linha ou semelhança vai tratar as quatro linhas de dados

como quatro unidades independentes — e nenhuma delas sabe o que é 4,5.

O conserto é linearizar: repetir o cabeçalho em cada linha de dados antes de cortar. A

tabela deixa de ser uma tabela e passa a ser uma lista de pares autossuficientes, e a

questão passa a ser só como não cortar entre o cabeçalho repetido e a sua linha.

def linearize_tables(text: str) -> str:
    """Repete o cabeçalho da tabela Markdown em cada linha de dados.

    Só age em bloco que tem a forma de tabela: linhas começando com `|`
    e a segunda delas sendo só traço, dois-pontos e barra.
    """
    linhas = text.splitlines()
    saida: list[str] = []
    i = 0
    while i < len(linhas):
        if not linhas[i].strip().startswith("|"):
            saida.append(linhas[i])
            i += 1
            continue
        bloco: list[str] = []
        while i < len(linhas) and linhas[i].strip().startswith("|"):
            bloco.append(linhas[i].strip())
            i += 1
        if len(bloco) >= 2 and re.fullmatch(r"\|[\s|:-]+\|", bloco[1]):
            for linha in bloco[2:]:
                saida.extend([bloco[0], linha])   # cabeçalho + uma linha
        else:
            saida.extend(bloco)                   # não é tabela: deixa como está
    return "\n".join(saida)


linearizado = pack_lines(linearize_tables(DOC).splitlines(), 200, atoms=True)
for p in linearizado[2:4]:
    print(f"  ({len(p)}) {p!r}")

O pedaço 2 já é o resultado que a seção 2 queria e não conseguiu: duas linhas de tabela,

cada uma com o cabeçalho que dá sentido ao número.

E aqui está a segunda metade do achado, que é a mais importante: linearizar sozinho não resolve.

Se, depois de linearizar, você voltar a cortar por caractere, o corte de 200 cai entre o

cabeçalho repetido e a linha que ele descreve, e você reconstrói exatamente o problema

inicial. O que precisa mudar junto é a unidade de corte: no mínimo, a linha. É por isso

que pack_lines tem o parâmetro atoms e não corta no meio de ninguém.

A ordem importa: linearizar, e cortar por linha. Fazer um sem o outro não resolve

nada. E note que a segunda metade do conserto é uma linha de código — a mesma linha de

código que faltava na seção 4.


8. A experiência: o ranking quase não mexe, o conteúdo mexe

Agora dá para comparar as estratégias com número, e o resultado é contra-intuitivo.

A experiência usa o documento da seção 2, dez perguntas escritas contra ele, e um

recuperador de palavra — um stub que ranqueia os trechos por sobreposição de palavras

com a pergunta. O stub é fraco de propósito: o objetivo não é medir qualidade de busca,

é medir o quanto o corte muda o ranking quando o recuperador é o mesmo para as

estratégias.

import unicodedata

VALORES_DA_TABELA = ["4,5", "85", "92", "0,08"]

FATOS: dict[str, list[str]] = {
    "qual a frequencia da inspecao visual": ["30 dias"],
    "com que frequencia a medicao de vibracao e feita": ["90 dias"],
    "quem aprova uma inspecao fora do prazo": ["responsavel tecnico"],
    "qual o limite de vibracao": ["4,5", "Limite"],
    "o que fazer quando a vibracao passa do limite": ["Parar o equipamento", "Ação"],
    "qual o limite de temperatura": ["85", "Limite"],
    "o que fazer se a temperatura estiver alta": ["Reduzir a carga", "Ação"],
    "qual o limite de ruido": ["92", "Limite"],
    "qual a tolerancia de desalinhamento": ["0,08", "Limite"],
    "o que acontece com registro incompleto": ["não são aceitos"],
}


def _words(text: str) -> set[str]:
    """Palavras sem acento, para comparar texto do documento com pergunta."""
    plano = unicodedata.normalize("NFD", text.lower())
    limpo = "".join(c for c in plano if unicodedata.category(c) != "Mn")
    return {p for p in re.findall(r"[a-z0-9,]+", limpo) if len(p) > 1}


def rank(trechos: Sequence[Chunk], pergunta: str, k: int) -> list[Chunk]:
    """O `stub` de recuperador: sobreposição de palavras com a pergunta."""
    alvo = _words(pergunta)
    notas = [(len(alvo & _words(t.text)) / len(alvo), t.ordinal, t) for t in trechos]
    notas.sort(key=lambda x: (-x[0], x[1]))
    return [t for _, _, t in notas[:k]]


def has_facts(trechos: Sequence[Chunk], pergunta: str, k: int) -> bool:
    """O trecho do topo contém TODOS os fatos que a pergunta exige?

    Para pergunta de tabela os fatos incluem o cabeçalho: sem a palavra
    `Limite` junto do número, o número não responde à pergunta.
    """
    return any(
        all(f.lower() in t.text.lower() for f in FATOS[pergunta])
        for t in rank(trechos, pergunta, k)
    )


def orphans(trechos: Sequence[Chunk]) -> int:
    """Quantos trechos carregam um valor da tabela sem o cabeçalho dela."""
    return sum(
        1 for t in trechos
        if any(v in t.text for v in VALORES_DA_TABELA) and "Limite" not in t.text
    )


def starts_mid_line(trechos: Sequence[Chunk], doc: Document) -> int:
    """Quantos trechos começam onde nenhuma linha do documento começa."""
    linhas = set(doc.text.splitlines())
    return sum(1 for t in trechos if t.text.splitlines()[0] not in linhas)


def linearized_chunks(doc: Document) -> list[Chunk]:
    """Corte da seção 7: tabela linearizada e unidade de corte = a linha."""
    partes = pack_lines(linearize_tables(doc.text).splitlines(), 200, atoms=True)
    return [
        Chunk(
            chunk_id=f"{doc.doc_id}#{i}", doc_id=doc.doc_id, parent_id=None,
            ordinal=i, text=p, token_count=count_tokens(p),
        )
        for i, p in enumerate(partes)
    ]


ESTRATEGIAS = {
    "fixed_size": lambda d: FixedSizeChunker().split(d),
    "structural": lambda d: split_by_structure(d),
    "structural+": lambda d: split_by_structure(d, repack=True),
    "linearizado": linearized_chunks,
}

for nome, estrategia in ESTRATEGIAS.items():
    ts = estrategia(DOCUMENTO)
    c3 = sum(1 for p in FATOS if has_facts(ts, p, 3))
    c1 = sum(1 for p in FATOS if has_facts(ts, p, 1))
    print(
        f"{nome:<12} {len(ts):>2} trechos  cobertura@3 {c3}/10  "
        f"completo@1 {c1}/10  meio-de-linha {starts_mid_line(ts, DOCUMENTO)}  "
        f"orfãos {orphans(ts)}"
    )

A saída é esta:

fixed_size    5 trechos  cobertura@3 7/10  completo@1 5/10  meio-de-linha 3  orfaos 1
structural   11 trechos  cobertura@3 4/10  completo@1 3/10  meio-de-linha 0  orfaos 4
structural+   6 trechos  cobertura@3 7/10  completo@1 6/10  meio-de-linha 0  orfaos 1
linearizado   5 trechos  cobertura@3 9/10  completo@1 5/10  meio-de-linha 0  orfaos 0

São quatro leituras, e elas contam histórias diferentes. Leia da esquerda para a direita.

1. O ranking quase não se move entre as duas implementações sãs. O corte por caractere

e o corte estrutural reempacotado dão os mesmos 7/10 na cobertura do top-3 — e são

estratégias com números de trechos, token e qualidade de conteúdo completamente

diferentes. Se o seu único critério para avaliar um cortador é "o trecho certo entrou no

top-10", você vai concluir que as duas são iguais. Elas não são: uma tem três trechos

começando no meio de uma linha, a outra não tem nenhum.

2. O ranking desaba quando o corte fragmenta demais. O structural sem reagrupamento

cai para 4/10. Não é o texto que piorou, foi a granulação: onze pedaços, dos quais cinco

são linhas isoladas de tabela, disputam as três primeiras vagas com trechos que têm

conteúdo. Aqui o chunking aparece no ranking — e aparece para o lado errado.

3. O conteúdo não acompanha o ranking. O número de trechos que carregam um valor da

tabela sem o cabeçalho é 1, 4, 1 e 0. Só a linearização zera a conta. E a estratégia que

ganha no ranking (9/10) é a única que zera.

Esse é o eixo inteiro do artigo: chunking quase não aparece no ranking, e aparece no que o trecho contém. A busca devolveu o mesmo trecho bom nas duas primeiras linhas da

tabela; o que muda é se esse trecho, lido sozinho, tem a resposta dentro dele. A

consequência prática é direta: se você avalia corte por coverage@k, você vai parar na

segunda linha da tabela e concluir que estrutural é igual a caractere fixo — e

orfaos() era o número que mostrava a diferença.

E vale registrar o que esta experiência não prova. O documento tem 1,2 mil caracteres,

as dez perguntas foram escritas contra ele e o recuperador é um stub de sobreposição de

palavras. Isso é uma demonstração de mecanismo, não um benchmark: com um vetor de verdade,

o ranking de todas as linhas muda. O que não depende do recuperador é a última

coluna — um trecho que tem 0,08 e não tem a palavra Limite está errado em qualquer

ranking, com qualquer modelo.


9. O prefixo de contexto: o caminho do trecho, colado nele

Até aqui o trecho carrega de onde veio por campo — doc_id, ordinal, parent_id.

Campos servem para você, para citar a fonte e para filtrar. Eles não servem para o modelo,

porque o modelo só vê o texto.

Considere o trecho | Vibração | mm/s | 4,5 | Parar o equipamento | com o cabeçalho

repetido. Ele responde "qual o limite de vibração?". Para a pergunta "e o que fazer se

estourar?", ele responde, sim. Mas para "essa tabela é de qual documento?", ele não tem

como saber.

O conserto é colar no texto do trecho o caminho de seções que leva até ele, antes de

indexar:

def build_path_chunks(doc: Document) -> list[Chunk]:
    """Fatia o documento anotando, em cada trecho, o caminho de títulos.

    O prefixo é texto, não metadado: ele entra no `text` que vai para o
    vetor e para o prompt. É o que torna o trecho legível sozinho.
    """
    pilha: list[str] = []
    titulo = doc.text.splitlines()[0].strip()
    trechos: list[Chunk] = []

    for ordinal, paragrafo in enumerate(
        [p.strip() for p in doc.text.split("\n\n") if p.strip()]
    ):
        linhas = paragrafo.splitlines()
        cabecalho = re.match(r"^(\d+)\.\s+(.*)$", linhas[0])
        if cabecalho and not paragrafo.startswith("|"):
            nivel = int(cabecalho.group(1))
            # A pilha mantém a hierarquia: ao ver "3.", os níveis acima saem.
            pilha = pilha[: nivel - 1] + [f"{nivel}. {cabecalho.group(2)}"]
            corpo = "\n".join(linhas[1:]).strip()
            if not corpo:
                continue      # a seção é só um título: nada a indexar
            texto = corpo
        else:
            texto = paragrafo

        prefixo = " > ".join([titulo, *pilha])
        trechos.append(
            Chunk(
                chunk_id=f"{doc.doc_id}#{ordinal}", doc_id=doc.doc_id, parent_id=None,
                ordinal=ordinal,
                # O prefixo entra no TEXTO, antes do corpo do trecho.
                text=f"{prefixo}\n{texto}",
                token_count=count_tokens(f"{prefixo}\n{texto}"),
                metadata={"path": prefixo},
            )
        )
    return trechos


for t in build_path_chunks(DOCUMENTO)[2:4]:
    print(f"  {t.text[:70]!r}")
    print(f"  path={t.metadata['path']!r}")

O terceiro trecho sai como

MANUAL DE OPERAÇÕES > 1. Escopo > 2. Frequência\nA inspeção visual ocor e o quarto como

MANUAL DE OPERAÇÕES > 1. Escopo > 2. Frequência > 3. Tabela de tolerâ. O mesmo trecho,

com o caminho que o torna legível fora do documento. A mesma ideia, com um prefixo gerado

por LLM em vez de por hierarquia de títulos, é o que a

Contextual Retrieval da Anthropic

propõe: anexar a cada trecho algumas dezenas de palavras de contexto sobre o documento

antes de indexar, para que o trecho ganhe em recuperação o que ganhou em leitura.

O custo é concreto e vale registrar: o prefixo é indexado, então ele conta para o vetor e conta para o orçamento de contexto da consulta. Ele aparece em todos os k

trechos devolvidos, vezes k. Com k igual a 10 e prefixo de 60 caracteres, são 600

caracteres de contexto repetido em cada consulta — e o [Lost in the

Middle](https://arxiv.org/abs/2305.09617) mostra que conteúdo repetido no meio do contexto

não é contexto neutro.


10. A decisão, por tipo de documento

A tabela vem depois de tudo, porque só depois de ver os quatro danos dá para escolher.

Tipo de documentoEstratégia de corteUnidade de corteO que ainda pode quebrar
Prosa corrida, sem tabelaEstrutural reempacotado; semântico por cima se a medição pedirParágrafoParágrafo que muda de assunto sem separador
Manual, norma, contratoEstrutural por título e seção, com repackParágrafoLista numerada longa vira um bloco só
Documento com tabelaLinearizar e cortar por linhaCabeçalho + uma linhaCélula que ocupa várias linhas
Código, log, configuraçãoEstrutural por linha, sobreposição maiorBloco de funçãoFunção longa com comentário no meio
Ficha curta, pergunta frequente, chamadoO documento inteiro é o trechoDocumentoNada — é o caso sem risco
PDF sem camada de textoNenhuma: o corte é irrelevante—A extração do texto, que vem antes do corte

Quatro regras que valem para todas as linhas:

Comece por estrutural com reagrupamento. É o único custo adicional zero, e ele já

elimina a falha mais bruta. Small-to-big por cima dele, se a medição pedir.

Meça antes de trocar. Se a sua base não tem tabela, a linearização é trabalho sem

retorno — ela aumenta o número de caracteres indexados sem mudar nada que a busca use. O

código da seção 7 roda em qualquer base; a decisão é sobre a sua base, e só a medição

dela responde.

Dê um número ao repack. A diferença entre 4/10 e 7/10 na seção 8 veio de uma linha

de código. Esse número vai aparecer em algum lugar do seu código — vale ser explícito

sobre ele.


11. Como medir se o corte prestou

Duas medições, respondendo a perguntas diferentes.

A primeira é a cobertura: o trecho que responde à pergunta apareceu no top-k? É a

métrica que o artigo 01 já definiu, e ela é reexibida aqui

completa porque a série inteira depende dela:

from enum import StrEnum


class ScoreSource(StrEnum):
    """De onde veio o número da busca. Guardar isso é obrigatório."""

    VECTOR = "vector"          # similaridade de significado
    LEXICAL = "bm25"           # busca por palavra exata
    GRAPH = "graph"            # achou por relação, não por texto
    COMMUNITY = "community"    # resumo de um grupo do grafo


class Scored(Contract):
    """Um resultado de recuperação, com a origem do score."""

    ref_id: str                # qual chunk/doc/nó foi escolhido
    score: float               # o número
    source: ScoreSource        # e de que tipo de busca ele veio
    payload: dict[str, Any] = Field(default_factory=dict)


def coverage_at_k(hits: Sequence[Scored], trecho_certo: str) -> float:
    """1.0 se o trecho que respondia à pergunta apareceu entre os k."""
    return 1.0 if any(h.ref_id == trecho_certo for h in hits) else 0.0


def reciprocal_rank(hits: Sequence[Scored], trecho_certo: str) -> float:
    """1 / posição do trecho certo. Ignora o que veio abaixo."""
    for posicao, hit in enumerate(hits, start=1):
        if hit.ref_id == trecho_certo:
            return 1.0 / posicao
    return 0.0

Para aplicá-la ao corte, o trecho_certo deixa de ser um identificador único: com

sobreposição, dois trechos podem conter a mesma frase, e a sobreposição de 40 caracteres da

seção 2 produz exatamente isso. O gabarito passa a ser um conjunto de identificadores

aceitáveis, e a pergunta é se algum deles entrou:

def as_scored(chunk: Chunk) -> Scored:
    """Embrulha o trecho no contrato de resultado, com a origem do score."""
    return Scored(ref_id=chunk.chunk_id, score=1.0, source=ScoreSource.LEXICAL)


def coverage_da_estrategia(estrategia, doc: Document, k: int) -> float:
    """Cobertura@k do corte, aceitando qualquer trecho que traga o fato."""
    ts = estrategia(doc)
    acertos = 0
    for pergunta in FATOS:
        # Gabarito tolerante à sobreposição: vale qualquer trecho que
        # contenha todos os fatos da pergunta.
        gabarito = {t.chunk_id for t in ts if has_facts([t], pergunta, 1)}
        if not gabarito:
            continue
        devolvidos = [as_scored(t) for t in rank(ts, pergunta, k)]
        acertos += max(coverage_at_k(devolvidos, ref) for ref in gabarito)
    return acertos / len(FATOS)


for nome, estrategia in ESTRATEGIAS.items():
    print(f"{nome:<12} cobertura@3 = {coverage_da_estrategia(estrategia, DOCUMENTO, 3):.2f}")

A segunda medição não é numérica, e é a que pegou o dano da seção 8: o trecho responde sozinho? Três perguntas, e a terceira é a que importa:

  1. O número que a pergunta procura está no trecho?
  2. O que dá sentido a esse número também está no trecho?
  3. Alguém que só visse esse trecho saberia responder?

A experiência da seção 8 é a segunda e a terceira em forma de função. orphans() conta

trechos que têm o número e não têm o cabeçalho: é a pergunta 2 virada código. Ela vale

mais que a cobertura para decidir corte, porque mede a qualidade do material entregue em

vez da posição dele no ranking — e a experiência mostrou que as duas coisas andam em

direções opostas.

Se você parou aqui, o seu sistema corta documento respeitando estrutura, guarda origem e

hierarquia, tem uma estratégia declarada por tipo de documento, e tem duas medições: uma

para saber se a busca acha o trecho, outra para saber se o trecho presta.


TL;DR

  • O corte decide se a resposta existe. Se o trecho não está na conversa, não há o que

    o modelo use — e o sintoma aparece como "resposta genérica", não como erro de corte.

  • Cortar por caractere é o erro mais comum. Ele produz trechos que começam no meio da

    linha, e a sobreposição padrão não repete contexto: repete palavra de fronteira.

  • Nenhum cortador salva tabela. Estrutural separa o cabeçalho das linhas; semântico

    separa ainda mais, porque linhas de tabela não têm palavra em comum. O conserto é

    linearizar e cortar por linha — as duas coisas, não uma.

  • "Corte estrutural" não é uma estratégia, é um espaço de implementação. O mesmo

    algoritmo com e sem reagrupamento deu 4/10 e 7/10 de cobertura, com uma linha de código

    de diferença.

  • Chunking quase não aparece no ranking, e muito no conteúdo. Duas estratégias

    diferentes empataram em 7/10 de cobertura e ao mesmo tempo divergiram em três trechos

    partidos ao meio e um trecho órfão. A única que zerou os órfãos foi a linearizada.

  • Meça com duas perguntas: o trecho certo entrou no top-k, e o trecho responde sozinho.

    A primeira quase não muda com o corte; a segunda muda tudo.


Referências

  • Guia de vetores da OpenAI — o que é token e por que ele não é caractere, que é a base do campo token_count
  • Divisão recursiva por caractere — LangChain — a implementação de referência do corte por separador, e o repack que ela não faz
  • Contextual Retrieval — Anthropic — prefixar o trecho com contexto antes de indexar, a versão com LLM do caminho de seções da seção 9
  • Small-to-Big: Two-Phase Embedding and Retrieval — indexar pequeno e entregar grande, que é a estratégia da seção 5
  • Lost in the Middle: How Language Models Use Long Contexts — por que conteúdo repetido no meio do contexto não é contexto neutro