Spark batch LLM feldolgozás
A platform Spark-alapú transformer osztályai lehetővé teszik, hogy vállalati adatokat – adatbázisokat, rekordokat, dokumentumokat – LLM-mel dolgozz fel anélkül, hogy az adat elhagyná a szervezet infrastruktúráját. Ez az eszközkészlet az enterprise AI agent integráció alapja.
Az adatszuverenitás és a shadow AI
A szervezetekben ma egyre nagyobb nyomás nehezedik az alkalmazottakra, hogy AI-t használjanak a napi munkában. Ahol a szervezet nem biztosít megbízható, vállalati keretek között működő AI eszközt, ott az emberek saját megoldásokat keresnek – és érzékeny vállalati adatokat másolnak külső felhőszolgáltatásokba. Ez a shadow AI jelensége: nem rosszindulatból, hanem kényszerűségből.
A platform Spark transformer rétege erre ad választ: az AI feldolgozás ott fut, ahol az adat van. Az adat nem másolódik ki, nem kerül fel felhőszolgáltatásba, nem hagyja el a szervezet infrastruktúráját. A vállalat tudásának feldolgozása, gazdagítása és lekérdezése teljes egészében on-premise történik – a chatbot csak az egyik lehetséges belépési felület felette.
Mire való?
Az egymás utáni, szekvenciális LLM hívások sok dokumentum vagy sor feldolgozásakor szűk keresztmetszetet alkotnak. A Spark transformer réteg ezt oldja fel: a sorok párhuzamosan, konfigurálható batch mérettel kapnak LLM hívást.
Tipikus felhasználási területek:
- Vállalati adatok AI-alapú gazdagítása: meglévő adatbázis tartalmának LLM-mel történő kiegészítése – osztályozás, összefoglalás, struktúrálás, fordítás – anélkül, hogy az adat elhagyná a rendszert.
- Ingestion pipeline: dokumentumok összefoglalása, kulcsszó-kinyerés, NER annotáció feltöltés előtt.
- Batch enrichment: ütemezett feldolgozás nagy rekordszámon – az agent futhat chatbot kérés nélkül is, önálló batch jobként.
Az osztályhierarchia
Transformer (SparkML alap)
└── BaseTransformer
├── BaseLLMTransformer ← párhuzamos LLM hívások – ezt kell leszármaztatni
│ ├── SummaryToolTransformer ← platform komponens (összefoglalás)
│ └── BatchedSummaryTransformer ← platform komponens (összefoglalás + kulcsszavak)
└── BaseBatchedTransformer ← nem-LLM batch feldolgozás
└── AbstractIngestionTransformer ← dokumentum-alapú ingestion – ld. lentebb
A SummaryToolTransformer és a BatchedSummaryTransformer
a platform ingestion pipeline-jának belső komponensei – névvel hivatkozhatók, de
plugin fejlesztők nem leszármaztatják őket. Az LLM-alapú saját transformer mindig a
BaseLLMTransformer-ből indul; a nem-LLM, dokumentum-beszerző/-tároló
transformer az AbstractIngestionTransformer-ből (részletek: BaseBatchedTransformer és AbstractIngestionTransformer).
BaseLLMTransformer – az alap
Konstruktorban a system prompt és az opcionális JSON response format adható meg. A konfigurálható paraméterek builder-stílusú setter-ekkel láncolhatók:
| Setter | Default | Leírás |
|---|---|---|
setBatchSize(n) | 5 | Párhuzamos LLM hívások száma egy Spark partíción belül |
setTemperature(f) | null | LLM temperature (null = modell default) |
setMaxCompletitionToken(n) | null | Maximális válasz token |
setRetryPolicy(p) | 3×, exp backoff | Újrapróbálkozási stratégia LLM hiba esetén |
setUsedLlm(type) | lokális | "CLOUD_OPENAI" = felhő modell, egyéb = lokális vLLM |
setModel(name) | konfig default | Felülírja a modell nevét (pl. hosszabb kontextusú modell választásához) |
setBasePath(basePath) | null | Élő progress-számláló célkönyvtára - hívás nélkül a transform() nem ír élő számlálót, a végrehajtás egyébként változatlan marad |
setPhase(phase) | null | Élő progress-számláló fázisneve (pl. "SUMMARY_GENERATION", "GRAPH_INGESTION") |
Három kötelező abstract metódus
// Melyik sorokat kell feldolgozni?
// false → kihagyja (pl. ha a kimeneti oszlop már tartalmaz értéket → idempotencia)
protected abstract boolean isNeedTransform(Row r);
// Dataset előkészítése: kimeneti oszlopok felvétele null értékkel
protected abstract Dataset<Row> prepareDataSet(Dataset<?> dataset);
// Az LLM válasz feldolgozása → Map<oszlopnév, érték>
// A map kulcsai az output oszlopok nevei, értékei a beírandó adatok
protected abstract Map<String, Object> parseResponseJson(Row row, String retJson)
throws Exception;
isNeedTransform() az idempotencia kulcsa: ha egy sor
kimeneti oszlopa már ki van töltve, az LLM hívás kihagyásra kerül.
Ez lehetővé teszi a feldolgozás biztonságos újraindítását részleges
hiba vagy megszakítás után – különösen hasznos több tízezer soros batch-eknél.
Teljes példa: GroundTrueQuestionGeneratorTransformer
A következő valódi implementáció RAG dokumentumokból generál ground truth kérdés-válasz párokat IR metrikák méréséhez. Jól mutatja a saját transformer tipikus felépítését: strukturált JSON kimenet, több output oszlop, és domain-specifikus minőségi szabályok a promptban.
Az eredmény két oszlopba kerül: a kérdések (queries) és a
dokumentum–kérdés megfeleltetések (qrels) – a standard BEIR/TREC
formátumnak megfelelően.
public class GroundTrueQuestionGeneratorTransformer extends BaseLLMTransformer {
// ── Kimeneti sémák ──────────────────────────────────────────────────────────
private static final ArrayType QUERY_RETURN_TYPE =
DataTypes.createArrayType(DataTypes.createStructType(new StructField[]{
new StructField("id", DataTypes.StringType, true, Metadata.empty()),
new StructField("query", DataTypes.StringType, true, Metadata.empty()),
new StructField("type", DataTypes.StringType, true, Metadata.empty()),
new StructField("answer", DataTypes.StringType, true, Metadata.empty()),
new StructField("importantWords",
DataTypes.createArrayType(DataTypes.StringType), true, Metadata.empty()),
}));
private static final ArrayType QREL_RETURN_TYPE =
DataTypes.createArrayType(DataTypes.createStructType(new StructField[]{
new StructField("query_id", DataTypes.StringType, true, Metadata.empty()),
new StructField("doc_id", DataTypes.StringType, true, Metadata.empty()),
new StructField("text", DataTypes.StringType, true, Metadata.empty()),
new StructField("relevance", DataTypes.FloatType, true, Metadata.empty()),
}));
private String queriesCol;
private String qrelsCol;
// ── System prompt – minőségi szabályok a kérdésalkotáshoz ──────────────────
private static final String SYSTEM_PROMPT = """
Szerepköröd: RAG rendszer IR metrikáinak méréséhez ground truth kérdésgenerátor.
FONTOS SZABÁLYOK:
- A kérdés minősége fontosabb, mint a mennyiség. Ha nem alkotható minőségi
kérdés, adj vissza ÜRES JSON tömböt.
- Csak olyan kérdést fogalmazz meg, amire van válasz a szövegben.
- Utaló szavakat ("ilyen", "olyan", "ez", "az") tartalmazó kérdések tiltottak.
- Semmitmondó kérdések tiltottak.
FELADAT:
- Válassz ki 2-5 releváns mondatot válaszként (answer).
- Fogalmazz meg rá kérdést, amelyre ez a válasz tökéletesen illeszkedik.
- Ha lehetséges, különböző típusú kérdéseket alkotj:
factoid | entity_lookup | definition | reasoning | contextual
""";
// ── JSON response format (constrained decoding) ─────────────────────────────
private static final String RESPONSE_FORMAT = """
{
"type": "json_schema",
"json_schema": {
"name": "ground_truth_output",
"schema": {
"type": "object",
"properties": {
"resp": {
"type": "array",
"minItems": 0,
"maxItems": 5,
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"question": { "type": "string" },
"answer": { "type": "string" },
"type": { "type": "string",
"description": "factoid|entity_lookup|definition|reasoning|contextual" },
"importantWords":{ "type": "array",
"items": { "type": "string" } }
},
"required": ["question", "answer", "type", "importantWords"]
}
}
},
"required": ["resp"]
}
}
}
""";
public GroundTrueQuestionGeneratorTransformer() throws Exception {
super();
setSystemPrompt(SYSTEM_PROMPT, RESPONSE_FORMAT);
setRetryPolicy(new RetryPolicy(0, 2_000, 10_000));
}
public GroundTrueQuestionGeneratorTransformer setOutputCols(
String queriesCol, String qrelsCol) {
this.queriesCol = queriesCol;
this.qrelsCol = qrelsCol;
return this;
}
@Override public String uid() { return "GroundTrueQuestionGeneratorTransformer"; }
// ── Séma kiterjesztés – két új Array oszlop ─────────────────────────────────
@Override
public StructType transformSchema(StructType schema) {
StructType s = schema;
if (!hasField(s, queriesCol)) s = s.add(queriesCol, QUERY_RETURN_TYPE, true);
if (!hasField(s, qrelsCol)) s = s.add(qrelsCol, QREL_RETURN_TYPE, true);
return s;
}
// ── Minden sort feldolgozunk (nincs idempotencia-szűrés) ────────────────────
@Override
protected boolean isNeedTransform(Row r) { return true; }
@Override
protected Dataset<Row> prepareDataSet(Dataset<?> dataset) {
return dataset
.withColumn(queriesCol, lit(null).cast(QUERY_RETURN_TYPE))
.withColumn(qrelsCol, lit(null).cast(QREL_RETURN_TYPE));
}
// ── LLM válasz feldolgozása → queries + qrels sorok ────────────────────────
@SuppressWarnings({"rawtypes", "unchecked"})
@Override
protected Map<String, Object> parseResponseJson(Row row, String retJson)
throws Exception {
Map<String, Object> valueMap =
objectMapper.readValue(retJson, new TypeReference<>(){});
List<Map> resultList = (List) valueMap.get("resp");
List<Row> queryRows = new ArrayList<>();
List<Row> qrelRows = new ArrayList<>();
String docId = row.getAs("_key");
for (Map result : resultList) {
String id = UUID.randomUUID().toString();
String question = (String) result.get("question");
String type = (String) result.get("type");
String answer = (String) result.get("answer");
// Spark Scala-kompatibilis tömb az importantWords-höz
Object rawWords = result.get("importantWords");
WrappedArray<String> words = rawWords instanceof Collection
? WrappedArray$.MODULE$.make(((Collection) rawWords).toArray())
: null;
queryRows.add(RowFactory.create(id, question, type, answer, words));
qrelRows .add(RowFactory.create(id, docId, row.getAs(inputCol), 1f));
}
// Spark Scala-kompatibilis tömb a sorokhoz
return Map.of(
queriesCol, WrappedArray$.MODULE$.make(queryRows.toArray()),
qrelsCol, WrappedArray$.MODULE$.make(qrelRows.toArray())
);
}
}
Használat
// RAG dokumentumok betöltése (pl. egy domain teljes PageEmbedding gyűjteménye)
Dataset<Row> pages = loadPagesFromSource();
GroundTrueQuestionGeneratorTransformer generator =
new GroundTrueQuestionGeneratorTransformer()
.setOutputCols("queries", "qrels")
.setBatchSize(5);
Dataset<Row> enriched = generator.transform(pages);
// Kérdések és qrel-ek kinyerése a kiértékelő pipeline-hoz
enriched.selectExpr("explode(queries) as q").select("q.*").show();
enriched.selectExpr("explode(qrels) as r").select("r.*").show();
BaseBatchedTransformer és AbstractIngestionTransformer – dokumentum-alapú ingestion
A BaseBatchedTransformer a BaseLLMTransformer nem-LLM
testvére: ugyanazt a párhuzamos, batch-elt sor-feldolgozó keretet adja
(repartition(1) + ThreadPoolExecutor, ugyanaz a három
abstract metódus – isNeedTransform, prepareDataSet,
processRow), de LLM hívás nélkül – tetszőleges soronkénti
mellékhatásra (fájlírás, hálózati hívás, formátumkonverzió) használható.
Az AbstractIngestionTransformer erre épül: egy kész,
dokumentum-alapú ingestion transformer, amely
DocumentMetaData sorokból álló Dataset-et fogad, és a platform
meglévő ingestion staging formátumába (source.json + tartalom
fájlok) írja – ugyanabba a formátumba, amit a manuális dokumentum-feltöltés és
a platform saját scraper rétege is használ. A kimenet így minden további teendő
nélkül bekerül a normál embedding/lemmatizálási pipeline-ba, amint a batch
a feldolgozó sorba kerül.
Row mezőneveivel
dolgozik, nem magával a bean típussal – ettől függetlenül a támogatott, helyes
választás plugin ingestion input-hoz mindig DocumentMetaData
(common/beans, @SdkApi(strip=false)). Az InputDocument
egy másik, korábban félreértett bean: a platform belső, a RagDBService/
DocumentLoader olvasó oldalán használt reprezentációja, nem
része az SDK-nak (nincs @SdkApi annotációja) – plugin ne
hivatkozzon rá közvetlenül.
DocumentMetaData – a bemeneti sor
| Mező | Típus | Leírás |
|---|---|---|
id | Long |
Egyedi azonosító – lehet null, ilyenkor a platform generál egyet. |
name | String | Dokumentum / cikk címe. |
text | String |
Ha ki van töltve, a transformer nem tölt le semmit – változatlanul tárolja. |
url | String |
Ha text üres, innen tölti le a tartalmat a transformer. |
format | String |
Bemeneten informatív – a végleges formátumot a transformer állapítja meg (letöltés/konverzió eredménye alapján). |
system | String | Forrásrendszer azonosítója. |
summary | String |
Rövid, sima szöveges összefoglalás. Hagyd üresen, ha a dokumentum tartalmához nem tartozik érdemi, kész összefoglaló – ha ki van töltve, az ingestion pipeline ezt tekinti a dokumentum végleges összefoglalójának, és nem generáltat hozzá LLM-mel újat. |
date | Date | A dokumentum aktualitásának dátuma. |
sections | List<String> | Kategória címkék listája. |
writers | List<String> | Szerzők nevének listája. |
labels | List<String> | A tartalmat leíró címkelista. |
sections/writers/labels egy fix,
cikk-adatbázisra tervezett séma – eredetileg cikkek kategóriáinak,
szerzőinek és címkefelhőjének tárolására. Nincs a bean-en általános célú,
tetszőleges kulcs-érték tár – ha egy scraper struktúrált mezőket nyer ki egy forrás
oldalról, amik nem illenek ebbe a három listába (pl. egy termék részletező
oldalának táblázatos adatai), ne próbáld ráerőltetni ezekre a mezőkre: építs
belőlük inkább egy önálló, text mezővel kitöltött
DocumentMetaData sort (a kulcs-érték párokat egyetlen szöveggé
formázva), summary nélkül. L. a pupha referencia
implementációt lentebb.
Feldolgozási logika soronként
- Ha
textki van töltve → változatlanul tároljatxtformátumban. - Ha csak
url→ letölti a tartalmat, és a detektált tartalomtípus alapján dönt:.doc/.docx→ PDF-re konvertálja (a platform meglévőDocumentLoader.convertToPDFmetódusával), és PDF-ként tárolja – a lapokra bontást, OCR-t és mondatgenerálást a downstream pipeline végzi el, ugyanúgy, mint bármely feltöltött PDF esetén.- már PDF → változatlanul tárolja.
text/html→ a látható szöveget kinyeri (Jsoup),txt-ként tárolja.- egyéb → nyers szövegként tárolja.
HttpContentTypeUtil segédosztályon
keresztül történik: elsőként detectByExtension(url) – tisztán az URL
végződéséből, hálózati hívás nélkül –, és csak ismeretlen/hiányzó kiterjesztés esetén
esik vissza detectByHead(url)-ra, ami egy szigorú (5 mp) timeout-tal
védett HEAD kérés. Ez azért fontos, mert egyes szerverek a HEAD metódusra
egyáltalán nem válaszolnak (a kérés a végtelenségig blokkolna timeout nélkül) –
emiatt saját tartalomletöltő kódban soha ne indíts HEAD (vagy GET)
kérést explicit timeout()/connectTimeout() nélkül.
Resume-safe URL deduplikáció
Egy batch könyvtár csak akkor törlődik, ha a benne lévő tartalom sikeresen eljutott az
adatbázisig (downstream feldolgozás). Ha egy scrape félbeszakad, a már letöltött, de még
fel nem dolgozott batch könyvtárak a helyükön maradnak. A transformer ezt kihasználja:
prepareDataSet()-ben, egyszer a batch indulásakor, összegyűjti az adott
domain többi (nem a jelenleg induló) batch könyvtárában található
fájlokat egy memóriabeli fájlnév-indexbe. Soronként, letöltés előtt megnézi, szerepel-e
ott már az adott URL-hez tartozó fájl ({sha256(url)}.{format} néven) – ha
igen, onnan másolja át a tartalmat, HTTP letöltés és doc→PDF konverzió
nélkül.
text mezőből közvetlenül érkező sorok (amikhez nincs URL)
továbbra is tartalom-hash alapú néven tárolódnak.
BaseBatchedTransformer leszármazottban (így az
AbstractIngestionTransformer-ben is) a prepareDataSet()-ben
felépített mutable állapotot tároló mező soha ne legyen transient.
A transform() a prepareDataSet()-et a driveren futtatja le, utána
viszont a soronkénti feldolgozás egy Spark mapPartitions closure-ön keresztül
fut, ami a teljes transformer példányt szerializálja/deszerializálja - még
repartition(1) mellett is. Egy transient mező ezen a határon
mindig null-ra áll vissza a deszerializált (ténylegesen futó) példányban,
függetlenül attól, mit tartalmazott a driveren futó példányban - ez pontosan azt a látszatot
kelti, mintha egy "másik" transformer példány futna. A mezőnek éppen ellenkezőleg,
szerializálhatónak kell lennie (pl. HashMap<String,String> jó, egy nem
szerializálható típus viszont futásidőben dobna hibát).
ScrapeTransformer)
képest: az AbstractIngestionTransformer nem követ újabb
linkeket a letöltött tartalomból – kizárólag a sorban megadott URL-t
dolgozza fel. Ha egy weboldalról több, egymásra mutató dokumentumot (pl. egy
katalógusoldal és a hozzá tartozó mellékletek) kell beszedni, a plugin saját
feladata a bejárás/linkfeltárás és a DocumentMetaData sorok
előállítása – a transformer csak a végső, konkrét dokumentum-URL-ekkel
dolgozik, soronként egyszer.
Használat
@SdkApi) kizárólag no-arg
konstruktort kaphatnak – az ASM-alapú stub-generálás ezt követeli meg. Emiatt
az AbstractIngestionTransformer-nek (és minden leszármazottjának)
nincs paraméterezett konstruktora: a domain/system
a setDomain()/setSystem() settereken keresztül
állítódik be, konstruktor után.
// 1. Saját, domain-specifikus leszármazott – nincs felülírandó metódus és
// nincs saját konstruktor sem (l. fenti figyelmeztetés)
public class MyIngestionTransformer extends AbstractIngestionTransformer {
}
// 2. DocumentMetaData sorok összeállítása – a plugin saját beszerzési logikájával
// (pl. egy katalógusoldal bejárásával, majd a talált dokumentum-URL-ek kigyűjtésével)
List<DocumentMetaData> rows = new ArrayList<>();
DocumentMetaData doc = new DocumentMetaData();
doc.setName("Példa dokumentum");
doc.setUrl("https://forras.example.com/dokumentum.docx");
doc.setSystem("MY_SYSTEM");
rows.add(doc);
// 3. Dataset építése és a transformer futtatása
Dataset<DocumentMetaData> dataset =
sparkSessionStorage.getSession().createDataset(rows, Encoders.bean(DocumentMetaData.class));
String batchId = UUID.randomUUID().toString();
MyIngestionTransformer transformer = (MyIngestionTransformer) new MyIngestionTransformer()
.setDomain("MY_DOMAIN")
.setSystem("MY_SYSTEM")
.setBatchId(batchId);
Dataset<Row> result = transformer.transform(dataset.toDF());
// 4. Beütemezés a feldolgozó sorba – az injektált IngestService platform service
// végzi: kikényszeríti a Spark lusta kiértékelést, BatchStatus-t ment Redis-be,
// majd a batchId-t a feldolgozó sorba lökve elindítja a downstream pipeline-t
ingestService.startIngest(batchId, result);
setBatchId(batchId) a tárolási útvonalat a platform
fix konvenciója alapján maga állítja elő (rag.storage.root
system property + batchId – nincs külön domain alkönyvtár) – a plugin
fejlesztőnek nem kell (és nem is szabad) ismernie vagy paraméterként átadnia a
tárolási gyökeret, mert a downstream feldolgozó ugyanezt a fix helyet keresi;
egy plugin által tetszőlegesen megadott útvonal eltérést okozna. A domain a
könyvtárszerkezetben nem jelenik meg, hanem egy "domain" nevű, kiterjesztés
nélküli jelölőfájlba kerül a batch könyvtár gyökerébe (tartalma a domain neve) –
enélkül a downstream feldolgozás domain nélkülinek (null) látná a batch-et, ami
hibás collection-neveket és kimaradó domain-szűrt ingestion tool-okat
eredményezne. setDomain()-t setBatchId() előtt kell
hívni, különben a jelölőfájl írása hibázik.
IngestService – beütemezés és állapotkövetés
Az IngestService Spring-managed platform service
(egyszerű @Autowired-del injektálható), amely a transformer
lefutása után kapott Dataset<Row>-ot fogadja
és intézi a beütemezést:
startIngest(String batchId, Dataset<Row> rows)– kikényszeríti a Spark lusta kiértékelést (rows.count()),BatchStatus-t ír Redis-be (accepteddarabszám,createdAt,status:QUEUEDha van feldolgozandó sor,DONEha nulla), majd abatchId-t a feldolgozó sorba (rag.redis.ingest-queue) löki. Visszatérési érték: a mentettBatchStatus.getIngestStatus(String batchId)– visszaadja a Redis-ben tároltBatchStatus-t (pl. egy admin tool státusz-lekérdezéséhez).
BatchStatus mezői: createdAt, status
(String), accepted (Long),
errors (List<DocumentError>),
finishedAt. DocumentError mezői: system,
id, errorMessage – egy adott dokumentum feldolgozási
hibájának leírására (jelenleg az IngestService maga nem tölti fel,
a hívó oldal saját hibakezeléséhez áll rendelkezésre).
AbstractIngestionTransformer-be zárva – a transformer egy plain
Spark Transformer példány, nem Spring-managed bean, így nincs
@Value/@Autowired injektálása. A beütemezést és az
állapotkövetést az IngestService végzi, amelyet a hívó (plugin
saját, Spring-managed) service egyszerű @Autowired-del kap meg –
nincs szükség RedisTemplate vagy tárolási property-k közvetlen
injektálására a plugin oldalán.
Chatbot-független batch feldolgozás
A transformer réteg nem kötődik a chatbot pipeline-hoz. Önálló Java/Spark programból, adminisztrációs tool-ból vagy ütemezett batchből is futtatható, ha a Spark session és a platform LLM kliensek elérhetők.
Tipikus önálló feldolgozási minta:
// 1. Adatforrás betöltése (pl. CSV, Parquet, JDBC)
Dataset<Row> rawData = spark.read()
.option("header", "true")
.csv("/data/input/records.csv");
// 2. Transformer alkalmazása
SummaryToolTransformer summarizer = new SummaryToolTransformer(null);
summarizer.setOutputCols("summary").setBatchSize(10);
Dataset<Row> enriched = summarizer.transform(rawData);
// 3. Eredmény visszaírása
enriched.write()
.mode(SaveMode.Overwrite)
.parquet("/data/output/enriched_records");
// 4. Vagy visszaírható a vállalati rendszerbe (JDBC, API, stb.)
isNeedTransform() idempotencia mechanizmusa különösen hasznos
hosszú batch futásoknál: ha a feldolgozás félbeszakad, az újraindítás után
csak az elvégzetlen sorok kapnak LLM hívást, a már kész sorok érintetlenek maradnak.