Werkplaats / Notebook
In jouw eigen tempo
← Alle bouwprojecten
RAG · PYTHON NOTEBOOK

Bouw je eigen zoekende antwoordgever

Van zes fictieve bronnen naar een eigen index, begrensde context, controleerbare citaten en een eerlijke eindtest.

120 min5 cellen
De opdracht, leerdoelen en voorkennis
  • Je bouwt een volledige lokale RAG-keten op eigen brontekst.
  • Je scheidt bronzoeken, antwoordregels, devselectie en eindtest met werkelijke meetresultaten.

Lees eerst: Bouw een evaluatieharnas met een echte eindtest · Combineer woordmatches en vectorburen via hun rang · Maak ieder citaat terugvindbaar · Zoek uit waar de RAG-pijplijn faalt.

Een antwoord kan correct klinken terwijl de opgehaalde bron verkeerd is. In dit bouwproject maak je iedere stap zichtbaar: bronindex, zoekresultaten, context, extract, citaat en meetrij.

De zes apparaatteksten zijn eigen fictieve onderwijsdata. De generator kopieert een gecontroleerde passage volgens vaste regels; hij is geen LLM. Alle indexgewichten en evaluatiewaarden worden werkelijk in Python berekend.

1. Bouw eerst een indexje dat je begrijpt

Begin met “lamp” en “lamp lamp lamp sensor”. De alfabetische woordenschat is lamp,sensor. De termfrequenties zijn [1,0] en [3,1]; de tekstlengten zijn één en vier tokens.

toy = [
    dict(id='a', title='Kort',
         text='lamp'),
    dict(id='b', title='Lang',
         text='lamp lamp lamp sensor')
]
mini = build_rag_index(toy)
tf = np.array([[1, 0], [3, 1]], float)
df = np.count_nonzero(tf, axis=0)
L = tf.sum(axis=1)
idf = np.log1p((2-df+.5)/(df+.5))
print(mini['vocabulary'])
print(tf.astype(int).tolist())
print(L.tolist(), df.tolist())
print(np.round(idf, 4))
# ['lamp', 'sensor']
# [[1, 0], [3, 1]]
# [1.0, 4.0] [2, 1]
# [0.1823 0.6931]

N is het aantal chunks en df_t het aantal chunks met term t. Het inverse documentgewicht I_t gebruikt hier de volgende BM25-conventie. Voor lamp is dat ln(1.2), voor sensor ln(2).

It=ln(1+N−dft+0.5dft+0.5)

Herhaling verzadigt en langere passages krijgen een lengtecorrectie. Noem f_t,d de termfrequentie, L_d de chunklengte en de gemiddelde lengte L met een streep erboven. We gebruiken k1=1.2 en b=.75.

Kd=1.2(0.25+0.75LdL¯)wt,d=It2.2ft,dft,d+Kd

De gemiddelde lengte is 2.5. Daardoor zijn de K-waarden .66 en 1.74; het lampgewicht wordt ongeveer .241631 en .253865. Zonder lengtecorrectie zou je andere getallen krijgen.

print(np.round(mini['weights'], 6))
top = retrieve_rag(mini, 'lamp', 1)
print([h['id'] for h in top])
# [[0.241631 0.      ]
#  [0.253865 0.556542]]
# ['b:0']

Schrijf build_rag_index met snapshots en woordchunks van venster 32, overlap 0. Bewaar de alfabetische woordenschat, gewichtsmatrix en afgesproken method-string. De gegeven helpers bewaken de bronversie; maximaal 256 chunks en 2048 termen begrenzen dit notebook.

Dit is een eigen lexicale index, geen dense embeddingmodel of volledige Lucene-reproductie. Bouw opnieuw als de brontekst verandert.

01
CHECKPOINTBouw een index van ongewijzigde eigen bronnen

2. Volg één echte voorbeeldvraag

De vraag “Hoe herstart ik het licht?” krijgt met de aliaslijst de woorden reset en lamp. Dit is een handgeschreven vervanging, geen taalbegrip. Onbekende woorden leveren geen gewichtskolom op.

De score S_d telt de gewichten voor T(q): de unieke verwerkte querytermen die in de woordenschat staan. Herhaalde woorden tellen niet extra.

Sd=∑t∈T(q)wt,d
q = 'Hoe herstart ik het licht?'
print(query_terms(q, True))
hits = retrieve_rag(index, q, 3, True)
print([h['id'] for h in hits])
# ['hoe', 'reset', 'ik', 'het', 'lamp']
# ['lamp-reset:0', 'lamp-garantie:0',
#  'hub-reset:0']

Schrijf query_terms en retrieve_rag. Neem alleen positieve scores, sorteer op score en daarna chunk-ID, en geef maximaal k hits. Een opgehaalde garantiepassage hoeft de resetvraag nog niet te beantwoorden.

02
CHECKPOINTZoek met letterlijke woorden en een zichtbare aliaskeuze

3. Controleer het extract opnieuw

De contextpacker neemt hier drie passages op, samen 249 tekens inclusief headers. De toegestane onderwerpen zijn lamp, sensor en hub; intents zijn reset, garantie, batterij en update.

De generator vereist precies één onderwerp en één intent en zoekt beide woorden in de citaten. Alleen de lamp-resetpassage voldoet.

ctx = pack_context(
    [h['chunk'] for h in hits],
    index['documents'], 700)
extract = extract_rag_answer(
    q, ctx, True)
print(ctx['used_chars'])
print(extract['answer'] ==
      RECORDS[0]['text'])
verified = verify_citations(
    extract['citations'],
    index['documents'])
print(verified[0]['status'])
empty = run_rag(index, q,
    dict(k=3, budget=0, expand=True))
print(empty['answer'])
# 249
# True
# valid
# None

Schrijf extract_rag_answer en run_rag. Bij identieke passende quotetekst kies je de eerste volledige quote en één citaatkopie en verifieer je die tegen de bron. Geen match, meerdere verschillende passende teksten of een leeg contextbudget leidt tot onthouding.

De gevonden tekst zegt dat de knop tien seconden vast moet. Letterlijke overeenkomst bewijst nog geen waarheid of algemene relevantie. Een conflict buiten de opgehaalde context blijft voor deze generator onzichtbaar.

03
CHECKPOINTGeef alleen een controleerbare passage terug

4. Kies op dev en bewaar de meetrij

De zestien vragen bestaan uit acht dev- en acht testformuleringen over hetzelfde corpus. run_rag ontvangt uitsluitend index, vraag en config; de verwachte antwoorden blijven in de evaluator.

dev = {
    name: evaluate_rag_pipeline(
        index, cases, config, 'dev')
    for name, config in variants.items()
}
chosen = choose_rag_variant(dev)
for name, report in dev.items():
    print(name,
          report['summary']['accuracy'])
print(chosen)
row = dev['aliases']['rows'][0]
print(row['id'], row['supported'],
      row['answer'] == row['expected'])
# literal 0.75
# aliases 1.0
# aliases
# q00 True True

Schrijf evaluate_rag_pipeline en choose_rag_variant. Valideer alle gevallen, ook buiten de gevraagde split. Case-IDs zijn maximaal 100 tekens; split, groep en vraag maximaal 2000, met unieke IDs en genormaliseerde vragen en minstens één geval in de gevraagde split.

Kies hoogste accuracy, daarna faithfulness en daarna alfabetische naam. Herbereken uit de rijen; vertrouw geen opgegeven summary. supported betekent hier uitsluitend dat het antwoord exact uit een geldig citaat komt.

04
CHECKPOINTKies de variant uitsluitend op dev

5. Maak de blinde vlek zichtbaar

Bevries aliases vóór de eindtest. Die variant haalt zes van acht antwoorden of terechte onthoudingen goed. De ongeziene formuleringen opnieuw starten en stroomcel vallen buiten de aliaslijst.

A=68=0.75
report = final_rag_evaluation(
    index, cases, chosen, variants)
print(report['summary']['accuracy'])
groups = report['groups']
for group, m in groups.items():
    print(group, m['n'], m['accuracy'])
print(len(experiment_prompt))
# 0.75
# alias 1 1.0
# direct 3 1.0
# unknown 2 1.0
# unseen 2 0.0
# 399

Schrijf final_rag_evaluation met de echte groepswaarden, groepsnamen en n-annotaties in een nieuwe balkfiguur. De prompt voor vrij modelonderzoek blijft onder 1000 tekens; het echte model bewaakt daarnaast zijn eigen tokenlimiet.

Pas je de aliases na deze test aan, dan heb je nieuwe testvragen nodig. Deze meting gaat over nieuwe formuleringen bij dezelfde bronnen en is geen LLM-benchmark.

05
CHECKPOINTRapporteer ook wat je systeem nog niet begrijpt

Wat neem je mee?

  • Indexgewichten, context en citaatcontrole zijn afzonderlijk na te rekenen.
  • Een geldig extract is een beperkte mechanische garantie.
  • Dev kiest de variant; test laat de resterende fouten zien.

Hierna voeg je een agentlus en tools toe, met expliciete grenzen aan rechten en bronvertrouwen.

Bronnen: Lewis et al. — Retrieval-Augmented Generation ↗ Lucene — BM25Similarity ↗ Gao et al. — Generating Text with Citations ↗ Sentence Transformers — information retrieval evaluation ↗ · Gecontroleerd 2026-10-29.

Python start zodra jij code uitvoert.
[1]

Bouw een index van ongewijzigde eigen bronnen

De gegeven helpers bewaren exacte brontekst, versie en posities. Jij schrijft build_rag_index met woordvenster 32 en overlap 0. Maak eerst de kleine berekening met lamp en lamp lamp lamp sensor uit de opdracht hierboven.

Die twee passages geven termfrequenties [1,0] en [3,1], lengten 1 en 4 en gemiddelde lengte 2.5. Met de BM25-lengtecorrectie wordt het lampgewicht ongeveer .241631 en .253865. De formules en het uitgewerkte voorbeeld staan in de projectuitleg.

Bouw daarna de index voor de zes gegeven RECORDS en bewaar index. Geef een alfabetische vocabulary, de echte gewichtsmatrix, documents, chunks en de afgesproken method-string terug. Maximaal 256 chunks en 2048 termen houden deze oefenindex begrensd.

Bekijk voorbeeld
import re
import hashlib
from collections import Counter
import numpy as np

ALIASES = {'herstart': 'reset', 'licht': 'lamp', 'accu': 'batterij', 'bijwerken': 'update'}
RECORDS = [
    {'id': 'lamp-reset', 'title': 'Lamp herstellen', 'text': 'Reset de lamp door de knop tien seconden vast te houden.'},
    {'id': 'lamp-garantie', 'title': 'Lampgarantie', 'text': 'De garantie van de lamp duurt twee jaar.'},
    {'id': 'sensor-reset', 'title': 'Sensor herstellen', 'text': 'Reset de sensor door de knop vijf seconden vast te houden.'},
    {'id': 'sensor-batterij', 'title': 'Sensorbatterij', 'text': 'De batterij van de sensor gaat twaalf maanden mee.'},
    {'id': 'hub-reset', 'title': 'Hub herstellen', 'text': 'Reset de hub via de lokale herstelknop.'},
    {'id': 'hub-update', 'title': 'Hub bijwerken', 'text': 'Een update van de hub duurt drie minuten.'},
]

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)}

def build_rag_index(records):
    documents = document_snapshots(records)
    chunks = [c for d in documents for c in word_chunks(d, 32, 0)]
    if len(chunks) > 256:
        raise ValueError('Dit notebook ondersteunt maximaal 256 chunks')
    tokenized = [re.findall(r'[^\W_]+', c['text'].casefold()) for c in chunks]
    vocabulary = sorted({t for ts in tokenized for t in ts})
    if not 1 <= len(vocabulary) <= 2048:
        raise ValueError('Woordenschat 1 tot 2048 termen')
    counts = [Counter(ts) for ts in tokenized]
    tf = np.array([[count[t] for t in vocabulary] for count in counts], dtype=float)
    lengths, df = tf.sum(axis=1), np.count_nonzero(tf, axis=0)
    idf = np.log1p((len(chunks) - df + .5) / (df + .5))
    weights = idf * tf * 2.2 / (tf + 1.2 * (.25 + .75 * lengths[:, None] / lengths.mean()))
    return {'documents': documents, 'chunks': chunks, 'vocabulary': vocabulary, 'weights': weights,
            'method': 'own-bm25-v1|k1=1.2|b=.75|casefold-unicode-words'}

index=build_rag_index(RECORDS)
print("Bronnen / chunks:",len(index["documents"]),len(index["chunks"]))
print("Indexvorm:",index["weights"].shape)
[2]

Zoek met letterlijke woorden en een zichtbare aliaskeuze

Schrijf query_terms en retrieve_rag. Bij Hoe herstart ik het licht? vervangen de gegeven aliases herstart door reset en licht door lamp. Deze handgeschreven lijst herkent alleen de vastgelegde woorden.

Tel iedere unieke bekende queryterm eenmaal mee in de gewichtskolommen. In het kleine indexvoorbeeld wint b:0 bij zoeken op lamp met k=1. Neem alleen positieve scores en sorteer daarna op score en chunk-ID.

De query is niet-leeg na strip en maximaal 2000 tekens; expand is een echte boolean en k een echte integer van 1 tot 20. Onbekende termen geven geen gewicht en kunnen dus een lege hitslijst opleveren. Bewaar hits voor de gegeven voorbeeldvraag met k=3 en expand=True.

Bekijk voorbeeld
def query_terms(query, expand):
    if not isinstance(query, str) or not query.strip() or len(query) > 2000 or type(expand) is not bool:
        raise ValueError('Geef een niet-lege query tot 2000 tekens en een bool')
    terms = re.findall(r'[^\W_]+', query.casefold())
    return [ALIASES.get(t, t) if expand else t for t in terms]

def retrieve_rag(index, query, k, expand=False):
    # index is the internal result of build_rag_index, never a deserialized file.
    if type(k) is not int or not 1 <= k <= 20:
        raise ValueError('k tussen 1 en 20')
    terms = set(query_terms(query, expand))
    columns = [i for i, t in enumerate(index['vocabulary']) if t in terms]
    scores = index['weights'][:, columns].sum(axis=1)
    order = sorted((i for i, score in enumerate(scores) if score > 0), key=lambda i: (-scores[i], index['chunks'][i]['id']))[:k]
    return [{'id': index['chunks'][i]['id'], 'score': float(scores[i]), 'chunk': index['chunks'][i]} for i in order]

hits=retrieve_rag(index,"Hoe herstart ik het licht?",3,True)
print("Gevonden:",[h["id"] for h in hits])
[3]

Geef alleen een controleerbare passage terug

De contextpacker en citaatcontrole zijn gegeven. Schrijf extract_rag_answer en run_rag zonder toegang tot verwachte antwoorden uit de evaluatieset. Geef de generator uitsluitend de vraag en gevonden context.

De voorbeeldvraag levert 249 tekens verpakte context op. Alleen het citaat over lamp-reset bevat de verwerkte woorden lamp en reset. Bij identieke passende quotetekst kies je de eerste volledige quote en één citaatkopie; verifieer de verwijzing opnieuw tegen de bron.

Precies één onderwerp (lamp, sensor of hub) en één intent (reset, garantie, batterij of update) zijn vereist. Geen passende quote, verschillende passende teksten of een budget van nul leidt tot onthouding. quote_valid bewijst hier uitsluitend dat het antwoord exact gelijk is aan een geldig citaat, niet dat de bron waar of algemeen relevant is.

Bekijk voorbeeld
def checked_sources(documents):
    if not isinstance(documents, list) or any(not isinstance(d, dict) or set(d) != {'id', 'title', 'text', 'revision'} for d in documents):
        raise ValueError('Document-snapshots vereist')
    checked = document_snapshots([{k: d[k] for k in ['id', 'title', 'text']} for d in documents])
    if any(d['revision'] != c['revision'] for d, c in zip(documents, checked)):
        raise ValueError('Brontekst en versie verschillen')
    return {d['id']: d for d in checked}

def pack_context(chunks, documents, budget):
    sources = checked_sources(documents)
    if not isinstance(chunks, list) or len(chunks) > 256 or type(budget) is not int or not 0 <= budget <= 60000:
        raise ValueError('Maximaal 256 chunks en budget 0–60000 tekens')
    for chunk in chunks:
        if not isinstance(chunk, dict) or not isinstance(chunk.get('document_id'), str) or chunk['document_id'] not in sources:
            raise ValueError('Onbekende bron')
        chunk_coverage(sources[chunk['document_id']], [chunk])
    text, citations, seen_texts = '', [], set()
    for chunk in chunks:
        if chunk['text'] in seen_texts:
            continue
        label = 'S' + str(len(citations) + 1)
        block = f'[{label}] {chunk["document_id"]}@{chunk["revision"][:12]} [{chunk["start"]},{chunk["end"]})\n{chunk["text"]}'
        candidate = text + ('\n\n' if text else '') + block
        if len(candidate) > budget:
            continue
        text = candidate
        seen_texts.add(chunk['text'])
        citations.append({'source_id': label, 'document_id': chunk['document_id'], 'revision': chunk['revision'],
                          'start': chunk['start'], 'end': chunk['end'], 'quote': chunk['text']})
    return {'text': text, 'used_chars': len(text), 'citations': citations}

def verify_citations(citations, documents):
    sources = checked_sources(documents)
    if not isinstance(citations, list) or len(citations) > 256:
        raise ValueError('Maximaal 256 verwijzingen')
    results, seen = [], set()
    for c in citations:
        if not isinstance(c, dict) or set(c) != {'source_id', 'document_id', 'revision', 'start', 'end', 'quote'}:
            raise ValueError('Ongeldige verwijzingsvelden')
        if not isinstance(c['source_id'], str) or not re.fullmatch(r'S[1-9][0-9]{0,3}', c['source_id']) or c['source_id'] in seen or not isinstance(c['document_id'], str) or not re.fullmatch(r'[A-Za-z0-9_-]{1,40}', c['document_id']):
            raise ValueError('Unieke bronlabels en document-IDs vereist')
        if not isinstance(c['revision'], str) or not re.fullmatch(r'[a-f0-9]{64}', c['revision']) or type(c['start']) is not int or type(c['end']) is not int or not isinstance(c['quote'], str) or not 1 <= len(c['quote']) <= 20000:
            raise ValueError('Versie, posities of citaat ongeldig')
        seen.add(c['source_id']); source = sources.get(c['document_id'])
        status = 'valid'
        if source is None:
            status = 'unknown-source'
        elif c['revision'] != source['revision']:
            status = 'stale-source'
        elif not 0 <= c['start'] < c['end'] <= len(source['text']):
            status = 'invalid-span'
        elif c['quote'] != source['text'][c['start']:c['end']]:
            status = 'quote-mismatch'
        results.append({'source_id': c['source_id'], 'status': status})
    return results

def extract_rag_answer(question, context, expand):
    terms = set(query_terms(question, expand))
    subjects = terms & {'lamp', 'sensor', 'hub'}
    intents = terms & {'reset', 'garantie', 'batterij', 'update'}
    if len(subjects) != 1 or len(intents) != 1:
        return {'answer': None, 'citations': []}
    required = subjects | intents
    candidates = [c for c in context['citations'] if required <= set(query_terms(c['quote'], False))]
    # Different matching passages are ambiguous in this deliberately small policy.
    if not candidates or len({c['quote'] for c in candidates}) != 1:
        return {'answer': None, 'citations': []}
    return {'answer': candidates[0]['quote'], 'citations': [dict(candidates[0])]}

def run_rag(index, question, config):
    if not isinstance(config, dict) or set(config) != {'k', 'budget', 'expand'}:
        raise ValueError('Config bevat exact k, budget en expand')
    hits = retrieve_rag(index, question, config['k'], config['expand'])
    context = pack_context([h['chunk'] for h in hits], index['documents'], config['budget'])
    result = extract_rag_answer(question, context, config['expand'])
    verified = verify_citations(result['citations'], index['documents'])
    if any(v['status'] != 'valid' for v in verified):
        raise ValueError('Generator leverde een ongeldig citaat')
    return {**result, 'ranking': [h['id'] for h in hits], 'context': context,
            'quote_valid': bool(verified) and result['answer'] == result['citations'][0]['quote']}

answer=run_rag(index,"Hoe herstart ik het licht?",{"k":3,"budget":700,"expand":True})
print("Antwoord:",answer["answer"])
print("Citaat:",answer["citations"][0]["document_id"])
print("Citaat exact:",answer["quote_valid"])
[4]

Kies de variant uitsluitend op dev

De gegeven zestien vragen bevatten acht dev- en acht testgevallen over hetzelfde corpus. Schrijf evaluate_rag_pipeline en choose_rag_variant. Bewaar per vraag de zoekranglijst, het antwoord en de meetrij; expected blijft uitsluitend evaluatie-invoer.

Literal haalt .75 op dev en aliases haalt 1. Kies eerst hoogste accuracy, daarna faithfulness en daarna alfabetische variantnaam; None voor faithfulness telt als -1. Herbereken uit rows en negeer een eventueel verouderd summary.

Valideer alle gevallen vooraf, ook buiten de gevraagde split. Case-ID is maximaal 100 tekens; split, group en question maximaal 2000; IDs en genormaliseerde vragen zijn uniek en de gevraagde split bevat minstens één geval. Gebruik uitsluitend devrapporten om te kiezen en bewaar dev_reports en selected.

Bekijk voorbeeld
def retrieval_metrics(ranking, relevant, k):
    for items in [ranking, relevant]:
        if not isinstance(items, list) or len(items) > 1000 or any(not isinstance(i, str) or not 1 <= len(i) <= 100 for i in items) or len(set(items)) != len(items):
            raise ValueError('Unieke begrensde IDs vereist')
    if type(k) is not int or not 1 <= k <= 1000:
        raise ValueError('k tussen 1 en 1000')
    selected, gold = ranking[:k], set(relevant)
    positions = [rank for rank, identifier in enumerate(selected, 1) if identifier in gold]
    return {'precision': len(positions) / k, 'recall': len(positions) / len(gold) if gold else None,
            'rr': (1 / positions[0] if positions else 0.) if gold else None}

def validate_rag_rows(rows):
    if not isinstance(rows, list) or not 1 <= len(rows) <= 1000:
        raise ValueError('Geef 1 tot 1000 meetrijen')
    seen = set()
    for row in rows:
        if not isinstance(row, dict) or set(row) != {'id', 'ranking', 'relevant', 'answer', 'expected', 'supported'} or not isinstance(row['id'], str) or not 1 <= len(row['id']) <= 100 or row['id'] in seen:
            raise ValueError('Unieke meet-IDs en vaste velden vereist')
        seen.add(row['id']); retrieval_metrics(row['ranking'], row['relevant'], 1)
        if any(v is not None and (not isinstance(v, str) or not v.strip() or len(v) > 2000) for v in [row['answer'], row['expected']]) or type(row['supported']) is not bool:
            raise ValueError('Antwoorden zijn tekst of None; supported is bool')
        if (row['expected'] is None) != (not row['relevant']) or (row['answer'] is None and row['supported']):
            raise ValueError('Deze oefening vereist bronlabels voor antwoordbare vragen en geen support bij onthouding')

def evaluate_rag(rows, k):
    validate_rag_rows(rows)
    metrics = [retrieval_metrics(r['ranking'], r['relevant'], k) for r in rows]
    answerable = [m for m in metrics if m['recall'] is not None]
    answered = [r for r in rows if r['answer'] is not None]
    unknown = [r for r in rows if r['expected'] is None]
    return {'n': len(rows), 'answerable': len(answerable), 'answered': len(answered),
            'recall': sum(m['recall'] for m in answerable) / len(answerable) if answerable else None,
            'mrr': sum(m['rr'] for m in answerable) / len(answerable) if answerable else None,
            'accuracy': sum(r['answer'] == r['expected'] for r in rows) / len(rows),
            'coverage': len(answered) / len(rows),
            'faithfulness': sum(r['supported'] for r in answered) / len(answered) if answered else None,
            'unknown_abstention': sum(r['answer'] is None for r in unknown) / len(unknown) if unknown else None}

def diagnose_rag(rows, k):
    validate_rag_rows(rows)
    result = []
    for row in rows:
        metrics = retrieval_metrics(row['ranking'], row['relevant'], k)
        if row['expected'] is None:
            reason = 'appropriate-abstention' if row['answer'] is None else 'unanswerable-answered'
        elif metrics['rr'] == 0:
            reason = 'retrieval-miss'
        elif row['answer'] is None:
            reason = 'unnecessary-abstention'
        elif row['answer'] != row['expected']:
            reason = 'wrong-answer'
        elif not row['supported']:
            reason = 'unsupported-answer'
        else:
            reason = 'supported-correct'
        result.append({'id': row['id'], 'reason': reason})
    return result

def make_rag_cases():
    # Own questions and explicit ground truth. These labels never enter run_rag.
    definitions = [
        ('dev', 'direct', 'Hoe reset ik de lamp?', 'lamp-reset'),
        ('dev', 'direct', 'Hoe lang duurt de garantie van de lamp?', 'lamp-garantie'),
        ('dev', 'direct', 'Hoe lang gaat de batterij van de sensor mee?', 'sensor-batterij'),
        ('dev', 'direct', 'Hoe reset ik de sensor?', 'sensor-reset'),
        ('dev', 'alias', 'Hoe herstart ik het licht?', 'lamp-reset'),
        ('dev', 'alias', 'Hoe lang gaat de accu van de sensor mee?', 'sensor-batterij'),
        ('dev', 'unknown', 'Welke kleur heeft de hub?', None),
        ('dev', 'unknown', 'Hoe zwaar is de maan?', None),
        ('test', 'direct', 'Hoe voer ik een reset van de hub uit?', 'hub-reset'),
        ('test', 'direct', 'Hoe lang duurt een update van de hub?', 'hub-update'),
        ('test', 'direct', 'Wat is de garantie op de lamp?', 'lamp-garantie'),
        ('test', 'alias', 'Hoe herstart ik de sensor?', 'sensor-reset'),
        ('test', 'unseen', 'Hoe start ik de lamp opnieuw?', 'lamp-reset'),
        ('test', 'unseen', 'Wanneer vervang ik de stroomcel van de sensor?', 'sensor-batterij'),
        ('test', 'unknown', 'Welke kleur heeft de lamp?', None),
        ('test', 'unknown', 'Wat is het weer morgen?', None),
    ]
    texts = {r['id']: r['text'] for r in RECORDS}
    return [{'id': f'q{i:02}', 'split': split, 'group': group, 'question': q,
             'relevant': [doc+':0'] if doc else [], 'expected': texts[doc] if doc else None}
            for i, (split, group, q, doc) in enumerate(definitions)]

cases=make_rag_cases()
variants={'literal':{'k':3,'budget':700,'expand':False},'aliases':{'k':3,'budget':700,'expand':True}}

def evaluate_rag_pipeline(index, cases, config, split):
    if split not in ('dev', 'test') or not isinstance(cases, list) or not 1 <= len(cases) <= 1000:
        raise ValueError('Begrensde cases en split dev/test vereist')
    ids, questions = set(), set()
    for c in cases:
        if not isinstance(c, dict) or set(c) != {'id', 'split', 'group', 'question', 'relevant', 'expected'} or any(not isinstance(c[k], str) or not c[k].strip() or len(c[k]) > 2000 for k in ['id', 'split', 'group', 'question']):
            raise ValueError('Ongeldig meetgeval')
        normalized = c['question'].strip().casefold()
        if c['id'] in ids or normalized in questions or c['split'] not in ('dev', 'test'):
            raise ValueError('Dubbele ID/vraag of onbekende split')
        ids.add(c['id']); questions.add(normalized)
        # Use the same ground-truth and shape validator as the evaluation lesson.
        validate_rag_rows([{'id': c['id'], 'ranking': [], 'relevant': c['relevant'],
                                      'answer': None, 'expected': c['expected'], 'supported': False}])
    rows, groups = [], {}
    for c in cases:
        if c['split'] != split:
            continue
        result = run_rag(index, c['question'], config)
        row = {'id': c['id'], 'ranking': result['ranking'], 'relevant': c['relevant'],
               'answer': result['answer'], 'expected': c['expected'], 'supported': result['quote_valid']}
        rows.append(row); groups.setdefault(c['group'], []).append(row)
    return {'split': split, 'rows': rows, 'summary': evaluate_rag(rows, config['k']),
            'groups': {g: evaluate_rag(rs, config['k']) for g, rs in sorted(groups.items())},
            'diagnoses': diagnose_rag(rows, config['k'])}

def choose_rag_variant(reports):
    if not isinstance(reports, dict) or not 1 <= len(reports) <= 8:
        raise ValueError('Geef 1 tot 8 devrapporten')
    expected_ids, ranked = None, []
    for name, report in reports.items():
        if not isinstance(name, str) or not name.strip() or len(name) > 100 or not isinstance(report, dict) or report.get('split') != 'dev':
            raise ValueError('Kies alleen benoemde devvarianten')
        rows = report.get('rows')
        # Selection uses answer accuracy and support, not a supplied summary.
        summary = evaluate_rag(rows, 1)
        ids = {row['id'] for row in rows}
        if expected_ids is not None and ids != expected_ids:
            raise ValueError('Alle varianten moeten dezelfde devvragen hebben')
        expected_ids = ids
        support = summary['faithfulness'] if summary['faithfulness'] is not None else -1.
        ranked.append((-summary['accuracy'], -support, name))
    return sorted(ranked)[0][2]

dev_reports={name:evaluate_rag_pipeline(index,cases,config,"dev") for name,config in variants.items()}
selected=choose_rag_variant(dev_reports)
print("Dev literal / aliases:",dev_reports["literal"]["summary"]["accuracy"],dev_reports["aliases"]["summary"]["accuracy"])
print("Gekozen:",selected)
[5]

Rapporteer ook wat je systeem nog niet begrijpt

Schrijf final_rag_evaluation voor uitsluitend de gekozen variant op test. Maak een nieuwe Matplotlib-balkfiguur uit report.groups met groepsnamen, echte accuracy, aantallen bij de balken, y-aslabel en titel. Gebruik geen vast ingevulde groepsscores.

Aliases haalt zes van acht testgevallen goed. De alfabetische groepen alias, direct, unknown en unseen hebben aantallen 1, 3, 2 en 2 en accuracies 1, 1, 1 en 0. De nulscore op ongeziene formuleringen blijft zo zichtbaar naast het totaal van .75.

De woorden opnieuw starten en stroomcel staan niet in de aliaslijst. Pas je die lijst na deze test aan, dan heb je nieuwe testvragen nodig. De gegeven prompt voor vrij modelonderzoek blijft onder 1000 tekens; het echte model controleert daarnaast zijn eigen tokenlimiet.

Bekijk voorbeeld
import matplotlib.pyplot as plt

def final_rag_evaluation(index, cases, chosen, registry):
    import matplotlib.pyplot as plt
    if not isinstance(registry, dict) or not isinstance(chosen, str) or chosen not in registry:
        raise ValueError('Kies een bestaande variantnaam')
    report = evaluate_rag_pipeline(index, cases, registry[chosen], 'test')
    groups = list(report['groups'])
    scores = [report['groups'][g]['accuracy'] for g in groups]
    fig, ax = plt.subplots(figsize=(4.2, 4.4))
    bars = ax.bar(groups, scores, color='#236b4f')
    ax.set_ylim(0, 1.25); ax.set_ylabel('Exact goed\nof terecht onthouden', fontsize=12)
    ax.tick_params(labelsize=12)
    ax.set_title('Eigen RAG: bevroren variant\nop testvragen', fontsize=12)
    for group, bar, score in zip(groups, bars, scores):
        ax.text(bar.get_x() + bar.get_width()/2, score+.03,
                f'{score:.0%}\n(n={report["groups"][group]["n"]})', ha='center', fontsize=12)
    fig.tight_layout()
    return report

test_report=final_rag_evaluation(index,cases,selected,variants)
print('Test accuracy:',test_report['summary']['accuracy'])
print('Ongeziene formuleringen:',test_report['groups']['unseen']['accuracy'])
print('Diagnoses:',[d['reason'] for d in test_report['diagnoses']])
experiment_prompt='Beantwoord de vraag op basis van de broncontext. Noem het bronlabel. Onthoud je bij ontbrekend bewijs.\nVraag: Hoe herstart ik het licht?\nBroncontext:\n'+answer['context']['text']
assert len(experiment_prompt)<=1000
print('Prompt voor vrij modelonderzoek:\n'+experiment_prompt)