Skip to content

Product Search via Qdrant — Nova Tool Vetorial (complementar ao BigQuery)

Documento de design da nova busca de produtos (experiências, guias, transfers, tickets) usando Qdrant como vector store, complementar à tool busca_produtos (BigQuery), sem substituí-la.

Visão Geral

A tool atual busca_produtos (ifriend_agent/tools/busca_produtos_tool.py) roda, a cada chamada do agente, uma query no BigQuery que: (1) gera o embedding da query via ML.GENERATE_EMBEDDING (modelo mte002, 768D, Vertex AI); (2) faz um full scan em mv_product_search_flat_embeddings calculando ML.DISTANCE (COSINE) contra 4 colunas de embedding (title, description, location, features, pesos 0.40/0.30/0.20/0.10); e (3), quando há destino, roda uma segunda passada de embedding + ML.DISTANCE para inferir o país mais provável e aplicar um filtro geográfico (threshold 0.30). Isso custa 2 chamadas de embedding + 1-2 full scans por busca, sem índice vetorial de fato.

Este documento propõe uma nova tool que usa Qdrant (já em produção para a Knowledge Base) como vector store para produtos, com um pipeline de indexação próprio — sem depender do BigQuery.

Fonte (CloudSQL MySQL, view de produtos)
  │
  ▼
Cloud Run Job (Cloud Scheduler dispara periodicamente)
  │  1. lê produtos novos/alterados (watermark por updated_at)
  │  2. gera embeddings localmente (fastembed, paraphrase-multilingual-MiniLM-L12-v2, 384D)
  │  3. upsert no Qdrant (collection "products")
  ▼
Qdrant (qdrant-aiagent-service)
  │
  ▼
Nova tool do agente (busca_produtos_qdrant)
  │  1. embedda a query do usuário localmente (mesmo modelo)
  │  2. filtro payload (tipo, geo exato, negócio) + busca vetorial nomeada
  │  3. reaproveita enrich_products.py + blocks/*.py já existentes
  ▼
Resposta ao agente (mesmo formato de busca_produtos)

1. Diagnóstico do Schema (BigQuery mv_product_search_flat_embeddings e a view-fonte real no MySQL)

O primeiro diagnóstico deste documento foi feito em cima do schema do BigQuery (a tabela já com embeddings). Como a fonte real do novo pipeline é a view mv_product_search_flat no CloudSQL (MySQL 5.7) — o dado antes de qualquer embedding — vale registrar o schema real dela, que revela campos que a análise inicial não conhecia:

CREATE TABLE `mv_product_search_flat` (
  `product_id` bigint(20) unsigned NOT NULL,
  `source_type` enum('ifriend','experience') NOT NULL,
  `product_type` varchar(30),
  `title` varchar(255), `description` longtext,
  `city` varchar(100), `state` varchar(100), `country` varchar(100), `country_code` varchar(3),
  `latitude` decimal(14,8), `longitude` decimal(14,8),
  `place_names` text, `interests` text, `languages` text,
  `highlights` text, `includes` text, `excludes` text,
  `variations` text, `provider_skus` text,
  `duration` varchar(15), `price` decimal(14,2), `currency_code` varchar(4),
  `rating` decimal(3,2), `review_count` int(11), `with_presential_guide` tinyint(1),
  `published` tinyint(1), `salable` varchar(30), `featured` tinyint(1), `is_green` tinyint(1),
  `doc_text` longtext,
  `canonical_url` varchar(255), `display_url` tinytext, `updated_at` datetime,
  `pro` tinyint(4), `price_net` decimal(14,2), `deleted_at` datetime, `agency_id` bigint(20) unsigned,
  `photo_url` varchar(255), `needs_confirmation` tinyint(4), `confirmation_by` varchar(30),
  PRIMARY KEY (`product_id`,`source_type`),
  KEY `idx_place` (`country_code`,`state`,`city`),
  KEY `idx_price` (`price`),
  KEY `idx_flags` (`published`,`featured`,`is_green`),
  FULLTEXT KEY `ft_semantic` (`title`,`description`,`doc_text`)
)

Achado crítico: a chave primária é composta (product_id, source_type). product_id sozinho não é único — a mesma view combina duas tabelas de origem diferentes (ifriend e experience, cada uma com seu próprio autoincrement), então um guia (source_type='ifriend') e uma experiência (source_type='experience') podem ter o mesmo product_id. Isso é tratado em detalhe na seção 3 (design do Point ID do Qdrant) — é um cuidado que a análise inicial deste documento (feita só sobre o schema pós-embedding do BigQuery, que não expõe source_type na tool atual) não tinha capturado.

O que já é uma boa base

Característica Por quê ajuda
Conteúdo já particionado em fatias semânticas (title, description, location, features) Base pronta para vetores nomeados no Qdrant — evita "um vetor genérico que dilui sinal"
Campos de filtro de negócio já existem (published, salable, deleted_at, needs_confirmation, pro, product_type, agency_id, price/price_net, currency_code, rating, review_count, featured, is_green) Baratos de indexar como payload filtrável, sem precisar de embedding para isso
updated_at presente Permite sync incremental determinístico (watermark)
latitude/longitude já existem na view Diferente do que o diagnóstico inicial assumia (geo só por texto livre) — isso permite filtro geográfico exato por distância real, não só aproximação por embedding (ver seção 3, revisado)
interests já existe (texto livre) Já é, em essência, o campo de "tags/categoria" que a análise inicial recomendava criar do zero — precisa só ser normalizado (ver lacunas)
languages já existe (texto livre) Idem — o dado já existe na origem, só não chega estruturado até guide_cards.py hoje
doc_text (concatenação usada no índice FULLTEXT ft_semantic do MySQL) Já é um texto único title+description+extra pronto para uso — interessante como insumo futuro de busca híbrida (BM25 já existe no MySQL, dense no Qdrant)
place_names, excludes, variations, provider_skus, with_presential_guide, confirmation_by Campos adicionais disponíveis na origem, não usados hoje pela tool BigQuery — candidatos a payload conforme necessidade

Lacunas que reduzem precisão hoje

Lacuna Impacto Recomendação
interests/languages existem mas como texto livre não normalizado (não array) Não dá pra indexar como payload keyword diretamente — precisa de parsing/normalização no job de sync Job de sync faz o split/normalize (ex.: "espanhol, inglês" → ["es","en"]) antes de gravar no payload
duration é string livre Não é possível filtrar "passeios de até 3 horas" Normalizar para duration_minutes (int) no job de sync
Sem sinal de popularidade/conversão (cliques, bookings, CTR) Ranking não aprende com comportamento real — já listado como roadmap em docs/search/PRODUCT_SEARCH.md Reservar um campo numérico no payload do Qdrant para boost de score quando o dado existir
product_id sozinho não é chave única (ver achado crítico acima) Risco de colisão de Point ID no Qdrant se não tratado explicitamente Ver seção 3 — Point ID derivado de (source_type, product_id)
product_type_embedding (no BigQuery) é computado mas não é usado no ranking atual (confirmado em busca_produtos_tool.py) Custo de embedding pago à toa Não replicar esse vetor no Qdrant — product_type é enum fechado, resolve-se com payload filter

Conclusão: o dado de origem é mais rico do que o diagnóstico inicial (feito só sobre o schema pós-embedding do BigQuery) sugeria — interests, languages, latitude/longitude e doc_text já existem na view MySQL. O trabalho do job de sync é menos "inventar campo novo" e mais "normalizar o que já existe" para virar payload filtrável, além de resolver corretamente a chave composta.


2. Estratégia de Embeddings

Modelo

Atualização (deploy em stage): intfloat/multilingual-e5-small foi removido do catálogo de modelos suportados pelo fastembed numa versão mais recente da lib (fastembed>=0.3.0, sem pin, resolveu para 0.8.0 no build e o job passou a falhar com Model ... is not supported in TextEmbedding). O modelo usado agora é sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2 — mesma dimensão (384D), mesmo racional de escolha (leve, multilíngue, ~50 idiomas incl. PT/EN/ES), e suportado na versão atual. Diferente do e5-small, não precisa de prefixos "query: "/"passage: ". fastembed foi fixado em ==0.8.0 em runtime/workers/product_search_sync/requirements.txt para evitar repetir esse tipo de quebra numa rebuild futura. Isso já não é mais o mesmo modelo da Knowledge Base — as duas features usam modelos diferentes agora (a KB ainda depende de intfloat/multilingual-e5-small via fastembed>=0.3.0 sem pin, o que é o mesmo risco não tratado — ver observação no fim desta seção).

Texto original desta seção (mantido como histórico da decisão de dimensão/latência, hoje com o modelo trocado por indisponibilidade, não por preferência):

Local via fastembed — mesmo modelo já em produção na Knowledge Base: intfloat/multilingual-e5-small, 384D (runtime/workers/knowledge_base/embedder.py, ifriend_agent/tools/knowledge_base_tool.py).

Decisão revista: a versão inicial deste documento propunha multilingual-e5-base (768D) só para manter paridade dimensional com o BigQuery/mte002. Essa paridade era conveniência, não requisito funcional (os dois pipelines de embedding já são independentes — ver nota abaixo). Diante da prioridade de reduzir latência percebida pelo usuário (reclamações já existentes sobre demora do agente), a escolha final é e5-small:

Critério e5-small (384D) e5-base (768D)
Parâmetros / custo de inferência Menor — melhor latência por query ~3x mais parâmetros
Qualidade em PT/EN/ES A família E5 multilíngue é treinada no mesmo corpus para os dois tamanhos; o gap de qualidade entre small e base é mais visível em idiomas de poucos recursos. PT/EN/ES são idiomas de alto recurso, então a perda de qualidade esperada é pequena Levemente melhor, mas ganho marginal para esses 3 idiomas
Reuso de infraestrutura Mesmo modelo da Knowledge Base — pode compartilhar a mesma instância singleton do modelo em memória entre as duas features, economizando RAM Modelo adicional carregado em memória
Paridade dimensional com BigQuery Não Sim (mas não é usada por nada hoje)

Se uma avaliação offline (comparando recall em uma amostra de queries reais contra os produtos esperados) mostrar que o modelo atual não atinge qualidade suficiente, o upgrade para um modelo maior (768D/1024D) fica documentado como opção — a collection pode ser reindexada com outro modelo/dimensão sem impactar a Knowledge Base, já que são coleções independentes.

Atualização — risco na Knowledge Base parcialmente endereçado: fastembed foi fixado em ==0.8.0 e o modelo trocado para paraphrase-multilingual-MiniLM-L12-v2 também em ifriend_agent/requirements.txt e runtime/workers/knowledge_base/requirements.txt — resolve o risco de quebra no próximo rebuild. Mas a reindexação das collections por afiliado já em produção (indexadas com o modelo antigo) ainda não foi feita — ver aviso em docs/affiliate/knowledge-base.md. Não fazer deploy do serviço principal/worker da KB em produção antes de decidir como tratar isso (as duas coisas — pin de dependência e reindexação de dados — são mudanças com riscos bem diferentes, tratadas separadamente de propósito).

Importante: os embeddings do Qdrant não são os mesmos hoje armazenados no BigQuery (aqueles vêm de mte002/Vertex AI). Trata-se de um pipeline de embedding paralelo e independente, gerado a partir dos dados crus do CloudSQL. As duas tools convivem com fontes de vetor diferentes — os scores de uma não são comparáveis com os da outra, e isso deve ficar explícito para qualquer pessoa que for comparar as duas buscas.

Campos embeddados

Mesmo particionamento que já funciona bem no BigQuery, mapeado a partir do texto bruto do MySQL:

Vetor nomeado Conteúdo de origem
title title
description description
location concat de city + state + country
features concat de highlights + includes

product_type não é embeddado (ver seção 1) — vira payload filtrável.


3. Design da Collection no Qdrant

Segue o padrão já em produção em runtime/workers/knowledge_base/qdrant_writer.py (VectorParams, Distance.COSINE, client.collection_exists/create_collection idempotente), mas com uma collection única e global — produtos são um catálogo compartilhado entre afiliados; a diferenciação por afiliado continua no pós-processamento, como hoje em context_product_price/context_product_affiliate_url.

Configuração

Item Valor
Nome da collection products (stage_products em stage — mesmo padrão de prefixo de qdrant_ops.py/qdrant_writer.py)
Vetores nomeados title, description, location, features — todos 384D (paraphrase-multilingual-MiniLM-L12-v2), Distance.COSINE
Point ID derivado de (source_type, product_id) — ver subseção dedicada abaixo

Point ID: por que product_id sozinho não serve

A view MySQL tem chave primária composta (product_id, source_type) — a mesma tabela combina produtos de duas origens (ifriend e experience), cada uma com seu próprio autoincrement. Isso significa que pode existir um guia e uma experiência com o mesmo product_id. Usar product_id puro como Point ID no Qdrant causaria colisão silenciosa: o upsert do segundo produto sobrescreveria o do primeiro.

Solução: gerar o Point ID como um UUID determinístico a partir da chave composta, usando uuid.uuid5 (Qdrant aceita string UUID como ID de ponto nativamente):

import uuid

NAMESPACE = uuid.UUID("...")  # UUID fixo do projeto, gerado uma vez e versionado

def point_id(source_type: str, product_id: int) -> str:
    return str(uuid.uuid5(NAMESPACE, f"{source_type}:{product_id}"))

Determinístico (mesmo input → mesmo UUID sempre) e sem colisão entre ifriend:123 e experience:123 — mantém a idempotência de upsert que product_id puro daria, sem o risco de colisão. product_id e source_type continuam guardados como campos separados no payload (abaixo), para filtro e para a tool devolver a chave composta correta ao restante do pipeline (enrich_products.py e os block builders precisam saber diferenciar as duas origens ao montar URLs/preços — checar na implementação se esse código já assume product_id único; se sim, é um ajuste necessário para aceitar a chave composta, independente deste pipeline Qdrant).

Payload

product_id, source_type, product_type, title, description,
city, state, country, country_code, latitude, longitude,
duration_minutes, price, price_net, currency_code,
rating, review_count,
published, salable, featured, is_green, pro, needs_confirmation,
canonical_url, display_url, photo_url, agency_id, updated_at,
interests, languages

interests/languages entram já normalizados como arrays (split + trim do texto livre da view — ver seção 1) — não são campos novos a criar na origem, só precisam de parsing no job de sync.

Índices de payload (create_payload_index)

Campo Tipo de índice Uso
product_type keyword filtro por tipo (guia/experience/ticket/transfer)
source_type keyword desambiguação ifriend vs experience
country_code keyword filtro geográfico exato
city keyword filtro geográfico exato
pro bool filtro de guia profissional
published, salable bool/keyword defesa extra além da exclusão no índice
price range (float) filtro de faixa de preço
rating range (float) filtro de nota mínima
interests keyword (array) filtro por categoria/interesse
languages keyword (array) filtro de idioma do guia
location_geo (construído de latitude/longitude) geo (geo_point) filtro geográfico por raio real, ver abaixo

Filtro geográfico: coordenadas reais > texto exato > vetor fuzzy

A view MySQL já tem latitude/longitude por produto — isso muda o desenho em relação ao BigQuery (que só tem texto livre e por isso precisa de embedding fuzzy + threshold heurístico 0.30, documentado em docs/search/PRODUCT_SEARCH.md). Propor três camadas, da mais precisa para a mais aproximada:

  1. Filtro geo nativo do Qdrant (geo_radius/geo_bounding_box sobre location_geo) quando o destino informado puder ser geocodificado em coordenadas (ex.: via um dicionário canônico cidade→lat/long construído a partir dos próprios produtos já indexados, sem depender de serviço de geocoding externo) — é filtro por distância real, não por similaridade textual/semântica.
  2. Filtro exato de payload (city/country_code) quando o destino casar diretamente com o texto normalizado — rápido, sem geocoding.
  3. Fallback por vetor location (busca aproximada, equivalente ao comportamento atual do BigQuery) só quando as duas camadas acima não retornarem nada — cobre os casos multilíngues que hoje o embedding resolve bem (ex.: "Florence" → "Firenze").

Exclusão no índice, não filtro em runtime

Produtos com published=0, salable != 'online' ou deleted_at preenchido não entram no Qdrant — o job de sincronização os remove/deixa de fora, em vez de aplicar filtro em toda busca (como o SQL faz hoje). Isso reduz o tamanho da collection e o custo de cada busca.


4. Job de Sincronização (Cloud Run Job + Cloud Scheduler)

Segue o precedente de infraestrutura de runtime/workers/knowledge_base/, mas lendo do CloudSQL em vez de reagir a eventos Pub/Sub.

Fonte

View mv_product_search_flat no CloudSQL (MySQL 5.7) — schema completo na seção 1. Contém todos os campos de negócio necessários, sem colunas de embedding (essas nascem no próprio job, não na origem), e com chave primária composta (product_id, source_type) — ver tratamento na seção 3.

Execução

Cloud Scheduler dispara o Cloud Run Job periodicamente (ex.: a cada 15–30 min, ajustável conforme volume de mudanças de catálogo).

Estratégia incremental e onde fica o watermark

Watermark por cursor composto (updated_at, product_id, source_type), no mesmo espírito do padrão já usado em docs/bigquery-embedding/consulta-programada.sql (WHERE updated_at > @last_watermark), mas com dois componentes extra para permitir checkpoint seguro por chunk (ver abaixo).

Decisão: persistir o watermark no próprio CloudSQL, em vez de Firestore — o job já mantém uma conexão com o MySQL para ler a view, então reaproveitar o mesmo banco para o estado de sincronização evita introduzir uma segunda dependência de infraestrutura só para isso. Tabela de controle dedicada (não a view, que é derivada e não deve receber escrita):

CREATE TABLE IF NOT EXISTS `qdrant_product_sync_watermark` (
  `sync_name`          varchar(50)     NOT NULL PRIMARY KEY,
  `last_synced_at`     datetime        NOT NULL,
  `cursor_product_id`  bigint unsigned NOT NULL DEFAULT 0,
  `cursor_source_type` varchar(20)     NOT NULL DEFAULT '',
  `last_run_status`    varchar(20)     NOT NULL DEFAULT 'ok',
  `updated_at`         datetime        NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB;

cursor_product_id/cursor_source_type foram adicionadas via ALTER TABLE guardado (idempotente, ignora "coluna já existe") em watermark.py::ensure_table — mesmo padrão de migração de ifriend_agent/memory/cloudsql/cloudsql_memory_service.py::_ensure_schema, já que a tabela já existia em stage antes dessa mudança.

Por que um cursor composto, não só updated_at: um cursor baseado só em timestamp tem um problema de "empate" — se dois produtos tiverem o mesmo updated_at e caírem em lados opostos de uma borda de chunk, o segundo poderia ser pulado na retomada. A tupla (updated_at, product_id, source_type), comparada via row-value comparison do MySQL (WHERE (updated_at, product_id, source_type) > (%s, %s, %s)), bate exatamente com o ORDER BY da query — não há como pular ou reprocessar incorretamente uma borda com empate.

Uma única linha (sync_name='product_search_qdrant'), já que é um único job. Fluxo:

  1. No início da execução, capturar job_start_time = NOW() na aplicação (não no banco).
  2. SELECT last_synced_at, cursor_product_id, cursor_source_type FROM qdrant_product_sync_watermark WHERE sync_name = ... FOR UPDATE — o lock de linha protege o instante da leitura.
  3. Consultar a view com o cursor composto (aceitável reprocessar produtos na borda — o upsert é idempotente pelo Point ID determinístico da seção 3, então isso é seguro e barato).
  4. Processar em chunks (ver próxima seção). A cada chunk processado com sucesso, gravar o cursor como a tupla do último produto do chunk e fazer COMMIT imediatamente — cada chunk vira uma transação curta e durável, não uma só transação gigante presa até o fim do run inteiro. Isso libera o lock FOR UPDATE entre chunks (uma sobreposição rara de execuções do Scheduler vira redundância inofensiva graças ao upsert idempotente, não corrupção).
  5. Só quando todos os chunks terminam com sucesso: fechar a janela, gravando o cursor sentinela (job_start_time, 0, '') — usa o horário de início do job (não o MAX(updated_at) do lote lido) para não perder produtos escritos na origem durante o processamento, e inicia a próxima janela.
  6. Se o job falhar/for morto no meio (timeout, OOM), o cursor fica exatamente onde o último chunk bem- sucedido deixou — a próxima execução retoma dali, não reprocessa o lote inteiro do zero.

Fluxo por execução

  1. Lê da view os produtos com cursor maior que o watermark (ou updated_at IS NULL, ver mysql_reader.py).
  2. Normaliza campos de texto livre: interests/languages (split em array), duration → duration_minutes (int), monta location_geo a partir de latitude/longitude.
  3. Processa em chunks de SYNC_CHUNK_SIZE (200), na ordem original da query — cada chunk é separado em ativo/inativo internamente (não em duas listas globais, para o checkpoint refletir a posição real).
  4. Para os ativos do chunk: gera os 4 embeddings localmente (fastembed, em sub-lotes de EMBED_BATCH_SIZE), monta o payload completo (seção 3) e calcula o Point ID determinístico uuid5(source_type:product_id), faz upsert no Qdrant — idempotente, sem risco de colisão entre ifriend e experience com o mesmo product_id.
  5. Para os inativos do chunk (deleted_at preenchido, ou published=0/salable != 'online'): remove do Qdrant (client.delete por Point ID, calculado da mesma chave composta).
  6. Grava o checkpoint do chunk (passo 4 da seção anterior) e segue pro próximo chunk.
  7. Ao final de todos os chunks, fecha a janela (passo 5 da seção anterior).

Normalização necessária (não é dado novo, é parsing do que já existe)

Diferente da primeira versão deste documento — que assumia tags/languages/duration_minutes como campos a serem criados do zero na origem — a view MySQL já expõe interests, languages e duration. O trabalho do job é normalizar esses textos livres para os tipos que o Qdrant indexa bem (arrays de keyword, inteiro em minutos). Isso é uma tarefa de implementação do próprio job, não uma dependência externa de outro time.

Processamento em chunks (evita OOM numa primeira carga completa)

Uma primeira sincronização (view recém-populada, ou reindexação manual) pode enxergar o catálogo inteiro como "alterado" de uma vez — todos os produtos ganham updated_at novo no momento da materialização. Por isso product_processing.run() não acumula o catálogo inteiro em memória: fatia active_rows/ inactive_keys em lotes de SYNC_CHUNK_SIZE (200) e faz upsert/delete no Qdrant a cada lote, não uma vez só no final. EMBED_BATCH_SIZE (32) continua controlando o sub-lote passado pro ONNX runtime dentro de cada chunk — são dois níveis de lote independentes.

Limite conhecido, não resolvido: mysql_reader.fetch_changed_products ainda faz um único fetchall() — se o catálogo for realmente enorme (dezenas/centenas de milhares de linhas com texto longo), o rows bruto em memória antes do chunking ainda pode crescer bastante. Se o OOM voltar a acontecer mesmo com o chunking, o próximo passo é paginar a própria leitura do MySQL (cursor não- bufferizado ou keyset pagination em updated_at/product_id/source_type), não só o lado de escrita.

_TASK_TIMEOUT e retomada após timeout/kill: uma primeira carga completa pode legitimamente demorar mais que o timeout padrão do Cloud Run Job — por isso _TASK_TIMEOUT está em 3600 (1h) nos dois cloudbuild. Se mesmo assim o job for morto no meio (timeout, OOM, restart), o cursor composto fica exatamente no último checkpoint de chunk salvo (ver seção acima) — a próxima execução retoma dali, em vez de reprocessar o lote inteiro do zero. Isso resolve o desperdício de reembeddar tudo de novo a cada tentativa; se o catálogo for grande o bastante para precisar de várias execuções mesmo com retomada, considerar subir _TASK_TIMEOUT ainda mais (até o máximo suportado por Cloud Run Jobs, 24h).


5. Nova Tool do Agente (busca_produtos_qdrant)

Nome definitivo a confirmar na implementação.

Desenhada como complemento, não substituição — reaproveitando ao máximo o código já existente.

Assinatura

Mesma assinatura de busca_produtos para ser um drop-in nos agentes existentes:

async def busca_produtos_qdrant(
    tool_context: ToolContext,
    metadados_busca: str,
    destino: Optional[str] = None,
    tipo_produto: Optional[str] = None,
    guia_pro: Optional[bool] = False,
    limit: Optional[int] = 10,
    suppress_blocks: Optional[bool] = False,
) -> list:

Fluxo

  1. Embedda metadados_busca localmente com fastembed (paraphrase-multilingual-MiniLM-L12-v2, 384D, modelo já quente em memória — ver seção 7) — sem chamada de rede, latência de milissegundos.
  2. Monta o Filter do Qdrant a partir de tipo_produto/guia_pro/allow_needs_confirmation (mesma lógica de affiliate_config já usada hoje em busca_produtos_tool.py).
  3. Resolve destino: geo real (coordenadas) → filtro exato de payload → fallback por vetor location, nessa ordem de precisão (seção 3).
  4. query_points combinando os vetores nomeados com os mesmos pesos já validados em produção (title 0.40 / description 0.30 / location 0.20 / features 0.10).
  5. Normaliza o score do Qdrant (similaridade cosseno) para o mesmo formato de saída já usado: distance = 1 - score, relevance = round(score * 100, 2).
  6. Devolve product_id e source_type juntos por item (a chave composta da origem — ver seção 3); checar na implementação se enrich_products.py e os block builders precisam de ajuste para consumir a chave composta, já que a tool BigQuery hoje não expõe source_type no resultado.

Reuso direto de código existente (sem duplicar)

  • ifriend_agent/tools/context/enrich_products.py — preço, status de catálogo, URL de afiliado/whitelabel.
  • ifriend_agent/tools/blocks/experience_cards.py e guide_cards.py — geração dos blocks de UI.

A nova tool só precisa produzir a mesma lista de dicts de produto que busca_produtos já produz antes do enriquecimento — todo o resto do pipeline é reaproveitado sem alteração.

Fallback progressivo

Equivalente ao fallback já existente em busca_produtos_tool.py: remove tipo_produto antes de remover o filtro de destino, na mesma ordem de prioridade.

Convivência nos agentes

busca_produtos (BigQuery) permanece como está. busca_produtos_qdrant entra como opção adicional a ser plugada nos tools=[...] dos agentes (discovery_agent, itinerary_agent, support_agent, custom_tour_agent). Qual tool usar por padrão em cada agente é decisão de uma fase de rollout futura (ver seção 7) — fora do escopo desta entrega.


6. Performance e Cold Start (embedding de query em tempo real)

Origem da preocupação: usuários já reclamam de lentidão do agente, e a nova tool introduz uma inferência de embedding local no caminho crítico de cada busca. O risco dominante não é o custo de inferência em si (texto curto, modelo já carregado — tipicamente poucos milissegundos), e sim carregar o modelo do zero na primeira requisição de um processo frio. Mitigações a tratar como obrigatórias na implementação:

Mitigação Descrição
Preload no startup, não lazy-load O modelo fastembed deve ser instanciado durante a inicialização do processo (antes de aceitar tráfego), e não na primeira chamada da tool — mesmo padrão de singleton já usado em embedder.py, mas carregado eagerly.
Min-instances ≥ 1 O serviço que roda o agente/tool não pode escalar a zero, ou todo cold start volta a pagar o custo de carregar o ONNX runtime + pesos do modelo (pode levar segundos).
Cache de queries repetidas Reaproveitar o mesmo padrão de _TTLCache já usado em ifriend_agent/tools/knowledge_base_tool.py (cache em memória, chave por hash da query normalizada, TTL configurável) para evitar reembeddar textos idênticos/recorrentes ("passeios em Paris" chega várias vezes por dia de usuários diferentes).
~~Modelo compartilhado com a Knowledge Base~~ Não se aplica mais — desde a troca por indisponibilidade do e5-small no fastembed, produtos usam paraphrase-multilingual-MiniLM-L12-v2 e a KB continua em intfloat/multilingual-e5-small. Se a KB migrar para o mesmo modelo no futuro, essa otimização de singleton compartilhado volta a valer.
Benchmark antes de fechar o design Medir, no ambiente real de produção (mesma classe de CPU/memória do serviço), a latência p50/p95 de embeddar uma query curta com o modelo já quente — isso deve ser o número usado para decidir se a etapa de embedding é ou não um gargalo real, em vez de assumir.

7. Convivência das Duas Tools e Próximos Passos

  • Este documento não propõe remover busca_produtos/BigQuery — as duas tools convivem.
  • Antes de trocar qualquer default de agente, recomenda-se uma fase de validação em shadow/A-B, comparando relevância e latência das duas abordagens lado a lado.
  • Roadmap incremental (já listado em docs/search/PRODUCT_SEARCH.md) e como o Qdrant facilita cada item:
Item do roadmap Como o Qdrant ajuda
Hybrid search (dense + BM25) Qdrant suporta sparse vectors nativamente — pode ser adicionado como um vetor nomeado extra sem redesenhar a collection
Reranker (BGE-reranker-v2) Retrieval em duas etapas: over-fetch no Qdrant, rerank em cima do top-N — natural de plugar na tool
Query rewriting via LLM Independente do vector store, mas se beneficia da menor latência do Qdrant para iterar mais rápido
Fine-tuning de embeddings para turismo BR A collection isolada permite trocar o modelo/dimensão dos vetores de produto sem impactar a Knowledge Base
Logs de feedback (cliques/conversão) Vira payload numérico (popularity_score) usável em boost de score nas queries do Qdrant

Variáveis de Ambiente Propostas

Variável Default sugerido Descrição
QDRANT_URL (reaproveitado da KB) Endpoint do qdrant-aiagent-service
QDRANT_API_KEY (reaproveitado da KB) Credencial do Qdrant
PRODUCT_SEARCH_COLLECTION products Nome da collection (prefixo stage_ em stage)
PRODUCT_EMBEDDING_MODEL sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2 Modelo fastembed para embeddings de produto (384D) — não é mais o mesmo modelo da Knowledge Base (ver seção 2)
PRODUCT_SEARCH_TOP_K 10 Limite padrão de resultados (equivalente ao limit atual)
PRODUCT_GEO_FALLBACK_ENABLED true Habilita fallback por vetor location quando filtro exato de geo não encontra nada
PRODUCT_SEARCH_CACHE_TTL 600 TTL (segundos) do cache em memória de embeddings/resultados de query repetida — mesmo padrão de KB_CACHE_TTL
PRODUCT_SYNC_WATERMARK_TABLE qdrant_product_sync_watermark Tabela de controle no próprio CloudSQL onde fica o último updated_at sincronizado (ver seção 4)

Arquivos Envolvidos (referência)

Arquivo Papel
ifriend_agent/tools/busca_produtos_tool.py Tool atual (BigQuery) — não alterada
docs/search/PRODUCT_SEARCH.md Documentação da tool atual — referência de pesos, fallback e roadmap
runtime/workers/knowledge_base/qdrant_writer.py Precedente de escrita/upsert no Qdrant a seguir
runtime/workers/knowledge_base/embedder.py Precedente de uso de fastembed como singleton
ifriend_agent/tools/knowledge_base_tool.py Precedente de busca async no Qdrant com cache TTL e threshold
ifriend_agent/tools/context/enrich_products.py Enriquecimento pós-query — reaproveitado sem alteração
ifriend_agent/tools/blocks/experience_cards.py, guide_cards.py Geração de blocks de UI — reaproveitados sem alteração
runtime/infra/k8s/qdrant.yaml Deployment do Qdrant (qdrant-aiagent-service)