Files
novi-lessons/Les14-RAG-Embeddings/Les14-Lesstof.md
2026-06-03 16:58:25 +02:00

570 lines
16 KiB
Markdown

# 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 — Cursor + Vercel deploy
---
## 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/