# Aprendendo a usar IA na ingestão de dados

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

Por Sofia Carvalho, Pedro Mello, Erick Mendes · 26 de outubro de 2026 · Projetos da Síntese, Desafios de implementação, Inteligência Artificial · 17 min de leitura

Publicado em https://sinteselabs.com/artigos/processo-de-engenharia-do-productsearcher

---

#### **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](https://discord.gg/5vz4nBVSGv).

---

Fontes

- Notion, [Página do ProductSearcher](https://marmalade-linen-4f8.notion.site/ProductSearcher-3865658dda14806fba0ffe185835ea2f?pvs=74). Todos os dados são transparentes.
- Github, [Repositório do projeto](https://github.com/Erick-wilding675/ProductSearcher).

---

Síntese Labs · https://sinteselabs.com/artigos/processo-de-engenharia-do-productsearcher

