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 ambosRRF é 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 topoPadrõ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) // fallbackSe 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.