Pular para o conteúdo
Projetos da Síntese

Aprendendo a usar IA na ingestão de dados

Em projetos AI First, nem sempre é necessário colocar IA em tudo.

PorSofia Carvalho,Pedro Mello eErick Mendes

17 min de leitura
capa-productsearcher
Seções deste artigo

Resumo

  • Durante o desenvolvimento de uma ferramenta que idealizamos para validação em Venture, passamos por uma sequência de falhas que mudou onde decidimos colocar a IA: na ingestão do catálogo, uma vez, e não na resposta ao usuário.
  • Na nossa suíte de 10 consultas, a cobertura no top 5 foi de 27% para 100% e a precisão de 16% para 68%. Boa parte do caminho veio de uma extensão do Postgres (unaccent), sem IA nenhuma.
  • Construímos busca vetorial, fusão por RRF, parser com LLM e explicação com LLM. Medimos, e desligamos ou recusamos as quatro.
  • A pior classe de bug foi a que não dá erro e só devolve menos.

Durante o desenvolvimento de uma ferramenta que idealizamos para validação como Venture, passamos por uma busca que devolvia zero

A ferramenta ajuda quem está decidindo o que comprar. Ela interpreta a consulta, encontra produtos em um catálogo curado, ordena com critérios que o usuário consegue enxergar e compara os itens lado a lado, e dois clientes consomem a mesma API, um web app e uma extensão do Chrome que responde na própria página de resultados do Google, sem abrir chat. A regra que nos guiou desde o primeiro documento foi que o sistema precisa funcionar inteiro sem nenhum modelo de linguagem, e que a IA só entra onde ela ganhar de forma medida, e foi essa regra que acabou nos poupando de bastante trabalho que parecia promissor.

Na primeira rodada contra o catálogo real a consulta notebook gamer ate R$8000 devolveu zero resultados, enquanto notebook gamer devolvia 29. Quando fizemos uma auditoria no meio do projeto encontramos três causas, e nenhuma delas lançava erro. A primeira era o slug de categoria, porque o parser devolvia notebook e o catálogo gravava notebooks, e com isso todo filtro por categoria zerava em silêncio. A segunda vinha do plainto_tsquery, que combina os termos com AND, e como o parser extraía o teto de preço mas deixava ate, r e 8000 na consulta, esses termos viravam obrigatórios e nenhum título de produto os tem. A terceira eram as palavras de intenção, já que o dicionário portuguese do Postgres descarta "para" e "com" mas mantém "melhor", "barato" e "quero", e elas entravam no AND e zeravam a busca do mesmo jeito.

A correção que implementamos foi separar o que a pessoa digitou (raw, usado em log e interface) do resíduo que vai para o índice de texto (text), e com isso melhor notebook ate R$5000 saiu de 0 para 41 candidatos. A mesma auditoria nos mostrou algo mais incômodo, porque várias tarefas marcadas como concluídas na verdade não funcionavam. A suíte de relevância nunca tinha chegado a rodar, o cliente de API descrevia endpoints que não existem e 48 testes do worker não executavam na CI, e foi daí que veio a nossa desconfiança de qualquer "concluído" que ninguém exercitou de ponta a ponta.


A stack que escolhemos, em poucas linhas

Cada escolha tem um registro de decisão (ADR) com as alternativas descartadas e o gatilho para revisar.

FastAPI em monólito modular

O backend é um único serviço, dividido em módulos com fronteira clara (catalog, search e ai). Isso nos dá um deploy só e separação de responsabilidades sem pagar o custo de operar vários serviços. Para uma equipe pequena, microserviços seriam trabalho de operação sem ganho em troca.

Postgres no Supabase, com busca textual e pgvector

Texto e vetor vivem no mesmo banco. Com isso não precisamos manter um serviço de busca separado, e a mesma consulta pode combinar filtros, texto e, quando for o caso, similaridade vetorial.

Fly.io, com a máquina sempre ligada

O popup da extensão não tolera coldstart, porque a pessoa abre e espera a resposta na hora. Por isso descartamos hospedagens que desligam a aplicação por inatividade. O Fly.io nos deu uma máquina pequena e sempre ligada.

Banco na mesma região, em sa-east-1

Uma busca faz de 3 a 5 idas ao banco. Com o banco no exterior, cada ida pagaria o preço do oceano dentro de um orçamento de 500 ms. Colocar o Supabase em sa-east-1, ao lado do backend, resolveu isso na arquitetura, e não com cache.

Next.js na Vercel

No frontend queríamos o mínimo de infraestrutura para cuidar. A Vercel publica a cada push e nos deixa focar na interface, enquanto a lógica de busca fica toda na API.

GitHub Actions

O deploy do backend só dispara depois que os testes passam, e publica exatamente o commit que foi testado. Assim, o que está em produção é sempre algo que passou pela suíte.

Catálogo curado, gerado offline

O catálogo vem de um seed em YAML, montado fora da produção com Apify e a API do Mercado Livre. Não há scraping em produção nem fonte externa no caminho crítico da busca, então uma instabilidade de terceiros não derruba a resposta.

Keep-alive pelo próprio Fly

Usamos o health check do Fly para manter a API acordada e o banco ativo. Ele faz uma sondagem periódica com SELECT 1, sem precisar de cron nem de segredo extra para isso.


A régua veio antes de qualquer IA

Antes de apostar em modelo nós escrevemos o placar. A suíte de casos de uso tem 10 consultas, como "notebook para jogos", "fone para academia" e "fone para viagem de avião", e duas decisões de desenho nela valem mais do que o código.

A primeira é que o ground truth de cada caso é uma regra sobre as especificações do produto e nunca o nome. O índice de texto é construído sobre nome, modelo e descrição, então usar o nome como verdade mediria apenas se a busca acha o que a busca indexa, e daria nota alta sem servir melhor o usuário. Um caso de "jogos", por exemplo, exige que o notebook tenha placa de vídeo.

A segunda é que cada caso traz uma coluna de acaso, ou seja, a precisão que teríamos devolvendo itens aleatórios. Um predicado que metade do catálogo satisfaz não mede nada, porque devolver qualquer coisa já acerta, e por isso descartamos os predicados largos demais, já que 60% de precisão é ótimo onde o acaso é 20% e é um fracasso onde o acaso é 70%.

Também limpamos o catálogo antes de medir, com 141 duplicatas removidas, porque medir recall com duplicata falsearia o acaso em até 18 pontos. O baseline, então, foi de 27% de cobertura no top 5, 16% de precisão média e 7 consultas em 11 com zero resultado.

Metade do caminho veio de uma extensão do Postgres

Nossa suspeita inicial era semântica, algo como o usuário digitar "escritório" e o anúncio falar "home office", mas a causa de boa parte dos zeros era bem mais simples. O corpus é texto de marketplace, escrito com acento, e a consulta em português costuma chegar sem. A palavra escritorio casava com 0 documentos e escritório com 15, e como o AND exige todos os termos, um único termo sem acento zerava a consulta inteira.

Criamos uma configuração de busca própria, a portuguese_unaccent, usada tanto na coluna gerada quanto na consulta, e o detalhe que mais importa aqui é que se coluna e consulta usarem configurações diferentes não existe erro nenhum, só um resultado menor. Sem IA nenhuma o coverage foi de 27% para 55% e a precision de 16% para 34%. O passo seguinte, ainda sem IA, foi ensinar o parser a transformar faixas numéricas como "até 1,5kg" em filtro duro sobre a especificação, e isso levou o coverage a 60% e a precision a 37%.

Fizemos esses ajustes antes de qualquer LLM por uma questão de método, porque medir o LLM antes creditaria a ele um ganho que era do unaccent, e o mesmo critério tirou um caso do agregado mais tarde, já que quando o parser passou a resolver "fone com bateria para o dia todo" com um filtro duro a precisão daquele caso virou 100% por construção, e ele deixou de ser medida de IA e virou controle. O custo também entrou no registro, porque com a dobra de acento na frente o stemmer enfraquece e "programação" deixa de virar program e vira programaca, algo que aceitamos e escrevemos o porquê.

A IA entrou para preparar o dado, uma vez

O que sobrou depois disso eram necessidades que o título do anúncio simplesmente não diz, como "para faculdade" ou "leve para viagem". A resposta mais comum seria interpretar toda consulta com um modelo em runtime, mas nós preferimos o caminho inverso.

Um LLM recebe, para cada produto, nome, descrição e as especificações que já estão no banco, e devolve rótulos de um conjunto fechado por categoria (jogos, trabalho, estudo, edicao-video, programacao e portabilidade em notebooks, e esporte, chamadas, viagem, trabalho e jogos em fones), com qualquer rótulo fora do conjunto descartado. O resultado é gravado uma vez em um JSONB, e em runtime "notebook para jogos" vira um filtro comum de containment sobre esse JSONB, no caminho que já existia, o que significa nenhuma chamada a modelo no caminho da resposta, nenhuma latência extra e nenhum provedor que possa cair.

Três cuidados de calibração valem o registro:

  • Teto de três rótulos por produto. Na primeira passada, um notebook de 2,49 kg recebeu os seis rótulos, portabilidade inclusive. Rótulo que vale para todo mundo não filtra nada.
  • Rótulo é recomendação, não inventário. O prompt pede o que o produto faz melhor que os concorrentes da mesma categoria, e não tudo o que ele aguenta.
  • O glossário define a necessidade, nunca o limiar. O gabarito da suíte diz que "leve" é até 1,6 kg. Se escrevêssemos isso no prompt, o rotulador repetiria o teste que deveria ser medido por ele. O rotulador julga, a suíte julga o rotulador, e os dois não podem ler a mesma régua.

Rodamos o rotulador em um modelo aberto de 120 bilhões de parâmetros, com temperatura zero, e 213 dos 235 produtos receberam ao menos um rótulo, enquanto os outros 22 têm fichas magras demais para sustentar qualquer recomendação. Comparando cada rótulo com o predicado da suíte, oito das nove necessidades medidas bateram a régua com folga contra o acaso, e as que ficaram mais fracas tinham explicação, sendo "faculdade" o melhor exemplo, já que o gabarito dela inclui teto de preço e o rotulador não vê preço de propósito, porque preço muda e rótulo carimbado não.

Com o filtro no ar o placar ficou assim.

Medida

Início

Após unaccent

Após filtro numérico

Após rótulos

Coverage@5

27%

55%

60%

100%

Precision@5 (média)

16%

34%

37%

68%

Consultas com zero resultado

7 de 11

2 de 11

2 de 10

0 de 10

Metade desse ganho veio sem IA nenhuma, e a outra metade veio de uma IA que roda só na ingestão, e isso nos deixou com a parte de IA do sistema exatamente onde ela é barata e verificável, com o resultado gravado e medido contra um gabarito que ela não enxerga.

Construímos a busca vetorial e a desligamos

Nada disso quer dizer que ignoramos o caminho semântico, porque nós construímos ele inteiro, com um modelo de embedding multilíngue de 768 dimensões (e5-base) rodando dentro da própria API em ONNX com quantização int8, carga offline dos vetores, pgvector e uma união com o resultado textual por Reciprocal Rank Fusion. Depois medimos.

Configuração

Precision@5

Coverage@5

Busca textual + rótulos (a que ficou)

68%

100%

Vetorial sozinha

34%

90%

Hybrid por RRF

64%

90% a 100%, conforme a medição

A busca vetorial acertou a categoria em todos os casos e errou a especificação, o que faz sentido porque os predicados da suíte são sobre placa de vídeo, memória, peso e preço e nada disso está no texto vetorizado. Tentamos a correção óbvia, que era colocar as especificações dentro do texto, e a precisão piorou de 42% para 36%, e isso nos mostrou que vetor não substitui filtro sobre atributo.

As outras duas peças de IA que o plano previa tiveram o mesmo fim, e por um critério que escrevemos antes de medir. O parser com LLM só entraria se a consulta voltasse vazia depois do parser de regras e do FTS, e nenhuma consulta da suíte voltava vazia. A explicação com LLM só valeria se acrescentasse algo à versão determinística, que já narra exatamente os fatores do ranking e tem quatro regras de fidelidade escritas como teste, e um LLM só reescreveria a mesma frase e faria essas regras deixarem de ser verificáveis.

Houve também uma tentação que recusamos. Uma varredura do limiar de disparo do fallback achou 70% de precisão, mas os pools se concentravam em exatamente 20 itens, então o limiar estava encaixado na distribuição daquela suíte, e o que parecia ganho era overfit de 10 casos em um movimento de dois pontos, que equivale a um único item mudando de lugar.

O caminho vetorial continua construído, testado e desligado por feature flag, e a razão não é teimosia. A evidência contra ele é estreita, com 235 produtos, duas categorias e 10 consultas escritas por nós, e os casos em que o vetorial plausivelmente ajuda, como erro de digitação e sinônimo imprevisto, são justamente os que uma suíte curada não contém. Ele espera uso real para ser reavaliado, e só com medição nova por cima, porque proposta de IA sem número é proposta no vazio.


Os bugs que não davam erro

Quase tudo que nos custou tempo tinha a mesma assinatura, o sistema não falhava e só devolvia menos, e alguns casos nos ensinaram bastante.

Os rótulos que sumiam. O upsert de product_specs substituía o JSONB de atributos inteiro, e com isso qualquer recarga do seed apagava a rotulagem. A consulta "notebook" respondia normalmente, e "notebook gamer" voltava vazia sem uma linha de erro. A correção foi mesclar o JSONB (||) em vez de substituir, e o trade-off ficou escrito, porque quem mescla preserva também o que o seed deixou de conhecer, então a ingestão agora avisa toda chave órfã em vez de apagá-la ou mantê-la em silêncio.

A medida lida como dinheiro. "Notebook até 1,5kg" virava price_max = 15.0, porque o parser lia o ponto como separador de milhar e o "1,5" virava quinze reais, e a busca voltava com zero resultados. Hoje a unidade colada ao número diz ao parser que aquilo não é preço, e a mesma consulta vira weight_kg <= 1.5 e devolve 7 resultados.

O merge que virava array. Ao montar o lado direito do || com um cast(str, JSONB), o driver serializava uma string, e no Postgres objeto || escalar devolve um array, então {"ram_gb": 16} virou [{"ram_gb": 16}, "{...}"] sem nenhum erro. Só um teste de integração pegou isso.

O prefixo esquecido no modelo de embedding. O e5 degrada se query: ou passage: faltar e não avisa, e por isso o prefixo ficou encapsulado dentro do provider, sem nenhum caminho público que aceite texto cru. Pelo mesmo princípio extraímos as condições duras da busca, como preço e marca, para uma função única compartilhada pelos dois braços da busca, e com isso divergência deixou de ser proibida por convenção e passou a ser impossível por construção.

O script que nunca tinha rodado. O cleanup_orphans só tinha exercitado o --dry-run, e na primeira execução real morreu com um erro de SQL, porque faltava declarar o parâmetro como expanding. A transação abortou sem apagar nada, e a lição virou regra, caminho que o dry-run não percorre não está testado. Ela se repetiu depois no rotulador, cuja guarda de validação rejeitava todo fone, porque cobrava atributos obrigatórios que a ficha dos fones não tinha.

O dado também nos enganou

Dois problemas de dados nasceram do mesmo erro, o de confiar em algo que nunca tinha sido exercitado.

O enriquecimento do catálogo pela API do Mercado Livre nunca tinha funcionado. Os identificadores do seed eram de produto de catálogo e o endpoint que usávamos devolvia 404 em 10 de 10 amostras, então tudo que havia no banco vinha do parser de título. Quando corrigimos, a identidade do produto passou a ser o parent_id da API, com o SKU do fabricante como desempate, e as variantes de cor foram fundidas somando as ofertas, e com isso a busca parou de mostrar o mesmo produto cinco vezes.

Depois disso um notebook apareceu anunciado por mais de dois milhões de reais. O valor estava na fonte e na API e não havia como saber o preço certo, e corrigir seria inventar dado, então decidimos classificar em vez de descartar. A ingestão marca a oferta como rejeitada, grava o motivo e preserva o original, e o limite é estatístico por categoria, o maior entre cinco vezes a mediana e duas vezes o percentil 99, com a regra desligada quando a categoria tem menos de 10 preços. Preço, ranking e comparação leem só ofertas válidas, e uma consulta mostra exatamente o que foi tirado de circulação. Os multiplicadores saíram da distribuição do catálogo atual e não de um gabarito, e o registro de decisão diz isso.


Medir em condição real mudou um requisito

Para rodar o embedding dentro da API nós medimos o servidor com uma CPU limitada e não a máquina de desenvolvimento, que enxerga 16. Com a configuração padrão o p95 de uma consulta foi de 804 ms, e com uma única variável de ambiente (OMP_NUM_THREADS=1) foi para 63 ms, treze vezes menos. O runtime dimensiona o pool de threads pelas CPUs que enxerga, enquanto o container concede uma, e na máquina de 16 vCPU esse número nunca teria aparecido. Fixamos as threads também no código, para que a configuração errada deixasse de ser possível.

A quantização int8 e o ONNX levaram a imagem de 2.546 MB para 739 MB, o pico de memória de 1.220 MB para 872 MB e a carga do modelo de 10,5 s para 2,5 s, sem trocar nem o modelo nem a dimensão. O tamanho da imagem acabou virando dois números, porque 404 MB é o que se puxa e 886 MB é o que ocupa em disco, e as ferramentas do Docker davam três valores diferentes para o mesmo artefato, o que nos ensinou que citar tamanho sem dizer o método é como citar número sem unidade.

Como a busca vetorial ficou desligada, a produção nem carrega o modelo, e a API leve usa cerca de 87 MB de memória, e foi isso que deixou uma máquina pequena dar conta do host. É uma consequência direta de termos medido antes de decidir.

O que isso não prova

  • A suíte tem 10 consultas, escritas por nós. Dizemos "na nossa suíte" porque o gabarito vem da mesma equipe e porque o denominador passou de 11 para 10 casos no meio do caminho, então o salto é real nessa régua, e a régua é pequena.
  • A evidência contra a busca vetorial é estreita. Ela vale para 235 produtos em duas categorias e não é um argumento geral contra busca semântica.
  • Ainda temos pouco uso real. A mediana de resposta medida de fora, internet incluída, ficou em 349 ms, mas o p95 medido dentro do servidor segue pendente.
  • O teste de relevância deixou de rodar em todo merge. A CI ficou vermelha porque os rótulos vivem no banco e não no seed versionado, então pulamos as consultas que dependem deles, registramos o custo por escrito e deixamos como correção de raiz versionar os rótulos no seed. Trocamos um vermelho sem informação por um verde honesto mais uma lacuna declarada.
  • A hipótese de produto continua sendo hipótese. A ferramenta existe para validação em Venture, e a ideia de um assistente contextual dentro da página de busca ainda precisa de usuário real para ser confirmada ou refutada.

O que levaríamos para o próximo projeto

Se tivéssemos que resumir o que aprendemos em uma frase, seria:

Escrever a suíte antes de escolher a técnica foi o que tornou todo o resto detectável e com isso o ground truth por fato do produto, a coluna de acaso e o gatilho escrito de cada decisão deixaram de ser burocracia e viraram o jeito de saber quando parar.

Consertar o que não é IA primeiro, como acento, parser e dado limpo, nos mostrou quanto do ganho era de cada coisa, e colocar a IA na ingestão antes de colocá-la na resposta nos deu um sistema que continua funcionando se o provedor cair. E registrar o erro no próprio documento, com número corrigido e custo assumido, foi o que nos permitiu rever cada passo sem recomeçar do zero.

Se você trabalha com busca, dados de marketplace ou IA aplicada e discorda de algum ponto, queremos ouvir, e a conversa acontece na comunidade da Síntese (abre em outra aba).


Fontes

Quem escreveu

Escrito por Sofia Carvalho, Pedro Mello e Erick Mendes.

sofia-carvalho

Sofia Carvalho

Desenvolvedora full-stack

Membro da Comunidade. Escreve sobre sobre inteligência artificial e inovação digital.

LinkedIn
pedro-mello

Pedro Mello

Desenvolvedor full-stack

Membro da Comunidade. Escreve sobre engenharia de software e produto.

LinkedIn
Retrato de Erick Mendes

Erick Mendes

Produto e Capacitação

Sócio da Síntese Labs. Escreve sobre a evolução da Inteligência Artificial no Brasil.

LinkedIn
Projetos da SínteseDesafios de implementaçãoInteligência Artificialbusca semânticabusca vetorialfull-text searchpgvectorPostgreSQLrelevância de buscamétricas de relevânciahybrid searchReciprocal Rank FusionIA na ingestãorotulagem com LLMLLM em produçãoarquitetura de buscaqualidade de dados

Continuar lendo

Tem um problema parecido para resolver?

A Síntese constrói software sob medida e capacita times em desenvolvimento agêntico, com cada etapa observável e rastreável.

Falar com a Síntese