DOMAIN: Sistema de Busca com Elasticsearch
Skill gerado a partir do pack templates-claude-code. Arquivo de origem: dominio/10-busca-elasticsearch.md. Use como baseline e adapte ao projeto antes de mudancas grandes.
Conteudo do template
CONTEXT
Elasticsearch é poderoso e fácil de usar de forma errada. Mapping definido incorretamente não pode ser corrigido sem reindexar. Relevância sem tuning retorna resultados ruins que destroem a experiência. Sincronização com banco de dados gera eventual consistency — os times esquecem que buscas mostram dados potencialmente stale.
STACK ASSUMPTIONS
- Search: Elasticsearch 8.x / OpenSearch 2.x
- Client: @elastic/elasticsearch (Node.js) ou elasticsearch-py
- DB: PostgreSQL como source of truth
- Sync: CDC (Debezium) ou event-driven via queue
- Cache: Redis para queries repetitivas de alta frequência
CORE CONCEPTS
- Index: equivalente a uma tabela no Elasticsearch
- Mapping: schema do index — define tipos dos campos (keyword, text, integer, etc.)
- Analyzer: como o texto é tokenizado para busca (standard, portuguese, custom)
- Inverted Index: estrutura interna que torna busca full-text rápida
- Relevance Score (_score): pontuação de relevância calculada por BM25
- Aggregation: facets, contagens, histogramas — usado em filtros de e-commerce
- Boost: multiplicar score de campos específicos (título > descrição)
- Alias: apontar para index real — permite blue-green reindex sem downtime
ARCHITECTURE RULES
Mapping Strategy
- Definir mapping EXPLICITAMENTE antes de indexar qualquer documento
- Nunca usar dynamic mapping em produção (campos inesperados criam mapping ruim)
keywordpara campos filtráveis/aggregáveis (id, status, category)textpara campos de busca full-text (title, description)keyword+textno mesmo campo: usefieldsmulti-field- Campos numéricos:
integer/float/double(nãotext) - Datas:
datecom formato explícito
"title": {
"type": "text",
"analyzer": "portuguese",
"fields": {
"keyword": { "type": "keyword" }
}
}
Analyzer Configuration
- Português:
portugueseanalyzer com stemming - Autocomplete:
edge_ngramtokenizer (min 2, max 10 chars) - Search-as-you-type: tipo nativo
search_as_you_typeou custom edge_ngram - Synonyms: synonym token filter com arquivo de sinônimos externos (sem reindex para atualizar)
- Test analyzers:
POST /{index}/_analyzeantes de criar index
Relevance Tuning
- Boost por campo: título (3x) > categoria (2x) > tags (1.5x) > descrição (1x)
- Boost por recência:
gaussdecay function nopublished_at - Boost por popularidade: field
view_countcomfield_value_factor - Function score combinado: relevância textual + decay temporal + popularidade
- A/B test relevance: log query + resultado + click para medir CTR
Reindex Strategy (Zero Downtime)
1. Criar novo index: products_v2 (com novo mapping)
2. Iniciar reindex: POST /_reindex (source: products_v1, dest: products_v2)
3. Durante reindex: novos documentos indexados em AMBOS indexes
4. Aguardar reindex completar → validar contagem + spot check
5. Atualizar alias: products → products_v2
6. Parar de indexar em products_v1
7. Deletar products_v1 após verificação de 24h
Sync Strategy (DB → ES)
- Event-driven: após escribas no DB, enfileirar job de reindex do documento
- CDC (Debezium): capturar changes do WAL do PostgreSQL → publicar em Kafka → worker indexa
- Polling: job periódico (a cada 30s) buscando
updated_at > last_sync— simples mas com lag - Nunca indexar diretamente no ES dentro de transaction de banco
- Sempre indexar assincronamente (em falha do ES, o dado está no DB)
ROUTING TABLE
| Trigger | Action |
|---|---|
| GET /search?q=...&category=...&sort=... | Query ES com filtros + facets + paginação |
| GET /search/autocomplete?q=... | Query search_as_you_type ou edge_ngram |
| Business event: product.created | Enfileirar job: index document no ES |
| Business event: product.updated | Enfileirar job: partial update (_update) no ES |
| Business event: product.deleted | Enfileirar job: delete document do ES |
| GET /search/facets?category=... | Aggregations: contagem por filtro |
| POST /admin/search/reindex | Iniciar reindex completo (via job, não síncrono) |
| GET /admin/search/reindex/:jobId | Status do reindex em andamento |
| GET /admin/search/health | Status do cluster ES + lag de sincronização |
| POST /admin/search/analyzers/test | Testar analyzer em texto específico |
| GET /admin/search/mapping | Exibir mapping atual do index |
CRITICAL RULES
- Mapping explícito SEMPRE — nunca dynamic mapping em produção
- Usar alias (não nome do index direto) em todas as queries da aplicação
- Reindex sempre via alias swap — nunca recriar index com mesmo nome
- ES não é fonte de verdade — banco de dados sempre é authoritative
- Falha ao indexar no ES não deve falhar a operação principal (async)
- Nunca deletar o index sem ter backup ou possibilidade de reindexar
- Queries de busca sempre com
timeoutconfigurado (ex: 5s) - Aggregation em campos
texté proibido (usarkeywordsub-field) - Paginação profunda (
from: 10000) proibida — usar search_after - Logs de query com tempo de execução para monitorar regressões de performance
COMMON PITFALLS
❌ Dynamic mapping cria campo text onde deveria ser keyword
// ERRADO: produto es indexado sem mapping → status vira "text"
PUT /products/_doc/1 { "status": "published" }
// Agora `status` é "text" e não pode ser usado em aggregations/filters eficientemente
// CORRETO: mapping explícito antes de indexar
PUT /products { "mappings": { "properties": { "status": { "type": "keyword" } } } }
❌ Paginação com from alto (deep pagination)
// ERRADO: ES lê e descarta 10.000 documentos — caro e lento
GET /products/_search { "from": 10000, "size": 20 }
// CORRETO: search_after com sort
GET /products/_search {
"size": 20,
"sort": [{ "created_at": "desc" }, { "_id": "asc" }],
"search_after": ["2024-01-15T10:00:00", "last-seen-id"]
}
❌ Indexar dentro de transaction de banco
// ERRADO: se ES falhar, a transaction faz rollback desnecessário
await db.transaction(async (trx) => {
await trx.products.create(product);
await es.index({ index: 'products', document: product }); // falha aqui = rollback
});
// CORRETO: salvar no banco → disparar evento → worker indexa assincronamente
await db.products.create(product);
await queue.add('index-product', { productId: product.id });
QUALITY GATES
- Mapping explícito definido antes do primeiro documento indexado
- Todos os queries usam alias (não nome do index diretamente)
- Paginação usando search_after (não from/size para páginas > 100)
- Aggregations apenas em campos
keyword - Queries com timeout configurado
- Lag de sincronização monitorado (alerta se > 60s)
- Procedure de reindex zero-downtime documentada e testada
- Analyzers testados com conteúdo real antes de criar index
- Falha no ES não quebra a operação principal
- Relevance score validado com queries de teste regulares
FORBIDDEN
- Dynamic mapping em produção
- Aggregations em campos do tipo
text from> 1000 em queries de busca (deep pagination)- Índice direto por nome (sem alias) em código de aplicação
- Indexar documentos dentro de transações de banco de dados
- Usar ES como banco de dados principal (sem DB relacional como fallback)
- Deletar e recriar index com o mesmo nome (perda do alias + downtime)