Embeddings how-to: bouw je eigen AI-kennisbank voor Claude, ChatGPT en Llama

Liever nieuwe posts als video? Abonneer op het aiMattie YouTube-kanaal — of bekijk alle video's in de playlist.

TL;DR

  • Wat: Embeddings zijn de techniek waarmee je jouw eigen documenten doorzoekbaar maakt voor Claude, ChatGPT of een lokale Llama — op betekenis, niet op trefwoord.
  • Waarom relevant: Zonder embeddings blijft een AI-assistent een vreemde in je bedrijf. Mét embeddings kent hij je offertes, contracten, handleidingen en mailwisselingen.
  • Wat je ermee kunt: Deze how-to bouwt de complete pipeline: model kiezen, chunken, opslaan in een vectordatabase, zoeken, en aankoppelen aan Claude (via MCP) of ChatGPT (via function calling). Inclusief de volledig lokale variant met Llama.

Ik krijg deze vraag bijna wekelijks: "Kan ik Claude of ChatGPT niet gewoon al onze documenten geven?" Het korte antwoord is nee — niet zomaar. Het langere antwoord is dat je een laag ertussen bouwt die embeddings heet. Dat is minder ingewikkeld dan het klinkt, en het is de enige bouwsteen die je echt zelf in handen moet hebben. Modellen wissel je later moeiteloos om; je kennisbank blijft.

In dit artikel leg ik uit wat embeddings zijn, welke opties er zijn (inclusief lokaal draaien met Llama), en bouw ik stap voor stap de complete pipeline. Met code die werkt.

Wat een embedding eigenlijk is

Een embedding is een stuk tekst omgezet naar een rij getallen. Meestal een paar honderd tot een paar duizend. Die rij noemen we een vector.

Het bijzondere zit hem in wat die getallen betekenen: teksten die inhoudelijk op elkaar lijken, krijgen vectoren die dicht bij elkaar liggen. "Hoe zeg ik mijn abonnement op?" en "Ik wil mijn contract beëindigen" delen geen enkel woord, maar hun vectoren liggen praktisch tegen elkaar aan. Dát is het hele punt: zoeken op betekenis in plaats van op letters.

Een klassieke zoekfunctie (Ctrl+F, of een database-LIKE) vindt alleen wat er letterlijk staat. Een embedding-zoekopdracht vindt wat er wordt bedoeld.

Hoe dat rekenkundig werkt: je berekent de cosinusgelijkenis tussen twee vectoren — een getal tussen -1 en 1, waarbij 1 betekent "identiek van betekenis". In de praktijk hoef je dat niet zelf te programmeren; elke vectordatabase doet dit voor je.

Waarom je dit nodig hebt

Claude en ChatGPT hebben een contextvenster — de hoeveelheid tekst die je in één gesprek kunt meesturen. Dat venster is inmiddels enorm (bij Claude Opus 5 zo'n 1 miljoen tokens), maar drie problemen blijven:

  1. Kosten. Al je documenten élke vraag opnieuw meesturen betaal je élke vraag opnieuw.
  2. Kwaliteit. Een model dat 400 documenten krijgt terwijl er 3 relevant zijn, presteert slechter dan een model dat alleen die 3 krijgt.
  3. Volume. Je bedrijfsarchief past sowieso niet in enig contextvenster.

De oplossing heet RAG — Retrieval Augmented Generation. Vertaald: eerst zoeken, dan pas het model laten praten. Embeddings zijn de "retrieval" in dat woord.

De vier bouwstenen

Elke RAG-opstelling, hoe groot ook, bestaat uit dezelfde vier onderdelen:

  1. Het embeddingmodel — zet tekst om in vectoren. Dit is een ander model dan Claude of ChatGPT.
  2. De vectordatabase — slaat die vectoren op en zoekt razendsnel de dichtstbijzijnde.
  3. De retrieval-laag — knipt documenten op, haalt de juiste stukken op, en sorteert ze.
  4. Het taalmodel — Claude, ChatGPT of Llama, dat op basis van die stukken een antwoord formuleert.

Belangrijk om te begrijpen: Claude en ChatGPT zitten alleen in stap 4. Ze maken de embeddings niet. Anthropic heeft zelfs helemaal geen eigen embedding-endpoint — die verwijzen door naar Voyage AI. OpenAI heeft er wél een, maar die staat los van het chatmodel.

Dat is goed nieuws: je kunt dus prima met OpenAI-embeddings werken en Claude als schrijver gebruiken. Of andersom. Of alles lokaal.

Welk embeddingmodel kies je?

De praktische opties, met wat ik ervan vind:

OpenAI text-embedding-3-small — de veilige standaard. 1536 dimensies, ongeveer $0,02 per miljoen tokens, goed genoeg voor 90% van de gevallen. Ondersteunt Matryoshka: je kunt de vector inkorten naar bijvoorbeeld 512 dimensies met beperkt kwaliteitsverlies, wat je opslagkosten flink drukt. Nederlands gaat prima.

OpenAI text-embedding-3-large — 3072 dimensies, ongeveer $0,13 per miljoen tokens. Merkbaar beter bij subtiele verschillen, maar duurder in opslag en zoektijd.

Voyage AI (voyage-3-large) — dit is wat Anthropic zelf aanraadt voor Claude-gebruikers. Scoort bovenaan op retrieval-benchmarks en heeft domeinvarianten voor finance en legal. Prijs richting $0,18 per miljoen tokens. De moeite waard als retrieval-kwaliteit je bottleneck is.

Cohere Embed (multilingual) — sterk als je met meerdere talen door elkaar werkt. Nederlands, Engels en Duits in één index zonder problemen.

Open source: bge-m3, multilingual-e5-large, nomic-embed-text — draaien lokaal, kosten nul per token, en zijn goed genoeg. bge-m3 is mijn favoriet voor Nederlandse content: 8192 tokens context, meertalig, en levert dense én sparse vectoren tegelijk (handig voor hybride zoeken, daarover zo meer).

Mijn advies: begin met text-embedding-3-small als je snel wilt. Kies bge-m3 via Ollama als je data het pand niet uit mag. Stap over naar Voyage als je merkt dat je zoekresultaten tekortschieten.

Eén regel die je moet onthouden

Een vector uit model A en een vector uit model B zijn onvergelijkbaar. Wissel je van embeddingmodel, dan moet je je hele archief opnieuw embedden. Kies dus bewust, en bewaar altijd de ruwe tekst naast de vector — dan is opnieuw indexeren een kwestie van een script laten lopen in plaats van documenten opnieuw verzamelen.

Welke database?

  • pgvector — een extensie op PostgreSQL. Als je al Postgres draait: gewoon doen. Je houdt je metadata, je rechten en je backups op één plek. Tot enkele miljoenen vectoren prima.
  • Qdrant — een echte vectordatabase, in Rust geschreven, draait in één Docker-container. Snel, goed filteren op metadata, ingebouwde hybride zoekfunctie. Mijn standaardkeuze voor nieuwe projecten.
  • Chroma — het makkelijkst om mee te beginnen, prima voor prototypes en kleine collecties.
  • sqlite-vec — vectorzoeken in een SQLite-bestand. Ideaal voor desktop-apps of iets wat op één machine moet draaien zonder server.

Grote namen als Pinecone en Weaviate zijn ook goed, maar voor de meeste MKB-schaal (een paar honderdduizend documenten) betaal je daar voor capaciteit die je niet gebruikt.

De volledige pipeline, stap voor stap

Ik bouw hieronder een werkende opstelling met Qdrant als database en TypeScript als taal. De aanpak is identiek in Python.

Stap 1: de database starten

# docker-compose.yml
name: kennisbank
services:
  qdrant:
    image: qdrant/qdrant:latest
    container_name: kennisbank-qdrant
    ports:
      - "6333:6333"
    volumes:
      - ./qdrant_storage:/qdrant/storage
    restart: unless-stopped
docker compose up -d

Klaar. Je hebt nu een vectordatabase op http://localhost:6333.

Stap 2: documenten opknippen (chunking)

Dit is de stap die het meeste verschil maakt in kwaliteit, en die de meeste mensen overslaan. Je embedt geen heel document — je embedt stukken van een paar honderd woorden.

Waarom: een vector is een gemiddelde van alles wat erin zit. Embed je een contract van 40 pagina's als één vector, dan krijg je een vector die "vaag over contracten" betekent en nergens goed op matcht.

Vuistregels die bij mij werken:

  • 400 tot 800 tokens per chunk (ruwweg 300–600 Nederlandse woorden).
  • 10–15% overlap tussen chunks, zodat een zin die op de grens valt niet verloren gaat.
  • Knip op natuurlijke grenzen — koppen, alinea's, paragraafnummers. Niet middenin een zin.
  • Zet de kop van het hoofdstuk in elke chunk. Een chunk die begint met "Artikel 7.2 — Opzegtermijn" vindt de zoekmachine veel beter dan losse tekst.
// chunk.ts
export interface Chunk {
  text: string;
  source: string;
  heading: string;
}

/** Knip markdown op koppen, dan op grootte, met overlap. */
export function chunkMarkdown(doc: string, source: string, maxChars = 2400, overlap = 300): Chunk[] {
  const sections = doc.split(/\n(?=#{1,3}\s)/);
  const chunks: Chunk[] = [];

  for (const section of sections) {
    const heading = section.match(/^#{1,3}\s+(.+)$/m)?.[1] ?? source;

    if (section.length <= maxChars) {
      chunks.push({ text: section.trim(), source, heading });
      continue;
    }

    for (let i = 0; i < section.length; i += maxChars - overlap) {
      const slice = section.slice(i, i + maxChars).trim();
      if (slice.length > 100) {
        // De kop meegeven aan elk deel, anders verliest deel 2 zijn context.
        chunks.push({ text: `${heading}\n\n${slice}`, source, heading });
      }
    }
  }
  return chunks;
}

Stap 3: embedden en opslaan

// ingest.ts
import OpenAI from "openai";
import { QdrantClient } from "@qdrant/js-client-rest";
import { chunkMarkdown } from "./chunk.js";
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";

const openai = new OpenAI();
const qdrant = new QdrantClient({ url: "http://localhost:6333" });

const COLLECTION = "kennisbank";
const MODEL = "text-embedding-3-small";
const DIM = 1536;

async function embed(texts: string[]): Promise<number[][]> {
  // Batch je aanvragen: 100 chunks per call scheelt aanzienlijk in doorlooptijd.
  const res = await openai.embeddings.create({ model: MODEL, input: texts });
  return res.data.map((d) => d.embedding);
}

async function main() {
  await qdrant.createCollection(COLLECTION, {
    vectors: { size: DIM, distance: "Cosine" },
  }).catch(() => { /* bestaat al */ });

  const dir = "./documenten";
  const chunks = readdirSync(dir)
    .filter((f) => f.endsWith(".md"))
    .flatMap((f) => chunkMarkdown(readFileSync(join(dir, f), "utf-8"), f));

  console.log(`${chunks.length} chunks te embedden`);

  for (let i = 0; i < chunks.length; i += 100) {
    const batch = chunks.slice(i, i + 100);
    const vectors = await embed(batch.map((c) => c.text));

    await qdrant.upsert(COLLECTION, {
      wait: true,
      points: batch.map((c, j) => ({
        id: i + j,
        vector: vectors[j],
        // Bewaar de ruwe tekst: zonder payload heb je alleen getallen.
        payload: { text: c.text, source: c.source, heading: c.heading },
      })),
    });
    console.log(`  ${i + batch.length}/${chunks.length}`);
  }
}

main();

Stap 4: zoeken

// search.ts
import OpenAI from "openai";
import { QdrantClient } from "@qdrant/js-client-rest";

const openai = new OpenAI();
const qdrant = new QdrantClient({ url: "http://localhost:6333" });

export interface Hit { text: string; source: string; score: number }

export async function search(vraag: string, limit = 5): Promise<Hit[]> {
  const { data } = await openai.embeddings.create({
    model: "text-embedding-3-small",
    input: vraag,
  });

  const results = await qdrant.search("kennisbank", {
    vector: data[0].embedding,
    limit,
    with_payload: true,
    // Zwakke treffers weglaten is beter dan ze meesturen: ze leiden het model af.
    score_threshold: 0.3,
  });

  return results.map((r) => ({
    text: String(r.payload?.text ?? ""),
    source: String(r.payload?.source ?? ""),
    score: r.score,
  }));
}

Stap 5: het antwoord laten schrijven door Claude

// answer.ts
import Anthropic from "@anthropic-ai/sdk";
import { search } from "./search.js";

const client = new Anthropic();

export async function beantwoord(vraag: string): Promise<string> {
  const hits = await search(vraag, 6);

  const context = hits
    .map((h, i) => `[${i + 1}] bron: ${h.source}\n${h.text}`)
    .join("\n\n---\n\n");

  const response = await client.messages.create({
    model: "claude-opus-5",
    max_tokens: 16000,
    system:
      "Je beantwoordt vragen uitsluitend op basis van de meegeleverde documenten. " +
      "Verwijs naar bronnen met [nummer]. Weet je het niet uit de documenten, " +
      "zeg dat dan expliciet in plaats van te gokken.",
    messages: [
      { role: "user", content: `Documenten:\n\n${context}\n\nVraag: ${vraag}` },
    ],
  });

  return response.content
    .filter((b) => b.type === "text")
    .map((b) => (b as { text: string }).text)
    .join("");
}

Dat is de complete pipeline. Vijf bestanden, geen framework.

De volledig lokale variant met Llama

Wil je dat je data het pand niet verlaat — bij medische dossiers, personeelsinformatie of juridische stukken vaak geen keuze maar een eis — dan draai je alles zelf. Met Ollama is dat bijna even weinig werk.

# Installeren en modellen ophalen
curl -fsSL https://ollama.com/install.sh | sh

ollama pull bge-m3           # het embeddingmodel (1024 dimensies)
ollama pull llama3.3:70b     # het taalmodel (of llama3.2:3b op een lichte machine)

Embeddings maken:

// embed-lokaal.ts
export async function embedLokaal(texts: string[]): Promise<number[][]> {
  const out: number[][] = [];
  for (const text of texts) {
    const res = await fetch("http://localhost:11434/api/embed", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ model: "bge-m3", input: text }),
    });
    const json = await res.json();
    out.push(json.embeddings[0]);
  }
  return out;
}

Let op: bge-m3 levert 1024 dimensies, geen 1536. Zet DIM in je ingest-script dus om, en maak een nieuwe collectie aan — een bestaande collectie accepteert geen andere vectorgrootte.

Het antwoord laten schrijven gaat via hetzelfde Ollama-endpoint:

curl http://localhost:11434/api/chat -d '{
  "model": "llama3.3:70b",
  "messages": [
    {"role": "system", "content": "Antwoord alleen op basis van de documenten."},
    {"role": "user", "content": "Documenten: ...\n\nVraag: ..."}
  ],
  "stream": false
}'

Wat het kost aan hardware: een 3B-model draait op elke moderne laptop. Voor 8B heb je zo'n 8 GB VRAM nodig, voor 70B in 4-bit kwantisatie zo'n 40 GB — dus twee stevige GPU's of één datacenterkaart. Het embeddingmodel zelf is licht: bge-m3 draait probleemloos op CPU, alleen langzamer.

Eerlijk over de kwaliteit: een lokale Llama van 8B haalt het niet bij Claude Opus 5 of GPT-5 in redeneervermogen. Maar voor "vat deze drie gevonden alinea's samen" — wat RAG in de praktijk is — is het verschil veel kleiner dan bij open vragen. De retrieval doet het zware werk; het model formuleert alleen.

Een hybride opzet werkt vaak het best: embeddings en opslag lokaal (daar zit je data in), en alleen de uiteindelijke formulering naar een cloudmodel, met alleen de gevonden fragmenten in de aanvraag.

Aankoppelen aan Claude: MCP

Tot nu toe riepen we Claude aan vanuit onze eigen code. Andersom kan ook: je kennisbank beschikbaar maken binnen Claude Desktop of Claude Code, zodat je gewoon in het chatvenster vragen stelt en Claude zelf besluit om te zoeken. Dat gaat via MCP, het Model Context Protocol.

// mcp-server.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { search } from "./search.js";

const server = new McpServer({ name: "kennisbank", version: "1.0.0" });

server.tool(
  "zoek_kennisbank",
  "Doorzoekt de interne documenten van het bedrijf op betekenis. " +
    "Gebruik dit bij vragen over eigen contracten, procedures of handleidingen.",
  { vraag: z.string(), aantal: z.number().optional() },
  async ({ vraag, aantal }) => {
    const hits = await search(vraag, aantal ?? 5);
    return {
      content: [
        {
          type: "text",
          text: hits
            .map((h) => `bron: ${h.source} (score ${h.score.toFixed(2)})\n${h.text}`)
            .join("\n\n---\n\n"),
        },
      ],
    };
  }
);

await server.connect(new StdioServerTransport());

Registreren in Claude Desktop (claude_desktop_config.json):

{
  "mcpServers": {
    "kennisbank": {
      "command": "node",
      "args": ["/pad/naar/dist/mcp-server.js"],
      "env": { "OPENAI_API_KEY": "sk-..." }
    }
  }
}

Vanaf dat moment kun je in Claude vragen: "Wat is onze opzegtermijn richting leveranciers?" en zoekt hij het zelf op. Dit is wat mij betreft de mooiste vorm: geen eigen chat-interface bouwen, geen inlogscherm onderhouden. Je bouwt alleen de zoekfunctie; de interface heb je al.

Aankoppelen aan ChatGPT

Bij ChatGPT heb je twee routes:

Function calling in je eigen applicatie — je definieert zoek_kennisbank als tool, GPT geeft aan dat hij hem wil aanroepen, jij voert de zoekopdracht uit en stuurt het resultaat terug. Hetzelfde patroon als hierboven, maar dan in jouw code.

Een Custom GPT met Actions — je publiceert je zoekfunctie als HTTP-endpoint met een OpenAPI-beschrijving, en koppelt die aan een Custom GPT. Dan kunnen collega's binnen ChatGPT bij de kennisbank zonder dat jij iets bouwt.

Let bij die tweede route op: dat endpoint staat op het internet. Zet er authenticatie op, en filter per gebruiker welke documenten zichtbaar zijn — anders lekt je vectordatabase de HR-map naar de hele organisatie.

Twee verbeteringen die het meeste opleveren

Wanneer je basis staat en de resultaten nog niet goed genoeg zijn, zijn dit de twee ingrepen met de meeste impact:

1. Hybride zoeken. Puur op betekenis zoeken mist exacte termen: artikelnummers, productcodes, eigennamen. Combineer je vectorzoekopdracht met een klassieke trefwoordzoekopdracht (BM25) en voeg de ranglijsten samen. Qdrant en pgvector ondersteunen dit allebei. In de praktijk levert dit vaak meer op dan overstappen naar een duurder embeddingmodel.

2. Reranking. Haal 30 kandidaten op in plaats van 5, en laat een klein rerank-model (Cohere Rerank, Voyage Rerank, of het open bge-reranker-v2-m3) die 30 opnieuw sorteren op relevantie voor precies deze vraag. Geef daarna de beste 5 aan het taalmodel. Dit kost een fractie van een seconde en verhoogt de precisie merkbaar.

Wat je ermee kunt: use cases uit de praktijk

  • Interne helpdesk. Alle handleidingen, procedures en eerdere ticketantwoorden in één index. Nieuwe medewerkers stellen vragen aan de kennisbank in plaats van aan een collega.
  • Offertes en contracten. "Welke afspraken hebben we met leveranciers over levertijden?" — over 400 pdf's tegelijk, met bronvermelding per antwoord.
  • Klantenservice-suggesties. Bij een binnenkomende mail automatisch de drie meest gelijkende eerdere cases erbij zoeken en een conceptantwoord voorstellen. De medewerker beslist.
  • Doublures opsporen. Cosinusgelijkenis is prima om bijna-identieke content te vinden: dubbele klantrecords, herhaalde tickets, bijna-gelijke productteksten in een webshop. (Dit blog gebruikt precies die truc om te voorkomen dat hetzelfde nieuws twee keer wordt gepubliceerd.)
  • Aanbestedingen en subsidies. Nieuwe publicaties automatisch vergelijken met een profiel van wat jij doet, en alleen de relevante doorsturen.
  • Vergaderverslagen. Notulen en transcripties embedden. "Wat hebben we in Q1 besloten over die prijsverhoging?"
  • Productcatalogus. Klanten laten zoeken op omschrijving in plaats van op artikelnummer — "waterdichte jas voor koud weer" vindt de goede jas, ook als die woorden niet in de producttekst staan.

Wat het kost

Reken met een archief van 10.000 documenten van gemiddeld 2.000 woorden. Dat is ruwweg 27 miljoen tokens.

  • Eenmalig embedden met text-embedding-3-small: rond de €0,55. Met voyage-3-large: rond de €4,50. Lokaal met bge-m3: gratis, ongeveer een uur rekentijd.
  • Zoekopdrachten: een vraag is 10–20 tokens. Duizend vragen per dag kost je fracties van een cent.
  • Opslag: 30.000 chunks × 1536 dimensies is ongeveer 180 MB. Op je eigen server verwaarloosbaar.
  • De antwoorden: hier zit je echte rekening. Claude Opus 5 kost $5 per miljoen invoertokens en $25 per miljoen uitvoertokens. Stuur je 6 chunks van 500 tokens mee, dan is dat ~3.000 invoertokens per vraag: ongeveer anderhalve cent. Voor eenvoudiger vragen is Claude Haiku 4.5 ($1 / $5 per miljoen) vaak ruim voldoende.

Kort gezegd: de embeddings zijn goedkoop, het genereren is de kostenpost. Precies daarom is goede retrieval financieel interessant — minder, maar betere fragmenten meesturen is tegelijk goedkoper én beter.

Fouten die ik het vaakst zie

  • Te grote chunks. Hele documenten embedden. Dit is fout nummer één en verklaart de meeste teleurstellende resultaten.
  • Alleen de vector opslaan. Zonder de ruwe tekst in je payload heb je een index die je nooit kunt herbouwen of controleren.
  • Geen metadata. Sla altijd bron, datum, afdeling en toegangsniveau op. Zonder die velden kun je later niet filteren, en filteren heb je nodig zodra er rechten in het spel komen.
  • Embeddingmodel wisselen zonder herindexeren. Levert stilletjes onzin op — geen foutmelding, alleen slechte resultaten.
  • Geen evaluatieset. Schrijf 20 vragen op waarvan je het juiste antwoorddocument kent en draai die na elke wijziging. Zonder meetlat is elke aanpassing een gok.
  • Verouderde inhoud. Een kennisbank die niemand bijwerkt geeft na een jaar met veel overtuiging verkeerde antwoorden. Zet het opnieuw indexeren in een cronjob.

Wat ik zou kiezen

Moet je vandaag beginnen, dan is dit mijn volgorde:

  1. Qdrant in Docker — één container, klaar in twee minuten.
  2. text-embedding-3-small om te starten, chunks van 500–700 tokens met 15% overlap.
  3. Claude Opus 5 via MCP als antwoordlaag, zodat je geen interface hoeft te bouwen.
  4. Werkt het en zit er gevoelige data in: stap over naar bge-m3 via Ollama, dezelfde pipeline, alleen andere endpoints.
  5. Zijn de resultaten niet scherp genoeg: hybride zoeken en reranking erbij, in die volgorde.

Het belangrijkste inzicht: je bouwt hier geen AI. Je bouwt een zoekmachine die snapt wat je bedoelt. Welk model er daarna een nette alinea van maakt, is een instelling die je op elk moment kunt wijzigen — als je die vier bouwstenen maar netjes gescheiden houdt.

80% van de AI-projecten haalt productie nooit.

Ik bouw de 20% — van prototype naar draaiend systeem, binnen weken.