RAG (Retrieval-Augmented Generation) is the backbone of modern AI applications — but setting it up properly requires stitching together embeddings, vector stores, text splitters, LLM providers, and streaming responses. Most tutorials show you how to do it in a Jupyter notebook or a 50-line Python script.
I wanted a production-ready, modular, TypeScript-first template that I could drop into any NestJS project. So I built easy-rag.
The Investigation: Vercel AI SDK vs LangChain.js
Before writing a single line of code, I spent a full SDD Explore phase comparing the two main orchestration libraries:
| Aspect | Vercel AI SDK | LangChain.js |
|---|---|---|
| GitHub Stars | 24,550 ⭐ | 17,734 ⭐ |
| npm downloads/week | 12.9M | 4.2M |
| Providers | ~30+ official | ~15 official + community |
| Streaming | First-class | Functional but verbose |
| Vector stores | ❌ Not native | ✅ 20+ integrations |
| Document loaders | ❌ Not native | ✅ PDF, CSV, HTML, Markdown… |
| Text splitters | ❌ Not native | ✅ RecursiveCharacter, Token… |
| Retrievers | ❌ Not native | ✅ MultiQuery, ParentDocument… |
| Client ID | Functional, clean DX | Class-based, more verbose |
The Hybrid Decision
Neither library won outright. Instead, I chose a hybrid architecture:
Vercel AI SDK (orchestration core)
├── generateText / streamText / embed
├── @ai-sdk/openai → DeepSeek (OpenAI-compatible API)
├── @ai-sdk/anthropic, @ai-sdk/google (extensible)
└── @ai-sdk/langchain (official bridge)
└── LangChain text splitters + document loaders
Why? AI SDK has 3x more downloads, better streaming, and a cleaner DX. LangChain has the mature RAG ecosystem (text splitters, document loaders, retrievers). The official @ai-sdk/langchain adapter lets you use both without friction.
Architecture
Design Decisions (6 ADRs)
| ADR | Decision | Rationale |
|---|---|---|
| 001 | Vercel AI SDK as core | Streaming first-class, 30+ providers, clean DX |
| 002 | LangChain only for RAG components | Via @ai-sdk/langchain adapter |
| 003 | pgvector without ORM | Raw pg Pool, full control over vector queries |
| 004 | DeepSeek via OpenAI-compatible API | @ai-sdk/openai with custom baseURL |
| 005 | Modules by domain, not by layers | Clean Architecture, extractable packages |
| 006 | Zod for config validation | Shared peer dependency with AI SDK |
Module Design
src/
├── config/ # Zod-validated env vars (13 vars)
├── ai/ # AiService (generateText, streamText, embed)
├── embeddings/ # EmbeddingsService (abstract + implementation)
├── vector-store/ # VectorStoreService (abstract + pgvector)
└── rag/ # RagService + IngestionService + RagController
├── ingestion/ # chunk → embed → store pipeline
└── query/ # embed → search → context → stream
Data Flow
Ingestion:
POST /rag/ingest { content: "text..." }
→ RecursiveCharacterTextSplitter → [chunks]
→ EmbeddingsService.embed(chunk) → [vectors]
→ VectorStoreService.storeChunks() → { chunks: N, ids: [...] }
Query (streaming):
POST /rag/query { question: "What is X?" }
→ embed(question) → [vector]
→ similaritySearch(vector, topK) → context docs
→ buildPrompt(question, context)
→ AiService.streamText(prompt) → SSE stream
The SSE stream is sent as text/event-stream with data: {chunk}\n\n format. The client receives tokens in real-time as the LLM generates them.
Implementation Details
Vector Schema (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);
Provider Abstraction
The AiService auto-detects which provider to use:
// DeepSeek (default, via OpenAI-compatible API)
const deepseek = createOpenAI({
baseURL: 'https://api.deepseek.com/v1',
apiKey: process.env.DEEPSEEK_API_KEY,
});
// Or OpenAI (set OPENAI_API_KEY env var)
const openai = createOpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
The client can switch providers simply by setting different environment variables. The @ai-sdk/openai package handles both DeepSeek and OpenAI through the same API surface.
Stats & Testing
- Test suites: 6 (config, ai, embeddings, vector-store, ingestion + e2e)
- Tests: 42 passing (32 unit + 10 e2e via supertest)
- E2E coverage:
POST /rag/ingest,POST /rag/query(SSE),GET /api/health, 404 handling - CI: GitHub Actions — builds and runs all tests on push/PR to
main - Source files: 28 TypeScript files
- Dependencies: 13 production + 13 development
- Build: tsup ESM, 16KB bundle
Try It
git clone https://github.com/fxckcode/easy-rag.git
cd easy-rag
pnpm install
docker compose up -d # starts pgvector
cp .env.example .env # set your DEEPSEEK_API_KEY
pnpm start:dev
Then test it:
# Ingest a document
curl -X POST http://localhost:3000/rag/ingest \
-H "Content-Type: application/json" \
-d '{"content": "NestJS is a framework for building efficient Node.js server-side applications.", "source": "docs"}'
# Query with streaming
curl -X POST http://localhost:3000/rag/query \
-H "Content-Type: application/json" \
-d '{"question": "What is NestJS?"}'
The project is MIT licensed and available on GitHub.