Files
novi-lessons/Les16-RAG-Embeddings/Les16-Lesstof.md
2026-06-17 07:15:32 +02:00

13 KiB

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
  2. Wat is een embedding?
  3. Vector similarity
  4. RAG pipeline
  5. pgvector in Supabase
  6. Chunking strategieën
  7. Index pipeline in code
  8. Query pipeline in code
  9. Wanneer wel/niet 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

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

-- < => > = 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

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

create extension if not exists vector;

Of via Supabase Dashboard → Database → Extensions → enable vector.

Schema

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.

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:

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

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:

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

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