SDK
Haladó fejezet

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.

💡
Egy agent, amely a vállalati adatbázis sorait LLM-mel osztályozza, összefoglalja vagy gazdagítja, ugyanolyan platformon fut, mint a chatbot agent – azonos adatszuverenitási garanciákkal, naplózhatósággal és hozzáférés-kezeléssel. Az adat ott marad, ahol volt.

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:

ℹ️
A transformer osztályok SDK elérhetőségéről egyeztetés szükséges a platform csapatával. Futásidőn elérhetők a platform classpathján.

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:

SetterDefaultLeí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;
ℹ️
Az 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();
💡
Ez a transformer pontosan azt illusztrálja, ami a platform lényege: a vállalati tudás (RAG dokumentumok) helyben, on-premise LLM-mel kerül feldolgozásra – az adat nem hagyja el a szervezet infrastruktúráját, mégis AI-alapú minőségmérés végezhető rajta.

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.

ℹ️
A transformer a soronkénti feldolgozás közben a Spark 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ípusLeírás
idLong Egyedi azonosító – lehet null, ilyenkor a platform generál egyet.
nameStringDokumentum / cikk címe.
textString Ha ki van töltve, a transformer nem tölt le semmit – változatlanul tárolja.
urlString Ha text üres, innen tölti le a tartalmat a transformer.
formatString Bemeneten informatív – a végleges formátumot a transformer állapítja meg (letöltés/konverzió eredménye alapján).
systemStringForrásrendszer azonosítója.
summaryString 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.
dateDateA dokumentum aktualitásának dátuma.
sectionsList<String>Kategória címkék listája.
writersList<String>Szerzők nevének listája.
labelsList<String>A tartalmat leíró címkelista.
⚠️
A 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

⚠️
A tartalomtípus detektálása a 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.

ℹ️
A talált fájlt a mechanizmus kizárólag másolja, sosem mozgatja vagy törli – elképzelhető, hogy egy másik, párhuzamosan futó batch is hivatkozik rá. Emiatt a fájlok emiatt is URL-hash alapú néven tárolódnak (nem tartalom-hash alapján, mint a manuális feltöltési útvonalon) – a letöltés előtti kereséshez a névnek kizárólag az URL-ből kell előállíthatónak lennie, a tartalmat pedig pont letöltés nélkül akarjuk megismerni. A 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).
💡
A legfontosabb különbség a scrape réteghez (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

💡
Az SDK hatálya alá tartozó osztályok (@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);
ℹ️
A 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:

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).

ℹ️
A Redis feldolgozó-sorba történő beütemezés szándékosan nincs az 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.)
💡
A 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.