mozak.tech Engenharia de IA Corporativa 97%

Parte V — Projetando Sistemas Inteligentes para Produção

5.4 — Padrões Recorrentes: RAG Híbrido e Aprovação Humana no Fluxo (Design Patterns)

Objetivo da Aula

Ao concluir esta aula, você será capaz de:

Implementar Hybrid RAG com BM25/keyword search + dense retrieval + RRF

Implementar HITL (Human-in-the-Loop) approval gate para ações críticas

Aplicar Model Router para redução de custo por complexidade de query

Completar os 2 TODOs do starter TypeScript

Calcular economia de custo com semantic cache

Por que isso importa

Decisão de Arquitetura: RAG Híbrido e RRF

Busca vetorial densa captura semântica mas dilui termos exatos: "ISO 31000" ou "CVE-2024-1234" podem não ter embedding próximo o suficiente se o corpus é grande. BM25 (busca lexical, como Elasticsearch) captura termos exatos mas ignora semântica — "comprar" e "adquirir" são documentos distintos. A solução é combinar os dois rankings. Reciprocal Rank Fusion (RRF) faz isso sem calibrar pesos: para cada documento, soma 1 / (k + posição) nos dois rankings (k=60 é o padrão). O documento que aparece bem nos dois ranqueamentos recebe score alto; o que aparece só num recebe menos. Use RAG híbrido quando seu corpus tem: códigos, siglas, nomes próprios, IDs, ou termos técnicos que a busca semântica pode "diluir".

RAG básico falha em dois casos comuns: termos exatos (“código ISO 31000”) que embedding não captura, e queries semânticas (“o que significa risco?”) que BM25 perde. Hybrid RAG combina ambos. HITL resolve o problema de confiança: quando um agente autônomo está prestes a executar ação irreversível (enviar email, deletar dados, cobrar cartão), humanos precisam aprovar.

Conceitos Fundamentais

TODO 1: Hybrid RAG

async function hybridRAG(query: string): Promise<string> {

  // === ETAPA 1: BM25/Keyword Search ===

  // Encontra documentos com termos exatos da query

  function bm25(query: string, docs: string[]): Array<{doc: string, score: number}> {

    const termos = query.toLowerCase().split(/\s+/).filter(t => t.length > 2)

    return docs.map((doc, i) => {

      const docLower = doc.toLowerCase()

      const score = termos.reduce((acc, t) => acc + (docLower.includes(t) ? 1 : 0), 0)

      return { doc, score, rank: 0 }

    }).filter(r => r.score > 0).sort((a, b) => b.score - a.score)

  }

  

  // === ETAPA 2: Dense Retrieval (embedding similarity) ===

  function denseRetrieval(query: string, docs: string[]): Array<{doc: string, score: number}> {

    const vocab = ['ia', 'llm', 'modelo', 'treinamento', 'dados', 'rag', 'embedding', 'agente']

    const embed = (texto: string) => vocab.map(v => (texto.toLowerCase().match(new RegExp(v, 'g')) || []).length)

    const qEmb = embed(query)

    const dot = (a: number[], b: number[]) => a.reduce((s, v, i) => s + v * b[i], 0)

    const norm = (v: number[]) => Math.sqrt(v.reduce((s, x) => s + x * x, 0))

    

    return docs.map(doc => {

      const dEmb = embed(doc)

      const n1 = norm(qEmb), n2 = norm(dEmb)

      const score = n1 * n2 === 0 ? 0 : dot(qEmb, dEmb) / (n1 * n2)

      return { doc, score }

    }).filter(r => r.score > 0).sort((a, b) => b.score - a.score)

  }

  

  // === ETAPA 3: RRF (Reciprocal Rank Fusion) ===

  // Combina rankings: score = Σ 1 / (k + rank)  onde k=60 é constante

  function rrfFusion(

    lista1: Array<{doc: string}>,

    lista2: Array<{doc: string}>,

    k = 60

  ): Array<{doc: string, rrf_score: number}> {

    const scores: Map<string, number> = new Map()

    

    lista1.forEach(({doc}, rank) => {

      scores.set(doc, (scores.get(doc) || 0) + 1 / (k + rank + 1))

    })

    lista2.forEach(({doc}, rank) => {

      scores.set(doc, (scores.get(doc) || 0) + 1 / (k + rank + 1))

    })

    

    return Array.from(scores.entries())

      .map(([doc, rrf_score]) => ({ doc, rrf_score }))

      .sort((a, b) => b.rrf_score - a.rrf_score)

  }

  

  // === ETAPA 4: Re-ranker (LLM verifica relevância) ===

  const bm25Results = bm25(query, BASE_CONHECIMENTO)

  const denseResults = denseRetrieval(query, BASE_CONHECIMENTO)

  const fusionResults = rrfFusion(bm25Results, denseResults).slice(0, 3)

  

  // LLM como re-ranker final

  if (fusionResults.length > 0) {

    const rerankerResponse = await client.messages.create({

      model: 'claude-haiku-4-5-20251001',

      max_tokens: 50,

      messages: [{

        role: 'user',

        content: `Query: "${query}"\nDocumentos (1-${fusionResults.length}):\n${fusionResults.map((r, i) => `${i+1}. ${r.doc}`).join('\n')}\nQual documento (número) é mais relevante? Responda só o número.`,

      }],

    })

    const numStr = rerankerResponse.content[0].type === 'text' ? rerankerResponse.content[0].text : '1'

    const idx = parseInt(numStr.trim()) - 1

    const topDoc = fusionResults[Math.max(0, Math.min(idx, fusionResults.length - 1))].doc

    

    const response = await client.messages.create({

      model: 'claude-haiku-4-5-20251001',

      max_tokens: 400,

      messages: [{ role: 'user', content: `Contexto:\n${topDoc}\n\nPergunta: ${query}` }],

    })

    return response.content[0].type === 'text' ? response.content[0].text : ''

  }

  

  return await ragBasico(query)

}

TODO 2: HITL Approval Gate

async function hitlGate(acao: string, contexto: string): Promise<boolean> {

  // 1. Classificar ação como crítica ou não

  const acoesNaoCriticas = ['leitura', 'consulta', 'busca', 'listar']

  const ehCritica = !acoesNaoCriticas.some(a => acao.toLowerCase().includes(a))

  

  if (!ehCritica) {

    console.log(`  [HITL] Ação não-crítica — aprovação automática: ${acao}`)

    return true

  }

  

  // 2. Enviar para fila de aprovação (mock)

  console.log(`  [HITL] ⚠️ Ação crítica detectada:`)

  console.log(`    Ação: ${acao}`)

  console.log(`    Contexto: ${contexto.slice(0, 200)}`)

  console.log(`  [HITL] Aguardando aprovação humana... (simulando 2s)`)

  

  // 3. Aguardar aprovação (timeout 30s em produção)

  await new Promise(resolve => setTimeout(resolve, 2000))

  

  // Mock: 80% de aprovação para demonstração

  const aprovado = Math.random() > 0.2

  console.log(`  [HITL] ${aprovado ? '✅ APROVADO' : '❌ REJEITADO'} pelo revisor`)

  

  // 4. Retornar resultado

  return aprovado

}

Aprofundamento Técnico

Por que RRF e não média simples?

BM25 rank: [doc1=10pts, doc2=8pts, doc3=1pt]

Dense rank: [doc2=0.9, doc3=0.85, doc1=0.4]



Média simples: doc1=(10+0.4)/2=5.2, doc2=(8+0.9)/2=4.45

→ doc1 ganha pelo BM25 dominante



RRF: doc1=1/61+1/63=0.0164, doc2=1/62+1/61=0.0325, doc3=1/63+1/62=0.0160

→ doc2 ganha por ranking consistente em ambos

RRF é escala-agnóstico — funciona mesmo quando BM25 scores (absolutos) e cosine similarity (0-1) têm escalas diferentes.

Exemplos Anotados

Exemplo 1: Hybrid vs RAG Básico

Query: "RAG ISO 31000 norma técnica"



RAG Básico (embedding only):

→ Encontra docs sobre RAG (semântica)

→ Perde "ISO 31000" (termo técnico específico)



Hybrid RAG:

→ BM25 encontra "ISO 31000" se estiver na base

→ Dense encontra docs sobre RAG

→ RRF combina → documento sobre RAG com referência ISO 31000 aparece no topo

Padrões e Armadilhas

Padrões

Padrão 1: HITL por ação, não por domínio

// Bom: verificar o verbo da ação

const ACOES_CRITICAS = ['delete', 'send', 'charge', 'publish', 'transfer']



// Ruim: verificar o domínio

const DOMINIOS_CRITICOS = ['pagamentos']  // e "consultar pagamentos"?

Padrão 2: Re-ranker barato (Haiku) Re-ranker só escolhe entre 3-5 docs — não precisa de modelo poderoso.

Armadilhas

⚠️ Armadilha 1: k=0 em RRF → divisão por zero

const k = 60  // constante padrão, nunca 0

1 / (k + rank + 1)  // rank=0 → 1/61

⚠️ Armadilha 2: HITL com timeout infinito Em produção, se o revisor não responde em 30 minutos, tome uma decisão segura (rejeitar ou escalar).

⚠️ Armadilha 3: Base de conhecimento vazia → dense retrieval retorna nada

if (fusionResults.length === 0) return await ragBasico(query)  // fallback
⚗ Laboratório prático — mozak.tech
Se não for realizar o laboratório, pule para o próximo capítulo.

Ponte para o Lab

TODO 1 — hybridRAG(query): implementar BM25 (terms match), dense retrieval (embedding similarity), RRF fusion (1/(k+rank)), re-ranker LLM, call final com top doc.

TODO 2 — hitlGate(acao, contexto): classificar como crítica/não-crítica, simular fila de aprovação com timeout, retornar boolean.

Agora você está pronto para o lab.