Crear un Chatbot con RAG y OpenAI desde Cero (2026)
Crea un chatbot que responde con TUS datos: tutorial RAG paso a paso con Node.js, OpenAI y ChromaDB. Código completo.
Tabla de contenidos
Un chatbot normal con ChatGPT solo sabe lo que GPT aprendió en su entrenamiento. Un chatbot con RAG sabe eso + tus propios documentos. Puede responder preguntas sobre tu código, tus PDFs, tu base de datos interna, lo que sea. Para un caso real de IA + documentos en producción, mira el ecosistema de IA para reuniones con Gemini y Supabase.
En este tutorial construimos uno desde cero con Node.js.
¿Qué es RAG?
Retrieval-Augmented Generation = antes de responder, la IA busca información relevante en tus documentos y la usa como contexto.
Flujo RAG:
1. Usuario pregunta algo
2. El sistema busca documentos relevantes en tu base de datos
3. Los documentos se pasan como contexto al LLM
4. El LLM responde usando esa información específica
Sin RAG: “¿Cuál es la política de devoluciones?” → La IA inventa algo genérico.
Con RAG: La IA busca en tu documento de políticas y cita la información real.
Stack que usamos
| Componente | Tecnología | Por qué |
|---|---|---|
| Runtime | Node.js 20+ | Universal, async nativo |
| LLM | OpenAI GPT-4.1 mini | Buena calidad, barato |
| Framework | LangChain.js | Simplifica la cadena RAG |
| Embeddings | OpenAI text-embedding-3-small | Rápido y preciso |
| Vector DB | ChromaDB | Fácil setup, open-source |
| Frontend | HTML + Vanilla JS | Sin complejidad extra |
Paso 1: Setup del proyecto
mkdir mi-chatbot-rag
cd mi-chatbot-rag
npm init -y
npm install langchain @langchain/openai @langchain/community \
chromadb chromadb-default-embed express dotenv
Crear .env:
OPENAI_API_KEY=sk-tu-api-key-aqui
PORT=3000
Paso 2: Cargar documentos
Creamos un script que lee tus documentos y los convierte en vectores (embeddings).
// src/ingest.js
import { DirectoryLoader } from 'langchain/document_loaders/fs/directory';
import { TextLoader } from 'langchain/document_loaders/fs/text';
import { PDFLoader } from '@langchain/community/document_loaders/fs/pdf';
import { RecursiveCharacterTextSplitter } from 'langchain/text_splitter';
import { OpenAIEmbeddings } from '@langchain/openai';
import { Chroma } from '@langchain/community/vectorstores/chroma';
import 'dotenv/config';
async function ingestDocs() {
console.log('Cargando documentos...');
// Cargar archivos de la carpeta docs/
const loader = new DirectoryLoader('./docs', {
'.txt': (path) => new TextLoader(path),
'.md': (path) => new TextLoader(path),
'.pdf': (path) => new PDFLoader(path),
});
const rawDocs = await loader.load();
console.log(`${rawDocs.length} documentos cargados`);
// Dividir en chunks pequeños (mejor para búsqueda)
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 1000, // 1000 caracteres por chunk
chunkOverlap: 200, // Solapamiento para no cortar contexto
});
// Estos parámetros son clave para no perder contexto.
// Si tu chatbot sigue fallando con documentos largos, revisa las
// técnicas avanzadas en: /blog/error-context-length-exceeded-openai-claude-2026/
const docs = await splitter.splitDocuments(rawDocs);
console.log(`${docs.length} chunks creados`);
// Crear embeddings y guardar en ChromaDB
const embeddings = new OpenAIEmbeddings({
modelName: 'text-embedding-3-small', // $0.02 por 1M tokens
});
await Chroma.fromDocuments(docs, embeddings, {
collectionName: 'mi-chatbot',
url: 'http://localhost:8000', // ChromaDB local
});
console.log('Documentos indexados correctamente');
}
ingestDocs();
Ejecutar ChromaDB
# Con Docker
docker run -p 8000:8000 chromadb/chroma
# O sin Docker
pip install chromadb
chroma run --path ./chroma_data
Preparar documentos
Crea una carpeta docs/ y mete ahí tus archivos:
docs/
politicas.txt
faq.md
manual-producto.pdf
precios.txt
node src/ingest.js
# Output: 15 documentos cargados → 87 chunks creados
Paso 3: El motor de respuestas (RAG Chain)
// src/ragChain.js
import { ChatOpenAI, OpenAIEmbeddings } from '@langchain/openai';
import { Chroma } from '@langchain/community/vectorstores/chroma';
import { PromptTemplate } from '@langchain/core/prompts';
import { StringOutputParser } from '@langchain/core/output_parsers';
import { RunnableSequence } from '@langchain/core/runnables';
import 'dotenv/config';
// Conectar a ChromaDB
const embeddings = new OpenAIEmbeddings({
modelName: 'text-embedding-3-small',
});
const vectorStore = await Chroma.fromExistingCollection(embeddings, {
collectionName: 'mi-chatbot',
url: 'http://localhost:8000',
});
const retriever = vectorStore.asRetriever({
k: 4, // Buscar los 4 chunks más relevantes
});
// Modelo
const llm = new ChatOpenAI({
modelName: 'gpt-4.1-mini',
temperature: 0.3, // Más determinista para info factual
});
// Prompt template
const promptTemplate = PromptTemplate.fromTemplate(`
Eres un asistente útil que responde preguntas basándote en la información proporcionada.
CONTEXTO (documentos relevantes):
{context}
REGLAS:
- Responde SOLO con información del contexto proporcionado
- Si no encuentras la respuesta en el contexto, di "No tengo información sobre eso"
- Sé conciso y directo
- Si citas datos específicos, menciona de qué documento vienen
- Responde en español
PREGUNTA: {question}
RESPUESTA:`);
// Cadena RAG
const ragChain = RunnableSequence.from([
{
context: async (input) => {
const docs = await retriever.invoke(input.question);
return docs.map(d => d.pageContent).join('\n\n---\n\n');
},
question: (input) => input.question,
},
promptTemplate,
llm,
new StringOutputParser(),
]);
export { ragChain };
Paso 4: Servidor API
// src/server.js
import express from 'express';
import { ragChain } from './ragChain.js';
import 'dotenv/config';
const app = express();
app.use(express.json());
app.use(express.static('public'));
// Endpoint del chat
app.post('/api/chat', async (req, res) => {
try {
const { question } = req.body;
if (!question?.trim()) {
return res.status(400).json({ error: 'Pregunta requerida' });
}
const answer = await ragChain.invoke({ question });
res.json({ answer });
} catch (error) {
console.error('Error:', error.message);
res.status(500).json({ error: 'Error al procesar la pregunta' });
}
});
// Streaming (mejor UX)
app.post('/api/chat/stream', async (req, res) => {
try {
const { question } = req.body;
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
const stream = await ragChain.stream({ question });
for await (const chunk of stream) {
res.write(`data: ${JSON.stringify({ text: chunk })}\n\n`);
}
res.write('data: [DONE]\n\n');
res.end();
} catch (error) {
res.status(500).json({ error: 'Error al procesar' });
}
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Chatbot RAG corriendo en http://localhost:${PORT}`);
});
Paso 5: Frontend simple
<!-- public/index.html -->
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Chatbot RAG</title>
<style>
* { box-sizing: border-box; margin: 0; }
body { font-family: system-ui; background: #0f172a; color: #e2e8f0;
display: flex; justify-content: center; padding: 2rem; }
.chat { width: 100%; max-width: 600px; }
.messages { height: 60vh; overflow-y: auto; padding: 1rem;
border: 1px solid #334155; border-radius: 12px; margin-bottom: 1rem; }
.msg { padding: 0.75rem 1rem; border-radius: 8px; margin-bottom: 0.5rem;
max-width: 85%; line-height: 1.5; }
.user { background: #7c3aed; margin-left: auto; }
.bot { background: #1e293b; border: 1px solid #334155; }
.input-row { display: flex; gap: 0.5rem; }
input { flex: 1; padding: 0.75rem 1rem; border-radius: 8px;
border: 1px solid #334155; background: #1e293b; color: #e2e8f0; }
button { padding: 0.75rem 1.5rem; border-radius: 8px; border: none;
background: #7c3aed; color: white; cursor: pointer; font-weight: 600; }
button:hover { background: #6d28d9; }
</style>
</head>
<body>
<div class="chat">
<h2 style="margin-bottom:1rem;">💬 Chatbot RAG</h2>
<div class="messages" id="messages"></div>
<div class="input-row">
<input id="input" placeholder="Pregunta algo..."
onkeydown="if(event.key==='Enter')ask()">
<button onclick="ask()">Enviar</button>
</div>
</div>
<script>
const messages = document.getElementById('messages');
const input = document.getElementById('input');
async function ask() {
const q = input.value.trim();
if (!q) return;
addMsg(q, 'user');
input.value = '';
const botMsg = addMsg('Pensando...', 'bot');
try {
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question: q })
});
const data = await res.json();
botMsg.textContent = data.answer;
} catch (e) {
botMsg.textContent = 'Error al conectar con el servidor.';
}
messages.scrollTop = messages.scrollHeight;
}
function addMsg(text, type) {
const div = document.createElement('div');
div.className = `msg ${type}`;
div.textContent = text;
messages.appendChild(div);
messages.scrollTop = messages.scrollHeight;
return div;
}
</script>
</body>
</html>
Paso 6: Ejecutar
# Terminal 1: ChromaDB
docker run -p 8000:8000 chromadb/chroma
# Terminal 2: Indexar documentos (solo la primera vez)
node src/ingest.js
# Terminal 3: Servidor
node src/server.js
Abre http://localhost:3000 y pregunta lo que quieras sobre tus documentos.
Mejoras para producción
1. Añadir historial de conversación
// Mantener contexto entre mensajes
const chatHistory = [];
app.post('/api/chat', async (req, res) => {
const { question } = req.body;
chatHistory.push({ role: 'user', content: question });
// Incluir historial en el contexto
const historyText = chatHistory.slice(-6) // Últimos 3 pares
.map(m => `${m.role}: ${m.content}`)
.join('\n');
const answer = await ragChain.invoke({
question: `Historial:\n${historyText}\n\nPregunta actual: ${question}`
});
chatHistory.push({ role: 'assistant', content: answer });
res.json({ answer });
});
2. Usar modelos más baratos
| Modelo | Coste por consulta (~2K tokens) | Calidad |
|---|---|---|
| GPT-4.1 | ~$0.006 | ⭐⭐⭐⭐⭐ |
| GPT-4.1 mini | ~$0.001 | ⭐⭐⭐⭐ |
| GPT-4.1 nano | ~$0.0003 | ⭐⭐⭐ |
Para un chatbot de FAQ, GPT-4.1 nano alcanza perfectamente y cada consulta cuesta $0.0003 (3000 consultas = $1).
3. Alternativas gratuitas
Reemplaza OpenAI por Ollama para coste $0:
import { ChatOllama } from '@langchain/community/chat_models/ollama';
import { OllamaEmbeddings } from '@langchain/community/embeddings/ollama';
const llm = new ChatOllama({ model: 'llama4' });
const embeddings = new OllamaEmbeddings({ model: 'nomic-embed-text' });
Estructura final del proyecto
mi-chatbot-rag/
├── docs/ # Tus documentos
│ ├── faq.md
│ └── politicas.txt
├── public/
│ └── index.html # Frontend
├── src/
│ ├── ingest.js # Indexar documentos
│ ├── ragChain.js # Lógica RAG
│ └── server.js # API Express
├── .env # API keys
└── package.json
Costes totales
| Componente | Coste |
|---|---|
| ChromaDB | $0 (self-hosted) |
| Embeddings (indexar 100 docs) | ~$0.01 |
| GPT-4.1 nano (1000 consultas) | ~$0.30 |
| Hosting (VPS básico) | $5/mes |
| Total | ~$5/mes |
Con Ollama local: $0/mes total. Para más detalle sobre los costes de cada modelo, consulta la calculadora de precios de IA.
Si quieres convertir este chatbot en un agente completo con herramientas y búsqueda web, sigue mi tutorial para crear un agente de IA con LangChain. Y si buscas APIs gratuitas para tu chatbot, mira cómo usar la API de ChatGPT y Claude gratis.
¿Quieres ver más proyectos que construí con IA? En mi portfolio muestro cada proyecto con su stack y proceso de desarrollo.