Skip to content

Production RAG: From Retrieval to Accurate Answers

Build production RAG pipelines that retrieve real context and answer accurately: chunking, embedding models, vector search, re-ranking and evaluation.

4 min read
A RAG pipeline flowchart showing document chunking, embedding, vector search retrieval, and LLM generation with context injection

The Problem RAG Solves

LLMs have impressive general knowledge but cannot access your private documents, recent data, or domain-specific information. Fine-tuning is expensive and slow to update. RAG retrieves relevant documents at query time and injects them into the prompt, giving the LLM current, accurate context without retraining.

Document Chunking Strategy

How you split documents determines retrieval quality. Chunks that are too small lose context. Chunks that are too large dilute the relevant information and waste token budget.

tstypescript
interface Chunk {
  id: string;
  text: string;
  metadata: {
    source: string;
    page?: number;
    section?: string;
    chunkIndex: number;
  };
}
 
// ❌ Fixed-size splitting — breaks mid-sentence, loses context
function badChunking(text: string, size: number): string[] {
  const chunks: string[] = [];
  for (let i = 0; i < text.length; i += size) {
    chunks.push(text.slice(i, i + size));
  }
  return chunks;
}
 
// ✅ Recursive character splitting with overlap
function chunkDocument(
  text: string,
  config: {
    chunkSize: number;
    chunkOverlap: number;
    separators: string[];
  }
): string[] {
  const { chunkSize, chunkOverlap, separators } = config;
  const chunks: string[] = [];
 
  function splitRecursive(text: string, separatorIndex: number): string[] {
    if (text.length <= chunkSize) return [text];
 
    const separator = separators[separatorIndex] ?? "";
    const splits = text.split(separator).filter(Boolean);
 
    const result: string[] = [];
    let current = "";
 
    for (const split of splits) {
      const candidate = current
        ? current + separator + split
        : split;
 
      if (candidate.length > chunkSize && current) {
        result.push(current.trim());
        // Overlap: keep the tail of the previous chunk
        const overlapText = current.slice(-chunkOverlap);
        current = overlapText + separator + split;
      } else {
        current = candidate;
      }
    }
    if (current.trim()) result.push(current.trim());
 
    // If chunks are still too large, split with next separator
    if (separatorIndex < separators.length - 1) {
      return result.flatMap((chunk) =>
        chunk.length > chunkSize
          ? splitRecursive(chunk, separatorIndex + 1)
          : [chunk]
      );
    }
 
    return result;
  }
 
  return splitRecursive(text, 0);
}
 
const defaultConfig = {
  chunkSize: 512,
  chunkOverlap: 50,
  separators: ["\n\n", "\n", ". ", " "],
};

Embedding and Indexing

Convert chunks into vector embeddings and store them in a vector database for similarity search.

tstypescript
interface EmbeddingResult {
  chunkId: string;
  vector: number[];
  metadata: Record<string, unknown>;
}
 
async function embedChunks(
  chunks: Chunk[],
  model: string = "text-embedding-3-small"
): Promise<EmbeddingResult[]> {
  const batchSize = 100;
  const results: EmbeddingResult[] = [];
 
  for (let i = 0; i < chunks.length; i += batchSize) {
    const batch = chunks.slice(i, i + batchSize);
 
    const response = await fetch("https://api.openai.com/v1/embeddings", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
      },
      body: JSON.stringify({
        model,
        input: batch.map((c) => c.text),
      }),
    });
 
    const data = await response.json();
 
    for (let j = 0; j < batch.length; j++) {
      results.push({
        chunkId: batch[j].id,
        vector: data.data[j].embedding,
        metadata: {
          text: batch[j].text,
          ...batch[j].metadata,
        },
      });
    }
  }
 
  return results;
}
 
// Store in vector database (Pinecone example)
async function indexEmbeddings(
  embeddings: EmbeddingResult[],
  namespace: string
): Promise<void> {
  const vectors = embeddings.map((e) => ({
    id: e.chunkId,
    values: e.vector,
    metadata: e.metadata,
  }));
 
  await pineconeIndex.namespace(namespace).upsert(vectors);
}

Retrieval with Re-Ranking

Vector similarity search retrieves candidates. A re-ranker scores them for actual relevance to the query, filtering out semantically similar but irrelevant results.

tstypescript
interface RetrievedChunk {
  text: string;
  score: number;
  metadata: Record<string, unknown>;
}
 
async function retrieveWithReranking(
  query: string,
  config: { topK: number; rerankTopN: number }
): Promise<RetrievedChunk[]> {
  // Step 1: Embed the query
  const queryEmbedding = await embedQuery(query);
 
  // Step 2: Vector search — retrieve more candidates than needed
  const candidates = await pineconeIndex.query({
    vector: queryEmbedding,
    topK: config.topK * 3, // Over-fetch for re-ranking
    includeMetadata: true,
  });
 
  // Step 3: Re-rank candidates for relevance
  const reranked = await rerank(
    query,
    candidates.matches.map((m) => ({
      text: m.metadata?.text as string,
      score: m.score ?? 0,
      metadata: m.metadata ?? {},
    }))
  );
 
  return reranked.slice(0, config.rerankTopN);
}
 
async function rerank(
  query: string,
  docs: RetrievedChunk[]
): Promise<RetrievedChunk[]> {
  const response = await fetch("https://api.cohere.ai/v1/rerank", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.COHERE_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      model: "rerank-english-v3.0",
      query,
      documents: docs.map((d) => d.text),
      top_n: docs.length,
    }),
  });
 
  const data = await response.json();
  return data.results.map((r: { index: number; relevance_score: number }) => ({
    ...docs[r.index],
    score: r.relevance_score,
  }));
}

Generation with Retrieved Context

Inject retrieved chunks into the LLM prompt with clear instructions to use them as the primary source and to acknowledge when the context does not contain the answer.

tstypescript
function buildRAGPrompt(
  query: string,
  context: RetrievedChunk[]
): string {
  const contextBlock = context
    .map((c, i) => `[Source ${i + 1}] (${c.metadata.source})\n${c.text}`)
    .join("\n\n---\n\n");
 
  return `You are a helpful assistant. Answer the user's question using ONLY the provided context. If the context does not contain enough information to answer fully, say so explicitly. Do not make up information.
 
## Context
${contextBlock}
 
## Question
${query}
 
## Instructions
- Cite sources using [Source N] notation
- If the context doesn't contain the answer, say "I don't have enough information to answer this question based on the available documents"
- Be concise and direct`;
}
 
async function generateAnswer(
  query: string,
  context: RetrievedChunk[]
): Promise<{ answer: string; sources: string[] }> {
  const prompt = buildRAGPrompt(query, context);
 
  const response = await fetch("https://api.openai.com/v1/chat/completions", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
    },
    body: JSON.stringify({
      model: "gpt-4o",
      messages: [{ role: "user", content: prompt }],
      temperature: 0.1,
    }),
  });
 
  const data = await response.json();
  return {
    answer: data.choices[0].message.content,
    sources: context.map((c) => c.metadata.source as string),
  };
}

Evaluating RAG Quality

Without evaluation, you cannot tell if your RAG pipeline is improving or degrading. Measure retrieval recall, answer faithfulness, and relevance.

tstypescript
interface RAGEvaluation {
  query: string;
  expectedAnswer: string;
  retrievedChunks: RetrievedChunk[];
  generatedAnswer: string;
  metrics: {
    retrievalRecall: number;    // Did retrieval find relevant docs?
    answerFaithfulness: number; // Does the answer stick to retrieved context?
    answerRelevance: number;    // Does the answer address the query?
  };
}
 
async function evaluateRAG(
  testCases: Array<{
    query: string;
    expectedAnswer: string;
    relevantDocIds: string[];
  }>
): Promise<RAGEvaluation[]> {
  const results: RAGEvaluation[] = [];
 
  for (const testCase of testCases) {
    const chunks = await retrieveWithReranking(testCase.query, {
      topK: 10,
      rerankTopN: 5,
    });
 
    const { answer } = await generateAnswer(testCase.query, chunks);
 
    const retrievedIds = chunks.map((c) => c.metadata.chunkId as string);
    const retrievalRecall =
      testCase.relevantDocIds.filter((id) => retrievedIds.includes(id)).length /
      testCase.relevantDocIds.length;
 
    results.push({
      query: testCase.query,
      expectedAnswer: testCase.expectedAnswer,
      retrievedChunks: chunks,
      generatedAnswer: answer,
      metrics: {
        retrievalRecall,
        answerFaithfulness: await scoreFaithfulness(answer, chunks),
        answerRelevance: await scoreRelevance(answer, testCase.query),
      },
    });
  }
 
  return results;
}

Key Takeaways

RAG quality depends on every stage of the pipeline: chunking, embedding, retrieval, re-ranking, and generation. Chunk documents semantically with overlap to preserve context across boundaries. Over-fetch candidates during vector search and re-rank for precision—embedding similarity alone misses nuance.

Instruct the LLM to cite sources and acknowledge information gaps. Evaluate your pipeline with test cases that measure retrieval recall, answer faithfulness, and relevance. Start with a simple pipeline—chunk, embed, retrieve, generate—then add re-ranking and evaluation. The pipeline that retrieves the right context wins; the generation step is only as good as what you feed it.

Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX