Inicio / Inteligencia Artificial / AI-First Full Stack: Construye Apps con IA / RAG: Implementación Práctica

RAG: Implementación Práctica

Pipeline RAG completo: ingesta, chunking, embeddings, retrieval, re-ranking y generación con contexto.

Intermedio
🔒 Solo lectura
📖

Estás en modo lectura

Puedes leer toda la lección, pero para marcar progreso, hacer ejercicios y ganar XP necesitas una cuenta Pro.

Desbloquear por $9/mes

RAG: Implementación Práctica

RAG (Retrieval Augmented Generation) es el patrón más importante en aplicaciones AI-First. Permite que un LLM responda preguntas usando tus datos propios, reduciendo alucinaciones y eliminando la necesidad de re-entrenar el modelo.


Arquitectura RAG

Pregunta del usuario
        │
        ▼
┌──────────────────┐
│   1. EMBEDDING   │  Convertir pregunta a vector
│   de la query    │
└────────┬─────────┘
         │
         ▼
┌──────────────────┐
│   2. RETRIEVAL   │  Buscar documentos similares en vector DB
│   (búsqueda)     │
└────────┬─────────┘
         │
         ▼
┌──────────────────┐
│  3. RE-RANKING   │  Reordenar por relevancia (opcional)
│                  │
└────────┬─────────┘
         │
         ▼
┌──────────────────┐
│  4. GENERATION   │  LLM genera respuesta usando los docs como contexto
│  (LLM + contexto)│
└──────────────────┘

Paso 1: Ingesta y Chunking

¿Por qué chunking?

Los documentos largos no caben en un solo embedding. Hay que dividirlos en chunks de tamaño manejable:

interface Chunk {
  content: string;
  metadata: {
    source: string;
    chunkIndex: number;
    totalChunks: number;
  };
}

// Chunking simple por caracteres con overlap
function chunkText(text: string, options: {
  chunkSize?: number;
  overlap?: number;
} = {}): string[] {
  const { chunkSize = 1000, overlap = 200 } = options;
  const chunks: string[] = [];

  for (let i = 0; i < text.length; i += chunkSize - overlap) {
    chunks.push(text.slice(i, i + chunkSize));
  }

  return chunks;
}

Chunking inteligente (por secciones)

function chunkByHeadings(markdown: string): Chunk[] {
  const sections = markdown.split(/^##\s+/gm);
  const chunks: Chunk[] = [];

  for (const section of sections) {
    if (section.trim().length < 50) continue; // Ignorar secciones vacías

    // Si la sección es muy larga, subdividir
    if (section.length > 2000) {
      const subChunks = chunkText(section, { chunkSize: 1000, overlap: 200 });
      chunks.push(...subChunks.map((c, i) => ({
        content: c,
        metadata: { source: '', chunkIndex: i, totalChunks: subChunks.length },
      })));
    } else {
      chunks.push({
        content: section,
        metadata: { source: '', chunkIndex: 0, totalChunks: 1 },
      });
    }
  }

  return chunks;
}

Chunking recursivo (LangChain style)

function recursiveChunk(
  text: string,
  maxSize: number = 1000,
  separators: string[] = ['\n\n', '\n', '. ', ' ']
): string[] {
  if (text.length <= maxSize) return [text];

  const separator = separators[0];
  const parts = text.split(separator);
  const chunks: string[] = [];
  let current = '';

  for (const part of parts) {
    if ((current + separator + part).length > maxSize && current) {
      chunks.push(current.trim());
      current = part;
    } else {
      current = current ? current + separator + part : part;
    }
  }

  if (current) {
    if (current.length > maxSize && separators.length > 1) {
      chunks.push(...recursiveChunk(current, maxSize, separators.slice(1)));
    } else {
      chunks.push(current.trim());
    }
  }

  return chunks;
}

Paso 2: Pipeline de Ingesta Completo

import OpenAI from 'openai';
import { readFileSync, readdirSync } from 'fs';
import { join } from 'path';

class RAGIngester {
  private openai: OpenAI;

  constructor() {
    this.openai = new OpenAI();
  }

  async ingestDirectory(dirPath: string) {
    const files = readdirSync(dirPath).filter(f => f.endsWith('.md'));
    console.log(`Procesando ${files.length} archivos...`);

    for (const file of files) {
      const content = readFileSync(join(dirPath, file), 'utf-8');
      const chunks = recursiveChunk(content, 1000);

      // Generar embeddings en batch
      const response = await this.openai.embeddings.create({
        model: 'text-embedding-3-small',
        input: chunks,
      });

      // Insertar en la base de datos
      for (let i = 0; i < chunks.length; i++) {
        await db.query(
          `INSERT INTO documents (content, embedding, metadata)
           VALUES ($1, $2::vector, $3)`,
          [
            chunks[i],
            JSON.stringify(response.data[i].embedding),
            JSON.stringify({
              source: file,
              chunk_index: i,
              total_chunks: chunks.length,
            }),
          ]
        );
      }

      console.log(`  ✓ ${file}: ${chunks.length} chunks indexados`);
    }
  }
}

Paso 3: Retrieval

Búsqueda semántica básica

async function retrieve(query: string, topK = 5) {
  // Embedding de la pregunta
  const response = await openai.embeddings.create({
    model: 'text-embedding-3-small',
    input: query,
  });
  const queryEmbedding = response.data[0].embedding;

  // Búsqueda por similitud coseno
  const results = await db.query(`
    SELECT content, metadata,
           1 - (embedding <=> $1::vector) AS similarity
    FROM documents
    WHERE 1 - (embedding <=> $1::vector) > 0.5
    ORDER BY embedding <=> $1::vector
    LIMIT $2
  `, [JSON.stringify(queryEmbedding), topK]);

  return results.rows;
}

Búsqueda híbrida (semántica + keywords)

async function hybridSearch(query: string, topK = 5) {
  const embedding = await getEmbedding(query);

  // Combina búsqueda semántica con full-text search
  const results = await db.query(`
    WITH semantic AS (
      SELECT id, content, metadata,
             1 - (embedding <=> $1::vector) AS semantic_score
      FROM documents
      ORDER BY embedding <=> $1::vector
      LIMIT 20
    ),
    keyword AS (
      SELECT id, content, metadata,
             ts_rank(to_tsvector('spanish', content),
                     plainto_tsquery('spanish', $2)) AS keyword_score
      FROM documents
      WHERE to_tsvector('spanish', content) @@ plainto_tsquery('spanish', $2)
      LIMIT 20
    )
    SELECT COALESCE(s.id, k.id) AS id,
           COALESCE(s.content, k.content) AS content,
           COALESCE(s.metadata, k.metadata) AS metadata,
           COALESCE(s.semantic_score, 0) * 0.7 +
           COALESCE(k.keyword_score, 0) * 0.3 AS combined_score
    FROM semantic s
    FULL OUTER JOIN keyword k ON s.id = k.id
    ORDER BY combined_score DESC
    LIMIT $3
  `, [JSON.stringify(embedding), query, topK]);

  return results.rows;
}

Paso 4: Generación con contexto

async function ragChat(query: string): Promise<string> {
  // 1. Recuperar documentos relevantes
  const docs = await retrieve(query, 5);

  // 2. Construir contexto
  const context = docs
    .map((doc, i) => `[Documento ${i + 1}] (Fuente: ${doc.metadata.source})\n${doc.content}`)
    .join('\n\n---\n\n');

  // 3. Generar respuesta con LLM
  const response = await openai.chat.completions.create({
    model: 'gpt-4o',
    messages: [
      {
        role: 'system',
        content: `Eres un asistente que responde preguntas basándote en los documentos proporcionados.

REGLAS:
- Responde SOLO basándote en el contexto proporcionado
- Si la información no está en los documentos, di "No tengo información sobre eso"
- Cita las fuentes relevantes al final de tu respuesta
- Sé preciso y conciso`,
      },
      {
        role: 'user',
        content: `CONTEXTO:\n${context}\n\n---\n\nPREGUNTA: ${query}`,
      },
    ],
    temperature: 0.3, // Baja temperatura para factualidad
  });

  return response.choices[0].message.content!;
}

RAG Avanzado

Re-ranking con LLM

async function rerankWithLLM(query: string, docs: Document[]): Promise<Document[]> {
  const response = await openai.chat.completions.create({
    model: 'gpt-4o-mini',
    response_format: { type: 'json_object' },
    messages: [
      {
        role: 'system',
        content: 'Ordena los documentos por relevancia para la pregunta. Retorna JSON: { "ranking": [index] }',
      },
      {
        role: 'user',
        content: `Pregunta: ${query}\n\nDocumentos:\n${docs.map((d, i) => `[${i}]: ${d.content.slice(0, 200)}`).join('\n')}`,
      },
    ],
  });

  const { ranking } = JSON.parse(response.choices[0].message.content!);
  return ranking.map((i: number) => docs[i]);
}

Contextual chunking

// Agrega contexto del documento al chunk antes de embedear
async function contextualChunk(fullDoc: string, chunk: string): Promise<string> {
  const response = await openai.chat.completions.create({
    model: 'gpt-4o-mini',
    messages: [{
      role: 'user',
      content: `Dado este documento:\n${fullDoc.slice(0, 2000)}\n\nResume en 1-2 oraciones el contexto de este fragmento:\n${chunk}`,
    }],
    max_tokens: 100,
  });

  return `${response.choices[0].message.content}\n\n${chunk}`;
}

Query expansion

async function expandQuery(query: string): Promise<string[]> {
  const response = await openai.chat.completions.create({
    model: 'gpt-4o-mini',
    messages: [{
      role: 'user',
      content: `Genera 3 reformulaciones de esta pregunta para mejorar la búsqueda:\n"${query}"\nRetorna solo las 3 preguntas, una por línea.`,
    }],
  });

  const expanded = response.choices[0].message.content!.split('\n').filter(Boolean);
  return [query, ...expanded]; // Buscar con la original + reformulaciones
}

Clase RAG completa

class RAGService {
  private openai: OpenAI;

  constructor() {
    this.openai = new OpenAI();
  }

  async query(question: string): Promise<{
    answer: string;
    sources: { content: string; source: string; similarity: number }[];
  }> {
    // 1. Retrieval
    const docs = await hybridSearch(question, 10);

    // 2. Re-ranking
    const reranked = await rerankWithLLM(question, docs);
    const topDocs = reranked.slice(0, 5);

    // 3. Generation
    const context = topDocs
      .map((d, i) => `[${i + 1}] ${d.content}`)
      .join('\n\n');

    const response = await this.openai.chat.completions.create({
      model: 'gpt-4o',
      messages: [
        { role: 'system', content: RAG_SYSTEM_PROMPT },
        { role: 'user', content: `Contexto:\n${context}\n\nPregunta: ${question}` },
      ],
      temperature: 0.2,
    });

    return {
      answer: response.choices[0].message.content!,
      sources: topDocs.map(d => ({
        content: d.content.slice(0, 200),
        source: d.metadata.source,
        similarity: d.similarity,
      })),
    };
  }
}
🔒

Ejercicio práctico disponible

Pipeline RAG: chunking y retrieval

Desbloquear ejercicios
// Pipeline RAG: chunking y retrieval
// Desbloquea Pro para acceder a este ejercicio
// y ganar +50 XP al completarlo

function ejemplo() {
    // Tu código aquí...
}

¿Te gustó esta lección?

Con Pro puedes marcar progreso, hacer ejercicios, tomar quizzes, ganar XP y obtener tu constancia.

Ver planes desde $9/mes