A két darab, ami a te SaaS-odban tényleg számít: a Gateway mint az AI-forgalom kontrollpontja (költséggel, kvótával, biztonsággal), és az AI Search mint dokumentum-kereső — konkrét, tenantonként szeparált példával, plusz őszinte összevetés a Postgres full-text search-csel.
A 15. modulban „proxynak" neveztem, de ez alulértékeli. Pontosabb: az AI Gateway az a hely, ahol az összes AI-hívásod áthalad — és emiatt az egyetlen pont, ahol egységesen tudsz mérni, korlátozni, cache-elni és védeni. Ha egy multitenant SaaS-ban AI-funkciót adsz ki, három kérdés fog felmerülni az első hónapban: mennyibe kerül tenantonként? mi van, ha valaki visszaél vele? mi van, ha a szolgáltató elesik? — mindháromra ez a válasz.
A Gateway ma egységes REST API-t ad az összes modellhez, szolgáltatótól függetlenül. Négy endpoint-alak közül választhatsz aszerint, hogy melyik SDK-t használod:
| Endpoint | Mire |
|---|---|
POST /ai/run | univerzális: bármely modell, bármely modalitás |
POST /ai/v1/chat/completions | OpenAI SDK-kompatibilis — a meglévő kódod base URL-cserével működik |
POST /ai/v1/responses | OpenAI Responses API-kompatibilis |
POST /ai/v1/messages | Anthropic SDK-kompatibilis |
curl -X POST "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/ai/v1/chat/completions" \
-H "Authorization: Bearer $CF_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "model": "openai/gpt-5.5",
"messages": [{"role":"user","content":"Foglald össze ezt a ticketet…"}] }'
Figyeld meg, mi nincs ebben a hívásban: OpenAI API-kulcs. Két modell közül választhatsz:
| Képesség | Mit csinál | Mire jó a SaaS-odban |
|---|---|---|
| Logging | minden kérés/válasz, modell, tokenszám, user agent; szűrhetően | hibakeresés, minőség-ellenőrzés, „miért ezt válaszolta?" |
| Caching | azonos kérésekre cache-elt válasz | költség és latencia: ismétlődő promptoknál (súgó-magyarázatok, sablon-generálás) azonnali nyereség |
| Rate limiting | kérésszám-korlát a szolgáltató előtt | visszaélés-védelem; a hívás el sem megy, tehát nem is kerül pénzbe |
| Spend limits | dollár-alapú költségkeret; a tokenhasználatból és a modellárból számolt valós költséget méri, és 429-cel blokkol a keret felett | ez a kulcs a tenant-szintű költségvédelemhez — lásd lent |
| Guardrails + DLP | prompt/válasz szűrése nem biztonságos tartalomra; érzékeny adatok (személyes, pénzügyi) felismerése | compliance és felelősség: nem a te kódodban kell szűrőt írni |
| Fallback + retry | hiba esetén másik modell/szolgáltató | rendelkezésre állás: egy szolgáltatói kiesés nem visz le funkciót |
Ez az a képesség, amiért érdemes végigolvasni ezt a szakaszt. A spend limit szabályai egyéni metaadat-dimenziók szerint is szűkíthetők — vagyis ha minden hívásba beteszed a tenant_id-t metaadatként, akkor tenantonként tudsz napi/havi dollárkeretet szabni, a Gateway szintjén, a te kódod nélkül:
import OpenAI from "openai";
export function aiClient(env, tenantId: string, userId: string) {
return new OpenAI({
apiKey: env.CF_API_TOKEN,
baseURL: `https://api.cloudflare.com/client/v4/accounts/${env.CF_ACCOUNT_ID}/ai`,
defaultHeaders: {
// egyéni metaadat: ezek lesznek a spend limit és a logok dimenziói
"cf-aig-metadata": JSON.stringify({ tenant_id: tenantId, user_id: userId }),
"cf-aig-gateway-id": "saas-prod",
},
});
}
Ezután a dashboardon felveszel néhány szabályt — kódmódosítás nélkül:
| Szabály | Mit véd |
|---|---|
| tenantonként max $20/nap | egy elszabadult ügyfél nem viszi el a havi AI-keretedet |
| userenként max $2/nap | egy tenanton belüli visszaélés (vagy hibás integráció) |
| gateway-szinten max $500/nap | a globális vészfék — bármi is történjen |
| a drága modellre $50/nap | a frontier-modell használatának plafonja |
Ahhoz, hogy értsd, mit spórolsz vele, előbb nézzük meg, mi a teljes RAG-pipeline, amit egyébként neked kellene megépítened és üzemeltetned:
Egy instance = beépített tár + vektorindex + a fenti pipeline. Nem kell hozzá R2-bucket, embedding-modell-választás, chunkolási stratégia vagy külön index-karbantartás. Amit még kapsz:
A helyzeted: közös adatbázis tenant_id oszlopokkal (5b. modul), és most jön egy funkció, hogy a felhasználók feltölthetik a saját dokumentumaikat, és kérdezhetnek belőlük. A kérdés: hogyan szeparálod?
A hivatalos — és szerintem is helyes — ajánlás: tenantonként egy instance. Az üzleti adat marad a közös DB-ben, a dokumentumok viszont fizikailag külön indexbe kerülnek. A kettő nem mond ellent egymásnak: a shared DB-d marad, csak a kereshető tartalom szeparálódik.
wrangler.jsonc{
"ai_search_namespaces": [
{ "binding": "AI_SEARCH", "namespace": "default", "remote": true }
]
}
A remote: true itt kötelező a lokális fejlesztéshez (11. modul): az AI Search nem fut lokálisan, a wrangler dev a deployolt instance-okhoz proxyzik.
Illeszd be a meglévő onboarding-folyamatodba (7. modul Workflow-jába, ha ott van):
server/utils/docs.tsexport const tenantIndexId = (tenantId: string) => `tenant-${tenantId}`;
export async function provisionTenantIndex(env, tenantId: string) {
await env.AI_SEARCH.create({ id: tenantIndexId(tenantId) });
}
export async function deprovisionTenantIndex(env, tenantId: string) {
await env.AI_SEARCH.delete(tenantIndexId(tenantId)); // GDPR-törléskor: egy hívás
}
export default defineEventHandler(async (event) => {
const { env } = event.context.cloudflare;
const { tenantId, userId } = await requireSession(event); // a saját auth-od
const form = await readMultipartFormData(event);
const file = form.find((f) => f.name === "file");
const docId = crypto.randomUUID();
const key = `${tenantId}/${docId}/${file.filename}`;
// (a) az eredeti fájl R2-be — ez marad az igazság (6. modul)
await env.UPLOADS.put(key, file.data, {
httpMetadata: { contentType: file.type },
customMetadata: { tenantId, userId, docId },
});
// (b) a kereshető másolat a TENANT SAJÁT indexébe
const index = env.AI_SEARCH.get(tenantIndexId(tenantId));
await index.items.uploadAndPoll(`${docId}__${file.filename}`, file.data);
// (c) a metaadat a közös DB-be — itt él a jogosultság és a listázás
await env.DB.prepare(
`INSERT INTO documents (id, tenant_id, owner_user_id, filename, r2_key, visibility)
VALUES (?, ?, ?, ?, ?, ?)`
).bind(docId, tenantId, userId, file.filename, key, "team").run();
return { ok: true, docId };
});
A hármas felosztás szándékos, és a kurzus korábbi elveit követi: R2 az eredeti fájl (olcsó, nulla egress), AI Search a kereshető index (ez „csak" derivált adat — újraépíthető), D1/Postgres a metaadat és a jogosultság (ez a tranzakcionális igazság, 6. modul hármas felosztása).
Itt jön a rész, ami elegánsabb, mint elsőre gondolnád. A namespace-szintű kereséssel több instance-t kérdezhetsz egy hívásban, és minden találat magával hozza, melyik indexből jött:
server/api/docs/ask.post.tsexport default defineEventHandler(async (event) => {
const { env } = event.context.cloudflare;
const { tenantId } = await requireSession(event);
const { question } = await readBody(event);
const results = await env.AI_SEARCH.search({
messages: [{ role: "user", content: question }],
ai_search_options: {
// a közös termék-dokumentáció ÉS a tenant saját anyagai — egy rangsorban
instance_ids: ["product-docs", tenantIndexId(tenantId)],
},
});
// forrás szerint csoportosítva adjuk vissza a UI-nak
return {
answer: results.response,
sources: results.data.map((chunk) => ({
text: chunk.content,
from: chunk.instance_id === "product-docs" ? "Súgó" : "Saját dokumentumok",
})),
};
});
Ez az a pont, ahol a szeparáció szerkezeti, nem fegyelmi kérdés: a lekérdezés csak azokat az indexeket látja, amiket felsorolsz — és a tenant azonosítója a session-ből jön, nem a kliens kéréséből. Egy másik tenant adata nem attól nem jelenik meg, mert jól írtad meg a WHERE-t, hanem mert fizikailag nincs abban az indexben, amit megkérdeztél.
Gyakori igény: egy tenanton belül a HR-es dokumentumait ne lássa a fejlesztő. Három szintet érdemes ismerni:
| Szint | Megoldás | Mikor |
|---|---|---|
| Tenant | instance tenantonként | mindig — ez az alap |
| Csoport/projekt | instance csoportonként (tenant-acme-hr), és a keresésnél csak a jogosult instance-okat sorolod fel | ha kevés, jól körülhatárolt csoport van, és erős izoláció kell |
| Felhasználó / finomhangolt jogosultság | utószűrés a saját DB-dből: a találatok docId-jait visszaellenőrzöd a documents táblán a user jogosultságával | ha sok, dinamikusan változó jogosultság van (ez az általános eset) |
// a találatokból kinyerjük a dokumentum-azonosítót (a fájlnév-prefixből)
const docIds = [...new Set(results.data.map(c => c.filename.split("__")[0]))];
// és a KÖZÖS DB dönt arról, mit láthat ez a user
const allowed = await env.DB.prepare(
`SELECT id FROM documents
WHERE tenant_id = ? AND id IN (${docIds.map(() => "?").join(",")})
AND (visibility = 'team' OR owner_user_id = ?)`
).bind(tenantId, ...docIds, userId).all();
const allowedSet = new Set(allowed.results.map(r => r.id));
const visible = results.data.filter(c => allowedSet.has(c.filename.split("__")[0]));
Mivel az index derivált adat, a helyreállítás egyszerű: az R2-ben ott az összes eredeti fájl, a DB-ben a lista — egy Workflow (7. modul) végigmegy rajtuk és újraindexel. Ezt érdemes megírni még az indulás előtt: ez a te „mi van, ha elromlik az index" válaszod.
Fontos, hogy ezt tisztán lásd, mert a kettő nem ugyanazt a problémát oldja meg, és a „jobb" kérdésre az őszinte válasz: attól függ, mit keresel.
A tsvector/tsquery lexikai keresés: szótövezés, stopszavak, súlyozás, rangsorolás (ts_rank), GIN-index. Kiváló, ha a felhasználó tudja a szavakat, amiket keres. Amit viszont nem tud: nem érti a jelentést. A „hogyan mondom le az előfizetést" kérdés nem fogja megtalálni a „számlázási ciklus megszüntetése" című szakaszt, mert nincs közös szótő. És nem tud PDF-et olvasni, nem darabol, nem generál választ.
| Szempont | Postgres FTS | AI Search |
|---|---|---|
| Keresés jellege | kulcsszó/lexikai; a pontos egyezés erőssége | hibrid: jelentés + kulcsszó |
| Természetes nyelvű kérdés | gyenge | erre való |
| Fájlformátumok (PDF, docx) | neked kell kinyerned a szöveget | beépített feldolgozás |
| Chunkolás, embedding, index | nincs (pgvectorral: te építed) | kezelt pipeline |
| Generált válasz forrásokkal | nincs | van |
| Konzisztencia az adattal | tranzakcionális: a commit után azonnal kereshető | aszinkron: az indexelés késik, külön rendszer |
| SQL-lel kombinálható (join, filter, sorrend) | természetes | külön rendszer; utószűrés kell |
| Pontos szűrők (dátum, státusz, összeg) | erős | korlátozottabb |
| Multitenant izoláció | WHERE tenant_id = ? — egy kifelejtett feltétel = szivárgás | külön instance — szerkezetileg nem szivároghat |
| Üzemeltetés | nulla új rendszer | új függőség, új limitek, új költségsor |
| Költség | gyakorlatilag ingyen (a DB-den fut) | tárolás + lekérdezés + modellhívás |
| Törlés / GDPR | DELETE | instance törlése egy hívással |
WHERE tenant_id), az AI Search-nél szerkezeti (nem is létezik a másik tenant adata abban az indexben). Egy hibás query az egyiknél adatszivárgás, a másiknál üres találatlista.remote: true kell — és így a dev-környezeted valódi (staging!) instance-t használ. Ügyelj rá, hogy ez ne a production namespace legyen (11. modul figyelmeztetése).Minden hívásba egyéni metaadatot teszel (cf-aig-metadata: tenant_id, user_id), majd a Gatewayen spend limit szabályt veszel fel erre a dimenzióra. A Gateway a tokenhasználatból és a modellárból számolt valós költséget méri, és 429-cel blokkol a keret felett. Kemény termék-kvótához viszont továbbra is saját DO-limiter kell, mert a spend limit eventually consistent.
A teljes pipeline-t: fájlfeldolgozás (PDF, docx), chunkolás, embedding, index-karbantartás, hibrid keresés (vektor + kulcsszó), rangsorolás/boosting, similarity cache és generált válasz forrásokkal. pgvectorral ezt mind te építed és tartod karban — az adatbázis csak a vektorokat tárolja.
WHERE tenant_id mintával?
Mert a lekérdezés csak a felsorolt instance-okat látja, és a tenant azonosítója a session-ből jön: a másik tenant adata fizikailag nincs abban az indexben. Egy hibás lekérdezés így üres találatot ad, nem idegen adatot — a shared táblánál viszont egy kifelejtett feltétel azonnal szivárgás.
Utószűrés a saját DB-dből: a találatok dokumentum-azonosítóit visszaellenőrzöd a documents táblán a user jogosultságával. A csapda: ha generált választ kérsz, a modell már látta az összes chunkot a szűrés előtt — ilyenkor előbb nyers találatokat kérj, szűrj, és csak a szűrt kontextusból generálj.
Mert az embedding-hívásokra is hatna: cache-elt vektorok kerülhetnek az indexbe vagy a lekérdezésbe, ami csendben rontja a találati minőséget. A rate limit hasonlóan megakaszthatja az indexelést. Találat-cache-re az AI Search saját similarity cache-e való.
Ha az alkalmazásod saját rekordjaiban keresel (ügyfélnév, számlaszám, ticket-cím), ahol a tranzakcionális konzisztencia, a joinok és a pontos szűrők számítanak; vagy ha egyszerű kulcsszavas keresés is elég és nincsenek feltöltött dokumentumok. Ne vezess be új rendszert egy már megoldott problémára.