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:
- Tier (prioridade da fonte): fontes tier 1 (como The Verge, Ars Technica) são preferidas sobre tier 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:
| # | Fonte | Título |
|---|---|---|
| 1 | The New Stack | “Kubernetes 1.32 Brings Native Sidecar Containers to GA” |
| 2 | InfoQ | “Kubernetes v1.32 Released with Sidecar Containers and Dynamic Resource Allocation” |
| 3 | DevOps.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.
💬 Comentários