# RAG naar productie brengen [[TOC]] ## Van prototype naar productie Een werkend RAG-prototype is een begin. Productie vereist meer: - **Betrouwbaarheid**: het systeem werkt ook als een component faalt. - **Observability**: je kunt zien wat er misgaat en waarom. - **Efficiency**: kosten en latentie blijven acceptabel bij schaal. - **Maintainability**: je kunt documenten bijwerken zonder downtime. :::info title="Productie-checklist" Voordat je live gaat, regel je: een semantische cache, health checks, monitoring, incremental updates, rate limiting en een fallback-strategie. Mist er een, dan voel je dat pas op het slechtste moment. ::: ## Semantische cache Sla antwoorden op voor gelijksoortige vragen om kosten en latentie te verlagen. De truc is dat je niet op exacte tekst matcht, maar op de embedding van de vraag, zodat ook lichte herformuleringen een cache-hit geven. ```typescript interface CacheEntry { question: string; question_embedding: number[]; answer: string; retrieved_doc_ids: string[]; created_at: string; ttl_seconds: number; } class SemanticCache { private entries: CacheEntry[] = []; private readonly similarityThreshold: number; constructor(similarityThreshold = 0.95) { this.similarityThreshold = similarityThreshold; } async get(question: string, embedding: number[]): Promise { const now = Date.now(); const valid = this.entries.filter(e => { const age = (now - new Date(e.created_at).getTime()) / 1000; return age < e.ttl_seconds; }); for (const entry of valid) { const similarity = cosineSimilarity(embedding, entry.question_embedding); if (similarity >= this.similarityThreshold) { return entry.answer; } } return null; } set(question: string, embedding: number[], answer: string, ttlSeconds = 3600): void { this.entries.push({ question, question_embedding: embedding, answer, retrieved_doc_ids: [], created_at: new Date().toISOString(), ttl_seconds: ttlSeconds, }); if (this.entries.length > 10000) { this.entries.splice(0, 1000); } } } ``` :::warn title="Kies je drempel bewust" Een te lage similariteitsdrempel (bijvoorbeeld 0.85) levert verkeerde antwoorden op, omdat verschillende vragen dan toch een cache-hit krijgen. Begin streng (0.95 of hoger) en verlaag pas als je in de logs ziet dat echt gelijksoortige vragen worden gemist. ::: ## Incremental indexering Werk documenten bij zonder de hele index opnieuw op te bouwen. Verwijder eerst de oude chunks van een document op basis van metadata, en upsert daarna de nieuwe. ```typescript class IncrementalIndexer { async updateDocument(docId: string, newContent: string, source: string): Promise { await vectorDB.deleteByMetadata({ source_document_id: docId }); const chunks = recursiveChunk(newContent, 400, 50); const enriched = enrichChunks(chunks, source).map(c => ({ ...c, metadata: { ...c.metadata, source_document_id: docId }, })); const embeddings = await embedBatch(enriched.map(c => c.content)); await vectorDB.upsertBatch( enriched.map((c, i) => ({ id: c.id, embedding: embeddings[i], metadata: c.metadata, content: c.content })) ); } async deleteDocument(docId: string): Promise { await vectorDB.deleteByMetadata({ source_document_id: docId }); } } ``` ## Monitoring en observability Meet per query wat je kost, hoe lang het duurt en of de cache raakt. Zonder deze cijfers stuur je blind. ```typescript interface RAGQueryMetrics { query_id: string; question_length: number; retrieval_count: number; retrieval_latency_ms: number; generation_latency_ms: number; total_latency_ms: number; total_tokens: number; estimated_cost_usd: number; cache_hit: boolean; timestamp: string; } class RAGMonitor { async recordQuery(metrics: RAGQueryMetrics): Promise { await metricsStore.append(metrics); if (metrics.total_tokens > 10000) { console.warn(`Hoge token-usage voor query ${metrics.query_id}: ${metrics.total_tokens}`); } } async getDailyStats(): Promise<{ avg_latency: number; total_cost: number; cache_hit_rate: number }> { const today = new Date().toISOString().slice(0, 10); const todayMetrics = await metricsStore.query({ date: today }); return { avg_latency: average(todayMetrics.map(m => m.total_latency_ms)), total_cost: sum(todayMetrics.map(m => m.estimated_cost_usd)), cache_hit_rate: todayMetrics.filter(m => m.cache_hit).length / todayMetrics.length, }; } } ``` ## Fallback-strategie Als retrieval faalt, val dan terug op een direct LLM-antwoord en wees eerlijk tegen de gebruiker dat de kennisbank even niet beschikbaar is. ```typescript async function ragWithFallback(question: string): Promise { try { return await primaryRAG(question); } catch (retrievalError) { console.error("Retrieval mislukt, fallback naar directe LLM:", retrievalError); try { const response = await anthropic.messages.create({ model: "claude-haiku-4-5", max_tokens: 512, system: "Je bent een assistent. Beantwoord de vraag op basis van je kennis en vermeld duidelijk dat je geen toegang hebt tot de kennisbank.", messages: [{ role: "user", content: question }], }); return response.content[0].type === "text" ? response.content[0].text : "Tijdelijk niet beschikbaar"; } catch { return "De kennisbank is tijdelijk niet beschikbaar. Probeer het later opnieuw."; } } } ``` ## Health checks Een health-endpoint dat elke afhankelijkheid los pingt, maakt het meteen duidelijk welke component een storing veroorzaakt. ```typescript app.get("/health", async (req, res) => { const checks = await Promise.allSettled([ vectorDB.ping(), embedder.ping(), llm.ping(), ]); const names = ["vectordb", "embedder", "llm"]; const results = checks.map((check, i) => ({ name: names[i], status: check.status === "fulfilled" ? "ok" : "error", error: check.status === "rejected" ? check.reason?.message : undefined, })); const allOk = results.every(r => r.status === "ok"); res.status(allOk ? 200 : 503).json({ status: allOk ? "healthy" : "degraded", checks: results }); }); ``` :::tip title="Eerst meten, dan optimaliseren" Zet monitoring live voordat je gaat tunen. De grootste winst zit meestal in een betere cache-hit-rate en kortere prompts, niet in een duurder model. Pas je aanpassingen aan op wat de cijfers laten zien. ::: :::faq ### Hoe groot moet de semantische cache zijn? Begin met 5000 tot 10000 entries en een TTL van 1 tot 24 uur, afhankelijk van hoe snel je documenten veranderen. Houd de cache-hit-rate in de gaten: zit die onder de 20 procent, dan is de cache niet effectief en kun je de drempel of TTL bijstellen. ### Hoe doe ik incremental updates bij een grote documentset? Gebruik een webhook of een polling-systeem dat wijzigingen in de brondocumenten detecteert. Verwerk updates asynchroon in een job-queue, zodat de gebruikersinterface niet blokkeert tijdens het opnieuw embedden. ### Welke kosten zijn typisch voor een RAG-systeem? De grootste kostenpost is bijna altijd de LLM, niet het embedden. Embeddings zijn goedkoop: text-embedding-3-small kost ongeveer 0,02 dollar per miljoen tokens en text-embedding-3-large ongeveer 0,13 dollar per miljoen tokens (stand juni 2026). Generatie ligt veel hoger, grofweg 1 tot 15 dollar per miljoen tokens afhankelijk van het model: claude-haiku-4-5 zit rond 1 dollar input en 5 dollar output per miljoen tokens, zwaardere modellen liggen daarboven. Reken zelf na op de actuele prijslijst, want tarieven veranderen. ### Moet ik de vectordatabase cachen? Nee. Vectordatabases zijn al geoptimaliseerd voor snelle zoekopdrachten. Cache liever het eindresultaat (het antwoord) in de semantische cache, niet de losse vectorzoekopdracht. ### Wat doe ik als retrieval slechte resultaten geeft in productie? Log de opgehaalde chunks en hun similariteitsscores per query. Vaak ligt het aan te grote of te kleine chunks, ontbrekende metadata-filters of een te lage retrieval-count. Stel een evaluatieset van echte vragen samen en meet daarop voor en na elke wijziging. ### Hoe voorkom ik dat een storing bij de LLM-provider mijn hele systeem platlegt? Combineer timeouts, retries met backoff en de fallback hierboven. Overweeg een tweede provider of een goedkoper model als noodroute, en geef de gebruiker altijd een nette melding in plaats van een lege foutpagina. :::