# 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/
