Werkplaats / De werkplaats
In jouw eigen tempo
← RAG
MODULE 01 · LES 01

Knip bronnen op zonder hun herkomst kwijt te raken

Bewaar exacte brontekst en versie, maak overlappende passages en meet hoeveel tekst wordt gedupliceerd.

60 min3 checkpoints
DIT GA JE ONTDEKKEN
  • Je bewaart bij iedere passage het document, de versie en de oorspronkelijke tekenposities.
  • Je meet dekking en duplicatie bij verschillende chunkgroottes en overlap.

Probeer ook echte tekstvectoren in het embedding-lab.

Een antwoord citeert “Lamp aan”, maar de handleiding is inmiddels veranderd naar “Lamp uit”. Het document-ID bleef gelijk. Zonder bronversie kun je niet meer vaststellen bij welke tekst het oude citaat hoorde.

Retrieval-augmented generation (RAG) haalt informatie op en geeft die als context aan een antwoordmodel. We bouwen eerst de zoekkant: bronnen vastleggen, passages maken en hun herkomst bewaren. De oorspronkelijke RAG-publicatie koppelde een dense retriever aan een generatief model; onze route onderzoekt de onderdelen afzonderlijk.

1. Leg de exacte bron vast

Een snapshot bewaart id, title en de oorspronkelijke text. Bereken revision als SHA-256 over de UTF-8-bytes van die tekst. Ook witruimte, hoofdletters en regeleinden horen bij de versie waarnaar een citaat verwijst.

source = '  A  B\nC D E F G H  '
raw = [dict(id='d',title='Demo',
            text=source)]
doc = document_snapshots(raw)[0]
import hashlib
digest = hashlib.sha256(
    source.encode('utf-8')).hexdigest()
print(doc['revision'] == digest)  # True
print(doc['text'] == source)  # True
print(len(doc['revision']))  # 64
changed = document_snapshots([
    {**raw[0],'text':source+'!'}])[0]
print(changed['revision'] ==
      doc['revision'])  # False

Maak document_snapshots met nieuwe dictionaries. Accepteer 1–32 documenten, unieke begrensde IDs, een niet-lege titel en maximaal 20000 Python-tekens brontekst. Normaliseer de tekst niet voordat je de hash of offsets maakt.

Een versiehash is een vingerafdruk van bytes. Hij bewijst geen waarheid, auteur of toegangsrecht. Verandert een bron, dan moet je afgeleide passages en vectoren opnieuw op de juiste versie afstemmen.

01
CHECKPOINTEen citaat hoort bij één concrete bronversie

2. Maak woord- en tekenposities zichtbaar

Onze eerste chunker telt niet-witruimtereeksen met regex \S+. Dat zijn oefenwoorden en geen modeltokens. Met re.finditer krijg je behalve de woorden ook hun exacte tekenposities.

import re
words = list(re.finditer(r'\S+',source))
print(words[0].span())  # (2, 3)
print(source[2:3])  # A

Het eerste woord heeft woordindex nul, maar begint op tekenindex twee door de voorloopspaties. Een einde is exclusief: source[2:3] neemt teken twee mee en stopt vóór teken drie. De tekst van een passage is altijd dezelfde uitsnede uit de oorspronkelijke bron.

Neem venster W=4 en overlap O=1. Iedere nieuwe passage begint W−O=3 woorden verder: intervallen [0,4), [3,7) en [6,8). Stop zodra het laatste woord is opgenomen, zodat je geen extra staart met alleen overlap maakt.

chunks = word_chunks(doc,4,1)
for chunk in chunks:
    print(chunk['id'],
          chunk['start'],chunk['end'])
# d:0 2 10
# d:3 9 16
# d:6 15 18

Maak word_chunks met IDs document-ID:woordstart. Bewaar daarnaast document_id, revision, woordgrenzen, tekengrenzen en de exacte tekst. In de eerste passage blijft de dubbele spatie en interne newline behouden; witruimte buiten de gekozen woorden valt buiten de passage.

De grenzen zijn Python-tekenindices. Een JavaScript-client moet Unicode-posities expliciet omzetten. Zins- en paragraafgrenzen kunnen later nuttiger zijn, maar ook die keuze moet je op echte zoekvragen beoordelen.

EEN EIGEN BRON · EXACTE POSITIESWelke woorden sla je meerdere keren op?

Vensters tellen hier woorden, geen modeltokens. Kies een passage om haar exacte bronbereik te zien.

0De1lamp2brandt3groen4na5reset.6Houd7de8knop9tien10seconden11vast12voor13herstel.
De lamp brandt groen na reset.

Bron: eigen-lamp · tekenbereik [0, 30) in deze vaste tekst.

Passages3Dekking14 / 14Duplicatiefactor1.29×
02
CHECKPOINTOverlap bewaart context maar kost ruimte

3. Meet dekking en extra opslag afzonderlijk

De drie passages bewaren 4+4+2=10 woordvoorkomens. Ze dekken acht unieke oorspronkelijke posities. Noem het oorspronkelijke aantal N, het aantal unieke gedekte posities U en de opgeslagen voorkomens S.

coverage=UNduplicatie=SU

Hier is coverage 8/8=1 en de duplicatiefactor 10/8=1.25. Meer overlap kan samenhang bewaren, maar kost opslag en embeddingwerk. Het garandeert geen betere relevante passages.

r = chunk_coverage(doc,chunks)
print(r['word_occurrences'])  # 10
print(r['covered_words'])  # 8
print(r['duplication_factor'])  # 1.25
empty = chunk_coverage(doc,[])
print(empty['coverage'],
      empty['duplication_factor'])
# 0.0 None

Maak chunk_coverage als onafhankelijke audit van versie, IDs, offsets en tekst. Rapporteer ook ontbrekende woordindices. Zonder chunks is coverage nul en de duplicatiefactor None, omdat er geen gedekte woorden zijn.

03
CHECKPOINTTel originele woorden en opgeslagen voorkomens apart

Wat neem je mee?

  • Versie en offsets verbinden een passage met concrete brontekst.
  • Woordindices, tekenindices en modeltokens zijn verschillende eenheden.
  • Coverage meet dekking; duplicatie meet extra opgeslagen voorkomens.

Hierna zet je passages en vragen om in vergelijkbare vectoren.

Verder lezen & bronnen

Inhoud gecontroleerd op 2026-10-28.
DE VOLGENDE STAPVergelijk passages in dezelfde embeddingruimte
JOUW EXPERIMENT

Probeer het zelf

Python
main.py

Python start zodra jij code uitvoert.

UITVOER

Je uitvoer verschijnt hier.
Alles rekent in je eigen browser.

Jouw checkpoints0 / 3
01
Een citaat hoort bij één concrete bronversie

Schrijf document_snapshots(records). Geef 1–32 dictionaries exact id/title/text. IDs uniek en [A-Za-z0-9_-]{1,40}; title niet-leeg na strip() en ≤100 tekens, text niet-leeg na strip() en ≤20000. Retourneer nieuwe dictionaries met extra revision=SHA-256 van de exacte UTF-8-tekst. Ongeldige invoer of ongeldige UTF-8: ValueError.

Een hint

Gebruik hashlib.sha256(text.encode("utf-8")).hexdigest(). Gebruik strip alleen voor leegtecontrole; sla de oorspronkelijke tekst op.

02
Overlap bewaart context maar kost ruimte

Schrijf word_chunks(document,window=40,overlap=8). Controleer snapshotvelden en revision tegen de tekst. window is exacte int 1–256, overlap exacte int 0≤overlap<window. Geef id=document-ID:eerste_woordindex, document_id, revision, word_start/word_end, start/end en text. Ongeldig: ValueError.

Een hint

Bewaar re.finditer-matches zodat je tekst niet hoeft te reconstrueren met spaties. Stop na een chunk waarvan last gelijk is aan het aantal woorden.

03
Tel originele woorden en opgeslagen voorkomens apart

Schrijf chunk_coverage(document,chunks) met total_words, covered_words, coverage, word_occurrences, duplication_factor en missing_word_indices. Valideer de snapshot en maximaal 20000 chunks met exact de velden van word_chunks, unieke IDs, juiste bron/versie, exacte int-intervallen en broncitaat. Ongeldig: ValueError.

Een hint

Een set woordindices meet unieke dekking; som(word_end−word_start) meet alle voorkomens. Gelijke tekst op twee posities zijn twee verschillende bronposities.

Vastgelopen? Bekijk de voorbeeldoplossing
import re
import hashlib
import numpy as np

def document_snapshots(records):
    if not isinstance(records, list) or not 1 <= len(records) <= 32:
        raise ValueError('Geef 1 tot 32 documenten')
    seen, result = set(), []
    for row in records:
        if not isinstance(row, dict) or set(row) != {'id', 'title', 'text'}:
            raise ValueError('Document heeft exact id/title/text')
        if not isinstance(row['id'], str) or not re.fullmatch(r'[A-Za-z0-9_-]{1,40}', row['id']) or row['id'] in seen:
            raise ValueError('Unieke begrensde document-ID vereist')
        if not isinstance(row['title'], str) or not 1 <= len(row['title'].strip()) or len(row['title']) > 100:
            raise ValueError('Titel is niet leeg en maximaal 100 tekens')
        if not isinstance(row['text'], str) or not row['text'].strip() or len(row['text']) > 20000:
            raise ValueError('Brontekst is niet leeg en maximaal 20000 tekens')
        try:
            revision = hashlib.sha256(row['text'].encode('utf-8')).hexdigest()
        except UnicodeError as exc:
            raise ValueError('Tekst moet geldige UTF-8 zijn') from exc
        seen.add(row['id'])
        result.append({**row, 'revision': revision})
    return result

def word_chunks(document, window=40, overlap=8):
    if not isinstance(document, dict) or set(document) != {'id', 'title', 'text', 'revision'}:
        raise ValueError('Geef een document-snapshot')
    checked = document_snapshots([{k: document[k] for k in ['id', 'title', 'text']}])[0]
    if document['revision'] != checked['revision']:
        raise ValueError('Documenttekst en versiehash verschillen')
    if type(window) is not int or not 1 <= window <= 256 or type(overlap) is not int or not 0 <= overlap < window:
        raise ValueError('Window 1–256, overlap 0 tot window-1')
    words = list(re.finditer(r'\S+', document['text']))
    result, first = [], 0
    while first < len(words):
        last = min(first + window, len(words))
        start, end = words[first].start(), words[last - 1].end()
        result.append({'id': f'{document["id"]}:{first}', 'document_id': document['id'],
                       'revision': document['revision'], 'word_start': first, 'word_end': last,
                       'start': start, 'end': end, 'text': document['text'][start:end]})
        if last == len(words):
            break
        first += window - overlap
    return result

def chunk_coverage(document, chunks):
    # Reuse the snapshot check without changing the source text.
    word_chunks(document, 256, 0)
    if not isinstance(chunks, list) or len(chunks) > 20000:
        raise ValueError('Geef maximaal 20000 chunks')
    words = list(re.finditer(r'\S+', document['text']))
    covered, seen, occurrences = set(), set(), 0
    for chunk in chunks:
        if not isinstance(chunk, dict) or set(chunk) != {'id', 'document_id', 'revision', 'word_start', 'word_end', 'start', 'end', 'text'}:
            raise ValueError('Ongeldige chunkvelden')
        a, b = chunk['word_start'], chunk['word_end']
        if type(a) is not int or type(b) is not int or not 0 <= a < b <= len(words):
            raise ValueError('Ongeldig woordinterval')
        if chunk['id'] != f'{document["id"]}:{a}' or chunk['id'] in seen or chunk['document_id'] != document['id'] or chunk['revision'] != document['revision']:
            raise ValueError('Chunk-ID, document of versie klopt niet')
        start, end = words[a].start(), words[b - 1].end()
        if type(chunk['start']) is not int or type(chunk['end']) is not int or chunk['start'] != start or chunk['end'] != end or chunk['text'] != document['text'][start:end]:
            raise ValueError('Chunktekst/offsets komen niet overeen met de bron')
        seen.add(chunk['id']);covered.update(range(a, b));occurrences += b - a
    return {'total_words': len(words), 'covered_words': len(covered),
            'coverage': len(covered) / len(words), 'word_occurrences': occurrences,
            'duplication_factor': occurrences / len(covered) if covered else None,
            'missing_word_indices': sorted(set(range(len(words))) - covered)}

Voer de code uit en verklaar voor jezelf waarom elke stap werkt.