No artigo anterior, mostrei a arquitetura completa do Decodifica.Tech — as 7 etapas da pipeline, da coleta RSS até a publicação no Hugo. Agora vou mergulhar fundo em uma das etapas mais interessantes: como agrupamos centenas de artigos por dia em histórias únicas.

Este é o artigo 3 de 8 na série sobre os bastidores do projeto.

O problema: muitas fontes, mesma notícia

O Decodifica.Tech coleta de 242 fontes RSS diferentes. Num dia típico, isso gera centenas de artigos. Mas aqui está o ponto: quando a Microsoft anuncia uma novidade no Azure, ou o Kubernetes lança uma versão nova, 15 ou mais outlets publicam sobre a mesma história.

Se simplesmente listássemos tudo, o leitor veria a mesma notícia repetida dezenas de vezes. A experiência seria péssima. Precisamos de algo que olhe para todos esses artigos e diga: “esses 12 artigos falam da mesma coisa — vou agrupá-los e escolher o melhor representante”.

É exatamente isso que o módulo cluster.py faz. E ele resolve o problema usando três técnicas clássicas de NLP e ciência da computação: TF-IDF, similaridade de cosseno e Union-Find.

TF-IDF: transformando texto em números

Antes de comparar artigos, precisamos transformar texto em algo que o computador consiga medir. É aí que entra o TF-IDF (Term Frequency – Inverse Document Frequency).

A intuição é simples:

  • TF (Term Frequency): quanto mais uma palavra aparece num documento, mais relevante ela é para aquele documento.
  • IDF (Inverse Document Frequency): se uma palavra aparece em todos os documentos (como “o”, “de”, “que”), ela tem pouco poder discriminativo. O IDF penaliza essas palavras comuns.

O resultado é um vetor numérico para cada artigo, onde cada dimensão representa um termo do vocabulário, e o valor indica a importância daquele termo naquele documento específico em relação ao corpus todo.

Exemplo prático: imagine três artigos. Se o termo “kubernetes” aparece em dois deles mas não no terceiro, ele terá um peso TF-IDF alto nos dois primeiros — sinalizando que eles podem ser sobre o mesmo assunto.

No código real, usamos o TfidfVectorizer do scikit-learn:

vectorizer = TfidfVectorizer(
    max_features=5000,
    stop_words="english",
    ngram_range=(1, 2),
)
tfidf_matrix = vectorizer.fit_transform(texts)

Algumas decisões importantes aqui:

  • max_features=5000: limitamos o vocabulário a 5.000 termos. Isso evita que o vetor fique gigantesco (e lento) com termos raríssimos que aparecem uma única vez. Na prática, 5.000 features capturam bem o vocabulário de notícias tech.
  • stop_words="english": removemos palavras comuns em inglês (as fontes são majoritariamente em inglês). Palavras como “the”, “is”, “and” não ajudam a distinguir histórias.
  • ngram_range=(1, 2): além de palavras individuais (unigrams), capturamos bigrams — pares de palavras consecutivas. Isso é crucial! “machine learning” como bigram é muito mais informativo do que “machine” e “learning” separados. “Kubernetes 1.32” como bigram identifica precisamente a versão.

O input para o vectorizer é a concatenação de título + resumo de cada artigo:

texts = [f"{a['title']} {a.get('summary', '')}" for a in articles]

Similaridade de cosseno: medindo a “distância” entre artigos

Agora temos um vetor numérico para cada artigo. Como medimos se dois artigos são similares?

A similaridade de cosseno mede o ângulo entre dois vetores no espaço multidimensional. A intuição geométrica é elegante:

  • Se dois vetores apontam na mesma direção (ângulo 0°), a similaridade é 1.0 — os documentos são idênticos em termos de vocabulário.
  • Se são perpendiculares (ângulo 90°), a similaridade é 0.0 — não compartilham nenhum termo relevante.
  • Quanto menor o ângulo, mais similares os documentos.

A fórmula é: cos(θ) = (A · B) / (||A|| × ||B||) — o produto escalar dividido pelo produto das magnitudes.

A beleza do cosseno é que ele ignora o comprimento dos vetores. Um artigo com 500 palavras e outro com 2.000 palavras sobre o mesmo assunto terão alta similaridade, porque o cosseno olha a direção, não a magnitude.

No código:

sim_matrix = cosine_similarity(tfidf_matrix)

Isso gera uma matriz N×N onde sim_matrix[i][j] é a similaridade entre o artigo i e o artigo j. Valores vão de 0 a 1.

Union-Find: agrupamento transitivo com path compression

Temos a matriz de similaridade. Agora precisamos agrupar artigos. Mas por que não simplesmente “se A é similar a B, coloca no mesmo grupo”?

Porque a similaridade é transitiva na prática: se o artigo A é similar ao B, e o B é similar ao C, então A, B e C falam da mesma história — mesmo que A e C não sejam diretamente similares entre si (talvez usem vocabulário diferente, mas B serve como “ponte”).

O Union-Find (ou Disjoint Set Union) é a estrutura de dados perfeita para isso. Ele mantém conjuntos disjuntos e suporta duas operações eficientes:

  • Find(x): encontra o representante (raiz) do conjunto de x.
  • Union(a, b): une os conjuntos de a e b.
n = len(articles)
parent = list(range(n))

def find(x):
    while parent[x] != x:
        parent[x] = parent[parent[x]]  # path compression
        x = parent[x]
    return x

def union(a, b):
    ra, rb = find(a), find(b)
    if ra != rb:
        parent[ra] = rb

O detalhe sutil é o path compression na linha parent[x] = parent[parent[x]]. Sem isso, a árvore pode ficar desbalanceada e o find fica O(n). Com path compression, as operações ficam praticamente O(1) amortizado.

O loop principal aplica o threshold:

for i in range(n):
    for j in range(i + 1, n):
        if sim_matrix[i][j] >= SIMILARITY_THRESHOLD:
            union(i, j)

O threshold mágico: 0.35

SIMILARITY_THRESHOLD = 0.35

Como cheguei nesse valor? Experimentação. Com textos curtos (título + resumo), a similaridade TF-IDF tende a ser mais baixa do que com textos completos. Testei com dados reais:

  • 0.25: agressivo demais — agrupava artigos vagamente relacionados (ex: dois artigos sobre Azure, mas um sobre networking e outro sobre AI).
  • 0.50: conservador demais — deixava artigos sobre a mesma história em clusters separados quando usavam vocabulário diferente.
  • 0.35: sweet spot — captura artigos sobre a mesma notícia mesmo com variações de linguagem, mas não agrupa temas apenas “parecidos”.

Escolhendo o melhor representante

Depois de agrupar, precisamos escolher um artigo para representar cada cluster. Não queremos qualquer um — queremos o melhor:

best = sorted(
    cluster_articles_list,
    key=lambda a: (a.get("tier", 2), -len(a.get("summary", ""))),
)[0]

A lógica é um sort com dois critérios:

  1. Tier (prioridade da fonte): fontes tier 1 (como The Verge, Ars Technica) são preferidas sobre tier 2.
  2. Comprimento do resumo (desempate): entre artigos do mesmo tier, preferimos o que tem resumo mais longo — geralmente indica cobertura mais completa.

O resultado final inclui a contagem de cobertura (coverage_count) — quantas fontes cobriram aquela história — e a lista de todas as fontes:

story = {
    "id": best["id"],
    "title": best["title"],
    "coverage_count": len(cluster_articles_list),
    "sources": [
        {"name": a["source_name"], "url": a["url"]}
        for a in cluster_articles_list
    ],
}

Exemplo prático: Kubernetes 1.32

Imagine que três artigos chegam no pipeline:

#FonteTítulo
1The New Stack“Kubernetes 1.32 Brings Native Sidecar Containers to GA”
2InfoQ“Kubernetes v1.32 Released with Sidecar Containers and Dynamic Resource Allocation”
3DevOps.com“What’s New in Kubernetes 1.32: Sidecars, DRA, and More”

Após TF-IDF com bigrams, termos como “kubernetes 1.32”, “sidecar containers” terão alto peso nos três artigos. A similaridade de cosseno entre eles ficará acima de 0.35:

  • sim(1,2) ≈ 0.62
  • sim(1,3) ≈ 0.51
  • sim(2,3) ≈ 0.48

Union-Find une todos no mesmo cluster. O artigo 1 (The New Stack, tier 1, resumo mais longo) é escolhido como representante. O coverage_count fica 3 e as três fontes aparecem na lista de cobertura.

O leitor vê uma história com a indicação “coberta por 3 fontes” — em vez de três entradas repetidas.

Limitações e possíveis melhorias

O approach com TF-IDF tem limitações conhecidas:

  • Dependência lexical: TF-IDF compara palavras exatas. Se um artigo diz “ML” e outro “machine learning”, o bigram não vai casar. Embeddings semânticos (como os do OpenAI ou sentence-transformers) resolveriam isso, capturando similaridade de significado.
  • Complexidade O(n²): a matriz de similaridade cresce quadraticamente. Com ~300 artigos/dia, isso é trivial (~45.000 comparações). Mas se escalássemos para 10.000 artigos, precisaríamos de algo como LSH (Locality-Sensitive Hashing).
  • Threshold fixo: um único threshold para todas as categorias não é ideal. Artigos sobre “AI” têm vocabulário mais diverso que artigos sobre “security patches” — talvez thresholds por categoria fossem melhores.

Na prática, para o volume do Decodifica.Tech (~200-400 artigos/dia), TF-IDF funciona muito bem: é rápido (< 1 segundo), não precisa de API externa, não tem custo, e a qualidade de agrupamento é excelente para textos jornalísticos (que tendem a usar vocabulário consistente para a mesma notícia).

O que vem a seguir

No próximo artigo, vou mostrar como transformamos as histórias agrupadas em um podcast diário com vozes neurais — usando Azure AI Speech para gerar áudio natural em português com múltiplos locutores. Spoiler: envolve SSML, prosódia customizada e um custo surpreendentemente baixo.


Curtiu o deep dive? Tem perguntas sobre a implementação ou sugestões de melhoria? Deixe um comentário abaixo!

O código completo do Decodifica.Tech é open source: github.com/ricmmartins/decodifica.tech. O módulo de clusterização está em pipeline/cluster.py.