
Wie linked data publiceert, kent het probleem: de dataset zelf is rijk aan informatie, maar de beschrijving voor eindgebruikers blijft vaak leeg, te technisch of te generiek. Terwijl juist die beschrijving bepaalt of iemand begrijpt wat hij voor zich heeft.
In LDMax is dat proces daarom grotendeels geautomatiseerd. Niet door een taalmodel "zomaar iets" te laten verzinnen, maar door eerst gerichte context uit een SPARQL-endpoint op te halen en die context vervolgens als feitelijke basis aan een LLM mee te geven. Het resultaat is een datasetbeschrijving die kort, begrijpelijk en inhoudelijk beter onderbouwd is.
De kern van de aanpak is simpel: eerst halen we informatie op uit de graph, daarna laten we een model daar een compacte tekst van maken.
Voor zo’n datasetbeschrijving gebruiken we niet alleen de metadata die al in de applicatie staat, zoals naam, taal, licentie, trefwoorden of organisatie. We vullen dat aan met context uit het SPARQL-endpoint zelf. Daarmee krijgt het model niet alleen te zien hoe de dataset heet, maar ook waar de dataset inhoudelijk in zit.
Concreet bouwen we context op uit vijf bronnen:
algemene indexstatistieken van het endpoint, zoals aantallen triples, subjects, predicates en objects de meest voorkomende classes in de dataset de meest voorkomende predicates alle triples die direct aan de dataset-URI hangen inkomende links naar die dataset-URI vanuit andere resources
Dat laatste is belangrijk. Een datasetbeschrijving wordt veel sterker als je niet alleen kijkt naar "wat zegt de dataset over zichzelf?", maar ook naar "hoe verwijst de rest van de graph naar deze dataset?". Juist daar zie je vaak de semantische rol van een dataset.
Een vereenvoudigde versie van de SPARQL-queries die hiervoor gebruikt worden, ziet er zo uit.
Voor de meest voorkomende classes:
SELECT ?class (COUNT(DISTINCT ?s) AS ?count) WHERE {
?s a ?class .
}
GROUP BY ?class
ORDER BY DESC(?count)
LIMIT 12Voor de meest voorkomende predicates:
SELECT ?predicate (COUNT(*) AS ?count) WHERE {
?s ?predicate ?o .
}
GROUP BY ?predicate
ORDER BY DESC(?count)
LIMIT 16Voor triples die direct aan de dataset hangen:
SELECT ?predicate ?object WHERE {
<https://platform.ldmax.nl/datasets/organisatie/dataset> ?predicate ?object .
}
ORDER BY ?predicate
LIMIT 80Voor inkomende links naar de dataset:
SELECT ?subject ?predicate WHERE {
?subject ?predicate <https://platform.ldmax.nl/datasets/organisatie/dataset> .
}
ORDER BY ?predicate
LIMIT 40Daarna zetten we alles om naar een compact JSON-contextobject. Dus niet een losse berg triples, maar een gestructureerd pakket met precies de informatie die een model nodig heeft om een feitelijke beschrijving te schrijven.
Dat contextobject bevat bijvoorbeeld:
- dataset-IRI
- slug en naam
- bestaande beschrijving, als die er al is
- talen en licentie
- trefwoorden, genre, ruimtelijke en temporele dekking
- top classes
- top predicates
- dataset-triples
- inbound links
- endpointstatistieken
Vervolgens krijgt het taalmodel een strakke opdracht mee. Geen open creatieve prompt, maar juist een beperkte instructie, bijvoorbeeld:
- schrijf in vloeiend Nederlands
- geef alleen geldige JSON terug
- gebruik uitsluitend informatie uit de context
- maximaal 120 woorden
- feitelijk en zonder marketingtaal
- formuleer neutraal als iets onzeker is
De prompt die ik gebruik ziet er zo uit:
Schrijf in vloeiend Nederlands. Geef alleen geldige JSON terug met key "description". Maximaal 120 woorden, feitelijk en zonder marketingtaal.. Gebruik alleen informatie die ondersteund wordt door de context hieronder. Als iets onzeker is, formuleer neutraal.
Dat werkt beter dan een vrijblijvende vraag als "schrijf een mooie datasetbeschrijving". Je wilt bij dit soort toepassingen namelijk geen marketingcopy, maar redactionele precisie.
Verder vertel ik tegen het LLM wat de rol is die die het moet aannemen:
Je bent een dataredacteur die feitelijke, heldere datasetbeschrijvingen schrijft.
Wat ik hier interessant aan vind, is dat SPARQL en LLM’s elkaar niet vervangen maar juist aanvullen.
SPARQL is goed in het exact en controleerbaar ophalen van feiten uit een graph. Een LLM is goed in het omzetten van die feiten naar leesbare taal. Als je die rollen zuiver houdt, krijg je een veel betrouwbaarder resultaat dan wanneer je een model zelf laat raden waar een dataset over gaat.
Met andere woorden:
SPARQL doet de kennisextractie. Het LLM doet de taalredactie.
Dat is ook precies waarom de prompt niet begint met vrije tekst, maar met gestructureerde context. Eerst ophalen wat aantoonbaar in de data zit, daarna pas genereren.
Een extra voordeel is dat je zo ook goed fallbackgedrag kunt inbouwen. Als er geen API-key is, of als de modeloutput ongeldig is, kun je altijd terugvallen op een simpele beschrijving op basis van bestaande metadata, bijvoorbeeld: naam van de dataset, publicerende organisatie en de belangrijkste thema’s. Daardoor blijft het systeem robuust.
Voor mij is dit een goed voorbeeld van hoe je AI praktisch inzet in linked data-omgevingen. Niet als black box bovenop de data, maar als laatste stap in een gecontroleerde keten:
identificeer de dataset
- haal context op uit de graph
- structureer die context
- geef het model een beperkte, toetsbare opdracht
- valideer de output
-sla alleen bruikbare beschrijvingen op
Zo maak je datasetbeschrijvingen schaalbaar, zonder de semantische kwaliteit uit het oog te verliezen.
Wie met linked data werkt, weet dat goede metadata vaak het verschil maakt tussen "ergens gepubliceerd" en "daadwerkelijk bruikbaar". Juist daarom is dit soort AI-ondersteuning interessant: niet om metadata te vervangen, maar om bestaande semantische structuur beter toegankelijk te maken voor mensen.
Voorbeeld datasetbeschrijving
Deze beschrijving is gemaakt voor de dataset van de Jan Menze van Diepen Stichting:
De Jan Menze van Diepen Collectie omvat Nederlandse en Europese kunstwerken zoals prenten, schilderijen en keramiek. De collectie wordt beheerd door de Stichting Menze van Diepen en is gericht op kunst uit de periode 1000-1900. De objecten zijn voornamelijk afkomstig uit Nederland, met een focus op Groningen. De collectie bevat ook Aziatische keramiek en glas. De stukken worden tentoongesteld in musea en exposities. De dataset bevat 447 triples met informatie over 94 onderwerpen en 27 eigenschappen, waaronder creatieve werken en afbeeldingen. De collectie is beschikbaar onder een Creative Commons-licentie (BY 4.0).
JavaScript-voorbeeld
Dit voorbeeld laat dezelfde flow zien die ik in LDMax gebruik, maar dan in een losse Node.js-setting: SPARQL-context ophalen, prompt bouwen en die naar OpenAI sturen. Dit bestand kun je ook als Github GIST bekijken.
import fetch from "node-fetch";
const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
const OPENAI_MODEL = process.env.OPENAI_MODEL || "gpt-4.1-mini";
const SPARQL_ENDPOINT = "https://example.org/sparql";
const DATASET_IRI = "https://platform.ldmax.nl/datasets/organisatie/dataset";
async function runSparql(query) {
const response = await fetch(SPARQL_ENDPOINT, {
method: "POST",
headers: {
"content-type": "application/sparql-query",
accept: "application/sparql-results+json"
},
body: query
});
if (!response.ok) {
throw new Error(`SPARQL query failed: ${response.status} ${await response.text()}`);
}
return response.json();
}
function bindingsToRows(json) {
return (json.results?.bindings || []).map((binding) => {
const row = {};
for (const [key, value] of Object.entries(binding)) {
row[key] = value.value;
}
return row;
});
}
async function getTopClasses() {
const query = `
SELECT ?class (COUNT(DISTINCT ?s) AS ?count) WHERE {
?s a ?class .
}
GROUP BY ?class
ORDER BY DESC(?count)
LIMIT 12
`;
return bindingsToRows(await runSparql(query));
}
async function getTopPredicates() {
const query = `
SELECT ?predicate (COUNT(*) AS ?count) WHERE {
?s ?predicate ?o .
}
GROUP BY ?predicate
ORDER BY DESC(?count)
LIMIT 16
`;
return bindingsToRows(await runSparql(query));
}
async function getDatasetTriples(datasetIri) {
const query = `
SELECT ?predicate ?object WHERE {
<${datasetIri}> ?predicate ?object .
}
ORDER BY ?predicate
LIMIT 80
`;
return bindingsToRows(await runSparql(query));
}
async function getInboundLinks(datasetIri) {
const query = `
SELECT ?subject ?predicate WHERE {
?subject ?predicate <${datasetIri}> .
}
ORDER BY ?predicate
LIMIT 40
`;
return bindingsToRows(await runSparql(query));
}
async function buildContext() {
const [topClasses, topPredicates, datasetTriples, inboundLinks] = await Promise.all([
getTopClasses(),
getTopPredicates(),
getDatasetTriples(DATASET_IRI),
getInboundLinks(DATASET_IRI)
]);
return {
dataset: {
iri: DATASET_IRI,
name: "Voorbeeld dataset",
description: "",
inLanguage: ["nl"],
license: "https://creativecommons.org/publicdomain/zero/1.0/",
keywords: ["erfgoed", "collecties", "linked data"]
},
organisation: {
slug: "voorbeeld-organisatie",
name: "Voorbeeld Organisatie",
type: "Organization"
},
topClasses,
topPredicates,
datasetTriples,
inboundLinks
};
}
function buildPrompt(context) {
return [
"Schrijf in vloeiend Nederlands.",
'Geef alleen geldige JSON terug met key "description".',
"Maximaal 120 woorden, feitelijk en zonder marketingtaal.",
"Gebruik alleen informatie die ondersteund wordt door de context hieronder.",
"Als iets onzeker is, formuleer neutraal.",
"--- CONTEXT START ---",
JSON.stringify(context, null, 2),
"--- CONTEXT END ---"
].join("\n");
}
async function generateDescription(prompt) {
if (!OPENAI_API_KEY) {
throw new Error("OPENAI_API_KEY ontbreekt");
}
const response = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
authorization: `Bearer ${OPENAI_API_KEY}`,
"content-type": "application/json"
},
body: JSON.stringify({
model: OPENAI_MODEL,
response_format: { type: "json_object" },
temperature: 0.2,
messages: [
{
role: "system",
content: "Je bent een dataredacteur die feitelijke, heldere datasetbeschrijvingen schrijft."
},
{
role: "user",
content: prompt
}
]
})
});
if (!response.ok) {
throw new Error(`OpenAI request failed: ${response.status} ${await response.text()}`);
}
const data = await response.json();
const raw = data.choices?.[0]?.message?.content || "{}";
const parsed = JSON.parse(raw);
if (typeof parsed.description !== "string" || !parsed.description.trim()) {
throw new Error("Geen geldige description ontvangen");
}
return parsed.description.trim();
}
async function main() {
const context = await buildContext();
const prompt = buildPrompt(context);
const description = await generateDescription(prompt);
console.log("Gegenereerde datasetbeschrijving:\n");
console.log(description);
}
main().catch((error) => {
console.error(error);
process.exit(1);
});