Como construir busca inteligente com IA pra sua app: Vector Search, Hybrid Search e Semantic Ranking do zero
Seus usuários buscam "como resolver aquele negócio do login que fica travando" e o buscador retorna zero resultados, porque nenhum documento tem essa frase exata. Enquanto isso, existe um artigo perfeitamente relevante chamado "Resolvendo problemas de expiração de token de autenticação" — invisível pra busca por keywords.
Essa é a falha fundamental da busca tradicional: ela faz match de palavras, não de significado. E em 2026, os usuários esperam busca que entenda intenção.
A boa notícia: construir busca com IA não é mais projeto de doutorado. Com modelos de embedding modernos, bancos vetoriais e alguns padrões espertos, dá pra montar um sistema de busca que genuinamente entende o que os usuários querem dizer — não só o que digitam.
Neste guia, vamos construir um sistema de busca AI production-ready passo a passo. Começamos com os fundamentos de vector search, evoluímos pra hybrid search (o ponto ideal pra maioria das apps), adicionamos reranking semântico pra precisão, e cobrimos as armadilhas de produção que os tutoriais ignoram. Tudo com código TypeScript que dá pra shipar de verdade.
As três gerações da busca
Antes de entrar no código, vamos entender onde estamos e por que cada geração existe.
Geração 1: Busca por keywords (BM25/TF-IDF)
É o que a maioria das apps ainda usa. tsvector do PostgreSQL, modo default do Elasticsearch, ou até queries SQL com LIKE.
-- A abordagem clássica SELECT * FROM articles WHERE to_tsvector('english', title || ' ' || body) @@ to_tsquery('english', 'authentication & token & expiry');
Como funciona: Conta quantas vezes os termos da query aparecem nos documentos, pondera por raridade (IDF), e rankeia por score de relevância.
Onde funciona bem:
- Match exato de termos ("ERROR 0x80070005")
- Busca de um documento específico por nome
- Queries estruturadas com operadores booleanos
- Jargão de domínio que modelos de embedding talvez não conheçam
Onde falha:
- Sinônimos ("carro" vs "automóvel" vs "veículo")
- Entender intenção ("como deixar meu site mais rápido" → deveria matchear "otimização de performance web")
- Tolerância a typos (fuzzy matching ajuda parcialmente)
- Queries multi-idioma
Geração 2: Vector Search (Semântico)
Vector search converte texto em representações numéricas (embeddings) que capturam significado. Conceitos similares acabam próximos no espaço vetorial, independente das palavras usadas.
// "resolver problema de login" e "corrigir erro de autenticação" // viram vetores próximos const embedding1 = await embed("resolver problema de login"); const embedding2 = await embed("corrigir erro de autenticação"); cosineSimilarity(embedding1, embedding2); // ~0.92 (muito similares!)
Como funciona: Um modelo de embedding (como text-embedding-3-small da OpenAI ou nomic-embed-text open-source) converte texto num vetor de alta dimensão (normalmente 256-1536 dimensões). Buscar vira encontrar os vizinhos mais próximos no espaço vetorial.
Onde brilha:
- Entender intenção por trás de queries vagas
- Busca cross-lingual (embeddings transcendem barreiras de idioma)
- Achar conteúdo semanticamente relacionado mesmo sem overlap de palavras
Onde patina:
- Match exato de keywords (ironicamente!)
- Termos técnicos raros que o modelo nunca viu
- Viés de recência — embeddings não sabem o que é "novo"
- Queries com filtros/facetas ("artigos com tag React publicados depois de 2025")
Geração 3: Hybrid Search + Reranking (O sweet spot de 2026)
O insight: busca por keyword e vector search falham de maneiras complementares. Combinando os dois, cada um cobre o ponto cego do outro.
Query do usuário
↓
┌──────────────────────┐
│ Retrieval paralelo │
│ ┌─────────────────┐ │
│ │ BM25 (keywords) │──→ Top 20 resultados por keyword
│ └─────────────────┘ │
│ ┌─────────────────┐ │
│ │ Vector (semânt.) │──→ Top 20 resultados semânticos
│ └─────────────────┘ │
└──────────────────────┘
↓
Reciprocal Rank Fusion (merge + dedup)
↓
Top 40 candidatos (mergeados)
↓
LLM Reranker (opcional, mas poderoso)
↓
Top 10 resultados finais
É isso que vamos construir. Bora.
Step 1: Vector Search com pgvector
Não precisa de banco vetorial dedicado pra começar. PostgreSQL com a extensão pgvector dá conta de milhões de vetores com performance excelente e te dá a vantagem de manter tudo num banco só.
Setup do banco
-- Habilitar pgvector CREATE EXTENSION IF NOT EXISTS vector; -- Tabela de documentos com coluna de embedding CREATE TABLE documents ( id SERIAL PRIMARY KEY, title TEXT NOT NULL, content TEXT NOT NULL, metadata JSONB DEFAULT '{}', embedding vector(1536), -- Dimensão do text-embedding-3-small created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); -- Índice HNSW pra busca aproximada de vizinhos próximos -- Isso é a chave da performance em escala CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64); -- Índice de full-text search pro BM25 também ALTER TABLE documents ADD COLUMN search_vector tsvector GENERATED ALWAYS AS ( setweight(to_tsvector('english', coalesce(title, '')), 'A') || setweight(to_tsvector('english', coalesce(content, '')), 'B') ) STORED; CREATE INDEX ON documents USING gin(search_vector);
Gerando embeddings
import OpenAI from 'openai'; const openai = new OpenAI(); async function generateEmbedding(text: string): Promise<number[]> { const truncated = text.slice(0, 8000); const response = await openai.embeddings.create({ model: 'text-embedding-3-small', input: truncated, dimensions: 1536, }); return response.data[0].embedding; } // Embedding em lote (até 2048 inputs por chamada) async function generateEmbeddings( texts: string[] ): Promise<number[][]> { const batchSize = 100; const allEmbeddings: number[][] = []; for (let i = 0; i < texts.length; i += batchSize) { const batch = texts.slice(i, i + batchSize); const response = await openai.embeddings.create({ model: 'text-embedding-3-small', input: batch.map(t => t.slice(0, 8000)), dimensions: 1536, }); allEmbeddings.push( ...response.data.map(d => d.embedding) ); if (i + batchSize < texts.length) { await new Promise(r => setTimeout(r, 100)); } } return allEmbeddings; }
Vector search básico
import { Pool } from 'pg'; const pool = new Pool({ connectionString: process.env.DATABASE_URL }); async function vectorSearch( query: string, limit: number = 10 ): Promise<SearchResult[]> { const queryEmbedding = await generateEmbedding(query); const result = await pool.query(` SELECT id, title, content, metadata, 1 - (embedding <=> $1::vector) AS similarity FROM documents WHERE embedding IS NOT NULL ORDER BY embedding <=> $1::vector LIMIT $2 `, [JSON.stringify(queryEmbedding), limit]); return result.rows; }
O operador <=> calcula distância cosseno. Subtraindo de 1 temos similaridade cosseno (maior = mais parecido).
Tuning de performance
Com índices HNSW, o parâmetro chave é ef_search. Ele controla o trade-off entre velocidade e recall.
-- Default: ef_search = 40 (rápido, ~95% recall) SET hnsw.ef_search = 40; -- Maior precisão: ef_search = 100 (~99% recall, 2-3x mais lento) SET hnsw.ef_search = 100;
Benchmarks com 1M de documentos (1536 dimensões):
| ef_search | Recall@10 | Latência (p50) | Latência (p99) |
|---|---|---|---|
| 40 | 95.2% | 5ms | 15ms |
| 100 | 98.8% | 12ms | 30ms |
| 200 | 99.5% | 25ms | 55ms |
Pra maioria das apps, ef_search = 100 é o ponto ideal.
Step 2: Adicionando keyword search (BM25)
Vector search sozinho não basta. Quando um usuário busca "ERROR-4012" ou "RFC 7519", keyword search é objetivamente melhor.
async function keywordSearch( query: string, limit: number = 10 ): Promise<SearchResult[]> { const sanitized = query.replace(/[^\w\s]/g, ' ').trim(); const tsQuery = sanitized.split(/\s+/).join(' & '); const result = await pool.query(` SELECT id, title, content, metadata, ts_rank_cd(search_vector, to_tsquery('english', $1)) AS rank FROM documents WHERE search_vector @@ to_tsquery('english', $1) ORDER BY rank DESC LIMIT $2 `, [tsQuery, limit]); return result.rows; }
Step 3: Hybrid Search com Reciprocal Rank Fusion
Aqui é onde a mágica acontece: combinar resultados de keyword e vector. A abordagem padrão é Reciprocal Rank Fusion (RRF), que mergeia listas rankeadas sem precisar normalizar scores de sistemas diferentes.
Como RRF funciona
Score RRF = Σ (1 / (k + rank_i))
k é uma constante (normalmente 60) e rank_i é a posição do documento em cada lista. Um documento que aparece em primeiro nas duas listas ganha score fusionado maior do que um que é primeiro em só uma.
Implementação
interface SearchResult { id: number; title: string; content: string; metadata: Record<string, unknown>; score: number; } interface HybridSearchOptions { limit?: number; keywordWeight?: number; vectorWeight?: number; rrfK?: number; } async function hybridSearch( query: string, options: HybridSearchOptions = {} ): Promise<SearchResult[]> { const { limit = 10, keywordWeight = 0.3, vectorWeight = 0.7, rrfK = 60, } = options; const candidateCount = limit * 4; const [keywordResults, vectorResults] = await Promise.all([ keywordSearch(query, candidateCount), vectorSearch(query, candidateCount), ]); const rrfScores = new Map<number, { score: number; doc: SearchResult; }>(); keywordResults.forEach((doc, index) => { const rank = index + 1; const rrfScore = keywordWeight * (1 / (rrfK + rank)); rrfScores.set(doc.id, { score: rrfScore, doc }); }); vectorResults.forEach((doc, index) => { const rank = index + 1; const rrfScore = vectorWeight * (1 / (rrfK + rank)); const existing = rrfScores.get(doc.id); if (existing) { existing.score += rrfScore; // Nas duas listas — boost! } else { rrfScores.set(doc.id, { score: rrfScore, doc }); } }); return Array.from(rrfScores.values()) .sort((a, b) => b.score - a.score) .slice(0, limit) .map(({ doc, score }) => ({ ...doc, score })); }
Quando ajustar os pesos
| Caso de uso | Peso keyword | Peso vector | Por quê |
|---|---|---|---|
| Q&A geral | 0.3 | 0.7 | Intenção importa mais |
| Busca de código | 0.6 | 0.4 | Símbolos exatos importam |
| Lookup de erros | 0.7 | 0.3 | Códigos de erro são match exato |
| Conversacional | 0.2 | 0.8 | Queries em linguagem natural |
| Multi-idioma | 0.1 | 0.9 | Embeddings transcendem idioma |
Step 4: Reranking semântico (O multiplicador de qualidade)
Hybrid search te leva a 80%. Reranking pega os 20% restantes — e muitas vezes esses 20% são a diferença entre "busca boa" e "busca mágica".
O que o reranking faz
Retrieval (vector + keyword) é otimizado pra recall — jogar a rede larga. Reranking é otimizado pra precisão — olhar cada candidato com cuidado e dar nota de relevância real.
Cross-encoder reranker
import Anthropic from '@anthropic-ai/sdk'; const anthropic = new Anthropic(); async function rerank( query: string, candidates: SearchResult[], topK: number = 10 ): Promise<RerankedResult[]> { const candidateList = candidates .map((c, i) => `[${i}] Title: ${c.title}\nContent: ${c.content.slice(0, 500)}`) .join('\n\n'); const response = await anthropic.messages.create({ model: 'claude-sonnet-4-20250514', max_tokens: 2000, messages: [{ role: 'user', content: `You are a search relevance judge. Given a query and candidate documents, score each document's relevance from 0.0 to 1.0. Query: "${query}" Candidates: ${candidateList} Return JSON array: [{"index": 0, "score": 0.95, "reason": "directly answers the query"}, ...] Return ONLY the JSON array, no other text.`, }], }); const scores = JSON.parse( (response.content[0] as { text: string }).text ) as { index: number; score: number; reason: string }[]; return scores .sort((a, b) => b.score - a.score) .slice(0, topK) .map(s => ({ ...candidates[s.index], rerankerScore: s.score, relevanceReason: s.reason, })); }
Modelos de reranking dedicados (alternativa mais barata)
import { CohereClient } from 'cohere-ai'; const cohere = new CohereClient({ token: process.env.COHERE_API_KEY }); async function cohereRerank( query: string, candidates: SearchResult[], topK: number = 10 ): Promise<RerankedResult[]> { const response = await cohere.v2.rerank({ model: 'rerank-v3.5', // ou 'rerank-v4.0-pro' pro último query, documents: candidates.map(c => ({ text: `${c.title}\n${c.content.slice(0, 1000)}`, })), topN: topK, }); return response.results.map(r => ({ ...candidates[r.index], rerankerScore: r.relevanceScore, relevanceReason: '', })); }
Comparação de custos pra 1.000 queries de reranking/dia (20 candidatos cada):
| Reranker | Latência | Custo/mês |
|---|---|---|
| Claude Sonnet (LLM) | ~800ms | ~$90 |
| Cohere Rerank v4.0 | ~180ms | ~$6 |
| Cohere Rerank v3.5 | ~200ms | ~$5 |
| Jina Reranker v2 | ~150ms | ~$4 |
| Self-hosted (cross-encoder) | ~100ms | Só custo de servidor |
Step 5: Juntando tudo
Pipeline completo numa função production-ready:
interface SearchConfig { limit: number; keywordWeight: number; vectorWeight: number; useReranker: boolean; rerankerType: 'llm' | 'cohere' | 'none'; candidateMultiplier: number; } const DEFAULT_CONFIG: SearchConfig = { limit: 10, keywordWeight: 0.3, vectorWeight: 0.7, useReranker: true, rerankerType: 'cohere', candidateMultiplier: 4, }; async function search( query: string, config: Partial<SearchConfig> = {} ): Promise<SearchResult[]> { const cfg = { ...DEFAULT_CONFIG, ...config }; const candidateCount = cfg.limit * cfg.candidateMultiplier; const [keywordResults, vectorResults] = await Promise.all([ keywordSearch(query, candidateCount), vectorSearch(query, candidateCount), ]); const fused = reciprocalRankFusion(keywordResults, vectorResults, cfg); if (cfg.useReranker && fused.length > 0) { const rerankerInput = fused.slice(0, cfg.limit * 2); if (cfg.rerankerType === 'llm') return rerank(query, rerankerInput, cfg.limit); if (cfg.rerankerType === 'cohere') return cohereRerank(query, rerankerInput, cfg.limit); } return fused.slice(0, cfg.limit); }
Considerações pra produção
Montar o pipeline é a parte fácil. Deixar ele confiável, rápido e economicamente viável em escala é onde entra a engenharia de verdade.
1. Frescor dos embeddings
Quando documentos mudam, os embeddings ficam defasados. Você precisa de uma estratégia:
// Opção 1: Sync na escrita (simples, adiciona latência de escrita) async function updateDocument(id: number, content: string) { const embedding = await generateEmbedding(content); await pool.query(` UPDATE documents SET content = $1, embedding = $2::vector, updated_at = NOW() WHERE id = $3 `, [content, JSON.stringify(embedding), id]); } // Opção 2: Fila assíncrona de embeddings (recomendado pra produção) import { Queue } from 'bullmq'; const embeddingQueue = new Queue('embeddings', { connection: { host: 'localhost', port: 6379 }, }); async function updateDocumentAsync(id: number, content: string) { // Atualiza conteúdo imediatamente await pool.query( 'UPDATE documents SET content = $1, updated_at = NOW() WHERE id = $2', [content, id] ); // Enfileira geração do embedding await embeddingQueue.add('generate', { documentId: id, content, }, { attempts: 3, backoff: { type: 'exponential', delay: 1000 }, }); }
2. Compreensão da query
Queries cruas dos usuários muitas vezes precisam de pré-processamento antes de entrar no pipeline:
async function preprocessQuery(rawQuery: string): Promise<{ processedQuery: string; searchConfig: Partial<SearchConfig>; }> { // 1. Detectar se é busca exata de código/erro const isExactMatch = /^[A-Z]+-\d+$|^ERROR|^0x|^HTTP \d{3}/.test(rawQuery); if (isExactMatch) { return { processedQuery: rawQuery, searchConfig: { keywordWeight: 0.9, vectorWeight: 0.1, useReranker: false }, }; } // 2. Expandir queries abreviadas (step LLM opcional) // "k8s OOM pod restart" → "Kubernetes out of memory pod restart troubleshooting" // 3. Detectar idioma pra suporte multi-idioma // Embeddings lidam com cross-lingual naturalmente, mas BM25 precisa config por idioma return { processedQuery: rawQuery, searchConfig: {}, }; }
3. Estratégia de cache
Geração de embedding é a operação mais cara. Cache agressivamente:
import { Redis } from 'ioredis'; const redis = new Redis(process.env.REDIS_URL); async function getCachedEmbedding(text: string): Promise<number[] | null> { const key = `emb:${simpleHash(text)}`; const cached = await redis.get(key); if (cached) return JSON.parse(cached); return null; } async function cacheEmbedding(text: string, embedding: number[]): Promise<void> { const key = `emb:${simpleHash(text)}`; await redis.set(key, JSON.stringify(embedding), 'EX', 86400); // TTL 24h } // Wrapper com cache async function getEmbedding(text: string): Promise<number[]> { const cached = await getCachedEmbedding(text); if (cached) return cached; const embedding = await generateEmbedding(text); await cacheEmbedding(text, embedding); return embedding; }
4. Monitoramento e medição de qualidade
Não dá pra melhorar o que não se mede. Rastreie essas métricas:
interface SearchMetrics { // Performance totalLatencyMs: number; embeddingLatencyMs: number; retrievalLatencyMs: number; rerankLatencyMs: number; // Qualidade (precisa de feedback do usuário ou sinais implícitos) clickThroughRate: number; // % de buscas com clique meanReciprocalRank: number; // média 1/posição do primeiro resultado clicado noResultsRate: number; // % de buscas com 0 resultados // Custo embeddingTokensUsed: number; rerankerCallsMade: number; }
5. Escalando além do PostgreSQL
pgvector funciona surpreendentemente bem até ~5M de vetores. Acima disso:
| Escala | Recomendação | Por quê |
|---|---|---|
| < 100K vetores | pgvector | Simples, mesmo banco |
| 100K - 5M | pgvector + HNSW tuning | Ainda aguenta, tune m e ef |
| 5M - 50M | Banco vetorial dedicado | Pinecone, Weaviate, Qdrant |
| 50M+ | Banco vetorial distribuído | Milvus, Vespa, custom |
O caminho de migração do pgvector pra um banco vetorial dedicado é direto — a geração de embeddings e a API de busca continuam as mesmas; você só troca a camada de armazenamento/query.
Escolhendo um modelo de embedding
| Modelo | Dimensões | Max tokens | Qualidade (MTEB) | Custo/1M tokens | Melhor pra |
|---|---|---|---|---|---|
| OpenAI text-embedding-3-small | 1536 | 8191 | 62.3 | $0.02 | Default custo-benefício |
| OpenAI text-embedding-3-large | 3072 | 8191 | 64.6 | $0.13 | Máxima qualidade (API) |
| Cohere embed-v4.0 | 256–1536 | 128,000 | 66.2 | $0.10 | Multi-idioma, multimodal |
| Voyage AI voyage-3 | 256–2048 | 32,000 | 67.1 | $0.06 | Documentos longos |
| nomic-embed-text (open) | 64–768 | 8192 | 62.4 | Grátis (self-host) | Privacidade, sem custo API |
| BGE-M3 (open) | 1024 | 8192 | 63.0 | Grátis (self-host) | Multi-idioma, self-hosted |
Importante: Uma vez que você escolhe um modelo de embedding, trocar depois exige re-embeder o corpus inteiro. Escolha com cuidado.
Erros comuns (e como evitar)
Erro 1: Chunking agressivo demais
Se você divide documentos em chunks minúsculos, perde contexto:
// ❌ Ruim: chunks fixos de 200 tokens perdem contexto const chunks = splitByTokenCount(document, 200); // ✅ Melhor: chunking semântico com overlap function semanticChunk(text: string): string[] { const paragraphs = text.split(/\n\n+/); const chunks: string[] = []; let current = ''; for (const para of paragraphs) { if (current.length + para.length > 1500) { if (current) chunks.push(current); current = para; } else { current += '\n\n' + para; } } if (current) chunks.push(current); return chunks.map((chunk, i) => { if (i === 0) return chunk; const prevLastSentence = chunks[i - 1].split(/\. /).pop(); return `${prevLastSentence}. ${chunk}`; }); }
Erro 2: Ignorar filtragem por metadata
-- ❌ Ruim: buscar em tudo e filtrar depois SELECT * FROM documents ORDER BY embedding <=> $1::vector LIMIT 10; -- ✅ Bom: filtrar primeiro, buscar no subconjunto SELECT * FROM documents WHERE metadata->>'category' = 'engineering' AND created_at > NOW() - INTERVAL '90 days' ORDER BY embedding <=> $1::vector LIMIT 10;
Erro 3: Não testar com queries reais
Monte um test set com queries reais dos seus usuários (search logs, tickets de suporte, feedback). Métricas automatizadas como NDCG e MRR servem, mas nada substitui olhar os resultados das suas top 50 queries com seus próprios olhos.
// Monte um golden test set const testCases = [ { query: "como resolver aquele negócio do login travando", expectedTopResult: "Resolvendo problemas de expiração de token", expectedInTop5: ["Guia de troubleshooting de auth", "Gestão de sessão"], }, // ... mais 50 queries reais do seu search log ]; async function evaluateSearch() { let hits = 0; for (const tc of testCases) { const results = await search(tc.query, { limit: 5 }); if (results.some(r => r.title === tc.expectedTopResult)) { hits++; } } console.log(`Recall@5: ${(hits / testCases.length * 100).toFixed(1)}%`); }
Erro 4: Ignorar o cold start
No lançamento, você não tem search logs. Não sabe o que os usuários vão buscar. Comece com pesos generosos de keyword (0.5/0.5 hybrid) e vá migrando pro vector gradualmente conforme coleta dados pra calibrar.
Conclusão: A árvore de decisão da busca
Construir busca AI não é escolher uma técnica — é empilhar elas corretamente:
-
Comece com hybrid search (BM25 + vector). Só isso já supera cada abordagem individual em 15-25%.
-
Adicione reranking quando precisar de precisão. Uma chamada Cohere Rerank adiciona ~200ms e custa centavos, mas melhora dramaticamente a qualidade do top-3.
-
Use pgvector a menos que tenha razão específica pra não usar. Vetores no mesmo PostgreSQL simplifica ops, transações, backups e JOINs.
-
Meça sem parar. Rastreie CTR, taxa de zero-resultados, e monte um golden test set com queries reais.
-
Não complique embeddings no dia um. Comece com
text-embedding-3-small, faça deploy, colete queries reais, e depois decida se precisa de modelo mais poderoso (e mais caro).
A distância entre "busca por keywords" e "busca com IA" não é mais tese de doutorado. Com os padrões deste guia, um dev sozinho consegue montar num fim de semana um sistema de busca que cinco anos atrás exigiria um time dedicado por um trimestre. As ferramentas amadureceram. Os padrões estão provados. Só falta construir.
Explore ferramentas relacionadas
Experimente estas ferramentas gratuitas do Pockit