Content Features para IA/ML
Status: ✅ Implementado
Data: Dezembro 2024
Versão: 1.0
Visão Geral
Sistema de normalização e extração de features de conteúdos (rádios, podcasts, episódios, programas) preparado para machine learning e sistemas de recomendação. Todos os metadados são normalizados para padrões ISO e estruturados de forma consistente para facilitar treino de modelos de IA.
Objetivos
- Normalização de Metadados: Converter categorias/tags diversas em gêneros padronizados
- Extração de Features: Keywords, idiomas, países seguindo padrões ISO
- Preparação para ML: Estrutura pronta para embeddings de texto e áudio
- Performance: Índices otimizados para queries de recomendação
- Escalabilidade: Suporte para milhões de documentos com paginação
Arquitetura
Schema: ContentFeatures
Localização (source of truth): content-features/src/lib/schemas/content-features.schema.ts
Nota: no API, os ficheiros em apps/api/src/app/common/*/content-features.* são shims (re-exports) para @audissey/content-features.
{
// Identificação
contentType: 'radio' | 'podcast' | 'episode' | 'program',
contentId: ObjectId, // Referência ao documento original
// Metadados Normalizados
title: string,
description: string,
genres: NormalizedGenre[], // gêneros padronizados
primaryGenre?: NormalizedGenre,
language: string, // ISO 639-1 (pt, en, es, etc.)
country: string, // ISO 3166-1 alpha-2 (PT, BR, US, etc.)
// Features Temporais
duration: number, // segundos
publicationDate: Date,
broadcastTime: string, // "09:00", "21:30"
broadcastDays: string[], // ["monday", "tuesday", ...]
// Features de Popularidade
popularity: number,
playsTotal: number,
listenTimeMsTotal: number,
favoritesCount: number,
viewCount: number,
listenCount: number,
averageRating: number,
ratingCount: number,
// Relacionamentos
parentId: ObjectId, // Rádio/Podcast pai
parentType: ContentType,
// Features para ML
embeddings: {
text: number[], // Text embeddings (futuro)
audio: number[], // Audio embeddings (futuro)
lastUpdated: Date
},
// Search & Discovery
tags: string[],
keywords: string[], // Extraídas automaticamente
contentUrl: string,
imageUrl: string
// Estado / metadados técnicos
isActive: boolean,
lastVerified?: Date,
metadata?: Record<string, unknown>
}
Gêneros Normalizados
25+ categorias cobrindo os principais tipos de conteúdo:
// Ver enum completo em `@audissey/content-features`
// Exemplos:
// - NEWS = 'news'
// - MUSIC_POP = 'pop'
// - MUSIC_ROCK = 'rock'
// - MUSIC_ELECTRONIC = 'electronic'
// - SPORTS = 'sports'
// - SOCIETY = 'society'
// - OTHER = 'other'
Serviços
ContentNormalizationService
Responsabilidade: Normalizar metadados diversos para padrões consistentes.
Localização (source of truth): content-features/src/lib/services/content-normalization.service.ts
Funcionalidades
Normalização de Gêneros (
normalizeGenres)- Mapeia 100+ variações PT/EN para gêneros padronizados
- Exemplos:
- "notícias", "news", "informação" →
news - "música pop", "pop music" →
pop - "desporto", "sports", "esportes" →
sports
- "notícias", "news", "informação" →
Normalização de Idiomas (
normalizeLanguage)- Converte para ISO 639-1
- "portuguese", "português" →
pt - "english", "inglês" →
en - "spanish", "español" →
es
Normalização de Países (
normalizeCountry)- Converte para ISO 3166-1 alpha-2
- "Portugal", "portuguese" →
PT - "Brasil", "Brazil" →
BR - "United States", "USA" →
US
Extração de Keywords (
extractKeywords)- Remove stopwords (PT/EN)
- Filtra palavras curtas (< 3 chars)
- Limita a 20 keywords mais relevantes
- Converte para lowercase
Normalização de Horários (
normalizeBroadcastTime)- Converte para formato "HH:MM"
- Valida horários (00:00 - 23:59)
Normalização de Dias (
normalizeBroadcastDays)
- Array de strings (ex.: "monday", "tuesday", ...)
- Mapeia dias da semana PT/EN
ContentFeaturesService
Responsabilidade: CRUD de features normalizadas.
Localização (source of truth): content-features/src/lib/services/content-features.service.ts
Métodos Principais
// Upsert por tipo de conteúdo
upsertRadioFeatures(data: RadioFeaturesData): Promise<ContentFeaturesDocument>
upsertPodcastFeatures(data: PodcastFeaturesData): Promise<ContentFeaturesDocument>
upsertEpisodeFeatures(data: EpisodeFeaturesData): Promise<ContentFeaturesDocument>
upsertProgramFeatures(data: ProgramFeaturesData): Promise<ContentFeaturesDocument>
// Busca e filtros
findFeatures(filters: ContentFeaturesFilters): Promise<ContentFeaturesDocument[]>
// Atualização de embeddings (futuro)
updateEmbeddings(contentType: ContentType, contentId: string, embeddings: object): Promise<void>
// Incremento de métricas
incrementViewCount(contentType: ContentType, contentId: string): Promise<void>
incrementListenCount(contentType: ContentType, contentId: string): Promise<void>
// Estatísticas agregadas
getStatistics(): Promise<ContentFeaturesStats>
Endpoints Admin
Base URL: /api/admin/content-features
Autenticação: JWT Bearer Token (obrigatório)
1. Estatísticas Gerais
GET /api/admin/content-features/stats
Authorization: Bearer <token>
Resposta:
{
"total": 1000,
"byType": {
"episode": 1000,
"podcast": 50,
"radio": 25
},
"byGenre": {
"news": 300,
"talk": 250,
"music": 150
},
"byLanguage": {
"pt": 600,
"en": 400
},
"byCountry": {
"PT": 500,
"BR": 300,
"US": 200
}
}
2. Busca com Filtros
GET /api/admin/content-features/search?contentType=episode&genres=news&language=pt&limit=10
Authorization: Bearer <token>
Query Parameters:
contentType:radio|podcast|episode|programgenres: Genre normalizado (pode repetir para múltiplos)language: Código ISO 639-1country: Código ISO 3166-1 alpha-2parentId: ID do parent (rádio/podcast)isActive:true|falselimit: Número de resultados (default: 10)
Resposta: Array de ContentFeaturesDocument
3. Detalhes de Conteúdo Específico
GET /api/admin/content-features/:contentType/:contentId
Authorization: Bearer <token>
Exemplo:
GET /api/admin/content-features/episode/694afaeb7948ae57c35bcf87
Resposta: ContentFeaturesDocument completo
Migration Script
Execução
npm run migrate:content-features
O que faz:
- Conecta à base de dados
- Busca todos os conteúdos ativos (rádios, podcasts, episódios)
- Para cada documento:
- Extrai metadados originais
- Normaliza gêneros, idiomas, países
- Extrai keywords do título/descrição
- Cria/atualiza documento em
content_features
- Exibe estatísticas: sucesso/erros por tipo
Saída Exemplo:
🚀 Starting ContentFeatures migration...
================================================
📻 Processing Radios...
Processed 50/100 radios...
✅ Radios completed: 100/100
🎙️ Processing Podcasts...
Processed 50/200 podcasts...
✅ Podcasts completed: 200/200
🎧 Processing Episodes...
Processed 500/1000 episodes...
✅ Episodes completed: 1000/1000
================================================
📊 MIGRATION STATISTICS
================================================
📻 Radios: 100/100 (0 errors)
🎙️ Podcasts: 200/200 (0 errors)
🎧 Episodes: 1000/1000 (0 errors)
================================================
✅ Total Success: 1300
✨ Migration completed successfully!
Configuração
Localização: apps/api/src/scripts/migrate-content-features.ts
Batch Size:
- Rádios: Todos
- Podcasts: Todos
- Episódios: 1000 (limitado para não sobrecarregar)
Progresso:
- Rádios: Log a cada 50
- Podcasts: Log a cada 50
- Episódios: Log a cada 100
Índices MongoDB
Índices otimizados para queries de recomendação:
// Índice único composto (previne duplicados)
{ contentType: 1, contentId: 1 } // unique: true
// Índices para filtros
{ contentType: 1, genres: 1 }
{ contentType: 1, language: 1, country: 1 }
{ parentId: 1, parentType: 1 }
{ isActive: 1, contentType: 1 }
// Índices para search
{ title: 1 }
{ keywords: 1 }
Uso em Produção
1. População Inicial
# Executar migration para popular features de conteúdos existentes
npm run migrate:content-features
2. Atualização Contínua
Integrar nos processors existentes:
// Em sync-podcasts.processor.ts
async processSyncPodcasts() {
// ... sincronizar podcasts ...
// Atualizar features
for (const podcast of newPodcasts) {
await this.contentFeaturesService.upsertPodcastFeatures({
_id: podcast._id,
title: podcast.title,
description: podcast.description,
categories: podcast.categories,
language: podcast.language,
// ...
});
}
}
3. Sistema de Recomendação
// Exemplo: Recomendar episódios similares
async getRecommendations(episodeId: string, limit = 10) {
// Buscar features do episódio atual
const currentFeatures = await this.contentFeaturesService.findFeatures({
contentType: 'episode',
contentId: episodeId
});
// Buscar episódios com gêneros similares
const similar = await this.contentFeaturesService.findFeatures({
contentType: 'episode',
genres: currentFeatures[0].genres,
language: currentFeatures[0].language,
isActive: true
});
// Ordenar por popularidade
return similar
.sort((a, b) => b.listenCount - a.listenCount)
.slice(0, limit);
}
ML Training Data Pipeline (US-87)
Objetivo
Serviço de agregação diária que cruza UserFeatures × ContentFeatures para produzir a coleção ml_training_data, usada como input direto pelo modelo de recomendação.
Coleção `ml_training_data`
Cada documento é um snapshot diário de um par (user, content):
| Campo | Tipo | Origem |
|---|---|---|
snapshotDate |
string (ISO date) |
Data do snapshot |
userId |
string |
UserFeatures |
lastActiveHour |
number |
UserFeatures.dailyBehaviorMetrics |
lastActiveDayOfWeek |
number |
UserFeatures.dailyBehaviorMetrics |
playsByContentType |
Map<string, number> |
UserFeatures.dailyBehaviorMetrics |
playsByContent |
Map<string, number> |
UserFeatures.dailyBehaviorMetrics |
listenTimeMsByContent |
Map<string, number> |
UserFeatures.dailyBehaviorMetrics |
skipsByContent |
Map<string, number> |
UserFeatures.dailyBehaviorMetrics |
userMostActiveHour |
number |
UserFeatures |
userPreferredGenres |
string[] |
UserFeatures |
contentType |
string |
ContentFeatures |
contentId |
string |
ContentFeatures |
title |
string |
ContentFeatures |
genres |
string[] |
ContentFeatures |
primaryGenre |
string |
ContentFeatures |
language |
string |
ContentFeatures |
country |
string |
ContentFeatures |
popularity |
number |
ContentFeatures |
playsTotal |
number |
ContentFeatures |
listenTimeMsTotal |
number |
ContentFeatures |
favoritesCount |
number |
ContentFeatures |
Índice único: { snapshotDate, userId, contentType, contentId } — garante idempotência.
Dia das métricas: as linhas de um utilizador levam a entrada de UserFeatures.dailyMetrics com date === snapshotDate. Sem essa entrada (utilizador sem atividade nesse dia), userMostActiveHour e userPreferredGenres vêm da entrada anterior mais recente, e playsByContentType, listenTimeMsByContent e skipsByContent ficam vazios: as interações de outro dia não são repetidas. Entradas posteriores a snapshotDate nunca são usadas. Antes, sem entrada exata, usava-se a entrada mais recente, mesmo de um dia posterior: com o cursor 11 dias atrasado em staging (2026-10-16), um snapshot antigo levava escutas de dias seguintes.
Agendamento
| Cron | Serviço | DB Target | Descrição |
|---|---|---|---|
0 2 * * * |
UserFeaturesAggregation | core | Atualiza métricas diárias do utilizador |
30 2 * * * |
ContentPopularityAggregation | ops | Recalcula popularidade do conteúdo |
30 3 * * * |
MlTrainingDataService | ops (writes) / core (reads) | Gera snapshot user × content |
O serviço corre às 03:30 AM (Europe/Lisbon), garantindo que UserFeatures (core DB, atualizado às 02:00) e ContentFeatures (ops DB, atualizado às 02:30) já estão prontos.
Tempo de escuta nas agregações
listenTimeMsByContent (UserFeatures), userGenreScores[].listenTimeMs e listenTimeMsTotal (ContentFeatures) somam o tempo reportado em play_ended e em play_skipped (tempo ouvido no item que o utilizador deixou ao mudar de rádio/conteúdo). O play_skipped só credita tempo quando o evento vem de uma app que reporta segmentos (appVersion >= LISTEN_SEGMENT_MIN_APP_VERSION, guardado na ingestão a partir de X-App-Version), para não contar de novo valores acumulados enviados por versões antigas; o número de saltos (skipsByContent) não muda. Nos dois, o play_ended de apps com segmentos credita só listenDurationMs (não o tempo de relógio, que contaria pausas e tempo já reportado); versões antigas mantêm o relógio play_started → play_ended (no UserFeatures, só sem listenDurationMs explícito), creditado uma vez por play_started. Detalhes, limites e como verificar em EVENTS_API.md.
O ml-service lê listenTimeMsByContent no treino com a mesma chave <tipo>:<id>; hoje o fit do LightFM ignora esses pesos (sem sample_weight), ver ML_SERVICE_SETUP.md.
Database Connection Mapping
| Model / Collection | Mongoose Connection | Database (staging) | Autoscale Max |
|---|---|---|---|
UserFeatures |
default (core) | audissey-staging |
4,000 RU/s |
ContentFeatures |
OPS | audissey-staging-ops |
1,000 RU/s |
MlTrainingData |
OPS | audissey-staging-ops |
1,000 RU/s |
UserEmbedding |
OPS | audissey-staging-ops |
1,000 RU/s |
ContentEmbedding |
OPS | audissey-staging-ops |
1,000 RU/s |
⚠️ Atenção:
ml_training_dataecontent_featurespartilham o budget de 1,000 RU/s do ops DB com mais ~17 outras colecções. O jobcontent-popularity(02:30) escreve no mesmo ops DB, podendo causar contenção se se estender para além das 03:30. Ver ML_TRAINING_SCALABILITY.md para análise detalhada de capacidade.
Cursor-based Backlog
- Cursor key:
ml-training-data-snapshot-v1 - Cada execução gera só o snapshot de ontem, se ainda faltar. Um cursor com mais de 1 dia de atraso salta os dias em falta (o treino só lê o snapshot mais recente). Antes, a execução tentava recuperar até 7 dias em atraso, mas um dia com lotes de 50 e 1 s de pausa já gastava o orçamento de 45 min. Por isso o atraso nunca descia: em staging, a 2026-10-16 estava em 11 dias.
- Um dia com 0 linhas não avança o cursor; a execução seguinte passa ao seu próprio "ontem".
Cosmos DB Throttle-Safe Writes
The cross-join of ContentFeatures × UserFeatures generates a very high write volume.
All write settings are tuneable via environment variables so dev (low-RU) and prod can use different values:
| Env Var | Default | Description |
|---|---|---|
ML_TRAINING_BATCH_SIZE |
50 |
Initial docs per insertMany (adaptive may lower this at runtime) |
ML_TRAINING_BATCH_DELAY_MS |
1000 |
Initial pause between batches (ms) |
ML_TRAINING_MAX_RETRIES |
20 |
Max retries on Cosmos DB 16500 |
ML_TRAINING_INITIAL_RETRY_MS |
2000 |
Initial exponential backoff (ms) |
ML_TRAINING_TTL_DAYS |
30 |
Days to keep snapshot docs |
ML_TRAINING_MAX_USERS |
0 |
Cap users per run (0 = unlimited) |
ML_TRAINING_BATCH_SIZE and ML_TRAINING_BATCH_DELAY_MS are mapped in deploy/config/appsettings/worker/*.json from the Variable Groups (Oct 2026: 1000 / 100 in all environments). Before, the VG values never reached the worker, which ran on the defaults.
Backoff is exponential and capped at 30 s (must stay below KEDA pollingInterval of 30 s to avoid spurious scale-down). ordered: false preserves partial successes; only failed doc indexes are retried.
Adaptive Rate Limiting
The snapshot uses adaptive rate limiting that self-tunes to the available RU budget:
- On throttle: batch size is halved (floor 5) and inter-batch delay increased by 50%.
- After 10 consecutive throttle-free batches: batch size grows by 25% and delay shrinks by 10%, gradually recovering toward the configured values.
- Heartbeat logs include the current effective
batchSizeanddelayfor observability.
This means the configured ML_TRAINING_BATCH_SIZE and ML_TRAINING_BATCH_DELAY_MS are starting values (ceilings), not fixed rates. On low-RU environments (dev/staging ops DB at 1,000 RU/s shared), the adaptive limiter will quickly converge to a sustainable throughput without manual tuning.
For prod (higher RU budget), override via Container App env vars:
ML_TRAINING_BATCH_SIZE=200
ML_TRAINING_BATCH_DELAY_MS=200
ML_TRAINING_MAX_RETRIES=12
ML_TRAINING_INITIAL_RETRY_MS=3000
For staging (ops DB 1,000 RU/s shared), recommended values:
ML_TRAINING_BATCH_SIZE=50
ML_TRAINING_BATCH_DELAY_MS=1000
ML_TRAINING_MAX_USERS=500
With adaptive rate limiting these defaults are fine — the system will self-throttle down if needed.
📊 Para análise detalhada de capacidade e escalabilidade, ver ML_TRAINING_SCALABILITY.md.
Ficheiros
apps/worker/src/app/ml-training-data/
├── schemas/
│ └── ml-training-data.schema.ts # Schema Mongoose
├── ml-training-data.module.ts # Module NestJS
├── ml-training-data.service.ts # Cron service
└── ml-training-data.service.spec.ts # Testes unitários
apps/worker/src/app/jobs/
├── jobs.controller.ts # HTTP endpoints (manual trigger → BullMQ)
├── schemas/
│ └── job-execution-log.schema.ts # Execution log schema
└── processors/
└── snapshot-ml-training.processor.ts # BullMQ processor
Admin Portal
- Página:
/ml - Objetivo: centralizar análise, diagnóstico e teste manual do pipeline de recomendações no Admin Portal.
- Inclui diagnóstico por etapas do pipeline, trigger manual do snapshot
ml_training_data, treino manual durável via BullMQ + Container Apps Job e teste de recomendações poruserId. - O treino mostra estado partilhado por
run_id; refresh/restart de uma réplica não perde o progresso e erros do processo one-shot fazem o BullMQ falhar. - Página:
/ml/jobs - Objetivo: mostrar o estado operacional dos jobs ML (
aggregate-user-features-daily,aggregate-content-popularity-dailyeml-training-data-snapshot-daily). - Inclui atraso face ao alvo diário, cursor associado quando existe, evidência de materialização e estado atual dos embeddings persistidos.
Índices de diagnóstico do Admin
Antes de publicar estas páginas, aplicar em dev → staging → prod e confirmar o
build dos índices Atlas versionados userfeatures {updatedAt:-1,_id:-1},listeninghistories {playedAt:-1,_id:-1} econtent_features {updatedAt:-1,_id:-1}. Criá-los fora do pico: o build inicial
faz scan e consome CPU/IO, cada B-tree aumenta storage e a manutenção acrescenta
write amplification (updatedAt em updates; playedAt em inserts/alterações).
O impacto esperado é baixo a médio, mas não pode ser quantificado sem
cardinalidade/tamanho de chave, tier e billing Atlas. O desired-state local não
prova que o build remoto já ocorreu.
Roadmap Futuro
Fase 1: Embeddings de Texto ✅ (Preparado)
- Integrar modelo de embeddings (Sentence Transformers)
- Gerar embeddings para títulos/descrições
- Atualizar campo
embeddings.text - Busca semântica com similaridade cosine
Fase 2: Embeddings de Áudio
- Integrar modelo de audio embeddings
- Processar samples de áudio
- Armazenar em
embeddings.audio - Recomendação baseada em similaridade sonora
Fase 3: Modelo de Recomendação
- Collaborative filtering
- Content-based filtering
- Hybrid approach
- A/B testing de algoritmos
Fase 4: Analytics Avançado
- Tracking de clicks/plays
- Análise de padrões temporais
- Segmentação de utilizadores
- Personalização de feeds
Performance
Índices
- Queries com filtros: < 10ms
- Agregações de stats: < 50ms
- Busca paginada: < 20ms
Capacidade
- Testado: 1000 episódios
content_features: Suporta milhões de documentos (leitura)ml_training_data: Limitado pelo RU budget do ops DB — ver ML_TRAINING_SCALABILITY.md- Paginação: Implementada em todos os endpoints
Otimizações Aplicadas
- Índices compostos para queries frequentes
- Lean queries (sem hidratação Mongoose)
- Projection de campos essenciais
- Batch processing na migration
Troubleshooting
Snapshot stuck / cursor not advancing (Error 16500)
Symptom: snapshot-ml-training has 0% success rate; cursor stays at the same date.
Root Cause: Cosmos DB RU throttling (Error 16500 / TooManyRequests) exhausts all retries during insertMany.
Diagnosis:
- Check execution logs via Admin Portal → Ingestion Status →
snapshot-ml-training - Query Log Analytics (KQL):
ContainerAppConsoleLogs_CL | where Log_s contains "SnapshotMlTraining" and Log_s contains "16500" | order by TimeGenerated desc | take 50
Fix:
- The adaptive rate limiter now self-tunes batch size/delay — no manual env-var tuning should be needed in most cases
- If still throttling excessively, reduce
ML_TRAINING_BATCH_SIZE(e.g.25for dev) - Increase
ML_TRAINING_BATCH_DELAY_MS(e.g.2000for dev) - Set
ML_TRAINING_MAX_USERSto cap cross-join volume on low-RU tiers - After config change, trigger snapshot manually from Admin Portal
KEDA kills worker mid-snapshot (scale-to-zero)
Symptom: Snapshot starts, logs show retry backoff, then container is terminated with ManuallyStopped / KEDAScaleTargetDeactivated.
Root Cause: KEDA monitors only bull:<queue>:wait Redis lists. Once a job moves to :active, KEDA no longer sees it. If all :wait lists are empty during a polling cycle, KEDA scales the worker to 0 — killing any in-progress job.
Mitigations (all deployed):
- KEDA
:activerule — a 13th KEDA scale rule (keda-redis-snapshot-ml-training-active) monitorsbull:snapshot-ml-training:activewithlistLength=1. While the job is processing, KEDA sees the:activeitem and keeps the replica alive. - Manual trigger routed through BullMQ —
POST /api/admin/ml-training/trigger-snapshotnow enqueues a BullMQ job (visible to KEDA) instead of running in-process. - Retry cap reduced —
MAX_RETRY_DELAY_MSset to 30 s (below KEDApollingIntervalof 30 s) so the longest sleep during retries doesn't fall entirely within a single KEDA polling window.
KEDA config per environment (all Container Apps workers):
| Setting | Value |
|---|---|
pollingInterval |
30 |
cooldownPeriod |
300 |
minReplicas |
0 |
maxReplicas |
2 |
| Scale rules | 13 (12 :wait + 1 :active) |
Erro: "language override unsupported: unknown"
Causa: Text index do MongoDB não suporta "unknown" como idioma.
Solução:
# Drop da collection e recriar
db.content_features.drop()
npm run migrate:content-features
Migration com muitos erros
Verificar:
- Conexão à base de dados
- Credenciais MongoDB
- Documentos originais têm campos necessários
- Logs de erro detalhados
Performance lenta
Otimizar:
- Verificar índices:
db.content_features.getIndexes() - Analisar queries:
.explain("executionStats") - Aumentar batch size na migration
- Usar projeção de campos
Recursos Adicionais
Ficheiros Relevantes
apps/api/src/app/
├── common/
│ ├── schemas/
│ │ └── content-features.schema.ts # Schema MongoDB
│ └── services/
│ ├── content-normalization.service.ts # Normalização
│ └── content-features.service.ts # CRUD
├── admin/
│ ├── admin.controller.ts # Endpoints
│ └── admin.module.ts # Module config
└── scripts/
└── migrate-content-features.ts # Migration script
docs/features/
└── CONTENT_FEATURES_ML.md # Este documento
Comandos Úteis
# Build
npx nx build api
# Migration
npm run migrate:content-features
# Verificar dados
mongosh "mongodb://admin:admin123@localhost:27017/audissey?authSource=admin"
> db.content_features.countDocuments()
> db.content_features.findOne()
# Testar endpoints
curl -H "Authorization: Bearer <token>" \
http://localhost:3000/api/admin/content-features/stats
Conclusão
O sistema ContentFeatures fornece uma base sólida para machine learning e recomendações, com:
✅ Metadados normalizados e consistentes
✅ Estrutura preparada para embeddings
✅ Performance otimizada com índices
✅ Migration automatizada
✅ Endpoints RESTful documentados
✅ Escalável para milhões de documentos
✅ Adaptive rate limiting para Cosmos DB throttle-safe writes
✅ Análise de escalabilidade documentada (ML_TRAINING_SCALABILITY.md)
Pronto para integração com modelos de IA! 🚀