Saltar al contenido principal

RAG, embeddings y bases de datos vectoriales

Los LLMs tienen dos limitaciones fundamentales:

  1. Conocimiento estático: solo saben lo que aprendieron durante el entrenamiento (hasta cierta fecha).
  2. Sin acceso a tus datos: no conocen tu documentación, tu base de datos, tus PDFs, tus tickets de soporte…

RAG (Retrieval-Augmented Generation) soluciona esto: antes de enviar la pregunta al LLM, buscamos información relevante en nuestros propios datos y la incluimos en el prompt.

Embeddings: la base técnica

Los ordenadores no entienden palabras, sino números. Un embedding es la traducción de un texto (palabra, frase o documento) a una lista de números (un vector).

Lo especial de estos vectores es que capturan el significado semántico: textos con conceptos parecidos acaban en posiciones cercanas dentro de ese espacio matemático, lo que permite realizar búsquedas por "sentido" en lugar de por coincidencia exacta de letras.

"Node.js es JavaScript en el servidor"  → [0.12, -0.45, 0.88, ...]  (1536 dimensiones)
"JavaScript backend con Node" → [0.13, -0.44, 0.85, ...] ← muy cercano
"Receta de paella valenciana" → [-0.72, 0.31, -0.19, ...] ← muy lejano

Generar embeddings con OpenAI

OpenAI ofrece modelos especializados como text-embedding-3-small. Este modelo toma un texto y devuelve un array de 1536 números reales que lo representan.

import OpenAI from 'openai';

const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

const result = await client.embeddings.create({
model: 'text-embedding-3-small', // o text-embedding-3-large
input: 'Node.js es un entorno de ejecución de JavaScript'
});

const vector = result.data[0].embedding; // Array de 1536 números
console.log(vector.length); // 1536

Generar embeddings con Ollama (local)

Si prefieres no depender de una API externa o quieres ahorrar costes, Ollama permite ejecutar modelos de embeddings como nomic-embed-text directamente en tu hardware.

import ollama from 'ollama';

const result = await ollama.embeddings({
model: 'nomic-embed-text',
prompt: 'Node.js es un entorno de ejecución de JavaScript'
});

const vector = result.embedding; // Array de 768 números

Similitud coseno

Una vez que tenemos los vectores, necesitamos una forma de compararlos. La similitud coseno es una métrica matemática que mide el ángulo entre dos vectores. Si el resultado es cercano a 1, significa que los textos son semánticamente muy parecidos; si es cercano a 0 (o negativo), no tienen relación.

function cosineSimilarity(a, b) {
const dot = a.reduce((sum, val, i) => sum + val * b[i], 0);
const normA = Math.sqrt(a.reduce((sum, val) => sum + val * val, 0));
const normB = Math.sqrt(b.reduce((sum, val) => sum + val * val, 0));
return dot / (normA * normB);
}

const sim = cosineSimilarity(vectorA, vectorB);
// > 0.9 → muy similares
// ~ 0.5 → algo relacionados
// < 0.2 → no relacionados

Bases de datos vectoriales

Almacenan los embeddings y permiten búsquedas eficientes por similitud (ANN, Approximate Nearest Neighbor).

pgvector (PostgreSQL + vectores)

La opción más práctica si ya usas PostgreSQL.

# Docker con pgvector ya incluido
docker run -d \
-e POSTGRES_PASSWORD=password \
-p 5432:5432 \
pgvector/pgvector:pg16
-- Habilitar la extensión
CREATE EXTENSION vector;

-- Tabla para almacenar documentos con su embedding
CREATE TABLE documentos (
id SERIAL PRIMARY KEY,
contenido TEXT,
metadata JSONB,
embedding vector(1536) -- dimensiones del modelo de OpenAI
);

-- Índice para búsquedas rápidas
CREATE INDEX ON documentos USING ivfflat (embedding vector_cosine_ops);
import pg from 'pg';
import OpenAI from 'openai';

const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

// Insertar un documento
async function insertarDocumento(contenido, metadata = {}) {
const { data } = await openai.embeddings.create({
model: 'text-embedding-3-small',
input: contenido
});
const embedding = data[0].embedding;

await pool.query(
'INSERT INTO documentos (contenido, metadata, embedding) VALUES ($1, $2, $3)',
[contenido, metadata, JSON.stringify(embedding)]
);
}

// Buscar documentos similares
async function buscarSimilares(pregunta, limite = 5) {
const { data } = await openai.embeddings.create({
model: 'text-embedding-3-small',
input: pregunta
});
const embedding = data[0].embedding;

const { rows } = await pool.query(
`SELECT contenido, metadata,
1 - (embedding <=> $1::vector) AS similitud
FROM documentos
ORDER BY embedding <=> $1::vector
LIMIT $2`,
[JSON.stringify(embedding), limite]
);

return rows;
}

ChromaDB (vectorial dedicada, local)

A diferencia de SQL, una base de datos vectorial nativa como ChromaDB está diseñada específicamente para este propósito. Es muy sencilla de usar porque gestiona automáticamente la generación de embeddings y el almacenamiento de metadatos.

import { ChromaClient, OpenAIEmbeddingFunction } from 'chromadb';

const client = new ChromaClient({ path: 'http://localhost:8000' });

const embedder = new OpenAIEmbeddingFunction({
openai_api_key: process.env.OPENAI_API_KEY
});

// Crear o recuperar una colección
const coleccion = await client.getOrCreateCollection({
name: 'documentacion',
embeddingFunction: embedder
});

// Añadir documentos (Chroma genera los embeddings automáticamente)
await coleccion.add({
ids: ['doc-1', 'doc-2'],
documents: ['Node.js usa el event loop...', 'Express.js es un framework...'],
metadatas: [{ fuente: 'guia-nodejs.pdf' }, { fuente: 'guia-express.pdf' }]
});

// Buscar
const resultados = await coleccion.query({
queryTexts: ['¿Cómo funciona el event loop?'],
nResults: 3
});

console.log(resultados.documents[0]); // Top 3 fragmentos relevantes

Pipeline RAG completo

Este es el flujo completo de un sistema RAG en producción:

Fase 1 — Indexación (ingesta de datos)

Esta fase se realiza previamente. El objetivo es preparar nuestros datos para que sean buscables. Consta de tres pasos:

  • Cargar.
  • Trocear (chunking) para que los textos quepan en la ventana de contexto del LLM.
  • Guardar los trozos con su vector asociado.
import fs from 'fs/promises';

// 1. Cargar documentos del sistema de archivos
async function cargarDocumentos(directorio) {
const archivos = await fs.readdir(directorio);
const docs = [];

for (const archivo of archivos) {
const contenido = await fs.readFile(`${directorio}/${archivo}`, 'utf-8');
docs.push({ contenido, fuente: archivo });
}
return docs;
}

// 2. Trocear (chunking) – fundamental para la precisión y no superar el contexto
function trocearTexto(texto, tamañoChunk = 500, solapamiento = 50) {
const palabras = texto.split(' ');
const chunks = [];

for (let i = 0; i < palabras.length; i += tamañoChunk - solapamiento) {
const chunk = palabras.slice(i, i + tamañoChunk).join(' ');
if (chunk.trim()) chunks.push(chunk);
}

return chunks;
}

// 3. Generar e insertar embeddings en la BD
async function indexarDocumentos(directorio) {
const documentos = await cargarDocumentos(directorio);

for (const doc of documentos) {
const chunks = trocearTexto(doc.contenido);

for (const chunk of chunks) {
await insertarDocumento(chunk, { fuente: doc.fuente });
}
}

console.log('Indexación completada');
}

Fase 2 — Consulta (recuperación y respuesta)

Cuando el usuario pregunta, realizamos la búsqueda semántica, recuperamos los trozos más relevantes y se los pasamos al LLM junto con la pregunta original. El LLM actúa entonces como una interfaz que "lee" esos documentos por nosotros y extrae la respuesta.

import OpenAI from 'openai';

const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

async function preguntarConRAG(pregunta) {
// 1. Buscar contexto relevante en la base de datos vectorial
const fragmentos = await buscarSimilares(pregunta, 4);
const contexto = fragmentos
.map(f => f.contenido)
.join('\n\n---\n\n');

// 2. Construir prompt enriquecido (Augmentation)
const prompt = `Eres un asistente que responde preguntas basándose ÚNICAMENTE en el contexto proporcionado.
Si la información no está en el contexto, di que no tienes esa información.

CONTEXTO:
${contexto}

PREGUNTA: ${pregunta}`;

// 3. Llamar al LLM (Generation)
const response = await openai.chat.completions.create({
model: 'gpt-4o-mini',
messages: [{ role: 'user', content: prompt }],
temperature: 0.2 // baja temperatura para mayor fidelidad al texto
});

return {
respuesta: response.choices[0].message.content,
fuentes: [...new Set(fragmentos.map(f => f.metadata?.fuente))]
};
}

RAG con Vercel AI SDK

El Vercel AI SDK simplifica esto con funciones integradas para calcular similitudes sin necesidad de implementar la fórmula matemática manualmente.

import { generateText, embed, cosineSimilarity } from 'ai';
import { openai } from '@ai-sdk/openai';

// Buscar contexto y generar respuesta
async function chatConDocumentos(pregunta, documentos) {
// Embedding de la pregunta
const { embedding: queryEmbedding } = await embed({
model: openai.embedding('text-embedding-3-small'),
value: pregunta
});

// Encontrar los más similares calculando la similitud coseno
const similares = documentos
.map(doc => ({
...doc,
similitud: cosineSimilarity(queryEmbedding, doc.embedding)
}))
.sort((a, b) => b.similitud - a.similitud)
.slice(0, 4);

const contexto = similares.map(d => d.contenido).join('\n\n');

// Generar respuesta con el prompt enriquecido
const { text } = await generateText({
model: openai('gpt-4o-mini'),
system: 'Responde usando solo el contexto dado. Si no sabes, dilo.',
prompt: `Contexto:\n${contexto}\n\nPregunta: ${pregunta}`
});

return text;
}

Estrategias de chunking

El tamaño de los fragmentos (chunks) es crítico para el éxito del RAG. Si son muy pequeños (por ejemplo, 1 palabra), carecen de contexto para que el LLM los entienda. Si son muy grandes (por ejemplo, 50 páginas), diluyen el significado específico que buscamos y pueden superar el límite de caracteres (ventana de contexto) del modelo.

El tamaño ideal depende de tu caso de uso:

EstrategiaTamañoCuándo usarla
Chunk fijo200-500 palabrasUso general
Por párrafosVariableDocumentos bien estructurados
Con solapamiento10-15% de solapamiento (overlap)Evitar cortes en contexto importante
SemánticoVariableMáxima calidad (más complejo)

En general:

  • Chunks más pequeñosmayor precisión en la búsqueda.
  • Chunks más grandesmás contexto por fragmento.

Experimenta con tu caso de uso específico.