RAG
RAG op Drupal 9: retrofit in 45 minuten zonder editor-pijn
Vijfenveertig minuten van composer require tot een werkende /api/rag/search endpoint, terwijl de Drupal-redactie niets merkt. Dit is het playbook dat we gebruiken.

Het is dinsdag. Je supportlead vraagt voor de zoveelste keer waarom de zoekfunctie in het helpcentrum nog steeds de retourbeleid-PDF uit 2019 ophoest, terwijl er sinds mei een schone herziening live staat. De Drupal-site heeft zo'n 1.400 kennisbank-nodes. De redactie verhuist niet naar Notion. De CFO tekent geen replatform. Je hebt een betaald abonnement bij een model-leverancier en een middag vrij.
Dit is het playbook dat we gebruiken wanneer een klant retrieval-augmented generation wil koppelen aan een bestaande Drupal 9-installatie zonder de redactieflow te raken. Vijfenveertig minuten van composer require tot een werkende /api/rag/search endpoint. De redactie merkt bij het eerste opslaan niets. Bij het tweede opslaan zien ze een klein statusregeltje verschijnen.
De enige randvoorwaarde die telt
RAG-retrofits gaan stuk wanneer ze het CMS bevechten in plaats van meebewegen. De redactie heeft al een plek waar ze content opslaat: het node-formulier. Wat je ook bouwt, dat opslaan moet de enige bron van waarheid zijn. Geen tweede CMS. Geen "plak je artikel even in de embeddings-tool". Geen nachtelijke exportjobs die uit de pas gaan lopen met de gepubliceerde staat en vervolgens de schuld krijgen wanneer de chatbot een concept citeert.
De architectuur staat dus vast voordat je begint. Drupal blijft het system of record. Een sidecar regelt embeddings en vector search. De verbinding ertussen is één entity hook met een queue erachter. De rest is detail dat je kunt vervangen zonder de redactie van slag te brengen.
Drupal 9 is op 1 november 2023 end-of-life gegaan. Als je RAG aan D9 koppelt, plan dan tegelijk de overstap naar D10. De hook-signatures hier dragen probleemloos over, maar de security-klok tikt al ruim twee jaar.
Minuut 0 tot 10: inventariseer voordat je een regel schrijft
Open de database. Tel wat je daadwerkelijk gaat indexeren.
SELECT type, COUNT(*)
FROM node_field_data
WHERE status = 1
GROUP BY type
ORDER BY 2 DESC;
Je zoekt twee dingen. Ten eerste de content types met echte antwoorden (vaak kb_article, faq, product, policy). Ten tweede de types die dat nadrukkelijk niet hebben (landingspagina's, redirects, taxonomie-stubs, galerijen met alleen plaatjes). Het hele RAG-kwaliteitsverhaal begint hier. Embed je de navigatie-nodes, dan retourneert je retriever "Neem contact op" voor elke tweede query en is het supportteam tegen donderdag het vertrouwen in de bot kwijt.
Schrijf de allowlist op. Drie of vier content types is normaal. Is de site meertalig, controleer dan of body-velden per node vertaald worden of dat je één node per taal hebt. De retriever moet weten met welk taalslot er vergeleken wordt, dus dat antwoord landt rechtstreeks in het chunk-schema dat je zo gaat aanmaken.
Minuut 10 tot 15: kies de vector store
Drie redelijke antwoorden in 2026. pgvector als je al Postgres in de stack hebt (de meeste ops-teams hebben dat). Qdrant of Weaviate als je een aparte service wilt met een nette HTTP API en het prima vindt om die te draaien. Een managed vendor als je niets wilt draaien en finance akkoord is met per-query pricing.
Voor een kennisbank van 1.400 nodes is pgvector op dezelfde Postgres-instance die de Drupal-site al gebruikt lastig te kloppen. Eén back-up, één set credentials, één netwerkgrens, en je ops-team hoeft geen nieuwe service te leren. Maak de tabel nu aan.
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE rag_chunks (
id BIGSERIAL PRIMARY KEY,
node_id INT NOT NULL,
node_type TEXT NOT NULL,
revision_id INT NOT NULL,
langcode TEXT NOT NULL DEFAULT 'en',
chunk_index INT NOT NULL,
body TEXT NOT NULL,
embedding VECTOR(1536) NOT NULL,
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX rag_chunks_node_idx ON rag_chunks (node_id, langcode);
CREATE INDEX rag_chunks_embedding_idx
ON rag_chunks USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);
Minuut 15 tot 30: de sync hook
Dit is het enige stukje Drupal-code dat binnen de site moet wonen. Maak er een klein custom module van, geen contrib-patch.
# modules/custom/abn_rag/abn_rag.info.yml
name: 'ABN RAG sync'
type: module
description: 'Push knowledge-base nodes into the vector store on save.'
core_version_requirement: ^9.5 || ^10
package: 'ABN'
dependencies:
- drupal:node
// modules/custom/abn_rag/abn_rag.module
<?php
use Drupal\Core\Entity\EntityInterface;
const ABN_RAG_TYPES = ['kb_article', 'faq', 'policy'];
function abn_rag_node_insert(EntityInterface $node) {
abn_rag_queue($node, 'upsert');
}
function abn_rag_node_update(EntityInterface $node) {
abn_rag_queue($node, 'upsert');
}
function abn_rag_node_delete(EntityInterface $node) {
abn_rag_queue($node, 'delete');
}
function abn_rag_queue(EntityInterface $node, string $op) {
if (!in_array($node->bundle(), ABN_RAG_TYPES, true)) {
return;
}
if ($op !== 'delete' && !$node->isPublished()) {
return;
}
\Drupal::queue('abn_rag_sync')->createItem([
'op' => $op,
'nid' => (int) $node->id(),
'vid' => (int) $node->getRevisionId(),
'lang' => $node->language()->getId(),
]);
}
Drie dingen om op te merken. De hook embed nooit inline; hij plaatst alleen op de queue, zodat een trage embeddings-API-call het opslaan van de redactie niet kan blokkeren. Hij sleutelt op de revision ID, waardoor een revert via hetzelfde codepad een verse upsert ophaalt als elke andere update. En hij valt af op niet-gepubliceerde nodes, waardoor concepten buiten de index blijven tot ze echt live gaan. Die laatste guard mag je niet weglaten, vooral niet als de site Drupals content moderation workflow gebruikt.
De worker is gewone Drupal queue API. De cron runner pakt hem op een normale site elke minuut op; op een drukkere site draai je drush queue:run abn_rag_sync vanuit een supervisor-proces.
// src/Plugin/QueueWorker/RagSyncWorker.php
namespace Drupal\abn_rag\Plugin\QueueWorker;
use Drupal\Core\Queue\QueueWorkerBase;
use Drupal\node\Entity\Node;
/**
* @QueueWorker(
* id = "abn_rag_sync",
* title = @Translation("RAG sync"),
* cron = {"time" = 30}
* )
*/
class RagSyncWorker extends QueueWorkerBase {
public function processItem($data) {
$indexer = \Drupal::service('abn_rag.indexer');
if ($data['op'] === 'delete') {
$indexer->delete($data['nid']);
return;
}
$node = Node::load($data['nid']);
if ($node && $node->isPublished()) {
$indexer->upsert($node, $data['lang']);
}
}
}
De RagIndexer-service doet het echte werk: strip de CKEditor-HTML naar platte tekst, chunk op paragraaf-grenzen met een overlap van 200 tokens, raak het embeddings-endpoint, schrijf naar rag_chunks. Ongeveer zestig regels PHP als je het saai houdt. Hou de token count per chunk conservatief; redacteuren plakken lange tabellen en je wilt niet dat één chunk het context window opblaast van welk model de downstream supportbot ook gebruikt.
Minuut 30 tot 40: de retrieval endpoint
Stel één read-only route bloot. Geen write-oppervlak, geen auth-bypass, geen "oh, dan voegen we later wel een search endpoint toe aan dezelfde controller"-verleiding.
# abn_rag.routing.yml
abn_rag.search:
path: '/api/rag/search'
defaults:
_controller: '\Drupal\abn_rag\Controller\RagSearchController::search'
methods: [POST]
requirements:
_permission: 'access content'
public function search(Request $request) {
$payload = json_decode($request->getContent(), true);
$query = trim($payload['q'] ?? '');
$k = min((int) ($payload['k'] ?? 5), 20);
$lang = $payload['lang'] ?? 'en';
if ($query === '') {
return new JsonResponse(['hits' => []]);
}
$vec = $this->embedder->embed($query);
$rows = $this->connection->query(
'SELECT node_id, body, 1 - (embedding <=> :v) AS score
FROM rag_chunks
WHERE langcode = :lang
ORDER BY embedding <=> :v
LIMIT :k',
[':v' => $vec, ':lang' => $lang, ':k' => $k]
)->fetchAll();
return new JsonResponse(['hits' => $rows]);
}
De <=>-operator is pgvectors cosine distance. Lager is dichterbij; we draaien hem om naar een similarity score zodat de chatbot-prompt een drempel als score > 0.78 kan gebruiken om te beslissen of er überhaupt geantwoord wordt. Weigeren te antwoorden wanneer retrieval zwak is, is de grootste kwaliteitsknop die je hebt. De meeste klachten over "hallucinerende chatbots" die we in klantaudits zien, gaan terug op een retrieval-laag die nooit "ik weet het niet" zei en een generation-prompt die maar wat verzon op basis van de top-1 chunk, hoe slecht die ook was.
Minuut 40 tot 45: geef de redactie een groen lampje
Dit is het stukje dat je vertrouwen oplevert bij de mensen die de content onderhouden. Voeg een klein blok toe aan het node-bewerkformulier dat het aantal chunks en het tijdstip van de laatste indexering voor de huidige node toont. Twee regels hook_form_node_form_alter, één query tegen rag_chunks en een render-array snippet.
Wanneer een redacteur een beleidsupdate opslaat en even later "4 chunks geïndexeerd, 2 seconden geleden" onder de titel ziet verschijnen, stopt het twijfelen of de bot het memo heeft ontvangen. Die piepkleine zichtbare feedback loop is meer waard dan welk intern dashboard ook. Het geeft QA bovendien een plek om te verifiëren voor ze gaan publiceren, wat minder Slack-pings in jouw inbox betekent.
Wat we bewust niet hebben gebouwd
Geen re-ranker. Geen hybride keyword + vector fusion. Geen semantische cache. Geen per-tenant toegangscontrole. Geen image embeddings. Dat zijn allemaal echte dingen, een aantal ervan is nuttig, en ze horen allemaal in de fase na de 45 minuten. Het doel van de retrofit is om vandaag een verdedigbare baseline in productie te krijgen, zodat je kunt meten waar het in de praktijk breekt voordat je een week stopt in het oplossen van een probleem dat de supporttickets niet hebben.
Multimodale embeddings, hybride sparse-plus-dense retrieval en graph-augmented varianten zijn allemaal echte technieken die de moeite waard zijn wanneer de data erom vraagt. Bestaat je kennisbank uit 1.400 tekst-nodes, dan heb je die problemen nog niet. Los eerst de saaie tekst-retrieval op. Voeg de fancy onderdelen pas toe als de ticketdata je precies vertelt welke je nodig hebt.
De test van vijf minuten die je als eerste schrijft
Voordat je het af noemt, schrijf je één PHPUnit kernel test die een node aanmaakt, de queue draait, het endpoint queryt, en bevestigt dat de node als top-1 terugkomt. Hij faalt op de dag dat een Drupal point release een hook signature wijzigt of een embeddings-provider stilletjes zijn model rouleert. Precies de dag waarop je dat wilt weten.
public function testIndexedNodeIsRetrievable() {
$node = $this->createNode([
'type' => 'kb_article',
'title' => 'Return window',
'body' => 'Customers may return items within 30 days of delivery.',
]);
$this->runQueue('abn_rag_sync');
$hits = $this->ragSearch('how long do I have to return something');
$this->assertSame((int) $node->id(), (int) $hits[0]['node_id']);
}
Wat we tegenkwamen bij een echte klus
Toen we vorig kwartaal de supportbot-retrofit bouwden voor het Drupal 9-portaal van een Nederlandse verzekeringsmakelaar, was het ding waar we tegenaan liepen niet de embeddings of de vector store. Het was de redactieflow: hun compliance-team herziet beleidsnodes via Drupals content moderation states, en de eerste versie van onze sync duwde vrolijk in-review concepten de index in, omdat hook_entity_update bij elke transitie afgaat. We hebben dat opgelost met de isPublished() guard hierboven en een aparte listener voor het published moderation event, zodat de redactie de workflow waarin ze getraind zijn gewoon kon blijven gebruiken. Dat soort voetnoten scheidt het retrofitten van AI-agents op een verouderd CMS van greenfield-werk.
Wil je dit vandaag proberen: draai het SQL-blok hierboven tegen een staging-kopie van je Postgres, scaffold de module-skelet, en houd de tijd bij. Vijfenveertig minuten is haalbaar. Wat de rest van de week kost, is beslissen welke van je content types daadwerkelijk in de index hoort, en dat gesprek voer je het beste met de redactie bij een kop koffie, niet via een Jira-ticket.
Kern
Behandel Drupal als de bron van waarheid en bout RAG eraan vast als een queue-gedreven sidecar. De redactieflow blijft onaangeroerd, en de versheid van je vectoren krijg je gratis bij elk opslaan.
FAQ
Waarom pgvector in plaats van een aparte vector database?
Omdat de Drupal-site Postgres al heeft. Eén back-up, één credential, één netwerkgrens. Stap pas over op Qdrant of een managed vendor wanneer query-volume of het aantal vectoren pgvector traag maakt, niet eerder.
Werkt dit ook op Drupal 10?
Ja, zonder codewijzigingen. De info.yml verklaart al compatibiliteit met 9.5 en 10. De hook-signatures, queue API en het routing-formaat zijn identiek op beide releases.
Vertraagt het indexeren het opslaan van nodes voor de redactie?
Nee. De hook plaatst alleen op de queue. De embedding-call en de vector-write gebeuren in de queue worker, die buiten de request draait. Het opslaan door de redactie keert net zo snel terug als voor de module geïnstalleerd was.
Hoe houd je niet-gepubliceerde concepten uit de chatbot?
De hook valt af op isPublished() voordat hij op de queue plaatst, en de worker controleert het opnieuw voor het upserten. Concepten en moderation states bereiken de vector store nooit, ook niet als een redacteur herhaaldelijk opslaat.