Lab 94 — Busca de produto com IA: híbrida, rerank e geração
O problema, e a Cadência que o revisita de novo
O L20 resolveu o problema de renderização das páginas de produto da Cadência: HTML pronto na borda via CloudFront, TTFB medido, SEO recuperado. A página de resultado de busca é servida pela mesma distribuição. O que não foi resolvido — porque não era o escopo daquele laboratório — é o que acontece DENTRO da caixa de busca: uma consulta SELECT ... WHERE nome ILIKE '%termo%' OR descricao ILIKE '%termo%' contra a tabela de produtos, sem nenhuma noção de relevância entre os resultados encontrados.
O time de produto notou primeiro pelo relatório de conversão: a taxa de quem busca e compra estava consistentemente mais baixa que a taxa de quem navega por categoria e compra. A investigação levou a dois padrões de busca que se repetiam: comprador digita "parafuso 3/4" e o produto — cadastrado por um fornecedor como "PARAFUSO 3/4 POL AÇO INOX" e por outro como "Parafuso 0,75\" inox" — não aparece, porque o texto exato não bate em nenhum dos dois casos. Ou comprador digita "tinta branca" e recebe uma lista sem ordem nenhuma: um produto com a frase citada de passagem numa ficha técnica compete de igual para igual com a tinta branca mais vendida da categoria.
Os dois padrões têm a mesma raiz: ILIKE não tem noção de relevância nem de variação semântica — ele só verifica se a substring aparece em algum lugar do texto. É exatamente o oposto do defeito que o L85 diagnosticou no RAG de atendimento: lá, a busca vetorial pura generalizava demais um código de produto exato e perdia o termo específico; aqui, a busca puramente lexical por substring não generaliza NADA — nem formatação diferente da mesma medida, nem relevância entre resultados que batem a mesma palavra. O time reconheceu o padrão: é o mesmo problema de recuperação do L85, só que na direção inversa, e agora em busca de produto, não de política de atendimento.
"A IA melhora a busca gerando a resposta" é o erro que este laboratório existe para desarmar
A tentação óbvia, vendo um LLM disponível, é deixá-lo ler os candidatos e decidir quais produtos mostrar — parece que ele "entende" a intenção de compra melhor que um score de relevância. Ele não entende: ele só vê os candidatos que alguém já filtrou para ele, nunca o catálogo inteiro, e não tem o sinal de RECALL que a recuperação híbrida calculou. Um produto que a busca nunca trouxe para perto nunca vai aparecer numa lista que o LLM decide — não importa quão bem ele escreva sobre os que chegaram. Recuperação decide QUALIDADE; geração só apresenta.
O que este laboratório NÃO é
Não é o RAG de atendimento do L83/L85 — aquele responde pergunta sobre política com citação; este ordena produtos de catálogo para compra. A TÉCNICA de recuperação (BM25 + vetor, fundidos por RRF, reordenados por cross-encoder) é a MESMA do L85 — não seria honesto reexplicá-la do zero aqui. O que muda, e é o que este laboratório de fato ensina, é a escala (catálogo inteiro, milhões de buscas), o critério de prova (conversão em teste A/B, não taxa de acerto num golden set) e o papel invertido da geração: no L85 ela gerava a resposta inteira; aqui ela só apresenta um destaque sobre uma lista que já está decidida antes dela ser chamada.
O que você vai conseguir fazer
Objetivos verificáveis: cada um se prova com uma medição do teste A/B, ou com um comando na seção de implantação.
- Reproduzir os dois padrões de busca ruim ("parafuso 3/4" sem resultado, "tinta branca" sem ordenação) e diagnosticar por que uma consulta ILIKE não resolve nenhum dos dois.
- Configurar um domínio OpenSearch Service dedicado ao catálogo de produto, com busca híbrida (BM25 léxico + k-NN vetorial) sobre o mesmo índice.
- Fundir os dois rankings por Reciprocal Rank Fusion — a mesma técnica do L85, aplicada a nome, descrição e atributo de produto em vez de política de atendimento.
- Rerankear o top-N fundido com um cross-encoder (Bedrock Rerank), e medir a posição do SKU certo antes e depois.
- Restringir a geração (Bedrock) a escrever um destaque de atributo por produto exibido, SEM permitir que ela escolha, filtre ou reordene a lista — com verificação de código que reprova se a ordem mudar.
- Cachear na borda (CloudFront) a resposta dos termos de busca mais populares, reaproveitando a distribuição do L20, sem cachear termo de cauda longa.
- Rodar um teste A/B real, 50/50, medindo conversão de busca — não taxa de acerto de golden set — com significância estatística.
- Configurar um domínio OpenSearch Multi-AZ e restringir IAM ao modelo de rerank e ao domínio específicos, sem `"Resource": "*"`.
O que a certificação cobra disto
| Conceito | Certificação | Como aparece aqui | O que dominar |
|---|---|---|---|
| Busca híbrida em escala de catálogo | MLA-C01, SAP-C02 | BM25 + k-NN sobre 182 mil SKUs, com volume de busca ordens de grandeza maior que o RAG de atendimento | por que a mesma técnica de recuperação muda de criticidade conforme o volume e o que está em jogo quando ela erra |
| Papel da geração num pipeline de busca | MLA-C01, AIF-C01 | Bedrock Claude restrito a escrever destaque de atributo, nunca a decidir a lista de produtos | reconhecer quando IA generativa DEVE ficar de fora da decisão de ranking, e por quê |
| Cache dinâmico na borda por popularidade de consulta | SAA-C03, SAP-C02 | CloudFront cacheando resposta dos termos mais buscados por poucos minutos, sem cachear cauda longa | diferenciar conteúdo cacheável por padrão de acesso, não por tipo de rota |
| Teste A/B como prova de arquitetura | MLA-C01 | split 50/50 por sessão, medindo conversão com significância estatística, não taxa de acerto de golden set | por que prova de sistema em produção usa métrica de negócio, não só métrica de modelo |
| Disponibilidade de índice de busca em escala de receita | SAP-C02 | OpenSearch Service Multi-AZ, com o mesmo mecanismo do L85 e orçamento de tolerância a falha mais apertado | por que o mesmo padrão técnico (zone awareness) carrega risco de negócio diferente conforme o sistema |
| Custo e latência de reranking em alto volume | MLA-C01, SAP-C02 | rerank só sobre o top-20/30 fundido, cache de borda absorvendo os termos populares antes de chegar ao domínio | por que a mesma disciplina de custo do L85 se torna obrigatória, não opcional, quando o volume multiplica |
Onde isto costuma ser cobrado errado
A pergunta clássica descreve uma busca de e-commerce que "melhorou" depois de um LLM passar a reordenar os resultados por prompt, e pede para identificar o risco dessa arquitetura. A resposta esperada não é "o modelo pode ficar lento" — é reconhecer que decisão de RANKING de catálogo pertence à camada de recuperação, com sinal medido (BM25, embedding, cross-encoder), e que deslocar essa decisão para a geração troca um sinal de relevância calculado por uma impressão de fluência de texto, sem recall sobre o catálogo inteiro.
Requisitos, e como cada um muda o desenho
Requisito que não vira uma linha do pipeline de busca é intenção. O quarto requisito organiza todos os outros: nada aqui pode deixar a geração decidir a lista.
| Requisito | Valor declarado | O que ele decide no desenho |
|---|---|---|
| Medida ou código escrito de forma diferente por fornecedores diferentes não pode virar busca sem resultado (caso "parafuso 3/4") | obrigatório | inclui sub-busca k-NN vetorial em paralelo à léxica — capta variação de formatação que ILIKE nunca capta |
| Busca por termo genérico precisa ordenar por relevância, não só achar a substring (caso "tinta branca") | obrigatório | inclui sub-busca BM25 formal (pontuação por frequência/raridade de termo), em vez de ILIKE binário |
| Fusão não pode depender da escala de score de cada motor | obrigatório | RRF (fusão por posição no ranking), a mesma técnica do L85, sem soma de score bruto entre motores diferentes |
| Geração NUNCA decide quais produtos aparecem nem em que ordem | obrigatório — é o contrato central deste laboratório | Bedrock recebe o top-N JÁ ordenado pelo rerank; escreve só destaque de atributo por produto, com verificação de código que reprova reordenação |
| Busca cabe no orçamento de latência de busca-enquanto-digita | até 400 ms no braço tratamento; cache de borda reduz isso para termo popular | CloudFront cacheia resposta de termo popular por minutos; rerank só sobre top-20/30, nunca o catálogo inteiro |
| Domínio de busca sobrevive à perda de uma zona de disponibilidade | obrigatório em produção | OpenSearch Service Multi-AZ (zone awareness), mesmo mecanismo do L85, orçamento de tolerância mais apertado por volume de receita |
| Credencial do domínio OpenSearch não fica fixa no código | obrigatório | Secrets Manager com rotação, fine-grained access control por role do IAM |
| Auditoria distingue produto ranqueado pela recuperação de texto escrito pela geração | obrigatório | log estruturado no CloudWatch registra a origem do ranking (BM25, vetorial ou ambos) separado do texto de destaque gerado |
Arquitetura mínima: busca por substring — o defeito que derruba conversão
É o desenho que a Cadência já tinha em produção antes deste laboratório: uma única consulta de substring, sem noção de relevância nem de variação semântica. Sem busca híbrida, sem rerank, sem geração nenhuma — não há nada para uma IA apresentar quando a lista de resultado já sai errada ou vazia.
- → busca "parafuso 3/4" digitada na caixa de pesquisa do catálogo
- → encaminha a chamada de busca dinâmica, sem cache — a resposta muda a cada tecla
- → repassa o termo exatamente como foi digitado, sem normalização
- → SELECT com ILIKE '%termo%' em nome e descrição
- → linhas que contêm a substring em qualquer posição, sem pontuação de relevância
- → lista sem ordenação — ou vazia, quando a formatação do fornecedor não bate
- → resposta da busca, para renderizar a grade de resultado
- → grade de resultado fora de ordem, ou vazia
- Fora da AWS
- Rede e entrega
- Compute
- Banco de dados
É o desenho que a Cadência tinha antes deste laboratório: o termo digitado vira um ILIKE contra nome e descrição, sem pontuação de relevância nem entendimento de variação de formatação. Percorra os passos: no quarto, repare que "parafuso 3/4" e "parafuso 0,75 polegada" são o mesmo produto para um comprador e duas strings diferentes para o banco — a busca não sabe disso.
- O comprador digita a busca do jeito que pensa. Sem saber que o texto exato importa, ele escreve a medida ou o nome do jeito mais natural — não necessariamente o jeito que o fornecedor cadastrou no catálogo.
- A borda repassa a chamada dinâmica sem cache. A distribuição herdada do L20 cacheia o HTML da página, mas a resposta de busca em si muda a cada tecla e atravessa direto até a origem.
- A API repassa o termo exatamente como veio. Nenhuma normalização de medida, sinônimo ou variação de formatação acontece nesta camada — o termo cru chega à função de busca.
- A função monta um único ILIKE, sem relevância. "parafuso 3/4" e "parafuso 0,75 polegada" são o mesmo produto para o comprador — e duas strings completamente diferentes para uma consulta de substring.
- O Postgres devolve substring — ou nada. Quando o texto bate, vêm linhas em qualquer ordem, sem nenhuma pontuação de relevância entre elas. Quando a formatação não bate, a busca volta vazia.
- A resposta sai fora de ordem, ou vazia. O comprador recebe uma grade sem critério de relevância, ou nenhum resultado — e o produto que ele queria pode estar no catálogo o tempo todo, só cadastrado com outra formatação.
Busca sem resultado não é bug de UX — é venda perdida
Um comprador que busca já demonstrou intenção de compra mais forte que um comprador navegando por categoria. Devolver zero resultado, ou uma lista fora de ordem, para uma busca cujo produto EXISTE no catálogo, é o ponto exato onde a plataforma perde uma venda que já estava praticamente fechada. É o motivo de este laboratório priorizar conversão como métrica de prova, não satisfação percebida.
Arquitetura para produção: híbrida, RRF, rerank — geração restrita a apresentar
Esta topologia se justifica pelos requisitos que a Figura 1 deixa em aberto: não perder variação de formatação, ordenar por relevância real, e — o mais importante — nunca deixar a geração decidir a lista. Cada peça nova abaixo rastreia a um desses três; se você não consegue apontar qual, ela é adorno.
- → mesma busca digitada, para comparar com a Figura 1
- → termo popular servido do cache de borda; termo de cauda longa segue adiante
- → encaminha a chamada HTTP para a função híbrida
- → sub-busca léxica: match query em nome e descrição, favorece termo exato e frequência
- → sub-busca vetorial: k-NN no campo embedding, favorece variação de formatação e sinônimo
- → ranking por relevância de termo, até 50 candidatos
- → ranking por proximidade semântica, até 50 candidatos
- → top-20 fundidos por RRF — nenhum score bruto de motor sobrevive à fusão
- → top-5 já na ORDEM final decidida pelo cross-encoder — Bedrock só recebe atributos para escrever destaque, nunca decide quais aparecem
- → MESMA lista, MESMA ordem recebida — só com destaque de atributo adicionado a cada card
- → grade de produto pronta, com destaque escrito
- → resposta da busca; termo popular fica cacheado por minutos para a próxima consulta igual
- → grade de resultado ordenada, com destaque de atributo
- → credencial de acesso granular ao domínio, rotacionada automaticamente
- → loga a origem do ranking e o texto gerado, em campos separados
- Fora da AWS
- Rede e entrega
- Compute
- Conceito de arquitetura
- Analytics
- IA e machine learning
- Segurança e identidade
- Gestão e governança
A técnica de recuperação (BM25 + k-NN, fundidos por RRF, reordenados por cross-encoder) é a mesma do L85. O que muda aqui: o volume (catálogo inteiro, não um golden set de 40 perguntas), o cache de borda absorvendo os termos populares, e o papel da geração — ela entra DEPOIS que a ordem já está decidida, e só escreve destaque de atributo. Repare no penúltimo passo: se o Bedrock tentasse reordenar a lista, a verificação de código rejeitaria o resultado.
- A busca chega, e a borda já filtra termo popular. Termos muito buscados (cerca de 20% dos termos, 70% do tráfego) já respondem direto do cache do CloudFront; o resto segue adiante.
- A busca vira duas consultas, não uma. A função dispara duas sub-buscas no MESMO domínio: uma léxica (BM25), uma vetorial (k-NN) — nenhuma substitui a outra, cada uma cobre um dos dois defeitos da Figura 1.
- RRF funde sem depender da escala de nenhum score. A fusão usa só a posição de cada produto em cada ranking — a mesma fórmula do L85, sem normalizar duas escalas incompatíveis.
- O cross-encoder decide a ORDEM final, antes de qualquer geração. O reranking lê busca e produto juntos numa única passada — é a última etapa que decide QUAIS produtos e em que ORDEM. Depois deste ponto, nada mais reordena a lista.
- A geração só escreve destaque — nunca decide a lista. O Bedrock recebe o top-5 já ordenado e escreve um destaque curto por produto (atributo relevante para aquela busca). A verificação de código confirma que a ordem e a lista de IDs não mudaram.
- A grade volta pronta, e o termo popular fica cacheado. A resposta segue de volta pela mesma cadeia; se o termo era popular, o CloudFront guarda essa resposta por alguns minutos para a próxima busca igual não pagar o custo total de novo.
- Credencial e log ficam fora do caminho de decisão. O Secrets Manager entrega a credencial rotacionada antes da consulta, e o CloudWatch recebe o log de origem do ranking separado do texto gerado — nenhum dos dois participa da decisão de ranking em si.
A ordem dos produtos está decidida antes do Bedrock ser chamado
Repare que o diagrama tem uma linha divisória lógica: tudo até o cross-encoder decide QUAIS produtos e em que ORDEM. O Bedrock entra DEPOIS, sobre uma lista já fechada — ele nunca vê o catálogo inteiro, nunca escolhe entre candidatos, nunca reordena. Essa separação é o que torna o ganho de conversão medido na seção de prova atribuível à recuperação, e não a um acaso de como o LLM decidiu escrever naquele dia.
Como funciona, ponta a ponta
O payload abaixo mostra a chamada de apresentação: o Bedrock recebe o top-5 JÁ na ordem final e os atributos de cada produto — e a função valida que a lista de IDs que volta é idêntica à que entrou, antes de aceitar o destaque escrito.
{
"requisicaoApresentacao": {
"termoBusca": "parafuso 3/4",
"produtosNaOrdemFinal": [
{"sku": "PRF-3025", "nome": "Parafuso 3/4 pol aço inox, cabeça sextavada", "posicao": 1},
{"sku": "PRF-3031", "nome": "Parafuso 0,75\" inox, rosca soberba", "posicao": 2},
{"sku": "PRF-1180", "nome": "Parafuso 1/2 pol aço carbono zincado", "posicao": 3}
]
},
"respostaApresentacao": {
"produtosComDestaque": [
{"sku": "PRF-3025", "posicao": 1, "destaque": "medida exata da busca, em aço inox"},
{"sku": "PRF-3031", "posicao": 2, "destaque": "mesma medida, rosca soberba (fixação mais firme)"},
{"sku": "PRF-1180", "posicao": 3, "destaque": "medida diferente — 1/2 polegada, não 3/4"}
]
},
"_comentario": "a funcao compara produtosNaOrdemFinal.map(p => p.sku) com produtosComDestaque.map(p => p.sku) ANTES de aceitar a resposta — se a ordem ou a lista de SKUs mudou, a chamada e rejeitada e a grade original (sem destaque) e servida, nunca uma lista reordenada pelo LLM"
}
O destaque muda a explicação, nunca a posição
Repare que o SKU PRF-1180 (medida errada) continua na posição 3 — o rerank já tinha decidido isso antes da geração ser chamada. O que a geração acrescenta é a FRASE que explica por que aquele produto está ali ('medida diferente'), o que ajuda o comprador a decidir mais rápido. Se o Bedrock tentasse mover PRF-1180 para o topo por achar o texto mais "relevante", a verificação de SKU/posição rejeitaria a resposta inteira.
As decisões, e o que se perde em cada uma
📋 A busca de catálogo da Cadência devolve resultado sem relevância ou vazio, derrubando a conversão de quem busca. Um LLM já está disponível via Bedrock, e alguém no time propõe deixá-lo ler os candidatos e decidir/reordenar a lista, argumentando que ele "entende" melhor a intenção de compra do que um score de relevância.
A recuperação híbrida tem o sinal de RECALL que a geração nunca tem: ela avalia o catálogo inteiro (182 mil SKUs), não só os poucos candidatos que alguém filtrou antes de chamar o LLM. BM25 resolve a ordenação de busca genérica; o vetor resolve a variação de formatação; o cross-encoder confirma a ordem final com um modelo que lê busca e produto juntos. A geração entra só depois, sobre uma decisão já fechada — o que torna o ganho medido na prova atribuível à recuperação, não a um acaso de prompt.
Alt: Deixar o LLM ler a lista de candidatos e decidir/reordenar pela "intenção" do comprador — o LLM não tem o sinal de recall que a recuperação híbrida calculou sobre o catálogo inteiro — ele só vê o que chegou até ele, nunca o que a busca deixou de trazer, e reordena por fluência de texto, não por relevância de catálogo medida
Alt: Aumentar peso de correspondência exata (boost manual) sem BM25 formal — é um ajuste específico por categoria de produto, que quebra de novo a cada categoria nova cadastrada — não é uma solução estrutural, é um remendo que precisa ser refeito sempre
Alt: Substituir toda a busca por embedding + LLM (RAG sobre o catálogo, sem sinal léxico) — reproduz o mesmo ponto cego que o L85 diagnosticou no atendimento — busca puramente semântica perde código e medida exata — agora em escala de catálogo, com efeito direto em receita
Alt: Gerar uma resposta em linguagem natural listando produtos (chat de compras) em vez de grade ordenada — aumenta a latência percebida e remove o padrão de UX de e-commerce (comparação visual rápida entre cards) que o próprio teste A/B deste laboratório usa para medir conversão
| Decisão | Escolha | Alternativas | Motivo | O que se perde |
|---|---|---|---|---|
| Onde a geração entra no pipeline | Depois do rerank, só para apresentação | geração decidindo a lista; geração ausente (sem destaque nenhum) | isola o efeito medido na recuperação; ainda entrega valor de UX com o destaque de atributo | não existe personalização de linguagem na ORDEM — só no texto de apoio de cada card, que é a troca deliberada deste desenho |
| Onde roda o cache de resposta de busca | CloudFront, só para termo popular (top 20% dos termos) | cachear tudo; não cachear nada | absorve 70% do tráfego sem sobrecarregar o domínio, sem servir resposta velha para termo raro que muda de resultado com frequência | termo popular pode ficar até alguns minutos desatualizado se um produto sai de estoque nesse intervalo |
| Quantos candidatos o rerank recebe | top-20 a 30, vindos da fusão RRF | rerankear os 5 primeiros só; rerankear o catálogo inteiro | espaço suficiente para a fusão errar um pouco sem o cross-encoder perder o produto certo, sem explodir custo em escala de catálogo | produto fora do top-30 fundido nunca chega ao cross-encoder, mesmo que fosse o mais relevante |
| Como a geração é restringida a apresentar | verificação de código compara lista de SKUs antes/depois da chamada ao Bedrock | confiar na instrução do prompt sem verificar; não usar geração nenhuma | prompt sozinho não é garantia — um teste com dado real já mostrou modelo "sugerindo" reordenação por engano; a verificação é o que torna o contrato central deste laboratório à prova de regressão | chamada rejeitada por divergência de SKU serve a grade sem destaque naquele request — pequena perda de UX, nunca de relevância |
Construir: o domínio OpenSearch de catálogo com busca híbrida
O domínio é dedicado ao catálogo de produto — não reaproveita o domínio de atendimento do L85, porque volume e padrão de acesso são diferentes o suficiente para justificar isolamento de capacidade e de fatura.
# dominio-busca-produto.tf — OpenSearch Service Multi-AZ dedicado ao
# catalogo, com campo de texto (BM25) e campo de vetor (k-NN) no MESMO
# indice de produto. O search pipeline de fusao e criado por fora do
# Terraform, igual ao L85 — confira a versao do provider antes de assumir
# que isso mudou.
resource "aws_opensearch_domain" "busca_produto" {
domain_name = "cadencia-busca-produto"
engine_version = "OpenSearch_2.19" # confira a versao GA mais recente antes de aplicar
cluster_config {
instance_type = "r6g.xlarge.search" # maior que o do L85: volume de catalogo e maior
instance_count = 6 # 3 por AZ — sobrevive a perda de 1 zona
zone_awareness_enabled = true
zone_awareness_config {
availability_zone_count = 2
}
dedicated_master_enabled = true
dedicated_master_type = "r6g.large.search"
dedicated_master_count = 3
}
vpc_options {
subnet_ids = [aws_subnet.privada[0].id, aws_subnet.privada[1].id]
security_group_ids = [aws_security_group.opensearch_produto.id]
}
encrypt_at_rest { enabled = true }
node_to_node_encryption { enabled = true }
# Mesmo principio do L85: security group decide QUEM alcanca a porta,
# fine-grained access control decide QUEM le cada indice depois disso.
advanced_security_options {
enabled = true
internal_user_database_enabled = false
master_user_options {
master_user_arn = aws_iam_role.opensearch_admin.arn
}
}
tags = {
Squad = "catalogo"
Custo = "busca-produto" # rateio distinto do dominio de atendimento do L85
}
}
O search pipeline de fusão RRF é o mesmo recurso do L85, criado via API REST sobre o índice de produto em vez do índice de política de atendimento.
# pipeline-rrf-produto.sh — cria o search pipeline de fusao no dominio de
# catalogo. Mesmo processor do L85; confira o nome exato disponivel na
# versao do seu dominio antes de aplicar em producao.
DOMINIO="https://$(terraform output -raw opensearch_produto_endpoint)"
curl -s -X PUT "$DOMINIO/_search/pipeline/hibrido-produto-rrf" \
-H "Content-Type: application/json" \
--aws-sigv4 "aws:amz:us-east-1:es" \
-d '{
"description": "funde BM25 (nome/descricao) e k-NN (embedding) do catalogo por RRF",
"phase_results_processors": [
{
"score-ranker-processor": {
"combination": { "technique": "rrf", "rank_constant": 60 }
}
}
]
}'
curl -s "$DOMINIO/_search/pipeline/hibrido-produto-rrf" | jq .
// BuscaProdutoHibridaService.cs — dispara a consulta hybrid contra o
// indice de produto, combinando BM25 (campo "nomeDescricao") com k-NN
// (campo "embedding"), usando o search pipeline criado acima.
public sealed class BuscaProdutoHibridaService
{
private readonly OpenSearchClient _cliente;
public BuscaProdutoHibridaService(OpenSearchClient cliente) => _cliente = cliente;
public async Task<IReadOnlyList<ProdutoCandidato>> BuscarAsync(
string termoBusca, float[] embeddingTermo, int candidatosPorMotor = 50)
{
var resposta = await _cliente.SearchAsync<ProdutoDocumento>(s => s
.Index("catalogo-produtos")
.Pipeline("hibrido-produto-rrf")
.Size(candidatosPorMotor)
.Query(q => q
.Hybrid(h => h.Queries(
// Sub-busca 1: BM25 lexica — resolve ordenacao de busca generica
qq => qq.Match(m => m.Field(f => f.NomeDescricao).Query(termoBusca)),
// Sub-busca 2: k-NN vetorial — resolve variacao de formatacao/medida
qq => qq.Knn(k => k
.Field(f => f.Embedding)
.Vector(embeddingTermo)
.K(candidatosPorMotor))
))));
if (!resposta.IsValid)
throw new BuscaIndisponivelException(resposta.DebugInformation);
// A ordem ja vem fundida por RRF — sem score bruto comparavel aqui,
// so a posicao relativa que o pipeline calculou.
return resposta.Hits
.Select((h, i) => new ProdutoCandidato(h.Source.Sku, h.Source.Nome, posicaoFundida: i))
.ToList();
}
}
Construir: geração restrita a apresentação, imposta em código
É a peça que não existe no L85 — lá a geração RESPONDIA a pergunta inteira; aqui ela só escreve destaque sobre uma lista que já saiu do reranking. O código abaixo é o que impõe essa restrição na prática, não só em prosa.
// ServicoApresentacaoRestrita.cs — chama o Bedrock so para escrever um
// destaque de atributo por produto, e REJEITA qualquer resposta que mude
// a lista de SKUs ou a ordem recebida do rerank. Isto e o contrato central
// deste laboratorio, imposto em codigo, nao so em instrucao de prompt.
public class ServicoApresentacaoRestrita
{
private readonly AmazonBedrockRuntimeClient _cliente = new();
// ID de inference profile do modelo de geracao — restrito por IAM na
// secao de seguranca abaixo, igual ao padrao do L85.
private const string ModeloArn =
"arn:aws:bedrock:us-east-1:111122223333:inference-profile/us.anthropic.claude-sonnet-4-20250514-v1:0";
public async Task<IReadOnlyList<ProdutoComDestaque>> ApresentarAsync(
string termoBusca, IReadOnlyList<ProdutoReordenado> produtosNaOrdemFinal)
{
var skusEsperados = produtosNaOrdemFinal.Select(p => p.Sku).ToList();
var prompt = MontarPromptApresentacao(termoBusca, produtosNaOrdemFinal);
var resposta = await _cliente.InvokeModelAsync(new()
{
ModelId = ModeloArn,
Body = SerializarPedido(prompt),
});
var destaques = DesserializarDestaques(resposta.Body);
// O CONTRATO: a lista de SKUs que volta tem de ser EXATAMENTE a mesma,
// na MESMA ordem, que entrou. Qualquer divergencia — produto a mais, a
// menos, ou fora de ordem — e um sinal de que o modelo tentou decidir
// a lista, e a resposta e descartada.
var skusRecebidos = destaques.Select(d => d.Sku).ToList();
if (!skusRecebidos.SequenceEqual(skusEsperados))
{
_logger.LogWarning(
"Bedrock devolveu SKU ou ordem diferente do rerank para {Termo} — " +
"servindo grade sem destaque, geracao NUNCA decide a lista",
termoBusca);
return produtosNaOrdemFinal
.Select(p => new ProdutoComDestaque(p.Sku, p.Nome, Destaque: null))
.ToList();
}
return produtosNaOrdemFinal
.Zip(destaques, (p, d) => new ProdutoComDestaque(p.Sku, p.Nome, d.Destaque))
.ToList();
}
}
Sem a verificação de SKU/ordem, o contrato central é só prosa
Uma instrução de prompt do tipo "não reordene os produtos" não é garantia — é pedido. Testado neste laboratório: com um prompt mal redigido, o modelo já "sugeriu" mover um produto fora de estoque para o topo achando que estava ajudando, sem nenhum sinal de erro. A verificação de código (`SequenceEqual` entre SKUs esperados e recebidos) é o que torna esse contrato à prova de regressão — sem ela, o mesmo antipadrão que este laboratório existe para evitar entra pela porta dos fundos.
O prompt de apresentação nunca pede ao modelo para "escolher os melhores produtos" — pede só para descrever, em poucas palavras, o atributo mais relevante de CADA produto que já está na lista, na ordem em que já está.
Segurança: quem chama o quê, e com qual credencial
Quatro credenciais participam deste desenho, cada uma restrita ao que usa de verdade — o mesmo princípio do L41 e do L85, aplicado a mais uma peça: o domínio de busca de produto.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "BuscarNoDominioDeProdutoEspecifico",
"Effect": "Allow",
"Action": ["es:ESHttpPost", "es:ESHttpGet"],
"Resource": "arn:aws:es:us-east-1:111122223333:domain/cadencia-busca-produto/*"
},
{
"Sid": "ChamarRerankRestritoAoModelo",
"Effect": "Allow",
"Action": ["bedrock:Rerank"],
"Resource": "arn:aws:bedrock:us-east-1::foundation-model/amazon.rerank-v1:0"
},
{
"Sid": "ChamarGeracaoDeApresentacaoRestrito",
"Effect": "Allow",
"Action": ["bedrock:InvokeModel"],
"Resource": "arn:aws:bedrock:us-east-1:111122223333:inference-profile/us.anthropic.claude-sonnet-4-20250514-v1:0"
},
{
"Sid": "LerCredencialDoDominioDeProduto",
"Effect": "Allow",
"Action": ["secretsmanager:GetSecretValue"],
"Resource": "arn:aws:secretsmanager:us-east-1:111122223333:secret:cadencia/opensearch-produto-*"
}
]
}
A policy de geração não pode ganhar escopo de decisão
A action `bedrock:InvokeModel` restrita ao ARN do modelo de apresentação garante QUEM pode chamar o Bedrock — não garante O QUE ele decide. Isso é código de aplicação (a verificação de SKU/ordem da seção anterior), não IAM. Confundir os dois é o erro que faria alguém achar que "a policy já protege" — ela protege contra chamada indevida, não contra o modelo tentando reordenar a lista dentro de uma chamada legítima.
| Risco | Probabilidade | Impacto | Prevenção |
|---|---|---|---|
| Credencial do domínio fixa em variável de ambiente | Média | Alto — acesso de leitura ao catálogo inteiro se vazar | Secrets Manager com rotação automática; nunca literal no código ou no Terraform |
| Geração reordena a lista sem verificação de código | Baixa, mas já observada em teste | Alto — desloca a decisão de ranking para o componente sem sinal de recall, revertendo o ganho medido de conversão | verificação `SequenceEqual` de SKU/ordem antes de aceitar a resposta do Bedrock, com fallback sem destaque |
| `Resource: "*"` na policy de `bedrock:Rerank` ou `InvokeModel` | Baixa, se revisão de PR pegar | Médio — função chamaria qualquer modelo, inclusive um mais caro por engano | ARN específico do modelo de rerank e do modelo de geração, cada um na sua própria statement |
| Fine-grained access control desligado no domínio | Baixa em ambiente novo, alta em domínio "herdado" de outro laboratório | Alto — qualquer credencial de rede lê o catálogo inteiro, sem distinção de índice | `advanced_security_options.enabled = true` desde a criação do domínio, nunca como retrofit |
Implantar, e provar com teste A/B
A prova aqui não é taxa de acerto de golden set — é conversão medida em teste A/B real, porque a pergunta que importa para o negócio não é "o sistema acertou a resposta", é "o comprador que buscou, comprou".
# teste-ab-busca-produto.sh — split 50/50 por hash do ID de sessao (nao
# por usuario logado, para nao vazar tratamento entre abas), rodado por 14
# dias corridos sobre todo o trafego de busca do catalogo.
# ── Braco controle: busca antiga (ILIKE) ────────────────────────────────
python3 medir_conversao.py --braco controle --periodo 14d \
--saida resultado-controle.json
# Buscas no braco: 1.284.312
# Compras atribuidas a busca em ate 30 min: 29.668
# Conversao de busca: 2,31%
# Buscas sem nenhum resultado (zero-result): 7,9%
# Latencia p95: ~180 ms
# ── Braco tratamento: hibrida + RRF + rerank + apresentacao ─────────────
python3 medir_conversao.py --braco tratamento --periodo 14d \
--saida resultado-tratamento.json
# Buscas no braco: 1.291.048
# Compras atribuidas a busca em ate 30 min: 46.220
# Conversao de busca: 3,58%
# Buscas sem nenhum resultado (zero-result): 2,4%
# Latencia p95: ~340 ms sem cache; ~45 ms para termo popular via CloudFront
# ── Significancia: teste Z de duas proporcoes ───────────────────────────
python3 significancia_ab.py \
--controle resultado-controle.json --tratamento resultado-tratamento.json
# uplift absoluto: +1,27 p.p. | uplift relativo: +55,0%
# z = 60,3 | p < 0,0001 — muito acima do limiar de significancia,
# esperado dado o tamanho de amostra tipico de busca de e-commerce
# ── Confirmacao: geracao nunca alterou SKU nem ordem ─────────────────────
python3 auditar_apresentacao.py --periodo 14d
# Chamadas ao Bedrock de apresentacao: 1.291.048
# Divergencias de SKU/ordem detectadas e rejeitadas: 214 (0,017%)
# Todas as 214 serviram grade sem destaque, NUNCA lista reordenada pelo LLM
| Prova | Controle — busca por ILIKE | Tratamento — híbrida + RRF + rerank + apresentação | O que mudou |
|---|---|---|---|
| Conversão de busca (clique → compra em 30 min) | 2,31% (29.668 de 1.284.312 buscas) | 3,58% (46.220 de 1.291.048 buscas) | uplift relativo de 55%, estatisticamente significativo (z ≈ 60,3; p < 0,0001) |
| Taxa de busca sem nenhum resultado (zero-result) | 7,9% | 2,4% | busca vetorial resgata variação de formatação que ILIKE nunca capturava |
| Posição do SKU exato ("parafuso 3/4 pol") | fora do resultado — zero-result | posição 1, após rerank | BM25 + vetor trazem o produto certo aos candidatos; cross-encoder confirma a posição |
| Ordenação de busca genérica ("tinta branca") | sem critério — ordem de inserção na tabela | ordenada por relevância real (BM25 + rerank) | termo genérico deixa de depender de acaso de cadastro para decidir a ordem |
| Latência p95 por busca | ≈180 ms (ILIKE simples) | ≈340 ms sem cache; ≈45 ms para termo popular via CloudFront | cache de borda absorve os 20% de termos que respondem por 70% do tráfego |
| Divergência de SKU/ordem na chamada de apresentação | não se aplica — sem geração no braço controle | 0,017% das chamadas (214 de 1.291.048), todas rejeitadas e servidas sem destaque | confirma que a geração nunca decidiu a lista — mesmo quando tentou, a verificação de código bloqueou |
214 divergências não são zero — e não deveriam ser tratadas como bug raro
0,017% parece desprezível até virar 214 buscas reais em que um modelo tentou reordenar produtos por conta própria. A verificação de código impediu que qualquer uma delas mudasse a lista que o comprador viu — mas o número confirma que a restrição em prompt sozinha teria vazado em escala real. É a prova de que o contrato "geração só apresenta" precisa estar em código, não em instrução de texto para o modelo.
Quebrar de propósito: quatro falhas, e a que inventa um produto
A regra que organiza este desenho é uma só: a geração APRESENTA a lista, nunca a decide. As injeções abaixo testam o que acontece quando cada peça falha — e a primeira testa se aquela regra é real ou é só uma frase no documento de arquitetura.
| Falha injetada | Como injetar | Sintoma enganoso | Diagnóstico correto |
|---|---|---|---|
| Buscar um produto que o catálogo não tem | Pesquisar por um item plausível e inexistente — "argamassa AC-IV flexível cinza", por exemplo | Se o desenho estiver certo, a busca devolve "nada encontrado" e a geração não escreve nada. Se estiver errado, aparece uma frase simpática descrevendo um produto que não existe, com características verossímeis | É o teste definitivo da regra deste laboratório. Um modelo instruído a "apresentar os resultados" completa a lacuna quando a lista está vazia, porque completar lacuna é o que ele faz. A defesa não é instruir melhor: é a aplicação NÃO CHAMAR a geração quando a lista está vazia, e validar que todo identificador citado na saída existe entre os recuperados. Produto inventado em página de e-commerce é problema de consumidor, não de qualidade |
| Reordenação indisponível | Negar a permissão de invocação do reordenador | A busca continua respondendo, com a ordem que a fusão produziu. A conversão cai alguns dias depois | Falhar aberto é o certo aqui — busca fora do ar custa mais que busca mal ordenada. O erro é falhar aberto em SILÊNCIO: sem alarme, a queda de conversão vira uma investigação de marketing de duas semanas para descobrir uma permissão negada |
| Índice de busca indisponível, com o `ILIKE` antigo ainda no código | Derrubar o domínio e deixar o caminho de contingência assumir | A busca "continua funcionando". Ninguém abre chamado | Voltou para o comportamento que originou o laboratório: sem relevância, sem variação de formatação, e "parafuso 3/4" não acha nada de novo. Caminho de contingência que degrada silenciosamente para a versão ruim é pior que erro visível — ele consome a conversão sem gerar nenhum sinal. Se existir, precisa de alarme próprio e de um aviso na interface |
| Índice defasado em relação ao estoque | Zerar o estoque de um produto no banco e buscar por ele antes da próxima indexação | O produto aparece bem posicionado, o comprador clica, e a página do produto diz indisponível | O índice de busca é uma cópia, e cópia tem idade. Preço e estoque são os campos que mais doem quando divergem, e a correção não é indexar mais rápido: é buscar no índice e conferir disponibilidade na fonte antes de exibir, ou aceitar a defasagem de propósito e sinalizar. A pior escolha é a atual — não decidir, e descobrir pela reclamação |
A primeira injeção precisa virar teste automatizado
Buscar por um produto inexistente é um teste de uma linha e deveria rodar a cada implantação. É a única forma de garantir que uma mudança de prompt, de modelo ou de versão de biblioteca não transformou "apresentar a lista" em "escrever sobre o tema" — e essa transformação acontece sem nenhum erro, numa atualização que parecia inofensiva.
Investigando a queda de conversão na busca de catálogo da Cadência, o time descobre dois padrões: buscas com medida exata de produto ("parafuso 3/4") voltam vazias, e buscas com termo genérico ("tinta branca") voltam numa ordem sem relação com relevância. A busca usa uma única consulta ILIKE contra nome e descrição. Qual é a causa estrutural COMUM aos dois padrões?
Observabilidade: as perguntas que o painel tem de responder
Busca de e-commerce tem uma vantagem rara sobre os outros laboratórios desta banda: existe um sinal de qualidade objetivo e imediato, que é o comportamento do comprador. O painel precisa usá-lo em vez de métrica técnica isolada.
- Qual a proporção de buscas sem nenhum resultado? Cada uma é um comprador que desistiu, e a lista dos termos mais buscados sem resultado é a melhor pauta de produto que existe.
- Qual a taxa de clique na primeira posição, e como ela se distribui pelas cinco primeiras? Se o clique está espalhado ou concentrado no fim, a ordenação está errada.
- Alguma resposta gerada citou um identificador de produto que não estava entre os recuperados? O valor aceitável é zero, e isto precisa ser verificado por código, não por amostragem.
- Qual a diferença de conversão entre quem chega por busca e quem chega por categoria? Era a métrica que abriu o laboratório, e continua sendo a que mede o sucesso dele.
- Qual a idade do índice em relação ao catálogo, em minutos?
| Alarme | Limiar inicial | O que ele pega |
|---|---|---|
| BuscaSemResultado | acima de 8% das buscas do dia | lacuna de catálogo, sinônimo não mapeado, ou índice quebrado |
| IdentificadorInexistenteNaResposta | qualquer ocorrência | a primeira injeção de falha chegando à produção — produto inventado |
| ReordenadorIndisponivel | qualquer falha de invocação | degradação silenciosa da ordenação, antes de a conversão denunciar |
| CaminhoDeContingenciaAtivo | qualquer ocorrência | o `ILIKE` antigo assumindo — o laboratório inteiro desligado sem ninguém saber |
| IdadeDoIndice | acima de 15 minutos | divergência de preço e estoque entre a busca e a página do produto |
Escala: 900 lojas, campanha, e a perda de uma zona
| Ordem de grandeza | O que muda no desenho | O que NÃO muda |
|---|---|---|
| Catálogo atual, tráfego normal | Nada. O domínio absorve, e a reordenação sobre poucos candidatos cabe no orçamento de latência de uma página de resultado | O orçamento de latência em si: busca de e-commerce compete com a paciência do comprador, e é mais apertado que o do RAG de atendimento |
| Campanha com tráfego multiplicado | A reordenação por consulta vira o termo dominante de custo e de latência, porque o volume de BUSCA é muito maior que o de pergunta ao atendimento. Reordenar só as consultas de cauda longa, e servir as populares de cache, passa a ser obrigatório | A regra de que a geração não decide a lista — que não tem nada a ver com escala e continua valendo |
| Catálogo multiplicado por dez | A indexação incremental deixa de ser conveniência: reindexar tudo passa a não caber na janela. Dimensionamento de shard e mapeamento de campo passam a importar de verdade | A necessidade de conferir disponibilidade na fonte antes de exibir — que fica mais importante conforme a defasagem do índice cresce |
| Perda de uma zona de disponibilidade | Com réplica e zone awareness, o domínio serve com capacidade reduzida. É a mesma compra do L85 | Se a réplica não existir, o cluster fica amarelo e continua respondendo — e ninguém percebe até a segunda zona |
| Perda da região | A busca cai. O caminho de contingência para o banco relacional volta a ser atraente, e volta a ser a versão ruim | Se houver contingência, ela precisa ser uma decisão declarada com aviso, não um `catch` esquecido no código |
Custo: por que a conta de busca não se parece com a de atendimento
Este laboratório usa quase as mesmas peças do L85, e a conta se comporta de forma completamente diferente. O motivo é o volume: uma loja recebe muito mais buscas que perguntas de atendimento, e cada termo variável é multiplicado por esse número maior.
| Cenário | O que domina | O que ninguém nota |
|---|---|---|
| Tráfego normal, reordenação em toda busca | A reordenação, com folga sobre o domínio | A distribuição de termos de busca é muito concentrada: um punhado de termos responde por boa parte do volume. Cachear o topo é a otimização de maior retorno e a mais fácil de fazer |
| Campanha, com geração em toda busca | A geração, que passa a ser o maior item da conta | É o cenário em que alguém propõe desligar a geração para economizar — e a decisão certa é cachear por termo, não desligar. A frase de apresentação é idêntica para o mesmo termo, e recalculá-la é pagar duas vezes pelo mesmo texto |
| Cauda longa — termos raros, cada um buscado uma vez | Reordenação e geração, sem nenhum cache para ajudar | É onde o dinheiro é bem gasto: são exatamente as buscas que o `ILIKE` não resolvia. Otimizar a cauda longa para economizar desfaz o laboratório |
A métrica que fecha a conta
Preço de instância, de reordenação e por 1.000 tokens muda por região e por modelo. Ordem de grandeza para dimensionar; confirme na página de preços antes de levar a proposta. Aqui existe o denominador que os outros laboratórios desta banda não têm: receita. O custo de busca por pedido convertido é diretamente comparável à margem, e foi a diferença de conversão entre busca e categoria que abriu o laboratório — é ela que o fecha.
Well-Architected nos seis pilares
| Pilar | Situação hoje | Risco | Melhoria | Prioridade |
|---|---|---|---|---|
| Excelência operacional | Indexação a partir do catálogo; pipeline de fusão criado por API | Recurso fora do estado da infraestrutura, e índice com idade que ninguém monitora | Verificação pós-implantação do pipeline e métrica de idade do índice | Alta |
| Segurança | Domínio em rede privada; a geração não recebe entrada do usuário sem delimitação | O termo de busca é entrada de usuário chegando perto de um modelo — a mesma superfície do L90 | Tratar o termo como dado delimitado e validar que a saída só cita identificadores recuperados | Alta |
| Confiabilidade | Zone awareness com réplica; caminho de contingência para o banco | A contingência degrada para a versão ruim sem sinal | Alarme quando a contingência assumir, e aviso explícito na interface | Alta |
| Eficiência de desempenho | Reordenação em toda busca | Orçamento de latência de página de resultado é mais apertado que o do atendimento | Reordenar seletivamente e cachear o topo da distribuição de termos | Alta |
| Otimização de custo | Geração recalculada por busca | O termo mais caro é recalculado para termos idênticos | Cachear a apresentação por termo normalizado | Alta |
| Sustentabilidade | Cluster permanente dimensionado para o pico de campanha | Capacidade ociosa fora de campanha | Revisar o dimensionamento após o primeiro ciclo sazonal completo | Baixa |
Onde mais IA entra nesta busca, e onde ela não pode entrar
| Ideia | Vale a pena? | Por quê |
|---|---|---|
| Normalizar variação de unidade e formatação na ingestão | Sim, e ataca a causa em vez do sintoma | "3/4 POL" e '0,75"' são o mesmo parafuso escrito por dois fornecedores. Normalizar isso no momento em que o produto entra no catálogo resolve para toda busca futura, custa uma vez por produto, e melhora também a navegação por categoria — que a busca híbrida não toca. É o maior ganho por real gasto desta lista |
| Personalizar o ranking pelo histórico do comprador | Sim, depois de medir | Ganho real em e-commerce, e com uma armadilha conhecida: personalização estreita a exposição do catálogo e pode reduzir descoberta. Entra com teste A/B contra conversão E contra diversidade de produtos vistos, nunca só contra a primeira |
| A geração escolhendo quais produtos mostrar | Não, e é a linha que define o laboratório | Um modelo escolhendo produtos otimiza plausibilidade textual, não relevância nem margem nem disponibilidade — e não é auditável. A lista sai da busca, ordenada por um reordenador que se mede; a geração recebe a lista pronta e só a apresenta. Trocar isso é abrir mão de qualquer capacidade de explicar por que um produto apareceu |
Anti-padrões deste laboratório
| Anti-padrão | Por que alguém faz | Sintoma em produção | Forma correta |
|---|---|---|---|
| Deixar o LLM escolher/reordenar os produtos da busca, achando que ele "entende melhor" a intenção | parece a solução mais moderna e simples: um prompt resolve, sem integrar rerank nem RRF | resultado muda de ordem entre chamadas idênticas, o LLM "inventa" atributo que o produto não tem, e o produto certo simplesmente não aparece quando a busca inicial não trouxe ele nos candidatos | recuperação híbrida + rerank decide QUAIS e em que ORDEM; geração só escreve destaque sobre o que já foi decidido, com verificação de código |
| Só busca lexical exata (ILIKE), porque "já funciona" e não exige infraestrutura nova | parece grátis: já existe no banco relacional, sem custo de domínio novo | toda variação de formatação (medida, unidade, marca escrita diferente) vira busca sem resultado, e a taxa de zero-result sobe | BM25 (relevância de termo) + vetor (variação semântica) cobrem os dois pontos cegos que ILIKE não cobre nenhum |
| Rerankear o catálogo inteiro em vez do top-N já fundido | parece mais seguro rerankear mais candidatos, "para não perder nenhum bom" | latência e custo do cross-encoder crescem linearmente com o volume de catálogo, e o orçamento de busca-enquanto-digita estoura | cross-encoder só sobre o top-20/30 que a fusão já filtrou, nunca os 182 mil SKUs do catálogo inteiro |
| Medir só "conversão geral do site" em vez de conversão atribuída à busca | um número só é mais simples de reportar num painel executivo | a melhoria real de recuperação fica escondida dentro de uma métrica que mistura tráfego de busca com navegação direta e campanha paga | medir conversão SEGMENTADA por sessão que passou pela busca, comparando braço de controle e tratamento no mesmo teste A/B |
| Não cachear nenhuma resposta de busca na borda, porque "busca é sempre dinâmica" | parece mais simples tratar toda busca como não cacheável, sem lógica de exceção por popularidade | o domínio OpenSearch recebe carga repetida dos mesmos termos populares a cada tecla digitada, inflando custo e latência sem necessidade | cachear resposta de termo popular por poucos minutos no CloudFront, mantendo termo de cauda longa sem cache |
Evolução em níveis: da busca por substring à plataforma de dados e IA de catálogo
A terceira arquitetura não é mais um desenho — é a resposta a QUANDO trocar de desenho. Cada nível resolve um risco medido neste laboratório e compra outro no lugar.
Busca por ILIKE contra nome e descrição, herdada de antes deste laboratório. Sem relevância, sem variação semântica.Busca híbrida (BM25 + k-NN) com fusão RRF e reranking por cross-encoder, servida na borda via CloudFront, com geração restrita a apresentação e imposta em código.O ranking passa a ponderar histórico de navegação e compra do comprador como sinal adicional, além de BM25 e vetor.O teste A/B vira suíte contínua — canary permanente medindo conversão e zero-result a cada mudança de índice ou de modelo de rerank, antes de rollout total.A mesma pipeline híbrida atende busca digitada no site, busca por voz no app e feed de produto exportado a marketplace parceiro, cada canal com orçamento de latência diferente.A mesma técnica passa a orientar não só o ranking de busca, mas merchandising automatizado — destaque de produto na home, recomendação cross-sell — sempre com a mesma disciplina: recuperação e sinal medido decidem, geração só apresenta.Por que este laboratório vem depois do L20 e do L85
A ordem não é negociável: busca híbrida e reranking em escala de catálogo (nível 2) pressupõem que a borda já serve HTML pronto com cache (L20) e que a técnica de fusão e reranking já foi validada em produção com um volume menor (L85). Quem tenta aplicar isso direto num catálogo de e-commerce, sem ter medido a técnica antes numa escala menor, está apostando o orçamento de latência de produção no primeiro teste.
Quando algo não funciona
| Sintoma | Causa provável | Como investigar | Log ou métrica | Correção |
|---|---|---|---|---|
| A busca descreve um produto que não existe | A geração foi chamada com lista vazia e completou a lacuna | Reproduzir o termo e inspecionar a lista recuperada antes da geração | Ocorrências de identificador citado fora dos recuperados | Não chamar a geração com lista vazia, e validar por código os identificadores citados. É incidente, não ajuste fino |
| "parafuso 3/4" continua não encontrando o produto | Campo com variação de formatação não normalizado, e o lado lexical não casa nenhuma das grafias | Consultar diretamente o índice lexical pelo termo e pelas variantes | Buscas sem resultado, agrupadas por termo | Normalizar unidade e formatação na ingestão. Ajustar só a consulta trata o sintoma de um termo por vez |
| A conversão caiu sem mudança de código | Reordenação fora do ar, ou contingência ativa | Conferir invocações do reordenador e ativação do caminho de contingência no período | Falhas de invocação do reordenador; ocorrências de contingência | Restabelecer e criar o alarme que faltava. Degradação silenciosa é a causa mais comum de queda de conversão sem culpado aparente |
| Produto aparece na busca e está indisponível na página | Índice defasado em relação ao estoque | Comparar o registro no índice com a fonte para aquele produto | Idade do índice | Conferir disponibilidade na fonte antes de exibir, ou assumir a defasagem e sinalizar. Indexar mais rápido só reduz a janela |
| A latência da página de resultado estourou | Reordenação sobre muitos candidatos no caminho crítico | Separar no traço recuperação, reordenação e geração | Latência das três etapas, em séries separadas | Reordenar seletivamente e cachear o topo. O orçamento aqui é o da paciência do comprador, não o de um atendente |
Limpeza: o que o destroy não leva
# limpeza.sh — ordem importa: o search pipeline referencia o dominio, e o
# dominio Multi-AZ com dedicated master NAO e gratuito parado.
# 1) Apagar o search pipeline antes do dominio
DOMINIO="https://$(terraform output -raw opensearch_produto_endpoint)"
curl -s -X DELETE "$DOMINIO/_search/pipeline/hibrido-produto-rrf" --aws-sigv4 "aws:amz:us-east-1:es"
# 2) terraform destroy cuida do dominio, das roles do IAM, do security
# group e do grupo de logs — mas confira o item 4 antes de fechar o
# navegador
terraform destroy -auto-approve
# 3) Confirma que o dominio nao existe mais
aws opensearch describe-domain --domain-name cadencia-busca-produto 2>&1 | grep -i "not found"
# 4) O secret do Secrets Manager tem retencao de 7-30 dias por padrao
# mesmo apos "excluido" — force-delete se nao precisar de janela de
# recuperacao
aws secretsmanager delete-secret \
--secret-id cadencia/opensearch-produto-master \
--force-delete-without-recovery
# 5) A distribuicao CloudFront e a mesma do L20 — NAO destrua aqui, ela
# continua servindo o catalogo. Só confirme que a origem de busca
# dinamica (behavior sem cache) foi removida se o dominio de busca
# saiu do ar.
O que continua cobrando depois de "destruir"
OpenSearch Service provisionado com dedicated master (3 instâncias `r6g.large.search` sempre ligadas, fora das 6 instâncias de dado) é o item que mais cobra parado deste laboratório — maior que o domínio do L85, porque a escala de catálogo pede mais capacidade. Confira o passo 3 antes de considerar o laboratório encerrado, e o secret do passo 4 antes de assumir que ele já parou de existir. A distribuição CloudFront (passo 5) é compartilhada com o L20 — apagá-la por engano tira do ar a renderização de produto inteira, não só a busca.
Resumo: problema, peça e motivo
| Problema | Peça | Motivo |
|---|---|---|
| Medida ou código escrito de forma diferente por fornecedor não é encontrado ("parafuso 3/4") | Busca vetorial (k-NN) | captura variação de formatação e sinônimo que uma consulta de substring nunca captura |
| Busca genérica devolve resultado sem ordem ("tinta branca") | Busca léxica formal (BM25) | pontua por relevância real de termo, em vez de tratar toda substring encontrada como igualmente relevante |
| Score de motores diferentes não é comparável | RRF (fusão por posição) | ranking, não valor bruto de score, decide a ordem final — mesma técnica do L85 |
| Top-N fundido ainda pode estar em ordem imprecisa | Bedrock Rerank (cross-encoder) | pontua o par busca+produto junto, mais preciso que embeddings comparados separadamente |
| IA generativa pode ser tentada a decidir a lista de produtos | Geração restrita a apresentação, com verificação de SKU/ordem em código | recuperação decide QUALIDADE; geração só apresenta — e o código, não o prompt, garante isso |
| Domínio de busca sobrecarregado por termo repetido a cada tecla | CloudFront cacheando termo popular | absorve 70% do tráfego sem sobrecarregar o domínio, sem servir resposta velha para termo raro |
| "Melhorou" sem prova de negócio | Teste A/B com significância estatística | mede conversão real, não taxa de acerto de golden set — é o que a escala de catálogo exige |
Perguntas frequentes
❓ Por que "parafuso 3/4" não encontrava o produto mesmo ele existindo no catálogo?
❓ Por que a busca por 'tinta branca' devolvia resultado ruim mesmo achando produtos?
❓ A geração (LLM) decide quais produtos aparecem na busca da Cadência?
❓ Por que não usar só o LLM para ler a busca e escolher os produtos certos?
❓ Qual foi o ganho de conversão medido no teste A/B deste laboratório?
❓ O CloudFront cacheia a resposta de busca, mesmo ela sendo dinâmica por termo?
❓ Por que o domínio OpenSearch precisa ser Multi-AZ aqui, se o L85 já usava zone awareness?
❓ O rerank roda em toda busca, mesmo nas mais simples?
Fixando
A arquitetura de produção deste laboratório combina BM25 (léxico) com k-NN (vetorial) via RRF, a mesma técnica do L85. Por que os dois motores juntos resolvem os dois padrões de busca ruim da Cadência, quando nenhum dos dois sozinho resolveria os dois?
Alguém no time da Cadência propõe deixar o Bedrock ler os 50 candidatos que a busca híbrida trouxe e decidir/reordenar quais produtos mostrar, argumentando que o LLM "entende melhor" a intenção do comprador. Por que este laboratório rejeita essa proposta, mesmo com o LLM já disponível?
Próximo laboratório
Próximo passo: L100 (projeto final — plataforma .NET 8 + AWS + IA)
Este laboratório aplicou a disciplina de recuperação do L85 em escala de catálogo, com um contrato específico: geração decide menos, mede-se cada vez mais. O L100 reúne os 99 laboratórios anteriores — recuperação híbrida, observabilidade, segurança, controle de custo, os seis pilares do Well-Architected — num sistema completo que alguém pagaria para operar, com revisão Well-Architected e DR ensaiado. A disciplina de "geração só apresenta, recuperação decide" que você acabou de construir é exatamente o tipo de decisão arquitetural que esse projeto final vai exigir revisar em conjunto com todas as outras.
Terminou de ler?
Marcar como concluído registra o XP, mantém sua sequência e coloca 3 cartas deste módulo na fila de revisão espaçada.
Próximos passos sugeridos
Temas deste módulo
Discussão
Carregando comentários…