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/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.


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 destino="Salvador", tipo_produto="experience"
itinerary_agent Multi-destino destino="Paris", suppress_blocks=True
support_agent Pré-qualificação destino="<se conhecido>", limit=5

Testes

Arquivo Cobertura
ifriend_agent/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