466 lines
13 KiB
Markdown
466 lines
13 KiB
Markdown
# Les 15 — 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 16 — MCP servers
|
|
|
|
---
|
|
|
|
## 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. [Wanneer wel/niet RAG](#9-wanneer-welniet-rag)
|
|
|
|
---
|
|
|
|
## 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. 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;
|
|
```
|
|
|
|
---
|
|
|
|
## 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/
|