
Por Stamatios Stamou Jr·O grafo por trás do Em 3 min
O Em 3 min já tem mais de 800 edições. Até semana passada, cada uma nascia e morria sozinha: você lia sobre a OpenAI mudando de assistência para execução e nunca chegava no time de 3 pessoas que envia centenas de PRs com agentes - embora os dois textos contassem a mesma história por ângulos diferentes.
As tags não resolviam. "Dev & Engenharia" juntava o time de 3 pessoas com a migração TypeScript do Yelp. "Trabalho & Gestão" juntava o engenheiro mid-level com o operário indiano que filma a própria rotina para treinar robô. Tag é canal de feed, não é conexão.
Três perguntas
A solução foi tratar cada notícia como nó de um grafo e responder três perguntas sobre ela:
É o mesmo fato? A marca d'água do Claude no dia 12 e no dia 13 é a mesma história andando. Isso vira uma story_key - um identificador curto do fato.
De quem é? Anthropic, Claude, OpenAI, Grok. Isso vira entidade.
Sobre o quê? Isso vira tema, de uma lista fechada de 27 slugs (ia-no-trabalho, agentes-de-codigo, chips-infra...). Lista fechada é decisão, não preguiça: sem ela, "IA no trabalho", "futuro do emprego" e "mid-level" nunca se juntam.
O detalhe que mais gosto: isso não custa uma chamada a mais. O modelo já está lendo o artigo para escrever o resumo; o mesmo JSON que devolve o resumo agora devolve entidades, temas e story_key. Quando a fonte falha (um post do X que não abre), o card fica sem classificação e fora do grafo. Sem inventar.
Vetor escolhe, rerank decide
Com os metadados na mão, falta o motor. Ele tem dois estágios, e a analogia é com busca: primeiro você recupera candidatos, depois você ordena.
Embeddings. O título e o resumo de cada card viram um vetor de 2.048 números. Dois textos sobre o mesmo assunto ficam "perto" nesse espaço, mesmo sem nenhuma palavra em comum. Isso cobre o caso que os metadados não pegam: dois cards do mesmo fato com story_key divergente.
Rerank. Vetor é bom para escolher candidatos, ruim para ordenar. Então os candidatos passam por um reranker - outro modelo que olha o par (notícia A, notícia B) e diz qual par é mais relacionado. É o mesmo desenho que o Google usa: recall no primeiro estágio, precisão no segundo.
Os dois modelos são os Nemotron da NVIDIA, via OpenRouter. No fim, cada card ganha até 4 arestas com tipo: continua esta história, mesma entidade ou mesmo tema. A aresta vira o bloco "Relacionados" no fim de cada matéria - e o mesmo dado alimenta o Grafo das notícias, onde dá para navegar o mapa inteiro em 3D.
Por que SQLite
Os vetores e os pares de rerank ficam num arquivo SQLite local, o graph.db. Três razões:
Custo. Vetorizar 800 cards de novo a cada run seria pagar a mesma conta todo dia. Com cache, o run diário só processa os ~40 cards novos.
Robustez. O arquivo é dado derivado: pode apagar que o pipeline reconstrói. Se a API cai no meio, o run aborta antes de gravar e tenta de novo no dia seguinte - nada de grafo degradado silenciosamente.
Simplicidade. Site estático não pode ter dependência viva. Banco vetorial em produção, Docker, serviço a mais para cair: tudo isso ficou de fora por escolha. O Qdrant que uso em outro projeto não veio para cá porque aqui ele não agrega - 800 vetores cabem num arquivo, e o HTML publicado não fala com banco nenhum. Se um dia forem 15 mil cards, o desenho continua igual: a conta acontece no build, nunca no request.
O que mudou na prática
Hoje 98% dos cards têm pelo menos um relacionado. São 2.800 arestas ligando 721 matérias - e as 230 entidades passaram por curadoria manual, porque modelo inventa marca e escreve "xAI" de cinco jeitos diferentes.

O grafo também mudou como eu leio o próprio feed. Histórias que eu achava que eram ponto isolado - chips, regulação, agentes - formam cachos densos quando você vê 800 edições de uma vez. Vale abrir o Grafo das notícias, passar o mouse pelos nós e clicar no que chamou atenção.
E se você chegou aqui sem conhecer o pipeline: como o Em 3 min é feito.