Lede. O mesmo remédio aparece no seu acervo como "Cloridrato de sertralina", "sertralina 50mg" e "CLORIDRATO DE SERTRALINA". São três nós, e três nós matam qualquer agregação — grau, comunidade, caminho. Neste artigo você monta o entity linking (o casamento entre a forma como o texto escreve e o nó canônico do grafo) em quatro estágios de custo crescente, com a normalização em português fazendo o trabalho pesado, e com todo match registrando o estágio que o produziu. Onde você está na linha. Este é o passo 6 de 13 — entity linking. Depende direto do artigo 05, de onde vêm as entidades já validadas e ancoradas no trecho; aqui elas ganham um id canônico. Depois dele: o artigo 07, que transforma os nós casados em grafo. Se você quer só a decisão prática, vá para as seções 2 e 7.
1. O problema: a mesma coisa escrita de três formas
Entity linking é o casamento entre a forma como o texto escreve uma entidade e o nó
canônico do grafo. O termo aparece também como entity resolution e, em sistemas de
conhecimento, como record linkage
(a formulação do problema). A ideia é que
o grafo tenha um nó por coisa, e que cada menção do texto aponte para ele.
Sem esse passo, o grafo é um lixo. Uma entidade que aparece trinta vezes como trinta nós
não tem grau trinta: tem trinta nós de grau um, e grau um não conecta nada. A
comunidade do artigo 07 sai fragmentada, a travessia de caminho morre no primeiro salto, e
a resposta para "quem aparece junto com X" volta vazia — sem erro, só vazia.
Arylina em três formas, três nós:
# Continua do artigo 05: as entidades saem validadas, e cada uma tem um # `name` e uma `mention`. O grafo, por enquanto, é um dicionário burro. # `name` é o que o modelo devolveu como forma canônica, que no artigo 05 já # veio normalizado pelo prompt. Aqui ele vira a chave do grafo. MENTOES = [ ("Cloridrato de sertralina", "produto"), ("sertralina 50mg", "produto"), ("CLORIDRATO DE SERTRALINA", "produto"), ] grafo: dict[str, set[str]] = {} # chave do nome -> nós em que ele aparece for nome, tipo in MENTOES: grafo.setdefault(nome, set()).add(tipo) print("nós criados:", len(grafo)) for chave in grafo: print(" -", chave)
Três chaves, três nós. A pergunta "quantos documentos mencionam sertralina?" devolve
3 para o primeiro nó, 1 para cada um dos outros, e a resposta que o usuário esperava
— três — não existe em lugar nenhum do sistema. Nenhum erro, nenhuma exceção, nenhum log:
a resposta errada parece uma resposta certa.
O que salva isso não é um modelo melhor. É uma decisão de pipeline: o nome do nó não vai ser a string que o texto usou. Vem de uma forma canônica, e a menção vira uma
referência a essa forma.
2. Quatro estágios, do mais barato ao mais caro
O casamento não é uma operação, são quatro, e a ordem é por custo. Cada estágio é mais
lento e mais tolerante que o anterior, e cada um herda a chance de errar do anterior.
A analogia honesta é a de quem resolve um nome numa lista de contatos: primeiro procura o
nome exato (rápido e certeiro), depois o nome sem acento e sem particípio (ainda rápido),
depois o nome "quase igual" (lento, e pode errar), e por último o nome que "soa parecido"
(muito lento, e erra com frequência). Nessa ordem, o primeiro palpite bom quase sempre é
também o mais barato.
O estágio 1 é o único que pode acertar sem ambiguidade, porque ele compara com uma lista
que você construiu. Ele também é onde entra o conhecimento que o texto não carrega: o
apelido que o texto usa e que a sua base chama de outro jeito. Ele precisa existir mesmo
que pareça redundante.
O estágio 2 é o que este artigo resolve de verdade, e o assunto das seções 3 e 4. Ele
compara a forma canônica com a menção depois de normalizar as duas — e normalizar as
duas, não só a menção, é o detalhe que decide se a etapa funciona.
O estágio 3 é tolerante por construção: aceita quase igual. O estágio 4 é o mais
tolerante de todos, e por isso é o que nunca deve decidir sozinho.
| Estágio | Compara | Custo | Aceita erro? | Onde o ajuste mora |
|---|---|---|---|---|
| 1 · id e apelidos | string exata contra dicionário | o menor | não | na lista de apelidos |
| 2 · chave normalizada | forma canônica da menção contra a do nó | baixo | quase não | no normalizador |
| 3 · fuzzy | semelhança caractere a caractere | médio | sim, e em silêncio | no limiar |
4 · embedding | vetor de significado | o maior | sim, e em silêncio | no limiar e na fila de revisão |
O "aceita erro?" da tabela é a coluna que decide a política: nos dois primeiros estágios
um erro é raro e consertável. Nos dois últimos o erro é silencioso, e por isso o
resultado precisa carregar o estágio que o produziu.
3. A normalização em português (o núcleo do artigo)
A forma canônica de um nó e a forma de uma menção quase nunca são iguais como string.
Elas se tornam iguais depois de um pipeline de regras. O pipeline tem ordem fixa, e a
ordem é parte do contrato: a mesma sequência roda na menção e no nome do nó, sempre.
São sete passos, do mais mecânico ao mais frágil. Os primeiros reescrevem caracteres; o
último decide se duas palavras são a mesma palavra, e é nele que a heurística morre sem
aviso.
# Continua do artigo anterior: o grafo burro, agora com pipeline de nomes. import math import re import unicodedata from collections.abc import Sequence from difflib import SequenceMatcher def sem_acento_e_caixa(texto: str) -> str: """Passo 1: minúsculas sem acento. A decomposição NFKD separa a letra do acento; o filtro descarta só os sinais combinantes (os acentos) e preserva o restante do caractere. É o que o [módulo `unicodedata`](https://docs.python.org/3/library/unicodedata.html) faz, e é a única parte do pipeline que depende da biblioteca padrão. """ decomposto = unicodedata.normalize("NFKD", texto.casefold()) return "".join(c for c in decomposto if not unicodedata.combining(c)) # Passo 2: número colado em unidade. Texto real escreve "50mg", e o tokenizer # do modelo trata como um token só. A separação é feita por olhar para trás e # para a frente, sem consumir caractere. DIGITO_OU_LETRA = re.compile(r"(?<=\d)(?=[a-z])") # Passo 3: sufixo legal. Sai antes da pontuação, porque "S.A." vira "S A" # depois e perde o sentido. SUFIXO_LEGAL = re.compile(r"\b(s\.?a\.?|ltda\.?|limitada|eireli|epp|inc)\b") # Passo 4: pontuação, com uma exceção. Separador de milhar e separador de # decimal é pontuação que faz parte do número: "12.500,00" tem que virar # "1250000" e não "12 500 00", senão a mesma quantia escrita de duas formas # produz duas chaves. O preço da regra: "1.23" e "123" também convergem, e # ninguém escreve "1.23 reais" com frequência suficiente para o erro aparecer. PONTUACAO_ENTRE_DIGITOS = re.compile(r"(?<=\d)[.,](?=\d)") NAO_ALFA_NUMERICO = re.compile(r"[^\w\s]") # Passo 5: palavra funcional dentro de nome próprio. "de", "da", "do" não # mudam o referente, então saem da chave. STOPWORDS = {"de", "da", "do", "das", "dos", "e"} # Passo 6: unidade e dose. "50 mg" qualifica a entidade, não a nomeia. UNIDADES = {"mg", "ml", "g", "gr", "kg", "mcg", "ui", "%", "comprimido", "comprimidos", "capsula", "capsulas"} def sem_pontuacao(texto: str) -> str: """Passo 4 isolado, para ficar legível na hora de depurar.""" limpo = PONTUACAO_ENTRE_DIGITOS.sub("", texto) limpo = NAO_ALFA_NUMERICO.sub(" ", limpo) return " ".join(limpo.split()) def sem_stopwords(tokens: Sequence[str]) -> list[str]: """Passo 5 isolado. Janela curta: remove só a palavra, nunca a frase.""" return [t for t in tokens if t not in STOPWORDS] def sem_unidades_e_dose(tokens: Sequence[str]) -> list[str]: """Passo 6 isolado: descarta a unidade e o número que a qualifica. "50 mg" vira nada; "50" sozinho vira "50". A diferença importa porque o número sozinho é parte de outro nome (o número do contrato, a data). """ saida: list[str] = [] for token in tokens: if token in UNIDADES: anterior = saida[-1].replace(",", ".") if saida else "" if anterior.replace(".", "").isdigit(): saida.pop() continue saida.append(token) return saida
O passo 7 é o que ninguém coloca e o que mais quebra: o plural. Em português o plural
não é "o mais s", e uma heurística que só faz isso falha em silêncio.
# Continua o bloco anterior: o último passo do pipeline. # Plurais que a heurística erraria. Cada entrada é um par que NÃO casa sem # lista: "meses" sem ela vira "mese", e "mês" vira "mes" -- duas chaves para a # mesma coisa, sem nenhum aviso. IRREGULARES = { "meses": "mes", "paises": "pais", "ingleses": "ingles", "homens": "homem", "pães": "pao", "irmaos": "irmao", "analises": "analise", "mes": "mes", } # Palavras que terminam em "s" mas estão no singular, ou que são forma # comum. Sem esta lista, "depois" vira "depoi" e a chave fica inútil. NAO_SINGULARIZAVEIS = {"mais", "menos", "depois", "atras", "ambas", "ambos"} def no_singular(token: str) -> str: """Passo 7: tenta o singular de um token. A ordem das regras importa: as terminações mais específicas primeiro, porque "ações" precisa virar "ação" e não "açõe". """ if token in NAO_SINGULARIZAVEIS or len(token) < 4: return token if token in IRREGULARES: return IRREGULARES[token] if token.endswith(("ões", "ães")): # acoes -> acao, maes -> mao return token[:-3] + "ao" if token.endswith("ais"): # animais -> animal, sinais -> sinal return token[:-1] if token.endswith("ns") and not token.endswith("ns."): return token[:-1] # jovens -> jovem,irmaos -> irmao if token.endswith("s") and not token.endswith("us"): return token[:-1] # contratos -> contrato return token
Roda o pipeline completo e olha o que ele produz. Este é o teste que vale a pena ter em
repositório: ele é a documentação executável da normalização.
# Continua o bloco anterior: o pipeline inteiro, e o que ele devolve. def chave_canonica(nome: str) -> str | None: """Chave de comparação de um nome de entidade, ou None se não der. None é uma resposta de primeira classe: significa "este nome não serve para comparação exata" e o destino da menção é a revisão, não um chute no estágio 3 ou 4. """ base = sem_acento_e_caixa(nome) # 1 base = DIGITO_OU_LETRA.sub(" ", base) # 2 base = SUFIXO_LEGAL.sub(" ", base) # 3 base = sem_pontuacao(base) # 4 tokens = base.split() tokens = sem_stopwords(tokens) # 5 tokens = sem_unidades_e_dose(tokens) # 6 tokens = [no_singular(t) for t in tokens] # 7 return " ".join(tokens) or None print("--- a normalização em ação ---") for nome in ["Cloridrato de sertralina", "sertralina 50mg", "CLORIDRATO DE SERTRALINA", "Norte Energia S.A.", "Norte Energia", "Contratos de 2024", "contrato", "R$ 12.500,00", "Ltda", "policies and codes"]: print(f" {nome!r:36} -> {chave_canonica(nome)!r}")
Cinco linhas dessa saída merecem atenção:
-
"Cloridrato de sertralina"e"CLORIDRATO DE SERTRALINA"dão a mesma chave. Erapara ser assim: são a mesma escrita, com caixa diferente.
-
"sertralina 50mg"dá uma chave diferente da primeira. E está certo: "50mg" édose, e a dose não faz parte do nome da entidade. É por isso que o passo 2 existe.
-
"Norte Energia S.A."e"Norte Energia"dão a mesma chave. O sufixo legal saiu. -
"Contratos de 2024"e"contrato"não dão a mesma chave, e devem mesmo dardiferente: um é uma lista de contratos, o outro é o conceito.
-
"policies and codes"vira"policie and code". O passo 7 não sabe que idioma é:aplicou a regra de plural do português a um texto em inglês e produziu uma chave que não
é palavra nenhuma, em nenhum idioma. A lista de irregulares é por idioma, e um acervo
multilíngue precisa de um pipeline por idioma — ou de um classificador de idioma antes
do passo 1.
"R$ 12.500,00", na mesma linha, vira"r 1250000": os separadores saemde propósito, e as duas formas usuais de escrever a mesma quantia convergem para a mesma
chave, que é o que interessa para casar.
⚠️ Normalizar só a menção é o erro que faz o estágio 2 não funcionar
A tentação é normalizar o texto que chegou e comparar com o nome do nó "como está nobanco". Aí o estágio 2 nunca acerta, porque o nome canônico está escrito por uma
pessoa e a menção por outra. As duas pontas do comparação passam pelo mesmo pipeline,
sempre, na mesma ordem. Se você normaliza só uma, o estágio 2 vira decorativo e todo
match cai no fuzzy — que é a forma cara e silenciosa de errar.
4. A armadilha da stopword: nome genérico é nome válido
A lista de palavras funcionais merece uma conversa à parte, porque ela produz um erro que
não aparece no relatório: ela derruba nomes válidos.
"Norte Energia" sobrevive bem à remoção de "de". Mas "Banco do Brasil" vira "banco
brasil", e "Ministério da Saúde" vira "ministerio saude" — as duas chaves ainda são
legíveis e ainda casam, só perderam a preposição. O problema aparece quando o nome é
composto de uma palavra funcional e de mais nada: "D", "E", "Ltda", "S.A.". Depois dos
sete passos, a chave é vazia. E a chave vazia é o pior valor possível, porque ela é igual
a si mesma: toda entidade que vira chave vazia colide com toda outra que vira chave vazia,
e o "match" perfeito que o pipeline devolve não casa com nada.
A defesa tem duas partes. A primeira é o None da função: chave vazia não vira chave, vira
None, e None vai para a fila de revisão. A segunda é o piso de tamanho: uma chave de
um ou dois caracteres não é um nome, é um resto de normalização.
# Continua do bloco anterior: os casos degenerados, e o que eles viram. TAMANHO_MINIMO = 3 # caracteres: abaixo disso a chave não identifica ninguém def chave_ou_revisao(nome: str) -> tuple[str, str | None]: """Devolve (chave, motivo) e o motivo explica por que a chave é None. Devolver o motivo, e não só o None, é o que permite gerar o relatório da seção 7 sem refazer a normalização. """ chave = chave_canonica(nome) if chave is None: return "", "nome_vazio_apos_normalizacao" if len(chave) < TAMANHO_MINIMO: return chave, "chave_curta_demais" return chave, None for nome in ["Ltda", "D", "A E", "Norte Energia", "de", "Banco do Brasil"]: chave, motivo = chave_ou_revisao(nome) print(f" {nome!r:22} -> chave={chave!r:18} motivo={motivo}")
O segundo problema da lista de stopwords é o oposto: ela é escrita para a sua base, não
para o português em geral. "de", "da", "do" saem com segurança quase sempre. "e" é
perigoso em nome de empresa. E qualquer palavra que você adicionar tem chance de estar
sendo usada como nome próprio em algum lugar do acervo — foi assim que "União" virou
chave vazia na primeira vez que alguém tentou remover "da".
💡 Trate a lista de stopwords como dado versionado, junto da ontologia
Mudar a lista de stopwords muda a chave de toda entidade da base. É a mesma categoria demudança que trocar a ontologia no artigo 05: precisa de versão, precisa de reindex, e
precisa que as duas versões convivam até você medir a diferença. Guarde a lista em
arquivo ou banco, e versione junto do código que a lê.
5. Estágio 3: o fuzzy, e o limiar que ele exige
O estágio 3 aceita o "quase igual". Em Python isso é a
difflib, da biblioteca padrão, e ela
devolve um número entre 0 e 1: o quanto as duas strings se pareciam caractere a caractere.
O número não diz nada sozinho — o limiar diz. E o limiar não é um número universal: é uma
decisão sobre o seu acervo, que você mede e não deduz.
Duas decisões embutidas, e a segunda é a que quase todo mundo ignora.
A primeira é o valor de corte. Se for baixo demais, o fuzzy junta o que não é junto.
Se for alto demais, ele nunca entra e o custo do estágio 4 sobe para todo mundo.
A segunda é o tamanho da chave. String curta casa com string curta por acidente: duas
palavras de quatro letras podem ter um valor alto de semelhança e não ter nada a ver. Por
isso o limiar sobe quando a chave é curta.
# Continua do bloco anterior: o casamento tolerante, com o limiar explícito. LIMIAR_LONGO = 0.95 # chave com 6 caracteres ou mais LIMIAR_CURTO = 0.90 # chave curta: o número engana, e por isso o piso sobe def limiar_para(chave: str) -> float: """Piso de semelhança, em função do tamanho da chave. Em cima do piso: a regra de similaridade. Abaixo do piso: rua sem placa. """ return LIMIAR_CURTO if len(chave) < 6 else LIMIAR_LONGO def similaridade(a: str, b: str) -> float: """Semelhança caractere a caractere, entre 0 e 1. `autojunk=False` não é detalhe: com o padrão ligado, o `difflib` trata como lixo os caracteres que se repetem muito em string longa, e em nome de documento o que se repete é justamente a parte que identifica. """ return SequenceMatcher(None, a, b, autojunk=False).ratio() # O que o número faz com nomes de verdade: for a, b in [("sertralina", "sertralina"), ("sertralina", "sertrallina"), ("belo horizonte", "belo horzonte"), ("sertralina", "sertralina xr"), ("unidade", "unid")]: print(f" {a!r:20} x {b!r:20} = {similaridade(a, b):.3f} " f"(piso {limiar_para(min(a, b))})")
E tem a porta de saída. Quando o volume de comparações começa a pesar, a resposta é
trocar de biblioteca, não reescrever o algoritmo:
rapidfuzz faz a mesma comparação com a mesma
ideia, e troca SequenceMatcher por rotinas escritas em C++, com autojunk fora de
discussão e opções de pontuação que o difflib não tem. A regra continua a mesma: o que
muda é quem faz a conta, nunca o limiar que você escolheu.
6. Estágio 4: o embedding, permissivo e sempre revisável
O estágio 4 compara os vetores de significado — o embedding do artigo 03, a mesma
coisa que a busca vetorial usa para achar trecho parecido. A promessa dele é maior: ele
acha que "Cloridrato de sertralina" e "sertralina 50 mg" falam da mesma coisa mesmo sem
nenhuma palavra em comum. O custo é o mais alto dos quatro, e o erro é o mais silencioso,
porque similaridade de vetor é um número sem explanation nenhuma.
Este artigo roda offline, então o código abaixo usa uma aproximação por n-grama, que é
barata, determinística e não é semântica: ela reconhece sobreposição de letras, e só
isso. Serve para exercitar o caminho do código, do limiar e do registro do estágio. Para
a qualidade de verdade, troque pelo modelo do artigo 03.
# Continua do bloco anterior: o último estágio, com um vetor de mentira. DIMENSAO = 96 def indice_de(token: str, dim: int = DIMENSAO) -> int: """Bucket determinístico, para o vetor não mudar a cada execução. `hash()` de string é aleatório por processo no Python, e um vetor que muda a cada execução não serve nem para teste de regressão nem para relatório de auditoria. Somar os códigos dos caracteres é lento e determinístico, que é tudo que este exemplo pede. """ return sum(ord(c) for c in token) % dim def vetor_ngrama(texto: str, dim: int = DIMENSAO) -> list[float]: """Aproximação semântica barata: LetterHashing por n-grama. NÃO é embedding de modelo. É uma projeção de sobreposição de letras num vetor de tamanho fixo, e por isso só "entende" quando as strings já são parecidas. Está aqui para o código rodar sem chave de API. """ base = sem_acento_e_caixa(texto) alvo = [base] + [base[i:i + 3] for i in range(max(0, len(base) - 2))] vetor = [0.0] * dim for token in alvo: vetor[indice_de(token, dim)] += 1.0 norma = math.sqrt(sum(v * v for v in vetor)) or 1.0 return [v / norma for v in vetor] def cosseno(a: Sequence[float], b: Sequence[float]) -> float: """Vetores normalizados: o produto interno já é o cosseno.""" return sum(x * y for x, y in zip(a, b, strict=True)) # O quanto o vetor de n-grama reconhece o que os três primeiros estágios # perderam. Com um modelo de verdade os números mudam, e é por isso que o # limiar abaixo é calibrado e não chutado. for a, b in [("sertralina cloridrato", "sertralina"), ("unidade norte", "unidade sul")]: print(f" cosseno({a!r}, {b!r}) = {cosseno(vetor_ngrama(a), vetor_ngrama(b)):.3f}")
O limiar do estágio 4 é mais baixo que o do fuzzy, e essa asimetria é deliberada: a taxa
de acerto por unidade de tempo é melhor, então vale insistir mais. O que paga o preço é a
fila de revisão. Um match de estágio 4 entra no grafo sinalizado, e sinalizado quer
dizer que existe uma consulta que lista os nós com match de estágio 4 para você olhar.
7. A auditoria por estágio: por que o registro do estágio não é opcional
Todo match deste artigo carrega três coisas: o nó, o número e o estágio que o
produziu. O estágio parece burocracia e não é: é o que separa "erro de política" de
"erro de modelo", e é o único jeito de auditar um casamento permissivo depois.
A tabela do relatório é a que você olha antes de mexer em qualquer limiar:
# Continua do bloco anterior: a montagem do índice de nós e a resolução. from collections import Counter from enum import StrEnum from typing import Any, NamedTuple class Estagio(StrEnum): """De onde veio o casamento. Guardar isto é obrigatório.""" ALIAS = "alias" # 1 · id ou apelido exato CANONICO = "canonico" # 2 · chave normalizada FUZZY = "fuzzy" # 3 · semelhança de caracteres EMBEDDING = "embedding" # 4 · vetor de sentido NOVO = "novo" # não casou em lugar nenhum class No(NamedTuple): """O nó canônico. A chave é a forma canônica do nome, já normalizada.""" node_id: str kind: str chave: str nome: str class Resultado(NamedTuple): """O que o linker devolveu para uma menção.""" node_id: str estagio: Estagio similaridade: float class Ligador: """Os quatro estágios, na ordem, com o registro de cada um. Os limiares são parâmetros do construtor e não constantes de módulo: eles são a única coisa que você vai querer calibrar, e calibrar não pode exigir editar código no meio de um lote. """ def __init__(self, limiar_fuzzy: float = LIMIAR_LONGO, limiar_embedding: float = 0.60) -> None: self.limiar_fuzzy = limiar_fuzzy self.limiar_embedding = limiar_embedding self._nos: list[No] = [] # catálogo canônico self._por_forma: dict[str, No] = {} # forma bruta (caixa e acento) -> nó self._por_chave: dict[str, No] = {} # chave normalizada -> nó self._embeddings: list[list[float]] = [] # vetor de cada nó, na ordem def registrar(self, node_id: str, kind: str, nome: str, apelidos: Sequence[str] = ()) -> No: """Cria o nó canônico e indexa por forma, por chave e por vetor. São dois dicionários porque são duas perguntas diferentes: "o texto escreveu exatamente assim?" e "o texto escreveu de um jeito que normaliza para o mesmo nome?". Uma pergunta, um índice -- se as duas dividissem a mesma tabela, uma das duas viraria varredura. """ chave = chave_ou_revisao(nome)[0] no = No(node_id=node_id, kind=kind, chave=chave, nome=nome) self._nos.append(no) self._embeddings.append(vetor_ngrama(nome)) for forma in (nome, *apelidos): # A forma bruta entra com caixa e acento removidos, que é o que o # estágio 1 compara: sem isso, "CLORIDRATO DE SERTRALINA" nunca # acharia o apelido "Cloridrato de sertralina". self._por_forma.setdefault(sem_acento_e_caixa(forma), no) self._por_chave.setdefault(chave_ou_revisao(forma)[0], no) return no def resolver(self, nome: str, kind: str) -> Resultado | None: """Tenta os quatro estágios. O primeiro que casar, vence.""" # Estágio 1: a forma como veio, sem normalizar além de caixa e acento. bruto = sem_acento_e_caixa(nome) if bruto in self._por_forma: return Resultado(self._por_forma[bruto].node_id, Estagio.ALIAS, 1.0) # Estágio 2: a chave normalizada, comparada com a chave do nó. chave, _ = chave_ou_revisao(nome) if chave in self._por_chave: return Resultado(self._por_chave[chave].node_id, Estagio.CANONICO, 1.0) # Estágio 3: semelhança de caracteres contra todos os nós do mesmo tipo. melhor: tuple[float, No] | None = None for no in self._nos: if no.kind != kind or not no.chave: continue if chave and similaridade(chave, no.chave) >= limiar_para(chave): nota = similaridade(chave, no.chave) if melhor is None or nota > melhor[0]: melhor = (nota, no) if melhor is not None: return Resultado(melhor[1].node_id, Estagio.FUZZY, melhor[0]) # Estágio 4: vetor. Sempre o último, porque é o que mais erra. if chave: alvo = vetor_ngrama(chave) melhor = max(((cosseno(alvo, v), no) for v, no in zip(self._embeddings, self._nos, strict=True) if no.kind == kind), default=None) if melhor and melhor[0] >= self.limiar_embedding: return Resultado(melhor[1].node_id, Estagio.EMBEDDING, melhor[0]) return None
Os dois primeiros estágios são uma consulta de dicionário, e por isso não escalam com o
acervo: a lista de nós só entra nos estágios 3 e 4, que são os caros por natureza. Se um
dia esses dois virarem gargalo, o caminho não é afinar o limiar — é reduzir o número de
candidatos antes deles, com índice invertido por token, que é assunto do artigo 08.
Agora o exemplo completo, que é onde as três formas do começo finalmente se encontram:
# Continua do bloco anterior: o acervo de teste, rodando os quatro estágios. ligador = Ligador() ligador.registrar("n-1", "produto", "sertralina", apelidos=["Cloridrato de sertralina"]) ligador.registrar("n-2", "local", "Belo Horizonte") ligador.registrar("n-3", "documento", "Relatório Anual de Atividades 2024") ACERVO = [ ("Cloridrato de sertralina", "produto"), ("sertralina 50mg", "produto"), ("CLORIDRATO DE SERTRALINA", "produto"), ("Sertralina", "produto"), ("sertralina-cloridrato", "produto"), ("belo horzonte", "local"), ("Relatório Anual de Atividades 2021", "documento"), ("Ltda", "organizacao"), ] resultados: list[tuple[str, Resultado | None]] = [] for nome, tipo in ACERVO: resultados.append((nome, ligador.resolver(nome, tipo))) for nome, resultado in resultados: if resultado is None: print(f" {nome!r:38} -> NÃO RESOLVIDO (vai para a fila de revisão)") else: print(f" {nome!r:38} -> {resultado.node_id} via {resultado.estagio} " f"({resultado.similaridade:.2f})")
# Continua o bloco anterior: a conta por estágio, que é a auditoria. CONTAGEM: dict[str, int] = {} for _, resultado in resultados: estagio = str(resultado.estagio) if resultado else "nao_resolvido" CONTAGEM[estagio] = CONTAGEM.get(estagio, 0) + 1 total = sum(CONTAGEM.values()) print(f"menções por estágio ({total} no total):") for estagio, quantidade in sorted(CONTAGEM.items(), key=lambda item: -item[1]): print(f" {estagio:<14} {quantidade:>3} ({round(100 * quantidade / total)}%)")
Três leituras dessa tabela, e cada uma aponta uma conserto diferente:
-
Estágio 2 resolve pouco? O normalizador tem buraco, e o conserto é no pipeline
(passo 7 ausente, lista de stopwords errada), não no limiar do fuzzy. Aumentar o limiar
só empurra o problema para o vetor, onde ele vira irrecuperável.
-
Estágio 3 resolve muito? Ou a sua base tem muito erro de digitação — e então o
fuzzy está trabalhando — ou o limiar está frouxo e está juntando entidade que não é.
O relatório por tipo (seção 6 do artigo 05) separa as duas hipóteses.
-
Estágio 4 resolving? Você está pagando o vetor mais caro para resolver o que o
normalizador devia resolver. Vale a pena, desde que a fila de revisão exista e alguém
olhe.
Cinco linhas dessa saída merecem ser lidas com calma, porque cada uma é um tipo diferente
de coisa acontecendo:
-
"sertralina-cloridrato"casou por embedding. Os três primeiros estágios falharamporque a forma é diferente, e o vetor reconheceu o sobreposto. É a prova de que o
estágio 4 funciona, e também de que ele precisa de revisão: se aquele nó não fosse o
certo, nada no relatório apontaria o erro.
-
"Relatório Anual de Atividades 2021"casou com o nó de 2024. As duas chavesdiferem em um dígito no final, a semelhança passa do piso, e o casamento é errado. O nó
errado é silencioso, e o número de similaridade parece saudável.
-
"belo horzonte"casou por fuzzy, com um dígito faltando. É o fuzzy fazendo otrabalho pelo qual ele existe — e, ao lado da linha de cima, é a prova de que ele não
distingue erro de digitação de entidade diferente.
-
"Ltda"não resolveu. Virou chave vazia, o estágio 2 não achou, o fuzzy comparoustring vazia, e o
embeddingcomparou vetor vazio. O resultado éNone, que é aresposta certa: em vez de um match inventado, a menção vai para a fila.
-
As três primeiras formas de sertralina casaram no mesmo
n-1: duas pelo apelido, queo estágio 1 acha depois de tirar caixa e acento, e uma pela chave normalizada, porque o
passo 2 separou o "50mg" do nome. Uma entidade, três nós, resolvido.
O conserto para o caso do relatório não é subir o limiar do fuzzy — isso derruba
encontramento legítimo em toda a base. O conserto é específico: quando o nome contém dígito, o fuzzy não entra. Um ano, um número de contrato, um código de norma é
identificador, e identificador se casa por igualdade, nunca por semelhança.
⚠️ Similaridade alta não é prova de que são a mesma coisa
É por isso que todo resultado carrega o estágio. Um nó montado só de casamentos de
fuzzyeembeddingé um nó que ninguém revisou, e o relatório por estágio é o quete permite dizer isso em uma linha. Sem o estágio no registro, o único jeito de saber
é reler o acervo inteiro, e ninguém faz isso duas vezes.
8. Merge de nós: quando unificar é seguro
Os quatro estágios reduzem a multiplicidade na entrada. O que sobra é a multiplicidade já
gravada: nós que nasceram separados em execuções diferentes, antes de a normalização
existir, ou que são a mesma entidade com nomes que nenhum pipeline junta. Isso se resolve
com merge, e merge é a operação que cria conceito falso mais facilmente que qualquer
outra em RAG — porque a falha dele é silenciosa e estrutural.
A regra que evita quase todo problema cabe numa frase, e ela é assimétrica: merge por evidência, nunca por semelhança. Evidência é um dos três fatos abaixo, e só eles:
-
Os dois nomes têm a mesma chave canônica depois do normalizador. "Norte Energia S.A." e
"Norte Energia" são a mesma empresa, e a evidência é que o passo 3 do pipeline tirou o
sufixo dos dois.
-
Um nome é o outro mais um qualificador conhecido — filial, matriz, grupo. Um
qualificador que você não conhece não é qualificador: é outra entidade com nome
parecido.
-
Os dois nós compartilham dois ou mais vizinhos. Dois nós que aparecem sempre ao lado
das mesmas entidades, nos mesmos documentos, provavelmente são a mesma coisa — e
"provavelmente" é o que a auditoria do artigo 11 vai medir.
O que não é evidência: a semelhança do nome. "Unidade de Saúde Centro" e "Unidade
Centro" são nomes parecidos e conceitos diferentes; "Banco do Brasil" e "Banco do Brasil
S.A." são nomes parecidos e o mesmo conceito. A diferença entre os dois casos não está no
nome, está na evidência. Chamar o fuzzy para decidir merge é a forma mais rápida de criar
um nó que não existe e que nada vai revelar depois.
# Continua do bloco anterior: a decisão de merge, com o motivo da recusa. # Qualificadores que você conhece. O que não está aqui não é qualificador: # é outra entidade com nome parecido. O sufixo legal (passo 3 do pipeline) # já saiu da chave, então o que sobra aqui é prefixo de filial, matriz, # grupo ou holding. QUALIFICADORES = {"filial", "matriz", "grupo", "holding", "participacoes", "controlada", "controladora"} def e_qualificador_de(a: No, b: No) -> bool: """Um dos nomes é o outro mais um qualificador desta lista. "Sertralina" e "Cloridrato de sertralina" NÃO passam por aqui, e a recusa é deliberada: "cloridrato" não é qualificador de empresa. Quem quiser as duas como o mesmo nó registra uma como apelido da outra, no estágio 1, onde a decisão fica visível. """ for curto, longo in ((a, b), (b, a)): if not curto.chave or not longo.chave.endswith(curto.chave): continue resto = longo.chave[: -len(curto.chave)].strip() if resto and set(resto.split()) <= QUALIFICADORES: return True return False def pode_unificar(a: No, b: No, vizinhos: dict[str, set[str]]) -> tuple[bool, str]: """Diz se vale unificar, e sempre devolve o motivo. Devolver o motivo mesmo na recusa é o que permite gerar o relatório da seção 9 e o que impede a equipe de reimplementar a heurística por fora. """ if a.kind != b.kind: return False, "tipos diferentes não se unificam" if a.chave and a.chave == b.chave: return True, "chave canônica idêntica" if e_qualificador_de(a, b): return True, "um nome é o outro mais um qualificador conhecido" if len(vizinhos_comum(a, b, vizinhos)) >= 2: return True, "dois ou mais vizinhos em comum" return False, "sem evidência: nomes parecidos não são evidência" def vizinhos_comum(a: No, b: No, vizinhos: dict[str, set[str]]) -> set[str]: """Vizinhos que os dois nós compartilham. Função pura de propósito: ela depende do grafo inteiro, que só existe depois que todos os nós foram criados, e por isso não pertence a `No`. A regra é a mesma em qualquer lugar: função pura quando dá, método quando o estado é o objeto. """ return (vizinhos.get(a.node_id, set()) & vizinhos.get(b.node_id, set())) # Quatro casos e quatro respostas diferentes. A lista de vizinhos é a que o # artigo 07 vai construir; aqui ela é pequena e escrita à mão, porque o # ponto é a regra de decisão e não a origem dos dados. def no(node_id: str, kind: str, nome: str) -> No: """Atalho do demo: monta o nó com a chave que o pipeline produziria. Sem este atalho, o exemplo passaria o nome cru no lugar da chave e as regras de merge receberiam dados que o `registrar` nunca produziria. """ return No(node_id=node_id, kind=kind, chave=chave_ou_revisao(nome)[0], nome=nome) VIZINHOS: dict[str, set[str]] = { "n-1": {"n-2", "n-3", "n-7"}, "n-2": {"n-1", "n-3", "n-7"}, "n-3": {"n-1", "n-2", "n-7"}, "n-7": {"n-1", "n-2", "n-3"}, "n-4": {"n-2"}, } CANDIDATOS = [ # A: depois do normalizador, os dois nomes são a mesma chave. Unifica. (no("n-1", "produto", "sertralina"), no("n-8", "produto", "Sertralina Ltda")), # B: a mesma string, dois tipos. Não unifica: são duas coisas mesmo. (no("n-1", "produto", "sertralina"), no("n-9", "local", "Sertralina")), # C: nomes parecidos, nenhum vizinho em comum. Não unifica. (no("n-2", "local", "Belo Horizonte"), no("n-4", "local", "Unidade de Saúde Centro")), # D: nomes diferentes, dois vizinhos em comum. Unifica, como proposta. (no("n-3", "documento", "Relatório Anual"), no("n-7", "documento", "Relatório Anual de Atividades 2024")), ] for a, b in CANDIDATOS: unificar, motivo = pode_unificar(a, b, VIZINHOS) print(f" {a.nome:30} + {b.nome:34} -> " f"{'UNIFICAR' if unificar else 'manter separado'}: {motivo}")
💡 MergeReversível é merge aplicável
Grave a proposta com os dois ids e o motivo antes de aplicar, e mantenha as arestas do nóantigo apontando para o novo. Desfazer merge depois que alguém viu a resposta com o nó
unificado custa mais do que a decisão de unify levou.
9. A ordem para montar (e o número de cada passo)
-
Monte o catálogo canônico primeiro, com os nós e os apelidos que você já sabe. É o
estágio 1, e é o único que acerta sem ambiguidade.
-
Rode a normalização nas duas pontas e veja quantas chaves distintas o mesmo nome
produz. Este é o teste que revela se o pipeline está inteiro.
-
Meça o relatório por estágio antes de ligar o estágio 4. Se o estágio 3 já resolve
quase tudo, o estágio 4 entra para a fila de revisão e não para o caminho quente.
-
Ligue o fuzzy com limiar alto e por tipo. Limiar global é o que faz o fuzzy
parecer bom na média e ser péssimo num tipo específico.
-
Só então o embedding, sempre com a lista de match revisável ligada no mesmo dia.
-
Merge por evidência, com registro. Similaridade de nome nunca decide merge.
Se você parou aqui, o seu grafo tem um nó por entidade, e cada match sabe dizer como foi
produzido. O que ele não tem ainda é nenhuma aresta: os nós existem e não se relacionam.
Isso é o artigo 07.
TL;DR
-
Três formas da mesma entidade são três nós, e três nós matam grau, comunidade e
caminho — sem erro, só resposta vazia.
-
O nome do nó não vem do texto. Vem de uma forma canônica sua; a menção vira
referência a essa forma.
-
Quatro estágios, em ordem de custo: id/apelido, chave normalizada, fuzzy,
embedding. Os dois últimos aceitam erro em silêncio. -
A normalização pt-BR é o núcleo: caixa, acento, número colado em unidade, sufixo
legal, pontuação sem quebrar número, stopword, dose, plural — sempre nos dois lados.
-
Nome genérico é nome válido. Chave vazia não é chave: é
None, eNonevai para arevisão. É por isso que a stopword precisa de lista versionada e de piso de tamanho.
-
Todo match registra o estágio. Sem isso não há auditoria, e o erro do fuzzy e do
embeddingé invisível. -
Nome com dígito não casa por fuzzy. Ano, número de contrato e código de norma são
identificador: ou é igual, ou não é a mesma entidade.
Referências
- Entity linking — Wikipedia — o termo e o recorte de problema que ele resolve
- difflib — Python —
SequenceMatcher, a comparação caractere a caractere do estágio 3, e oautojunk - unicodedata — Python — a normalização NFKD que separa a letra do acento
- rapidfuzz — PyPI — a mesma comparação com outro custo, para quando o volume aperta