Build a RAG pipeline with Node.js and TypeScript
Author
This is a tutorial, not a reference — follow it in order, and by the end you'll have a working command-line tool that answers questions using your own documents, built the same way you'd build one for a real project. If you already understand RAG conceptually and just want the mechanics, see the RAG explainer first; this piece assumes you've read something like it.
What will you build?
You'll build a Node.js script that ingests documents, embeds them, stores them in Pinecone, and answers questions by retrieving relevant chunks and passing them to an LLM (Claude or GPT).
The script does four things:
- Takes a folder of text documents
- Chunks and embeds them
- Stores embeddings in Pinecone (a hosted vector database)
- Answers questions by retrieving relevant chunks and handing them to an LLM
We'll build it twice: once as a minimal proof of concept, then again with the error handling and structure a real project needs.
What do you need before starting?
- Node.js 20+ — Pinecone's TypeScript client requires it (
node -vto check) - TypeScript 5.2+ — installed as a project dependency
- npm (ships with Node) or another package manager
- ~200MB free disk space for
node_modules - Any OS — macOS, Linux, or Windows (WSL recommended) — no GPU required
- A terminal and code editor
How do you get the required API keys?
You need three accounts (all have free tiers sufficient for this tutorial):
1. Pinecone (vector database) — Sign up at pinecone.io, copy your API key from API Keys.
2. OpenAI (embeddings — required regardless of generation provider) — Sign up at platform.openai.com, create an API key. Anthropic doesn't offer embeddings, so OpenAI's text-embedding-3-small is used for embedding no matter which provider you pick for generation.
3. Generation provider — pick one or both:
- Claude: console.anthropic.com → API Keys
- OpenAI: reuse the same key from step 2
Store keys in a .env file (never commit it):
PINECONE_API_KEY=your-pinecone-key
OPENAI_API_KEY=your-openai-key
ANTHROPIC_API_KEY=your-anthropic-key
LLM_PROVIDER=anthropic
How do you set up the project?
Create a project folder and initialize it:
mkdir rag-tutorial && cd rag-tutorial
npm init -y
npm install typescript tsx dotenv --save-dev
npm install @pinecone-database/pinecone openai @anthropic-ai/sdk
Create tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist"
},
"include": ["src/**/*.ts"]
}
Create .env and .gitignore (add .env, node_modules, dist).
Part 1: The Simple Version
This version hardcodes sample documents so you can see the whole pipeline work end-to-end before adding complexity. Create src/simple.ts:
import "dotenv/config";
import { Pinecone } from "@pinecone-database/pinecone";
import OpenAI from "openai";
import Anthropic from "@anthropic-ai/sdk";
const pc = new Pinecone({ apiKey: process.env.PINECONE_API_KEY! });
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY! });
const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY! });
const INDEX_NAME = "rag-tutorial-simple";
const EMBEDDING_MODEL = "text-embedding-3-small";
const EMBEDDING_DIMENSION = 1536;
const documents = [
{ id: "doc1", text: "Roomly is a student housing marketplace for FUTMINNA students, connecting them with verified off-campus rooms." },
{ id: "doc2", text: "RAG (retrieval-augmented generation) retrieves relevant text chunks and passes them to a language model as context before it answers." },
{ id: "doc3", text: "Pinecone is a managed vector database — it stores embeddings and finds the closest matches to a query embedding." },
];
async function embed(text: string): Promise<number[]> {
const response = await openai.embeddings.create({
model: EMBEDDING_MODEL,
input: text,
});
return response.data[0].embedding;
}
async function setupIndex() {
const existing = await pc.listIndexes();
const alreadyExists = existing.indexes?.some((i) => i.name === INDEX_NAME);
if (!alreadyExists) {
await pc.createIndex({
name: INDEX_NAME,
dimension: EMBEDDING_DIMENSION,
metric: "cosine",
spec: { serverless: { cloud: "aws", region: "us-east-1" } },
});
await new Promise((resolve) => setTimeout(resolve, 10_000));
}
}
async function ingestDocuments() {
const index = pc.index(INDEX_NAME);
const vectors = await Promise.all(
documents.map(async (doc) => ({
id: doc.id,
values: await embed(doc.text),
metadata: { text: doc.text },
})),
);
await index.upsert(vectors);
}
async function answerQuestion(question: string): Promise<string> {
const index = pc.index(INDEX_NAME);
const queryEmbedding = await embed(question);
const results = await index.query({
vector: queryEmbedding,
topK: 2,
includeMetadata: true,
});
const context = results.matches
.map((match) => match.metadata?.text)
.join("\n\n");
const prompt = `Answer the question using only the context below.\n\nContext:\n${context}\n\nQuestion: ${question}`;
if (process.env.LLM_PROVIDER === "openai") {
const completion = await openai.chat.completions.create({
model: "gpt-5.6-terra",
messages: [{ role: "user", content: prompt }],
});
return completion.choices[0].message.content ?? "";
}
const message = await anthropic.messages.create({
model: "claude-sonnet-5",
max_tokens: 500,
messages: [{ role: "user", content: prompt }],
});
return message.content[0].type === "text" ? message.content[0].text : "";
}
async function main() {
console.log("Setting up index...");
await setupIndex();
console.log("Ingesting documents...");
await ingestDocuments();
console.log("Asking a question...");
const answer = await answerQuestion("What does Pinecone do?");
console.log("\nAnswer:", answer);
}
main().catch(console.error);
Run it:
npx tsx src/simple.ts
You should see the index created, documents embedded and stored, and a grounded answer printed.
Switch providers by changing LLM_PROVIDER=openai in .env — no code changes needed.
Part 2: The Production-Shaped Version
The simple version works but has no error handling, no real document ingestion, and no rate-limit protection. Here's what changes.
How do you ingest real documents instead of hardcoded strings?
Read and chunk actual files:
import { readFileSync, readdirSync } from "fs";
import { join } from "path";
function chunkText(text: string, chunkSize = 800, overlap = 100): string[] {
const chunks: string[] = [];
let start = 0;
while (start < text.length) {
const end = Math.min(start + chunkSize, text.length);
chunks.push(text.slice(start, end));
start += chunkSize - overlap;
}
return chunks;
}
function loadDocuments(folderPath: string) {
const files = readdirSync(folderPath).filter((f) => f.endsWith(".txt"));
return files.flatMap((filename) => {
const content = readFileSync(join(folderPath, filename), "utf-8");
return chunkText(content).map((chunk, i) => ({
id: `${filename}-chunk-${i}`,
text: chunk,
source: filename,
}));
});
}
This uses fixed-size chunking with 100-character overlap. The RAG explainer covers semantic and parent-child chunking for more deliberate strategies.
How do you batch embedding calls to avoid rate limits?
Call the embeddings API with an array of texts:
async function embedBatch(texts: string[]): Promise<number[][]> {
const response = await openai.embeddings.create({
model: EMBEDDING_MODEL,
input: texts,
});
return response.data.map((d) => d.embedding);
}
How do you add retry logic with exponential backoff?
async function withRetry<T>(fn: () => Promise<T>, retries = 3): Promise<T> {
for (let attempt = 0; attempt < retries; attempt++) {
try {
return await fn();
} catch (err) {
const isLastAttempt = attempt === retries - 1;
if (isLastAttempt) throw err;
const status = (err as { status?: number }).status;
const isRetryable = status === 429 || (status !== undefined && status >= 500);
if (!isRetryable) throw err;
const delayMs = 500 * 2 ** attempt;
console.warn(`Retryable error (status ${status}), retrying in ${delayMs}ms...`);
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}
throw new Error("Unreachable");
}
Wrap embedding and generation calls: await withRetry(() => embedBatch(texts)).
How do you handle errors in depth?
Missing or invalid API key — fail immediately at startup
function requireEnv(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(`Missing required environment variable: ${name}. Check your .env file.`);
}
return value;
}
const PINECONE_API_KEY = requireEnv("PINECONE_API_KEY");
Rate limits (HTTP 429)
Covered by withRetry. If the provider returns a retry-after header, respect it instead of guessing.
Empty retrieval results — don't let the model hallucinate
if (results.matches.length === 0) {
return "I don't have any relevant information to answer that.";
}
Network or timeout failures
withRetry catches both. Add a maximum total timeout around the whole pipeline if running behind a user-facing request.
Pinecone index not ready — poll instead of guessing
async function waitUntilReady(indexName: string, timeoutMs = 60_000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const description = await pc.describeIndex(indexName);
if (description.status?.ready) return;
await new Promise((resolve) => setTimeout(resolve, 2_000));
}
throw new Error(`Index ${indexName} did not become ready within ${timeoutMs}ms`);
}
Troubleshooting Common Errors
| Error | Cause | Fix |
|---|---|---|
Cannot find module '@pinecone-database/pinecone' | Install incomplete or wrong folder | Confirm package.json present, re-run npm install |
401 Unauthorized | Wrong/expired key or quoting issue | Regenerate key, paste fresh without quotes |
429 Too Many Requests | Rate limit from embedding too fast | withRetry handles transient cases; batch fewer chunks if persistent |
Pinecone query returns undefined metadata | Upserted without metadata or queried without includeMetadata: true | Both required to get text back |
TypeScript moduleResolution errors | Wrong tsconfig.json | Use NodeNext exactly as shown |
| Model ignores retrieved context | Empty context or missing instruction | Check context not empty; ensure prompt says "use only the provided context" |
What's Next?
Natural extensions covered in the RAG explainer:
- Smarter chunking strategies (semantic, parent-child)
- Reranking step before generation
- Swapping fixed-size chunking for structure-aware approaches
The pipeline you built is the full mechanical shape — everything past this is refinement, not a different architecture.
References
- Pinecone, official Node.js/TypeScript client documentation
- OpenAI, embeddings API documentation
- Anthropic, embeddings guide
- Anthropic, Messages API reference