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
Chunke 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 oassunto 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 doseguinte 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
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, comresultados 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
ingestjá é 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
-
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 documento | Estratégia de corte | Unidade de corte | O que ainda pode quebrar |
|---|---|---|---|
| Prosa corrida, sem tabela | Estrutural reempacotado; semântico por cima se a medição pedir | Parágrafo | Parágrafo que muda de assunto sem separador |
| Manual, norma, contrato | Estrutural por título e seção, com repack | Parágrafo | Lista numerada longa vira um bloco só |
| Documento com tabela | Linearizar e cortar por linha | Cabeçalho + uma linha | Célula que ocupa várias linhas |
| Código, log, configuração | Estrutural por linha, sobreposição maior | Bloco de função | Função longa com comentário no meio |
| Ficha curta, pergunta frequente, chamado | O documento inteiro é o trecho | Documento | Nada — é o caso sem risco |
| PDF sem camada de texto | Nenhuma: 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:
- O número que a pergunta procura está no trecho?
- O que dá sentido a esse número também está no trecho?
- 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
repackque 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