Linkagem semântica por embeddings em 5 propriedades Sanar
Arquitetura que conecta CID-10, Exames, Ferramentas médicas, Blog e SanarPós via embeddings vetoriais. Cada página descobre suas vizinhas semânticas automaticamente, o usuário fica no ecossistema e o funil converge pros cursos pagos.
Contexto
O que é: uma camada de linkagem semântica entre 5 propriedades Sanar, CID-10, Exames, Ferramentas médicas, Blog e SanarPós, via embeddings vetoriais. Cada página descobre automaticamente suas vizinhas semânticas (sem depender de tag ou taxonomia manual) e o funil de navegação converge para o catálogo de cursos pagos em pos.sanar.com.br.
Contexto: 5 propriedades com SEO individual excelente vivendo em silos. Um artigo sobre hiperplasia adrenal congênita no blog não linkava pro CID E25, nem pro exame 17-OH progesterona, nem pra calculadora pediátrica, nem pro curso de Endocrinologia. Era a quíntupla óbvia daquele usuário, e ninguém ligava os pontos.
Problema
Linkagem manual é inviável em escala. O Sanar publica novos posts, exames, códigos CID e cursos toda semana sem coordenação entre times. Matching por keyword é superficial: um artigo sobre "insulina basal" deveria linkar pra "hemoglobina glicada", mas o matcher de tag não enxerga isso. Resultado: o usuário entrava via busca orgânica, lia uma página, saía.
Além disso, cada property fazia SEO para si. Ninguém passava link juice de forma sistemática pros cursos, a camada que realmente monetiza.
Abordagem
A ideia central: transformar cada URL em um vetor que representa o significado semântico da página (não só as palavras). Vetores próximos = páginas que tratam de conceitos próximos, mesmo sem palavra em comum. Na renderização, o engine busca os top-K vizinhos mais próximos e diversifica por tipo de propriedade, 1 CID + 1 Exame + 1 Ferramenta + 1 Curso, para que o bloco "Relacionados" nunca caia em câmara de eco.
Um segundo passo detecta entidades médicas no corpo do artigo via NER e injeta backlinks contextuais automaticamente: toda menção a um nome de exame vira link pra página canônica em /exames/, todo código CID vira link pra /cid10/, etc. Isso é o que fecha o anel de autoridade tópica.
Execução
Ingest: crawler Python coleta title + body + meta de todas as 5 properties, Blog (~2,5k artigos), Exames (114 páginas), CID-10 (~12k códigos), Ferramentas (calculadoras) e SanarPós (20+ cursos). Chunking de 2k tokens por documento, embedding via API multilingual (1024 dimensões).
Store: pgvector via Supabase. Index HNSW para busca sub-100ms em ~20k vetores. Metadata indexada separadamente para filtro por property_type e published_at.
Runtime no WordPress: shortcode [sp_related diversify=true limit=4] consulta a similarity API e renderiza o bloco. Middleware em the_content escaneia o HTML e converte termos médicos em links contextuais usando um léxico canônico mantido pelo time editorial.
final class Sp_Related_Shortcode {
public function render( array $atts ): string {
$atts = shortcode_atts( [
'limit' => 4,
'diversify' => 'true',
], $atts );
$post_id = get_the_ID();
$key = 'sp_related_' . $post_id . '_' . md5( serialize( $atts ) );
$cached = get_transient( $key );
if ( $cached !== false ) {
return $cached;
}
$response = wp_remote_post( self::API . '/similar', [
'timeout' => 8,
'body' => wp_json_encode( [
'url' => get_permalink( $post_id ),
'k' => (int) $atts['limit'],
'diversify' => $atts['diversify'] === 'true',
] ),
] );
if ( is_wp_error( $response ) ) {
return '';
}
$items = json_decode( wp_remote_retrieve_body( $response ), true ) ?: [];
$html = $this->render_items( $items );
set_transient( $key, $html, HOUR_IN_SECONDS );
return $html;
}
}from pgvector.psycopg import register_vector
def embed_and_index(doc: dict) -> None:
'''Embed a single document and upsert into pgvector.'''
text = f"{doc['title']}\n\n{doc['body'][:8000]}"
vector = embedding_client.embed(text) # 1024-dim
with conn.cursor() as cur:
cur.execute(
'''
INSERT INTO documents (url, title, property_type, published_at, embedding)
VALUES (%s, %s, %s, %s, %s)
ON CONFLICT (url) DO UPDATE SET
title = EXCLUDED.title,
embedding = EXCLUDED.embedding,
updated_at = NOW()
''',
(doc['url'], doc['title'], doc['property_type'],
doc['published_at'], vector),
)
def similar(url: str, k: int = 4, diversify: bool = True) -> list[dict]:
with conn.cursor() as cur:
cur.execute('SELECT embedding FROM documents WHERE url = %s', (url,))
source = cur.fetchone()
if not source:
return []
query = '''
SELECT url, title, property_type,
1 - (embedding <=> %s) AS similarity
FROM documents
WHERE url != %s
ORDER BY embedding <=> %s ASC
LIMIT %s
'''
cur.execute(query, (source[0], url, source[0], k * 4))
rows = cur.fetchall()
return _diversify_by_property(rows, k) if diversify else rows[:k]Resultados
A história que os números contam: o usuário que entrava no blog pelo Google para ler sobre hiperplasia adrenal congênita agora termina a sessão com quatro páginas vistas, um código CID pra referência, um exame laboratorial pra diagnóstico, uma calculadora pediátrica pra dose de corticoide e, no fim, uma página de curso de Endocrinologia Pediátrica no SanarPós. Ele não foi empurrado pro curso; ele chegou lá seguindo o próprio interesse, porque cada página tinha a vizinha semântica certa.
Output reutilizável
O engine é agnóstico de CMS, o contrato é {url, title, body, property_type, published_at}. Reaproveitável em qualquer ecossistema com múltiplas propriedades (e-commerce multi-marca, editora com várias revistas, SaaS com docs + blog + pricing).
Artefatos reutilizáveis: pipeline de ingest (Python), shortcode WordPress com cache, léxico de entidades médicas para NER, playbook de diversificação por tipo de propriedade, queries pgvector otimizadas com HNSW.