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

Beoordeel vraag en kandidaat samen

Bouw een transparante reranker, onderzoek de kandidaatgrens en laat het systeem bij twijfel onthouden.

60 min3 checkpoints
DIT GA JE ONTDEKKEN
  • Je onderscheidt losse tekstembeddings van gezamenlijke query-passagebeoordeling.
  • Je meet wat een reranker wel en niet kan terughalen uit een beperkte kandidatenlijst.

Probeer ook echte tekstvectoren in het embedding-lab.

Een zoekactie vindt een passage over een lamp, maar die passage geeft geen resetinstructie. Alleen woordoverlap is blijkbaar niet genoeg. We willen de gevonden kandidaten daarom opnieuw vergelijken met de vraag.

Een tweestapszoeker (retrieve-and-rerank) begint met een snelle kandidatenlijst. Daarna bekijkt een scorer ieder vraag-passagepaar. Een passage die nooit in de eerste lijst kwam, kan deze tweede stap niet kiezen.

1. Tel woorden én hun volgorde

Een bi-encoder berekent vraag en passage afzonderlijk; corpusvectoren kunnen vooraf klaarstaan. Een cross-encoder verwerkt het paar samen, met interactie tussen hun tokens. Dat vraagt een nieuwe berekening per paar.

Wij bouwen een transparante regel, geen geleerd transformer-model. De regel kijkt eerst welke zoekwoorden aanwezig zijn en daarna welke woorden direct na elkaar staan. Zo kun je iedere score narekenen.

Neem de drie zoekwoorden reset, lamp en veilig. Hun unieke opeenvolgende paren zijn reset→lamp en lamp→veilig. De passage “Reset lamp: niet veilig” bevat alle drie woorden, maar slechts het eerste paar.

Noem de unieke querywoorden Q en de passagewoorden D. P_Q en P_D bevatten de unieke opeenvolgende paren uit de oorspronkelijke tokenvolgorde, vóór het maken van woordsets. C is woorddekking en A paarsdekking.

C=|Q∩D||Q|A=|PQ∩PD||PQ|

In die passage is C=3/3=1 en A=1/2=.5. Bij een query van één woord ontbreken paren; A is dan volgens ons contract nul.

q = 'reset lamp veilig'
docs = {
    'a': 'De lamp reset veilig.',
    'b': 'Reset lamp veilig.',
    'c': 'Reset lamp: niet veilig.'
}
for name, text in docs.items():
    f = pair_features(q, text)
    print(name, f['coverage'],
          f['adjacency'])
# a 1.0 0.0
# b 1.0 1.0
# c 1.0 0.5

Tokeniseer met casefold en de Unicode-regex [^\W_]+. De dubbelepunt vormt geen woord; herhaalde woorden tellen voor coverage niet opnieuw. We gebruiken geen stemming of synoniemen.

De gewichten hieronder zijn gekozen oefenbeleid. Woorddekking krijgt .6 en paarsdekking .4.

s=0.6C+0.4A

Voor c geeft dit .6×1+.4×.5=.8. De ontkenning verhindert die hoge score niet: onze regel begrijpt de betekenis niet.

for name, text in docs.items():
    f = pair_features(q, text)
    score = (.6 * f['coverage']
             + .4 * f['adjacency'])
    print(name, round(score, 2))
# a 0.6
# b 1.0
# c 0.8

Schrijf pair_features met beide kenmerken. Beide teksten zijn niet-leeg na strip en maximaal 2000 tekens; de query moet bovendien minstens één regexwoord bevatten. Ongeldig betekent ValueError.

01
CHECKPOINTLaat zien waar de paarscore vandaan komt

2. Herordenen begint bij de kandidaten

Stel dat retrieval eerst a, daarna c en pas daarna b vindt. Met limit=2 scoor je alleen a en c; b is voor die run onbereikbaar. Met limit=3 kan b wel bovenaan komen.

candidates = ['a', 'c', 'b']
for limit in [2, 3]:
    hits = rerank_candidates(
        q, docs, candidates, limit)
    print([(h['id'], round(
        h['score'], 2)) for h in hits])
# [('c', 0.8), ('a', 0.6)]
# [('b', 1.0), ('c', 0.8), ('a', 0.6)]

Schrijf rerank_candidates: neem eerst de kandidaatprefix en sorteer daarna op aflopende score, met ID als alfabetische tie-break. Gebruik uitsluitend unieke bestaande document-IDs. Een lege kandidatenlijst levert geen hits.

Probeer in de visualisatie wat er gebeurt als de kandidaatlimiet groeit. Meer paarberekeningen kunnen een gemiste passage beschikbaar maken, maar een betere einduitkomst blijft afhankelijk van de scorer.

EIGEN REGELS · GEEN GELEERDE CROSS-ENCODERDe juiste passage staat net buiten de deur

Vraag: reset lamp. B bevat de handmatig gekozen resetinstructie. Laat meer kandidaten toe en bekijk de nieuwe volgorde.

Retrievalvolgorde

  1. 1 · A

    De reset van de lamp staat hier.

    Gaat naar de scorer
  2. 2 · C

    Reset sensor: houd de knop vijf seconden vast.

    Gaat naar de scorer
  3. 3 · B

    Reset lamp: houd de knop tien seconden vast.

    Buiten de kandidaatgrens

Na paarscores

  1. A0.60

    De reset van de lamp staat hier.

    coverage 1.0 · adjacency 0.0
  2. C0.30

    Reset sensor: houd de knop vijf seconden vast.

    coverage 0.5 · adjacency 0.0
Paarberekeningen2B bereikbaarneeSelectieonthouden

Score = 0,6 × coverage + 0,4 × adjacency. Deze regel begrijpt geen ontkenning; een hoge score of marge bewijst geen juistheid. Met één kandidaat laten we de margeregel passeren.

02
CHECKPOINTJe kunt alleen de opgehaalde kandidaten herordenen

3. Beslis apart of je voldoende bewijs hebt

Onze selectie vraagt een minimumscore én een minimummarge ten opzichte van nummer twee. In het laatste voorbeeld heeft b score 1 en c .8: een marge-eis van .1 slaagt, .3 niet. None betekent dat dit beleid zich onthoudt.

print(decide_rerank(hits, .8, .1))
print(decide_rerank(hits, 1, .3))
tie = [dict(id='z', score=.8),
       dict(id='a', score=.8)]
print(decide_rerank(tie, .8, 0))
print(decide_rerank(tie, .8, .01))
one = [dict(id='solo', score=.5)]
print(decide_rerank(one, .5, 1))
print(decide_rerank([], .5, 0))
# b
# None
# a
# None
# solo
# None

Bij marge nul mag een gelijkspel; alfabetisch wint a. Bij één kandidaat zetten we gap=1, zodat de margeregel passeert. Dit is een expliciete afspraak, geen bewijs dat één resultaat betrouwbaar is.

Schrijf decide_rerank met unieke IDs en gewone numerieke scores, drempel en marge binnen [0,1]. Kies beleidsgrenzen op dev en bevries ze vóór test. Een grote marge kan nog steeds een overtuigend fout resultaat kiezen.

03
CHECKPOINTEen hoge score is nog geen voldoende zeker antwoord

Wat neem je mee?

  • Een paarscore kan meer informatie gebruiken dan losse vectorovereenkomst.
  • De reranker kan uitsluitend binnen de opgehaalde kandidaten kiezen.
  • Score, marge en betekenis zijn verschillende zaken; onze regel mist ontkenning.

Hierna houden we bronversie, exacte tekst en verwijzing bij elkaar.

Verder lezen & bronnen

Inhoud gecontroleerd op 2026-10-29.
DE VOLGENDE STAPMaak ieder citaat terugvindbaar
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
Laat zien waar de paarscore vandaan komt

Schrijf pair_features(query,passage). Beide strings niet-leeg na strip en maximaal 2000 tekens; query moet minstens één regexwoord bevatten. Anders ValueError. Geef een dictionary met coverage en adjacency volgens de unieke verzamelingen hierboven.

Een hint

Maak woordparen met zip(tokens,tokens[1:]). Gebruik sets zodat herhaalde querywoorden niet dubbel tellen.

02
Je kunt alleen de opgehaalde kandidaten herordenen

Schrijf rerank_candidates(query,documents,candidates,limit). documents is een dictionary met 1–1000 IDs (stringlengte 1–100) naar niet-lege teksten ≤2000 tekens. candidates bevat maximaal 1000 unieke bestaande IDs; limit is een echte int 1–1000. Valideer de query ook bij een lege lijst. Retourneer voor candidates[:limit] dictionaries id/score volgens 0.6*coverage+0.4*adjacency, gesorteerd op (-score,id). Anders ValueError.

Een hint

Beperk eerst de kandidaten en score daarna. Een lege kandidatenlijst blijft leeg; zoek niet stiekem in alle documenten.

03
Een hoge score is nog geen voldoende zeker antwoord

Schrijf decide_rerank(hits,threshold,margin). hits maximaal 1000 exacte id/score-dictionaries met unieke niet-lege IDs ≤100; eindige gewone int/float-scores 0–1, geen bool. Drempel en marge ook zulke getallen. Sorteer als hierboven. Geef None bij leeg of niet aan beide eisen voldaan, anders beste ID; bij één hit is gap=1. Ongeldig: ValueError.

Een hint

Gebruik >= voor beide grenzen en verander de ingevoerde lijst niet. De teruggegeven ID is een selectie, geen gegenereerd antwoord.

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

def pair_features(query, passage):
    if any(not isinstance(t, str) or not t.strip() or len(t) > 2000 for t in [query, passage]):
        raise ValueError('Twee niet-lege teksten van maximaal 2000 tekens vereist')
    q, d = [re.findall(r'[^\W_]+', t.casefold()) for t in [query, passage]]
    if not q:
        raise ValueError('De query bevat minstens één woord')
    terms = set(q)
    pairs, document_pairs = set(zip(q, q[1:])), set(zip(d, d[1:]))
    return {'coverage': len(terms & set(d)) / len(terms),
            'adjacency': len(pairs & document_pairs) / len(pairs) if pairs else 0.}

def rerank_candidates(query, documents, candidates, limit):
    if not isinstance(documents, dict) or not 1 <= len(documents) <= 1000 or any(not isinstance(k, str) or not 1 <= len(k) <= 100 or not isinstance(v, str) or not v.strip() or len(v) > 2000 for k, v in documents.items()):
        raise ValueError('Geef maximaal 1000 begrensde document-IDs en teksten')
    if not isinstance(candidates, list) or len(candidates) > 1000 or any(not isinstance(c, str) or c not in documents for c in candidates) or len(set(candidates)) != len(candidates):
        raise ValueError('Kandidaten zijn unieke bestaande document-IDs')
    if type(limit) is not int or not 1 <= limit <= 1000:
        raise ValueError('Kandidaatlimiet 1–1000')
    pair_features(query, 'validatie')
    hits = []
    for identifier in candidates[:limit]:
        features = pair_features(query, documents[identifier])
        hits.append({'id': identifier, 'score': .6 * features['coverage'] + .4 * features['adjacency']})
    return sorted(hits, key=lambda h: (-h['score'], h['id']))

def decide_rerank(hits, threshold, margin):
    if any(type(x) not in (int, float) or not 0 <= x <= 1 for x in [threshold, margin]):
        raise ValueError('Drempel en marge tussen 0 en 1')
    if not isinstance(hits, list) or len(hits) > 1000:
        raise ValueError('Maximaal 1000 hits')
    ids = set()
    for h in hits:
        if not isinstance(h, dict) or set(h) != {'id', 'score'} or not isinstance(h['id'], str) or not 1 <= len(h['id']) <= 100 or h['id'] in ids or type(h['score']) not in (int, float) or not 0 <= h['score'] <= 1:
            raise ValueError('Unieke IDs en eindige scores 0–1 vereist')
        ids.add(h['id'])
    ranked = sorted(hits, key=lambda h: (-h['score'], h['id']))
    if not ranked:
        return None
    gap = ranked[0]['score'] - ranked[1]['score'] if len(ranked) > 1 else 1.
    return ranked[0]['id'] if ranked[0]['score'] >= threshold and gap >= margin else None

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