Document-kwaliteit bepaalt RAG-kwaliteit
Garbage in, garbage out. Een RAG-systeem is zo goed als de documenten die het indexeert. Slechte tekst-extractie, ruis en ontbrekende context leiden direct tot slechte antwoorden.
Investeer tijd in de document-verwerkingspipeline. Het rendement is hoog: verbeteringen hier verbeteren het systeem voor alle queries, niet alleen voor een enkele vraag.
Werk per bestandstype, niet generiek
Een PDF, een Word-bestand en een webpagina hebben elk hun eigen ruis (paginanummers, ingebedde afbeeldingen, navigatie). Een aparte loader per type levert schonere tekst op dan een enkele universele parser.
PDF-extractie
import pdfParse from "pdf-parse";
import fs from "fs/promises";
interface ExtractedDocument {
content: string;
metadata: {
source: string;
page_count: number;
extracted_at: string;
word_count: number;
};
}
async function extractPDF(filePath: string): Promise<ExtractedDocument> {
const buffer = await fs.readFile(filePath);
const data = await pdfParse(buffer);
let content = data.text
.replace(/\f/g, "
")
.replace(/
{3,}/g, "
")
.replace(/[^\S
]+/g, " ")
.trim();
content = removePageHeaders(content);
return {
content,
metadata: {
source: filePath,
page_count: data.numpages,
extracted_at: new Date().toISOString(),
word_count: content.split(/\s+/).length,
},
};
}
function removePageHeaders(text: string): string {
const lines = text.split("
");
const lineFrequencies = new Map<string, number>();
lines.forEach((line) => {
const trimmed = line.trim();
if (trimmed && trimmed.length < 100) {
lineFrequencies.set(trimmed, (lineFrequencies.get(trimmed) ?? 0) + 1);
}
});
const repeatedLines = new Set(
Array.from(lineFrequencies.entries())
.filter(([, count]) => count >= 3)
.map(([line]) => line)
);
return lines
.filter((line) => !repeatedLines.has(line.trim()))
.join("
");
}
De removePageHeaders-functie zoekt regels die op minstens drie pagina's identiek terugkomen (paginakoppen, voetteksten, documenttitels) en haalt ze weg. Pas de drempel aan op de omvang van je documenten.
Word-documenten (.docx)
import mammoth from "mammoth";
async function extractDocx(filePath: string): Promise<ExtractedDocument> {
const { value: markdown } = await mammoth.convertToMarkdown({ path: filePath });
const content = markdown
.replace(/!\[.*?\]\(.*?\)/g, "")
.replace(/
{3,}/g, "
")
.trim();
return {
content,
metadata: {
source: filePath,
page_count: 0,
extracted_at: new Date().toISOString(),
word_count: content.split(/\s+/).length,
},
};
}
Gebruik de markdown-output van mammoth: die behoudt opmaakstructuur zoals koppen en lijsten, wat nuttig is voor sectie-gebaseerde chunking later in de pipeline.
HTML en webpagina's
import * as cheerio from "cheerio";
function extractHTML(html: string, url: string): ExtractedDocument {
const $ = cheerio.load(html);
$("script, style, nav, footer, header, .cookie-banner, .advertisement").remove();
const title = $("title").text() || $("h1").first().text();
const mainEl = $("main, article, .content, #content").first();
const raw = mainEl.length && mainEl.text().length > 200
? mainEl.text()
: $("body").text();
const content = raw
.replace(/\s+/g, " ")
.trim();
return {
content: `# ${title}
${content}`,
metadata: {
source: url,
page_count: 1,
extracted_at: new Date().toISOString(),
word_count: content.split(/\s+/).length,
},
};
}
Verwijder eerst de bekende ruis-elementen (script, style, navigatie, cookie-banners) en pak daarna bij voorkeur de main- of article-container. Val alleen terug op de hele body als er geen duidelijke hoofdinhoud is.
Vertrouw extern HTML niet blind
HTML van het web kan kwaadaardige of misleidende inhoud bevatten die als instructie in je RAG-context belandt (prompt injection). Behandel geextraheerde webtekst als onbetrouwbare data, scheid die duidelijk van je systeeminstructies en sla nooit ruwe scripts mee op.
Kwaliteitscontrole
Controleer elk document voordat je het indexeert. Een mislukte extractie die ongemerkt in je vector-database belandt, vervuilt de zoekresultaten voor lange tijd.
interface QualityCheck {
passes: boolean;
issues: string[];
}
function checkDocumentQuality(doc: ExtractedDocument): QualityCheck {
const issues: string[] = [];
if (doc.metadata.word_count < 50) {
issues.push("Document te kort (< 50 woorden), mogelijk extractiefout");
}
const letters = doc.content.match(/[a-zA-ZÀ-ž]/g)?.length ?? 0;
const charRatio = doc.content.length ? letters / doc.content.length : 0;
if (charRatio < 0.5) {
issues.push(
`Lage letter-ratio (${(charRatio * 100).toFixed(0)}%), mogelijk scanfout of binaire inhoud`
);
}
const repetitionRatio = detectRepetition(doc.content);
if (repetitionRatio > 0.3) {
issues.push("Veel herhalende tekst, mogelijk mislukte header- of footer-verwijdering");
}
return { passes: issues.length === 0, issues };
}
function detectRepetition(text: string): number {
const lines = text.split("
").map((l) => l.trim()).filter(Boolean);
if (lines.length === 0) return 0;
const unique = new Set(lines).size;
return 1 - unique / lines.length;
}
Drie controles vangen de meeste problemen af: een minimale tekstlengte (lege of mislukte extractie), een letter-ratio (gescande of binaire ruis) en een herhalingsratio (mislukte ruis-verwijdering).
Metadata-verrijking
Rijke metadata maakt latere filtering en routing mogelijk: je kunt zoekopdrachten beperken tot een documenttype of onderwerp. Laat een snel, goedkoop model de metadata afleiden.
async function enrichMetadata(doc: ExtractedDocument): Promise<ExtractedDocument> {
const metadataResponse = await anthropic.messages.create({
model: "claude-haiku-4-5",
max_tokens: 200,
messages: [
{
role: "user",
content: `Extraheer de volgende metadata uit dit document als JSON:
- document_type: (beleid/handleiding/rapport/faq/overig)
- topics: array van maximaal 5 hoofdonderwerpen
- summary: een zin van maximaal 100 woorden
Document (eerste 1000 tekens): ${doc.content.slice(0, 1000)}
Geef alleen geldige JSON terug:`,
},
],
});
const text =
metadataResponse.content[0].type === "text" ? metadataResponse.content[0].text : "{}";
const extracted = JSON.parse(text.match(/\{.*\}/s)?.[0] ?? "{}");
return {
...doc,
metadata: { ...doc.metadata, ...extracted },
};
}
Het model claude-haiku-4-5 is snel en goedkoop genoeg om op elk document te draaien. Stuur alleen de eerste 1000 tekens mee: dat is doorgaans genoeg voor type en onderwerp, en het scheelt kosten.
Bewaar de bron in de metadata
Neem altijd source en extracted_at op. Bij RAG-antwoorden kun je dan een bronverwijzing tonen, en bij een herziening van een document weet je welke versie nog in je index staat.
Een praktische verwerkingsvolgorde
Van bestand naar geindexeerd document
- Kies de loader op basis van het bestandstype:
extractPDF,extractDocxofextractHTML. - Verwijder ruis: paginakoppen, voetteksten, navigatie en herhalende regels.
- Draai
checkDocumentQualityen log of verwerp documenten die niet slagen. - Verrijk met metadata via
enrichMetadata(type, onderwerpen, samenvatting). - Bereken een inhouds-hash en sla duplicaten over.
- Geef het schone document door aan je chunking- en embedding-stap.
Hoe verwerk ik gescande PDF-bestanden zonder tekstlaag?
Gebruik een OCR-tool zoals Tesseract (via tesseract.js) of een cloud-OCR-dienst zoals Google Cloud Vision of Azure Document Intelligence. Controleer de OCR-kwaliteit daarna met de letter-ratio-check, want OCR levert vaak rommelige tekst op.
Moet ik tabellen speciaal behandelen?
Ja. Eenvoudige tabellen zetten mammoth en pdf-parse om naar redelijke tekst. Complexe tabellen met meerdere header-rijen of samengevoegde cellen verwerk je beter via een tabel-specifieke parser of een vision-model dat de lay-out begrijpt.
Hoe snel verwerk ik een grote documentset?
Verwerk parallel in batches van 10 tot 20 documenten en sla tussenresultaten op, zodat je bij een fout niet helemaal opnieuw hoeft te beginnen. Een typische PDF van 10 paginas verwerkt in minder dan twee seconden, exclusief de metadata-stap met een model.
Moet ik dubbele documenten verwijderen?
Ja. Bereken een hash van de schone inhoud en sla die op. Documenten met dezelfde hash sla je over, of je verwijdert de oudere versie. Dat voorkomt dat hetzelfde antwoord meerdere keren bovenaan je zoekresultaten verschijnt.
Wat doe ik met heel grote documenten?
Splits ze niet pas bij het embedden, maar houd tijdens de extractie al rekening met de sectiestructuur. Koppen en lijsten uit de markdown-output geven natuurlijke grenzen voor latere chunking, wat de context per chunk verbetert.
Hoe ga ik om met meertalige documenten?
Detecteer de taal per document of per sectie en sla die op in de metadata. Zo kun je later filteren op taal en het juiste embedding-model of de juiste prompt kiezen.