Skip to content

Building RAG Systems That Actually Work

A practical guide to production RAG that returns grounded answers: embedding strategies, vector database choice, chunking, reranking and evaluation.

5 min read
A pipeline diagram showing document ingestion, embedding, retrieval, and LLM generation stages of a RAG system

Why RAG Beats Fine-Tuning for Most Use Cases

Fine-tuning an LLM on your data bakes knowledge into the model weights. This is expensive, slow to update, and impossible to audit. RAG keeps the knowledge external in a searchable index, feeding relevant context to the LLM at query time. You can update the knowledge base without retraining, trace every answer back to source documents, and control what information the model can access.

But naive RAG—embed documents, retrieve top-K, stuff into prompt—fails in production. Building one that works requires careful attention at every stage of the pipeline.

Document Chunking Strategies

How you split documents determines whether the retrieval step finds relevant context or returns fragments that confuse the LLM.

tstypescript
interface Chunk {
  id: string;
  content: string;
  metadata: {
    source: string;
    section: string;
    pageNumber?: number;
    chunkIndex: number;
    tokenCount: number;
  };
}
 
// ❌ Fixed-size chunking — splits mid-sentence, loses context
function naiveChunk(text: string, maxChars: number): string[] {
  const chunks: string[] = [];
  for (let i = 0; i < text.length; i += maxChars) {
    chunks.push(text.slice(i, i + maxChars));
  }
  return chunks;
}
 
// ✅ Semantic chunking — respects document structure
function semanticChunk(
  text: string,
  options: {
    maxTokens: number;
    overlapTokens: number;
    separators: string[];
  }
): Chunk[] {
  const { maxTokens, overlapTokens, separators } = options;
  const chunks: Chunk[] = [];
 
  // Split by strongest separator first
  let sections = [text];
  for (const separator of separators) {
    sections = sections.flatMap((section) =>
      section.split(separator).filter((s) => s.trim())
    );
  }
 
  let currentChunk = "";
  let chunkIndex = 0;
 
  for (const section of sections) {
    const sectionTokens = estimateTokens(section);
 
    if (estimateTokens(currentChunk) + sectionTokens > maxTokens) {
      if (currentChunk.trim()) {
        chunks.push({
          id: `chunk-${chunkIndex}`,
          content: currentChunk.trim(),
          metadata: {
            source: "",
            section: extractHeading(currentChunk),
            chunkIndex,
            tokenCount: estimateTokens(currentChunk),
          },
        });
        chunkIndex++;
 
        // Keep overlap for context continuity
        const words = currentChunk.split(/\s+/);
        const overlapWords = words.slice(-overlapTokens);
        currentChunk = overlapWords.join(" ") + "\n" + section;
      }
    } else {
      currentChunk += "\n" + section;
    }
  }
 
  if (currentChunk.trim()) {
    chunks.push({
      id: `chunk-${chunkIndex}`,
      content: currentChunk.trim(),
      metadata: {
        source: "",
        section: extractHeading(currentChunk),
        chunkIndex,
        tokenCount: estimateTokens(currentChunk),
      },
    });
  }
 
  return chunks;
}

The separator hierarchy matters: split on headings first, then paragraphs, then sentences. Overlap between chunks ensures that context spanning a boundary is not lost. For code documentation, treat function definitions and class boundaries as primary separators.

Embedding and Indexing

Embedding quality determines retrieval quality. The choice between embedding models matters less than how you prepare the text before embedding.

tstypescript
interface EmbeddingConfig {
  model: string;
  dimensions: number;
  maxInputTokens: number;
  batchSize: number;
}
 
class DocumentIndexer {
  constructor(
    private readonly embedder: EmbeddingService,
    private readonly vectorStore: VectorStore,
    private readonly config: EmbeddingConfig
  ) {}
 
  async indexDocuments(documents: Document[]): Promise<IndexResult> {
    let totalChunks = 0;
    let totalTokens = 0;
 
    for (const doc of documents) {
      const chunks = semanticChunk(doc.content, {
        maxTokens: this.config.maxInputTokens,
        overlapTokens: 50,
        separators: ["\n## ", "\n### ", "\n\n", ". "],
      });
 
      // Enrich chunks with document-level metadata
      const enrichedChunks = chunks.map((chunk) => ({
        ...chunk,
        content: this.enrichContent(chunk, doc),
        metadata: {
          ...chunk.metadata,
          source: doc.source,
          documentTitle: doc.title,
          lastUpdated: doc.updatedAt,
        },
      }));
 
      // Batch embed for efficiency
      for (let i = 0; i < enrichedChunks.length; i += this.config.batchSize) {
        const batch = enrichedChunks.slice(i, i + this.config.batchSize);
        const embeddings = await this.embedder.embed(
          batch.map((c) => c.content)
        );
 
        await this.vectorStore.upsert(
          batch.map((chunk, idx) => ({
            id: chunk.id,
            vector: embeddings[idx],
            metadata: chunk.metadata,
            content: chunk.content,
          }))
        );
      }
 
      totalChunks += enrichedChunks.length;
      totalTokens += enrichedChunks.reduce(
        (sum, c) => sum + c.metadata.tokenCount,
        0
      );
    }
 
    return { totalChunks, totalTokens };
  }
 
  private enrichContent(chunk: Chunk, doc: Document): string {
    // Prepend document context for better embeddings
    return `Document: ${doc.title}\nSection: ${chunk.metadata.section}\n\n${chunk.content}`;
  }
}

Retrieval and Reranking

Vector similarity search returns the K most similar chunks, but similarity is not relevance. A reranking step scores the retrieved chunks against the actual query using a cross-encoder, which is more accurate than embedding similarity alone.

tstypescript
interface RetrievalResult {
  chunks: ScoredChunk[];
  query: string;
  retrievalTimeMs: number;
}
 
interface ScoredChunk {
  chunk: Chunk;
  similarityScore: number;
  rerankScore?: number;
  finalScore: number;
}
 
class RAGRetriever {
  constructor(
    private readonly vectorStore: VectorStore,
    private readonly reranker: Reranker,
    private readonly config: {
      initialK: number;    // Retrieve more candidates
      finalK: number;      // Return fewer, better results
      similarityThreshold: number;
    }
  ) {}
 
  async retrieve(query: string): Promise<RetrievalResult> {
    const start = Date.now();
 
    // Stage 1: Broad retrieval via vector similarity
    const candidates = await this.vectorStore.search(
      query,
      this.config.initialK
    );
 
    // Filter by minimum similarity
    const filtered = candidates.filter(
      (c) => c.score >= this.config.similarityThreshold
    );
 
    // Stage 2: Rerank with cross-encoder
    const reranked = await this.reranker.rank(
      query,
      filtered.map((c) => c.content)
    );
 
    // Combine scores and take top-K
    const scored: ScoredChunk[] = filtered
      .map((candidate, idx) => ({
        chunk: candidate.chunk,
        similarityScore: candidate.score,
        rerankScore: reranked[idx].score,
        finalScore: 0.3 * candidate.score + 0.7 * reranked[idx].score,
      }))
      .sort((a, b) => b.finalScore - a.finalScore)
      .slice(0, this.config.finalK);
 
    return {
      chunks: scored,
      query,
      retrievalTimeMs: Date.now() - start,
    };
  }
}

Prompt Construction and Generation

The final prompt must clearly separate retrieved context from the user's question. The LLM needs instructions about how to handle conflicting sources, missing information, and the boundaries of what it should answer.

tstypescript
function buildRAGPrompt(
  query: string,
  retrievedChunks: ScoredChunk[],
  conversationHistory: Message[]
): string {
  const context = retrievedChunks
    .map(
      (chunk, idx) =>
        `[Source ${idx + 1}: ${chunk.chunk.metadata.source}]\n${chunk.chunk.content}`
    )
    .join("\n\n---\n\n");
 
  return `You are a helpful assistant. Answer the user's question based ONLY on the provided context. If the context does not contain enough information to answer, say so explicitly. Do not make up information.
 
If multiple sources contradict each other, note the discrepancy and present both perspectives.
 
## Context
${context}
 
## Conversation History
${conversationHistory.map((m) => `${m.role}: ${m.content}`).join("\n")}
 
## Current Question
${query}
 
## Instructions
- Cite sources using [Source N] notation
- If the answer requires information not in the context, state what is missing
- Be concise and direct`;
}

Evaluation and Quality Metrics

You cannot improve what you do not measure. RAG systems need evaluation along two axes: retrieval quality (did the system find the right chunks?) and generation quality (did the LLM produce a correct, grounded answer?).

tstypescript
interface RAGEvaluation {
  retrievalMetrics: {
    precision: number;    // Relevant chunks / retrieved chunks
    recall: number;       // Retrieved relevant / total relevant
    mrr: number;          // Mean Reciprocal Rank
  };
  generationMetrics: {
    faithfulness: number; // Is every claim supported by context?
    relevance: number;    // Does the answer address the question?
    completeness: number; // Are all relevant aspects covered?
  };
}
 
async function evaluateRAG(
  testCases: Array<{
    query: string;
    expectedChunkIds: string[];
    expectedAnswer: string;
  }>,
  retriever: RAGRetriever,
  generator: RAGGenerator
): Promise<RAGEvaluation> {
  let totalPrecision = 0;
  let totalRecall = 0;
  let totalMRR = 0;
 
  for (const testCase of testCases) {
    const result = await retriever.retrieve(testCase.query);
    const retrievedIds = result.chunks.map((c) => c.chunk.id);
 
    const relevant = retrievedIds.filter((id) =>
      testCase.expectedChunkIds.includes(id)
    );
 
    totalPrecision += relevant.length / retrievedIds.length;
    totalRecall += relevant.length / testCase.expectedChunkIds.length;
 
    // MRR: position of first relevant result
    const firstRelevantIdx = retrievedIds.findIndex((id) =>
      testCase.expectedChunkIds.includes(id)
    );
    totalMRR += firstRelevantIdx >= 0 ? 1 / (firstRelevantIdx + 1) : 0;
  }
 
  const n = testCases.length;
 
  return {
    retrievalMetrics: {
      precision: totalPrecision / n,
      recall: totalRecall / n,
      mrr: totalMRR / n,
    },
    generationMetrics: {
      faithfulness: 0, // Requires LLM-as-judge evaluation
      relevance: 0,
      completeness: 0,
    },
  };
}

Key Takeaways

Production RAG systems fail when any single stage is weak. Chunking determines whether relevant context is retrievable. Embedding quality depends more on input preparation than model choice. Reranking is the single highest-impact addition—it converts broad similarity into precise relevance.

Build the evaluation harness before optimizing anything else. Without ground-truth test cases measuring retrieval precision, recall, and generation faithfulness, every change is guesswork. Start with a small, high-quality test set and expand as failure modes emerge.

The prompt is the contract between retrieval and generation. It must clearly delineate context from instructions, tell the model when to refuse, and require source citations. A well-structured prompt with mediocre retrieval outperforms a poor prompt with excellent retrieval every time.

Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX