Back

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_searchRecall@10Latência (p50)Latência (p99)
4095.2%5ms15ms
10098.8%12ms30ms
20099.5%25ms55ms

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 usoPeso keywordPeso vectorPor quê
Q&A geral0.30.7Intenção importa mais
Busca de código0.60.4Símbolos exatos importam
Lookup de erros0.70.3Códigos de erro são match exato
Conversacional0.20.8Queries em linguagem natural
Multi-idioma0.10.9Embeddings 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):

RerankerLatênciaCusto/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)~100msSó 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:

EscalaRecomendaçãoPor quê
< 100K vetorespgvectorSimples, mesmo banco
100K - 5Mpgvector + HNSW tuningAinda aguenta, tune m e ef
5M - 50MBanco vetorial dedicadoPinecone, Weaviate, Qdrant
50M+Banco vetorial distribuídoMilvus, 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

ModeloDimensõesMax tokensQualidade (MTEB)Custo/1M tokensMelhor pra
OpenAI text-embedding-3-small1536819162.3$0.02Default custo-benefício
OpenAI text-embedding-3-large3072819164.6$0.13Máxima qualidade (API)
Cohere embed-v4.0256–1536128,00066.2$0.10Multi-idioma, multimodal
Voyage AI voyage-3256–204832,00067.1$0.06Documentos longos
nomic-embed-text (open)64–768819262.4Grátis (self-host)Privacidade, sem custo API
BGE-M3 (open)1024819263.0Grá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:

  1. Comece com hybrid search (BM25 + vector). Só isso já supera cada abordagem individual em 15-25%.

  2. Adicione reranking quando precisar de precisão. Uma chamada Cohere Rerank adiciona ~200ms e custa centavos, mas melhora dramaticamente a qualidade do top-3.

  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.

  4. Meça sem parar. Rastreie CTR, taxa de zero-resultados, e monte um golden test set com queries reais.

  5. 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.

AIsearchvector-databaseembeddingspgvectorPostgreSQLTypeScriptRAGsemantic-searchproduction

Explore ferramentas relacionadas

Experimente estas ferramentas gratuitas do Pockit