RAG - Dotando de Memoria a tu Agente. Parte 3

Introducción
Podrás encontrar el código de este artículo en: https://github.com/aperezl/ai-fullstack-serie/tree/rag-3
En los capítulos anteriores, sentamos las bases teóricas y prácticas para dotar de memoria a nuestro agente mediante la arquitectura de Generación Aumentada por Recuperación (RAG). Exploramos cómo transformar conocimiento estático en una base de datos vectorial dinámica y cómo conectar nuestro chatbot a esta memoria externa para obtener respuestas fundamentadas y precisas.
Ahora, damos un paso más allá para unir todas las piezas en una aplicación web completa y pulida. Este capítulo se centra en la ingeniería de componentes de software de alto nivel y en la creación de asistentes de IA con una personalidad definida.
Dejaremos atrás los componentes de un solo uso para refactorizarlos en una interfaz de usuario de chat genérica, configurable y reutilizable, un principio clave en el desarrollo de software escalable. Luego, aplicaremos este sistema para dar vida a un asistente especializado: un homenaje a Aaron Swartz. Integraremos una base de conocimiento específica sobre su vida y obra, y a través de un prompt de sistema cuidadosamente diseñado, le infundiremos una personalidad empática y respetuosa.
Al final de este capítulo, no solo tendrás un sistema RAG completamente funcional, sino que habrás aprendido a construir asistentes de IA que no solo responden preguntas, sino que lo hacen con carácter y propósito.
Refactorización de componentes de chatbot para máxima reutilización
El objetivo de esta refactorización es transformar un conjunto de componentes de chat específicos en un sistema de UI genérico y configurable. Un desarrollador senior no construye componentes para un solo uso; los diseña como abstracciones que pueden ser adaptadas a múltiples contextos con un mínimo esfuerzo. Lograremos esto extrayendo toda la configuración estática (textos, avatares, endpoints) y pasándola como props
.
Componente: `src/components/ui/Chatbot/ChatbotHeader.tsx`
Este componente es el más sencillo. Su única responsabilidad es mostrar la cabecera del chat. Lo refactorizamos para que acepte el título, el subtítulo y el avatar como propiedades.
interface ChatbotHeaderProps {
assistantName: string;
assistantDescription: string;
assistantAvatar: React.ReactNode;
}
export function ChatbotHeader({
assistantName,
assistantDescription,
assistantAvatar,
}: ChatbotHeaderProps) {
return (
<div className="bg-slate-700 border-b border-slate-600 p-4">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0">
{assistantAvatar}
</div>
<div>
<h1 className="text-white font-semibold">{assistantName}</h1>
<p className="text-slate-300 text-sm">{assistantDescription}</p>
</div>
</div>
</div>
);
}
Análisis de la refactorización:
- Se eliminaron todos los textos y elementos estáticos.
-
assistantAvatar
ahora es unReact.ReactNode
, lo que permite pasar un componenteImage
de Next.js, un simpleo cualquier otro elemento renderizable, ofreciendo máxima flexibilidad.
Componente: `src/components/ui/Chatbot/ChatbotLoading.tsx`
Similar a la cabecera, el indicador de carga debe poder mostrar el avatar del asistente que está "pensando".
interface ChatbotLoadingProps {
assistantAvatar: React.ReactNode;
}
export function ChatbotLoading({ assistantAvatar }: ChatbotLoadingProps) {
return (
<div className="flex gap-3 justify-start">
<div className="w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 mt-1">
{assistantAvatar}
</div>
<div className="bg-slate-700 rounded-lg px-4 py-2">
<div className="flex gap-1">
<div className="w-2 h-2 bg-slate-400 rounded-full animate-bounce"></div>
<div className="w-2 h-2 bg-slate-400 rounded-full animate-bounce" style={{ animationDelay: '0.1s' }}></div>
<div className="w-2 h-2 bg-slate-400 rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
</div>
</div>
</div>
);
}
Componente: `src/components/ui/Chatbot/ChatbotMessage.tsx`
Este componente es clave. Debe renderizar correctamente los mensajes del usuario y del asistente, cada uno con su propio avatar.
import { cn } from "@/lib/utils";
import { UIMessage } from "ai";
interface ChatMessageProps {
message: UIMessage;
assistantAvatar: React.ReactNode;
userAvatar: React.ReactNode;
}
export function ChatBotMessage({ message, assistantAvatar, userAvatar }: ChatMessageProps) {
const isUser = message.role === "user";
return (
<div className={cn("flex gap-3 mb-4", isUser ? "justify-end" : "justify-start")}>
{!isUser && (
<div className="w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 mt-1">
{assistantAvatar}
</div>
)}
<div
className={cn(
"max-w-[80%] rounded-lg px-4 py-2 break-words",
isUser ? "bg-yellow-500 text-slate-900" : "bg-slate-700 text-white",
)}
>
<div className="whitespace-pre-wrap leading-relaxed">
{message.parts.map((part, index) =>
part.type === 'text' ? <span key={index}>{part.text}</span> : null,
)}
</div>
</div>
{isUser && (
<div className="w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 mt-1">
{userAvatar}
</div>
)}
</div>
);
}
Componente: `src/components/ui/Chatbot/ChatbotMessages.tsx`
El contenedor de mensajes. Ahora necesita recibir los avatares para pasarlos a ChatBotMessage
y ChatbotLoading
, así como los textos del estado inicial.
"use client";
import { ChatBotMessage } from "./ChatbotMessage";
import { ChatbotLoading } from "./ChatbotLoading";
import { useEffect, useRef } from "react";
import { UIMessage } from "ai";
interface ChatMessagesProps {
messages: UIMessage[];
isLoading: boolean;
assistantAvatar: React.ReactNode;
userAvatar: React.ReactNode;
initialMessageTitle: string;
initialMessageDescription: string;
}
export function ChatbotMessages({
messages,
isLoading,
assistantAvatar,
userAvatar,
initialMessageTitle,
initialMessageDescription
}: ChatMessagesProps) {
const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};
useEffect(() => {
scrollToBottom();
}, [messages]);
return (
<div className="flex-1 overflow-y-auto p-4">
{messages.length === 0 && !isLoading && (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<div className="w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4">
{assistantAvatar}
</div>
<h2 className="text-white text-xl font-semibold mb-2">{initialMessageTitle}</h2>
<p className="text-slate-300">{initialMessageDescription}</p>
</div>
</div>
)}
{messages.map((message) => (
<ChatBotMessage
key={message.id}
message={message}
assistantAvatar={assistantAvatar}
userAvatar={userAvatar}
/>
))}
{isLoading && <ChatbotLoading assistantAvatar={assistantAvatar} />}
<div ref={messagesEndRef} />
</div>
);
}
Componente: `src/components/ui/Chatbot/ChatbotInput.tsx`
"use client"
import type React from "react"
import { type FormEvent, useRef, useEffect } from "react"
import { Button } from "@/components/ui/button"
import { Send } from "lucide-react"
interface ChatInputProps {
input: string
handleInputChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
handleSubmit: (e: FormEvent<HTMLFormElement>) => void
isLoading: boolean
inputPlaceholder: string
}
export function ChatbotInput({ input, handleInputChange, handleSubmit, isLoading, inputPlaceholder }: ChatInputProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null)
// Auto-resize textarea
useEffect(() => {
const textarea = textareaRef.current
if (textarea) {
textarea.style.height = "auto"
textarea.style.height = `${Math.min(textarea.scrollHeight, 120)}px`
}
}, [input])
useEffect(() => {
if (!isLoading) {
textareaRef.current?.focus()
}
}, [isLoading])
const onSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
if (input.trim() && !isLoading) {
handleSubmit(e)
}
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
if (input.trim() && !isLoading) {
e.currentTarget.form?.requestSubmit()
}
}
}
return (
<div className="border-t border-slate-600 bg-slate-700 p-4">
<form onSubmit={onSubmit} className="flex gap-2 items-center">
<div className="flex-1">
<textarea
ref={textareaRef}
value={input}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder={inputPlaceholder}
className="w-full bg-slate-600 text-white placeholder-slate-400 border border-slate-500 rounded-lg px-4 py-3 resize-none focus:outline-none focus:ring-2 focus:ring-yellow-500 focus:border-transparent min-h-[48px] max-h-[120px] no-scrollbar"
disabled={isLoading}
rows={1}
/>
</div>
<Button
type="submit"
disabled={!input.trim() || isLoading}
className="bg-yellow-500 hover:bg-yellow-600 text-slate-900 font-semibold px-4 py-3 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Send className="w-4 h-4" />
</Button>
</form>
</div>
)
}
Componente orquestador: `src/components/ui/Chatbot/Chatbot.tsx`
Este es el componente principal que une todo. Ahora se convierte en un orquestador que recibe toda la configuración y la distribuye a sus componentes hijos.
"use client";
import { ChatbotMessages } from "./ChatbotMessages";
import { ChatbotInput } from "./ChatbotInput";
import { ChatbotHeader } from "./ChatbotHeader";
import { useCustomChat } from "@/hooks/useCustomChat";
// Definimos las propiedades que nuestro Chatbot parametrizable aceptará
interface ChatbotProps {
apiEndpoint: string;
assistantName: string;
assistantDescription: string;
assistantAvatar: React.ReactNode;
userAvatar: React.ReactNode;
initialMessageTitle: string;
initialMessageDescription: string;
inputPlaceholder: string;
}
export function Chatbot({
apiEndpoint,
assistantName,
assistantDescription,
assistantAvatar,
userAvatar,
initialMessageTitle,
initialMessageDescription,
inputPlaceholder,
}: ChatbotProps) {
const {
input,
messages,
status,
handleInputChange,
handleSubmit,
} = useCustomChat({ api: apiEndpoint });
return (
<div className="flex flex-col h-full bg-slate-800 rounded-lg shadow-2xl">
<ChatbotHeader
assistantName={assistantName}
assistantDescription={assistantDescription}
assistantAvatar={assistantAvatar}
/>
<div className="flex-1 flex flex-col min-h-0">
<ChatbotMessages
messages={messages}
isLoading={status !== 'ready'}
assistantAvatar={assistantAvatar}
userAvatar={userAvatar}
initialMessageTitle={initialMessageTitle}
initialMessageDescription={initialMessageDescription}
/>
<ChatbotInput
input={input}
handleInputChange={handleInputChange}
handleSubmit={handleSubmit}
isLoading={status !== 'ready'}
inputPlaceholder={inputPlaceholder}
/>
</div>
</div>
);
}
Modificación de nuestro chatbot original
Tras nuestra exitosa refactorización hacia un sistema de componentes genérico, es crucial adaptar la página que alojaba nuestro chatbot original. El antiguo enfoque, donde los textos y avatares estaban definidos estáticamente dentro de los componentes, ha quedado obsoleto.
Ahora, la página principal tiene la responsabilidad de configurar el componente Chatbot pasándole todas las propiedades que necesita para renderizarse. Este cambio demuestra el poder de nuestra abstracción: la página se vuelve puramente declarativa. En lugar de contener lógica de presentación dispersa, simplemente le dice a nuestro componente Chatbot cómo debe verse y comportarse, proporcionando los textos, avatares y el endpoint de la API.
Así es como actualizamos la página de nuestro chatbot original para que se alinee con el nuevo diseño reutilizable:
import { Chatbot } from "@/components/ui/Chatbot/Chatbot"
import { UserIcon } from "lucide-react";
export default function ChatbotPage() {
const AssistantAvatar = () => <UserIcon className="text-white w-32 h-32" />
const UserAvatar = () => (
<div className="w-8 h-8 bg-slate-500 rounded-full flex items-center justify-center flex-shrink-0">
<UserIcon className="text-white w-5 h-5" />
</div>
);
return (
<div className="h-full w-full">
<Chatbot
apiEndpoint="/api/rag"
assistantName="Asistente AI"
assistantDescription="Siempre listo para ayudarte."
assistantAvatar={<AssistantAvatar />}
userAvatar={<UserAvatar />}
initialMessageTitle="¡Hola! Soy tu asistente AI"
initialMessageDescription="Pregúntame lo que quieras y te ayudaré."
inputPlaceholder="Pega cualquier código..." />
</div>
)
}
Análisis final:
- El componente
Chatbot
ya no contiene lógica de presentación estática. Su única función es recibir una configuración completa (ChatbotProps
) y pasarla a los componentes hijos. - Es ahora un "componente tonto" en el buen sentido: es totalmente controlado desde fuera, lo que lo hace predecible y fácil de integrar en cualquier página.
Fase de integración: Creando el asistente de Aaron Swartz
Paso 1: Preparar la base de Conocimiento
Primero, reemplaza el contenido de tu archivo data/conocimiento.md
con la biografía de Aaron Swartz. Este será el único "cerebro" de nuestro asistente.
# Aaron Swartz: Un Legado de Libertad en la Era Digital
Aaron H. Swartz (8 de noviembre de 1986 - 11 de enero de 2013) fue un programador informático, empresario, escritor, organizador político y activista de Internet estadounidense. Su trabajo se centró en la conciencia cívica y el activismo, y luchó incansablemente por un internet abierto y libre.
...resto del fichero en github.
Acción Requerida: Después de actualizar este archivo, debes volver a ejecutar el script de ingesta para que tu base de datos vectorial se actualice con esta nueva información.
pnpm ingest
Paso 2: Crear el Nuevo Endpoint de API para RAG
Ahora creamos el endpoint específico app/api/rag/route.ts
. Este es el backend que nuestro asistente de Aaron Swartz utilizará. Es una copia de la lógica RAG que ya construimos, pero ahora tiene un "system prompt" especializado.
import { google } from '@ai-sdk/google';
import { streamText, convertToModelMessages, UIMessage } from 'ai';
import { z } from 'zod';
import { findRelevantChunks } from '@/lib/ai/rag'; // Importamos nuestra nueva función
export const maxDuration = 30;
// Definimos un schema de Zod para validar el cuerpo de la petición
const PostBodySchema = z.object({
messages: z.array(z.any()), // Por ahora, aceptamos cualquier objeto de mensaje
});
export async function POST(req: Request) {
try {
const body = await req.json();
const validation = PostBodySchema.safeParse(body);
if (!validation.success) {
return new Response(JSON.stringify(validation.error.flatten()), { status: 400 });
}
const { messages }: { messages: UIMessage[] } = validation.data;
const lastUserMessage = messages[messages.length - 1];
const lastUserMessageText = lastUserMessage.parts.map(part => part.type === 'text' ? part.text : '').join('');
// Instanciar el modelo de IA
const model = google('gemini-2.0-flash-001');
const relevantChunks = await findRelevantChunks(lastUserMessageText);
// 2. Construir el contexto para el prompt
const context = relevantChunks.map(chunk => chunk.content).join('\n---\n');
// 3. Crear el prompt aumentado
const systemPrompt = `
Eres un asistente experto en la vida y el legado de Aaron Swartz.
Tu tono debe ser siempre respetuoso, empático y lleno de admiración por su trabajo.
Habla de él con cariño, como si estuvieras contando la historia de un amigo inspirador.
Responde a la pregunta del usuario basándote únicamente en el siguiente contexto.
Si la respuesta no se encuentra en el contexto, responde con amabilidad que esa información específica no está en tu base de conocimientos sobre Aaron.
Contexto sobre Aaron Swartz:
---
${context}
---
`;
// 4. Llamar al LLM con el prompt aumentado
const result = await streamText({
model: model,
system: systemPrompt,
messages: convertToModelMessages(messages),
});
return result.toUIMessageStreamResponse();
} catch (error) {
console.error("Error en la API de chat con RAG:", error);
return new Response("Un error inesperado ha ocurrido.", { status: 500 });
}
}
Análisis del backend:
- Prompt especializado: El
systemPrompt
es la clave. Define lapersona
del asistente: empático, respetuoso y cariñoso. Esta es la diferencia entre un chatbot genérico y un asistente con carácter. - Contexto aumentado: Aumentamos el número de chunks a 5 para darle al modelo un contexto más rico y permitirle formular respuestas más completas y matizadas sobre la vida de Aaron.
Paso 3: Crear la página del asistente
Ahora creamos la nueva página src/app/rag/page.tsx
que instanciará nuestro componente Chatbot
genérico con toda la configuración para el asistente de Aaron Swartz.
import { Chatbot } from "@/components/ui/Chatbot/Chatbot";
import Image from 'next/image';
import { UserIcon } from 'lucide-react';
// Avatares como componentes para mantener la página limpia
const AssistantAvatar = () => (
<Image
src="/aaron-swartz-avatar.png" // Necesitarás añadir esta imagen a tu carpeta /public
alt="Aaron Swartz"
width={32}
height={32}
className="rounded-full"
/>
);
const UserAvatar = () => (
<div className="w-8 h-8 bg-slate-500 rounded-full flex items-center justify-center flex-shrink-0">
<UserIcon className="text-white w-5 h-5" />
</div>
);
export default function AaronSwartzAssistantPage() {
return (
<div className="bg-slate-900 min-h-screen flex items-center justify-center p-4">
<div className="w-full max-w-2xl h-[80vh] md:h-[90vh]">
<Chatbot
apiEndpoint="/api/rag"
assistantName="Recordando a Aaron Swartz"
assistantDescription="Un tributo a su lucha por un internet libre."
assistantAvatar={<AssistantAvatar />}
userAvatar={<UserAvatar />}
initialMessageTitle="Hola, soy un asistente dedicado al legado de Aaron Swartz."
initialMessageDescription="Puedes preguntarme sobre su vida, sus logros y su impacto en el mundo digital."
inputPlaceholder="Pregunta sobre la vida de Aaron..."
/>
</div>
</div>
);
}
Acción Requerida:
- Encuentra una imagen de Aaron Swartz que te guste, renómbrala a
aaron-swartz-avatar.png
y colócala en tu directoriopublic/
.
Análisis de la Página:
- Máxima Reutilización: Observa qué limpia y declarativa es esta página. No contiene lógica de chat. Su única responsabilidad es configurar el componente
Chatbot
. - Paso de Props: Pasamos todas las propiedades que definimos: el endpoint de la API (
/api/rag
), los textos, los avatares, etc. - Avatares como Componentes: Definir los avatares como pequeños componentes locales (
AssistantAvatar
,UserAvatar
) es un buen patrón para mantener el JSX delreturn
limpio y fácil de leer.
Conclusión del Ejemplo Web Completo y del Capítulo
¡Lo hemos logrado! Al ejecutar pnpm dev
y navegar a http://localhost:3000/rag
, serás recibido por un asistente especializado.
Prueba estas preguntas:
- "¿Qué fue RSS y qué papel jugó Aaron?"
- "Háblame de su lucha con JSTOR."
- "¿Por qué es recordado como un mártir?"
- "¿Cuándo jugó al fútbol?" (Esta pregunta debería activar la respuesta de "no tengo información", ya que no está en el contexto).
Este capítulo ha sido un viaje denso desde la teoría abstracta de los embeddings hasta la implementación de un sistema RAG completamente funcional y la refactorización de componentes de UI para una máxima reutilización.
Logros Clave de este Capítulo:
- Dominio de RAG: Has implementado las dos pipelines de RAG (ingesta y recuperación) utilizando herramientas estándar de la industria como PostgreSQL,
pgvector
, Drizzle y LangChain. - Arquitectura de Datos para IA: Has diseñado y migrado un esquema de base de datos optimizado para la búsqueda de similitud vectorial.
- Ingeniería de Componentes Senior: Has refactorizado un conjunto de componentes de React para pasar de una implementación específica a una abstracción genérica y configurable, un pilar del desarrollo de software escalable.
- Creación de Asistentes con Personalidad: Has aprendido que la combinación de una base de conocimientos específica (RAG) y un
systemPrompt
bien diseñado es lo que da vida y carácter a un agente de IA.
Con la capacidad de dar a nuestros agentes conocimiento externo y una memoria persistente, hemos superado una de sus limitaciones más fundamentales. Nuestro chatbot ya no es solo un conversador elocuente; es un investigador informado. Sin embargo, su percepción del mundo sigue limitada al texto. El siguiente paso lógico es abrir sus sentidos a otra modalidad fundamental de la información humana: la visual. En el Capítulo 4, exploraremos el RAG avanzado y los agentes multi-modales, permitiendo que nuestros agentes no solo lean documentos, sino que también vean e interpreten imágenes, fusionando el conocimiento textual y visual para alcanzar un nivel de comprensión contextual mucho más profundo.
Podrás encontrar el código de este artículo en: https://github.com/aperezl/ai-fullstack-serie/tree/rag-3