FMFelipe MiillerNotes on software & systems
HomeBlogAbout
GitHub

Keep building.

Felipe Miiller · © 2026

MailGitHubGitHubLinkedinGitHub
View source on GitHub
Back to blog

Orçamento de contexto: o que entra quando tudo não cabe

04/10/2026
19 min de leitura
5631 palavras
RAGPythonPerformance
  • 1. O que estoura — e por que nada avisa
  • 2. O que cada tipo de bloco custa de verdade
  • Um caminho real, com nomes de entidade de verdade, para o custo não ser
  • subestimado por um caminho de letras.
  • 3. Ordem de degradação: o que sai primeiro
  • Do PRIMEIRO que sai ao ÚLTIMO que pode sair. Cada linha é uma chave do orçamento.
  • O que nunca sai, em nenhuma hipótese, mesmo com o orçamento no fim.
  • 4. A tentativa que todo mundo escreve (e o que ela quebra)
  • Score de chegada: o quanto o grafo confia que este nó é a resposta.
  • 5. O seletor: BFS priorizado por score, com teto de grau
  • 6. O subgrafo mínimo, e o que ele custa
  • 7. Taxa de truncamento: a métrica de operação
  • 8. Onde a montagem quebra
  • 9. Medir a montagem, e o que ela custa
  • TL;DR
  • Referências

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?

  1. Se eu cortar metade dos nós, o que some da resposta: o que respondia à pergunta, ou

    só o contexto?

  2. Se eu cortar a metade menos valuable, o modelo ainda entende a pergunta?

  3. 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 blocoO que ele éCusto realO que acontece se você corta
instruçãoa tarefa, o formato da resposta, a proibição de inventarquase zeroa resposta sai sem forma, ou afirma o que não viu
trechoo texto do documentolen(texto), o maior de todosa evidência some — é o corte mais caro do sistema
nóuma entidade, com a ligação até a sementerótulo + casca + caminhoa ponte some; a aresta vira órfã
arestaa relação que trouxe o nóuma linhao par nó-aresta inteiro sai junto
citaçãode onde veio o que ficouprecisa acompanhar o bloco que ficouum 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 seu doc_id e sem o título do documento. O modelo

lê 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:

  1. 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.

  2. 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.

  3. O teto de grau limita o custo. Cada nó expandido só oferece os seus

    teto_grau melhores vizinhos, então o trabalho é limitado pelos nós selecionados, e

    nã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:

  1. Corte o caminho depois de dois saltos (... > Autenticação > Rede). O modelo

    quase 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.

  2. 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.

  3. 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_componentes e o teto_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çaComo ela quebraO que o usuário vêComo descobrir sem depurar
orçamentocaracteres contados como se fossem tokenso provedor corta sozinho, às vezes no meio de uma citaçãobudget.report() por requisição
ordem de degradaçãoaresta cortada antes do nóo modelo afirma uma relação que não estava no contextomont.arestas com menos pares que nós
árvorenó sem o pai que o trouxefatos corretos, conclusão inventadan_componentes maior que 1
caminhocaminho de 4 saltos dentro do nócontexto curto e resposta rasaby_kind["aresta+no"] crescendo com a profundidade
teto de grauvizinho importante fora dos Na ponte sumiu e ninguém sabe por quêcompare teto_grau=3 com teto_grau=99
truncamentonão registradoresposta ruim sem causa aparentetaxa_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

artigo 02.

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, os ref_id que entraram, budget.report() e a taxa

de 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 floor do 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