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.

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).
// ❌ 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);
}// ✅ 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.
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.
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.
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.
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.
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.


