# Les 14 — Lesstof ## RAG + Embeddings — AI laten antwoorden op basis van eigen documenten **Vak:** AI-Assisted Development **Opleiding:** NOVI Hogeschool Utrecht **Vorige les:** Les 13 — Agents + Cursor + Vercel **Volgende les:** Les 15 — Agents --- ## Inhoud 1. [Het probleem dat RAG oplost](#1-het-probleem-dat-rag-oplost) 2. [Wat is een embedding?](#2-wat-is-een-embedding) 3. [Vector similarity](#3-vector-similarity) 4. [RAG pipeline](#4-rag-pipeline) 5. [pgvector in Supabase](#5-pgvector-in-supabase) 6. [Chunking strategieën](#6-chunking-strategieën) 7. [Index pipeline in code](#7-index-pipeline-in-code) 8. [Query pipeline in code](#8-query-pipeline-in-code) 9. [RAG-tool in een agent](#9-rag-tool-in-een-agent) 10. [Wanneer wel/niet RAG](#10-wanneer-welniet-rag) 11. [Productie-overwegingen](#11-productie-overwegingen) --- ## 1. Het probleem dat RAG oplost AI-modellen weten heel veel — maar niet wat in **jouw** documenten staat. Je interne kennisbank, klantcontracten, productdocumentatie, dat handboek dat vandaag binnenkwam. Twee naïeve aanpakken die niet werken: - **Hele document meesturen in context** — werkt voor 5 pagina's, niet voor 500. Te duur, te traag, past niet in context-window. - **AI zelf laten zoeken op het web** — werkt voor publieke info, niet voor jouw private data. **RAG (Retrieval-Augmented Generation)** is de oplossing. Drie woorden, simpele kern: > Haal de **relevante stukjes** uit je documenten op, geef die aan AI als context, AI antwoordt. Het magische zit in 'relevant'. Niet keyword matching — semantic search. AI vindt stukjes die *over hetzelfde gaan*, ook al gebruik je andere woorden. --- ## 2. Wat is een embedding? Een embedding is een **vector** — een array van ~1536 nummers tussen -1 en 1. Je stopt tekst erin, krijgt vector terug. ``` "De kat zit op de mat" → [0.12, -0.45, 0.88, ...] "Een poes ligt op het tapijt" → [0.14, -0.41, 0.85, ...] "Voetbal in Nederland" → [-0.73, 0.21, -0.32, ...] ``` Magisch: de eerste twee vectors zijn **dichtbij elkaar in de ruimte**. De derde is ver weg. Niet omdat woorden overlappen — omdat **betekenis** vergelijkbaar is. ### Embedding-modellen | Model | Dimensies | Snelheid | Cost | Wanneer | |-------|-----------|----------|------|---------| | `text-embedding-3-small` | 1536 | Snel | $0.02/1M tokens | Default | | `text-embedding-3-large` | 3072 | Trager | $0.13/1M tokens | Betere accuracy | | `nomic-embed-text` | 768 | Snel | Gratis (local) | Privacy, on-device | | `mxbai-embed-large` | 1024 | Middel | Gratis (local) | Open-source alt | Voor dit vak: `text-embedding-3-small`. Goed genoeg voor de meeste apps. ### In AI SDK ```typescript import { embed, embedMany } from "ai"; import { openai } from "@ai-sdk/openai"; const model = openai.textEmbeddingModel("text-embedding-3-small"); // Eén embedding const { embedding } = await embed({ model, value: "De kat zit op de mat", }); // Veel tegelijk (sneller) const { embeddings } = await embedMany({ model, values: ["chunk 1", "chunk 2", "chunk 3"], }); ``` `embedMany` is veel sneller dan een loop met `embed` — minder roundtrips. --- ## 3. Vector similarity 'Dichtbij elkaar' kun je op drie manieren meten: | Metric | Wat | Wanneer | |--------|-----|---------| | **Cosine similarity** | Hoek tussen vectors (-1 tot 1) | Default — werkt op text | | **Dot product** | Sum van producten (na normalize) | Sneller — als al genormaliseerd | | **Euclidean** | Afstand in ruimte | Zelden voor text | Cosine is de standaard voor text. OpenAI-embeddings zijn al genormaliseerd, dus cosine = dot product (mathematisch equivalent). ### Cosine similarity waarden - `1.0` — exact gelijke betekenis - `0.8-0.95` — sterk gerelateerd - `0.5-0.8` — zwak gerelateerd - `< 0.5` — meestal niet relevant - `0.0` — totaal ongerelateerd - `-1.0` — tegenovergesteld (zelden in praktijk) ### In pgvector ```sql -- < => > = cosine distance (1 - cosine similarity) SELECT content, 1 - (embedding <=> '[0.12, ...]'::vector) as similarity FROM chunks ORDER BY embedding <=> '[0.12, ...]'::vector LIMIT 5; ``` `<=>` is cosine distance — kleinste eerst betekent meest similar eerst. Andere operators in pgvector: - `<->` — Euclidean - `<#>` — negative dot product (sneller als al genormaliseerd) --- ## 4. RAG pipeline RAG splits in twee pipelines: **index time** (eenmalig per document) en **query time** (per vraag). ### Index time ``` PDF / docs ↓ Parse + chunk (~500 tokens per chunk) ↓ Embed elke chunk ↓ Store: chunk_text + embedding in pgvector ``` Doe je één keer per document, of bij elke document-update. ### Query time ``` User vraag ↓ Embed vraag (zelfde model als index!) ↓ Cosine similarity search → top-k chunks (k=3-10) ↓ Geef chunks als context aan LLM ↓ LLM antwoordt op basis van context ``` **Belangrijkste inzicht:** LLM ziet nooit de hele DB. Alleen ~5 relevante chunks per vraag. ### Context prompt template ```typescript const prompt = `Beantwoord de vraag op basis van deze context. Als de context geen antwoord bevat, zeg dat eerlijk. CONTEXT: ${chunks.map((c) => c.content).join("\n\n---\n\n")} VRAAG: ${question} ANTWOORD:`; ``` Drie ingrediënten: instructie, context, vraag. Variëren werkt, maar deze structuur is robuust. --- ## 5. pgvector in Supabase Postgres extension die `vector` datatype toevoegt. In Supabase: één click activeren, schaalbaar tot miljoenen rows. ### Activeren ```sql create extension if not exists vector; ``` Of via Supabase Dashboard → Database → Extensions → enable `vector`. ### Schema ```sql create table chunks ( id bigserial primary key, source text not null, -- filename page int, -- voor PDF page reference content text not null, -- de chunk-tekst embedding vector(1536), -- de vector created_at timestamp default now() ); ``` `vector(1536)` — dimensie moet matchen met embedding model. `text-embedding-3-small` = 1536. ### Index voor snelle search ```sql create index on chunks using hnsw (embedding vector_cosine_ops); ``` **HNSW** = Hierarchical Navigable Small Worlds. Approximate nearest neighbor. Tot 100x sneller dan exact search bij grote tabellen. Trade-off: HNSW is *approximate* — niet 100% accurate maar 99%+. Voor RAG: prima. Voor kleinere tabellen (<10k rows) kun je het index weglaten — exact search is dan al snel. ### Match function (Postgres RPC) Best practice: definieer een SQL function die je vanuit JS aanroept: ```sql create or replace function match_chunks( query_embedding vector(1536), match_count int default 5, filter_source text default null ) returns table ( id bigint, content text, source text, page int, similarity float ) language sql stable as $$ select chunks.id, chunks.content, chunks.source, chunks.page, 1 - (chunks.embedding <=> query_embedding) as similarity from chunks where filter_source is null or chunks.source = filter_source order by chunks.embedding <=> query_embedding limit match_count; $$; ``` Voordeel: één call vanuit JS, optionele filters (bijv. per document). --- ## 6. Chunking strategieën Hoe je documenten opknipt heeft enorme impact op RAG-kwaliteit. ### Drie aanpakken | Strategie | Hoe | Wanneer | |-----------|-----|---------| | **Fixed size** | 500 tokens per chunk, 50 overlap | Default, simpelste | | **Recursive** | Splits op `\n\n` → `\n` → `.` → ` ` | Behoudt structuur | | **Semantic** | Embed zinnen, group similar | Beste kwaliteit | ### Wat is een goede chunk - **~200-500 tokens** (~1000-2500 chars) - **Overlap 10-15%** — info aan grenzen niet verliezen - **Houdt semantisch geheel** — niet midden in zin knippen Te kleine chunks → fragmentatie, AI mist context Te grote chunks → ruis verdunt relevant info ### Fixed size in JS ```typescript function chunkText(text: string, size = 500, overlap = 50): string[] { const chunks: string[] = []; let i = 0; while (i < text.length) { chunks.push(text.slice(i, i + size)); i += size - overlap; } return chunks; } ``` Voor productie: gebruik `langchain` of `llamaindex` recursive splitter — handelt edge cases beter. ### Contextual retrieval (Anthropic, 2024) Nieuwe techniek: voor elke chunk genereert een LLM een kort 'context-stukje' dat de chunk in context van het document plaatst. Embed dat samen met de chunk. Resulteert in 30-50% betere retrieval. Voor productie de moeite, voor demo overkill. --- ## 7. Index pipeline in code Volledig pattern voor PDF-upload → chunks → embeddings → DB: ```typescript // app/api/index/route.ts import { extractText } from "unpdf"; import { embedMany } from "ai"; import { openai } from "@ai-sdk/openai"; import { createClient } from "@supabase/supabase-js"; const supabase = createClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! ); export async function POST(req: Request) { const formData = await req.formData(); const file = formData.get("file") as File; const buffer = new Uint8Array(await file.arrayBuffer()); // 1. Parse PDF const { text } = await extractText(buffer, { mergePages: true }); // 2. Chunk const chunks = chunkText(text, 500, 50); // 3. Embed all chunks const { embeddings } = await embedMany({ model: openai.textEmbeddingModel("text-embedding-3-small"), values: chunks, }); // 4. Insert in Supabase const { error } = await supabase.from("chunks").insert( chunks.map((content, i) => ({ source: file.name, content, embedding: embeddings[i], })) ); if (error) return Response.json({ error: error.message }, { status: 500 }); return Response.json({ chunks: chunks.length }); } ``` Belangrijk: `embedMany` doet alle chunks in één API-call. Veel sneller dan een loop. ### Kosten-indicatie `text-embedding-3-small` = $0.02 per 1M tokens. - 200-pagina PDF ≈ 80k tokens ≈ 160 chunks van 500 tokens - Embeddings: ~$0.0016 (minder dan een cent) Indexing is goedkoop. Doe je één keer per document. --- ## 8. Query pipeline in code ```typescript // app/api/ask/route.ts import { embed, generateText } from "ai"; import { openai } from "@ai-sdk/openai"; export async function POST(req: Request) { const { question } = await req.json(); // 1. Embed de vraag const { embedding } = await embed({ model: openai.textEmbeddingModel("text-embedding-3-small"), value: question, }); // 2. Similarity search const { data: chunks } = await supabase.rpc("match_chunks", { query_embedding: embedding, match_count: 5, }); if (!chunks?.length) { return Response.json({ answer: "Geen relevante info gevonden." }); } // 3. Build context const context = chunks .map((c, i) => `[Source ${i + 1}: ${c.source}, page ${c.page}]\n${c.content}`) .join("\n\n---\n\n"); // 4. Generate answer const { text } = await generateText({ model: openai("gpt-4o-mini"), prompt: `Beantwoord de vraag op basis van deze context. Als geen antwoord in context staat, zeg dat. CONTEXT: ${context} VRAAG: ${question} ANTWOORD:`, }); return Response.json({ answer: text, sources: chunks }); } ``` Tips: - Geef bronnen mee in output — gebruiker kan verifiëren - `top-k = 5` is goede default, experimenteer per case - Stream de output (`streamText`) voor betere UX --- ## 9. RAG-tool in een agent Combo van Les 13 (Agents) + Les 14 (RAG): ```typescript import { ToolLoopAgent, tool, stepCountIs } from "ai"; import { embed } from "ai"; import { z } from "zod"; const ragSearch = tool({ description: "Zoek in geüploade documenten op basis van semantic similarity.", inputSchema: z.object({ query: z.string().describe("Wat je wilt vinden"), }), execute: async ({ query }) => { const { embedding } = await embed({ model: openai.textEmbeddingModel("text-embedding-3-small"), value: query, }); const { data } = await supabase.rpc("match_chunks", { query_embedding: embedding, match_count: 5, }); return data; }, }); const docAgent = new ToolLoopAgent({ model: openai("gpt-4o"), system: `Je beantwoordt vragen over documenten. Werkwijze: 1. Zoek met ragSearch voor relevante info 2. Lees de resultaten 3. Eventueel: tweede ragSearch met andere query voor meer context 4. Antwoord met bronvermeldingen.`, tools: { ragSearch }, stopWhen: stepCountIs(10), }); const result = await docAgent.generate({ prompt: "Vergelijk de jazz- en rock-headliners op Polderfest 2027.", }); ``` Wat krijg je extra t.o.v. simple RAG: - **Multi-hop reasoning** — agent kan 2-3 searches doen voor complexe vragen - **Query rewriting** — agent kan zijn eigen zoek-query verbeteren - **Iterative refinement** — eerste resultaat niet goed? Probeer andere query Trade-off: duurder + langzamer dan single-call RAG. Voor open-ended vragen wel waard. --- ## 10. Wanneer wel/niet RAG ### Wanneer WEL - Veel documenten (>50 pagina's totaal) - Documenten veranderen vaak - Semantic search nodig (niet alleen exact match) - Privacy: data moet in jouw DB blijven - Bronvermelding is belangrijk ### Wanneer NIET - Klein document (<10 pagina's) — gewoon in system prompt - Exacte data (prijzen, IDs) — gebruik tool-calls / SQL - Structured data (tabellen) — SQL is beter - 1 keer per dag bevraagd — overhead niet de moeite - Code-base — gebruik grep / tree-sitter, geen embeddings ### Hybrid is vaak best In productie combineren teams: - **Semantic search** (RAG) voor concept-vragen - **Keyword/full-text** voor exacte termen - **SQL filter** voor metadata (datum, categorie) ```sql -- Voorbeeld hybrid in Supabase select content from chunks where source = 'product-handleiding.pdf' -- metadata filter and ( content ilike '%firmware%' -- keyword or embedding <=> $1 < 0.5 -- semantic ) order by embedding <=> $1 limit 5; ``` --- ## 11. Productie-overwegingen ### Re-ranking Top-5 van vector search is niet altijd de beste top-5 voor de LLM. **Re-ranking** = stap erna: 1. Vector search → 20 candidates 2. Re-rank model (Cohere, BGE) → top 5 echt 3. Aan LLM geven Cohere `rerank-3` is de populairste, kost ~$1/1k searches. ### Evaluation Hoe weet je dat je RAG goed werkt? Eval-suite: - 20 vragen + verwachte antwoorden - Run RAG, vergelijk output - LLM-as-judge: tweede model beoordeelt kwaliteit - Track over tijd: retrieval-accuracy, generation-quality Tools: Ragas, LangSmith, Vercel's eval helpers. ### Updates Wat als een document verandert? - **Delete + re-index** — simpel maar duur als alles change - **Incremental** — alleen veranderde chunks her-embedden - **Versioning** — oude versies bewaren met `version` column ### Cost Embeddings zijn goedkoop. Vergelijk: - 1000 documenten van 100 pagina's: - Indexing: ~$1 (eenmalig) - Storage in Supabase: ~$5/maand - Query: $0.0001 per vraag (embed) + LLM cost LLM-cost domineert. Optimaliseer daar. ### Privacy - Embeddings zijn NIET reversible — je kunt geen tekst terughalen uit een vector - Maar: vergelijkbare zinnen produceren vergelijkbare vectors — niet 100% anoniem - Voor gevoelige data: lokaal embedding model (Ollama, nomic-embed-text) --- ## Bronnen - **AI SDK Embeddings:** https://ai-sdk.dev/docs/ai-sdk-core/embeddings - **OpenAI Embeddings guide:** https://platform.openai.com/docs/guides/embeddings - **pgvector GitHub:** https://github.com/pgvector/pgvector - **Supabase pgvector docs:** https://supabase.com/docs/guides/database/extensions/pgvector - **Supabase vector search guide:** https://supabase.com/docs/guides/ai/vector-columns - **Anthropic Contextual Retrieval:** https://www.anthropic.com/news/contextual-retrieval - **unpdf (PDF parsing):** https://github.com/unjs/unpdf - **Cohere rerank:** https://docs.cohere.com/docs/reranking - **LlamaIndex RAG concepts:** https://docs.llamaindex.ai/en/stable/getting_started/concepts/ - **Ragas (RAG evaluation):** https://docs.ragas.io/