Zum Inhalt springen

Retrieval-Augmented Generation: RAG-Pipelines, die tragen

Baue effektive RAG-Pipelines mit TypeScript: Chunking, Embeddings, Feintuning der Vektorsuche, Prompt-Konstruktion und Evaluation der Retrieval-Qualität.

5 Min. Lesezeit
Diagramm einer RAG-Pipeline, das zeigt, wie Dokumente die Phasen Chunking, Embedding, Vektorspeicherung, Retrieval und Generierung durch ein LLM durchlaufen

Große Sprachmodelle halluzinieren. Sie erzeugen selbstbewussten, flüssigen Text, der richtig klingt, aber Fakten erfindet, Quellen falsch wiedergibt und verwandte Konzepte durcheinanderbringt. Retrieval-Augmented Generation (RAG) begegnet diesem Problem, indem es die Antworten des Modells in tatsächlich zur Anfragezeit abgerufenen Dokumenten verankert – das Modell generiert Antworten auf Grundlage von Belegen, nicht aus dem Gedächtnis.

Doch RAG-Systeme haben ihre eigenen Fehlerquellen. Schlechtes Chunking verteilt relevanten Kontext auf mehrere Fragmente. Minderwertige Embeddings liefern irrelevante Dokumente zurück. Naive Prompt-Konstruktion sprengt das Kontextfenster. Eine funktionierende RAG-Pipeline zu bauen erfordert sorgfältige Aufmerksamkeit für jede einzelne Stufe.

Die Architektur der RAG-Pipeline

Eine RAG-Pipeline hat zwei Phasen: Indexierung (offline) sowie Retrieval und Generierung (in Echtzeit).

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,
  });
}

Strategien für das Chunking von Dokumenten

Chunking bestimmt die Granularität des Retrievals. Sind die Chunks zu groß, verschwendest du Kontextfenster an irrelevante Inhalte. Sind sie zu klein, verlierst du den Kontext, der zur Beantwortung der Fragen nötig ist.

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 und Indexierung

Embeddings wandeln Text in dichte Vektoren um, die die semantische Bedeutung erfassen. Ähnliche Texte erzeugen Vektoren, die im Embedding-Raum nah beieinanderliegen.

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`
  );
}

Retrieval mit hybrider Suche

Reine Vektorsuche übersieht exakte Stichwort-Treffer. Reine Stichwortsuche übersieht semantische Ähnlichkeit. Kombiniere beide für bessere Ergebnisse.

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-Konstruktion mit Quellenangaben

Der Prompt muss das Modell anweisen, Antworten auf den abgerufenen Kontext zu stützen und Quellen anzugeben. Die Struktur ist entscheidend.

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: die Qualität eines RAG-Systems messen

Eine RAG-Pipeline ist immer nur so gut wie ihr Retrieval. Miss die Qualität von Retrieval und Generierung getrennt voneinander.

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;
}

Die wichtigsten Erkenntnisse

Die Chunking-Strategie bestimmt die Qualität des Retrievals – semantisches Chunking, das die Struktur des Dokuments (Überschriften, Absätze, Abschnitte) respektiert, schlägt fixgrößiges Chunking durchweg, weil es innerhalb jedes Chunks den Kontext bewahrt, der zur Beantwortung von Fragen nötig ist. Hybride Suche, die Vektorähnlichkeit und Stichwort-Matching per Reciprocal Rank Fusion kombiniert, liefert relevantere Ergebnisse als jeder der beiden Ansätze allein, weil sie sowohl semantische Treffer erfasst, die die Stichwortsuche übersieht, als auch exakte Begriffstreffer, die die Embedding-Ähnlichkeit übersieht. Die Prompt-Konstruktion muss das Modell explizit anweisen, Antworten ausschließlich auf den bereitgestellten Kontext zu stützen und Quellen anzugeben, denn ohne diese Einschränkungen vermischt das Modell die abgerufenen Informationen mit seinem parametrischen Wissen und erzeugt Antworten, die fundiert klingen, aber halluzinierte Details enthalten. Bewerte Retrieval und Generierung getrennt anhand von Precision-, Recall- und Faithfulness-Metriken auf einem kuratierten Testdatensatz – eine Pipeline mit schlechter Retrieval-Qualität wird unabhängig vom verwendeten Generierungsmodell niemals gute Antworten liefern.

Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX