GenAI with JavaScript
Building RAG Systems

Building RAG Systems with JavaScript

RAG (Retrieval-Augmented Generation) is the architectural pattern that makes LLMs truly useful for your private business data. It allows an AI model to answer questions about your company's proprietary documents, internal wikis, and databases without training or fine-tuning the model.


1. Why RAG is Essential

Base Large Language Models are trained on public internet data and have a fixed cutoff date. They do not know about:

  • Your internal company policies or Notion docs
  • Your customer database or private user records
  • Changes made to your API or pricing yesterday

2. The 3-Step RAG Architecture


3. Step 1: Document Chunking in JavaScript

You cannot embed an entire 50-page PDF at once because embedding models have token limits and large texts dilute semantic precision. You must split documents into smaller chunks with a slight overlap.

Why Overlap Matters

If a crucial sentence is split across two chunks, an overlap of 10-15% ensures context continuity between adjacent chunks.

/**
 * Splits a document into overlapping word chunks
 * @param {string} text - The raw document text
 * @param {number} chunkSize - Number of words per chunk (default 300)
 * @param {number} overlap - Overlapping words between chunks (default 30)
 * @returns {string[]} Array of chunk strings
 */
export function chunkText(text, chunkSize = 300, overlap = 30) {
  const words = text.split(/\s+/).filter(Boolean);
  const chunks = [];
  let start = 0;
 
  while (start < words.length) {
    const end = Math.min(start + chunkSize, words.length);
    const chunk = words.slice(start, end).join(" ");
    chunks.push(chunk);
 
    if (end === words.length) break;
    start += chunkSize - overlap;
  }
 
  return chunks;
}

4. Step 2: The Complete RAG Pipeline in Node.js

Here is an end-to-end RAG implementation connecting PostgreSQL with OpenAI:

import pg from "pg";
import OpenAI from "openai";
import { chunkText } from "./chunker.js";
 
export class RAGPipeline {
  constructor({ databaseUrl, openaiApiKey }) {
    this.pool = new pg.Pool({ connectionString: databaseUrl });
    this.openai = new OpenAI({ apiKey: openaiApiKey });
  }
 
  async init() {
    await this.pool.query("CREATE EXTENSION IF NOT EXISTS vector;");
    await this.pool.query(`
      CREATE TABLE IF NOT EXISTS rag_chunks (
        id SERIAL PRIMARY KEY,
        document_source TEXT NOT NULL,
        content TEXT NOT NULL,
        embedding vector(1536),
        created_at TIMESTAMP DEFAULT NOW()
      );
    `);
  }
 
  /**
   * Ingest a document: chunk text, generate embeddings, and save to Postgres
   */
  async ingestDocument(content, documentSource) {
    const chunks = chunkText(content, 300, 30);
    console.log(`Ingesting ${chunks.length} chunks from "${documentSource}"...`);
 
    for (const chunk of chunks) {
      // 1. Generate embedding
      const embRes = await this.openai.embeddings.create({
        model: "text-embedding-3-small",
        input: chunk
      });
      const vectorStr = JSON.stringify(embRes.data[0].embedding);
 
      // 2. Store in PostgreSQL
      await this.pool.query(
        "INSERT INTO rag_chunks (document_source, content, embedding) VALUES ($1, $2, $3)",
        [documentSource, chunk, vectorStr]
      );
    }
 
    console.log(`âś… Ingested "${documentSource}" successfully.`);
  }
 
  /**
   * Retrieve relevant chunks from pgvector
   */
  async retrieveContext(queryText, topK = 3, minSimilarity = 0.65) {
    const embRes = await this.openai.embeddings.create({
      model: "text-embedding-3-small",
      input: queryText
    });
    const vectorStr = JSON.stringify(embRes.data[0].embedding);
 
    const sql = `
      SELECT 
        content,
        document_source,
        (1 - (embedding <=> $1::vector)) AS similarity
      FROM rag_chunks
      WHERE (1 - (embedding <=> $1::vector)) >= $2
      ORDER BY embedding <=> $1::vector ASC
      LIMIT $3;
    `;
 
    const { rows } = await this.pool.query(sql, [vectorStr, minSimilarity, topK]);
    return rows;
  }
 
  /**
   * Answer a user question with grounded context and source citations
   */
  async ask(userQuestion) {
    // 1. Retrieve relevant context
    const contextItems = await this.retrieveContext(userQuestion);
 
    if (contextItems.length === 0) {
      return {
        answer: "I could not find any relevant information in the documentation to answer your question.",
        sources: []
      };
    }
 
    // 2. Format context and extract unique sources
    const contextBlock = contextItems
      .map((item, i) => `[Document ${i + 1}: ${item.document_source}]\n${item.content}`)
      .join("\n\n---\n\n");
 
    const uniqueSources = [...new Set(contextItems.map((c) => c.document_source))];
 
    // 3. Ask the LLM to generate the answer strictly using context
    const response = await this.openai.chat.completions.create({
      model: "gpt-4o-mini",
      temperature: 0.2, // Low temperature for high factual accuracy
      messages: [
        {
          role: "system",
          content: `You are a technical knowledge assistant.
Answer the user's question strictly using the provided context snippets.
If the answer cannot be found in the context, state clearly that you do not have enough information.
Always mention the source document name in your answer.`
        },
        {
          role: "user",
          content: `CONTEXT:
${contextBlock}
 
QUESTION:
${userQuestion}`
        }
      ]
    });
 
    return {
      answer: response.choices[0].message.content,
      sources: uniqueSources,
      retrievedChunksCount: contextItems.length
    };
  }
}

5. Running the Pipeline

import { RAGPipeline } from "./ragPipeline.js";
 
async function main() {
  const rag = new RAGPipeline({
    databaseUrl: "postgresql://localhost:5432/my_ai_db",
    openaiApiKey: process.env.OPENAI_API_KEY
  });
 
  await rag.init();
 
  // 1. Ingest documents
  await rag.ingestDocument(`
    Refund Policy:
    We offer a 14-day 100% money-back guarantee for all annual subscription plans.
    Monthly subscriptions can be canceled at any time from your billing dashboard, but are non-refundable.
    To request a refund on an annual plan, email refunds@example.com with your invoice ID.
  `, "refund-policy.md");
 
  await rag.ingestDocument(`
    Pricing Tiers:
    - Starter: $12/month (up to 3 team members)
    - Pro: $49/month (unlimited members, vector search enabled)
    - Enterprise: Custom contract.
  `, "pricing.md");
 
  // 2. Query
  const result = await rag.ask("Can I get a refund for my monthly subscription?");
  
  console.log("Answer:\n", result.answer);
  console.log("Sources Used:", result.sources);
}
 
main();

6. RAG Best Practices & Common Pitfalls

Best PracticeWhy It Matters
Optimal Chunk Size (200 - 500 words)Too small misses critical sentences; too large dilutes vector similarity.
Enforce Similarity ThresholdsIf the top similarity is < 0.60, do not send garbage data to the LLM.
Limit Context Chunks (Top 3 to 5)Feeding 20+ chunks can confuse the model and unnecessarily inflate token costs.
Always Ground with Explicit InstructionsInstruct the model: "If the context does not contain the answer, say you do not know."

👉 Next Step: Build a complete production Document Q&A REST API with Express and file upload support in Case Study: Document Q&A Bot Backend.