Lede. Pedir para um LLM que ele liste as entidades de um documento devolve um JSON impecável — com pelo menos uma entidade que não existe no documento. Neste artigo você monta a extração que aguenta isso: uma lista fechada de tipos, saída estruturada, a exigência de que cada entidade apareça literalmente no trecho com a posição certa, e um validador determinístico que roda depois do modelo. Todo o código roda offline, sem chave de API. Onde você está na linha. Este é o passo 5 de 13 — extração de entidades. É o primeiro passo da trilha do grafo: é daqui que saem os nós que o artigo 06 vai casar e que o artigo 07 vai agrupar. Antes dele: o artigo 01 (os contratos
DocumenteChunk, reexibidos aqui) e o artigo 02 (de onde vem a unidade que entra na extração). Se você quer a decisão prática e não a teoria, vá para as seções 5 e 6.
1. Por que "extraia tudo" é o pedido errado
Uma entidade é um pedaço do texto que nomeia uma coisa do mundo: uma pessoa, uma
empresa, um documento, um local, um prazo, um valor. É a matéria-prima de um grafo de conhecimento, a estrutura que responde "quem se relaciona com quem" em vez de "o que
este documento diz".
O pedido mais natural é o errado:
# O pedido que quase todo mundo começa usando -- e a resposta que ele dá. import json # Um trecho de contrato fictício. Esta é a unidade que a extração recebe: # no artigo 02 você viu que ela é o `Chunk`, nunca o documento inteiro. TEXTO = ( "CLÁUSULA SÉTIMA - DO PRAZO. No Contrato 2024-118, signed entre Norte Energia S.A. " "(contratante) e a Contratada, o prazo para entrega do lote é de 90 (noventa) dias " "corridos, contados da assinatura. O foro eleito para dirimir controvérsias é o da " "Comarca de Belo Horizonte/MG. O valor da multa por atraso é de R$ 12.500,00. " "A obrigação de seguro não se aplica à Contratada neste caso." ) PROMPT_INGENUO = ( "Extraia todas as entidades do texto abaixo.\n" 'Responda em JSON no formato [{"tipo": "...", "nome": "..."}].\n\n' f"--- TEXTO ---\n{TEXTO}\n--- FIM ---" ) def chamar_modelo(prompt: str) -> str: """Placeholder: aqui entraria a chamada real do seu provedor de LLM. A resposta é FIXA e traz duas entidades inventadas de propósito, para o artigo rodar sem chave de API. Quando for usar de verdade, este é o único ponto que você troca. """ return """{"entidades": [ {"tipo": "documento", "nome": "Cláusula Sétima - Do Prazo"}, {"tipo": "documento", "nome": "Contrato 2024-118"}, {"tipo": "organizacao", "nome": "Norte Energia S.A."}, {"tipo": "organizacao", "nome": "Contratada"}, {"tipo": "local", "nome": "Belo Horizonte/MG"}, {"tipo": "prazo", "nome": "Diretoria de Compliance"}, {"tipo": "organizacao", "nome": "Contrato 2024-777"} ]}""" def extrair_ingenuo(trecho: str) -> list[dict]: """Chamada "de confiança": o modelo devolve o que quiser, e você aceita.""" return json.loads(chamar_modelo(PROMPT_INGENUO))["entidades"] entidades = extrair_ingenuo(TEXTO) # A conferência mais honesta que dá para fazer sem LLM: a entidade existe # literalmente no trecho? (sem diferenciar caixa, porque caixa não é o problema) inexistentes = [e for e in entidades if e["nome"].lower() not in TEXTO.lower()] print(f"{len(entidades)} entidades devolvidas, {len(inexistentes)} ausentes do trecho") for entidade in inexistentes: print(" -", entidade["nome"], "| tipo:", entidade["tipo"])
As duas últimas linhas são o problema inteiro. Uma delas tem um tipo que você nunca declarou (prazo). A outra repete um número de contrato com um dígito trocado, que é
exatamente o tipo de erro que passa despercebido até alguém confiar no número.
E o detalhe que decide se dá para recuperar: nenhuma das duas diz onde está no texto.
O JSON não tem posição. Sem posição você não tem como provar que o modelo inventou — e
sem essa prova, a única defesa é desconfiar de tudo, o que descarta a entidade verdadeira
junto com a falsa.
⚠️ "O modelo inventou" não é um erro que aparece; é um erro que entra
A alucinação em formato estruturado não chega como texto torto nem como erro de API.Chega como um objeto bem formado, com campos preenchidos e o tipo certo, apontando
para um documento que não fala daquilo. Se você grava direto, o erro só aparece quando
alguém lê o nó do grafo e pergunta "de onde saiu isso?".
2. Ontologia fechada: a lista de tipos que o modelo pode preencher
O antídoto para a lista infinita é decidir o vocabulário antes de rodar.
Ontologia fechada é a lista de tipos que a extração pode preencher, decidida fora do
código. O termo técnico é closed ontology: o vocabulário é fixo, e qualquer coisa fora
dele é rejeitada em vez de criada. A diferença para uma ontologia aberta é essa — na
aberta, o modelo pode introduzir um tipo novo, e o grafo enche de rótulos que ninguém
consultou.
A analogia honesta é o formulário de declaração. Um formulário tem campos marcados, com
tipo, com instrução e com exemplo: você preenche o que couber nos campos, e o que não
couber fica de fora. A folha em branco nunca fica vazia — ela fica cheia de invenção, e
ninguém consegue dizer depois qual campo foi preenchido errado.
A ontologia faz dois trabalhos ao mesmo tempo:
-
Contrato com o modelo. A lista vai dentro do prompt, cada tipo com uma descrição e
um exemplo. A descrição é o que separa
documentodelocal; sem ela, o modelodecide por conta própria e cada execução decide diferente.
-
Filtro. O que está fora da lista é ejetado antes de gastar validação, antes de ir
para o grafo e antes de aparecer em qualquer consulta. A ontologia é a única parte do
contrato que você controla e que não depende do provedor.
# Continua o bloco anterior: o mesmo `TEXTO`, agora com contrato em volta. from __future__ import annotations import hashlib from enum import StrEnum 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 peça 1: o arquivo cru, antes de qualquer transformação. Guardar `uri` e `metadata` aqui é o que permite citar a fonte e filtrar por permissão mais adiante. """ 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. Serve para uma coisa só: saber se o documento mudou desde a última indexação. Sem isso, reindexar significa reindexar tudo. """ return hashlib.blake2b( self.text.encode("utf-8"), digest_size=16 ).hexdigest() class Chunk(Contract): """A peça 3: o trecho indexado. `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) class EntityType(StrEnum): """A ontologia fechada: os ÚNICOS tipos que a extração pode preencher. O enum é a parte mecânica do contrato. A parte que impede o modelo de inventar tipo é `ONTOLOGIA`, logo abaixo, porque carrega a descrição e o exemplo que vão no prompt. """ ORGANIZACAO = "organizacao" # empresa, órgão, instituição, departamento PESSOA = "pessoa" # nome próprio de pessoa física DOCUMENTO = "documento" # contrato, norma, laudo, processo LOCAL = "local" # cidade, estado, comarca, unidade TEMPORAL = "temporal" # prazo, data, período VALOR = "valor" # dinheiro, quantidade com unidade PRODUTO = "produto" # mercadoria, insumo, equipamento class TipoNaOntologia(Contract): """Uma entrada da ontologia, com o texto que vai no prompt. Guardar descrição e exemplo junto do tipo é o que transforma uma lista de palavras em contrato. Uma lista de palavras soltas não diz ao modelo quando NÃO usar o tipo — e é exatamente aí que ele erra. """ tipo: EntityType descricao: str exemplo: str quando_nao_usar: str # A ontologia é uma tabela, e está escrita como tabela: a ordem de leitura do # código é a ordem de leitura do prompt, e as duas não podem divergir. ONTOLOGIA: dict[EntityType, TipoNaOntologia] = { tipo: TipoNaOntologia(tipo=tipo, descricao=descricao, exemplo=exemplo, quando_nao_usar=quando_nao_usar) for tipo, descricao, exemplo, quando_nao_usar in [ (EntityType.ORGANIZACAO, "Empresa, órgão, instituição ou parte de contrato com nome próprio.", "Norte Energia S.A., Contratada", "Não use para pessoa física nem documento numerado."), (EntityType.PESSOA, "Nome próprio de pessoa física, como está escrito no trecho.", "Maria Souza, João Batista", "Não use para nome de empresa nem cargo sem nome."), (EntityType.DOCUMENTO, "Documento identificável por número, código ou título.", "Contrato 2024-118, Anexo II", "Não use para o texto de um documento, só para o documento."), (EntityType.LOCAL, "Lugar físico ou jurisdição nomeado.", "Belo Horizonte/MG, Comarca de Santos", "Não use para endereço sem nome de lugar."), (EntityType.TEMPORAL, "Prazo, data ou período, com o número que o define.", "90 dias corridos, 15/03", "Não use para duração sem valor."), (EntityType.VALOR, "Quantia em dinheiro ou quantidade com unidade.", "R$ 12.500,00, 18 meses", "Não use para número sem unidade nem moeda."), (EntityType.PRODUTO, "Mercadoria, insumo ou equipamento identificável.", "Serra Azul, lote A-4021", "Não use para o material do produto."), ] } # O conjunto que o validador vai consultar: é a versão "conjunto" do enum, # e a comparação é um `in`, que é das operações mais baratas que existem. TIPOS_PERMITIDOS = {t.value for t in EntityType}
Quantos tipos colocar? A tentação é listar quarenta. O efeito colateral é previsível:
tipos demais se confundem entre si, e a confusão aparece como erro de validação — que é o
lugar mais difícil de causalidade do sistema. Comece com os tipos que as perguntas dos
seus usuários realmente usam, que costumam ser de quatro a oito. O que faltar aparece
como classe nova, e a sua taxa de perda por tipo (seção 6) é o instrumento que decide
quando adicionar.
💡 A ontologia é configuração, não código
Se a lista de tipos mora em código, mudar a ontologia é um deploy. Se ela vive emarquivo ou banco, mudar é uma linha de dados — e dá para rodar as duas versões lado a
lado, que é o que permite medir a troca (seção 7). O
Contractacima é o que garanteque a entrada seja validada na porta: um modelo do
Pydantic rejeita campo que não
existe, em vez de deixar o erro chegar três peças adiante.
3. Saída estruturada resolve o formato, não a veracidade
Sair do "JSON em texto solto" resolve uma coisa só: a forma. O modelo passa a devolver
algo que você lê com json.loads em vez de caçar a primeira chave no meio de uma
explicação. Existem três mecanismos, e eles não são intercambiáveis.
JSON Schema (json-schema.org) é o vocabulário que descreve
o formato esperado. Você escreve o esquema — tipos, campos obrigatórios, valores permitidos
em cada campo — e o provedor o usa como instrução de formato. É o que produz o enum com
os seus tipos: com o enum preenchido, o modelo não tem como devolver prazo num campo
que só aceita os sete.
Tool calling inverte o problema: em vez de você pedir JSON, você declara uma
ferramenta ("função") com os mesmos parâmetros e o modelo preenche os argumentos, como
descrito na
documentação de tool use da Anthropic.
É a mesma mecânica com outra embalagem, e é útil quando a extração é uma de várias
operações entre as quais o modelo escolhe.
Saída estruturada com garantia do provedor é o degrau acima dos dois: o provedor passa
a garantir que a saída é válida segundo o esquema, em vez de torcer para que o modelo
siga a instrução. O
descreve esse degrau. O ponto de atenção é o alcance da garantia: ela cobre o formato —
o conjunto de campos, os tipos, o enum. Ela não diz nada sobre o conteúdo, e é
exatamente aí que nasce a mentira do primeiro exemplo: um objeto com kind, name,
mention, start e end corretamente preenchidos em tipo, e com mention que não
existe no trecho.
| Mecanismo | O que garante | Cobre conteúdo? | Custo | Quando usar |
|---|---|---|---|---|
| JSON em texto solto | nada | não | nenhum | só para depurar |
| JSON Schema como instrução | segue a instrução, com falha possível | não | prompt maior | quando o provedor não tem modo estrito |
| Tool calling | o modelo escolhe e preenche a função | não | um passo de decisão a mais | quando a extração é uma opção entre várias |
| Saída com garantia do provedor | saída válida segundo o esquema | não | igual ao schema | sempre que o provedor oferecer |
A última linha da tabela é a única recomendação que importa aqui: pegue a garantia de
formato que o provedor oferecer, e não acredite que ela resolveu a extração. As duas
coisas são independentes, e a segunda é problema do validador da seção 5.
# Continua o bloco anterior: usa `ONTOLOGIA` e `TIPOS_PERMITIDOS`. # O esquema vai para o provedor. `enum` trava o tipo, `required` trava o # campo faltando, `additionalProperties: false` proíbe campo a mais. # Repare no que o esquema não tem: nenhuma forma de descrever "tem que # existir no trecho". Formato se declara; veracidade, não. ESQUEMA = { "type": "object", "properties": { "entities": { "type": "array", "items": { "type": "object", "properties": { "kind": {"type": "string", "enum": sorted(TIPOS_PERMITIDOS)}, "name": {"type": "string"}, "mention": {"type": "string"}, "start": {"type": "integer"}, "end": {"type": "integer"}, }, "required": ["kind", "name", "mention", "start", "end"], "additionalProperties": False, }, } }, "required": ["entities"], "additionalProperties": False, } def montar_prompt(trecho: str) -> str: """Monta o prompt a partir da ontologia, não à mão. A lista de tipos, a descrição e o exemplo saem do mesmo lugar que o `enum` do esquema. Se você escrever a lista no prompt à mão, a primeira vez que a ontologia muda o prompt fica desatualizado e ninguém percebe. """ linhas = [ "Extraia as entidades do trecho abaixo.", "Use SOMENTE os tipos desta lista:", ] for tipo, spec in ONTOLOGIA.items(): linhas.append( f"- {tipo.value}: {spec.descricao} " f"Exemplo: {spec.exemplo} Não usar quando: {spec.quando_nao_usar}" ) linhas += [ "", "Copie `mention` literalmente do trecho, sem corrigir, sem resumir, " "sem trocar a caixa.", "Informe `start` e `end` com a posição dessa menção: início inclusivo, " "fim exclusivo.", "`name` é como o grafo vai chamar a coisa: pode ser normalizado " "(minúsculo, sem acento).", "Se não houver entidade de um tipo, não invente uma.", "", "--- TRECHO ---", trecho, "--- FIM ---", ] return "\n".join(linhas)
4. Grounding: a entidade tem que existir, na posição certa
Grounding é a exigência de que a afirmação gerada esteja ancorada em algo
verificável na fonte. Neste artigo, o que se ancora é a menção: a entidade extraída
tem que aparecer literalmente no trecho, e tem que dizer em qual posição.
A posição (span, em inglês) é o que separa grounding dejecture de grounding
verificável. Compare as duas afirmações:
-
"o modelo extraiu Norte Energia S.A." — não é conferível por ninguém.
-
"
TEXTO[42:60]é igual amention" — é uma comparação de string, roda emmicrossegundos, e dá a mesma resposta sempre.
É essa segunda forma que permite uma política de qualidade: o que não tem span não entra. Não porque a menção seja má, mas porque você não tem como provar que é boa, e o
custo de um nó falso no grafo é muito maior que o de uma entidade a menos.
# Continua o bloco anterior: as funções que conferem a âncora. import unicodedata def sem_acento_e_caixa(texto: str) -> str: """Minúsculas sem acento, para comparar duas escritas do mesmo nome. A decomposição NFKD separa a letra do acento; o filtro descarta só os sinais combinantes (os acentos) e preserva o restante do caractere. """ decomposto = unicodedata.normalize("NFKD", texto.casefold()) return "".join(c for c in decomposto if not unicodedata.combining(c)) def casar_exato(mencao: str, trecho: str, inicio: int, fim: int) -> bool: """Grounding estrito: o pedaço do trecho é exatamente a menção?""" if inicio < 0 or fim > len(trecho) or inicio >= fim: return False return trecho[inicio:fim] == mencao def casar_tolerante(mencao: str, trecho: str, inicio: int, fim: int) -> bool: """Grounding tolerante: ignora caixa e acento. Não decide nada sozinho. Serve para separar "o modelo errou o acento" de "o modelo inventou a entidade", que são defeitos muito diferentes: o primeiro se corrige no dado, o segundo se corrige no modelo. """ if inicio < 0 or fim > len(trecho) or inicio >= fim: return False return sem_acento_e_caixa(trecho[inicio:fim]) == sem_acento_e_caixa(mencao) # O que uma posição errada devolve: um pedaço que parece convincente e não # tem nada a ver com a entidade declarada. print("o modelo declarou 'Diretoria de Compliance' e apontou para:") print(" ", repr(TEXTO[8:31])) print("o pedaço existe e é texto real:", casar_exato("Diretoria de Compliance", TEXTO, 8, 31))
⚠️
spanbonito não éspancerto
A falha mais comum aqui é a posição que aponta para outro trecho. Ela passa emqualquer revisão visual rápida, porque a string devolvida existe e parece texto normal —
o erro está em ela não ser a menção declarada. Por isso a conferência é
trecho[start:end] == mention, e não "o span está dentro do documento".
5. O validador determinístico, que roda depois do modelo
Agora a peça que resolve o gancho deste artigo. A regra é uma só: o LLM propõe, o código decide. A validação é determinística — mesma entrada, mesma saída, sem rede — e
por isso ela pode ser testada, medida e endurecida sem chamar modelo nenhum.
Ela produz três pilhas, e não duas. Duas seriam "entrou" e "não entrou", e "não entrou"
junta defeitos de naturezas opostas: a entidade que o modelo inventou (um nó falso no
grafo) e a entidade que o modelo acertou mas com a caixa diferente (um falso negativo seu).
Juntando as duas, você não sabe qual problema está atacando.
A ordem das checagens é a ordem de custo: in num conjunto primeiro, comparação de
string depois, heurística mais cara por último. Assim o caso sem discussão sai antes de qualquer
trabalho.
# Continua o bloco anterior: as entidades que o modelo devolveu. from collections.abc import Sequence class ExtractedEntity(Contract): """Uma entidade extraída, ancorada no trecho. São dois campos diferentes, de propósito: - `mention` é o que o texto DIZ, e é nele que o grounding é conferido. - `name` é como o grafo vai CHAMAR essa coisa, e pode ser normalizado (minúsculo, sem acento, com a unidade removida). A separação é o que permite validar o dado e, ao mesmo tempo, padronizar o nome. Com um campo só, ou você valida o nome normalizado — e rejeita entidade boa — ou grava a forma crua — e o grafo nasce com quarenta nós para dez coisas. A normalização do `name` é o assunto do artigo 06. """ kind: EntityType name: str mention: str start: int end: int chunk_id: str class Rejeicao(Contract): """Por que uma entidade do modelo não entrou. Guardar o motivo é o que vira relatório. Sem ele, o validador é um filtro silencioso e você nunca descobre o que perdeu. `kind` é texto, não enum, porque pode ser justamente um tipo fora da ontologia — que é um dos motivos de rejeição. """ motivo: str kind: str name: str detalhe: str = "" class Extracao(Contract): """A saída do validador, nas três pilhas.""" aprovados: list[ExtractedEntity] rejeitados: list[Rejeicao] revisar: list[Rejeicao] # Curas de negação que mudam o sentido. Heurística, e por isso o resultado vai # para `revisar` e nunca direto para `aprovados`. CURAS_DE_NEGACAO = {"nao", "sem", "exceto", "salvo", "isento", "nao_obrigatorio"} def tem_negacao(trecho: str, inicio: int) -> bool: """Olha as cinco palavras antes da menção. Janela curta de propósito: "não se aplica à Contratada" precisa acusar, e um "não" de frase anterior não tem nada a ver com a entidade que vem depois. Janela larga transforma a heurística em alarme constante. """ janela = sem_acento_e_caixa(trecho[max(0, inicio - 60):inicio]) return bool(CURAS_DE_NEGACAO & set(janela.split()[-5:])) def validar(entidades: Sequence[dict], trecho: str, chunk_id: str) -> Extracao: """Validador determinístico. Mesma entrada, mesma saída, sem rede.""" aprovados: list[ExtractedEntity] = [] rejeitados: list[Rejeicao] = [] revisar: list[Rejeicao] = [] vistos: set[tuple[str, int, int]] = set() # (tipo, início, fim) def rejeitar(motivo: str, kind: str, name: str, detalhe: str = "") -> None: rejeitados.append(Rejeicao(motivo=motivo, kind=kind, name=name, detalhe=detalhe)) def marcar_revisao(motivo: str, kind: str, name: str, detalhe: str) -> None: revisar.append(Rejeicao(motivo=motivo, kind=kind, name=name, detalhe=detalhe)) for bruta in entidades: kind, name = str(bruta.get("kind", "")), str(bruta.get("name", "")) mencao = str(bruta.get("mention", "")) inicio, fim = bruta.get("start"), bruta.get("end") # 1. O tipo está na ontologia? A checagem mais barata que existe. if kind not in TIPOS_PERMITIDOS: rejeitar("tipo_fora_da_ontologia", kind, name) # 1b. O nome tem conteúdo? Menção ancorada com nome vazio não é # entidade, e a normalização do artigo 06 devolve chave vazia em nome # degenerado — por isso o nome não pode passar em branco. elif not name.strip(): rejeitar("nome_vazio", kind, name) # 2. A posição é número e cabe no trecho? `null` do modelo é caso comum. elif not isinstance(inicio, int) or not isinstance(fim, int): rejeitar("posicao_invalida", kind, name, f"start={inicio!r} end={fim!r}") elif inicio < 0 or fim > len(trecho) or inicio >= fim: rejeitar("span_fora_do_texto", kind, name, f"{inicio}..{fim} em {len(trecho)}") # 3. O pedaço do trecho é a menção? (o grounding propriamente dito) elif not casar_exato(mencao, trecho, inicio, fim): if casar_tolerante(mencao, trecho, inicio, fim): # Não é invenção, é imprecisão: vai para revisão humana. marcar_revisao("casamento_tolerante", kind, name, trecho[inicio:fim]) else: rejeitar("span_nao_bate", kind, name, trecho[inicio:fim]) # 4. A mesma menção não entra duas vezes (o modelo repete com frequência). elif (kind, inicio, fim) in vistos: rejeitar("mencao_duplicada", kind, name) # 5. Há negação antes? Varia para revisão, não sai da pipeline. elif tem_negacao(trecho, inicio): marcar_revisao("negacao", kind, name, trecho[max(0, inicio - 30):fim]) else: vistos.add((kind, inicio, fim)) aprovados.append(ExtractedEntity(kind=EntityType(kind), name=name, mention=mencao, start=inicio, end=fim, chunk_id=chunk_id)) return Extracao(aprovados=aprovados, rejeitados=rejeitados, revisar=revisar)
Rodando com a resposta que o modelo deu:
# Continua o bloco anterior: agora a resposta do modelo, e a comparação. def resposta_do_modelo(trecho: str, estrito: bool) -> dict: """Fixture: a resposta que o seu provedor devolve. Cinco entidades corretas, uma com a menção em outra caixa, uma sob negação, uma com posição apontando para outro lugar e, no modo não estrito, uma com tipo fora da ontologia. Todas com o mesmo formato — é isso que a saída estruturada garante, e é só isso que ela garante. O `span` das entidades honestas é calculado aqui, no fixture, para concentrar a atenção no validador. Um modelo de verdade erra no `span` também nas entidades que ele acertou. """ def span(alvo: str, ocorrencia: int = 1) -> tuple[int, int]: pos = -1 for _ in range(ocorrencia): pos = trecho.index(alvo, pos + 1) return pos, pos + len(alvo) def entidade(kind: str, nome: str, mencao: str, posicao: tuple[int, int]) -> dict: return {"kind": kind, "name": nome, "mention": mencao, "start": posicao[0], "end": posicao[1]} # (tipo, nome, menção, posição). A posição vem calculada porque a menção # nem sempre é procurável no trecho -- ver a caixa errada mais abaixo. linhas = [ ("documento", "contrato 2024-118", "Contrato 2024-118", span("Contrato 2024-118")), ("organizacao", "norte energia", "Norte Energia S.A.", span("Norte Energia S.A.")), ("local", "belo horizonte", "Belo Horizonte/MG", span("Belo Horizonte/MG")), ("temporal", "prazo de 90 dias", "90 (noventa) dias corridos", span("90 (noventa) dias corridos")), ("valor", "multa de 12.500,00", "R$ 12.500,00", span("R$ 12.500,00")), # Caixa errada: imprecisão, não invenção. A `mention` nem existe como # substring do trecho -- a posição é que aponta para o lugar certo. ("organizacao", "norte energia", "norte energia s.a.", span("Norte Energia S.A.")), # Sob negação: entra, mas não como afirmação. ("organizacao", "contratada", "Contratada", span("Contratada", 2)), # Invenção pura: a menção não existe e a posição aponta para outro texto. ("organizacao", "diretoria de compliance", "Diretoria de Compliance", (8, 31)), ] entidades = [entidade(*linha) for linha in linhas] if not estrito: # Modo não-estrito: o provedor não garante o `enum` e o tipo inventado # passa. É o caso que o filtro da ontologia ainda segura. entidades.append(entidade("prazo", "assinatura", "90 (noventa) dias corridos", span("90 (noventa) dias corridos"))) return {"entities": entidades} for rotulo, estrito in (("sem garantia de esquema", False), ("com garantia", True)): resposta = resposta_do_modelo(TEXTO, estrito=estrito) resultado = validar(resposta["entities"], TEXTO, chunk_id="doc-1#0") print(f"=== provedor {rotulo} ===") for rejeitado in resultado.rejeitados: print(" rejeitado:", rejeitado.motivo, "|", rejeitado.name, "|", rejeitado.detalhe) for pendente in resultado.revisar: print(" revisar: ", pendente.motivo, "|", pendente.name, "|", pendente.detalhe) print(" aprovados:", [e.name for e in resultado.aprovados]) print() resultado_livre = validar(resposta_do_modelo(TEXTO, estrito=False)["entities"], TEXTO, "doc-1#0") resultado_estrito = validar(resposta_do_modelo(TEXTO, estrito=True)["entities"], TEXTO, "doc-1#0") print("o modo estrito elimina o problema de formato:", len(resultado_estrito.rejeitados) < len(resultado_livre.rejeitados)) print("e não elimina o de conteúdo:", sorted({r.motivo for r in resultado_estrito.rejeitados}))
Repare no que a garantia de formato resolveu e no que não resolveu. O tipo inventado
desaparece: era problema de formato, e o enum do esquema é a ferramenta certa para
formato. A posição que aponta para outro lugar continua lá, com a resposta mais bem
formatada do mundo.
💡 O
mentioné o que se valida; onameé o que se usa
Confundir os dois é o erro que faz o validador derrubar a entidade certa. Se vocêvalida o
name, um nome normalizado ("norte energia") nunca casa com o trecho("Norte Energia S.A.") e a taxa de rejeição vira alta sem nenhum defeito no modelo.
A regra cabe numa frase: valide o que o texto diz; normalize o que você guarda.
E é por isso que o validador não checa plural. "Prazo de 90 dias" e "prazo de 90 dia"
são o mesmo name, e decidir entre os dois é trabalho de normalização, que o artigo 06 faz
com o pipeline de sete passos. Se o validador também cortasse o "s", a mesma normalização
rodaria duas vezes em pontos diferentes do pipeline, e a segunda passaria a depender da
ordem em que as entidades foram extraídas — que é a ordem em que o LLM respondeu. A regra
é: o validador decide se a entidade existe; o normalizador decide como ela se chama.
6. O relatório de rejeição: o número que você olha todo dia
O que fazer com o que foi rejeitado? A resposta curta: contar. Descartar em silêncio
transforma o validador em buraco negro, e buraco negro não tem taxa.
A contagem que importa é por motivo e por tipo, e ela serve para decidir a ordem
de trabalho: endurecer regra é sempre a última opção, não a primeira.
# Continua o bloco anterior: os resultados, agora contados. from collections import Counter from typing import Any def contagem_por_tipo(resposta: dict, resultado: Extracao) -> dict[str, dict[str, int]]: """Rejeição por tipo. É esta tabela que decide qual regra tocar. Uma taxa de 8% no total esconde um tipo que perde 70% do que o modelo oferece. Somado no total, o número parece saudável e aquele tipo está praticamente morto — sem que nada indique isso. """ oferecidas: Counter[str] = Counter(e.get("kind", "?") for e in resposta["entities"]) perdidas: Counter[str] = Counter( r.kind for r in (*resultado.rejeitados, *resultado.revisar) ) tabela: dict[str, dict[str, int]] = {} for kind, total in sorted(oferecidas.items()): perdas = perdidas.get(kind, 0) tabela[kind] = {"oferecidas": total, "aprovados": total - perdas, "perdas": perdas, "taxa_perda_pct": round(100 * perdas / total) if total else 0} return tabela def contagem_por_motivo(resultados: Sequence[Extracao]) -> Counter[str]: """Por que as entidades caíram. A ordem dos motivos é a ordem de trabalho.""" motivos: Counter[str] = Counter() for resultado in resultados: for r in (*resultado.rejeitados, *resultado.revisar): motivos[r.motivo] += 1 return motivos def imprimir_relatorio(lote: Sequence[tuple[str, dict, Extracao]]) -> None: """Imprime a conta do lote inteiro, por motivo e por tipo.""" resultados = [item[2] for item in lote] aprovados = sum(len(r.aprovados) for r in resultados) oferecidas = sum(len(resp["entities"]) for _, resp, _ in lote) print(f"lote: {oferecidas} entidades oferecidas, {aprovados} aprovadas " f"({round(100 * aprovados / oferecidas)}%)") print("motivos de perda:") for motivo, quantidade in contagem_por_motivo(resultados).most_common(): print(f" {motivo:<24} {quantidade}") print("perda por tipo:") acumulado: dict[str, dict[str, int]] = {} for _, resposta, resultado in lote: for kind, conta in contagem_por_tipo(resposta, resultado).items(): alvo = acumulado.setdefault(kind, {"oferecidas": 0, "perdas": 0}) alvo["oferecidas"] += conta["oferecidas"] alvo["perdas"] += conta["perdas"] for kind, conta in sorted(acumulado.items()): taxa = round(100 * conta["perdas"] / conta["oferecidas"]) print(f" {kind:<14} perdidas={conta['perdas']}/{conta['oferecidas']} ({taxa}%)")
Isto roda sobre um lote de dois trechos:
# Continua o bloco anterior: um segundo trecho, para o lote ter dois textos. TEXTO_2 = ( "ANEXO II - CRITÉRIOS DE ACEITE. O lote Serra Azul, código A-4021, será entregue " "na Unidade de Belo Horizonte até 15/03. A garantia é de 18 meses contados " "do aceite, e não se aplica ao lote A-4022." ) RESPOSTA_2 = {"entities": [ # `ANEXO II` começa no índice 0 do trecho, então a posição abaixo aponta # para o começo do documento em vez de para a menção: rejeitada. {"kind": "documento", "name": "a-4021", "mention": "A-4021", "start": 0, "end": 8}, {"kind": "produto", "name": "serra azul", "mention": "Serra Azul", "start": TEXTO_2.index("Serra Azul"), "end": TEXTO_2.index("Serra Azul") + 10}, # Menção sob negação: vai para revisão, não para o grafo. {"kind": "produto", "name": "a-4022", "mention": "A-4022", "start": TEXTO_2.index("A-4022"), "end": TEXTO_2.index("A-4022") + 6}, ]} lote = [ ("doc-1#0", resposta_do_modelo(TEXTO, estrito=False), validar(resposta_do_modelo(TEXTO, estrito=False)["entities"], TEXTO, "doc-1#0")), ("doc-2#1", RESPOSTA_2, validar(RESPOSTA_2["entities"], TEXTO_2, "doc-2#1")), ] imprimir_relatorio(lote) print() print("o que o relatório diz sobre `documento` no segundo trecho:") print(" ", contagem_por_tipo(RESPOSTA_2, lote[1][2])["documento"])
A honestidade que esse número cobra
Validação estrita derruba recall — isto é, descarta entidade verdadeira. Toda regra que
você acrescenta para pegar uma invenção tem custo em entidade boa, e esse custo não aparece
como erro: aparece como ausência.
Então a ordem de trabalho é invertida em relação à intuição. Antes de endurecer qualquer
regra:
-
Veja a perda por tipo. Um tipo com taxa alta quase sempre é problema de ontologia
mal escrita, não de modelo. O tipo que mais perde costuma ser aquele cuja descrição não
diz quando não usar.
-
Leia a pilha de revisar, não só a de rejeitados. A primeira mede o custo da sua
validação; a segunda é defeito confirmado.
-
Só endureça depois de olhar. Uma regra nova entra junto com o número que ela mudou.
Regra que entra sem número entra como crendade.
E o inverso também vale: perda muito baixa não é sinal de extração boa. Pode ser que o
grounding esteja desligado, e nesse caso a perda é zero porque nada é conferido.
⚠️
%de aprovação alto é o número mais fácil de fabricar de propósito
Ele sobe de três maneiras legítimas (modelo melhor, ontologia melhor, trecho menor) ede uma perigosa: relaxar a conferência. Guarde a taxa por motivo, não só a geral.
span_nao_batesubindo é o número que denuncia validação desligada — e ele só aparecese você tiver contado.
7. Idempotência: extrair de novo não pode duplicar nada
Extração é a etapa mais cara do carregamento, e ela roda em lote, sobre o acervo inteiro,
toda vez que a indexação roda. Sem uma chave que diga o que já foi processado, a segunda
passagem duplica tudo — e duplicação em grafo não aparece como erro de execução, aparece
como grau dobrado e modularidade torta, que é o artigo 07.
A chave tem três partes, e as três importam:
- o conteúdo do trecho (é o que muda quando o documento muda);
- a versão da ontologia (é o que muda quando você acrescenta um tipo);
- a identificação do modelo (é o que muda quando você troca de provedor).
Deixar a versão da ontologia de fora é o erro sutil: você corrige a ontologia, roda a
indexação, e o sistema pula os trechos porque o texto não mudou. A nova descrição nunca
entra em vigor, e a melhoria some sem erro.
# Continua o bloco anterior: a chave e o registro de processamento. # A versão é parâmetro, e não uma constante que alguém muda no meio da # execução: assim dá para gerar a chave de uma ontologia futura sem # reiniciar nada. VERSAO_ONTOLOGIA = "2026-10-04.1" MODELO_EXTRACAO = "exemplo-local-v1" def chave_de_extracao(conteudo: str, versao: str = VERSAO_ONTOLOGIA, modelo: str = MODELO_EXTRACAO) -> str: """Impressão digital do par (conteúdo, ontologia, modelo). O separador \\x1f importa: sem ele, ("ab", "c") e ("a", "bc") colidiriam. """ partes = "\x1f".join([conteudo, versao, modelo]) return hashlib.blake2b(partes.encode("utf-8"), digest_size=16).hexdigest() class RegistroDeExtracao: """O que já foi extraído. Em produção isto é uma tabela, não um dicionário. Guardar `chunk_id` junto da chave é o que permite responder "por que este trecho tem entidade" depois de um reindex — e é o que permite desfazer. """ def __init__(self) -> None: self._processados: dict[str, str] = {} def ja_processado(self, chave: str) -> bool: return chave in self._processados def registrar(self, chave: str, chunk_id: str) -> None: self._processados[chave] = chunk_id def __len__(self) -> int: return len(self._processados) registro = RegistroDeExtracao() documentos = [ Document(doc_id="doc-1", uri="urn:doc:1", title="Contrato 2024-118", text=TEXTO, metadata={"tenant": "empresa-a"}), Document(doc_id="doc-2", uri="urn:doc:2", title="Anexo II", text=TEXTO_2, metadata={"tenant": "empresa-a"}), ] # A unidade da extração é o trecho; aqui ele é o documento inteiro porque # esta base tem um trecho por documento. trechos = [ Chunk(chunk_id=f"{d.doc_id}#0", doc_id=d.doc_id, parent_id=None, ordinal=0, text=d.text, token_count=len(d.text.split()), metadata={"tenant": d.metadata["tenant"]}) for d in documentos ] for passagem in (1, 2): processados = pulados = 0 for trecho in trechos: chave = chave_de_extracao(trecho.text) if registro.ja_processado(chave): pulados += 1 continue registro.registrar(chave, trecho.chunk_id) processados += 1 print(f"passagem {passagem}: processados={processados} pulados={pulados}") # Onde o `content_hash` do documento entra: é a checagem mais grossa, antes # de chegar no trecho. Ela evita percorrer o acervo; a chave acima evita # reextrair o que não mudou. alterado = documentos[0].model_copy(update={"text": documentos[0].text + " "}) print("documento intocado mantém o hash:", documentos[0].content_hash == hashlib.blake2b( documentos[0].text.encode("utf-8"), digest_size=16).hexdigest()) print("documento alterado muda o hash:", documentos[0].content_hash != alterado.content_hash) # A prova de que a versão da ontologia está na chave: o mesmo trecho, outra # versão da ontologia, é outro trabalho. print("chave com a ontologia de hoje: ", chave_de_extracao(TEXTO)[:16]) print("chave com a ontologia de amanhã:", chave_de_extracao(TEXTO, "2026-11-01.1")[:16]) print("o registro não confunde os dois:", not registro.ja_processado(chave_de_extracao(TEXTO, "2026-11-01.1")))
E grave a versão junto da entidade, não só na chave de idempotência. No grafo, cada
entidade aprovada costuma guardar de onde veio: trecho, posição, versão da ontologia,
modelo. Sem isso, quando a qualidade cair três meses depois, você tem a taxa de perda e
nenhuma pista de qual versão a produziu. Com isso, a resposta é uma consulta.
8. Quando um classificador pequeno vence o LLM
Nem toda extração precisa de LLM. O classificador pequeno — um modelo treinado só para
escolher entre os rótulos da ontologia, sem chamar serviço nenhum — ganha em três
situações, e as três precisam valer ao mesmo tempo:
-
O rótulo é fechado e a posição não importa. Se você só precisa decidir o tipo de
um trecho que você já recortou, e não achar onde a menção está, um classificador faz
exatamente isso.
-
O volume é grande. Aqui o custo por chamada de modelo é o que domina o orçamento, e
a latência por trecho é o que domina o tempo de indexação.
-
Existe material anotado. Sem exemplo rotulado não há o que treinar — e aí o LLM
entra, porque ele já sabe ler.
O que o classificador não faz, e isso decide a arquitetura:
-
Não acha a menção. Ele classifica o que você já isolou. Para a âncora da seção 4
continua precisando de LLM, ou de regra.
-
Não aprende rótulo novo. Adicionar um tipo exige novo treino, novo rótulo no
conjunto anotado e novo deploy. Na ontologia fechada isso é uma linha de configuração.
-
Erra em silêncio e sem posição. Quando a passagem é ambígua, ele não tem como dizer
de onde tirou a decisão.
A arquitetura que costuma sair daí é híbrida, e é a recomendada: o classificador decide o
tipo, o LLM acha a menção, e o validador é o mesmo dos dois caminhos.
# Continua o bloco anterior: a via barata, para comparação. # Léxico por tipo. Não é classificador treinado: é o piso de custo, e já # serve de linha de base para comparar o LLM e de teste de regressão do # prompt, sem gastar uma chamada. LEXICO: dict[str, tuple[str, ...]] = { "documento": ("contrato", "cláusula", "anexo", "norma", "laudo", "processo"), "local": ("comarca", "unidade", "cidade", "município", "/mg", "/sp"), "organizacao": ("s.a.", "ltda", "eireli", "contratada", "contratante"), "produto": ("lote", "código", "produto", "insumo"), } def classificar_por_lexico(trecho: str) -> set[str]: """Tipos cujo léxico aparece no trecho. Barato, burro e explicável.""" alvo = sem_acento_e_caixa(trecho) return {tipo for tipo, termos in LEXICO.items() if any(t in alvo for t in termos)} lexico_doc1 = classificar_por_lexico(TEXTO) aprovados_doc1 = {e.kind.value for e in resultado_livre.aprovados} print("o léxico acha por custo zero:", sorted(lexico_doc1)) print("o LLM entregou, depois de validar:", sorted(aprovados_doc1)) print("o que ele não faz: achar a posição. Sem `start` e `end` não há grounding,") print("então o léxico compara e lembra -- não ancora. No 2o trecho:", sorted(classificar_por_lexico(TEXTO_2)))
Esse acordo entre léxico e LLM não é benchmark. São duas implementações com erro
diferente rodando sobre o mesmo trecho, e é isso que permite saber qual das duas está
errando quando elas discordam. Medir de verdade exige o conjunto anotado do artigo 11.
Um tipo de ontologia que não responde pergunta nenhuma não pertence na ontologia, e o
custo de acrescentar "porque pode ser que sirva" é medido: cada tipo novo aumenta a chance
de o modelo confundir dois deles, e a confusão aparece como perda na sua própria taxa por
tipo — um número que você vai usar para decidir outras coisas. O filtro honesto é o das
seções 1 e 8: algum tipo novo responde a uma pergunta que os seus usuários já fizeram?
9. A ordem para montar (e o número de cada passo)
Cada passo abaixo é verificável sem a etapa seguinte, e é por isso que a ordem não é
sugestão.
-
Escreva a ontologia e só ela. Ainda sem LLM: os tipos, cada um com descrição,
exemplo e "quando não usar". Este é o contrato, e ele é seu.
-
Rode a saída estruturada e o enum. Peça ao provedor a garantia de formato que ele
oferece. Neste ponto a taxa de aprovação é alta e enganosa — ela mede o formato.
-
Ligue o grounding. Uma linha:
trecho[start:end] == mention. A taxa de aprovaçãocai, e essa queda é a primeira informação real que você recebe sobre a extração.
-
Conte por motivo e por tipo. Este passo não tem prazo de término. Ele é o painel
que você olha antes de mexer em qualquer regra.
-
Só então endureça. Uma regra por vez, com o número antes e depois.
-
Ligue a idempotência antes de rodar em lote. A chave precisa existir antes da
primeira execução de verdade, porque corrigir duplicata em grafo é mais caro do que
prevenir.
Se você parou aqui, o seu sistema tem extração com contrato de tipo, âncora verificável no
trecho, três pilhas de resultado e um relatório por tipo. O que ele ainda não tem é o de
sempre do próximo passo: a mesma coisa escrita de três formas diferentes já virou três
nós. Isso é o artigo 06.
TL;DR
-
Saída estruturada resolve formato, não veracidade. O provedor garante que o objeto
é válido segundo o esquema; não garante que a entidade exista no documento.
-
A ontologia fechada é o seu contrato, não o do provedor, e o filtro dela funciona
mesmo sem o modo estrito.
-
Grounding é trecho[start:end] == mention. Sem posição não há prova, e sem prova
você só tem duas opções: confiar ou descartar tudo.
-
Valide três pilhas, não duas: aprovou, rejeitou, revisar. Juntar "o modelo inventou"
com "o modelo acertou com outra caixa" impede saber qual problema você tem.
-
Meça a perda por tipo antes de endurecer qualquer regra. Validação estrita derruba
recall, e o custo aparece como ausência, não como erro.
-
A chave de idempotência é (conteúdo, versão da ontologia, modelo). Sem a versão da
ontologia, corrigir a ontologia não muda nada.
-
Classificador pequeno vence com tipo fechado, volume alto e material anotado — e
ele nunca acha a posição, que é o que o grounding exige.
Referências
- Understanding JSON Schema — o vocabulário que descreve o formato esperado, base da seção 3
- Structured Outputs — OpenAI — o alcance da garantia de formato, e o que ela não cobre
- Tool use — Anthropic — o segundo mecanismo de saída estruturada, para comparar
- Models — Pydantic — por que a validação do contrato acontece na entrada e não no chamador