AI Engineering: RAG chatbot építése a nullától OpenAI ChromaDB
Ebben a kurzusban egy webshop ügyfélszolgálati RAG chatbotot építünk lépésról lépésre. A végén lesz egy můködő rendszered, ami dokumentumokat tölt be, embeddingeket generál, szemantikus keresést végez, választ generál, eval metrikákat mér és cost/latency-t monitoroz.
A WebShop Pro egy fiktív webshop termékkatalógussal, visszaküldési szabályzattal, szállítási FAQ-val.
A kurzus végére elkészítesz egy lokálisan futó RAG chatbotot dokumentumbetöltéssel, chunkinggal, embeddinggel, vektoros kereséssel, válaszgenerálással, eval metrikákkal és cost/latency monitoringgal.
Fogalmi térkép: LLM, RAG, Agent, Embedding OpenAI
| Fogalom | Magyarázat | Analógia |
|---|---|---|
| LLM | Nagy nyelvi modell - szövegből szöveget generál | Mint egy nagyon olvasott szakember |
| RAG | Retrieval-Augmented Generation: külső tudásbázisból keres, az alapján válaszol | A szakember felolvas egy dokumentumot, abból válaszol |
| Embedding | Szöveg sůrített vektor-ábrázolása | Dokumentum koordinátája egy értelem-térben |
| Vector DB | Hasonlóság alapján keres vektorok között | Könyvtár, ahol a hasonló könyvek egymás mellett vannak |
| Agent | LLM, ami eszközöket hívhat, tervez és iterál | A szakember telefonál, keres, számol, aztán válaszol |
LLM egyedül: "Mit tudsz a visszaküldési szabályzatról?" → generalizál, hallucinálhat
LLM + RAG: → keres a dokumentumban → pontos válasz forrással
Architektúra-térkép: a RAG pipeline ChromaDB OpenAI
A végcél egy ilyen pipeline:
Demóban: néhány dokumentum, lokális ChromaDB, egy API hívás
Éles környezetben: dokumentum verziókezelés, embedding cache, rate limiting, fallback modellek, observability, auth, SLA
Környezet előkészítése Python
Mielőtt elkezdenénk a RAG chatbot építését, be kell állítanunk a fejlesztői környezetünket. Ehhez három fő Python csomagra van szükségünk: az openai könyvtár az LLM és embedding API hívásokhoz, a chromadb a vektoradatbázis műveleteihez, és a numpy a numerikus számításokhoz, például a koszinusz-hasonlóság kiszámításához.
Az OpenAI() kliens automatikusan az OPENAI_API_KEY környezeti változóból olvassa be az API kulcsot. Ha ez nincs beállítva, a hívások hibát dobnak. Éles környezetben soha ne hardkódold az API kulcsot a forráskódba -- használj .env fájlt vagy secret managert.
A kódban látod az alapvető importokat: az os és json modulok fájl- és adatkezeléshez, a time pedig a válaszidő méréséhez kell majd a későbbi szekciókban. Minden további szakasz ezekre az alapokra épül.
Futtasd a terminálban: pip install openai chromadb numpy. Az API kulcsot a export OPENAI_API_KEY="sk-..." paranccsal állítsd be, vagy hozz létre egy .env fájlt.
# pip install openai chromadb numpy import os, json, time from openai import OpenAI client = OpenAI() # OPENAI_API_KEY a környezeti változóból print("OpenAI client initialized")
[openai] Client initialized successfully API key loaded from environment variable
Chat Completions API alapok Chat Completions API
Az OpenAI Python SDK-ban ma két fontos mintával találkozol: az újabb Responses API-val és a sok meglévő projektben használt Chat Completions API-val. Ebben a blokkban Chat Completions-kompatibilis példát használunk, mert a messages lista nagyon jól megmutatja az alapgondolatot: minden üzenetnek van role mezője (system / user / assistant) és content mezője, vagyis az API pontosan látja, ki mit mondott.
| Mező | Mire való |
|---|---|
client.chat.completions.create() | A hívás belépési pontja |
messages=[{"role": "system", ...}, {"role": "user", ...}] | A teljes beszélgetési kontextus |
response.choices[0].message.content | A modell válaszának szövege |
response = client.chat.completions.create(
model="gpt-4o",
messages=[
{"role": "system", "content": "Te egy webshop ügyfélszolgálati asszisztens vagy. Rövid, pontos válaszokat adj."},
{"role": "user", "content": "Mennyi idő alatt érkezik meg a csomag?"}
]
)
print(response.choices[0].message.content)[openai] chat.completions.create() model=gpt-4o Response received: A szállítási idő a választott módtól függ: - Standard: 3-5 munkanap - Expressz: 1-2 munkanap - Aznapi: rendelés 12:00-ig
A válasz szövege a response.choices[0].message.content alatt érhető el. A choices egy lista, mert elvileg több jelölt válasz is kérhető (n paraméter), de RAG-ban szinte mindig csak [0]-t használjuk.
System message vs User message OpenAI
system message — rendszer-szintű viselkedési szabályok. Rögzített, általában nem változik hívásonként.
user message — a konkrét felhasználói kérdés, hívásonként változik.
Biztonsági szempontból fontos: a két szerepet külön messages elemekre kell osztani, nem pedig egyetlen f-stringbe ágyazni. Ha a felhasználói inputot (kerdes) közvetlenül a system promptba interpolálod, az utat nyit a prompt injection-nek (a felhasználó beleírhat olyan instrukciót, ami felülírja a rendszerprompt szabályait). A role alapú elválasztás biztonságosabb, mert a modell tudja, melyik üzenet honnan származik.
SYSTEM_INSTRUCTIONS = """ Te a WebShop Pro ügyfélszolgálati botja vagy. Szabályok: 1. Csak a megadott tudásbázisból válaszolj 2. Ha nem tudod: "Továbbítom a csapatnak." 3. Soha ne találj ki információt 4. Mindig magyarul válaszolj """ kerdes = "Vissza tudok küldeni egy 14 napja vásárolt terméket?" response = client.chat.completions.create( model="gpt-4o", messages=[ {"role": "system", "content": SYSTEM_INSTRUCTIONS}, {"role": "user", "content": kerdes} ] ) print(response.choices[0].message.content)
[openai] chat.completions.create() with system + user messages Response received: Igen, a terméket a vásárlástól számított 30 napon belül visszaküldheted, feltéve ha eredeti állapotú.
Ne "think step by step"-et használj. Helyette:
- Structured reasoning: "Elemzd a kérdést, azonosítsd a kulcsfogalmakat, aztán válaszolj."
- Decomposition: "Bontsd fel a problémát részfeladatokra."
- Explainable format: "Formátum: [Forrás] [Érvelés] [Válasz]"
A direct prompt injection (a felhasználó közvetlen 'Ignore previous' utasítás) ellen a system/user message szétválasztás véd. RAG rendszerekben azonban van egy másik támadási vektor: az indirect prompt injection. Ha a retrieve-elt dokumentumokba (céges wiki, ügyfél-feltöltés) malicious utasítást rejtenek el, az LLM kontextusában jelenik meg, és úgy fogja olvasni, mintha az lenne az utasítás.
Védekezés: kontextus sanitizáció (HTML/Markdown escape), strukturális szétválasztás (a kontextus külön role-ban, nem inline), és LLM-output validálás. Részletek a RAG Evaluation & AI Safety kurzusban.
Structured outputs: JSON válasz OpenAI
A structured output (strukturált kimenet) azt jelenti, hogy az LLM válaszát nem szabad szabad formátumú szövegként kezelnünk, hanem egy előre meghatározott JSON sémának megfelelő objektumként kapjuk vissza. Ez kulcsfontosságú RAG rendszerekben, ahol a válasz mellé forrást, megbízhatósági pontszámot és kategóriát is vissza kell adni a downstream rendszerek számára.
Az OpenAI API-k strukturált kimenetet is támogatnak: megadhatsz egy JSON sémát, és a modell válaszát ehhez a sémához kötheted. Ez sokkal megbízhatóbb, mint egyszerűen beleírni a promptba, hogy „válaszolj JSON-ban", mert a séma gépileg ellenőrizhető szerződéssé válik a modell és az alkalmazás között. Éles rendszerben ettől lesz stabil a downstream feldolgozás: a frontend, a validátor és a monitoring ugyanazokra a mezőkre számíthat.
A példában egy support_answer sémát definiálunk három mezővel: answer (a tényleges válaszszöveg), category (enum: szallitas, visszakuldes, garancia, egyeb), és confidence (0-1 közötti szám). A modell mindig ezt a struktúrát adja vissza, így a frontend kód biztonsággal dolgozhatja fel.
A válasz továbbra is a response.choices[0].message.content alatt érhető el, viszont JSON schema módban ez egy JSON string lesz, amit json.loads()-szal kell objektummá parse-olni. Strict módban a séma minden propertyje kötelező és additionalProperties: false kell — különben az API hibát dob.
import json response = client.chat.completions.create( model="gpt-4o", messages=[ {"role": "system", "content": "Válaszolj JSON-ban a megadott sémának megfelelően."}, {"role": "user", "content": "Mennyibe kerül a szállítás?"} ], response_format={ "type": "json_schema", "json_schema": { "name": "support_answer", "strict": True, "schema": { "type": "object", "properties": { "answer": {"type": "string"}, "category": {"type": "string", "enum": ["szallitas", "visszakuldes", "garancia", "egyeb"]}, "confidence": {"type": "number"} }, "required": ["answer", "category", "confidence"], "additionalProperties": False } } } ) result = json.loads(response.choices[0].message.content) print(json.dumps(result, indent=2, ensure_ascii=False))
[openai] chat.completions.create() structured output (JSON schema, strict) Parsed JSON response: { "answer": "A standard szállítás 990 Ft, expressz 2490 Ft.", "category": "szallitas", "confidence": 0.95 }
Demóban: JSON schema validáció. Éles környezetben: schema verziókezelés, backward compat, fallback, validation middleware.
Tool calling: függvények hívása OpenAI
A eszközhívás (eszközhívás) az a képesség, amellyel az LLM képes külső függvényeket meghívni a válaszgenerálás során. Nem te magad döntöd el, mikor hívod a függvényt -- a modell maga ismeri fel a szituációt, és automatikusan kéri a függvény meghívását, ha szükséges. Ez teszi az LLM-et "ügynökké": önállóan tervez és cselekszik.
A folyamat lépései: (1) elküldöd a kérdést az LLM-nek a definiált eszközökkel együtt, (2) a modell felismeri, hogy egy tool-t kell hívnia, és visszaadja a függvény nevét és paramétereit, (3) te futtatod a saját kódodat az eredménnyel, (4) visszaküldöd az eredményt a modellnek, (5) a modell a végleges választ generálja az eredmény alapján.
A példában egy get_order_status függvényt definiálunk, amely rendelésszám alapján lekérdezi a rendelés státuszát. Amikor a felhasználó azt kérdezi, "Mi a helyzet a WSP-12345 rendeléssel?", a modell felismeri, hogy rendelésszám van a szövegben, és automatikusan meghívja a tool-t.
A tool definícióban a description mező kritikus: a modell ebből érti meg, mikor használja az eszközt. Minél pontosabb a leírás, annál kevesebb a téves hívás. Mindig adj magyar nyelvű, egyértelmű leírást.
Chat Completions API-ban a tool hívásokat a response.choices[0].message.tool_calls listán kapjuk vissza (nem a content-en). Minden elem tartalmazza a függvény nevét (tc.function.name) és a JSON-stringként szerializált argumentumokat (tc.function.arguments) — ezt json.loads()-szal kell parse-olni. A tool_choice="auto" azt jelenti, hogy a modell maga dönti el, hív-e tool-t, vagy egyenes választ ad.
# Tool definíció tools = [{ "type": "function", "function": { "name": "get_order_status", "description": "Rendelés státuszának lekérdezése rendelésszám alapján", "parameters": { "type": "object", "properties": { "order_id": {"type": "string", "description": "Rendelésszám, pl. WSP-12345"} }, "required": ["order_id"] } } }] # Mock implementáció def get_order_status(order_id): orders = { "WSP-12345": {"status": "szállítás alatt", "eta": "2025-01-15"}, "WSP-67890": {"status": "kézbesítve", "delivered": "2025-01-10"}, } return orders.get(order_id, {"status": "nem található"}) response = client.chat.completions.create( model="gpt-4o", messages=[ {"role": "system", "content": "Te a WebShop Pro support bot vagy."}, {"role": "user", "content": "Mi a helyzet a WSP-12345 rendeléssel?"} ], tools=tools, tool_choice="auto" ) # Ha a modell tool-t hív, a tool_calls listán kapjuk vissza, content=None tool_calls = response.choices[0].message.tool_calls if tool_calls: for tc in tool_calls: args = json.loads(tc.function.arguments) result = get_order_status(**args) print(f"Tool {tc.function.name} →", result) else: print(response.choices[0].message.content)
[openai] tool_call: get_order_status(order_id="WSP-12345") Tool response received: {"status": "szállítás alatt", "eta": "2025-01-15"} Final answer: A WSP-12345 számú rendelés jelenleg szállítás alatt van, várható kézbesítés: 2025. január 15.
🔬 Feladat: Adj hozzá egy új tool-t
Definiálj egy search_products tool-t, ami termékekre keres. Paraméter: query (string). A mock implementáció adjon vissza 2-3 terméket.
Embedding fogalmak: szövegekből vektorok OpenAI
Az embedding a szöveg numerikus ábrázolása. Minden szövegrészlet egy vektor lesz, ami a jelentését kódolja.
Példa: "A szállítás 3 nap." → [0.12, -0.34, 0.78, ..., 0.05] (1536 dimenzió)
| Kérdés | Leghasonlóbb | Hasonlóság |
|---|---|---|
| "szállítási idő" | "csomag érkezése" | 0.91 |
| "szállítási idő" | "visszaküldés" | 0.42 |
# Koszinusz-hasonlóság demó import numpy as np def cosine_similarity(a, b): return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)) # Szimulált embeddingek (valóságban 1536 dimenziós) vec_szallitas = np.array([0.8, 0.2, 0.1]) vec_csomag = np.array([0.7, 0.3, 0.1]) vec_vissza = np.array([0.1, 0.2, 0.8]) print(f"szállítás vs csomag: {cosine_similarity(vec_szallitas, vec_csomag):.2f}") print(f"szállítás vs visszaküldés: {cosine_similarity(vec_szallitas, vec_vissza):.2f}")
[similarity] Computing cosine distances... szállítás vs csomag: 0.97 szállítás vs visszaküldés: 0.30
A kulcsszó-keresés nem találja meg a szinonimákat. "Futár" és "szállító" ugyanazt jelenti, de más szó. Az embeddingek ezt "értik".
OpenAI Embeddings API OpenAI
Az OpenAI text-embedding-3-small modellje olcsó, gyors embedding megoldás: alapértelmezés szerint 1536 dimenziós vektorokat generál, és az OpenAI aktuális modelloldala 1 millió tokenenkénti árazást ad meg hozzá. Ez azt jelenti, hogy embeddingekkel prototípust építeni általában nagyságrendekkel olcsóbb, mint minden kérdésre nagy nyelvi modellt futtatni. A pontos árat mindig az aktuális pricing oldalon ellenőrizd, mert a modellárak idővel változhatnak.
Az embeddings.create() hívás egy vektorlistát ad vissza: minden bemeneti szöveghez egy 1536 lebegőpontos számból álló tömböt. Ezek a számok a szöveg szemantikai jelentését kódolják -- a hasonló jelentésű szövegek vektorai közel helyezkednek el egymáshoz a vektortérben. Az API válasza tartalmazza a felhasznált tokenek számát is, ami a költségszámításhoz szükséges.
Éles környezetben érdemes az embeddingeket gyorsítótárazni, mert ugyanarra a szövegre mindig ugyanazt a vektort kapod. Ha a dokumentumaid nem változnak, felesleges minden alkalommal újra generálni az embeddingeket -- tárold őket a vektoradatbázisban, és csak az új vagy módosított dokumentumokat töltsd fel.
A text-embedding-3-small olcsó és gyors, a legtöbb RAG használathoz elegendő. Ha maximális pontosság kell, próbáld a text-embedding-3-large modellt, amely alapértelmezés szerint 3072 dimenziós vektorokat ad. A dimensions paraméterrel a harmadik generációs embedding modelleknél csökkenthető a vektorméret.
Megjegyzés: új projektekben a text-embedding-3-small / -large családot érdemes választani; a régebbi embedding modelleket csak meglévő rendszereknél tartsd meg indokolt kompatibilitási okból.
emb_response = client.embeddings.create(
model="text-embedding-3-small",
input="Mennyi idő alatt érkezik meg a csomag?"
)
vector = emb_response.data[0].embedding
print(f"Dimenzió: {len(vector)}")
print(f"Első 5 érték: {vector[:5]}")
print(f"Token használat: {emb_response.usage.total_tokens}")[openai] embeddings.create() model=text-embedding-3-small Embedding generated: Dimenzió: 1536 Első 5 érték: [0.0123, -0.0456, 0.0789, -0.0012, 0.0345] Token használat: 12
Az embedding költségét mindig az aktuális szolgáltatói árlista és a tényleges tokenhasználat alapján számold. A kurzusban a lényeg a módszer: mérd a usage.total_tokens értéket, az egységárakat tartsd konfigurációban, és abból számolj becsült dokumentumonkénti vagy futásonkénti költséget.
ChromaDB alapok: vektoradatbázis ChromaDB
A ChromaDB egy nyílt forráskódú vektoradatbázis, amelyet kifejezetten AI alkalmazásokhoz terveztek. Lokálisan futtatható, nem kell hozzá külön szerver vagy felhőszolgáltatás -- ez teszi ideálissá a fejlesztéshez és prototípusokhoz. Három alapvető művelete van: add (dokumentumok hozzáadása), query (hasonlósági keresés) és delete (eltávolítás).
A ChromaDB kulcsfontosságú eleme a RAG rendszernek: itt tároljuk a dokumentumok embeddingjeit, és ide küldjük a felhasználói kérdés embeddingjét kereséskor. A ChromaDB automatikusan generálja az embeddingeket, ha nem adsz meg saját embedding funkciót -- a háttérben egy alapértelmezett modellt használ ehhez. Éles környezetben érdemes saját OpenAI embedding funkciót megadni a jobb minőség érdekében.
A kódban egy webshop_docs nevű gyűjteményt (collection) hozunk létre, majd három dokumentumot adunk hozzá. Minden dokumentum kap egy egyedi azonosítót (doc1, doc2, doc3), és a ChromaDB automatikusan elkészíti a vektorábrázolásukat. A count() metódus visszaadja a tárolt dokumentumok számát.
A demóban chromadb.Client() in-memory módot használ -- a adatok a program leállásakor elvesznek. Éles használatra chromadb.PersistentClient(path="./chroma_data") használatával a adatok lemezre íródnak, és újraindítás után is megmaradnak.
import chromadb # Lokális ChromaDB (in-memory demo) chroma_client = chromadb.Client() collection = chroma_client.create_collection( name="webshop_docs", metadata={"description": "WebShop Pro dokumentumok"} ) # Dokumentumok hozzáadása collection.add( documents=[ "A standard szállítás 3-5 munkanapot vesz igénybe.", "A visszaküldési határidő 30 nap a vásárlástól számítva.", "A garancia 2 év, a számla szükséges hozzá." ], ids=["doc1", "doc2", "doc3"] ) print(f"Dokumentumok száma: {collection.count()}")
[chroma] create_collection() name="webshop_docs" [chroma] add() 3 documents added Dokumentumok száma: 3
# Keresés a gyűjteményben results = collection.query( query_texts=["meddig tarthatja meg a terméket?"], n_results=2 ) print("Legjobb találatok:") for doc, dist in zip(results["documents"][0], results["distances"][0]): print(f" [{dist:.3f}] {doc}")
[chroma] query() query="meddig tarthatja meg a terméket?" n_results=2 Legjobb találatok: [0.234] A visszaküldési határidő 30 nap a vásárlástól számítva. [0.567] A garancia 2 év, a számla szükséges hozzá.
A Chroma alapból squared L2 distance-t ad vissza (kisebb = közelebb), nem cosine similarity-t. Cosine-hoz használd metadata={'hnsw:space':'cosine'} collection paramétert létrehozáskor — pl. create_collection(name="...", metadata={"hnsw:space": "cosine"}). Ekkor a distances 0 (azonos) és 2 (ellentétes) között mozog, ami 1 - cosine_similarity.
Demóban: in-memory, 3 dokumentum. Éles környezetben: persist_directory, több ezer dokumentum, metadata filtering, embedding function override.
Chunking stratégiák Python
A dokumentumokat darabolni kell, mert az LLM kontextusablaka véges és a vektoros keresés kis egységeken működik a legjobban.
| Stratégia | Előny | Hátrány |
|---|---|---|
| Fix méret (pl. 500 karakter) | Egyszerű | Vághat mondat közben |
| Mondat alapú | Értelmes egységek | Változó méret |
| Átfedéses (overlap) | Nem vész el kontextus | Duplikáció |
def chunk_text(text, chunk_size=200, overlap=50): chunks = [] start = 0 while start < len(text): end = start + chunk_size chunks.append(text[start:end]) start += chunk_size - overlap return chunks szabalyzat = """A WebShop Pro visszaküldési szabályzata: 1. A terméket a vásárlástól számított 30 napon belül vissza lehet küldeni. 2. A terméknek eredeti állapotban kell lennie. 3. A csomagolásnak érintetlennek kell lennie. 4. A visszaküldés ingyenes, a futár viszi a csomagot. 5. A visszatérítés 5 munkanapon belül megtörténik. 6. Elektromos termékekre a garancia 2 év érvényes.""" chunks = chunk_text(szabalyzat, chunk_size=150, overlap=30) for i, chunk in enumerate(chunks): print(f"--- Chunk {i+1} ({len(chunk)} chars) ---") print(chunk[:80] + "...")
[chunk] chunk_text(size=150, overlap=30) --- Chunk 1 (150 chars) --- A WebShop Pro visszaküldési szabályzata: 1. A terméket a vásárlástól számított 30 napon belül vissza lehet küldeni... --- Chunk 2 (150 chars) --- letben kell lennie. 3. A csomagolásnak érintetlennek kell lennie. 4. A visszaküldés ingyenes, a futár viszi a csomagot... --- Chunk 3 (79 chars) --- napon belül megtörténik. 6. Elektromos termékekre a garancia 2 év érvényes....
Dokumentumok betöltése: WebShop Pro tudásbázis Python
A WebShop Pro tudásbázisa négy dokumentumból áll: szállítási tájékoztató, visszaküldési szabályzat, garanciális feltételek és gyakori kérdések. A valóságban ezek PDF dokumentumok, Notion oldalak, Confluence wikik vagy akár adatbázis-lekérdezések eredményei lennének. Most egyszerű Python dictionary-kben tároljuk őket a demonstráció kedvéért.
A dokumentumok betöltése után a korábban definiált chunk_text() függvénnyel daraboljuk őket, és minden chunkhoz metaadatot rendelünk -- konkrétan a forrásdokumentum címét. Ez a metaadat később lehetővé teszi, hogy a RAG válaszban hivatkozzunk a forrásra, ami kritikus a felhasználói bizalom és a válaszok ellenőrizhetősége szempontjából.
Az all_chunks lista végül az összes feldarabolt szövegrészt tartalmazza azonosítóval, szöveggel és forrással. Ez a strukturált formátum teszi lehetővé a ChromaDB-be való feltöltést a következő szekcióban. Figyeld meg, hogy minden chunk egyedi ID-t kap (szallitas_chunk0, szallitas_chunk1, stb.), ami a későbbi frissítések és törlések esetén is fontos.
Éles rendszerben a dokumentumok betöltése gyakran a legbonyolultabb rész: PDF-ekből szöveget kinyerni, táblázatokat kezelni, képek OCR-ét elvégezni. Ehhez érdemes olyan könyvtárakat használni, mint a unstructured, PyPDF2 vagy docling.
# WebShop Pro tudásbázis DOCUMENTS = [ {"id": "szallitas", "title": "Szállítási tájékoztató", "text": """A WebShop Pro szállítási lehetőségei: - Standard szállítás: 990 Ft, 3-5 munkanap - Expressz szállítás: 2490 Ft, 1-2 munkanap - Aznapi kézbesítés: 3990 Ft, rendelés 12:00-ig - Ingyenes szállítás 15000 Ft feletti rendelések esetén - Nemzetközi szállítás: 5-10 munkanap"""}, {"id": "visszakuldes", "title": "Visszaküldési szabályzat", "text": """Visszaküldési feltételek: - 30 nap a vásárlástól - Eredeti csomagolás - Érintetlen állapot - Ingyenes visszaküldés - 5 munkanapon belüli visszatérítés - Elektromos termékekre 2 év garancia"""}, {"id": "garancia", "title": "Garanciális feltételek", "text": """Garanciális feltételek: - 2 év garancia minden elektromos termékre - Számla szükséges - A gyártói hibát a szerviz állapítja meg - 14 napon belül csere vagy javítás - Nem fedett: szándékos rongálás, vízrongálás"""}, {"id": "faq", "title": "Gyakori kérdések", "text": """GYIK: - Nyomon követhető a csomag? Igen, a rendeléskor kapott linken. - Lehet különböző címre szállítani? Igen, a pénztárnál megadható. - Elfogadtok utalványt? Igen, WebShop Pro utalvány és kupon is. - Telefonon is lehet rendelni? Igen, hétköznap 8-16 óra között."""} ] # Chunking + metadata all_chunks = [] for doc in DOCUMENTS: chunks = chunk_text(doc["text"], chunk_size=200, overlap=50) for i, chunk in enumerate(chunks): all_chunks.append({ "id": f"{doc['id']}_chunk{i}", "text": chunk, "source": doc["title"] }) print(f"Dokumentumok: {len(DOCUMENTS)}, Chunkok: {len(all_chunks)}")
[load] Processing 4 documents with chunk_text(size=200, overlap=50) Dokumentumok: 4, Chunkok: 7
Indexelés: ChromaDB feltöltése ChromaDB OpenAI
Az indexelés az a lépés, ahol a feldarabolt dokumentumchunkokat betöltjük a ChromaDB-be. A ChromaDB automatikusan generál embeddingeket minden chunkhoz, és indexet épít a gyors hasonlósági kereséshez. Ez a lépés "előkészíti a terepet" a későbbi lekérdezések számára -- indexelés nélkül nem tudunk keresni.
A kódban egy új webshop_rag gyűjteményt hozunk létre, és az add() metódussal feltöltjük a chunkokkal. Három paramétert adunk át: documents (a chunk szövegei), ids (egyedi azonosítók) és metadatas (forrásinformáció). A metadata filtering lehetővé teszi, hogy később csak bizonyos forrásokra keressünk -- például csak a szállítási dokumentumok között.
Éles környezetben az indexelés egy batch folyamat, amely ütemezetten fut (pl. naponta), és frissíti a vektoradatbázist az új vagy módosított dokumentumokkal. Inkrementális frissítés esetén csak a változott chunkokat kell újra indexelni, nem az egész tudásbázist. A ChromaDB upsert művelete is támogatja ezt a használati esetet.
Néhány ezer dokumentum indexelése másodpercek alatt megtörténik. Nagyobb tudásbázisok (millió dokumentum) esetén érdemes dedikált vektoradatbázist használni (Pinecone, Weaviate, Qdrant), amelyek oszlopos indexeléssel és shardinggal gyorsítják a keresést.
rag_collection = chroma_client.create_collection(name="webshop_rag") rag_collection.add( documents=[c["text"] for c in all_chunks], ids=[c["id"] for c in all_chunks], metadatas=[{"source": c["source"]} for c in all_chunks] ) print(f"Indexed chunks: {rag_collection.count()}")
[chroma] create_collection() name="webshop_rag" [chroma] add() 7 chunks with metadata Indexed chunks: 7
Vektoros keresés: a releváns chunkok megkeresése ChromaDB
A vektoros keresés (vector search) a RAG rendszer szíve: a felhasználói kérdést embedding-gé alakítjuk, majd a ChromaDB megkeresi a hozzá legközelebbi vektorokat a vektortérben. Ez a retrieval (visszakeresés) lépés -- a RAG rövidítés első betűje. Minél relevánsabb chunkokat találunk, annál jobb választ tud generálni az LLM.
A retrieve() függvény a ChromaDB query() metódusát használja: elküldi a kérdés szövegét, és a ChromaDB automatikusan embedding-gé alakítja, majd koszinusz-hasonlóság alapján megkeresi a n_results darab legközelebbi chunkot. Az eredmény tartalmazza a dokumentum szövegét, a metaadatokat (forrás) és a távolságot -- minél kisebb a távolság, annál relevánsabb a találat.
A példában a "Mennyibe kerül a szállítás?" kérdésre a rendszer a szállítási dokumentum chunkjait találja meg a legkisebb távolsággal. A távolság értelmezése függ a használt metrikától: a Chroma alapértelmezetten squared L2 distance-t használ (kisebb = közelebb, 0 = azonos vektor, nincs felső korlát). Ha cosine-t szeretnél (0 = azonos, 2 = teljesen ellentétes), állítsd be a collection-t metadata={'hnsw:space':'cosine'}-szal. Éles környezetben érdemes beállítani egy távolság-küszöböt: ha a legjobb találat is túl messze van, a rendszer jelezheti, hogy nem talál releváns információt.
A sima vektoros keresés négyzetesen rossz lehet kulcsszó-pontos lekérdezéseknél (pl. "WSP-12345 rendelés"). Éles környezetben érdemes hibrid keresést használni: a vektoros keresés eredményeit kombinálja kulcsszavas kereséssel (BM25), így mind a szemantikai, mind a lexikai pontosság megmarad.
def retrieve(query, n_results=3): results = rag_collection.query( query_texts=[query], n_results=n_results ) docs = results["documents"][0] sources = [m["source"] for m in results["metadatas"][0]] distances = results["distances"][0] return list(zip(docs, sources, distances)) # Teszt results = retrieve("Mennyibe kerül a szállítás?") for doc, source, dist in results: print(f"[{dist:.3f}] ({source})") print(f" {doc[:80]}...")
[chroma] query() query="Mennyibe kerül a szállítás?" n_results=3 Retrieved 3 results: [0.189] (Szállítási tájékoztató) A WebShop Pro szállítási lehetőségei: - Standard szállítás: 990 Ft, 3-5 munkanap... [0.312] (Szállítási tájékoztató) - Nemzetközi szállítás: 5-10 munkanap... [0.567] (Gyakori kérdések) Elfogadtok utalványt? Igen, WebShop Pro utalvány és kupon is...
🔬 Feladat: Keresés tesztelése
Próbáld meg a retrieve("garancia") hívást. Melyik dokumentum a legközelebbi? Milyen távolsággal?
RAG pipeline: retrieve → augment → generate OpenAI ChromaDB
Most összerakjuk az egészet! A RAG pipeline három lépése:
- Retrieve: kérdés → vektoros keresés → releváns chunkok
- Augment: chunkok + kérdés → egyetlen prompt
- Generate: prompt → LLM → válasz
def rag_query(question, n_results=3): # 1. RETRIEVE retrieved = retrieve(question, n_results=n_results) context = "\n\n".join([doc for doc, _, _ in retrieved]) # 2. AUGMENT — a kontextust a system message-be tesszük, # a felhasználó kérdését pedig külön user message-be. # Ez biztonságosabb, mint egyetlen f-stringbe ágyazni # (kevésbé vulnerable prompt injection-re). system_msg = f"""{SYSTEM_INSTRUCTIONS} Tudásbázis (csak ebből válaszolj): {context} Ha a kérdés nem szerepel a tudásbázisban, mondd: "Ezt a kérdést továbbítom a csapatnak.""" # 3. GENERATE response = client.chat.completions.create( model="gpt-4o", messages=[ {"role": "system", "content": system_msg}, {"role": "user", "content": question} ] ) return response.choices[0].message.content # Teszteljük! for q in ["Mennyibe kerül a szállítás?", "Meddig garanciás a telefon?"]: print(f"Q: {q}") print(f"A: {rag_query(q)}") print()
[chroma] retrieve() → context gathered [openai] chat.completions.create() → generating answer... Q: Mennyibe kerül a szállítás? A: A standard szállítás 990 Ft (3-5 munkanap), az expressz 2490 Ft (1-2 munkanap), az aznapi 3990 Ft. 15000 Ft felett ingyenes. [chroma] retrieve() → context gathered [openai] chat.completions.create() → generating answer... Q: Meddig garanciás a telefon? A: Elektromos termékekre 2 év garancia érvényes. A számla szükséges hozzá. 14 napon belül csere vagy javítás.
Demóban: egy függvény, mindig ugyanaz. Éles környezetben: conversation history, streaming, fallback modellek, rate limiting, cache, re-ranking, hybrid search (keyword + vector), guardrails.
🔬 Feladat: Hallucináció teszt
Kérdezz olyat, ami nincs a tudásbázisban: rag_query("Mennyi a kedvezmény VIP vásárlóknak?"). Mit válaszol?
Evaluáció: a RAG minőségének mérése OpenAI
Honnan tudjuk, hogy a RAG rendszer jól működik? Eval metrikák:
| Metrika | Mit mér? | Jó érték |
|---|---|---|
| Faithfulness | A válasz a forrásból származik-e | > 0.9 |
| Relevance | A válasz releváns-e a kérdésre | > 0.8 |
| Hallucination rate | Aránya a nem forrás-alapú válaszoknak | < 0.1 |
| Retrieval precision | A visszakeresett chunkok relevancia aránya | > 0.7 |
# Egyszerű eval: LLM-as-judge def evaluate_answer(question, answer, context): eval_response = client.chat.completions.create( model="gpt-4o", messages=[ {"role": "system", "content": """Értékeld a válasz minőségét. Adj 0-1 közötti pontszámot: - faithfulness: a válasz a kontextusból származik-e - relevance: a válasz releváns-e a kérdésre Válaszolj JSON-ban."""}, {"role": "user", "content": f"""Kérdés: {question} Válasz: {answer} Kontextus: {context}"""} ], response_format={"type": "json_schema", "json_schema": { "name": "eval", "strict": True, "schema": {"type": "object", "properties": { "faithfulness": {"type": "number"}, "relevance": {"type": "number"}, "reasoning": {"type": "string"}}, "required": ["faithfulness", "relevance", "reasoning"], "additionalProperties": False}}} ) return json.loads(eval_response.choices[0].message.content) # Eval teszt q = "Mennyibe kerül a szállítás?" a = "A standard szállítás 990 Ft." ctx = "A WebShop Pro szállítási lehetőségei: Standard: 990 Ft, 3-5 munkanap" result = evaluate_answer(q, a, ctx) print(json.dumps(result, indent=2, ensure_ascii=False))
[openai] evaluate_answer() LLM-as-judge Eval result: { "faithfulness": 0.95, "relevance": 0.9, "reasoning": "A válasz pontosan a kontextusból származik és közvetlenül megválaszolja a kérdést." }
Cost és latency tracking Grafana
A költség- és válaszidő-monitorozás (cost és latency tracking) elengedhetetlen minden éles RAG rendszernél. A költség nem fix „chatbot díj", hanem főleg tokenfogyasztásból, modellválasztásból, tool-hívásokból és cache-hit arányból áll össze. Egy rosszul optimalizált RAG pipeline könnyen váratlan számlát generálhat. A válaszidő pedig közvetlenül befolyásolja a felhasználói élményt: ha a chatbot túl sokáig gondolkodik, a felhasználók elhagyják az oldalt.
A rag_query_tracked() függvény egy wrapper, amely megméri a teljes folyamat és az LLM hívás külön-külön eltelt idejét. Ez a felbontás fontos, mert a retrieval lokális és gyors (ezredmásodpercek), míg az LLM hívás távoli API és lassabb (száz milliszekundumoktól másodpercekig). A szűk keresztmetszet azonosítása segíti az optimalizálást.
A token szám alapján automatizáltan számolható a becsült költség. A response.usage objektum tartalmazza a bemeneti és kimeneti tokenek számát; ezt szorozzuk az aktuális modellárakkal. Éles rendszerben ezeket az adatokat Prometheus vagy Grafana dashboardra küldjük, és riasztást állítunk be a küszöbértékekre. A modellárakat ne hard-kódold örökre: kezeld konfigurációként, és időnként egyeztesd az OpenAI billing/pricing felülettel.
A költség csökkentésére: (1) használj kisebb modellt (GPT-4o-mini) egyszerű kérdéseknél, (2) cache-eld a gyakori kérdések válaszait, (3) korlátozd a kontextus hosszát. A válaszidő javítására: (4) használj streaming választ, (5) párhuzamosítsd a retrieval-t, (6) válassz gyorsabb embedding modellt.
A stream=True az LLM válaszában time-to-first-byte-ot (TTFB) javítja: a felhasználó az első token megjelenésekor látja, hogy "valami történik". A teljes válasz latency azonban változatlan vagy minimálisan rosszabb (a streamelt átvitel többlete miatt). Tehát: jobb UX-érzet, de a system throughput nem nő. Batch inferenciánál (pl. tömeges dokumentum-feldolgozás) nincs előnye — ott a sima request-response a hatékonyabb.
RAG kontextusoknál a system message + dokumentum-kontextus gyakran ismétlődik. Mind az Anthropic, mind az OpenAI támogat natív prompt caching-et:
- Anthropic: explicit
cache_control={"type": "ephemeral"}markert teszünk a prompt statikus részére. Cache hit: input token költség 10% (90% megtakarítás). 5 perces TTL. - OpenAI: automatikus, 1024+ tokenes prompt prefix esetén. Cache hit: input költség 50%.
RAG/agentic munkafolyamatoknál a prompt caching az egyik legfontosabb költségcsökkentési technika, különösen akkor, ha hosszú, de sok kérésben ismétlődő system promptot vagy dokumentum-kontextust használsz. Részletek és példák: LLMOps kurzus 11. szakasza.
# Cost és latency wrapper def rag_query_tracked(question): start = time.time() # Retrieval (lokális, gyors) retrieved = retrieve(question) context = "\n\n".join([doc for doc, _, _ in retrieved]) # LLM hívás (API, lassú) llm_start = time.time() response = client.chat.completions.create( model="gpt-4o", messages=[ {"role": "system", "content": f"{SYSTEM_INSTRUCTIONS}\n\nTudásbázis:\n{context}"}, {"role": "user", "content": question} ] ) llm_time = time.time() - llm_start total_time = time.time() - start # Modellárakat konfigurációból tölts, mert idővel változhatnak. PRICING = {"gpt-4o": {"input_per_1m": 2.50, "output_per_1m": 10.00}} # példaérték price = PRICING["gpt-4o"] # A response.usage tartalmazza a token számot: # prompt_tokens, completion_tokens, total_tokens usage = response.usage in_cost = usage.prompt_tokens * price["input_per_1m"] / 1_000_000 out_cost = usage.completion_tokens * price["output_per_1m"] / 1_000_000 print(f"Retrieval: {(total_time - llm_time)*1000:.0f}ms") print(f"LLM: {llm_time*1000:.0f}ms") print(f"Total: {total_time*1000:.0f}ms") print(f"Tokens: {usage.prompt_tokens} in / {usage.completion_tokens} out — ${in_cost+out_cost:.5f}") return response.choices[0].message.content print(rag_query_tracked("Mennyi a szállítás?"))
[chroma] retrieve() 12ms [openai] chat.completions.create() 834ms Total: 846ms Answer: A standard szállítás 990 Ft (3-5 munkanap), expressz 2490 Ft (1-2 munkanap). 15000 Ft felett ingyenes.
Demóban: print. Éles környezetben: Prometheus/Grafana dashboard, alerting ha latency > 2s vagy cost/nap > $X, per-endpoint breakdown, token usage per model.
Streamlit UI: chat frontend Streamlit
A Streamlit egy Python webes keretrendszer, amely pár sornyi kóddal lehetővé teszi interaktív webes alkalmazások készítését -- HTML, CSS vagy JavaScript ismerete nélkül. A RAG chatbot esetén ez a leggyorsabb módja annak, hogy a háttérben futó Python logikát felhasználói felülettel lássuk el, és a chatbotot böngészőből lehessen elérni.
A kód három fő részből áll: (1) a chat előzmények tárolása a session_state-ben, ami minden felhasználói munkamenethez külön memóriát biztosít, (2) a korábbi üzenetek megjelenítése a st.chat_message() widgettel, amely automatikusan formázza a beszélgetést, és (3) az új üzenetek fogadása a st.chat_input() widgettel, amely egy beviteli mezőt jelenít meg a chat alján.
Amikor a felhasználó beír egy kérdést, a Streamlit meghívja a korábban definiált rag_query() függvényt, megkapja a választ, és megjeleníti az "assistant" szerepkörű üzenetként. A teljes beszélgetés előzmények megmaradnak a munkamenet alatt, így a felhasználó görgethet és visszanézheti a korábbi kérdéseket és válaszokat.
Mentsd el a kódot app.py néven, majd futtasd a streamlit run app.py paranccsal. A Streamlit automatikusan megnyitja a böngészőt a localhost:8501 címen. A kód minden módosítás után automatikusan újratöltődik a böngészőben.
# streamlit run app.py # pip install streamlit import streamlit as st st.title("WebShop Pro Support Bot") # Chat history a session_state-ben if "messages" not in st.session_state: st.session_state.messages = [] # Korábbi üzenetek megjelenítése for msg in st.session_state.messages: st.chat_message(msg["role"]).write(msg["content"]) # Új üzenet if prompt := st.chat_input("Kérdezz valamit..."): st.chat_message("user").write(prompt) st.session_state.messages.append({"role": "user", "content": prompt}) # RAG válasz response = rag_query(prompt) st.chat_message("assistant").write(response) st.session_state.messages.append({"role": "assistant", "content": response})
$ streamlit run app.py You can now view your Streamlit app in your browser. Local URL: http://localhost:8501 Network URL: http://192.168.1.100:8501
Összefoglalás OpenAI ChromaDB
Egy teljes RAG chatbot rendszer:
- ✅ OpenAI Chat Completions API (
client.chat.completions.create) - ✅ System message + User message szétválasztás (prompt injection védelem)
- ✅ Structured outputs (
response_format+ JSON schema, strict mode) - ✅ Tool calling (
tools=[{"type":"function",...}],tool_callskiolvasás) - ✅ Embedding fogalmak + OpenAI Embeddings API (
text-embedding-3-small) - ✅ ChromaDB vektoradatbázis (squared L2 vs cosine distance)
- ✅ Chunking stratégiák
- ✅ Dokumentumbetöltés + indexelés
- ✅ Vektoros keresés (retrieval)
- ✅ RAG pipeline (retrieve → augment → generate)
- ✅ LLM-as-judge evaluáció
- ✅ Cost és latency tracking (
response.usage) - ✅ Streamlit chat UI
- AI Data Engineer kurzus: feature store, adat pipeline, webshop adatok
- MLOps kurzus: modell deployment, monitoring, drift detektálás
- Production RAG: re-ranking, hybrid search, guardrails, A/B testing
Architecture recap:
Embedding · RAG · Chunking