fix: les
This commit is contained in:
465
Les16-RAG-Embeddings/Les16-Lesstof.md
Normal file
465
Les16-RAG-Embeddings/Les16-Lesstof.md
Normal file
@@ -0,0 +1,465 @@
|
||||
# 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/
|
||||
Reference in New Issue
Block a user