Files
novi-lessons/Les14-RAG-Embeddings/Les14-Lesstof.md
2026-06-07 10:44:05 +02:00

16 KiB

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 — Agents


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. RAG-tool in een agent
  10. Wanneer wel/niet RAG
  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

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. RAG-tool in een agent

Combo van Les 13 (Agents) + Les 14 (RAG):

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