RAG (Retrieval-Augmented Generation) es la columna vertebral de las aplicaciones de IA modernas — pero configurarlo correctamente requiere unir embeddings, vector stores, text splitters, providers de LLM y respuestas en streaming. La mayoría de los tutoriales muestran cómo hacerlo en un Jupyter notebook o un script de Python de 50 líneas.
Yo quería un template listo para producción, modular y TypeScript-first que pudiera usar en cualquier proyecto NestJS. Así que construí easy-rag.
La Investigación: Vercel AI SDK vs LangChain.js
Antes de escribir una sola línea de código, pasé una fase completa de SDD Explore comparando las dos bibliotecas de orquestación principales:
| Aspecto | Vercel AI SDK | LangChain.js |
|---|---|---|
| GitHub Stars | 24,550 ⭐ | 17,734 ⭐ |
| Descargas npm/semana | 12.9M | 4.2M |
| Providers | ~30+ oficiales | ~15 oficiales + comunidad |
| Streaming | Excelente | Funcional pero verboso |
| Vector stores | ❌ No nativo | ✅ 20+ integraciones |
| Document loaders | ❌ No nativo | ✅ PDF, CSV, HTML, Markdown… |
| Text splitters | ❌ No nativo | ✅ RecursiveCharacter, Token… |
| Retrievers | ❌ No nativo | ✅ MultiQuery, ParentDocument… |
La Decisión Híbrida
Ninguna biblioteca ganó rotundamente. En su lugar, elegí una arquitectura híbrida:
Vercel AI SDK (orquestación principal)
├── generateText / streamText / embed
├── @ai-sdk/openai → DeepSeek (API compatible OpenAI)
├── @ai-sdk/anthropic, @ai-sdk/google (extensible)
└── @ai-sdk/langchain (puente oficial)
└── LangChain text splitters + document loaders
¿Por qué? AI SDK tiene 3x más descargas, mejor streaming y mejor DX. LangChain tiene el ecosistema RAG maduro (text splitters, document loaders, retrievers). El adapter oficial @ai-sdk/langchain permite usar ambos sin fricción.
Arquitectura
Decisiones de Diseño (6 ADRs)
| ADR | Decisión | Razón |
|---|---|---|
| 001 | Vercel AI SDK como core | Streaming nativo, 30+ providers, DX limpio |
| 002 | LangChain solo para RAG | Via adapter @ai-sdk/langchain |
| 003 | pgvector sin ORM | pg Pool nativo, control total de queries vectoriales |
| 004 | DeepSeek via API compatible OpenAI | @ai-sdk/openai con baseURL custom |
| 005 | Módulos por dominio, no por capas | Clean Architecture, paquetes extraíbles |
| 006 | Zod para validación de config | Peer dependency compartida con AI SDK |
Diseño de Módulos
src/
├── config/ # Env vars validadas con Zod (13 vars)
├── ai/ # AiService (generateText, streamText, embed)
├── embeddings/ # EmbeddingsService (abstracto + implementación)
├── vector-store/ # VectorStoreService (abstracto + pgvector)
└── rag/ # RagService + IngestionService + RagController
├── ingestion/ # Pipeline: chunk → embed → store
└── query/ # Pipeline: embed → search → context → stream
Flujo de Datos
Ingestión:
POST /rag/ingest { content: "texto..." }
→ RecursiveCharacterTextSplitter → [chunks]
→ EmbeddingsService.embed(chunk) → [vectors]
→ VectorStoreService.storeChunks() → { chunks: N, ids: [...] }
Query (streaming):
POST /rag/query { question: "¿Qué es X?" }
→ embed(question) → [vector]
→ similaritySearch(vector, topK) → docs de contexto
→ buildPrompt(question, context)
→ AiService.streamText(prompt) → stream SSE
El stream SSE se envía como text/event-stream con formato data: {chunk}\n\n. El cliente recibe tokens en tiempo real mientras el LLM los genera.
Detalles de Implementación
Esquema Vectorial (pgvector)
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE embeddings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
content TEXT NOT NULL,
source TEXT,
metadata JSONB DEFAULT '{}',
embedding vector(1536),
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_embeddings_vector
ON embeddings USING ivfflat (embedding vector_cosine_ops);
Abstracción de Providers
AiService detecta automáticamente qué provider usar:
// DeepSeek (default, via API compatible OpenAI)
const deepseek = createOpenAI({
baseURL: 'https://api.deepseek.com/v1',
apiKey: process.env.DEEPSEEK_API_KEY,
});
// O OpenAI (setear OPENAI_API_KEY)
const openai = createOpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
El cliente puede cambiar de provider simplemente cambiando variables de entorno.
Stats & Testing
- Suites de test: 6 (config, ai, embeddings, vector-store, ingestion + e2e)
- Tests: 42 pasando (32 unit + 10 e2e via supertest)
- Cobertura E2E:
POST /rag/ingest,POST /rag/query(SSE),GET /api/health, manejo 404 - CI: GitHub Actions — build y tests en push/PR a
main - Archivos fuente: 28 archivos TypeScript
- Dependencias: 13 producción + 13 desarrollo
- Build: tsup ESM
Probarlo
git clone https://github.com/fxckcode/easy-rag.git
cd easy-rag
pnpm install
docker compose up -d # inicia pgvector
cp .env.example .env # configurar DEEPSEEK_API_KEY
pnpm start:dev
# Ingestionar un documento
curl -X POST http://localhost:3000/rag/ingest \
-H "Content-Type: application/json" \
-d '{"content": "NestJS es un framework para construir aplicaciones Node.js del lado del servidor.", "source": "docs"}'
# Query con streaming
curl -X POST http://localhost:3000/rag/query \
-H "Content-Type: application/json" \
-d '{"question": "¿Qué es NestJS?"}'
El proyecto tiene licencia MIT y está disponible en GitHub.