RAG-labor: építsünk egyet
Dokumentumokból válaszoló asszisztens nulláról, működő kóddal: darabolás, embedding, hibrid keresés, forrásjelölt válasz, kiértékelés és eszközhívás.
10 lépés · kb. 6 óra · haladó · 9 forrás · előfeltétel: Python-alapok. A keresési és mérési lépések a csomagok telepítése után API-kulcs és internet nélkül is futnak; nyelvi válaszhoz API-kulcs kell. A lépések végén rövid önellenőrző kvíz van; itt semmi nincs zárolva, mert a lépéseket a kóddal együtt követed.
# a teljes rendszer hét paranccsal
python3 keszits_korpuszt.py
python3 l1_darabolas.py
python3 l2_index.py
python3 l3_kereses.py
python3 l4_valasz.py "milyen vizsgával zárul a K-402"
python3 l5_eval.py
python3 l6_eszkoz.py
Mit építünk, és miért pont ezt
A végén ez lesz a kezedben: egy rendszer, amely egy online tanfolyamokat szervező oktatási platform hat belső dokumentumából (két kurzusleírás, ügyfélszolgálati útmutató, adatvédelmi szabályzat, ügyféljegy-kivonat, visszatérítési szabályzat) meg tud válaszolni ügyfélszolgálati kérdéseket: forrásmegjelöléssel, mérhető pontossággal, és ott, ahol számolni kell, eszközhívással. A platform és minden adat kitalált. Az ilyen, a válasz előtt keresést futtató felépítést visszakereséssel kiegészített generálásnak (RAG) nevezik. [1]
Miért éppen ez a feladat? Mert ez egy kis rendszer, amelyben több fontos gyakorlati nehézség is megjelenik. A felépítést a saját dokumentumaidra is átültetheted; ehhez a beolvasást, a darabolást, a metaadatokat és a tesztkérdéseket az új anyaghoz kell igazítanod, mert a kód ennek a hat dokumentumnak a szerkezetére készült. És három olyan dolgot tanít, amit olvasásból nem lehet megérteni:
- A darabolás dönt. A saját méréseiden látod majd, hogy a dokumentumok előkészítése jelentősen befolyásolja a keresés és a válaszadás minőségét.
- Mérés nélkül vakrepülés. Építünk egy kiértékelő készletet, és minden változtatást megmérünk.
- Az eszköz a határ. Kiderül, hol ér véget a keresés hatóköre, és hol kell a modellnek eszközt adni. Ez az agentikus rendszerek kiindulópontja.
Minden teljesített önellenőrző kvíz egy réteg. A haladásod csak ebben a böngészőben tárolódik.
Mi fut kulcs nélkül? A darabolás, az indexelés, a keresés és a kiértékelés a szükséges csomagok telepítése után internet és API-kulcs nélkül is fut. Ebben a módban a program a modellnek szánt teljes promptot írja ki válasz helyett, az eszközhívást pedig kézi példán mutatja be. Nyelvi válaszok generálásához a mellékelt API-integráció kell (ez a szolgáltatónál díjjal járhat; telepítése: python -m pip install anthropic sentence-transformers, és egy API-kulcs), vagy egy külön bekötött helyi nyelvi modell. Az alapértelmezett üzemmód egy szándékosan primitív, beépített embedding: épp elég ahhoz, hogy minden mechanizmus működjön, és épp elég rossz ahhoz, hogy a 8. lépésben látványos legyen a különbség, amikor igazi modellre váltasz.
Előkészület
Cél:működő Python-környezet, a labor mappája, és az a hat Python-fogás, amelyre a kód épül.
1.1 · Amire szükséged van
Python 3.10 vagy újabb, és egyetlen csomag. Nyiss egy terminált (Windowson PowerShellt), és ellenőrizd:
$ python3 --version Python 3.12.3 $ python3 -m pip install numpy
Ha a python3 nem működik, próbáld python-ként; Windowson a py -3 is jó. Ha egyik sem, a python.org-ról telepíts, és a telepítőben pipáld ki az „Add Python to PATH” opciót.
1.2 · A projektmappa
Töltsd le a labor.zip fájlt, csomagold ki valahová, ahol megtalálod, és lépj bele:
$ cd labor $ ls kozos.py keszits_korpuszt.py l1_darabolas.py l2_index.py l3_kereses.py l4_valasz.py l5_eval.py l6_eszkoz.py kerdesek.json OLVASSEL.md
A fájlnevek szándékosan sorszámozottak: az l1-től az l6-ig haladva épül fel a rendszer, és minden lépés a korábbi kimenetére támaszkodik.
1.3 · Python-alapozó: a hat dolog, ami az egész kódhoz elég
Ha a Python még bizonytalan, végezd el előbb a Python-alapok gyakorlólapjait. Ez a szakasz csak azt a hat fogást ismétli át, amely a labor kódjában előfordul. Nyugodtan gépeld be őket egy python3 parancsértelmezőbe.
nev = "K-402" # szöveg (string) darab = 26 # egész szám (int) pontszam = 0.87 # tizedes (float) aktiv = True # logikai (bool)
Az = nem egyenlőség, hanem értékadás: „ettől kezdve a nev ezt jelenti”. Az összehasonlítás jele ==.
kurzusok = ["K-402", "K-405", "K-410"]
kurzusok[0] # "K-402": a számozás NULLÁVAL kezdődik
kurzusok[-1] # "K-410": hátulról
kurzusok.append("K-420") # a végére tesz egyet
len(kurzusok) # 4: hány elem van benne
darab = {"kurzus": "K-402", "verzio": "3.1"}
darab["kurzus"] # "K-402"
darab.get("szin", "nincs") # "nincs": nem hibázik, ha nincs ilyen kulcs
darab["uj_kulcs"] = 5 # új pár felvétele
A .get() és a [] különbsége fontos: a szögletes zárójel hibát dob hiányzó kulcsnál, a .get() nem. Valós adatnál mindig van hiányzó mező, ezért használunk sokszor .get()-et.
for kurzus in kurzusok:
print(kurzus)
for sorszam, kurzus in enumerate(kurzusok):
print(sorszam, kurzus) # 0 K-402 / 1 K-405 / ...
def hasonlosag(a, b):
"""Ez a docstring: mit csinál a függvény."""
return a * b # a return adja vissza az eredményt
ertek = hasonlosag(3, 4) # 12
import numpy as np # az egész csomag, "np" néven from pathlib import Path # csak egy dolog belőle import kozos # a SAJÁT kozos.py fájlunk
Az utolsó sor a lényeg: a saját fájljaid is modulok. Ezért tudja az l3_kereses.py használni azt, amit a kozos.py-ban megírtunk.
Tanulási mód. Ne olvasd végig a kódot, mielőtt futtatnád. Futtasd le, nézd meg a kimenetet, aztán olvasd el azt a részt, amely előállította. Ez a sorrend sokkal gyorsabb, mert a kimenet kapaszkodót ad a kódhoz.
A korpusz: amiből dolgozunk
Cél:megismerni a hat dokumentumot, és észrevenni benne a két szándékos csapdát.
kiírva: K-402_kurzusleiras.md (1370 karakter) kiírva: K-405_kurzusleiras.md (924 karakter) kiírva: ugyfelszolgalati_utmutato.md (1464 karakter) kiírva: adatvedelmi_szabalyzat.md (952 karakter) kiírva: ugyfeljegy_kivonat_2025.md (1217 karakter) kiírva: visszateritesi_szabalyzat.md (855 karakter) Kész: 6 dokumentum a dokumentumok mappában.
Nyisd meg valamelyiket. Két dolgot figyelj meg, mert mindkettő szándékos, és mindkettő vissza fog jönni:
Fejléc metaadattal. Minden dokumentum elején ott a kurzus, a terület, a dokumentumtípus, a verzió és az érvényesség kezdete. Ezekre fogunk szűrni, és a szűrés a gyakorlatban többet ér, mint egy jobb embedding-modell.
Szándékos csapdák. A K-405 kurzusleírásában ott áll, hogy a K-402 vizsgakódja nem használható. Ez azért van, hogy legyen olyan darab, amely tele van a másik kurzusra vonatkozó kulcsszavakkal, mégis rossz válasz lenne rá. A 6. lépésben pontosan ebbe fogunk beleszaladni. A másik csapda a visszatérítési szabályzat értékhatára: a „300 ezer forint” sehol nem szerepel benne szó szerint.
Feladat. Nézd át a hat dokumentumot, és írj fel öt kérdést, amelyet egy ügyfélszolgálatos tényleg feltenne. Tedd félre őket: a 8. lépésben bekerülnek a kiértékelő készletbe. Fontos, hogy most írd meg őket, mielőtt látod, mit tud a rendszer: a rendszer ismeretében írt kérdések öncsalók.
Darabolás: a legalulértékeltebb lépés
Cél:szerkezet szerint darabolni, és minden darabhoz hozzáfűzni a kontextusát.
adatvedelmi_szabalyzat.md -> 4 darab K-402_kurzusleiras.md -> 5 darab K-405_kurzusleiras.md -> 4 darab ugyfeljegy_kivonat_2025.md -> 5 darab ugyfelszolgalati_utmutato.md -> 4 darab visszateritesi_szabalyzat.md -> 4 darab Összesen 26 darab -> darabok.json darabhossz: legrovidebb 176, atlag 272, leghosszabb 610 karakter
3.1 · Miért nem karakterszám szerint vágunk
A legelterjedtebb kezdő megoldás: „vágjuk 500 karakterenként, 50 karakter átfedéssel”. Ez működik, és rossz. Az 500. karakter ott vág, ahol éppen tart: egy táblázat közepén, egy mondat felénél, egy felsorolás harmadik pontjánál. A darab fele értelmetlen lesz, a másik fele hiányos.
Mi a szerkezetet követjük: minden ## fejezetcím egy darab. Ez a kód lelke:
reszek = re.split(r"^## (.+)$", szoveg, flags=re.MULTILINE)
re: a reguláris kifejezések (mintakeresés szövegben) modulja.^## (.+)$: a minta. Sor eleje (^), két kettőskereszt és szóköz, majd bármi (.+) a sor végéig ($).- A zárójel a
(.+)körül a trükk: attól, hogy csoportba tettük, asplit()nem eldobja a fejezetcímet, hanem beleteszi az eredménylistába. Így egyszerre kapjuk meg a címeket és a tartalmakat. flags=re.MULTILINE: enélkül a^csak a teljes szöveg elejére illeszkedne, nem minden sor elejére.
3.2 · A kulcslépés: a darab viszi magával a kontextusát
beagyazando = f"{cim} — {fejezet_cim}\n\n{tartalom}"
Vedd a K-402 kurzusleírásának 5. fejezetét. A puszta tartalom így kezdődik: „Megengedett hiányzás: legfeljebb 3 alkalom.” Ha ezt a mondatot önmagában indexeled, semmit nem ér: melyik kurzusról van szó? A cím hozzáfűzésével a darab így kezdődik: „K-402 Python-alapok tanfolyam — kurzusleírás — 5. Teljesítési feltételek”, és ettől kezdve a keresés is megtalálja a „K-402 hiányzás” kérdésre, a modell pedig tudja, mit olvas.
Kulcsgondolat. Ha egy darabot kiragadva olvasva nem tudod megmondani, miről szól, akkor a kereső sem fogja.
Mélyebben: amit valós dokumentumoknál kell megoldani
A mi korpuszunk tiszta markdown. A valóság PDF, szövegszerkesztős dokumentum, szkennelt papír. Amit ott meg kell oldani, sorrendben:
- Szövegkinyerés. PDF-hez a
pymupdfa legjobb ár-érték arányú (python -m pip install pymupdf). Ha a PDF szkennelt kép, OCR kell, de előbb ellenőrizd: sok „szkenneltnek” hitt PDF valójában tartalmaz szövegréteget. - Szerkezetfelismerés. Ha nincsenek markdown-fejezetcímek, a betűméret és a félkövérség alapján lehet címeket felismerni. A
pymupdfezt blokkonként megadja. - Táblázatok. A naiv szövegkinyerés a táblázat sorait összefolyatja, és a végeredmény nem hiányos, hanem hamis lesz. Ezért érdemes a táblázatokat külön kezelni (
camelot,pdfplumber), vagy vizuális modellel strukturált formába alakítani indexelés előtt. - Verziókezelés. Ha egy szabályzat frissül, a régi darabokat törölni kell az indexből. Enélkül a rendszer magabiztosan idéz elavult szabályt, és ez a legveszélyesebb hibatípus, mert nem hallucinációnak látszik, hanem hiteles válasznak.
Ökölszabály a darabmérethez: egy darab egy megválaszolható kérdésnyi legyen. Ha egy darab öt különböző témát tartalmaz, a vektora mind az ötnek az átlaga lesz, és egyikre sem fog igazán hasonlítani.
Embedding: szövegből vektor
Cél:érteni, mit csinál a beépített embedder, miért kell normalizálni, és hol cserélhető a motor.
BACKEND = beepitett vektor hossza: 256 normája: 1.0 első 8 elem: [0. 0. 0. 0. 0. 0. 0. 0.]
4.1 · Mit csinál a beépített embedder
Az alapértelmezett mód szándékosan primitív, és pont azért, hogy át tudd látni:
vektor = np.zeros(DIMENZIO, dtype=np.float32)
for szo in _szavakra_bont(szoveg):
h = hashlib.md5(szo.encode("utf-8")).hexdigest()
vodor = int(h, 16) % DIMENZIO
vektor[vodor] += 1.0
np.zeros(256): 256 nullából álló tömb. Ez lesz a vektorunk._szavakra_bont: kisbetűsít és szavakra vág; a kötőjeles kódokat (VK-402-2K) egyben tartja, mert azok azonosítók, nem három szó.md5(...): a szóból egy determinisztikus, hosszú hexadecimális számot csinál. A lényeg a determinizmus: ugyanaz a szó mindig ugyanazt adja.% DIMENZIO: a maradékos osztás a nagy számot 0 és 255 közé szorítja. Ez a „vödör”, ahová a szó esik.vektor[vodor] += 1.0: megszámoljuk, hány szó esett abba a vödörbe.
Két szöveg vektora akkor lesz hasonló, ha sok közös szavuk van. Ennyi. Ez nem szemantika, hanem szószámlálás. A „nem tudok belépni” és a „bejelentkezési hiba” itt két teljesen idegen vektor. Sőt, a magyar ragozás miatt a „hiányozni” és a „hiányzás” is két különböző szó a számára.
Miért jó ez nekünk? Mert így a 8. lépésben meg tudod mérni, mennyit ér egy igazi embedding-modell. Nem elhinni fogod, hanem látni egy számot előtte és utána.
4.2 · A normalizálás és a skalárszorzat
def normalizal(vektor):
hossz = np.linalg.norm(vektor)
if hossz == 0:
return vektor
return vektor / hossz
Az np.linalg.norm a vektor hossza (Pitagorasz-tétel, csak 256 dimenzióban). Ha ezzel elosztjuk, a vektor egységhosszú lesz: csak irányt hordoz, nagyságot nem. Ennek egyetlen, de nagyon praktikus következménye van: egységhosszú vektoroknál a koszinusz-hasonlóság egyszerű skalárszorzat, cos(a, b) = a · b. Ez számításilag kedvező, ezért sok koszinusz-alapú keresőrendszer előre normalizált vektorokkal dolgozik. Nem mind: vannak rendszerek, amelyek normalizálatlan skalárszorzattal vagy euklideszi távolsággal keresnek, a modelltől és az index beállításától függően.
Az if hossz == 0 ág nem elmélet: ha egy darab csupa olyan jelet tartalmaz, amely kiesik a szavakra bontásból, nullvektort kapsz, és a nullával osztás megállítaná a programot. A valós adat tele van ilyennel.
4.3 · A kapcsoló
def embedding(szoveg):
if BACKEND == "beepitett":
nyers = _beepitett_embedding(szoveg)
else:
nyers = _lokalis_embedding(szoveg)
return normalizal(nyers)
Ez a hét sor az, amiért az egész labor cserélhető motorral fut. A rendszer többi része soha nem tudja meg, melyik embedder dolgozik. Ez a mintázat (a változó rész egyetlen függvény mögé zárva) teszi lehetővé, hogy egy AI-rendszer túlélje a modellváltásokat. Éles fejlesztésnél ugyanezt csinálod a modellhívással is: egy vékony réteg, amely mögött cserélhető a szolgáltató.
Mélyebben: amikor igazi embeddingre váltasz
A lokalis mód egy többnyelvű mondat-embedding modellt tölt le és futtat a gépeden (python -m pip install sentence-transformers). Az első futásnál letölti a modellt (néhány száz MB), utána offline is megy. Az ilyen modelleket kifejezetten arra tanítják, hogy a jelentésükben hasonló mondatok vektorai közel essenek egymáshoz. [4] A magyar szöveg működik vele, de a minőség nyelvenként és szakterületenként erősen szór, ezért a saját anyagodon kell mérni, nem a benchmarkokban bízni.
- Újra kell indexelni. A vektorok nem vihetők át modellek között: a régi és az új tér semmilyen viszonyban nincs egymással. Futtasd újra az
l2_index.py-t. - Aszimmetrikus modellek. Sok embedding-modell külön előtagot vár a kérdéshez és a dokumentumhoz (
query: …/passage: …). Ha ezt elrontod, mérhetően romlik a találati arány, és nagyon nehéz észrevenni, mert minden „működik”. Nézd meg a modell dokumentációjában. - Lassabb lesz. A beépített embedder pillanatok alatt végez 26 darabbal; egy igazi modell CPU-n pár másodperc. Tízezer darabnál ez már percek: ilyenkor érdemes kötegelve embeddelni, amit a
sentence-transformersalapból tud, ha listát adsz neki egy szöveg helyett.
Index: hol laknak a vektorok
Cél:látni, hogy egy vektoros index két közönséges fájl, amelyeket egyetlen sorszám köt össze.
26 darab betöltve. BACKEND = beepitett Vektormátrix alakja: (26, 256) (26 darab x 256 dimenzió) Memóriaigény: 26.0 kB Kiírva: index.sqlite, vektorok.npy Számold ki: 1 millió darabnál ez a mátrix 1.0 GB lenne float32-ben. Ezért kell egy ponton kvantálás és közelítő keresés.
5.1 · Miért nincs vektoradatbázis
Mert nem kell. Két közönséges dolgot használunk:
| Hol | Mi | Miért ott |
|---|---|---|
index.sqlite | a darabok szövege és metaadata | szűrni és visszakeresni kell rá; az SQLite a Python része, nem kell telepíteni |
vektorok.npy | a 26×256-os mátrix | a numpy egyetlen művelettel szoroz vele; ez a gyors út |
A kettő közti kapocs egyetlen szám: a sor oszlop az adatbázisban ugyanaz, mint a sorindex a mátrixban. Az egész index ezen a párhuzamon áll, és ha valaha összekevered a kettőt (például rendezés nélkül olvasod ki a sorokat), a rendszer csendben rossz találatokat fog adni. Ezért van az olvasásban az ORDER BY sor.
5.2 · A paraméterezett lekérdezés
kapcsolat.execute("INSERT INTO darabok VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(sor, azonosito, forras_fajl, ...))
A ? helyőrző nem kényelmi kérdés. Ha a szöveget közvetlenül a lekérdezésbe fűznéd, egy dokumentum, amelyben véletlenül van egy aposztróf, eltörné a lekérdezést; rosszabb esetben egy rosszindulatú tartalom parancsot csempészhetne be. Ez az SQL-injektálás, és a helyőrző az ellenszere. Szokd meg így, mindig.
Feladat. Nyisd meg az adatbázist, és nézz bele. Nem kell hozzá program: python3 -c "import sqlite3; print(sqlite3.connect('index.sqlite').execute('SELECT sor, kurzus, fejezet FROM darabok LIMIT 8').fetchall())". Ez a fajta „nézzünk bele” mozdulat lesz a hibakeresés fő eszköze. Amíg nem látod az adatot, találgatsz.
Keresés: három módszer egymás mellett
Cél:vektoros és kulcsszavas keresést építeni, összefésülni őket, és megtalálni a rendszer legveszélyesebb hibáját.
Futtasd: python3 l3_kereses.py. Három beépített kérdésre mutatja meg egymás mellett a három kereső eredményét.
6.1 · Vektoros keresés
kv = kozos.embedding(kerdes) pontszamok = matrix @ kv legjobbak = np.argsort(pontszamok)[::-1][:k]
matrix @ kv: a@a mátrixszorzás jele. A 26×256-os mátrixot szorozzuk a 256 hosszú kérdésvektorral, és 26 pontszámot kapunk: minden darabra egyet, egyetlen művelettel.np.argsort(...): nem az értékeket rendezi, hanem az indexeket adja vissza növekvő sorrendben. Nekünk az index kell, mert azzal találjuk meg a darabot.[::-1]megfordítja a sorrendet (csökkenő lesz), a[:k]pedig az első k-t veszi.
És a metaadat-szűrés:
if kurzus_szuro:
for i, sor in enumerate(sorok):
if not megengedett(sor, kurzus_szuro):
pontszamok[i] = -np.inf
rendezett = np.argsort(pontszamok)[::-1]
return [(int(i), float(pontszamok[i])) for i in rendezett
if np.isfinite(pontszamok[i])][:k]
A -np.inf (mínusz végtelen) a kizárt darabokat a lista végére teszi, az np.isfinite pedig ki is veszi őket. Mindkettő kell: ha csak a végére kerülnének, nagyobb k mellett (vagy ha kevés a megengedett darab) visszajönnének a találatok közé. Ha kevesebb megengedett darab van k-nál, rövidebb listát kapsz. Ugyanez a függvény szűri a BM25-ágat is; ehhez a sorok metaadatait is át kell adni, különben a kód hibát jelez, hogy a kért szűrés ne maradjon el észrevétlenül. Az "altalanos" és a "tobb" azért van a megengedettek között, mert az általános szabályzatok és az ügyféljegy-kivonat minden kurzusra vonatkoznak.
6.2 · BM25: a kulcsszavas ág
KÉRDÉS: VK-405-1E
VEKTOR
1. [ 0.345] K-405_kurzusleiras.md | 2. Vizsga
2. [ 0.322] ugyfeljegy_kivonat_2025.md | UJ-2025-0501 (K-405)
BM25
1. [ 9.265] K-405_kurzusleiras.md | 2. Vizsga
2. [ 8.915] ugyfeljegy_kivonat_2025.md | UJ-2025-0501 (K-405)
A beépített vektorkereső itt is talál, mert maga is szószámláló: a kód egyetlen „szóként” mindig ugyanabba a vödörbe esik. Egy igazi embedding-modell viszont a ritka kódokat gyakran elmossa, mert épp az különbözteti meg őket, ami a jelentéstérben nem jelenik meg jól. Ezért kell a BM25 ág. A képlete, amelyet a kódban látsz [2]:
pontszám = Σszó IDF(szó) · f · (k₁ + 1)f + k₁ · (1 − b + b · hossz / átlaghossz)
- IDF: a ritka szó sokat ér. Az „a” és a „nem” minden dokumentumban ott van, tehát semmit nem árul el. A
VK-405-1Ekét darabban fordul elő, tehát ha megtalálod, szinte biztosan jó helyen jársz. A logaritmus miatt a ritkaság hozama csökkenő, nem robbanó. - Telítés (k₁) és hossznormálás (b). Ha egy szó ötvenszer szerepel, az nem ötvenszer olyan releváns, mint ha egyszer: a képlet telítődik. És egy hosszú dokumentumban több szó fér el, ezért az önmagában nem érdem: a b ezt normálja.
6.3 · Összefésülés: miért nem adhatók össze a pontszámok
A két pontszám más skálán mozog, és mást jelent. A koszinusz-hasonlóság általában −1 és +1 közé esik; a labor beépített, nemnegatív vektorainál 0 és 1 közé, egy igazi embedding-modellnél negatív is lehet. A BM25 lehet 0,5 vagy 9,3: nincs felső korlátja, és korpuszfüggő. A nyers értékek közvetlen összeadása ezért nem megbízható, könnyen az egyik ág dönt. Normalizálással és súlyozással össze lehet őket vonni, de a súlyt hangolni kell. Egyszerűbb, hangolás nélküli megoldás a rangok használata, a reciprok rangfúzió [3]:
pontok[index] = pontok.get(index, 0.0) + 1.0 / (konstans + rang)
Az első helyezett 1/60-ot kap, a második 1/61-et, és így tovább. A rang összemérhető a két lista között, a pontszám nem. Ha egy darab mindkét listán ott van, kétszer kap pontot, és pontosan ezt akarjuk: az a legjobb találat, amelyet mindkét módszer megtalált. A 60-as konstans a módszer eredeti cikkéből származik: a szerzők egy előkísérletben rögzítették, közel optimálisnak találták, és azt írták, hogy a pontos választás nem kritikus [3]. Azóta is gyakran ezt használják. Azt szabályozza, mennyivel ér többet az 1. hely a 10.-nél.
6.4 · A tanulságos kudarc
Futtasd le az l4_valasz.py-t a K-402 vizsgájáról szóló kérdéssel, és nézd meg az átadott forrásokat:
ÁTADOTT FORRÁSOK: [1] K-405_kurzusleiras.md — 2. Vizsga <-- ROSSZ KURZUS! [2] K-402_kurzusleiras.md — 2. Vizsga [3] K-405_kurzusleiras.md — 3. Képzési ütem [4] K-402_kurzusleiras.md — 1. Általános adatok
Az első helyen a másik kurzus leírása áll. Miért? Mert abban a fejezetben szerepel a „K-402”, a „vizsga” és a „VK-402-2K” is, csak épp azért, hogy figyelmeztessen: ide nem való. Minden kulcsszó stimmel, a jelentés az ellenkezője.
Ez a RAG legveszélyesebb hibatípusa. Nem hallucináció: a rendszer valódi dokumentumból, valódi mondatot fog idézni, csak rossz kurzusról. Egy ügyfélszolgálatos, aki megbízik benne, rossz vizsgakódot mond a hallgatónak. A modell jobb utasítással néha kiválaszthatja a helyes kurzust a kontextusból, de erre nem lehet építeni: a hiba a keresésben keletkezett, mielőtt a modell bármit látott volna, és ott is javítható a legmegbízhatóbban.
Két megoldás van, és mindkettőt érdemes megcsinálni:
- Metaadat-szűrés. Ha a kérdésből kinyered a kurzuskódot (egy reguláris kifejezés is elég:
[A-Z]-\d{3}), és mindkét keresési ágnak átadod akurzus_szuroparaméterben, a K-405 darabjai kiesnek. Ennél a hibánál ez a legközvetlenebb és legjobban ellenőrizhető javítás. - Forrásjelölés a válaszban. Ha a válasz mellett ott van, hogy melyik fájl melyik fejezetéből jött, az ember egy pillantással kiszúrja a tévedést. A gép hibázni fog; a rendszer feladata, hogy a hiba látható legyen.
Feladat. Írd meg a kurzuskód-kinyerést. Az l4_valasz.py kerdez() függvényében add át a szűrőt a vektoros és a BM25-ágnak is, aztán futtasd újra ugyanazt a kérdést. Kipróbáltuk: szűrővel a négy átadott forrás a K-402 vizsgája, általános adatai, egy K-402-es ügyféljegy és az előkészületről szóló fejezet, a K-405 pedig a tíz fúziós találat közül is eltűnik. Ha csak a vektoros ágat szűröd, a tíz találat között még két K-405-ös darab marad: a rangfúzió a helyezéseket nézi, és a szűretlen BM25-lista visszahozza őket. Ez az első saját javításod a rendszeren, és a 8. lépésben meg is tudod mérni, mennyit ért.
import re
talalat = re.search(r"[A-Z]-\d{3}", kerdes)
kurzus = talalat.group(0) if talalat else None
# és add át mindkét ágnak:
# vektor_kereses(kerdes, matrix, sorok, k=10, kurzus_szuro=kurzus)
# bm25_kereses(kerdes, bm25, k=10, sorok=sorok, kurzus_szuro=kurzus)
Válasz: a kontextus összeállítása
Cél:a megtalált darabokból ellenőrizhető, forrásjelölt választ kérni, és tudni, mit old meg ebből a prompt, és mit nem.
Futtasd: python3 l4_valasz.py "hány alkalmat lehet hiányozni a K-402-n". Ha nincs API-kulcsod, a szkript nem hív semmit, hanem kiírja a promptot, amelyet elküldene. Ez nem pótmegoldás: éles fejlesztésnél is ez az első, amit megnézel, ha rossz a válasz. A legtöbb „a modell buta” panasz valójában azt jelenti, hogy a promptban nem az volt, amit hittél.
7.1 · A sorrend nem esetleges
prompt = f"""DOKUMENTUMRÉSZLETEK:
{kontextus}
---
KÉRDÉS: {kerdes}
Válaszolj a fenti szabályok szerint, forrásjelöléssel."""
A dokumentumok elöl, a kérdés hátul. Két oka van, és mindkettő gyakorlati:
- A modell a kontextus elejét és végét kezeli a legmegbízhatóbban (lásd a Hibamódok 4. szintjét). A kérdés a legfontosabb elem, kerüljön a legjobb helyre, közvetlenül a generálás elé.
- Az állandó rész elöl gyorsítótárazható. Ha ezer kérdést futtatsz ugyanazzal a rendszerüzenettel, a szolgáltató a közös előtagot olcsóbban számolja, de csak akkor, ha tényleg közös előtag, vagyis a változó rész hátul van.
7.2 · A kimeneti szerződés
A rendszerüzenet négy dolgot köt ki, és mindegyik egy konkrét hibatípus ellen véd:
| Szabály | Mi ellen véd |
|---|---|
| Csak a megadott részletekből dolgozz | a modell általános tudásból „kiegészíti” a hiányt, hihetően és hamisan |
| Minden állítás után forrásszám | ellenőrizhetetlen válasz; a forrásjelölés teszi utólag vizsgálhatóvá |
| Ha nincs adat, mondd ki pontosan így | találgatás; a rögzített mondat gépileg is felismerhető |
| Ellentmondásnál mutasd mindkettőt | a modell csendben választ egyet, épp a K-402/K-405 esetben |
Figyeld meg az utolsót: a 6.4-ben látott csapdára ez a második védelmi vonal. Ha a rossz darab mégis bekerül, a modellnek legalább jeleznie kell az ellentmondást, ahelyett hogy összemosná a két kurzust.
Amit a szerződés nem old meg. Semmilyen prompt nem garantálja, hogy a modell be is tartja. Ezért kell a 8. lépés: meg kell mérni, hányszor tartja be. A promptírás hipotézis, a mérés a kísérlet.
Mérés: innentől nem hit, hanem adat
Cél:kiértékelő készlettel mérni a keresést, és a számokból kiolvasni, mit kell javítani.
Futtasd: python3 l5_eval.py. Eddig építettünk. Innentől azt kérdezzük: jó-e? És ami még fontosabb: ha holnap átírsz valamit, jobb lett-e? Enélkül minden változtatás vakrepülés: úgy érzed, javult, mert három példát megnéztél, és nem tudod, hogy közben tíz másik elromlott.
8.1 · Az ellenőrzött tesztkészlet (golden set)
A kerdesek.json tizenöt kérdést tartalmaz, mindegyikhez azt, hogy melyik dokumentum melyik fejezetében van a válasz. A rétegzés szándékos:
| Típus | Mit tesztel |
|---|---|
tipikus | a hétköznapi kérdések; ha ezek buknak, minden bukik |
szemantikus | ahol a kérdés és a dokumentum más szavakat használ; ez méri az embedding valódi értékét |
azonosito | kód, pontos karakterlánc; ez méri a BM25 értékét |
aggregalo | amihez számolni kell; a 9. lépésben derül ki, miért nem keresési feladat |
nincs_adat | amire a helyes válasz „nem tudom”; enélkül a mérés a magabiztos hazugságot jutalmazná |
8.2 · Ami kijön (beépített embedderrel)
Saját mérés, nem benchmark. Az alábbi számok a labor mellékelt, kitalált mintakorpuszán és kérdéssorán, a beépített embedderrel mért eredmények. Más korpuszon, más kérdésekkel és más modellel mások lesznek; általános tanulságot csak az irányokból érdemes levonni. Ez a futtatás a visszakeresést értékeli: a 15 kérdésből azt a 14-et, amelyre van válasz a dokumentumokban. A válaszgenerálás, a forráshűség és a hiányzó információ felismerése külön tesztet igényel.
k = 1: csak vektor recall@1: 6/14 ( 42.9%) csak BM25 recall@1: 8/14 ( 57.1%) hibrid (fúzió) recall@1: 7/14 ( 50.0%) k = 3: csak vektor recall@3: 9/14 ( 64.3%) csak BM25 recall@3: 11/14 ( 78.6%) hibrid (fúzió) recall@3: 10/14 ( 71.4%) k = 5: csak vektor recall@5: 12/14 ( 85.7%) csak BM25 recall@5: 12/14 ( 85.7%) hibrid (fúzió) recall@5: 10/14 ( 71.4%) TIPUSONKÉNTI BONTÁS (hibrid, k=3): aggregalo 1/1 azonosito 1/1 szemantikus 3/4 tipikus 5/8
Három dolgot olvass ki ebből, mert mindhárom általánosítható tanulság:
- A BM25 veri a vektorkeresést. Ez most nem meglepő: a beépített embedder maga is szószámláló, csak rosszabbul, mert a hash-vödrökben a szavak ütköznek, és elveszik az IDF-súlyozás. Egy jó embedding-modellel ez az arány a szemantikus kérdéseknél megfordulhat.
- A hibrid nem mindig nyer. k = 5-nél a fúzió (71,4%) rosszabb, mint a tiszta BM25 (85,7%). Ez fontos: a hibrid keresés nem varázsszó. Ha az egyik ág rossz, a fúzió lehúzza a jót. Ezért kell mérni, és nem az ökölszabályt követni.
- A kérdésenkénti bontás mondja meg, mit javíts. A bukott kérdések mind ugyanazt mutatják: a kérdés más szóalakot használ, mint a dokumentum. „Hány alkalmat lehet hiányozni” – a dokumentumban „Megengedett hiányzás”. „Mikor kell azonnal továbbadni” – a dokumentumban „Azonnali továbbítás”. „300 ezer forintos” – a dokumentumban „100 000 - 1 000 000 Ft között”. A magyar ragozás miatt a szószámláló kereső ezeket különböző szavaknak látja. Az összesített 71,4% ezt elrejtené.
8.3 · A nagy kísérlet
Ez a labor lényege, csináld meg. Jegyezd fel a fenti számokat. Telepítsd a sentence-transformers csomagot, a kozos.py-ban állítsd a BACKEND értékét "lokalis"-ra, építsd újra az indexet (python3 l2_index.py, kötelező, a régi vektorok érvénytelenek), aztán futtasd újra az l5_eval.py-t, és hasonlítsd össze.
Figyeld külön a szemantikus és a ragozás miatt bukott kérdéseket. Ott várható a legnagyobb ugrás, mert egy jó szemantikus embedding-modell olyan hasonlóságokat is felismerhet, amelyeket a puszta szóegyezés nem [4] [5]. Garancia erre sincs. Ha nem ugrik, az is információ: akkor a modell magyar nyelvi képessége a szűk keresztmetszet, és másik modellt kell próbálni. Előre nem tudjuk megmondani, mit fogsz látni: a magyar nyelvű teljesítmény modellenként erősen szór, és ezt őszintébb megmérni, mint megjósolni.
Mélyebben: amit ezen felül mérni kell éles rendszerben
A recall@k a visszakeresést méri [6]. Ebben a laborban kérdésenként egyetlen helyes fejezetet fogadunk el, ezért a mért érték valójában a találati arány (hit rate@k: a kérdések hány százalékánál van a helyes darab az első k-ban), ami ebben az esetben egybeesik a recall@k-val. Ha egy kérdéshez több releváns darab tartozik, a kettő eltér. A generáláshoz két további mérőszám kell, és mindkettőt gyakran egy második nyelvi modellel, bíró-modellel mérik [7]:
- Megalapozottság: a válasz minden állítása levezethető-e az átadott darabokból? Bontsd a választ állításokra, és mindegyikre kérdezd meg egy modelltől, hogy a forrás alátámasztja-e. Bináris ítélet, egy állítás egy hívás.
- Válaszrelevancia: a válasz a feltett kérdésre válaszol-e, vagy mellé.
A diagnózis ebből egyszerű: ha a recall jó és a megalapozottság rossz, a prompt vagy a modell a gond; ha a recall rossz, a darabolás, az indexelés vagy a keresés, és a promptot hangolni időpazarlás. A bíró-modellnél bináris vagy háromfokú ítéletet kérj, ne 1–10-es skálát; egy szempont egy hívás; és kalibráld emberrel: értékelj ki 50 esetet kézzel is, és nézd meg az egyezést. A bíró-modell jól skálázható, de maga is tévedhet és torzíthat: kimutatták például, hogy ítéletét befolyásolja a válaszok sorrendje és hossza, és hajlamos a saját válaszait előnyben részesíteni [8]. Fontos rendszernél ezért kézzel címkézett tesztkészlet és rendszeres emberi ellenőrzés kell mellé.
A kiértékelő készlet hosszú távú értéke nem a mai számban van, hanem abban, hogy minden éles hibából új tesztkérdés lesz, ahogy egy jó ügyfélszolgálat is minden visszatérő panaszból útmutatót ír. Tarts zárt részt (a készlet egyharmadát soha ne használd fejlesztésre), verziózd a készletet is, és legyen legalább egy ellentétes irányba feszítő mérőszámpár: a megalapozottság önmagában az óvatos, semmitmondó választ jutalmazza, ezért kell mellé a hasznosság. Ez a Goodhart-törvény konkrét megjelenése (lásd a Veszteségfüggvény 8. szintjét).
Eszközhívás: ahol a keresés véget ér
Cél:felismerni, mikor számolási és nem keresési feladat egy kérdés, és eszközt adni a modellnek.
5 ügyféljegy betöltve az adatbázisba.
KÉRDÉS: Mennyi volt a K-402 ügyféljegyeinek összes megoldási ideje 2025-ben?
[BACKEND != 'api' — a hurok helyett kézi bemutató következik]
2. Ezt kérné vissza (eszközhívás):
{"name": "ugyfeljegy_statisztika", "input": {"kurzus": "K-402"}}
3. A TE kódod lefuttatja:
{"kurzus": "K-402", "ugyfeljegyek_szama": 3, "osszes_megoldasi_ido_ora": 19,
"leghosszabb_megoldasi_ido_ora": 9, "jegyazonositok": [...]}
Próbáld ki a hibás ágat is:
{"hiba": "Nincs 'K-999' kódú kurzus. Ismert kurzusok: K-402, K-405."}
Ezt keresésből nem lehet megválaszolni, akármilyen jó az embedding. A válasz nincs leírva sehol: három ügyféljegyből kell összeadni (6 + 9 + 4 = 19 óra). Ez nem keresési, hanem számítási feladat: itt a keresés önmagában kevés. Egy RAG-rendszer ezért számítást és eszközhívást is tartalmazhat; az eszközhasználat nem a RAG ellentéte, hanem kiegészítése. A megoldás, hogy a modell eszközt hívhat, visszakapja az eredményt, és ebből dönti el a következő lépést. Ez az egyszerű hurok (modell → eszköz → eredmény → modell) már agentikus működés, ehhez nem kell külön keretrendszer [9].
9.1 · Az eszköz: egy közönséges függvény
def ugyfeljegy_statisztika(kurzus=None):
...
return {
"kurzus": kurzus or "összes",
"ugyfeljegyek_szama": len(sorok),
"osszes_megoldasi_ido_ora": sum(orak),
"leghosszabb_megoldasi_ido_ora": max(orak),
"jegyazonositok": [s[0] for s in sorok],
}
Az utolsó sor egy listaértelmezés: a [s[0] for s in sorok] ugyanaz, mintha ciklussal gyűjtenéd össze minden sor első elemét. A fontos rész viszont nem a szintaxis, hanem hogy mit ad vissza: összesített, tömör adatot. Nem a három teljes ügyféljegy szövegét, mert az bemenne a kontextusba, és tokent meg figyelmet enne. Az eszköz feladata a szűrés, nem a nyers adat átpasszolása.
9.2 · A tanító hibaüzenet
return {"hiba": f"Nincs '{kurzus}' kódú kurzus. Ismert kurzusok: K-402, K-405."}
A hibaüzenet nem azt mondja, hogy „érvénytelen paraméter”, hanem megmondja, mi a helyes. A modell ebből a következő lépésben ki tudja javítani magát, emberi beavatkozás nélkül. Egy „Hiba: 400” üzenettel viszont beragad, és találgatni kezd. A pontos hibaüzenet hasznos visszajelzés: segíthet a modellnek a következő eszközhívás javításában. Ez a beszélgetési előzményen keresztül hat, a modell súlyai nem változnak. Írd őket úgy, mintha egy új kollégának magyaráznád.
9.3 · Az agenthurok
for lepes in range(MAX_LEPES):
valasz = kliens.messages.create(..., tools=[ESZKOZ_SEMA], messages=uzenetek)
uzenetek.append({"role": "assistant", "content": valasz.content})
hivasok = [b for b in valasz.content if b.type == "tool_use"]
if not hivasok:
return szoveg # kész, van válasz
for hivas in hivasok:
kimenet = ugyfeljegy_statisztika(**hivas.input)
eredmenyek.append({...}) # az eredmény visszamegy
uzenetek.append({"role": "user", "content": eredmenyek})
- A modell soha nem futtat semmit, csak kér. Minden tényleges hatás a te kódodban keletkezik, ezért ott dől el minden jogosultsági és biztonsági kérdés is. Ez jó hír: a kontroll pontja pontosan meghatározott.
- A
**hivas.inputa szótárat nevesített paraméterekké bontja: a{"kurzus": "K-402"}-bőlugyfeljegy_statisztika(kurzus="K-402")lesz. Éles rendszerben ide ellenőrzés kell, mert a modell hívási kérése nem megbízható bemenet. - A
MAX_LEPESnem díszlet. Enélkül egy félresiklott modell a végtelenségig próbálkozhatna. Éles rendszerben mellé token- és költségkeret is kell. - Az előzményt te tartod karban. A modellnek nincs memóriája; a beszélgetés attól beszélgetés, hogy minden fordulóban visszaküldöd az egészet. Ezért drágul a hosszú agentfutás.
Feladat. Írj egy második eszközt: kurzus_adatok(kurzus), amely a kurzusleírásból visszaadja az alapadatokat (díj, létszám, megengedett hiányzás). Három helyen kell hozzányúlnod: a sémáját add át a modellnek (tools=[...]), vedd fel a VEGREHAJTOK szótárba, amely a hívott eszköz neve alapján választja ki a lefuttatandó függvényt, és ellenőrizd a bemenetét az adatbázis-hívás előtt, ahogy az ugyfeljegy_statisztika() teszi a kurzuskód típusával. Az ismeretlen eszköznevet, a hibás paraméternevet és a nem objektum formájú bemenetet az eszkoz_futtat() kezeli. Aztán tegyél fel egy kérdést, amelyhez mindkettő kell: „Hány ügyféljegy érkezett tavaly a K-402-ről, és hány alkalmat lehet hiányozni rajta?” Jó eséllyel a modell két külön hívást indít; ez a többlépéses agent legkisebb valódi példája. Garantálni nem lehet: futásonként eltérhet, hogyan bontja lépésekre a feladatot.
Innen hová: az éles rendszerig hátralévő út
Cél:tudni, milyen sorrendben érdemes egy működő, mért rendszert éles használatra felkészíteni.
Ami eddig elkészült, az egy működő, mért rendszer. Ami hiányzik belőle, az nem a modell, hanem a körítés. Nagyjából ebben a sorrendben érdemes hozzáadni:
| # | Mit | Miért ez a sorrend |
|---|---|---|
| 1 | Metaadat-szűrés a kérdésből kinyert kurzuskódra | a legnagyobb minőségjavulás a legkevesebb munkából (6.4) |
| 2 | Újrarangsorolás: keress 50-et, rendezd újra egy erősebb modellel, adj át 5-öt | a legjobb ár-érték arányú javítás; gyakran többet hoz, mint egy nagyobb válaszoló modell |
| 3 | Naplózás: minden kérdés, találat, prompt, válasz, költség | enélkül nincs hibakeresés, és a tesztkészlet ebből fog bővülni |
| 4 | Költség- és lépéskorlát, időtúllépés, újrapróbálkozás | az első éles nap meg fogja mutatni, miért |
| 5 | Verziókezelés az indexben: frissülő dokumentum régi darabjainak törlése | az elavult szabály magabiztos idézése a legveszélyesebb hibatípus |
| 6 | Jogosultság: a keresés csak azt adja vissza, amit a kérdező láthat | a technika hasonló (szűrés a rangsorolás előtt), de a jogosultságot a hitelesített felhasználóhoz kötött, megbízható szabály adja, nem a kérdésből kinyert kurzuskód |
| 7 | Igazi vektoradatbázis | csak ha már tízezres nagyságrendben vagy; előbb felesleges bonyolítás |
Figyeld meg a sorrendet. A vektoradatbázis a hetedik. A legtöbb kezdő projekt ott kezdi, és hónapok múlva derül ki, hogy a rendszer azért rossz, mert a darabolás rossz volt, és senki nem mért semmit. A technológiai választás szinte mindig a legkevésbé fontos döntés.
10.2 · Ha a saját anyagodra ülteted át
- Kezdd 20–50 dokumentummal, ne az egész archívummal. A tanulságok ugyanazok, a ciklusidő a tizede.
- Írd meg a tesztkészletet előbb, mint hogy megnéznéd, mit tud a rendszer. Ötven valódi kérdés azoktól, akik használni fogják: ez a legértékesebb fél nap az egész projektben.
- Tisztázd az adatvédelmet, mielőtt egyetlen dokumentumot feltöltenél bármilyen felhőszolgáltatásba. A
lokalismód pontosan azért van a laborban, hogy úgy is dolgozhass, hogy semmi nem hagyja el a gépedet. - Ne akarj rögtön csevegőrobotot. A legjobb első verzió egy keresődoboz, amely forrásjelölt választ ad. A beszélgetés jöhet később, ha egyáltalán kell.
Hibakereső
| Tünet | Ok | Megoldás |
|---|---|---|
ModuleNotFoundError: numpy | nincs telepítve | python3 -m pip install numpy (Windowson py -3 -m pip install numpy) |
FileNotFoundError: darabok.json | kimaradt egy lépés | futtasd sorban: keszits_korpuszt → l1 → l2 |
ModuleNotFoundError: kozos | nem a labor mappájából futtatod | cd labor, és onnan indítsd |
| A találatok értelmetlenek modellváltás után | régi vektorok az új embedderrel | futtasd újra az l2_index.py-t |
| Minden pontszám 0 körüli | a kérdés szavai egyáltalán nem szerepelnek a korpuszban | beépített módban ez normális: ez maga a szemantikai vakság |
| Ékezetes szavak nem találnak | kódolási hiba a fájlbeolvasásnál | mindenhol encoding="utf-8"; a kódban így van, saját fájlnál figyelj rá |
| A kiértékelés mindig 100% | túl könnyű a kérdéssor | ez nem siker, hanem jelzés: írj nehezebb és határesetekre vonatkozó kérdéseket |
Az utolsó gondolat. Ha ezt végigcsináltad, többet tudsz a RAG-rendszerekről, mint aki tíz cikket olvasott róluk, mert láttad, hol romlik el, és megmérted, mennyivel lett jobb, amikor javítottál. A következő lépés nem újabb olvasnivaló: vedd a saját dokumentumaidat, és futtasd le rajtuk ugyanezt a hét lépést.
Irodalom
A számok a szövegbeli [n] jelölésekre utalnak.
- [1]
Lewis, P. és mtsai. (2020). Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks. NeurIPS. arXiv:2005.11401
- [2]
Robertson, S., Zaragoza, H. (2009). The Probabilistic Relevance Framework: BM25 and Beyond. Foundations and Trends in Information Retrieval. doi:10.1561/1500000019
- [3]
Cormack, G. V., Clarke, C. L. A., Büttcher, S. (2009). Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods. SIGIR, 758–759. doi:10.1145/1571941.1572114
- [4]
Reimers, N., Gurevych, I. (2019). Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks. EMNLP. arXiv:1908.10084
- [5]
Karpukhin, V. és mtsai. (2020). Dense Passage Retrieval for Open-Domain Question Answering. EMNLP. arXiv:2004.04906
- [6]
Manning, C. D., Raghavan, P., Schütze, H. (2008). Introduction to Information Retrieval. Cambridge University Press. 8. fejezet: a keresés kiértékelése. nlp.stanford.edu/IR-book
- [7]
Es, S., James, J., Espinosa-Anke, L., Schockaert, S. (2023). Ragas: Automated Evaluation of Retrieval Augmented Generation. arXiv:2309.15217
- [8]
Zheng, L. és mtsai. (2023). Judging LLM-as-a-Judge with MT-Bench and Chatbot Arena. NeurIPS Datasets and Benchmarks. arXiv:2306.05685
- [9]
Yao, S. és mtsai. (2023). ReAct: Synergizing Reasoning and Acting in Language Models. ICLR. arXiv:2210.03629
A labor dokumentumai egy kitalált oktatási platform kitalált adatai. A kimenetek a beépített embedderrel, a letölthető kóddal mért valódi eredmények; a futási idő gépenként eltér.
Tanításban vagy a munkahelyeden használod? Örülnék, ha megírnád: csaplar.d@gmail.com