Lede. Você recuperou 40 nós de grafo e 12 trechos. Nenhum modelo aceita tudo isso. Alguém escolhe o que fica, e essa escolha é a peça que decide se a resposta é certa. Neste artigo você monta o seletor que faz essa escolha com critério: o custo real de cada tipo de bloco, a ordem em que cada um é sacrificado, o algoritmo que preserva o caminho até a resposta, e a métrica que avisa quando o corte está comendo o conteúdo. Todo o código roda offline, sem chave de API. Onde você está na linha. Este é o passo 9 de 13 — montagem do contexto. Antes dele: 01 · Anatomia do pipeline e 07 · Grafo e comunidades, com o 08 · GraphRAG como exemplo de entrada. Depois dele: 10 · Agentes com LangGraph e 12 · Guia de decisão. A seção 2 é autossuficiente: se você chegou aqui querendo a resposta, pule para ela.
1. O que estoura — e por que nada avisa
Uma comunidade com alguns milhares de nós não cabe na janela de contexto de nenhum
modelo. Isso todo mundo descobre. O que quase ninguém descobre é o que acontece
depois.
O contexto estoura, alguém corta, e o modelo não avisa que cortou. Não existe
campo "eu não vi o resto" na resposta. O que existe é um texto que responde com a
mesma confiança sobre o subgrafo que sobrou — e o subgrafo que sobrou foi escolhido
por uma lista ordenada por score, ou seja, por um critério que não sabe nada sobre a
pergunta. O modelo não mente: ele responde bem sobre o material que recebeu. O
problema é o material.
Por isso o orçamento não é um detalhe de engenharia no fim do pipeline. Ele é uma
decisão de qual evidência existe, tomada antes de qualquer geração, e ela é
invisível na resposta se você não tiver registrado o que foi recusado.
Três perguntas resolvem a maior parte dos casos, e todas têm a mesma resposta
estrutural: o que sai primeiro?
-
Se eu cortar metade dos nós, o que some da resposta: o que respondia à pergunta, ou
só o contexto?
-
Se eu cortar a metade menos valuable, o modelo ainda entende a pergunta?
-
Se eu cortar o bloco errado, eu percebo?
Este artigo é a resposta para as três: um orçamento (o limite), uma ordem de degradação (o que sai primeiro), e uma taxa de truncamento (a métrica que
responde à terceira).
2. O que cada tipo de bloco custa de verdade
O orçamento do artigo 01 conta caracteres, e cada bloco paga len(text). Isso
serve para o trecho de texto, que é tudo conteúdo. Para o resto do contexto, len()
não mede a coisa que importa: um nó tem um rótulo, mas ocupa no prompt um cabeçalho,
uma ligação com a semente e — se você guardar o caminho — um nome por salto.
Vamos reconstruir o Budget do artigo 01 exatamente como ele está lá, porque o
contrato das fichas da série não muda de artigo para artigo. A implementação vem
completa, e o que vem depois é construído em cima dela.
from __future__ import annotations from collections.abc import Sequence from enum import StrEnum from typing import Any from pydantic import BaseModel, ConfigDict, Field class Contract(BaseModel): """Base das fichas da série: campo que não existe é erro, ficha é imutável.""" model_config = ConfigDict(extra="forbid", frozen=True) class ScoreSource(StrEnum): """De onde veio o número da busca. Guardar a origem é obrigatório.""" VECTOR = "vector" LEXICAL = "bm25" GRAPH = "graph" COMMUNITY = "community" class Scored(Contract): """Um resultado de recuperação, com a origem do score. `payload` carrega o que a montagem precisa saber e o que ninguém pode perder: o texto que vai entrar, a origem, e de qual nó o bloco veio. """ ref_id: str score: float source: ScoreSource payload: dict[str, Any] = Field(default_factory=dict) class BudgetExhausted(RuntimeError): """O bloco não cabe. A decisão de cortar é de quem chamou, não daqui.""" class Budget: """Conta quantos caracteres o contexto pode gastar, por tipo de bloco. Por que caracteres e não tokens: dá para medir sem carregar o tokenizador para dentro do laço. É aproximação — e por isso existe o `floor`, uma reserva que nunca é gasta, para absorver a diferença entre caractere e token. """ def __init__(self, total_chars: int, floor_ratio: float = 0.15) -> None: self._total = total_chars self._floor = int(total_chars * floor_ratio) self._spent = 0 self._by_kind: dict[str, int] = {} @property def remaining(self) -> int: return self._total - self._spent def spend(self, kind: str, text: str) -> int: """Consome o texto. Levanta erro se não couber.""" cost = len(text) if cost > self.remaining - self._floor: raise BudgetExhausted( f"{kind} custa {cost}, resta {self.remaining} (piso {self._floor})" ) self._spent += cost self._by_kind[kind] = self._by_kind.get(kind, 0) + cost return cost def report(self) -> dict[str, Any]: """Para onde foi o orçamento. É o número que você olha todo dia.""" return { "total": self._total, "spent": self._spent, "remaining": self.remaining, "by_kind": dict(sorted(self._by_kind.items(), key=lambda kv: -kv[1])), }
Sobre a aproximação: caracteres não são tokens. O que o modelo realmente lê é a saída
do tokenizador dele, e a proporção muda entre modelos, entre idiomas e ao lado de
números e pontuação. Por isso o floor existe — e por isso o número de verdade, se você
precisar dele, é a contagem do tokenizador do modelo que você usa (o
tiktoken é o da OpenAI, e cada provedor tem o
seu). Caractere é o contador barato e bom o bastante para decidir o que entra; token
é o contador exato, e ele só entra na hora de medir o prompt final.
Agora o custo de cada tipo de bloco, incluindo a estrutura em volta do conteúdo.
def custo_no(rotulo: str, caminho: Sequence[str]) -> str: """O texto que um nó ocupa de fato, e não só o rótulo. O caminho é o culpado: tem um componente por salto, e uma travessia de 4 saltos escreve quatro nomes de entidade no contexto — cada um deles com o tamanho do nome real, não com o tamanho de uma letra. Ele é também a parte que o modelo quase nunca lê. """ return f"- Assunto: {rotulo} (via {' > '.join(caminho)})\n" def custo_aresta(origem: str, destino: str) -> str: """A aresta custa uma linha, e é a mais barata de todas.""" return f" {origem} -- {destino}\n" # Um caminho real, com nomes de entidade de verdade, para o custo não ser # subestimado por um caminho de letras. CAMINHO = ["Auditoria", "Acesso a dados", "Autenticação", "Rede"] INSTRUCAO = "Responda usando apenas o material abaixo e cite a origem.\n" for profundidade in (1, 2, 3, 4): bloco = custo_no("Retenção de registros", CAMINHO[:profundidade]) cortado = custo_no("Retenção de registros", CAMINHO[max(profundidade - 2, 0):profundidade]) print(f"{profundidade} saltos: bloco={len(bloco):3d} " f"com caminho cortado={len(cortado):3d} rótulo sozinho=21") print("aresta:", len(custo_aresta("Auditoria", "Acesso a dados")), "| instrução:", len(INSTRUCAO))
O rótulo "Retenção de registros" tem 21 caracteres. Na profundidade 1, o bloco inteiro
custa 49: o conteúdo é menos da metade. Na profundidade 4, custa 88 — quatro vezes o rótulo, e três quartos do bloco é caminho. Cortar o caminho depois de dois saltos
devolve o bloco para 59 caracteres mesmo na profundidade 4, e a aresta custa 30 (com
nomes de entidade reais, não com nomes de uma letra).
Agora a tabela, que só faz sentido depois dos números:
| Tipo de bloco | O que ele é | Custo real | O que acontece se você corta |
|---|---|---|---|
| instrução | a tarefa, o formato da resposta, a proibição de inventar | quase zero | a resposta sai sem forma, ou afirma o que não viu |
| trecho | o texto do documento | len(texto), o maior de todos | a evidência some — é o corte mais caro do sistema |
| nó | uma entidade, com a ligação até a semente | rótulo + casca + caminho | a ponte some; a aresta vira órfã |
| aresta | a relação que trouxe o nó | uma linha | o par nó-aresta inteiro sai junto |
| citação | de onde veio o que ficou | precisa acompanhar o bloco que ficou | um texto sem fonte é opinião |
3. Ordem de degradação: o que sai primeiro
"Quando estourar, corta o de menor score" é a regra que quase todo mundo escreve. Ela
está errada, porque score mede relevância para a pergunta e o orçamento precisa
decidir sobre estrutura. Um nó com score baixo no fim de uma árvore pode ser a
ponte que dá sentido a três nós de score alto.
A ordem de degradação é uma lista ranqueada, e cada posição existe por um motivo. Ela é
o inverso da ordem de entrada no contexto: o que entra primeiro é o último a sair.
# Do PRIMEIRO que sai ao ÚLTIMO que pode sair. Cada linha é uma chave do orçamento. ORDEM_DE_DEGRADACAO: tuple[str, ...] = ( "aresta+no", # 1. o par nó-aresta: a unidade que sai primeiro "trecho", # 2. trecho de score baixo que repete o que o nó já disse "citacao", # 3. só no fim: citação sem texto é mentira ) # O que nunca sai, em nenhuma hipótese, mesmo com o orçamento no fim. NUNCA_SACRIFICADO: frozenset[str] = frozenset({"instrucao"})
A primeira posição é a mais contraintuitiva, e ela vem de uma restrição, não de uma
opinião: nó e aresta não podem sair separados. A aresta sozinha não liga nada (o nó
que ela liga não está no contexto) e o nó sozinho é um fato sem pai, que o modelo
lê como afirmação solta. Então o par é a unidade, e é por isso que a chave do
orçamento é "aresta+no": um spend só, e os dois entram ou nenhum entra. Nenhum
refund é necessário, e nenhum órfão é possível.
A segunda posição é uma repetição: um trecho cujo conteúdo já está no nó, com score
menor que o do nó, é o primeiro a cair. A terceira é o fundo do poço: citação sem
texto não é evidência, é decoração.
A instrução está fora da lista, e não por ser importante — por ser barata e
estrutural. Ela custa 58 caracteres e sem ela o modelo não sabe o que fazer com o
material. Se sobrar aperto, o aperto sai do meio.
⚠️ "Cortei a citação para caber" é a falha que ninguém percebe
Um bloco de texto entra sem o seudoc_ide sem o título do documento. O modelolê a afirmação, e o sistema não consegue dizer de onde ela veio. Não dá erro, não
dá log, não dá exceção: dá uma resposta plausível e não auditável. Por isso a
citação entra no orçamento junto com o bloco que ela sustenta, e o teste
final é o da seção 7 — toda citação da resposta aponta para um documento que
existe.
4. A tentativa que todo mundo escreve (e o que ela quebra)
Vamos começar errado, como no artigo 01. Ordenar por score e cortar no limite é dez
linhas, roda, e produz contexto plausível. O defeito dele só aparece quando alguém
pergunta se a evidência ficou ligada a alguma coisa.
def montar_por_score( hits: Sequence[Scored], rotulos: dict[str, str], budget: Budget ) -> tuple[list[Scored], list[str]]: """ERRADO: os `n` melhores, por score, e acabou. O resultado parece certo — a lista é ordenada, os piores foram embora — e está errado de um jeito específico: os nós que sobraram não estão ligados uns aos outros. O modelo recebe seis fatos que não formam caminho nenhum, e a conclusão que ele tira é dele, não da sua base. """ budget.spend("instrucao", INSTRUCAO) # mesma instrução, para comparar escolhidos: list[Scored] = [] recusados: list[str] = [] for hit in sorted(hits, key=lambda h: (-h.score, h.ref_id)): try: budget.spend("no", custo_no(rotulos[hit.ref_id], [hit.ref_id])) except BudgetExhausted: recusados.append(hit.ref_id) continue escolhidos.append(hit) return escolhidos, recusados
Para medir o defeito, uma métrica que não é sobre score: a coesão do subgrafo
escolhido, isto é, quantos componentes conexos ele tem. Um é o valor saudável.
def n_componentes(nos: set[str], arestas: Sequence[tuple[str, str]]) -> int: """Quantos grupos isolados o subgrafo escolhido tem. Um é o esperado: o que entrou no contexto se sustenta junto. Mais que um significa que você pagou por fatos que não se conectam a nada, e a resposta vai parecer análise onde só há enumeração. """ vizinhos: dict[str, set[str]] = {no: set() for no in nos} for origem, destino in arestas: if origem in nos and destino in nos: vizinhos[origem].add(destino) vizinhos[destino].add(origem) vistos: set[str] = set() componentes = 0 for no in nos: if no in vistos: continue componentes += 1 pilha = [no] while pilha: atual = pilha.pop() if atual in vistos: continue vistos.add(atual) pilha.extend(vizinhos[atual] - vistos) return componentes
O acervo do exemplo, para os dois seletores rodarem: um grafo pequeno, com uma semente,
uma ponte e três pontas.
ROTULOS = { "Auditoria": "Cada acesso a dado fica registrado.", "Acesso a dados": "Cada função enxerga um conjunto de dados.", "Retenção de registros": "A retenção é auditada por um período fixo.", "Consentimento": "O consentimento é registrado antes do tratamento.", "Rede": "Falhas de rede são registradas.", "Autenticação": "A autenticação em dois fatores é a linha de base.", } ADJACENCIA: dict[str, list[tuple[str, float]]] = { "Auditoria": [("Acesso a dados", 2.0), ("Retenção de registros", 1.0)], "Acesso a dados": [("Autenticação", 1.0)], "Retenção de registros": [("Consentimento", 1.0)], "Consentimento": [], "Rede": [("Autenticação", 1.0)], "Autenticação": [], } # Score de chegada: o quanto o grafo confia que este nó é a resposta. SCORE = { "Auditoria": 0.90, "Acesso a dados": 0.88, "Retenção de registros": 0.60, "Consentimento": 0.30, "Autenticação": 0.20, "Rede": 0.10, }
5. O seletor: BFS priorizado por score, com teto de grau
O algoritmo que resolve os dois problemas de uma vez é uma busca em largura com fila de prioridade: em vez de escolher os n melhores e depois ligar, ele escolhe um
por vez e só considera os vizinhos do que já está dentro.
Três propriedades caem de graça, e as três são o ponto:
-
Coesão é estrutural. Cada nó novo entra junto com a aresta que o trouxe, e essa
aresta tem no máximo um pai. O resultado é uma árvore geradora (spanning tree):
o menor conjunto de arestas que mantém todos os nós escolhidos conectados. A coesão
não precisa ser verificada — ela é consequência do algoritmo.
-
O corte é local ao que já entrou. Se o orçamento acabar, o que não entra é
vizinhança do que já entrou, nunca um nó órfão no meio da lista.
-
O teto de grau limita o custo. Cada nó expandido só oferece os seus
teto_graumelhores vizinhos, então o trabalho é limitado pelos nós selecionados, enão pela tabela de arestas inteira.
E o orçamento é gasto em duas passagens, que é a ordem de degradação virando
código: primeiro o que não pode faltar, depois o que pode ser cortado.
import heapq from dataclasses import dataclass, field from typing import Any @dataclass(frozen=True) class Montagem: """O que saiu do orçamento, e o que ficou de fora. `recusados` existe para virar métrica. Uma montagem que não sabe o que recusou é uma montagem que não pode ser diagnosticada. """ blocos: tuple[Scored, ...] arestas: tuple[tuple[str, str], ...] recusados: tuple[str, ...] orcamento: dict[str, Any] = field(default_factory=dict) @property def nos(self) -> set[str]: """Os nós que entraram, sem a instrução (que não é nó).""" return {b.ref_id for b in self.blocos} - NUNCA_SACRIFICADO @property def taxa_de_truncamento(self) -> float: """Fração do que o seletor queria botar no contexto e não coube. 0.0 é o alvo. Acima de zero, o sistema está respondendo sobre um subgrafo menor do que o que a recuperação achou — e isso é uma decisão, não um detalhe. A seção 7 discute o que fazer com o número. """ total = len(self.blocos) + len(self.recusados) return len(self.recusados) / total if total else 0.0 def selecionar( semente: str, adjacencia: dict[str, list[tuple[str, float]]], score: dict[str, float], rotulos: dict[str, str], budget: Budget, *, teto_grau: int = 3, ) -> Montagem: """Escolhe o que entra no contexto, gastando o orçamento com critério. A primeira passagem paga o que não pode faltar: a instrução e a semente, com o texto que a sustenta. A segunda expande a fronteira em ordem de score, nó e aresta juntos, e para quando o orçamento acabar. A fila de prioridade vem do [módulo `heapq`](https://docs.python.org/3/library/heapq.html) da biblioteca padrão. O que está na fila não é "o próximo nó": é "o próximo par nó-aresta", e por isso o score que ordena é o peso da aresta por onde o nó foi alcançado. """ blocos: list[Scored] = [] arestas: list[tuple[str, str]] = [] recusados: list[str] = [] def gastar(kind: str, texto: str, ref_id: str, score_do_bloco: float, payload: dict[str, Any]) -> bool: """Tenta gastar. Devolve False e registra o recusado quando não cabe.""" try: budget.spend(kind, texto) except BudgetExhausted: recusados.append(ref_id) return False blocos.append(Scored(ref_id=ref_id, score=score_do_bloco, source=ScoreSource.GRAPH, payload=payload)) return True # Passagem 1 — o essencial, na ordem da degradação invertida. if not gastar("instrucao", INSTRUCAO, "instrucao", 1.0, {"tipo": "instrucao"}): return Montagem((), (), tuple(recusados), budget.report()) if not gastar("no", custo_no(rotulos[semente], [semente]), semente, score[semente], {"tipo": "semente", "texto": rotulos[semente]}): # Sem a semente não há grafo: devolve vazio, e o chamador trata como # "não tenho o que responder", em vez de tentar um nó de baixo score. return Montagem((), (), tuple(recusados), budget.report()) # Passagem 2 — a fronteira, em ordem de score. A raiz já está dentro, mas # ainda precisa ser expandida: é dela que sai o primeiro salto. fronteira: list[tuple[float, str]] = [(-score[semente], semente)] heapq.heapify(fronteira) dentro: set[str] = {semente} # já pago, na passagem 1 expandidos: set[str] = set() # já ofereceu seus vizinhos while fronteira: _, no = heapq.heappop(fronteira) if no in expandidos: continue expandidos.add(no) # O teto de grau entra aqui: só os N melhores vizinhos são candidatos. # N é o que segura o custo da travessia. for destino, peso in sorted(adjacencia.get(no, []), key=lambda par: (-par[1], par[0]))[:teto_grau]: if destino in dentro: continue # Nó e aresta num `spend` só: é o que garante que nenhum nó entra # sem o pai que o trouxe. Nenhum órfão, nenhum `refund`. if not gastar("aresta+no", custo_aresta(no, destino) + custo_no(rotulos[destino], [no, destino]), destino, peso, {"tipo": "no", "texto": rotulos[destino], "via": no}): # A primeira recusa encerra a expansão deste nó, e é assim que # a ordem de degradação é implementada: o par nó-aresta é a # unidade, e ela é a primeira a ser sacrificada. break arestas.append((no, destino)) dentro.add(destino) heapq.heappush(fronteira, (-peso, destino)) return Montagem(tuple(blocos), tuple(arestas), tuple(recusados), budget.report())
Rode com três orçamentos, e repare em duas coisas: o número de arestas nunca é zero
enquanto houver dois nós, e a truncagem só aparece quando o orçamento aperta de verdade.
for total in (300, 600, 1200): mont = selecionar("Auditoria", ADJACENCIA, SCORE, ROTULOS, Budget(total_chars=total)) print(f"orçamento {total:5d} -> blocos={len(mont.blocos)} arestas={len(mont.arestas)} " f"truncamento={mont.taxa_de_truncamento:.2f} " f"componentes={n_componentes(mont.nos, mont.arestas)}") print(" ", mont.orcamento["by_kind"])
O by_kind na segunda linha é a informação que interessa: para onde foi o orçamento,
por tipo de bloco. Ele não é detalhe de debug — é o painel que responde "por que a
resposta saiu curta?" sem precisar de trace.
E o critério por score, com o mesmo grafo e o mesmo orçamento, para comparar:
hits = [Scored(ref_id=no, score=SCORE[no], source=ScoreSource.GRAPH, payload={"tipo": "no", "texto": ROTULOS[no]}) for no in ROTULOS] escolhidos, recusados = montar_por_score(hits, ROTULOS, Budget(total_chars=300)) nos_do_score = {h.ref_id for h in escolhidos} print("por score:", sorted(nos_do_score), "recusados:", sorted(recusados), "componentes:", n_componentes(nos_do_score, []))
O resultado mereceu ser lido com atenção, porque o critério por score parece
"melhor": ele paga três nós contra os dois do seletor por árvore. Mas ele paga
zero arestas, e o resultado são três componentes conexos contra um. O terceiro
nó dele (Rede) entrou no contexto porque estava na lista — e ele não se liga, por
caminho nenhum, ao que já estava lá. O seletor por árvore pagou um nó a menos e
pagou também a ligação que faz os dois que entraram valer alguma coisa. É a diferença
entre uma lista de fatos e um raciocínio, e ela não aparece em nenhum número de
relevância: só na contagem de componentes.
6. O subgrafo mínimo, e o que ele custa
A árvore que o seletor constrói é a árvore geradora mínima em número de arestas do
subgrafo alcançado: n - 1 arestas para n nós, e nenhum caminho entre dois nós
selecionados se quebra. Para uma árvore geradora,
qualquer escolha de n - 1 arestas que mantém tudo conectado serve — e a BFS com
prioridade entrega uma delas, sem nunca precisar comparar pares de arestas.
Isso resolve o problema do meio do contexto, e aqui está o porquê. A evidência de
Lost in the Middle é que, quando o modelo lê um
contexto longo, o conteúdo do meio é o que ele menos usa — e o conteúdo que sobrevive a
um corte cego tende a ser justamente o que não responde à pergunta. Ligar cada nó ao
pai que o trouxe é tratar esse sintoma: a parte que explica por que aquele fato entrou
no contexto fica junto do fato, e não no meio de um bloco sem dono.
O preço é honesto e está medido na seção 2: o caminho é o que mais custa caracteres
por bloco, e cresce com a profundidade. Três saídas, em ordem de Rentabilidade — de
mais barata para mais cara:
-
Corte o caminho depois de dois saltos (
... > Autenticação > Rede). O modeloquase nunca usa o caminho inteiro, e o contexto encolhe na mesma proporção da
profundidade. É a mudança de uma linha que a seção 2 mediu.
-
Troque o caminho pela aresta da árvore. Se o bloco já está ligado aos vizinhos
pelas arestas, "de onde veio" é redundante com "com quem está ligado" — e a aresta
custa 30 caracteres, enquanto o caminho de dois saltos custa 45 a mais que o rótulo
sozinho.
-
Não gaste caminho em nó que já tem vizinho de score maior no contexto. A
ordenação por score já entrega isso na prática: o nó de score alto entra primeiro, e
o caminho dele é o que a fronteira compartilha.
O erro oposto também existe e é mais difícil de ver: manter toda aresta do subgrafo
para "não perder informação". Isso é o oposto de árvore geradora, custa quase o dobro e
não acrescenta caminho — toda aresta que não é de árvore tem um caminho alternativo que
já está escrito nas árvores. Guarde a segunda aresta mais forte de cada nó se a sua
base tem pergunta que depende de relação forte específica; fora disso, é gasto sem
retorno.
7. Taxa de truncamento: a métrica de operação
Toda montagem produz um número que ninguém olha: quanto do que a recuperação encontrou
não coube. Ele é a métrica que fecha o circuito, porque é o único que liga "a resposta
está ruim" a uma causa mecânica.
Montagem.taxa_de_truncamento já está no código da seção 5. O que falta é saber o que
fazer com ele, e isso depende do valor:
-
Taxa alta e resposta boa → o orçamento é pequeno demais para a base, ou a
recuperação está devolvendo lixo que você não precisaria. Aumente o orçamento ou
melhore a recuperação; não mexa no seletor.
-
Taxa alta e resposta ruim → o orçamento é o gargalo. Este é o único caso em que
aumentar a janela resolve, e é o único caso em que vale o custo de um modelo maior.
-
Taxa zero e resposta ruim → o problema não é o orçamento. É a recuperação, o
chunking ou o prompt. Mudar o seletor aqui é trabalho jogado fora.
-
Taxa zero e nenhum nó de score baixo entrando → você não tem problema de
truncamento, tem um grafo em que a travessia morre num canto. Olhe o
n_componentese oteto_grau.
⚠️ O orçamento protege o prompt, e não protege o log
O bloco que o seletor recusou já foi lido do banco para caber na decisão.Ler é o que importa para permissão: se o filtro de escopo não estiver dentro da
consulta, o conteúdo de outro tenant entrou em memória e vai para o log de
observabilidade, mesmo tendo sido cortado antes de chegar ao modelo — é o
payload filter aplicado
antes da leitura que resolve, não um filtro depois. A resposta fica certa — o que
vaza é a leitura. É o mesmo ponto do
artigo 01, com uma camada a mais: o orçamento
introduz uma leitura que o prompt final não mostra.
O outro lado da moeda, e é o que quase ninguém registra: o que entrou. Salve os
ref_id de cada bloco, junto com budget.report(). Sem isso, a única forma de descobrir
por que uma resposta saiu curta é refazer a pergunta e reexecutar tudo com o orçamento
maior — e o resultado desse teste não prova nada, porque a montagem é determinística mas
a base pode ter mudado. A taxa de truncamento é um número por requisição; ela só vira
alerta quando você a agrega e a compara com as respostas ruins do seu conjunto de 50
perguntas (o artigo 11).
8. Onde a montagem quebra
| Peça | Como ela quebra | O que o usuário vê | Como descobrir sem depurar |
|---|---|---|---|
| orçamento | caracteres contados como se fossem tokens | o provedor corta sozinho, às vezes no meio de uma citação | budget.report() por requisição |
| ordem de degradação | aresta cortada antes do nó | o modelo afirma uma relação que não estava no contexto | mont.arestas com menos pares que nós |
| árvore | nó sem o pai que o trouxe | fatos corretos, conclusão inventada | n_componentes maior que 1 |
| caminho | caminho de 4 saltos dentro do nó | contexto curto e resposta rasa | by_kind["aresta+no"] crescendo com a profundidade |
| teto de grau | vizinho importante fora dos N | a ponte sumiu e ninguém sabe por quê | compare teto_grau=3 com teto_grau=99 |
| truncamento | não registrado | resposta ruim sem causa aparente | taxa_de_truncamento por requisição |
A linha do orçamento é a mais traiçoeira, porque o sintoma aparece como "o modelo
respondeu mas esqueceu a segunda metade" e a causa está no contador. A linha da árvore
é a mais cara de diagnosticar, porque a resposta está gramaticalmente correta e
factualmente vazia: seis entidades que não formam caminho nenhum produzem uma resposta
que parece análise e não é.
9. Medir a montagem, e o que ela custa
Falta a régua. Uma montagem se mede em três números, e nenhum deles é "quantos blocos
entraram":
def relatorio_de_montagem(montagens: Sequence[Montagem]) -> dict[str, Any]: """Números que respondem: quanto do contexto foi usado, quanto foi cortado, e se o que ficou faz sentido junto. `componentes_maximas` é o número que pega o erro da árvore: uma montagem com 3 ou mais componentes conexas pagou por fatos que não se sustentam. """ if not montagens: return {"montagens": 0} usos = [m.orcamento["spent"] / m.orcamento["total"] for m in montagens] truncamentos = [m.taxa_de_truncamento for m in montagens] coesoes = [n_componentes(m.nos, m.arestas) for m in montagens] por_tipo: dict[str, int] = {} for m in montagens: for kind, custo in m.orcamento["by_kind"].items(): por_tipo[kind] = por_tipo.get(kind, 0) + custo return { "montagens": len(montagens), "uso_medio_do_orcamento": round(sum(usos) / len(usos), 3), "truncamento_medio": round(sum(truncamentos) / len(truncamentos), 3), "componentes_maximas": max(coesoes), "custo_por_tipo": dict(sorted(por_tipo.items(), key=lambda kv: -kv[1])), }
Rode com um orçamento que aperta de verdade, e depois leia os três números:
montagens = [ selecionar("Auditoria", ADJACENCIA, SCORE, ROTULOS, Budget(total_chars=total)) for total in (300, 600, 1200, 600, 300) ] print(relatorio_de_montagem(montagens))
O uso_medio_do_orcamento diz se o orçamento está dimensionado certo: uso baixo e
constante é orçamento sobrando (e você pagando por janela que não usa), uso colado em
1.0 é orçamento no limite — truncamento alto, e corte começando pelo bloco errado. O
custo_por_tipo diz onde mexer: se "aresta+no" domina, o caminho está comprido demais;
se "trecho" domina, o que está grande é o chunk, e o assunto volta a ser o
E, para ser honesto sobre o alcance destes números: eles valem para o grafo de exemplo
com seis nós. Ele existe para exercitar o encadeamento. A taxa de truncamento do seu
sistema só sai depois de rodar as suas 50 perguntas com o registro por requisição, e é
o artigo 11 que monta esse registro.
💡 Registre a montagem como parte da resposta
Grave, junto com a resposta, osref_idque entraram,budget.report()e a taxade truncamento. Quando alguém reclamar da resposta, você não precisa reexecutar: você
tem a lista exata do que o modelo viu. E quando trocar o seletor, você tem o número
de antes e o de depois na mesma tabela. Esse é o caminho mais curto entre "a
resposta piorou" e "a montagem mudou".
TL;DR
-
O orçamento é uma decisão de evidência, não um detalhe de prompt. O que é cortado
é escolhido antes da geração, e o modelo não avisa o que não viu.
-
O custo de um bloco não é o conteúdo dele. O caminho até a semente, dentro do nó,
é o que enche o orçamento: com nomes reais, um nó a 4 saltos custa quatro vezes o
próprio rótulo, e é a parte que o modelo quase nunca lê.
-
Nó e aresta saem juntos, e isso vem de uma restrição, não de uma preferência: nó
sem aresta é órfão, aresta sem nó não liga nada. Por isso a chave do orçamento é o
par, e nenhum
refundé necessário. -
BFS com fila de prioridade resolve os dois problemas juntos: a coesão vem da
estrutura (é uma árvore geradora) e o corte é local ao que já entrou.
-
A taxa de truncamento é a métrica de operação, e o que importa é o par
(truncamento, qualidade da resposta): taxa zero com resposta ruim é problema de
recuperação, não de orçamento.
-
O que o orçamento recusa já foi lido. O filtro de permissão vai dentro da consulta,
porque o corte do prompt não desfaz a leitura que o corte eliminou.
Referências
- Lost in the Middle: How Language Models Use Long Contexts — a evidência de que o meio do contexto é a pior posição, que motiva ligar cada nó ao pai que o trouxe
- tiktoken — o tokenizador real, e por isso o
floordo orçamento é reserva e não detalhe - heapq — Python — a fila de prioridade da biblioteca padrão que faz o BFS priorizado por score
- Spanning tree — Wikipedia — a estrutura mínima que mantém o subgrafo escolhido conectado, e o que ela deixa de fora
- Payload filtering — Qdrant — o filtro avaliado durante a busca, que precisa vir antes da leitura e não depois do corte