Back

Cómo construir búsqueda potenciada por IA para tu app: Vector Search, Hybrid Search y Semantic Ranking desde cero

Tus usuarios buscan "cómo arreglar lo del login cuando se traba" y tu buscador devuelve cero resultados, porque ningún documento contiene esa frase exacta. Mientras tanto, hay un artículo perfecto titulado "Resolución de problemas de expiración de tokens de autenticación" — invisible para la búsqueda por keywords.

Esta es la falla fundamental de la búsqueda tradicional: matchea palabras, no significado. Y en 2026, los usuarios esperan búsqueda que entienda intención.

La buena noticia: construir búsqueda potenciada por IA ya no es un proyecto de doctorado. Con modelos de embedding modernos, bases vectoriales y algunos patrones inteligentes, podés armar un sistema de búsqueda que genuinamente entiende lo que los usuarios quieren decir — no solo lo que tipean.

En esta guía vamos a construir un sistema de búsqueda AI production-ready paso a paso. Arrancamos con los fundamentos del vector search, evolucionamos a hybrid search (el punto dulce para la mayoría de las apps), agregamos reranking semántico para precisión, y cubrimos los problemas de producción que los tutoriales saltean. Todo con código TypeScript que podés shipear.

Las tres generaciones de la búsqueda

Antes de meternos al código, entendamos dónde estamos y por qué existe cada generación.

Generación 1: Búsqueda por keywords (BM25/TF-IDF)

Es lo que la mayoría de las apps siguen usando. tsvector de PostgreSQL, el modo default de Elasticsearch, o incluso queries SQL con LIKE.

-- El approach clásico SELECT * FROM articles WHERE to_tsvector('english', title || ' ' || body) @@ to_tsquery('english', 'authentication & token & expiry');

Cómo funciona: Cuenta cuántas veces aparecen los términos de la query en los documentos, pondera por rareza (IDF), y rankea por score de relevancia.

Dónde anda bien:

  • Match exacto de términos ("ERROR 0x80070005")
  • Búsqueda de un documento específico por nombre
  • Queries estructuradas con operadores booleanos
  • Jerga de dominio específico que los modelos de embedding capaz no entienden

Dónde falla:

  • Sinónimos ("auto" vs "coche" vs "vehículo")
  • Entender intención ("cómo hacer mi sitio más rápido" → debería matchear "optimización de rendimiento web")
  • Tolerancia a typos (aunque fuzzy matching ayuda parcialmente)
  • Queries multi-idioma

Generación 2: Vector Search (Semántico)

Vector search convierte texto en representaciones numéricas (embeddings) que capturan significado. Conceptos similares terminan cerca en el espacio vectorial, sin importar las palabras exactas.

// "arreglar problema de login" y "resolver error de autenticación" // terminan como vectores cercanos const embedding1 = await embed("arreglar problema de login"); const embedding2 = await embed("resolver error de autenticación"); cosineSimilarity(embedding1, embedding2); // ~0.92 (¡muy similares!)

Cómo funciona: Un modelo de embedding (como text-embedding-3-small de OpenAI o nomic-embed-text open-source) convierte texto en un vector de alta dimensión (típicamente 256-1536 dimensiones). Buscar se vuelve encontrar los vecinos más cercanos en el espacio vectorial.

Dónde brilla:

  • Entender la intención detrás de queries vagas
  • Búsqueda cross-lingual (los embeddings trascienden barreras de idioma)
  • Encontrar contenido semánticamente relacionado incluso sin overlap de palabras

Dónde flaquea:

  • Match exacto de keywords (¡irónicamente!)
  • Términos técnicos raros que el modelo no vio
  • Sesgo de recencia — los embeddings no saben qué es "nuevo"
  • Queries con filtros/facetas ("artículos con tag React publicados después de 2025")

Generación 3: Hybrid Search + Reranking (El sweet spot de 2026)

El insight clave: keyword search y vector search fallan de maneras complementarias. Combinándolos, se cubren mutuamente los puntos ciegos.

Query del usuario
    ↓
┌──────────────────────┐
│  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, pero potente)
    ↓
Top 10 resultados finales

Esto es lo que vamos a construir. Arrancamos.

Step 1: Vector Search con pgvector

No necesitás una base vectorial dedicada para empezar. PostgreSQL con la extensión pgvector maneja millones de vectores con excelente performance y te da la ventaja de tener todo en una sola base.

Setup de la base

-- Habilitar pgvector CREATE EXTENSION IF NOT EXISTS vector; -- Tabla de documentos con columna de embedding CREATE TABLE documents ( id SERIAL PRIMARY KEY, title TEXT NOT NULL, content TEXT NOT NULL, metadata JSONB DEFAULT '{}', embedding vector(1536), -- Dimensión de text-embedding-3-small created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); -- Índice HNSW para búsqueda aproximada de vecinos cercanos -- Esto es la clave del rendimiento a escala CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64); -- También índice de full-text search para BM25 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);

Generando embeddings

import OpenAI from 'openai'; const openai = new OpenAI(); async function generateEmbedding(text: string): Promise<number[]> { // Truncar al límite de tokens del modelo 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 por lotes (hasta 2048 inputs por llamada) 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) ); // Respetar rate limits 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; }

El operador <=> calcula la distancia coseno. Restando de 1 obtenemos similaridad coseno (más alto = más parecido).

Tuning de performance

Con índices HNSW, hay un parámetro clave: ef_search. Controla el trade-off entre velocidad y recall (precisión).

-- Default: ef_search = 40 (rápido, ~95% recall) SET hnsw.ef_search = 40; -- Mayor precisión: ef_search = 100 (~99% recall, 2-3x más lento) SET hnsw.ef_search = 100; -- En producción, seteá por query según el caso de uso

Benchmarks con 1M de documentos (1536 dimensiones):

ef_searchRecall@10Latencia (p50)Latencia (p99)
4095.2%5ms15ms
10098.8%12ms30ms
20099.5%25ms55ms

Para la mayoría de las apps, ef_search = 100 es el sweet spot.

Step 2: Agregando keyword search (BM25)

Vector search solo no alcanza. Cuando un usuario busca "ERROR-4012" o "RFC 7519", keyword search es objetivamente mejor. Sumemos BM25 full-text search.

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 con Reciprocal Rank Fusion

Acá está la magia: combinar resultados de keyword y vector. El enfoque estándar es Reciprocal Rank Fusion (RRF), que mergea listas rankeadas sin necesitar normalizar scores de distintos sistemas.

Cómo funciona RRF

RRF Score = Σ (1 / (k + rank_i))

Donde k es una constante (típicamente 60) y rank_i es la posición del documento en cada lista. Un documento que aparece primero en ambas listas saca un score fusionado más alto que uno primero en una sola.

Implementación

interface SearchResult { id: number; title: string; content: string; metadata: Record<string, unknown>; score: number; } interface HybridSearchOptions { limit?: number; keywordWeight?: number; // 0-1, peso para keyword vectorWeight?: number; // 0-1, peso para vector rrfK?: number; // Constante RRF, default 60 } async function hybridSearch( query: string, options: HybridSearchOptions = {} ): Promise<SearchResult[]> { const { limit = 10, keywordWeight = 0.3, vectorWeight = 0.7, rrfK = 60, } = options; // Correr ambas búsquedas en paralelo const candidateCount = limit * 4; const [keywordResults, vectorResults] = await Promise.all([ keywordSearch(query, candidateCount), vectorSearch(query, candidateCount), ]); // Armar mapa de scores const rrfScores = new Map<number, { score: number; doc: SearchResult; }>(); // Scorear resultados de keyword keywordResults.forEach((doc, index) => { const rank = index + 1; const rrfScore = keywordWeight * (1 / (rrfK + rank)); rrfScores.set(doc.id, { score: rrfScore, doc, }); }); // Scorear resultados de vector (sumar si ya existe, crear si no) 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; // En ambas listas — ¡boost! } else { rrfScores.set(doc.id, { score: rrfScore, doc, }); } }); // Ordenar por score fusionado return Array.from(rrfScores.values()) .sort((a, b) => b.score - a.score) .slice(0, limit) .map(({ doc, score }) => ({ ...doc, score })); }

Cuándo ajustar pesos

Caso de usoPeso keywordPeso vectorPor qué
Q&A general0.30.7La intención importa más
Búsqueda de código0.60.4Los símbolos exactos importan
Lookup de errores0.70.3Códigos de error son match exacto
Conversacional0.20.8Queries en lenguaje natural
Multi-idioma0.10.9Los embeddings trascienden el idioma

Step 4: Reranking semántico (El multiplicador de calidad)

Hybrid search te lleva al 80%. Reranking te da el 20% restante — y muchas veces ese 20% es la diferencia entre "buena búsqueda" y "búsqueda mágica".

Qué hace el reranking

El retrieval (vector + keyword) está optimizado para recall — tirar una red amplia. El reranking está optimizado para precisión — mirar cada candidato con lupa y scorear qué tan relevante es realmente para la query.

Un reranker toma la query y cada documento candidato como par y produce un score de relevancia. A diferencia de los embeddings (que codifican query y documento por separado), el reranker ve los dos juntos y captura relevancia de grano fino.

Cross-encoder reranker

import Anthropic from '@anthropic-ai/sdk'; const anthropic = new Anthropic(); interface RerankedResult extends SearchResult { rerankerScore: number; relevanceReason: string; } 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"}, ...] Score criteria: - 1.0: Directly and completely answers the query - 0.7-0.9: Highly relevant, addresses the core intent - 0.4-0.6: Partially relevant, related topic - 0.1-0.3: Tangentially related - 0.0: Not relevant at all 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 más barata)

LLM reranking es poderoso pero caro. Para alto volumen, usá un modelo de reranking dedicado:

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', // o 'rerank-v4.0-pro' para el ú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: '', })); }

Comparación de costos para 1.000 queries de reranking/día (20 candidatos c/u):

RerankerLatenciaCosto/mes
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)~100msSolo costo de server

Para la mayoría de las apps, un modelo de reranking dedicado es la mejor opción. Reservá LLM reranking para casos donde necesitás la capacidad de razonamiento.

Step 5: Armando todo

El pipeline completo en una sola función 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; // Etapa 1: Retrieval paralelo const [keywordResults, vectorResults] = await Promise.all([ keywordSearch(query, candidateCount), vectorSearch(query, candidateCount), ]); // Etapa 2: Reciprocal Rank Fusion const fused = reciprocalRankFusion( keywordResults, vectorResults, cfg ); // Etapa 3: Reranking (opcional) if (cfg.useReranker && fused.length > 0) { const rerankerInput = fused.slice(0, cfg.limit * 2); if (cfg.rerankerType === 'llm') { return rerank(query, rerankerInput, cfg.limit); } else if (cfg.rerankerType === 'cohere') { return cohereRerank(query, rerankerInput, cfg.limit); } } return fused.slice(0, cfg.limit); }

Consideraciones de producción

Armar el pipeline es la parte fácil. Hacerlo confiable, rápido y cost-effective a escala es donde está la ingeniería real.

1. Frescura de embeddings

Cuando los documentos cambian, sus embeddings envejecen. Necesitás una estrategia:

// Opción 1: Sync al escribir (simple, agrega latencia de escritura) 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]); } // Opción 2: Cola async de embeddings (recomendado para producción) import { Queue } from 'bullmq'; const embeddingQueue = new Queue('embeddings', { connection: { host: 'localhost', port: 6379 }, }); async function updateDocumentAsync(id: number, content: string) { await pool.query( 'UPDATE documents SET content = $1, updated_at = NOW() WHERE id = $2', [content, id] ); await embeddingQueue.add('generate', { documentId: id, content, }, { attempts: 3, backoff: { type: 'exponential', delay: 1000 }, }); }

2. Comprensión de la query

Las queries crudas de los usuarios muchas veces necesitan preprocesamiento antes de entrar al pipeline:

async function preprocessQuery(rawQuery: string): Promise<{ processedQuery: string; searchConfig: Partial<SearchConfig>; }> { // 1. Detectar si es búsqueda exacta de código/error 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 (paso LLM opcional) // "k8s OOM pod restart" → "Kubernetes out of memory pod restart troubleshooting" // 3. Detección de idioma para soporte multi-idioma // Los embeddings manejan cross-lingual naturalmente, pero BM25 necesita config por idioma return { processedQuery: rawQuery, searchConfig: {}, }; }

3. Estrategia de caching

La generación de embeddings es la operación más cara. Cacheá agresivamente:

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 } 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. Monitoreo y medición de calidad

No podés mejorar lo que no medís. Trackeá estas métricas:

interface SearchMetrics { // Performance totalLatencyMs: number; embeddingLatencyMs: number; retrievalLatencyMs: number; rerankLatencyMs: number; // Calidad (requiere feedback del usuario o señales implícitas) clickThroughRate: number; // % de búsquedas con click meanReciprocalRank: number; // promedio 1/posición del primer resultado clickeado noResultsRate: number; // % de búsquedas con 0 resultados // Costo embeddingTokensUsed: number; rerankerCallsMade: number; }

5. Escalando más allá de PostgreSQL

pgvector funciona sorprendentemente bien hasta ~5M de vectores. Más allá:

EscalaRecomendaciónPor qué
< 100K vectorespgvectorMantené la simpleza, misma DB
100K - 5Mpgvector + HNSW tuningTodavía anda, tuneá m y ef
5M - 50MDB vectorial dedicadaPinecone, Weaviate, Qdrant
50M+DB vectorial distribuidaMilvus, Vespa, custom

Eligiendo un modelo de embedding

ModeloDimensionesMax tokensCalidad (MTEB)Costo/1M tokensMejor para
OpenAI text-embedding-3-small1536819162.3$0.02Default cost-effective
OpenAI text-embedding-3-large3072819164.6$0.13Máxima calidad (API)
Cohere embed-v4.0256–1536128,00066.2$0.10Multi-idioma, multimodal
Voyage AI voyage-3256–204832,00067.1$0.06Documentos largos
nomic-embed-text (open)64–768819262.4Gratis (self-host)Privacidad, sin costos API
BGE-M3 (open)1024819263.0Gratis (self-host)Multi-idioma, self-hosted

Importante: Una vez que elegís un modelo de embedding, cambiarlo después requiere re-embeder todo tu corpus. Elegí con cuidado.

Errores comunes (y cómo evitarlos)

Error 1: Chunking demasiado agresivo

Si partís los documentos en chunks chiquitos, perdés contexto:

// ❌ Mal: chunks fijos de 200 tokens pierden contexto const chunks = splitByTokenCount(document, 200); // ✅ Mejor: chunking semántico con 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}`; }); }

Error 2: Ignorar filtrado por metadata

-- ❌ Mal: buscar en todo y filtrar después SELECT * FROM documents ORDER BY embedding <=> $1::vector LIMIT 10; -- ✅ Bien: filtrar primero, buscar en el subconjunto SELECT * FROM documents WHERE metadata->>'category' = 'engineering' AND created_at > NOW() - INTERVAL '90 days' ORDER BY embedding <=> $1::vector LIMIT 10;

Error 3: No testear con queries reales

Armá un test set con queries reales de tus usuarios (search logs, tickets de soporte, feedback). Las métricas automatizadas como NDCG y MRR sirven, pero nada reemplaza mirar los resultados de tus top 50 queries con tus propios ojos.

// Armá un golden test set const testCases = [ { query: "cómo arreglar lo del login cuando se traba", expectedTopResult: "Resolución de problemas de expiración de tokens", expectedInTop5: ["Guía de troubleshooting de auth", "Gestión de sesiones"], }, // ... 50 queries reales más de tu 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)}%`); }

Error 4: Ignorar el cold start

Cuando lanzás, no tenés search logs. No sabés qué van a buscar los usuarios. Arrancá con pesos generosos de keyword (0.5/0.5 hybrid) y gradualmente movete hacia vector a medida que juntás datos para calibrar.

Conclusión: El árbol de decisiones de búsqueda

Construir búsqueda AI no es elegir una técnica — es apilarlas correctamente:

  1. Arrancá con hybrid search (BM25 + vector). Solo esto supera a cada approach individual en un 15-25%.

  2. Agregá reranking cuando necesitás precisión. Un call a Cohere Rerank agrega ~200ms y cuesta centavos, pero mejora dramáticamente la calidad del top-3.

  3. Usá pgvector salvo que tengas una razón específica para no hacerlo. Tener los vectores en tu PostgreSQL simplifica ops, transacciones, backups y JOINs.

  4. Medí sin parar. Trackeá CTR, tasa de no-resultados, y armá un golden test set con queries reales.

  5. No sobre-ingenierees embeddings el día uno. Arrancá con text-embedding-3-small, shipealo, juntá queries reales, y después decidí si necesitás un modelo más power (y más caro).

La brecha entre "búsqueda por keywords" y "búsqueda AI" ya no es una tesis doctoral. Con los patrones de esta guía, un solo dev puede armar un sistema de búsqueda en un finde que hace cinco años hubiera requerido un equipo dedicado un trimestre entero. Las herramientas están maduras. Los patrones están probados. Lo único que queda es construirlo.

AIsearchvector-databaseembeddingspgvectorPostgreSQLTypeScriptRAGsemantic-searchproduction

Explora herramientas relacionadas

Prueba estas herramientas gratuitas de Pockit