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:
- Filtro geo nativo do Qdrant (
geo_radius/geo_bounding_boxsobrelocation_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. - Filtro exato de payload (
city/country_code) quando o destino casar diretamente com o texto normalizado — rápido, sem geocoding. - 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:
- No início da execução, capturar
job_start_time = NOW()na aplicação (não no banco). 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.- 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).
- 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
COMMITimediatamente — 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 lockFOR UPDATEentre chunks (uma sobreposição rara de execuções do Scheduler vira redundância inofensiva graças ao upsert idempotente, não corrupção). - 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 oMAX(updated_at)do lote lido) para não perder produtos escritos na origem durante o processamento, e inicia a próxima janela. - 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¶
- Lê da view os produtos com cursor maior que o watermark (ou
updated_at IS NULL, vermysql_reader.py). - Normaliza campos de texto livre:
interests/languages(split em array),duration→duration_minutes(int), montalocation_geoa partir delatitude/longitude. - 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). - 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ísticouuid5(source_type:product_id), fazupsertno Qdrant — idempotente, sem risco de colisão entreifriendeexperiencecom o mesmoproduct_id. - Para os inativos do chunk (
deleted_atpreenchido, oupublished=0/salable != 'online'): remove do Qdrant (client.deletepor Point ID, calculado da mesma chave composta). - Grava o checkpoint do chunk (passo 4 da seção anterior) e segue pro próximo chunk.
- 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¶
- Embedda
metadados_buscalocalmente 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. - Monta o
Filterdo Qdrant a partir detipo_produto/guia_pro/allow_needs_confirmation(mesma lógica deaffiliate_configjá usada hoje embusca_produtos_tool.py). - Resolve
destino: geo real (coordenadas) → filtro exato de payload → fallback por vetorlocation, nessa ordem de precisão (seção 3). query_pointscombinando os vetores nomeados com os mesmos pesos já validados em produção (title 0.40 / description 0.30 / location 0.20 / features 0.10).- Normaliza o score do Qdrant (similaridade cosseno) para o mesmo formato de saída já usado:
distance = 1 - score,relevance = round(score * 100, 2). - Devolve
product_idesource_typejuntos por item (a chave composta da origem — ver seção 3); checar na implementação seenrich_products.pye os block builders precisam de ajuste para consumir a chave composta, já que a tool BigQuery hoje não expõesource_typeno 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.pyeguide_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) |