Entidades que viram conhecimento: NER canônico, Wikidata e a lente semântica¶
Até esta rodada, uma entidade no DGB era apenas texto: "Min. da Saúde", "Ministério da Saúde" e "MS" eram três coisas diferentes para a plataforma, strings soltas presas a uma notícia e a mais nada. Em dois dias (10 e 11 de junho de 2026), a plataforma trocou esse NER "bruto" por entidades canônicas — cada menção passou a resolver para um nó de conhecimento com identidade estável, tipo (organização, pessoa, lugar, e agora também Eventos e Políticas públicas) e, quando possível, um QID da Wikidata como chave de deduplicação linked-data. No caminho, a busca de "relacionadas" deixou de ser por código de tema e virou similaridade semântica (com um ganho medido de ~4,3s para ~180ms), a tela da notícia ganhou chips agrupados por tipo e uma lente semântica que marca entidades no corpo do texto.
De strings soltas a nós de conhecimento¶
O ponto de partida era um NER que devolvia texto e um depósito de lixo. Entidades vinham como cadeias de caracteres, variações da mesma coisa nunca se encontravam, e tudo que o modelo não sabia classificar caía num balde MISC que não servia para nada.
ANTES (NER bruto) DEPOIS (entidade canônica)
----------------- --------------------------
"Min. da Saúde" --+ +-- entity_registry
"Ministério da | strings | id: canônico estável
Saúde" | soltas, | tipo: ORG
"MS" --+ sem ligação --> | label: Ministério da Saúde
| wikidata: Q1519799
+-- (alias --> o mesmo nó)
A evolução aconteceu em duas frentes que fecharam juntas: o extrator + a pipeline de canonicalização na camada de ciência de dados, e a fundação canônica (banco, índice, API e portal) que transforma esses nós em navegação real para quem lê o portal.
O extrator NER evoluído e a pipeline de canonicalização¶
A primeira metade da história está em data-science#26 e data-platform#179.
O NER saiu da chamada LLM combinada e ganhou chamada Bedrock própria, com modelo configurável via NER_MODEL_ID. Junto veio uma nova taxonomia em português que acrescenta Eventos e Políticas públicas aos tipos clássicos (organização, pessoa, lugar) — e, decisivo, um bloco explícito de "NÃO é entidade" no prompt, que drena o depósito MISC na origem em vez de deixar ruído escorrer para o índice. Cada entidade passou a sair com forma_canonica e salience, e a resposta crua do modelo passou a ser gravada em news_llm_raw (objeto JSONB) — o que torna o corpus reprocessável sem re-chamar o Bedrock.
A canonicalização propriamente dita (canonicalization_job.py, canonicalization.py, wikidata_client.py) trabalha por forma distinta, numa cascata com desligamentos progressivos:
menção (forma distinta)
|
v
gazetteer (159 órgãos) -- hit --> canonical_id
| miss
v
Opus (CANON_MODEL_ID) -- forma canônica + tipo
|
v
Wikidata QID (dedup) -- gates de confiança
| + escalada contextual (PER / homônimos)
v
entity_registry <-- backfill de canonical_id nas menções
O QID da Wikidata não é enfeite: é a chave de deduplicação. Duas formas diferentes que resolvem para o mesmo QID são, por definição, a mesma entidade. Para pessoas e homônimos, a pipeline aplica gates de confiança e uma escalada contextual — o guard citado no PR distingue, por exemplo, a Saúde brasileira de uma homônima estrangeira antes de fundir os nós. Tudo isso é resumível (pode parar e retomar) e faz backfill do canonical_id nas menções já existentes.
A fundação de dados que segura essa canonicalização veio nas migrações 015-020 (data-platform#179):
entity_registry— a tabela canônica, já modelada Neo4j-ready (guarde esse detalhe para o próximo post);entity_alias— o resolver determinístico de aliases, semeado com 159 órgãos derivados da tabelaagencies;- índice GIN sobre a coluna canônica,
news_llm_raw(o cru reprocessável) eentity_registry_seen(o estado da canonicalização).
O que a revisão adversarial pegou
A rodada de revisão adversarial não foi decorativa: em data-platform#179 ela flagrou e corrigiu uma colisão de alias e um bug crítico de offsets em NFD (a normalização Unicode que faz a lente apontar para o caractere certo). Em data-science#26, 136 testes verdes com Bedrock e Wikidata mockados — sem chamadas reais, sem reprocessamento acidental. Em data-platform#179, 586 testes unitários verdes.
A fundação canônica no Typesense e a lente semântica¶
Com o registro canônico pronto no Postgres, faltava expor isso ao portal — e foi onde o índice de busca precisou aprender os tipos novos.
O indexer que perdia entidade¶
O Typesense ganhou os campos entity_event, entity_policy e entity_canonical, e o indexer passou a rotear EVENT/POLICY e a emitir o canonical_id (data-platform#179). No caminho, um bug silencioso foi corrigido: tudo que não fosse ORG/PER/LOC — justamente os novos Eventos e Políticas — caía num entity_misc e era perdido na indexação. A lente semântica, do lado do data-platform, ganhou o feature-worker compute_content_annotations, que calcula offsets determinísticos com um mapa folded -> original robusto a NFD e casefold, para nunca destacar a palavra errada no corpo.
Relacionadas por significado, não por código de tema¶
Na API, graphql-api#17 trouxe as features calculadas para dentro do Article e reescreveu as "notícias relacionadas". Antes, "relacionadas" era um OR entre códigos de tema; agora é similaridade semântica via pgvector. O detalhe de engenharia importa: a query get_similar_articles foi reescrita para usar o índice HNSW (ORDER BY content_embedding <=> $1::vector, com o embedding do artigo base como literal), eliminando um Seq Scan que varria ~333 mil linhas. Medido localmente, o salto foi de ~4,3s para ~180ms.
Para não onerar listas e busca, Article.features é um campo lazy: um DataLoader por unique_id que só toca o news_features (JSONB) quando o campo é efetivamente selecionado. A assinatura GraphQL de relatedArticles ficou inalterada — a troca de mecanismo por baixo passou pelo gate anti-drift sem quebrar o portal. A mesma PR adicionou filtros por entidade e sentimento, o argumento sort (RELEVANCE/DATE/TRENDING/VIEWS) e o resolver público entitySuggestions para typeahead. 446 testes verdes.
A camada canônica em si veio logo em seguida (graphql-api#18): EntityType.canonicalId + salience, um resolver entity(id): EntityNode que lê o entity_registry, o modo canônico do entitySuggestions (faceta entity_canonical), o filtro de busca entityCanonical e o ArticleFeatures.contentAnnotations — a lente. Todas as mudanças de SDL foram puramente aditivas/nullable, o que mantém o drift gate seguro. 470 testes verdes.
A ordem de deploy não é opcional
Como o portal compila contra um snapshot do SDL, o graphql-api tem de ser deployado antes do portal. Os dois PRs de API deixam isso explícito, e o filtro/typeahead de entidade só retorna dados depois do reindex do Typesense em produção — até lá, degrada para vazio em vez de quebrar.
Grupos Eventos/Políticas e a lente na tela da notícia¶
Do lado do portal, o trabalho veio em duas PRs. portal#264 montou a tela da notícia enriquecida (o componente ArticleFeatures, composto de ArticleFichaBar + ArticleEntities), os filtros por sentimento e entidade com ordenação na /busca, o EntityMultiSelect com typeahead alimentado por entitySuggestions e as primeiras páginas /entidades/[slug]. 484 testes verdes.
portal#265 fechou a camada canônica na interface:
- chips agrupados de Eventos e Políticas Públicas, com
lib/entity-types.tscomo fonte única de cor e ícone por tipo; - páginas
/entidades/[id-canonico]com header vindo deentity(id)— nome, tipo, descrição e link para a Wikidata — e a lista de notícias via filtroentityCanonical, com fallback gracioso para os slugs de texto legados; - a
SemanticLens: a lente que marca entidades inline no corpo da notícia.
A lente merece uma nota de projeto. Ela usa uma estratégia validate-then-split: antes de destacar, valida que o trecho no offset corresponde de fato à entidade, e só então divide o texto — a revisão adversarial confirmou que a lente é incapaz de destacar a palavra errada. Ela vem desligada por padrão, com um toggle discreto ("ghost") e a preferência persistida em localStorage, para não poluir a leitura de quem não a quer. 525 testes verdes.
| Antes | Depois | |
|---|---|---|
| Entidade | string solta na notícia | nó canônico com id estável |
| Variações ("MS", "Min. Saúde") | três entidades distintas | um mesmo nó (via entity_alias) |
| Tipos reconhecidos | ORG / PER / LOC (+ balde MISC) |
+ Eventos + Políticas; sem MISC |
| Identidade externa | nenhuma | QID da Wikidata (linked data) |
| Relacionadas | OR de códigos de tema |
similaridade semântica (HNSW) |
| Entidade no corpo do texto | texto puro | lente semântica (marcação inline) |
Ligando os modelos: as env vars do Bedrock¶
Nada disso liga sozinho. Os modelos que o NER e a canonicalização usam são configuráveis, e infra#198 fez essa fiação via Terraform: variáveis ner_model_id e canon_model_id, com NER_MODEL_ID indo para o enrichment-worker e CANON_MODEL_ID para o Composer (onde a futura DAG de canonicalização vai rodar, já com boto3).
A decisão de segurança aqui foi deixar os defaults no modelo atual (anthropic.claude-3-haiku-20240307-v1:0), de forma que o terraform plan fosse um no-op seguro — apenas adiciona as env vars, sem mudar comportamento. Ativar o upgrade (Sonnet 4.6 para o NER, Opus 4.8 para a canonicalização) é um passo separado e deliberado. Os IDs exatos dos inference-profiles não foram inventados: sem AWS CLI no ambiente, o PR documenta o padrão esperado (us.anthropic.claude-sonnet-4-6-<rev>-v1:0 etc.) e pede confirmação da string real habilitada na conta.
Falha graciosa por design
O enrichment-worker é resiliente: se o NER_MODEL_ID estiver inválido, o NER degrada (entidades vazias) sem derrubar tema, resumo e sentimento — porque a chamada combinada desses três usa outro modelo. Um ID errado custa entidades naquele lote, não a pipeline inteira.
O 403 da Wikidata (e o driver de re-NER)¶
O primeiro reprocessamento real, sobre uma fatia de um mês, quase entregou uma canonicalização sem a parte que a torna linked data. A Wikidata começou a devolver HTTP 403 em todas as requisições — nenhum QID era linkado, e a deduplicação por Wikidata simplesmente não acontecia. A causa: a Wikimedia bloqueia requisições sem um User-Agent descritivo.
O fix foi cirúrgico (data-science#28): o wikidata_client.py passou a enviar um WIKIDATA_USER_AGENT no httpx.Client. A verificação foi feita ao vivo — sem UA, 403; com UA, 200 — e até os primeiros QIDs saíram corretos:
Renan Calheiros -> Q3623584
Ministério da Saúde -> Q1519799
Bolsa Família -> Q575545
A mesma PR trouxe scripts/renew_ner_window.py, um driver de re-NER de janela resumível e capado (newest-first) que reusa exatamente o caminho do worker — a ferramenta que conduziu o reprocessamento do mês e o backfill gradual seguinte.
Números¶
| Métrica | Valor |
|---|---|
| Migrações de banco | 015-020 (entity_registry, entity_alias, ...) |
| Órgãos semeados no resolver de alias | 159 (a partir de agencies) |
| Ganho da busca de relacionadas (HNSW) | ~4,3s -> ~180ms (Seq Scan em ~333k linhas eliminado) |
| Testes (data-science#26 / data-platform#179) | 136 / 586 verdes |
| Testes (graphql-api #17 / #18) | 446 / 470 verdes |
| Testes (portal #264 / #265) | 484 / 525 verdes |
Lições¶
- A chave de deduplicação tem de vir de fora. Enquanto entidade era só texto, "MS" e "Ministério da Saúde" jamais se encontrariam. O QID da Wikidata dá um ancoradouro externo e estável — duas formas que resolvem para o mesmo QID são a mesma coisa, sem heurística frágil.
- Elimine o balde de lixo na origem. Um bloco explícito de "NÃO é entidade" no prompt drena o
MISCantes de ele virar ruído no índice — muito mais barato que filtrar depois. E oentity_miscque perdia Eventos e Políticas mostra que um balde também engole o que você passou a querer. - Guarde o cru, reprocesse à vontade. Gravar a resposta do modelo em
news_llm_raw(JSONB) desacoplou o reprocessamento das chamadas ao Bedrock — a taxonomia pode evoluir sem re-pagar inferência. - Integrações externas quebram em silêncio. O 403 da Wikidata não derrubava nada: só parava de linkar QIDs. Um User-Agent descritivo resolveu, mas só foi visto porque o reprocessamento real exercitou o caminho de ponta a ponta.
- Falhe gracioso, deploye em ordem. Env vars com default no modelo atual tornam o
planum no-op; NER inválido degrada sem derrubar tema/resumo/sentimento; e SDL aditivo/nullable com "API antes do portal" mantém o drift gate honesto.
Com o entity_registry no ar, cada entidade agora é um nó com identidade, tipo e, quando dá, um QID. Mas um nó sozinho é só metade da promessa: o valor real está nas arestas — quem aparece com quem, quais órgãos e políticas se cruzam, como esses vínculos evoluem no tempo. Não por acaso a tabela nasceu Neo4j-ready. O próximo post da série conta como esses nós viraram um grafo de verdade — e o backfill retroativo que trouxe o passado do gov.br para dentro dele.
Série A evolução da plataforma DGB (jun–jul 2026) · post 1 de 4. Próximo post: O grafo do gov.br: entidades em Neo4j e o backfill retroativo.