Skip to content

Product Search — Busca Semântica com Filtro Geográfico

Arquitetura e implementação da busca de produtos (experiências, guias, transfers, tickets) na plataforma iFriend.

Visão Geral

A busca de produtos usa similaridade semântica via embeddings no BigQuery ML, combinada com filtro geográfico por embedding multilíngue. O LLM nunca acessa dados brutos — toda a consulta é feita via ferramenta busca_produtos.

Usuário → "experiences in Florence Italy"
  │
  ├─ metadados_busca="experiences in Florence Italy"
  └─ destino="Florence"  ← extraído pelo LLM
       │
       ▼
  ┌──────────────────────────────────────┐
  │  BigQuery ML — 2 embeddings por query│
  │                                      │
  │  1. full_query_emb → score semântico │
  │     (title 0.4 + desc 0.3 +         │
  │      location 0.2 + features 0.1)   │
  │                                      │
  │  2. dest_emb → filtro geográfico     │
  │     COSINE < threshold (0.30)        │
  │     (multilíngue: Florence=Firenze)  │
  └──────────────────────────────────────┘

Arquivo Principal

Arquivo Descrição
ifriend_agent/tools/busca_produtos_tool.py Tool que executa a busca no BigQuery
ifriend_agent/tools/buscar_produtos_orcamento_tool.py Wrapper que busca MÚLTIPLOS interesses de uma vez e já exibe os cards (ver Wrapper para Múltiplos Interesses)
ifriend_agent/tools/context/enrich_products.py Enriquecimento pós-query (preço, catálogo, URL)
ifriend_agent/tools/blocks/experience_cards.py UI blocks para experiences
ifriend_agent/tools/blocks/guide_cards.py UI blocks para guias

Parâmetros da Tool

busca_produtos(tool_context, metadados_busca, destino, tipo_produto, guia_pro, limit, suppress_blocks)

Parâmetro Tipo Obrigatório Descrição
metadados_busca str Sim Texto livre da busca do usuário
destino str Não Cidade/estado/pais para filtro geográfico via embedding multilíngue
tipo_produto str Não "guia", "experience", "ticket", "transfer"
guia_pro bool Não Se True, filtra apenas guias profissionais
limit int Não Máx produtos no resultado (padrão: 10)
suppress_blocks bool Não Se True, retorna só lista sem UI blocks

SQL Gerado Dinamicamente

Sem destino (busca ampla)

WITH input_embedding_raw AS (
    SELECT ml_generate_embedding_result AS raw_emb
    FROM ML.GENERATE_EMBEDDING(MODEL `...mte002`, (SELECT @query_text))
),
input_embedding AS (
    SELECT CASE WHEN ARRAY_LENGTH(raw_emb)=768 THEN raw_emb
           ELSE ARRAY(SELECT 0.0 FROM UNNEST(GENERATE_ARRAY(1,768))) END AS embedding
    FROM input_embedding_raw
),
fallback_vector AS (
    SELECT ARRAY(SELECT 0.0 FROM UNNEST(GENERATE_ARRAY(1,768))) AS zero_embedding
)
SELECT
    p.*,
    (0.40 * ML.DISTANCE(ie.emb, p.title_embedding, 'COSINE') +
     0.30 * ML.DISTANCE(ie.emb, p.description_embedding, 'COSINE') +
     0.20 * ML.DISTANCE(ie.emb, p.location_embedding, 'COSINE') +
     0.10 * ML.DISTANCE(ie.emb, p.features_embedding, 'COSINE')
    ) AS distance
FROM `table` AS p, input_embedding AS ie, fallback_vector AS f
WHERE p.published=1 AND p.salable='online' AND p.deleted_at IS NULL
ORDER BY distance ASC
LIMIT {limit}

Com destino (filtro geográfico ativado)

Adiciona um CTE extra dest_emb que embedda o nome do destino separadamente, e um filtro WHERE comparando com location_embedding via distância cosseno:

WITH
    ...input_embedding... (mesmo de cima),
    dest_emb_raw AS (
        SELECT ml_generate_embedding_result AS raw_emb
        FROM ML.GENERATE_EMBEDDING(MODEL `...mte002`, (SELECT @destino))
    ),
    dest_emb AS (
        SELECT CASE WHEN ARRAY_LENGTH(raw_emb)=768 THEN raw_emb
               ELSE ARRAY(SELECT 0.0 FROM UNNEST(GENERATE_ARRAY(1,768))) END AS embedding
        FROM dest_emb_raw
    ),
    ...fallback_vector...
SELECT ... FROM `table` AS p, input_embedding AS ie, dest_emb AS de, fallback_vector AS f
WHERE p.published=1 AND p.salable='online' AND p.deleted_at IS NULL
  AND ML.DISTANCE(de.embedding, p.location_embedding, 'COSINE') < @geo_threshold
ORDER BY distance ASC
LIMIT {limit}

Pesos do Score Semântico

Os pesos foram normalizados para soma = 1.0 na implementação atual:

Campo Peso Função
title_embedding 0.40 Título do produto
description_embedding 0.30 Descrição do produto
location_embedding 0.20 Cidade, estado, país
features_embedding 0.10 Highlights + includes

Histórico de pesos:

Versão title desc location features Soma
Anterior (pré-jun/2026) 0.5 0.4 0.3 0.1 1.3
Atual (jun/2026+) 0.40 0.30 0.20 0.10 1.0

O peso do location_embedding foi reduzido de 0.3 para 0.2 porque o filtro geográfico explícito (threshold cosseno) já garante que produtos fora do destino sejam excluídos. O score residual de localização serve apenas para ranquear produtos dentro do destino.


Filtro Geográfico via Embedding

Como funciona

Quando o LLM extrai um destino e passa como destino="Florence":

  1. O texto "Florence" é embeddado pelo mesmo modelo mte002 em um CTE separado
  2. Esse embedding é comparado via ML.DISTANCE(..., 'COSINE') com o location_embedding de cada produto
  3. Produtos com distância cosseno ≥ threshold são excluídos do resultado

Vantagens do embedding para filtro geográfico

Cenário String match falha Embedding funciona
"Florence" vs cidade="Firenze" ❌ ✅ (mesmo embedding semântico)
"Switzerland" vs país="Suíça" ❌ ✅
"Munique" vs cidade="München" ❌ ✅
"Rio de Janeiro" vs state="Rio de Janeiro" ✅ (exato) ✅
"Nova York" vs city="New York" ❌ ✅

Threshold

Variável Default Descrição
GEO_FILTER_THRESHOLD 0.30 Distância cosseno máxima entre embedding do destino e location_embedding do produto

Guia de ajuste:

Threshold Comportamento Recomendado para
0.30 Restritivo — só produtos no destino Padrão recomendado
0.45 Permissivo — pode incluir cidades com perfil turístico similar Destinos sem muitos produtos
0.60 Permissivo — inclui destinos próximos Destinos sem muitos produtos

Normalização de texto auxiliar

Além do embedding, o destino passa por normalização _normalize_text() para remover acentos e lowercase:

" São Paulo "  → "sao paulo"
"Grecía"       → "grecia"
"München"      → "munchen"
"  "           → None (sem filtro)

Essa normalização não substitui o embedding — serve apenas como preparação do texto para gerar o embedding no BigQuery.


Wrapper para Múltiplos Interesses (buscar_produtos_orcamento)

quote_agent, custom_tour_agent e proposal_agent (fluxo de orçamento/proposta em PDF) não chamam busca_produtos diretamente — eles usam buscar_produtos_orcamento (tools/buscar_produtos_orcamento_tool.py), que busca vários interesses de uma vez (ex: ["guia local", "tour histórico"]) e já monta o carrossel de cards combinado numa única chamada de tool, em vez de depender do LLM encadear N buscas + exibir_opcoes_produtos.

async def buscar_produtos_orcamento(tool_context, interesses_json, destino, limit_por_interesse=12):
    for interesse in json.loads(interesses_json):
        texto, tipo_produto = _parse(interesse)  # string simples OU {"query": ..., "tipo_produto": ...}
        query = f"{texto} {destino}".strip()
        resultado = await busca_produtos(
            tool_context=tool_context,
            metadados_busca=query,
            destino=destino,          # ⚠️ obrigatório — ver nota abaixo
            tipo_produto=tipo_produto,
            limit=limit_por_interesse,
            suppress_blocks=True,
        )
        # ...combina e chama exibir_opcoes_produtos no final

Cada item de interesses_json pode ser: - uma string simples ("tour gastronômico"); ou - um objeto {"query": "...", "tipo_produto": "guia"} — use quando o usuário pedir EXPLICITAMENTE um guia humano/acompanhante/pessoa local. Sem isso, a busca semântica tende a priorizar experiences (passeios) em vez de guias de verdade, já que tipo_produto é o único jeito de ativar o filtro product_type = 'guia' (ver Parâmetros da Tool).

⚠️ Gotcha corrigido: metadados_busca inclui o destino como TEXTO (f"{texto} {destino}"), mas isso não ativa o filtro geográfico — só o parâmetro destino= de busca_produtos ativa o CTE dest_emb/ML.DISTANCE(...) < @geo_threshold (ver Filtro Geográfico via Embedding). Uma versão anterior de buscar_produtos_orcamento não repassava destino=destino para busca_produtos — a busca funcionava "bem o suficiente" para experiences (cujo título/descrição geralmente menciona a cidade), mas guias (pessoas) retornavam de qualquer país, já que o texto livre sozinho não segura o filtro. Ao adicionar/editar qualquer wrapper que chame busca_produtos internamente, sempre repasse destino= explicitamente — não confie em concatenar o destino no texto da query.

Fallback Progressivo

Quando uma busca retorna zero resultados, a tool tenta variações automaticamente:

Tentativa 1: com tipo_produto + destino
  ↓ (vazio)
Tentativa 2: sem tipo_produto, com destino
  ↓ (vazio)
Tentativa 3: com tipo_produto, sem destino
  ↓ (vazio)
Tentativa 4: sem tipo_produto, sem destino (busca ampla)

Cada tentativa é logada com [busca_produtos] Fallback (...): N produtos.


Enriquecimento Pós-Query

Após a query SQL, os resultados passam por 4 estágios de enriquecimento:

Estágio Função Descrição
1 context_product_price Aplica markup do afiliado sobre price_net
2 context_product_catalog_status Verifica status online/offline via API
3 context_product_affiliate_url Substitui URL base pela do afiliado
4 context_product_whitelabel_url Substitui URL base pela do whitelabel

Agentes que Utilizam a Busca

Agente Tipo de busca Parâmetros típicos
discovery_agent Por destino + tipo (direto, via busca_produtos) destino="Salvador", tipo_produto="experience"
itinerary_agent Multi-destino (direto, via busca_produtos) destino="Paris", suppress_blocks=True
support_agent Pré-qualificação (direto, via busca_produtos) destino="<se conhecido>", limit=5
quote_agent, custom_tour_agent, proposal_agent Múltiplos interesses de uma vez, via buscar_produtos_orcamento (ver seção acima) interesses_json='[{"query": "guia local", "tipo_produto": "guia"}, "tour histórico"]', destino="Lisboa"

Testes

Arquivo Cobertura
runtime/evaluations/unit/busca_produtos.test.json Cenários com e sem destino, tipos de produto
ifriend_agent/tests/test_sub_agents.py Ferramentas dos agentes que usam busca_produtos

Variáveis de Ambiente

Variável Default Descrição
DATASET Ifriend_produto Dataset BigQuery
TABLE mv_product_search_flat_embeddings Tabela materializada com embeddings
EMBEDDING_MODEL_NAME mte002 Modelo de embedding (768D)
GEO_FILTER_THRESHOLD 0.30 Threshold para filtro geográfico via embedding

Roadmap Futuro

  • [ ] Hybrid search: Dense + BM25 para melhor recall em termos exatos
  • [ ] Reranker: BGE-reranker-v2 após retrieval para refinar top-k
  • [ ] Query rewriting: LLM reescreve a query do usuário antes de embeddar
  • [ ] Fine-tuning: Ajustar modelo de embedding para domínio de turismo brasileiro
  • [ ] Logs de feedback: Capturar cliques e conversões para treino contínuo

Detalhes no roadmap de fine-tuning em docs/archive/roadmap_finetuning_embeddings_turismo_rag.md