AI-agents bouwen

Zoeken op betekenis met MySQL, PHP en OpenAI: een intelligente database in vijf stappen

LIKE '%Laravel%' vindt de bootcamper en mist de API-architect. Zo bouw je met MySQL, PHP en OpenAI een database die op betekenis zoekt: geteste code, gemeten scores, RAG met GPT, en waarom de hoogste score niet de beste kandidaat is.

Erik van de Blaak
Erik van de Blaak
27 min leestijd • 93 weergaven
Zoeken op betekenis met MySQL, PHP en OpenAI: een intelligente database in vijf stappen

Stel: een recruitmentbureau heeft 100.000 kandidaten in MySQL staan. Een recruiter zoekt een ervaren Laravel-developer en typt wat iedereen typt:

SELECT reference FROM candidates WHERE profile_text LIKE '%Laravel%';

Ik heb dat nagebouwd met tien verzonnen profielen en het echt gedraaid. De zoekopdracht vond er twee. De eerste is iemand met anderhalf jaar bootcamp- en stage-ervaring, met "Laravel" vier keer in het profiel. De tweede is een engineer met negen jaar ervaring die "later deels Laravel" schreef. De kandidaat die de publieke API van een betaaldienst ontwierp, met OAuth2, rate limiting en versiebeheer, kwam niet boven. Die schreef "PHP" en "API-platformen", niet "Laravel".

Dat is het probleem van zoeken op woorden: mensen beschrijven hetzelfde werk op honderd manieren. De een is "PHP backend developer", de ander "software engineer met ervaring in webapplicaties", een derde "ontwikkelaar van complexe API-platformen". In dit artikel bouwen we een database die op betekenis zoekt, met MySQL, PHP en de OpenAI API. Alle code staat erin en is getest: tegen MySQL 8.4, met echte embeddings van text-embedding-3-small en een echte analyse door gpt-6-luna. De scores in dit artikel zijn gemeten, niet bedacht. Ze laten ook zien waarom de hoogste score niet de beste kandidaat hoeft te zijn.

Kort antwoord. Een database wordt "intelligent" door drie lagen. Embeddings maken van elke tekst een reeks getallen, zodat je op betekenis kunt zoeken. Een taalmodel weegt alleen de gevonden dossiers af tegen de eisen en noemt wat ontbreekt. De mens beslist. Embeddings beslissen niets, en het taalmodel ook niet.

Wat is een embedding? Een kaart waarop betekenis een plek heeft

Stel je een enorme kaart voor. Elke tekst krijgt daarop een punt. Teksten over hetzelfde onderwerp liggen dicht bij elkaar: "PHP backend developer" naast "software engineer voor webapplicaties", en "verpleegkundige op de IC" ergens heel anders. Een embedding is het adres van zo'n punt: een rij getallen.

Een klein voorbeeld met drie getallen. Doe voor de uitleg alsof het eerste getal "programmeren" meet, het tweede "webinterfaces" en het derde "zorg":

TekstVector
A: "PHP backend developer"[0.9, 0.2, 0.1]
B: "software engineer met ervaring in webapplicaties"[0.8, 0.4, 0.1]
C: "verpleegkundige op de IC"[0.1, 0.1, 0.9]

A en B delen geen enkel woord, maar hun pijlen wijzen bijna dezelfde kant op. Dat meet je met cosine similarity: de cosinus van de hoek tussen twee pijlen. Je vermenigvuldigt de getallen paarsgewijs en telt ze op (het inproduct), en deelt door de lengtes van beide pijlen:

A·B = 0.9×0.8 + 0.2×0.4 + 0.1×0.1 = 0.81
|A| = √(0.81 + 0.04 + 0.01) = 0.927
|B| = √(0.64 + 0.16 + 0.01) = 0.900
cos(A, B) = 0.81 / (0.927 × 0.900) = 0.97

cos(A, C) = 0.20 / (0.927 × 0.911) = 0.24

Een 1 betekent: precies dezelfde richting. Een 0 betekent: niets met elkaar te maken. A en B scoren 0,97, A en C 0,24.

Een echt model werkt met veel meer getallen. text-embedding-3-small geeft er standaard 1.536 per tekst. En die getallen hebben geen namen zoals "programmeren" of "zorg". Het model heeft de posities geleerd uit enorme hoeveelheden tekst, en niemand kan je vertellen wat getal 812 betekent. De rekensom blijft wel dezelfde. OpenAI levert de vectoren bovendien al op lengte 1 aan. Daardoor vallen de noemers weg, en is cosine similarity gelijk aan het inproduct.

Belangrijk om vast te houden: dit is meetkunde, geen begrip. Het model weet niet wat een sollicitatie is. Het weet dat bepaalde woorden en zinnen in vergelijkbare contexten voorkomen. Verderop staat een gemeten voorbeeld waarin dat precies misgaat: een kandidaat die schrijft senior te willen worden, scoort alsof die het al is.

Hoe werkt een intelligente database? Vier fasen, twee momenten

Het systeem doet zijn werk op twee verschillende momenten. Het voorbereiden gebeurt vooraf, één keer per profiel en opnieuw zodra een profiel verandert. Zoeken, analyseren en beslissen gebeuren pas als een recruiter een vraag stelt.

Architectuurschema in twee delen. Vooraf, fase A: MySQL levert de profieltekst aan PHP, PHP vraagt een embedding op bij de OpenAI Embeddings API en slaat de vector op in MySQL. Bij elke vraag, fase B tot en met D: de recruiter stelt een vraag aan PHP, PHP laat er een embedding van maken, filtert harde eisen met SQL in MySQL, vergelijkt de vectoren in PHP, stuurt de beste dossiers naar OpenAI GPT en geeft het advies met bronverwijzingen terug aan de recruiter, die beslist.
De vector van de vraag wordt alleen vergeleken met vectoren die vooraf zijn opgeslagen. GPT ziet nooit de hele database, alleen de paar dossiers die de zoekstap vond.

Fase A, gegevens voorbereiden. PHP leest een kandidaatprofiel uit MySQL en stuurt de relevante tekst naar de OpenAI Embeddings API. Die stuurt een vector van 1.536 getallen terug, en PHP slaat die op bij het profiel.

Fase B, zoeken. De recruiter stelt een vraag. PHP laat van die vraag ook een embedding maken. Dan filtert SQL eerst de harde eisen, zoals minimaal vijf jaar ervaring. PHP vergelijkt de vector van de vraag met de opgeslagen vectoren en houdt de best passende profielen over.

Fase C, analyse. Alleen die paar profielen gaan samen met de vacature-eisen naar een taalmodel. Het model zet de eisen naast het bewijs, citeert uit de dossiers en zegt wat ontbreekt.

Fase D, beslissen. De recruiter leest de onderbouwing, controleert de bronnen en beslist. Dat is geen beleefdheidsstap. Bij recruitment is het een wettelijke eis, daarover verderop meer.

Het voorbeeld in PHP en MySQL, in vijf stappen

Je hebt nodig: PHP 8.2 of hoger met PDO en cURL, MySQL 8.4 en een OpenAI API-sleutel. Ik gebruik MySQL 8.4 omdat dat de LTS-versie is; MySQL 8.0 is sinds 30 april 2026 end-of-life. De code werkt ook op 9.7. De sleutel en de databasegegevens staan in omgevingsvariabelen, nooit in de code en nooit in Git.

Stap 1: een tabel voor kandidaten, profielen en embeddings

schema.sql

CREATE TABLE candidates (
    id               INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    reference        VARCHAR(20)  NOT NULL UNIQUE,
    profile_text     TEXT         NOT NULL,
    years_experience TINYINT UNSIGNED NULL,
    region           VARCHAR(60)  NULL,
    available_from   DATE         NULL,
    embedding        JSON         NULL,
    embedding_model  VARCHAR(60)  NULL,
    embedding_hash   CHAR(64)     NULL,
    updated_at       TIMESTAMP    NOT NULL DEFAULT CURRENT_TIMESTAMP
                                  ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

Een paar keuzes vallen op. Er staat geen naam, e-mailadres of geboortedatum in. Voor zoeken op vaardigheden heb je die niet nodig, en wat er niet in staat, kan ook niet naar OpenAI lekken. Het referentienummer (K-2207) verwijst terug naar het echte dossier in je ATS. De harde gegevens, zoals jaren ervaring, regio en beschikbaarheid, staan in aparte kolommen. Daar wil je met gewone SQL op filteren, niet met een vector.

De embedding staat in een JSON-kolom. MySQL 8.4 heeft geen vectortype, en een JSON-array van 1.536 getallen werkt overal. In mijn test was zo'n kolom ongeveer 31 KB per profiel. Daarnaast bewaar ik twee kolommen die je later dankbaar zult zijn. embedding_model legt vast welk model de vector maakte, want vectoren van verschillende modellen kun je niet met elkaar vergelijken. embedding_hash is een vingerafdruk van de tekst waarvan de vector is gemaakt.

Stap 2: een embedding maken met PHP

openai.php

<?php
declare(strict_types=1);

function openai(string $endpoint, array $payload): array
{
    $key = getenv('OPENAI_API_KEY');
    if ($key === false || $key === '') {
        throw new RuntimeException('OPENAI_API_KEY is niet ingesteld');
    }

    $ch = curl_init('https://api.openai.com/v1/' . $endpoint);
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER     => [
            'Authorization: Bearer ' . $key,
            'Content-Type: application/json',
        ],
        CURLOPT_POSTFIELDS     => json_encode($payload, JSON_THROW_ON_ERROR),
        CURLOPT_CONNECTTIMEOUT => 10,
        CURLOPT_TIMEOUT        => 120,
    ]);

    $body = curl_exec($ch);
    if ($body === false) {
        throw new RuntimeException('Netwerkfout: ' . curl_error($ch));
    }
    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);

    $data = json_decode($body, true);
    if ($status >= 400 || !is_array($data)) {
        $message = $data['error']['message'] ?? substr($body, 0, 200);
        throw new RuntimeException("OpenAI gaf HTTP $status: $message");
    }
    return $data;
}

function embed(array $texts): array
{
    $data = openai('embeddings', [
        'model' => 'text-embedding-3-small',
        'input' => array_values($texts),
    ]);

    $vectors = [];
    foreach ($data['data'] as $item) {
        $vectors[$item['index']] = $item['embedding'];
    }
    ksort($vectors);
    return $vectors;
}

openai() is de enige plek waar het netwerk in beeld komt. De sleutel komt uit getenv(); ontbreekt hij, dan stopt de functie direct met een duidelijke fout. Er zijn twee time-outs: tien seconden om verbinding te maken en 120 voor het hele antwoord, want een analyse door een taalmodel kan lang duren (daarover straks meer). Een netwerkfout geeft een exception. Een HTTP-status van 400 of hoger ook, met de foutmelding van OpenAI erin; met een ongeldige sleutel krijg je bijvoorbeeld OpenAI gaf HTTP 401: Incorrect API key provided. Komt er geen JSON terug, bijvoorbeeld een HTML-foutpagina van een proxy, dan staan de eerste 200 tekens daarvan in de melding. Er zit geen curl_close() in: sinds PHP 8.0 doet die functie niets meer, en in PHP 8.5 is hij verouderd verklaard.

embed() stuurt een hele lijst teksten in één verzoek. De API accepteert tot 2.048 teksten per verzoek, met samen hoogstens 300.000 tokens, en hoogstens 8.192 tokens per tekst. Het antwoord bevat per tekst een index. Daarop sorteer ik, zodat vector 3 gegarandeerd bij tekst 3 hoort.

Een praktische valkuil uit mijn test. Op een Windows-laptop gaf cURL eerst unable to get local issuer certificate. De oorzaak was de virusscanner (Norton), die HTTPS-verkeer openbreekt en met een eigen certificaat opnieuw versleutelt. PHP kende dat certificaat niet. Zet de controle dan niet uit met CURLOPT_SSL_VERIFYPEER = false. Wijs curl.cainfo en openssl.cafile in php.ini naar een bundel waar dat certificaat in zit, of gebruik in PHP 8.2+ CURLOPT_SSL_OPTIONS => CURLSSLOPT_NATIVE_CA, zodat cURL de certificaten van Windows zelf vertrouwt.

Stap 3: opslaan, en alleen opnieuw bij een gewijzigd profiel

db.php

<?php
declare(strict_types=1);

function db(): PDO
{
    return new PDO(
        getenv('DB_DSN'),   // bijv. mysql:host=127.0.0.1;dbname=recruit;charset=utf8mb4
        getenv('DB_USER'),
        getenv('DB_PASS'),
        [
            PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        ]
    );
}

index_embeddings.php

<?php
declare(strict_types=1);
require __DIR__ . '/db.php';
require __DIR__ . '/openai.php';

$pdo  = db();
$rows = $pdo->query('SELECT id, profile_text, embedding_hash FROM candidates')->fetchAll();

// Alleen profielen die nieuw of gewijzigd zijn
$todo = array_filter($rows, fn ($r) => $r['embedding_hash'] !== hash('sha256', $r['profile_text']));

$update = $pdo->prepare(
    'UPDATE candidates
        SET embedding = ?, embedding_model = ?, embedding_hash = ?
      WHERE id = ?'
);

foreach (array_chunk($todo, 100) as $batch) {
    $vectors = embed(array_column($batch, 'profile_text'));

    $pdo->beginTransaction();
    foreach ($batch as $i => $row) {
        $update->execute([
            json_encode($vectors[$i]),
            'text-embedding-3-small',
            hash('sha256', $row['profile_text']),
            $row['id'],
        ]);
    }
    $pdo->commit();
}
echo count($todo) . " profielen bijgewerkt\n";

Het script vergelijkt per profiel de SHA-256-hash van de huidige tekst met de opgeslagen hash. Alleen nieuwe en gewijzigde profielen gaan naar OpenAI, in blokken van honderd. Elk blok wordt in één transactie opgeslagen. De eerste keer meldde het "10 profielen bijgewerkt", de tweede keer "0 profielen bijgewerkt". Je kunt het dus elk uur via cron draaien zonder voor dezelfde tekst twee keer te betalen.

Wat kost dat? Een rekenvoorbeeld. text-embedding-3-small kost $0,02 per miljoen tokens. Geef je 100.000 profielen gemiddeld 400 tokens, dan is dat 40 miljoen tokens: $0,80 voor de hele database. Via de Batch API, die 50% korting geeft en tot 50.000 teksten per batch verwerkt, wordt het ongeveer $0,40. De embeddings zijn zelden de dure kant van zo'n systeem.

Stap 4: de vraag omzetten naar een vector en vergelijken

search.php

<?php
declare(strict_types=1);
require_once __DIR__ . '/db.php';
require_once __DIR__ . '/openai.php';

function cosine(array $a, array $b): float
{
    $dot = $normA = $normB = 0.0;
    foreach ($a as $i => $value) {
        $dot   += $value * $b[$i];
        $normA += $value * $value;
        $normB += $b[$i] * $b[$i];
    }
    return $dot / (sqrt($normA) * sqrt($normB));
}

function search(PDO $pdo, string $question, int $minYears = 0, int $limit = 5): array
{
    $queryVector = embed([$question])[0];

    // Harde eisen eerst in SQL, betekenis daarna in PHP
    $stmt = $pdo->prepare(
        'SELECT id, reference, profile_text, years_experience, region, embedding
           FROM candidates
          WHERE embedding IS NOT NULL
            AND embedding_model = :model
            AND years_experience >= :min_years'
    );
    $stmt->execute(['model' => 'text-embedding-3-small', 'min_years' => $minYears]);

    $results = [];
    foreach ($stmt as $row) {
        $vector = json_decode($row['embedding'], true, 512, JSON_THROW_ON_ERROR);
        unset($row['embedding']);
        $row['score'] = cosine($queryVector, $vector);
        $results[] = $row;
    }

    usort($results, fn ($a, $b) => $b['score'] <=> $a['score']);
    return array_slice($results, 0, $limit);
}

cosine() is de formule van het voorbeeld met drie getallen, nu voor 1.536 getallen. Omdat OpenAI-vectoren al lengte 1 hebben, zou het inproduct ($dot) genoeg zijn. Ik laat de volledige formule staan, zodat de functie ook klopt voor modellen die niet normaliseren.

search() doet drie dingen in een bewuste volgorde. Eerst wordt de vraag een vector. Dan haalt SQL alleen de profielen op die door de harde filters komen, met een prepared statement, en alleen vectoren van hetzelfde model. Pas daarna rekent PHP per profiel de score uit, en sorteert het van hoog naar laag. Let op een detail: years_experience >= :min_years laat profielen weg waarin het aantal jaren ontbreekt, want NULL >= 5 is in SQL niet waar. Dat kan precies goed zijn, maar beslis het bewust.

Stap 5: de beste resultaten laten analyseren door GPT

analyse.php

<?php
declare(strict_types=1);
require_once __DIR__ . '/search.php';

function analyse(string $vacancy, array $candidates): array
{
    $dossiers = array_map(fn ($c) => [
        'candidate_id'     => $c['reference'],
        'profile_text'     => $c['profile_text'],
        'years_experience' => $c['years_experience'],
        'region'           => $c['region'],
    ], $candidates);

    $schema = [
        'type' => 'object',
        'additionalProperties' => false,
        'required' => ['candidates'],
        'properties' => [
            'candidates' => [
                'type'  => 'array',
                'items' => [
                    'type' => 'object',
                    'additionalProperties' => false,
                    'required' => ['candidate_id', 'relevant_skills', 'evidence',
                                   'missing_information', 'recommendation', 'sources'],
                    'properties' => [
                        'candidate_id'        => ['type' => 'string'],
                        'relevant_skills'     => ['type' => 'array', 'items' => ['type' => 'string']],
                        'evidence'            => ['type' => 'array', 'items' => ['type' => 'string']],
                        'missing_information' => ['type' => 'array', 'items' => ['type' => 'string']],
                        'recommendation'      => ['type' => 'string'],
                        'sources'             => ['type' => 'array', 'items' => ['type' => 'string']],
                    ],
                ],
            ],
        ],
    ];

    $response = openai('responses', [
        'model' => 'gpt-6-luna',
        'input' => [
            ['role' => 'system', 'content' =>
                'Je ondersteunt een recruiter. Gebruik uitsluitend de meegegeven dossiers. ' .
                'Citeer bij evidence letterlijk uit profile_text en noem in sources het ' .
                'candidate_id en de velden die je gebruikte. Staat iets er niet in, zet het ' .
                'onder missing_information en raad het niet. Je beslist niet: je adviseert ' .
                'alleen welke vervolgstap de recruiter kan nemen.'],
            ['role' => 'user', 'content' => json_encode(
                ['vacature' => $vacancy, 'dossiers' => $dossiers],
                JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE
            )],
        ],
        'text' => ['format' => [
            'type'   => 'json_schema',
            'name'   => 'candidate_advice',
            'strict' => true,
            'schema' => $schema,
        ]],
    ]);

    if (($response['status'] ?? '') !== 'completed') {
        throw new RuntimeException('Analyse niet afgerond: ' . ($response['status'] ?? 'onbekend'));
    }
    foreach ($response['output'] as $item) {
        if ($item['type'] !== 'message') {
            continue;
        }
        foreach ($item['content'] as $part) {
            if ($part['type'] === 'refusal') {
                throw new RuntimeException('Model weigerde: ' . $part['refusal']);
            }
            if ($part['type'] === 'output_text') {
                return json_decode($part['text'], true, 512, JSON_THROW_ON_ERROR);
            }
        }
    }
    throw new RuntimeException('Geen tekst in het antwoord');
}

Dit is de Responses API (POST /v1/responses), die OpenAI aanraadt voor nieuwe projecten. Chat Completions blijft bestaan. gpt-6-luna is OpenAI's zuinigste model voor afgebakende taken: $0,10 per miljoen inputtokens en $0,50 per miljoen outputtokens. Drie dossiers analyseren kost daarmee fracties van een cent.

Drie dingen maken het antwoord controleerbaar. De instructie verbiedt raden en verplicht letterlijke citaten met bronverwijzing. Het JSON-schema met strict: true dwingt de vorm af: elk veld is verplicht, en extra velden zijn niet toegestaan. En de code leest het antwoord zoals het werkelijk binnenkomt. output_text bestaat alleen in de SDK's van OpenAI. In het ruwe antwoord zoek je in output het item van het type message, en daarin het deel van het type output_text. Neem niet blind output[0]: daar kan eerst een redeneerstap staan. Een weigering komt als refusal terug, en een afgebroken antwoord heeft de status incomplete.

Eén meting om rekening mee te houden: de analyse van drie dossiers duurde in mijn test 77 seconden. De zoekstap duurde 3,9 seconden, bijna allemaal wachten op de embedding van de vraag; het vergelijken zelf kost bij honderd profielen 40 ms. Zet zo'n analyse dus in een wachtrij, of laat de recruiter eerst de zoekresultaten zien en het advies later. Een scherm met een draaiend wieltje is hier niet de goede oplossing.

Waarom JSON in MySQL een lesmodel is, geen vectorindex

De code hierboven leest bij elke zoekvraag alle vectoren uit de database en rekent ze een voor een door. Dat heet brute force. Het is exact: je mist nooit de beste match. Maar het schaalt lineair. Tien keer meer profielen betekent tien keer meer werk. Hoe het zit met vectoren in de verschillende databases:

DatabaseVectortypeAfstand berekenen in SQLVectorindex
MySQL 8.4 Community (LTS)Nee, gebruik JSON of BLOBNeeNee
MySQL 9.7 Community (LTS)VECTOR(N), tot 16.383 dimensiesNee: DISTANCE() zit niet in Community of CommercialNee
MySQL HeatWave (OCI) en MySQL AIJaJa: DISTANCE() met COSINE, DOT of EUCLIDEANVector store met ML_RAG
MariaDB 11.8 (LTS)JaJa: VEC_DISTANCE_COSINE()Ja: VECTOR INDEX (HNSW)
PostgreSQL + pgvector 0.8JaJa: operatoren zoals <=>Ja: HNSW en IVFFlat (tot 2.000 dimensies)

MySQL 9.7 Community kan een vector dus opslaan als VECTOR(1536), met STRING_TO_VECTOR() en VECTOR_DIM(). Wie daarna DISTANCE() aanroept, krijgt een foutmelding, want die functie is volgens de handleiding alleen beschikbaar in HeatWave op OCI en in MySQL AI. Op een gewone MySQL-server blijft het rekenwerk dus in PHP, wat je ook kiest. Wat er gebeurt als je hetzelfde in MariaDB, PostgreSQL en SQLite doet, beschreef ik in Wat AI met databases voor webapplicaties deed.

Regels, zoeken en analyse: drie technieken voor drie vragen

Traditionele software werkt met regels die een programmeur heeft uitgeschreven:

IF ervaring >= 5 AND kennis_laravel = true THEN uitnodigen

Dat is voorspelbaar en uitlegbaar. Het faalt zodra de werkelijkheid niet in je kolommen past. Wie "Symfony, later deels Laravel" schreef, heeft kennis_laravel dan op true of op false staan?

Semantisch zoeken vindt teksten die inhoudelijk op de vraag lijken, ook als de woorden verschillen. Het geeft een rangorde. Een oordeel geeft het niet.

AI-ondersteunde analyse laat een taalmodel de gevonden informatie naast de criteria leggen. Het model noemt wat bewezen is en wat ontbreekt, en stelt een vervolgstap voor.

Hoe ze samenwerken, zie je aan de echte scores. Dit gaf search() op de vraag "Senior PHP/Laravel-developer met aantoonbare API-ervaring", over alle tien profielen:

Staafgrafiek van gemeten cosine similarity met text-embedding-3-small voor tien fictieve kandidaten. K-2207, negen jaar ervaring, 0,681. K-1041, twee jaar ervaring, 0,671. K-3315, zeven jaar ervaring, 0,661. K-7004, WordPress, 0,583. De overige zes liggen tussen 0,46 en 0,57; het laagst scoren de projectmanager (0,463) en de data engineer (0,460).
Gemeten op 2 november 2026. Het verschil tussen nummer 1 en nummer 3 is 0,02. Ook het profiel dat het minst past, scoort nog 0,46.

Drie dingen vallen op.

Er bestaat geen "goede" score. Alle tien profielen scoren tussen 0,46 en 0,68, ook de projectmanager en de data engineer. Een score van 0,5 zegt bij dit model niets, en een vaste drempel als "alles boven 0,8 is een match" levert hier nul resultaten op. OpenAI noemt geen drempelwaarden. Bepaal ze op je eigen gegevens, met voorbeelden waarvan je de juiste uitkomst kent.

Ambitie scoort als ervaring. K-1041 heeft twee jaar ervaring en staat op 0,671, net onder de engineer met negen jaar. Het profiel eindigt met "Wil doorgroeien naar senior Laravel-developer". Voor de embedding staan "senior" en "Laravel" er gewoon in. Dat het een wens is en geen feit, ziet de vector niet. Ik heb het nagemeten: zonder die ene zin zakt de score naar 0,653, en dan staat K-3315 er wél boven. Het vaak herhaalde woord "Laravel" doet het meeste werk, de wens doet de rest.

De beste kandidaat staat misschien op drie. K-3315 ontwierp de publieke API van een betaaldienst, met OAuth2, rate limiting en versiebeheer. Dat is het sterkste API-bewijs van de drie, maar dit profiel scoort het laagst: de laatste twee jaar vooral Go, en Laravel wordt niet genoemd. Of dat een nadeel is, is een vraag voor het gesprek. Een vector kan die vraag niet beantwoorden.

Hier doen de lagen samen wat ze apart niet kunnen. SQL haalt K-1041 eruit met één regel (min_years = 5). Het zoeken vindt K-3315, die LIKE had gemist. En het taalmodel zag wat geen van beide zag. Over K-2207 schreef het: "Of de kandidaat API's zelf heeft ontworpen en gebouwd; er staat dat de kandidaat koppelingen bouwde." Dat is precies de vraag die een goede recruiter in het eerste gesprek stelt.

Tegelijk blijven de grenzen staan. Embeddings nemen geen beslissingen, ze sorteren. Een taalmodel kan zich vergissen, ook met citaten. En het advies is nooit beter dan het dossier: staat iemands API-werk niet in het profiel, dan bestaat het voor dit hele systeem niet.

Wat is RAG? Eerst de dossiers, dan pas het advies

Wat we in stap 4 en 5 bouwden, heet Retrieval-Augmented Generation, RAG. Het idee is simpel. Het taalmodel hoeft je database niet te kennen. Het krijgt per vraag alleen de informatie die de zoekstap vond, en schrijft op basis daarvan.

Vergelijk het met een goede adviseur. Die gaat niet uit het hoofd vertellen wie geschikt is, maar haalt eerst de drie relevante dossiers uit de kast, legt ze naast de vacature en schrijft dan een advies, met bij elke bewering het dossier en de bladzijde erbij.

Dat levert vier dingen op:

  • Actualiteit. Een profiel dat vanochtend is bijgewerkt, telt vanmiddag mee. Het model hoeft niet opnieuw getraind te worden, alleen die ene embedding moet opnieuw.
  • Controleerbaarheid. Elk citaat verwijst naar een kandidaat-ID en een veld. Een recruiter kan in tien seconden nagaan of het er echt staat.
  • Kosten. Rekenvoorbeeld: 100.000 profielen van 400 tokens zijn samen 40 miljoen tokens. Zelfs als dat in één prompt zou passen, kostte het bij gpt-6-luna minstens $4 per vraag, en boven 272.000 tokens per prompt rekent OpenAI het dubbele tarief. Drie dossiers zijn een paar honderd tokens.
  • Minder verzinsels. Een model dat alleen hoeft te kiezen uit wat het voorgelegd krijgt, en de opdracht heeft om lege plekken als "ontbreekt" te melden, verzint minder dan een model dat uit zijn geheugen moet putten. Minder is niet nul. Daarom zijn de citaten er.

Zoeken, classificeren, voorspellen en beslissen zijn vier verschillende dingen

Hetzelfde patroon werkt overal waar kennis in tekst zit die mensen verschillend opschrijven:

  • Klantenservice: bij een nieuwe klacht de vijf meest vergelijkbare opgeloste tickets tonen, met hun oplossing.
  • Juridisch team: clausules vinden die inhoudelijk lijken op een aansprakelijkheidsbeperking, ook als het woord "aansprakelijkheid" er niet in staat.
  • Ontwikkelteam: zoeken in de technische documentatie met de vraag zoals je hem stelt ("waarom valt de import 's nachts om?"), niet met de term die de schrijver toevallig koos.
  • Arbeidsmarktanalyse: vacatureteksten groeperen tot clusters van vaardigheden, en zien welke clusters per kwartaal groeien.
  • Recruitment: kandidaatprofielen naast functie-eisen leggen, zoals in dit artikel.

Houd de werkwoorden uit elkaar. Zoeken is: wat lijkt hierop? Classificeren is: in welk vakje hoort dit, bijvoorbeeld "factuurvraag" of "storing"? Dat kan met embeddings plus een paar honderd gelabelde voorbeelden. Voorspellen is: wat gaat er gebeuren? Beslissen is: wat doen we? Dat laatste blijft bij een mens.

Voorspellen is de valkuil. Dat vacatures over "Laravel" en "API" in de vectorruimte dicht bij elkaar liggen, zegt niets over de vraag naar Laravel-developers volgend jaar. Daarvoor heb je tijdreeksen nodig, historische aantallen, een statistisch model en een toets op data die het model nog niet had gezien. Vectoren kunnen tekst helpen ordenen voordat je gaat tellen. Een prognose maken ze niet.

Betrouwbaarheid, privacy en de wet: waar het misgaat

Een advies zonder bron is een mening

Het schema in stap 5 verplicht evidence (letterlijke citaten) en sources (kandidaat-ID en veldnaam). Daardoor kan een mens het advies controleren. Je kunt dit in PHP ook automatisch nalopen: komt elk citaat letterlijk voor in de profile_text van die kandidaat? Een citaat dat niet te vinden is, is een rood signaal. Toon dat advies dan niet, of markeer het.

Harde eisen horen in SQL

Een wettelijke werkvergunning, een verplicht diploma, beschikbaarheid per een datum: dat zijn ja/nee-vragen. Stel ze aan de database, niet aan een vector of een taalmodel. SQL vergist zich niet in "minimaal vijf jaar", en je kunt achteraf precies laten zien waarom iemand buiten de selectie viel.

Minder gegevens, minder risico

Stuur alleen naar OpenAI wat nodig is om op vaardigheden te zoeken. Laat naam, adres, geboortedatum, foto en nationaliteit weg. Haal ook zinnen weg die gezondheid, religie of afkomst prijsgeven ("na een burn-out", "vrijwilliger bij de moskee"). Zulke gegevens vallen onder artikel 9 van de AVG, de bijzondere persoonsgegevens, en die mag je in principe niet verwerken.

De API van OpenAI traint standaard niet op wat je instuurt. Gegevens voor misbruikcontrole worden wel tot dertig dagen bewaard. Zero Data Retention kan alleen na goedkeuring door OpenAI. Verwerking in Europa kan via eu.api.openai.com, maar ook dat vereist zo'n bewaarafspraak, en voor modellen die vanaf 5 maart 2026 zijn uitgebracht rekent OpenAI daar 10% meer voor.

Een embedding is niet anoniem

Het voelt alsof 1.536 kale getallen niets meer over een persoon zeggen. Dat klopt niet. Onderzoekers van Cornell lieten in 2023 zien dat hun methode Vec2Text 92% van de teksten van 32 tokens exact kon terughalen uit de embedding, en uit embeddings van medische notities volledige namen terugvond. Dat was bij een open embeddingmodel, niet bij text-embedding-3-small, maar het principe blijft staan: een embedding van een persoonsprofiel is een persoonsgegeven. Beveilig de kolom zoals je de tekst zou beveiligen, en verwijder hem samen met het profiel.

Wat de wet vraagt bij AI in recruitment

AVG, artikel 22: niemand mag worden onderworpen aan een besluit dat uitsluitend op geautomatiseerde verwerking berust en dat diegene aanmerkelijk treft. Het Hof van Justitie bepaalde in de SCHUFA-zaak (C-634/21, 7 december 2023) dat een automatisch berekende score zelf al zo'n besluit kan zijn, als degene die beslist er doorslaggevend op leunt. Een recruiter die alleen de top 3 van een vectorscore bekijkt en de rest nooit ziet, komt gevaarlijk dicht in de buurt.

De AI-verordening noemt AI voor werving en selectie, zoals het filteren van sollicitaties en het beoordelen van kandidaten, in bijlage III uitdrukkelijk als hoog risico. Door Verordening (EU) 2026/1744, de "Digital Omnibus", gelden die verplichtingen vanaf 2 december 2027, niet meer vanaf augustus 2026. Wie zo'n systeem gebruikt, moet dan onder meer zorgen voor menselijk toezicht door mensen met de nodige kennis en bevoegdheid. De logs moeten minstens zes maanden bewaard blijven, en werknemers en hun vertegenwoordigers moeten vooraf worden geïnformeerd. Het verbod op emotieherkenning op de werkvloer geldt al sinds 2 februari 2025. De Autoriteit Persoonsgegevens schreef in maart 2026 dat AI in recruitment snel groeit en grote risico's met zich meebrengt, en dat het vaak aan transparantie ontbreekt.

De mens houdt de pen vast

In de praktijk betekent dat het volgende. Het systeem ordent en adviseert, en een recruiter beslist en tekent. De recruiter kan elke kandidaat zien, ook nummer 40. Elk advies heeft bronnen. Elke beslissing wordt vastgelegd met wie, wanneer en waarom. En iemand die is afgewezen, kan vragen hoe dat besluit tot stand kwam en krijgt een antwoord dat een mens kan uitleggen.

Wanneer is PHP snel genoeg? Gemeten bij 100, 1.000 en 10.000 profielen

Ik heb de zoekstap gemeten met willekeurige vectoren van 1.536 dimensies, op een laptop met MySQL 8.4 in Docker en PHP 8.2 zonder JIT. Elke meting is de middelste van drie runs.

ProfielenOpslag als JSONWaarvan ophalen uit MySQLOpslag als float32-BLOB
10040 ms24 ms-
1.000405 ms246 ms-
10.0005.824 ms4.187 ms957 ms

De verrassing zit niet in de wiskunde. Alle cosinusberekeningen samen kostten bij 10.000 profielen 491 ms. Het meeste werk was het transport: MySQL slaat JSON binair op, maar zet het bij elke SELECT om naar tekst van 31 KB per profiel, en PHP moet die tekst weer ontleden. Bij 10.000 profielen nam alleen dat al 199 MB geheugen in beslag, en na json_decode 551 MB.

Sla je dezelfde vector op als een BLOB van 6.144 bytes (pack('g*', ...$vector) bij opslaan, unpack('g*', $blob) bij lezen), dan daalt het naar 957 ms. Dat is zes keer sneller. Het verschil in precisie met de JSON-versie was hoogstens 0,000000004. Voor rangschikken maakt dat niets uit.

Wat betekent dat in de praktijk? Reken het als vuistregel, want de grenzen hangen af van je server, het aantal gelijktijdige zoekvragen, het aantal dimensies en hoe snel een antwoord er moet zijn:

  • Rond de 100 kandidaten: de JSON-versie uit dit artikel volstaat. 40 ms merkt niemand.
  • Rond de 10.000 kandidaten: meten. Bijna zes seconden per zoekvraag is te traag voor een zoekveld. Stap over op een BLOB, filter met SQL vooraf zo veel mogelijk weg, en overweeg dimensions: 512 bij het maken van de embeddings. Dat geeft kortere vectoren, die wel iets minder nauwkeurig zijn. Meet ook dat.
  • Rond de 1.000.000 kandidaten: hier houdt brute force op. Doorgerekend vanaf de BLOB-meting zou één zoekvraag ruim anderhalve minuut duren, met gigabytes aan vectoren per verzoek. Dan heb je een vectorindex nodig, zoals HNSW in MariaDB of pgvector, of een aparte vectordatabase. Zo'n index zoekt bij benadering: veel sneller, maar hij kan af en toe de beste match missen.

Verder nog vier dingen die zich snel terugbetalen. Bewaar de embedding van veelgestelde zoekvragen, zodat dezelfde vraag niet steeds opnieuw naar OpenAI gaat. Maak bij een grote eerste vulling de embeddings via de Batch API, voor de helft van de prijs. Draai het indexscript na elke profielwijziging, of via cron; de hash zorgt dat alleen gewijzigde profielen opnieuw gaan. En wissel je ooit van embeddingmodel, maak dan alle vectoren opnieuw. Daarom bestaat de kolom embedding_model, en daarom filtert search() erop.

De demonstratie: één vraag in gewone taal, van zin tot advies

De recruiter typt:

"Welke kandidaten zijn geschikt voor een senior PHP/Laravel-functie, hebben aantoonbare API-ervaring en welke informatie ontbreekt nog om een goede selectie te maken?"

Een les uit de test: zet deze hele zin niet in één keer om in een vector. Het tweede deel ("welke informatie ontbreekt nog") is een opdracht aan het taalmodel, geen zoekterm. Met de volledige zin scoorde K-2207 0,614. Met alleen het zoekdeel ("Senior PHP/Laravel-developer met aantoonbare API-ervaring") steeg dat naar 0,681, en de afstand tot de irrelevante profielen werd groter. Splits de vraag dus. De applicatie kan dat met een vast formulier doen, of met een kleine eerste modelaanroep.

Daarna loopt het zo:

  1. Het zoekdeel wordt een embedding.
  2. SQL filtert de harde eisen.
  3. search() rangschikt wat overblijft.
  4. De drie beste dossiers gaan samen met de vacaturetekst naar analyse().
  5. Het advies komt terug als JSON met citaten en bronnen.
  6. De recruiter controleert en beslist.

Dit is het echte antwoord van gpt-6-luna uit de test, voor K-3315. De vacature vroeg om minimaal vijf jaar backendervaring, API-ontwerp en hybride werken in Utrecht.

{
  "candidate_id": "K-3315",
  "relevant_skills": ["Zeven jaar backendervaring", "PHP", "Go",
    "Ontwerp van publieke API's", "OAuth2", "Rate limiting", "Versiebeheer"],
  "evidence": [
    "“Zeven jaar backendervaring in Go en PHP; ontwierp de publieke API van een betaaldienst met OAuth2, rate limiting en versiebeheer.”",
    "“De laatste twee jaar vooral Go.”"
  ],
  "missing_information": [
    "Laravel-ervaring is niet vermeld.",
    "Hoeveel en hoe recent de PHP-ervaring is, is niet vermeld.",
    "Of de kandidaat bereid is twee dagen per week op kantoor in Utrecht te werken, is niet vermeld."
  ],
  "recommendation": "Nodig de kandidaat uit voor een technisch gesprek over PHP- en Laravel-ervaring en toets of die ervaring aansluit bij de rol; bespreek ook de hybride werklocatie.",
  "sources": ["K-3315: profile_text", "K-3315: region", "K-3315: years_experience"]
}

Over K-1041, de kandidaat met de ambitie, schreef het model zonder omwegen: "Minimaal vijf jaar ervaring als backendontwikkelaar is niet vermeld; het dossier noemt twee jaar totale ervaring" en "Ervaring als senior developer is niet vermeld; de kandidaat wil doorgroeien naar senior Laravel-developer." Wat de vector als "senior" las, haalde het taalmodel er wel uit. Het adviseerde geen afwijzing. Het stelde vragen voor, en liet het oordeel aan de recruiter.

Precies zo hoort de rolverdeling te zijn. Een intelligente database weet niet zelf alles, en beslist ook niet zelf. Het is een systeem dat de relevante informatie vindt, die in context analyseert en mensen helpt beter onderbouwde beslissingen te nemen. Wil je het zelf proberen: begin met honderd echte, geanonimiseerde profielen en tien vacatures waarvan je weet wie er destijds is aangenomen. Kijk of die persoon in de top tien staat. Dat ene getal zegt meer over je systeem dan elke demo.

Veelgestelde vragen

Heb ik MySQL 9 nodig voor vector search?

Nee. MySQL 9.7 Community heeft een VECTOR-type, maar geen afstandsfunctie en geen vectorindex. Het rekenen gebeurt dan nog steeds in PHP, net als met een JSON- of BLOB-kolom in MySQL 8.4. Wil je dat de database zelf afstanden berekent, kijk dan naar MySQL HeatWave, MariaDB 11.8 of PostgreSQL met pgvector.

Wat is een goede drempelwaarde voor cosine similarity?

Die bestaat niet in het algemeen. Bij text-embedding-3-small scoorden in mijn test alle tien profielen tussen 0,46 en 0,68, ook de niet-passende. Gebruik de rangorde in plaats van een drempel, of stel een drempel vast met voorbeelden uit je eigen gegevens waarvan je de juiste uitkomst kent.

Kan ik dit ook zonder OpenAI bouwen?

Ja. De architectuur blijft gelijk; alleen embed() en analyse() roepen een ander model aan, bijvoorbeeld een lokaal embeddingmodel en een lokaal taalmodel. Vectoren van verschillende modellen zijn onderling niet te vergelijken. Kies dus één model per database, en maak alles opnieuw als je overstapt.

Mag ik kandidaten automatisch laten selecteren door AI?

Niet volledig automatisch. Artikel 22 van de AVG verbiedt in principe besluiten die uitsluitend op geautomatiseerde verwerking berusten en iemand aanmerkelijk treffen. Volgens het SCHUFA-arrest kan een doorslaggevende score daar al onder vallen. Vanaf 2 december 2027 gelden daarnaast de verplichtingen voor hoog-risico-AI uit de AI-verordening. Laat het systeem ordenen en adviseren, en laat een mens beslissen.

Verder lezen op deze site

Bronnen

Deel dit artikel

Erik van de Blaak

Geschreven door

Erik van de Blaak

AI Solutions Engineer & Full-Stack Developer

Erik van de Blaak is AI Solutions Engineer en full-stack developer bij CareerValue BV. Hier schrijft hij over AI-codeertools en agents, zoals Claude Code.

Reacties (0)

Reageer op dit artikel

Wordt niet gepubliceerd

Ik lees elke reactie voordat die online komt.

Nog geen reacties op dit artikel.