Skip to content

Building Production-Ready RAG Pipelines with LangChain

Design retrieval-augmented generation pipelines that go beyond demos: chunking strategies, embedding optimization, reranking and evaluation metrics.

5 min read
RAG pipeline architecture diagram showing document ingestion, vector storage, retrieval, and LLM generation stages

Every RAG tutorial shows the same thing: load a PDF, split it into chunks, embed them, and ask questions. The demo works beautifully. Then you try it with real documents and the answers are wrong, hallucinated, or irrelevant. The gap between demo RAG and production RAG is enormous, and it lives in the details of chunking, retrieval, and evaluation.

Chunking Strategy Matters More Than Model Choice

The most impactful decision in a RAG pipeline isn't which LLM you use—it's how you split your documents. Bad chunking produces bad retrieval, and no amount of model sophistication can fix garbage input.

tstypescript
// ❌ Naive fixed-size chunking breaks semantic boundaries
interface NaiveChunkConfig {
  chunkSize: number;
  overlap: number;
}
 
function naiveChunk(text: string, config: NaiveChunkConfig): string[] {
  const chunks: string[] = [];
  for (let i = 0; i < text.length; i += config.chunkSize - config.overlap) {
    chunks.push(text.slice(i, i + config.chunkSize));
  }
  return chunks;
  // Problem: cuts mid-sentence, splits code blocks,
  // separates headers from their content
}
tstypescript
// ✅ Semantic-aware chunking preserves meaning
interface SemanticChunkConfig {
  maxChunkTokens: number;
  minChunkTokens: number;
  overlapSentences: number;
  preserveStructure: boolean;
}
 
interface DocumentChunk {
  content: string;
  metadata: {
    source: string;
    section: string;
    pageNumber?: number;
    chunkIndex: number;
    tokenCount: number;
  };
}
 
function semanticChunk(
  document: string,
  config: SemanticChunkConfig
): DocumentChunk[] {
  const sections = splitBySections(document);
  const chunks: DocumentChunk[] = [];
  let chunkIndex = 0;
 
  for (const section of sections) {
    const sentences = splitSentences(section.content);
    let currentChunk: string[] = [];
    let currentTokens = 0;
 
    for (const sentence of sentences) {
      const sentenceTokens = estimateTokens(sentence);
 
      if (
        currentTokens + sentenceTokens > config.maxChunkTokens &&
        currentTokens >= config.minChunkTokens
      ) {
        chunks.push({
          content: currentChunk.join(" "),
          metadata: {
            source: document.slice(0, 50),
            section: section.heading,
            chunkIndex: chunkIndex++,
            tokenCount: currentTokens,
          },
        });
 
        // Keep overlap sentences for context continuity
        const overlapStart = Math.max(
          0,
          currentChunk.length - config.overlapSentences
        );
        currentChunk = currentChunk.slice(overlapStart);
        currentTokens = currentChunk
          .reduce((s, sent) => s + estimateTokens(sent), 0);
      }
 
      currentChunk.push(sentence);
      currentTokens += sentenceTokens;
    }
 
    // Flush remaining content
    if (currentChunk.length > 0) {
      chunks.push({
        content: currentChunk.join(" "),
        metadata: {
          source: document.slice(0, 50),
          section: section.heading,
          chunkIndex: chunkIndex++,
          tokenCount: currentTokens,
        },
      });
    }
  }
 
  return chunks;
}
 
function splitBySections(text: string): { heading: string; content: string }[] {
  const sectionPattern = /^(#{1,3})\s+(.+)$/gm;
  const sections: { heading: string; content: string }[] = [];
  let lastIndex = 0;
  let lastHeading = "Introduction";
 
  let match: RegExpExecArray | null;
  while ((match = sectionPattern.exec(text)) !== null) {
    if (match.index > lastIndex) {
      sections.push({
        heading: lastHeading,
        content: text.slice(lastIndex, match.index).trim(),
      });
    }
    lastHeading = match[2];
    lastIndex = match.index + match[0].length;
  }
 
  if (lastIndex < text.length) {
    sections.push({
      heading: lastHeading,
      content: text.slice(lastIndex).trim(),
    });
  }
 
  return sections;
}
 
function splitSentences(text: string): string[] {
  return text.split(/(?<=[.!?])\s+/).filter(s => s.length > 0);
}
 
function estimateTokens(text: string): number {
  return Math.ceil(text.length / 4);
}

Section-aware chunking preserves the relationship between headings and their content. The metadata attached to each chunk enables filtered retrieval: "find chunks from the 'Authentication' section."

Hybrid Retrieval: Dense + Sparse

Vector similarity search (dense retrieval) excels at semantic matching but misses exact keyword matches. BM25 (sparse retrieval) finds exact terms but misses semantic equivalents. Combining both outperforms either alone.

tstypescript
interface RetrievalResult {
  chunk: DocumentChunk;
  score: number;
  source: "dense" | "sparse" | "hybrid";
}
 
interface HybridRetrieverConfig {
  denseWeight: number;   // 0-1, weight for vector search
  sparseWeight: number;  // 0-1, weight for BM25
  topK: number;
  rerankEnabled: boolean;
}
 
class HybridRetriever {
  private config: HybridRetrieverConfig;
 
  constructor(config: HybridRetrieverConfig) {
    this.config = config;
  }
 
  async retrieve(
    query: string,
    denseResults: RetrievalResult[],
    sparseResults: RetrievalResult[]
  ): Promise<RetrievalResult[]> {
    // Normalize scores to 0-1 range
    const normalizedDense = this.normalizeScores(denseResults);
    const normalizedSparse = this.normalizeScores(sparseResults);
 
    // Reciprocal Rank Fusion for combining results
    const fusedScores = new Map<string, number>();
    const chunkMap = new Map<string, DocumentChunk>();
    const k = 60; // RRF constant
 
    normalizedDense.forEach((result, rank) => {
      const key = result.chunk.metadata.chunkIndex.toString();
      const rrfScore = this.config.denseWeight / (k + rank + 1);
      fusedScores.set(key, (fusedScores.get(key) ?? 0) + rrfScore);
      chunkMap.set(key, result.chunk);
    });
 
    normalizedSparse.forEach((result, rank) => {
      const key = result.chunk.metadata.chunkIndex.toString();
      const rrfScore = this.config.sparseWeight / (k + rank + 1);
      fusedScores.set(key, (fusedScores.get(key) ?? 0) + rrfScore);
      chunkMap.set(key, result.chunk);
    });
 
    // Sort by fused score
    const results: RetrievalResult[] = Array.from(fusedScores.entries())
      .sort(([, a], [, b]) => b - a)
      .slice(0, this.config.topK)
      .map(([key, score]) => ({
        chunk: chunkMap.get(key)!,
        score,
        source: "hybrid" as const,
      }));
 
    return results;
  }
 
  private normalizeScores(
    results: RetrievalResult[]
  ): RetrievalResult[] {
    if (results.length === 0) return [];
    const maxScore = Math.max(...results.map(r => r.score));
    const minScore = Math.min(...results.map(r => r.score));
    const range = maxScore - minScore || 1;
 
    return results.map(r => ({
      ...r,
      score: (r.score - minScore) / range,
    }));
  }
}

Reciprocal Rank Fusion (RRF) is particularly effective because it doesn't require the scores from different systems to be on the same scale—it only uses rank positions.

Context Window Assembly

Retrieved chunks need careful assembly before being sent to the LLM. Simply concatenating top-K results often includes redundant information and wastes context window tokens.

tstypescript
interface ContextWindow {
  systemPrompt: string;
  retrievedContext: string;
  userQuery: string;
  totalTokens: number;
  maxTokens: number;
}
 
function assembleContext(
  query: string,
  results: RetrievalResult[],
  maxContextTokens: number
): ContextWindow {
  const systemPrompt =
    "Answer the question based on the provided context. " +
    "If the context doesn't contain enough information, say so. " +
    "Cite the source section when possible.";
 
  // Deduplicate overlapping chunks
  const deduplicated = deduplicateChunks(results);
 
  // Group by section for coherent reading
  const grouped = groupBySection(deduplicated);
 
  // Build context within token budget
  let contextParts: string[] = [];
  let currentTokens = 0;
 
  for (const [section, chunks] of grouped) {
    const sectionHeader = `[Section: ${section}]`;
    const sectionTokens = estimateTokens(sectionHeader);
 
    if (currentTokens + sectionTokens > maxContextTokens) break;
 
    contextParts.push(sectionHeader);
    currentTokens += sectionTokens;
 
    for (const chunk of chunks) {
      const chunkTokens = chunk.chunk.metadata.tokenCount;
      if (currentTokens + chunkTokens > maxContextTokens) break;
      contextParts.push(chunk.chunk.content);
      currentTokens += chunkTokens;
    }
  }
 
  const retrievedContext = contextParts.join("\n\n");
  const totalTokens =
    estimateTokens(systemPrompt) +
    currentTokens +
    estimateTokens(query);
 
  return {
    systemPrompt,
    retrievedContext,
    userQuery: query,
    totalTokens,
    maxTokens: maxContextTokens,
  };
}
 
function deduplicateChunks(
  results: RetrievalResult[]
): RetrievalResult[] {
  const seen = new Set<number>();
  return results.filter(r => {
    if (seen.has(r.chunk.metadata.chunkIndex)) return false;
    seen.add(r.chunk.metadata.chunkIndex);
    return true;
  });
}
 
function groupBySection(
  results: RetrievalResult[]
): Map<string, RetrievalResult[]> {
  const groups = new Map<string, RetrievalResult[]>();
  for (const result of results) {
    const section = result.chunk.metadata.section;
    const existing = groups.get(section) ?? [];
    existing.push(result);
    groups.set(section, existing);
  }
  return groups;
}

Evaluating RAG Quality

Without evaluation, you're guessing whether your RAG pipeline actually works. Three metrics matter most: retrieval relevance, answer correctness, and faithfulness (does the answer stick to the retrieved context?).

tstypescript
interface RAGEvaluation {
  query: string;
  expectedAnswer: string;
  retrievedChunks: DocumentChunk[];
  generatedAnswer: string;
  metrics: {
    retrievalRelevance: number;  // 0-1: are retrieved chunks relevant?
    answerCorrectness: number;   // 0-1: is the answer correct?
    faithfulness: number;        // 0-1: does answer follow from context?
    contextUtilization: number;  // 0-1: how much context was used?
  };
}
 
function calculateRetrievalRelevance(
  chunks: DocumentChunk[],
  relevantChunkIds: Set<number>
): number {
  if (chunks.length === 0) return 0;
  const relevant = chunks.filter(c =>
    relevantChunkIds.has(c.metadata.chunkIndex)
  );
  return relevant.length / chunks.length;
}
 
interface EvalDataset {
  queries: {
    query: string;
    expectedAnswer: string;
    relevantChunkIds: number[];
  }[];
}
 
function runEvaluation(
  pipeline: RAGPipeline,
  dataset: EvalDataset
): { avgRelevance: number; avgCorrectness: number } {
  let totalRelevance = 0;
  let totalCorrectness = 0;
 
  for (const item of dataset.queries) {
    const result = pipeline.query(item.query);
 
    const relevance = calculateRetrievalRelevance(
      result.chunks,
      new Set(item.relevantChunkIds)
    );
 
    totalRelevance += relevance;
    // Correctness typically requires LLM-as-judge evaluation
  }
 
  return {
    avgRelevance: totalRelevance / dataset.queries.length,
    avgCorrectness: totalCorrectness / dataset.queries.length,
  };
}

Build evaluation datasets from real user questions. Start with 50-100 query-answer pairs, manually verify the expected answers, and run evaluations after every pipeline change. Without this feedback loop, you're optimizing blindly.

Key Takeaways

Production RAG pipelines succeed or fail on three axes: chunking quality, retrieval precision, and systematic evaluation. Chunk documents along semantic boundaries—section headers, paragraph breaks, and logical units—rather than fixed character counts. Combine dense vector search with sparse BM25 retrieval using Reciprocal Rank Fusion to catch both semantic matches and exact keyword hits. Assemble context windows carefully by deduplicating overlapping chunks and grouping by section for coherent reading. Most importantly, build an evaluation dataset from real queries and measure retrieval relevance and answer correctness after every change. The teams that build reliable RAG systems treat it as an information retrieval engineering problem, not a prompt engineering problem.

Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX