Easy-RAG: A NestJS Template for Orchestrating RAG Pipelines

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:

AspectVercel AI SDKLangChain.js
GitHub Stars24,550 ⭐17,734 ⭐
npm downloads/week12.9M4.2M
Providers~30+ official~15 official + community
StreamingFirst-classFunctional 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 IDFunctional, clean DXClass-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)

ADRDecisionRationale
001Vercel AI SDK as coreStreaming first-class, 30+ providers, clean DX
002LangChain only for RAG componentsVia @ai-sdk/langchain adapter
003pgvector without ORMRaw pg Pool, full control over vector queries
004DeepSeek via OpenAI-compatible API@ai-sdk/openai with custom baseURL
005Modules by domain, not by layersClean Architecture, extractable packages
006Zod for config validationShared 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.

Share
D
Diego Duran
@fxckcode

Backend engineer who ships agentic CLI tooling. NestJS, Go, Django, AWS.