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

Combineer woordmatches en vectorburen via hun rang

Bouw een eenvoudige lexicale analyse, bereken BM25 en combineer kandidaten zonder ruwe scores door elkaar op te tellen.

75 min3 checkpoints
DIT GA JE ONTDEKKEN
  • Je berekent BM25 met expliciete termfrequentie, documentlengte en IDF-conventie.
  • Je combineert twee ranglijsten met reciprocal rank fusion en bewaakt unieke kandidaat-IDs.

Probeer ook echte tekstvectoren in het embedding-lab.

Een productcode zoals XR-2 kan vooral een exacte woordmatch nodig hebben. Een omschrijving met andere woorden kan juist bij een geschikte embedding passen. Hybride zoeken combineert kandidaten uit lexicaal zoeken en dense retrieval.

De twee routes hebben verschillende scoreschalen. We bouwen daarom eerst een lexicale baseline en combineren daarna rangposities. De oefeningen meten eigen zoekberekeningen, geen commerciële zoekdienst of universele kwaliteitswinst.

1. Maak de tokentrace controleerbaar

Onze analysator gebruikt Unicode-casefolding en regex [^\W_]+ voor reeksen letters en cijfers. Leestekens en underscores vormen grenzen. Dubbele woorden blijven behouden, omdat termfrequentie ze straks nodig heeft.

text = 'LAMP-reset_2 Straße café café'
for token in lexical_tokens(text):
    print(token)
# lamp
# reset
# 2
# strasse
# café
# café

Maak lexical_tokens voor maximaal 20000 Python-tekens; lege tekst mag. Gebruik voor query en documenten dezelfde analysator. Casefolding maakt hier Straße tot strasse, maar is geen stemming of synoniemenkennis.

Kapot en kapotte blijven verschillende tokens. Ook Unicode-normalisatie ontbreekt: samengevoegde en losse accentvormen kunnen verschillen. Een woordmatch hangt dus mede af van de gekozen tekstbewerking.

01
CHECKPOINTDe tekstanalysator bepaalt wat een match is

2. Verzadig herhaling en corrigeer lengte

BM25 weegt een term met een IDF-factor en een verzadigende frequentiebijdrage. N is het aantal documenten en dfₜ het aantal documenten met term t. We kiezen deze positieve IDF-conventie:

It=ln(1+N−dft+.5dft+.5)

Voor één document is f de termfrequentie, L zijn tokenlengte en A de gemiddelde documentlengte. k₁ bepaalt verzadiging en b lengtecorrectie. Definieer K om de score leesbaar te houden:

K=k1(1−b+bLA)wt=Itf(k1+1)f+Kscore=∑twt

Neem documenten lamp en lamp lamp lamp. N=2, df=2, lengtes 1 en 3 en gemiddelde 2 geven I=ln(1.2)≈.182322. Bij k₁=1.2 en b=.75 wordt K voor het korte document .75 en voor het lange 1.65.

De korte score is I×2.2/1.75≈.229204. De lange score is I×6.6/4.65≈.258779. Driemaal dezelfde term levert dus geen verdrievoudigde score op.

docs = ['lamp','lamp lamp lamp']
print(np.round(
    bm25_scores(docs,'lamp'),4))
# [0.2292 0.2588]
print(np.round(
    bm25_scores(docs,'lamp',b=0),4))
# [0.1823 0.2865]

Bij b=0 verdwijnt de lengtecorrectie, maar de frequentie blijft verzadigen. Maak bm25_scores met een som over unieke querytermen. Herhaling van een queryterm krijgt in deze oefening geen extra gewicht.

same = bm25_scores(docs,'lamp lamp')
print(np.allclose(
    same,bm25_scores(docs,'lamp')))
# True
print(np.round(bm25_scores(
    ['lamp','timer'],'lamp timer'),4))
# [0.6931 0.6931]

Bereken alleen bijdragen waar f>0; anders kan k₁=0 een ongeldige nuldeling veroorzaken. Lege documenten tellen mee in N en gemiddelde lengte; een volledig leeg corpus geeft nulscores. Gebruik de afgesproken parametergrenzen en weiger ongeldige invoer.

De IDF-conventie en gebruikelijke startwaarden komen uit Lucene, maar onze analysator en scoreberekening zijn een eigen variant. Dit is geen exacte reproductie van Lucene’s volledige implementatie. Beoordeel parameterkeuzes op ontwikkeldata en houd de eindtest apart.

02
CHECKPOINTVerzadig herhaling en corrigeer documentlengte

3. Combineer rang in plaats van ruwe score

Reciprocal rank fusion (RRF) geeft de eerste hit rang één. Elke aanwezige kandidaat krijgt per lijst een bijdrage; een ontbrekende kandidaat krijgt uit die lijst nul.

RRF(d)=∑r1c+rankr(d)

r loopt over ranglijsten en c is een positieve constante. Met lijsten [a,b] en [b,c] en didactisch c=1 krijgt a=.5, b=1/3+1/2≈.8333 en c=1/3. De kandidaat die beide routes ondersteunen wint.

rankings = [['a','b'],['b','c']]
for hit in reciprocal_rank_fusion(
        rankings,1):
    score = round(hit['score'],3)
    print(hit['id'],score)
# b 0.833
# a 0.5
# c 0.333
more = reciprocal_rank_fusion(
    rankings+[['c']])
print([h['id'] for h in more])
# ['b', 'c', 'a']

Maak reciprocal_rank_fusion voor 1–8 lijsten. IDs moeten binnen iedere lijst uniek zijn, maar mogen in verschillende lijsten voorkomen. Sorteer gelijke totaalscores alfabetisch; gebruik standaard c=60.

De oorspronkelijke publicatie koos 60 na een pilot, geen universeel optimum. RRF gebruikt geen absolute scoreafstand en maakt een zwakke zoekroute niet vanzelf betrouwbaar. Meet de gecombineerde relevante hits op echte ontwikkelvragen.

EIGEN RANGLIJSTEN · GEEN MODELRESULTATENEen gedeelde kandidaat kan boven twee favorieten komen

Iedere lijst geeft 1 / (c + rang). De ruwe BM25- en cosinusscores zijn niet nodig.

Woordzoeken
  1. code
  2. reset
  3. garantie
Vectorzoeken
  1. reset
  2. veiligheid
  3. code
1. reset
0.03252
2. code
0.03227
3. veiligheid
0.01613
4. garantie
0.01587
Eerste resultaatresetRRF-score0.03252

De balken zijn geschaald naar de hoogste score in deze run. Dit zijn rangbijdragen, geen betrouwbaarheidskansen.

03
CHECKPOINTTel rangbijdragen in plaats van onvergelijkbare scores

Wat neem je mee?

  • De analysator bepaalt welke woorden overeenkomen.
  • BM25 verzadigt frequentie en corrigeert lengte volgens een expliciete conventie.
  • RRF combineert rangbijdragen over alle lijsten.

Hierna onderzoek je welke gevonden kandidaten werkelijk bruikbaar zijn voor een antwoord.

Verder lezen & bronnen

Inhoud gecontroleerd op 2026-10-28.
DE VOLGENDE STAPBeoordeel vraag en kandidaat samen
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
De tekstanalysator bepaalt wat een match is

Schrijf lexical_tokens(text). Maximaal 20000 Python-tekens, lege tekst toegestaan. Gebruik text.casefold() en regex [^\W_]+. Geef de tokens in oorspronkelijke volgorde, inclusief dubbelen; anders ValueError.

Een hint

Gebruik re.findall. Verwijder geen dubbele woorden: termfrequentie heeft ze later nodig.

02
Verzadig herhaling en corrigeer documentlengte

Schrijf bm25_scores(documents,query,k1=1.2,b=.75). Documentenlijst 1–1000 strings, elk volgens lexical_tokens; query string≤2000. k1 en b zijn gewone int/float, geen bool, eindig met 0≤k1≤10 en 0≤b≤1. Retourneer NumPy-scorevector in documentvolgorde; anders ValueError.

Een hint

Counter per document levert f. df telt documenten waarin de term minstens eenmaal voorkomt. Gebruik alleen present=f>0 voor de breuk.

03
Tel rangbijdragen in plaats van onvergelijkbare scores

Schrijf reciprocal_rank_fusion(rankings,constant=60). Geef 1–8 lijsten van maximaal 1000 unieke niet-lege string-IDs ≤100. Lijsten mogen leeg zijn; dezelfde ID mag in verschillende lijsten voorkomen. constant is exacte int 1–1000. Geef {id,score} gesorteerd op aflopende som en dan ID; anders ValueError.

Een hint

enumerate(ranking,1) geeft de juiste rang. Sorteer pas nadat alle bijdragen zijn opgeteld.

Vastgelopen? Bekijk de voorbeeldoplossing
import re
import numpy as np
from collections import Counter

def lexical_tokens(text):
    if not isinstance(text, str) or len(text) > 20000:
        raise ValueError('Tekst is een string van maximaal 20000 tekens')
    return re.findall(r'[^\W_]+', text.casefold())

def bm25_scores(documents, query, k1=1.2, b=.75):
    if not isinstance(documents, list) or not 1 <= len(documents) <= 1000 or not isinstance(query, str) or len(query) > 2000:
        raise ValueError('Geef 1 tot 1000 documenten en een query van maximaal 2000 tekens')
    if any(type(v) not in (int, float) for v in [k1, b]) or not 0 <= k1 <= 10 or not 0 <= b <= 1:
        raise ValueError('k1 tussen 0 en 10, b tussen 0 en 1')
    tokens = [lexical_tokens(d) for d in documents]
    terms = set(lexical_tokens(query))
    counts, lengths = [Counter(t) for t in tokens], np.array([len(t) for t in tokens], dtype=float)
    scores = np.zeros(len(documents))
    average = lengths.mean()
    if average == 0:
        return scores
    for term in sorted(terms):
        frequency = np.array([c[term] for c in counts], dtype=float)
        df = int(np.count_nonzero(frequency))
        idf = np.log1p((len(documents) - df + .5) / (df + .5))
        present = frequency > 0
        denominator = frequency[present] + k1 * (1 - b + b * lengths[present] / average)
        scores[present] += idf * frequency[present] * (k1 + 1) / denominator
    return scores

def reciprocal_rank_fusion(rankings, constant=60):
    if not isinstance(rankings, list) or not 1 <= len(rankings) <= 8 or type(constant) is not int or not 1 <= constant <= 1000:
        raise ValueError('Geef 1 tot 8 ranglijsten en constante 1–1000')
    scores = {}
    for ranking in rankings:
        if not isinstance(ranking, list) or len(ranking) > 1000 or any(not isinstance(i, str) or not 1 <= len(i) <= 100 for i in ranking) or len(set(ranking)) != len(ranking):
            raise ValueError('Elke ranglijst bevat maximaal 1000 unieke niet-lege IDs')
        for rank, identifier in enumerate(ranking, 1):
            scores[identifier] = scores.get(identifier, 0.) + 1 / (constant + rank)
    return [{'id': identifier, 'score': score} for identifier, score in sorted(scores.items(), key=lambda item: (-item[1], item[0]))]

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