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
- Het probleem dat RAG oplost
- Wat is een embedding?
- Vector similarity
- RAG pipeline
- pgvector in Supabase
- Chunking strategieën
- Index pipeline in code
- Query pipeline in code
- 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 betekenis0.8-0.95— sterk gerelateerd0.5-0.8— zwak gerelateerd< 0.5— meestal niet relevant0.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.
Index voor snelle search
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 = 5is 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
- 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/