Vicco LabsVicco Labs
Construindo um assistente conversacional em produção · Parte 10
28 caracteres especiais, booleans que não existem

QueryBuilder: como transformar um objeto Pydantic numa query FT.SEARCH segura

Construir queries FT.SEARCH manualmente é onde você descobre que o RediSearch interpreta '&' como AND silenciosamente, sem erro nem exception.

20 ABR 2026·4 min de leitura·FT.SEARCH / Pydantic / Redis Stack / QueryBuilder
FT.SEARCH

Nos posts sobre Redis Stack mostrei o modelo Fat/Slim/Híbrido e como a decisão de modelagem afeta diretamente o que o LLM recebe. Hoje quero mostrar a camada entre o RouterOutput do DSPy e o Redis: o QueryBuilder.

É o componente que transforma um objeto Pydantic com filtros semânticos numa string FT.SEARCH válida, segura e correta. Parece trivial, mas não é.

O problema de construir queries FT.SEARCH manualmente

A primeira versão do código de busca construía a query como f-string:

Três problemas imediatos:

  • Caracteres especiais quebram o parser silenciosamente: o RediSearch tem 28 caracteres especiais em campos TAG que precisam ser escapados com backslash. Um nome de produto como "Nike Air (Plus)" sem escaping vira @nome:{Nike Air (Plus)}, que o RediSearch parseia como um grupo de alternativas mal formado. Resultado: zero resultados, sem erro.
  • Booleans não existem no RediSearch: o tipo nativo de campo booleano não existe. Campos como frete_gratis e em_estoque precisam ser armazenados e consultados como TAG com string "true"/"false". Se o upstream envia True, 1, "sim" ou "TRUE", a query precisa normalizar antes.
  • Input do LLM não é confiável: o DSPy retorna text_search="Nike Air Max 270". Isso precisa virar uma query fuzzy tokenizada, não uma substituição literal, senão você ou busca exato demais (zero resultados para grafia errada) ou flexível demais (resultados irrelevantes).

A arquitetura: Pydantic model → QueryBuilder → FT.SEARCH string

O Shelf Filter Model é o contrato entre o router e o builder. Cada campo tem tipo Python explícito e mapeamento para o campo Redis correspondente:

O extra="forbid" garante que campos inesperados vindos do LLM não passem silenciosamente, fazendo com que eles levantem ValidationError imediatamente.

O escaping de TAG: 28 caracteres especiais

O RediSearch tem uma lista de caracteres especiais que precisam ser escapados em valores de campo TAG. A maioria dos exemplos na internet só escapa os óbvios (-, .). Na prática, nomes de produtos têm (, ), +, &, / e outros.

O bug: "Nike & Co." sem escaping vira @marca:{Nike & Co.}. O RediSearch interpreta "&" como operador AND dentro do grupo de TAG, e parseia como @marca:{Nike} AND @marca:{Co.}, que obviamente retorna zero resultados, sem mensagem de erro, sem exception.

Booleans como TAG: a normalização que o upstream não faz por você

É simples: o problema fica na normalização antes de chegar aqui. APIs de catálogo retornam True, 1, "sim", "TRUE", "yes" para o mesmo campo. O Pydantic com validador resolve isso:

Sem esse validador, um campo frete_gratis="TRUE" passaria no Pydantic como string e chegaria no _format_tag_boolean como um valor truthy, gerando @frete_gratis:{True} (com T maiúsculo), que não casa com o índice que armazena "true" (minúsculo), retornando zero resultados, sem erro.

Fuzzy matching com regras de tokenização

O campo product_name é o mais delicado. O DSPy retorna texto livre. Você não quer busca exata (falha para qualquer variação de grafia) nem wildcard puro (retorna tudo).

A solução é tokenizar e aplicar regras por tamanho de token:

O resultado para "Nike Air Max 270":

Isso casa com "Nike Air Max 270 Black", "Nike Air Max 270 React" e tolera "Nikke" (Levenshtein 1). Não casa com "adidas Air Max", pois o %%Nike%% não vai casar com "adidas".

Sanitização: injeção no RediSearch é real

O LLM pode (e eventualmente vai) retornar texto que contém comandos RediSearch. text_search="tênis LIMIT 0 1000 SORTBY preco" não levanta exceção, só executa.

O fallback é "*", busca wildcard que retorna todos os documentos do índice. É fail-open: você entrega uma resposta genérica em vez de executar um comando injetado. Em contextos onde fail-closed seria mais adequado, a função pode lançar exceção em vez de retornar "*".

O cache de templates: não reconstruir a mesma query duas vezes

Para filtros estruturados sem text_search (ex: {category: "Tenis", gender: "Masculino", free_shipping: True}), a parte dinâmica da query é sempre a mesma para o mesmo conjunto de filtros. O QueryBuilder cacheia:

O product_name fica fora do cache, é o campo mais variável e o único que passa pela tokenização fuzzy. Os filtros estruturados (TAG e NUMERIC) são estáveis para o mesmo conjunto de valores.

O que o QueryBuilder entrega para o Redis Stack

Para uma query com {category: "Tenis", gender: "Masculino", price_max: 300, free_shipping: True, product_name: "Nike Air Max"}:

Que o Redis executa como:

O LLM recebe 5 campos × N produtos, não 60 campos nem um JSON de 800 tokens.

Na semana que vem: o pipeline completo. O caminho de uma query desde a mensagem do usuário até a resposta formatada (supervisor, router, anáfora, DSPy, tool, generate, critique) tudo junto numa visão de síntese.