Skip to content

Retrieval-Augmented Generation: RAG Pipelines That Work

Build effective RAG pipelines in TypeScript: document chunking, embeddings, vector search tuning, prompt construction and retrieval-quality evaluation.

5 min read
RAG pipeline diagram showing documents flowing through chunking, embedding, vector storage, retrieval, and LLM generation stages

Large language models hallucinate. They generate confident, fluent text that sounds correct but invents facts, misquotes sources, and confuses related concepts. Retrieval-Augmented Generation (RAG) addresses this by grounding the model's responses in actual documents retrieved at query time—the model generates answers based on evidence, not memory.

But RAG systems have their own failure modes. Bad chunking splits relevant context across fragments. Poor embeddings return irrelevant documents. Naive prompt construction overwhelms the context window. Building a RAG pipeline that works requires careful attention to each stage.

The RAG Pipeline Architecture

A RAG pipeline has two phases: indexing (offline) and retrieval + generation (real-time).

tstypescript
// ❌ Naive RAG — dumps entire documents into the prompt
async function naiveRAG(query: string) {
  const allDocs = await db.getAllDocuments();
  const prompt = `
    Here are all our documents:
    ${allDocs.map((d) => d.content).join("\n\n")}
 
    Answer this question: ${query}
  `;
  // Problems:
  // - Exceeds context window
  // - Irrelevant content dilutes the answer
  // - No relevance ranking
  // - Costs a fortune in tokens
  return llm.generate(prompt);
}
tstypescript
// ✅ Structured RAG pipeline
async function structuredRAG(query: string) {
  // 1. Embed the query
  const queryEmbedding = await embedQuery(query);
 
  // 2. Retrieve relevant chunks
  const chunks = await vectorStore.search(
    queryEmbedding,
    { topK: 5, minScore: 0.7 }
  );
 
  // 3. Construct grounded prompt
  const prompt = buildPrompt(query, chunks);
 
  // 4. Generate with citations
  return llm.generate(prompt, {
    temperature: 0.1,
    maxTokens: 1000,
  });
}

Document Chunking Strategies

Chunking determines the granularity of retrieval. Too large, and you waste context window on irrelevant content. Too small, and you lose the context needed to answer questions.

tstypescript
interface Chunk {
  id: string;
  content: string;
  metadata: {
    source: string;
    section: string;
    pageNumber?: number;
    chunkIndex: number;
  };
}
 
// Strategy 1: Fixed-size with overlap
function fixedSizeChunking(
  text: string,
  chunkSize: number = 500,
  overlap: number = 50
): string[] {
  const words = text.split(/\s+/);
  const chunks: string[] = [];
 
  for (
    let i = 0;
    i < words.length;
    i += chunkSize - overlap
  ) {
    chunks.push(
      words.slice(i, i + chunkSize).join(" ")
    );
  }
 
  return chunks;
}
 
// Strategy 2: Semantic chunking by sections
function semanticChunking(
  markdown: string
): Chunk[] {
  const sections = markdown.split(/^##\s+/m);
  const chunks: Chunk[] = [];
 
  for (let i = 0; i < sections.length; i++) {
    const section = sections[i].trim();
    if (!section) continue;
 
    const lines = section.split("\n");
    const heading = lines[0];
    const content = lines.slice(1).join("\n").trim();
 
    // Split large sections further
    if (content.length > 2000) {
      const paragraphs = content.split(/\n\n+/);
      let currentChunk = "";
 
      for (const para of paragraphs) {
        if (
          (currentChunk + para).length > 1500 &&
          currentChunk
        ) {
          chunks.push({
            id: `chunk-${chunks.length}`,
            content: `## ${heading}\n\n${currentChunk}`,
            metadata: {
              source: "",
              section: heading,
              chunkIndex: chunks.length,
            },
          });
          currentChunk = para;
        } else {
          currentChunk += (currentChunk ? "\n\n" : "") + para;
        }
      }
 
      if (currentChunk) {
        chunks.push({
          id: `chunk-${chunks.length}`,
          content: `## ${heading}\n\n${currentChunk}`,
          metadata: {
            source: "",
            section: heading,
            chunkIndex: chunks.length,
          },
        });
      }
    } else {
      chunks.push({
        id: `chunk-${chunks.length}`,
        content: `## ${heading}\n\n${content}`,
        metadata: {
          source: "",
          section: heading,
          chunkIndex: chunks.length,
        },
      });
    }
  }
 
  return chunks;
}

Embedding and Indexing

Embeddings convert text into dense vectors that capture semantic meaning. Similar texts produce vectors that are close in the embedding space.

tstypescript
import { OpenAI } from "openai";
 
const openai = new OpenAI();
 
async function generateEmbeddings(
  texts: string[]
): Promise<number[][]> {
  // Batch embedding for efficiency
  const batchSize = 100;
  const allEmbeddings: number[][] = [];
 
  for (let i = 0; i < texts.length; i += batchSize) {
    const batch = texts.slice(i, i + batchSize);
    const response = await openai.embeddings.create({
      model: "text-embedding-3-small",
      input: batch,
    });
 
    allEmbeddings.push(
      ...response.data.map((d) => d.embedding)
    );
  }
 
  return allEmbeddings;
}
 
async function indexDocuments(
  chunks: Chunk[]
): Promise<void> {
  const texts = chunks.map((c) => c.content);
  const embeddings = await generateEmbeddings(texts);
 
  // Store in vector database (using pgvector example)
  for (let i = 0; i < chunks.length; i++) {
    await db.query(
      `INSERT INTO document_chunks
       (id, content, embedding, metadata)
       VALUES ($1, $2, $3, $4)`,
      [
        chunks[i].id,
        chunks[i].content,
        JSON.stringify(embeddings[i]),
        JSON.stringify(chunks[i].metadata),
      ]
    );
  }
 
  console.log(
    `Indexed ${chunks.length} chunks with embeddings`
  );
}

Pure vector search misses exact keyword matches. Pure keyword search misses semantic similarity. Combine both for better results.

tstypescript
interface SearchResult {
  chunk: Chunk;
  score: number;
  matchType: "vector" | "keyword" | "hybrid";
}
 
async function hybridSearch(
  query: string,
  topK: number = 5
): Promise<SearchResult[]> {
  // Vector search
  const queryEmbedding = await generateEmbeddings([
    query,
  ]);
  const vectorResults = await db.query(
    `SELECT id, content, metadata,
            1 - (embedding <=> $1) as similarity
     FROM document_chunks
     ORDER BY embedding <=> $1
     LIMIT $2`,
    [JSON.stringify(queryEmbedding[0]), topK * 2]
  );
 
  // Full-text search
  const keywordResults = await db.query(
    `SELECT id, content, metadata,
            ts_rank(search_vector, plainto_tsquery($1)) as rank
     FROM document_chunks
     WHERE search_vector @@ plainto_tsquery($1)
     ORDER BY rank DESC
     LIMIT $2`,
    [query, topK * 2]
  );
 
  // Reciprocal Rank Fusion to combine results
  const scores = new Map<string, number>();
 
  vectorResults.rows.forEach(
    (row: { id: string }, idx: number) => {
      const rrf = 1 / (60 + idx + 1);
      scores.set(
        row.id,
        (scores.get(row.id) ?? 0) + rrf
      );
    }
  );
 
  keywordResults.rows.forEach(
    (row: { id: string }, idx: number) => {
      const rrf = 1 / (60 + idx + 1);
      scores.set(
        row.id,
        (scores.get(row.id) ?? 0) + rrf
      );
    }
  );
 
  // Sort by combined score and return top K
  const sorted = [...scores.entries()]
    .sort((a, b) => b[1] - a[1])
    .slice(0, topK);
 
  const allResults = [
    ...vectorResults.rows,
    ...keywordResults.rows,
  ];
  const resultMap = new Map(
    allResults.map((r: { id: string }) => [r.id, r])
  );
 
  return sorted.map(([id, score]) => ({
    chunk: resultMap.get(id) as unknown as Chunk,
    score,
    matchType: "hybrid" as const,
  }));
}

Prompt Construction with Citations

The prompt must instruct the model to base answers on retrieved context and cite sources. Structure matters.

tstypescript
function buildPrompt(
  query: string,
  results: SearchResult[]
): string {
  const context = results
    .map(
      (r, i) =>
        `[Source ${i + 1}: ${r.chunk.metadata.source}, ` +
        `Section: ${r.chunk.metadata.section}]\n` +
        `${r.chunk.content}`
    )
    .join("\n\n---\n\n");
 
  return `You are a helpful assistant that answers questions
based on the provided context. Follow these rules:
 
1. Only use information from the provided context
2. If the context doesn't contain enough information,
   say "I don't have enough information to answer that"
3. Cite sources using [Source N] notation
4. Be specific and concise
 
Context:
${context}
 
Question: ${query}
 
Answer (cite sources):`;
}
 
// Full pipeline
async function answerQuestion(
  query: string
): Promise<{
  answer: string;
  sources: SearchResult[];
}> {
  const results = await hybridSearch(query, 5);
 
  if (results.length === 0) {
    return {
      answer:
        "I couldn't find relevant information " +
        "to answer that question.",
      sources: [],
    };
  }
 
  const prompt = buildPrompt(query, results);
 
  const response = await openai.chat.completions.create(
    {
      model: "gpt-4o-mini",
      messages: [{ role: "user", content: prompt }],
      temperature: 0.1,
      max_tokens: 1000,
    }
  );
 
  return {
    answer: response.choices[0].message.content ?? "",
    sources: results,
  };
}

Evaluation: Measuring RAG Quality

A RAG pipeline is only as good as its retrieval. Measure both retrieval quality and generation quality separately.

tstypescript
interface EvalResult {
  query: string;
  retrievalPrecision: number;
  retrievalRecall: number;
  answerRelevance: number;
  faithfulness: number;
}
 
async function evaluateRAG(
  testCases: {
    query: string;
    expectedChunkIds: string[];
    expectedAnswer: string;
  }[]
): Promise<EvalResult[]> {
  const results: EvalResult[] = [];
 
  for (const testCase of testCases) {
    const searchResults = await hybridSearch(
      testCase.query,
      5
    );
    const retrievedIds = searchResults.map(
      (r) => r.chunk.id
    );
 
    // Retrieval precision: how many retrieved docs
    // are relevant?
    const relevantRetrieved = retrievedIds.filter((id) =>
      testCase.expectedChunkIds.includes(id)
    ).length;
    const precision =
      relevantRetrieved / retrievedIds.length;
 
    // Retrieval recall: how many relevant docs
    // were retrieved?
    const recall =
      relevantRetrieved /
      testCase.expectedChunkIds.length;
 
    // Generate answer and evaluate
    const { answer } = await answerQuestion(
      testCase.query
    );
 
    results.push({
      query: testCase.query,
      retrievalPrecision: precision,
      retrievalRecall: recall,
      answerRelevance: 0, // Score with LLM judge
      faithfulness: 0,    // Compare to sources
    });
  }
 
  return results;
}

Key Takeaways

Chunking strategy determines retrieval quality—semantic chunking that respects document structure (headings, paragraphs, sections) consistently outperforms fixed-size chunking because it preserves the context needed to answer questions within each chunk. Hybrid search combining vector similarity and keyword matching through reciprocal rank fusion retrieves more relevant results than either approach alone, catching both semantic matches that keyword search misses and exact-term matches that embedding similarity misses. Prompt construction must explicitly instruct the model to base answers only on provided context and cite sources, because without these constraints the model will blend retrieved information with its parametric knowledge and produce answers that sound grounded but contain hallucinated details. Evaluate retrieval and generation separately using precision, recall, and faithfulness metrics on a curated test set—a pipeline with bad retrieval quality will never produce good answers regardless of the generation model used.

Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX