Proyecto: App RAG Full-Stack
En este proyecto práctico, construirás una aplicación RAG (Generación Aumentada por Recuperación) completa que puede responder preguntas sobre tus propios documentos. Combinaremos todo lo aprendido: carga de documentos, división de texto, embeddings, almacenamiento vectorial, cadenas y streaming.
🏗️ Lo Que Construiremos
- 📥 Ingesta de Documentos: Sube PDFs y páginas web a una base de datos vectorial
- 🔍 Búsqueda Semántica: Encuentra fragmentos relevantes basados en preguntas del usuario
- 💬 IA Conversacional: Chatea con tus documentos con memoria
- 📡 Streaming: Transmisión de tokens en tiempo real para una gran UX
- 📚 Citas de Fuentes: Muestra de qué documentos proviene la respuesta
Estructura del Proyecto
rag-app/
├── src/
│ ├── lib/
│ │ ├── embeddings.ts # Configuración de embeddings
│ │ ├── vectorStore.ts # Setup del vector store
│ │ ├── ingest.ts # Pipeline de ingesta
│ │ └── ragChain.ts # Cadena RAG con memoria
│ ├── app/
│ │ ├── api/
│ │ │ ├── ingest/route.ts # Endpoint de carga
│ │ │ └── chat/route.ts # Endpoint de chat
│ │ └── page.tsx # UI de chat
├── .env.local
└── package.json
Paso 1: Setup y Configuración
# Instalar dependencias
npm install langchain @langchain/openai @langchain/community
npm install @langchain/core pdf-parse cheerio
// src/lib/embeddings.ts
import { OpenAIEmbeddings } from "@langchain/openai";
export const embeddings = new OpenAIEmbeddings({
modelName: "text-embedding-3-small",
});
Paso 2: Setup del Vector Store
// src/lib/vectorStore.ts
import { MemoryVectorStore } from "langchain/vectorstores/memory";
import { embeddings } from "./embeddings";
import { Document } from "@langchain/core/documents";
let vectorStore: MemoryVectorStore | null = null;
export async function getVectorStore() {
if (!vectorStore) {
vectorStore = new MemoryVectorStore(embeddings);
}
return vectorStore;
}
export async function agregarDocumentos(docs: Document[]) {
const store = await getVectorStore();
await store.addDocuments(docs);
console.log(`Agregados ${docs.length} documentos al vector store`);
}
export async function buscarDocumentos(consulta: string, k = 4) {
const store = await getVectorStore();
return store.similaritySearchWithScore(consulta, k);
}
Paso 3: Pipeline de Ingesta de Documentos
// src/lib/ingest.ts
import { PDFLoader } from "@langchain/community/document_loaders/fs/pdf";
import { CheerioWebBaseLoader } from "@langchain/community/document_loaders/web/cheerio";
import { RecursiveCharacterTextSplitter } from "langchain/text_splitter";
import { agregarDocumentos } from "./vectorStore";
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 1000,
chunkOverlap: 200,
});
export async function ingerirPDF(rutaArchivo: string) {
console.log(`Ingiriendo PDF: ${rutaArchivo}`);
const loader = new PDFLoader(rutaArchivo, { splitPages: true });
const docsRaw = await loader.load();
const docsDivididos = await splitter.splitDocuments(docsRaw);
const docsConMeta = docsDivididos.map((doc) => ({
...doc,
metadata: {
...doc.metadata,
source: rutaArchivo,
type: "pdf",
ingestedAt: new Date().toISOString(),
},
}));
await agregarDocumentos(docsConMeta);
return { fragmentos: docsConMeta.length, paginas: docsRaw.length };
}
export async function ingerirURL(url: string) {
console.log(`Ingiriendo URL: ${url}`);
const loader = new CheerioWebBaseLoader(url);
const docsRaw = await loader.load();
const docsDivididos = await splitter.splitDocuments(docsRaw);
const docsConMeta = docsDivididos.map((doc) => ({
...doc,
metadata: { ...doc.metadata, source: url, type: "web" },
}));
await agregarDocumentos(docsConMeta);
return { fragmentos: docsConMeta.length };
}
Paso 4: Cadena RAG con Memoria de Conversación
// src/lib/ragChain.ts
import { ChatOpenAI } from "@langchain/openai";
import { ChatPromptTemplate, MessagesPlaceholder } from "@langchain/core/prompts";
import { createStuffDocumentsChain } from "langchain/chains/combine_documents";
import { createRetrievalChain } from "langchain/chains/retrieval";
import { createHistoryAwareRetriever } from "langchain/chains/history_aware_retriever";
import { getVectorStore } from "./vectorStore";
const llm = new ChatOpenAI({
modelName: "gpt-4",
streaming: true,
temperature: 0.3,
});
export async function crearCadenaRAG() {
const vectorStore = await getVectorStore();
const retriever = vectorStore.asRetriever({ k: 4 });
const promptContextualizar = ChatPromptTemplate.fromMessages([
["system", `Dado el historial de chat y la última pregunta, reformula
la pregunta para que sea independiente. NO respondas la pregunta.`],
new MessagesPlaceholder("chat_history"),
["human", "{input}"],
]);
const retrieverConHistorial = await createHistoryAwareRetriever({
llm,
retriever,
rephrasePrompt: promptContextualizar,
});
const promptRespuesta = ChatPromptTemplate.fromMessages([
["system", `Eres un asistente útil que responde preguntas basándose en
el contexto proporcionado. Si el contexto no contiene información relevante,
dilo honestamente. Siempre cita qué documento fuente usaste.
Contexto: {context}`],
new MessagesPlaceholder("chat_history"),
["human", "{input}"],
]);
const documentChain = await createStuffDocumentsChain({
llm,
prompt: promptRespuesta,
});
return await createRetrievalChain({
retriever: retrieverConHistorial,
combineDocsChain: documentChain,
});
}
Paso 5: Ruta API con Streaming
// app/api/chat/route.ts
import { crearCadenaRAG } from "@/lib/ragChain";
import { HumanMessage, AIMessage } from "@langchain/core/messages";
export async function POST(req: Request) {
const { message, chatHistory = [] } = await req.json();
const chain = await crearCadenaRAG();
const history = chatHistory.map((msg: any) =>
msg.role === "user"
? new HumanMessage(msg.content)
: new AIMessage(msg.content)
);
const stream = await chain.stream({
input: message,
chat_history: history,
});
const encoder = new TextEncoder();
let sources: any[] = [];
const readable = new ReadableStream({
async start(controller) {
for await (const chunk of stream) {
if (chunk.answer) {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({
type: "token", content: chunk.answer
})}\n\n`)
);
}
if (chunk.context) {
sources = chunk.context.map((doc: any) => ({
contenido: doc.pageContent.slice(0, 200),
fuente: doc.metadata.source,
}));
}
}
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: "sources", sources })}\n\n`)
);
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
controller.close();
},
});
return new Response(readable, {
headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" },
});
}
🚀 Extendiendo Este Proyecto
- • Cambia a Pinecone o Supabase pgvector para almacenamiento persistente
- • Agrega UI de carga de archivos con drag-and-drop para PDFs
- • Implementa autenticación para que cada usuario tenga sus propios documentos
- • Agrega filtrado por metadatos (buscar solo ciertos tipos de documentos)
- • Despliega en Vercel con Edge Functions para baja latencia
💡 Puntos Clave
- • Una app RAG completa combina: Carga → División → Embedding → Almacenamiento → Recuperación → Generación
- • Los retrievers con historial hacen las conversaciones naturales al contextualizar preguntas de seguimiento
- • El streaming con SSE proporciona entrega de tokens en tiempo real para la mejor UX
- • Siempre proporciona citas de fuentes para que los usuarios puedan verificar las respuestas
- • Empieza con MemoryVectorStore, luego actualiza a una BD vectorial persistente para producción