OpenAI API w praktyce: function calling, embeddings i RAG
Kod produkcyjny dla OpenAI API: function calling, embeddings, vector database, RAG pipeline. Konkretne przykłady, realne pułapki, koszty. Dla developerów, nie marketerów.
OpenAI API w praktyce: function calling, embeddings i RAG
Ten post to kod produkcyjny, nie hello world. Po dwóch latach budowania aplikacji z OpenAI API mam sprawdzone wzorce, które używam w co drugim projekcie. Są gotowe do wklejenia i uruchomienia.
Function calling: stabilny kontrakt z modelem
Function calling to najważniejsza funkcja OpenAI API dla developerów. Pozwala wymusić, żeby model zwrócił strukturę zamiast chaotycznego tekstu. W produkcji jest niezastąpiony.
Podstawowy wzorzec (TypeScript)
import OpenAI from "openai";
import { z } from "zod";
const client = new OpenAI();
// Definiuj schemat tego co model ma zwrócić
const TaskSchema = z.object({
action: z.enum(["send_email", "create_task", "search_docs", "none"]),
args: z.record(z.string(), z.any()),
reasoning: z.string().max(500),
});
type Task = z.infer<typeof TaskSchema>;
async function classifyIntent(userMessage: string): Promise<Task> {
const completion = await client.chat.completions.create({
model: "gpt-4o-mini",
messages: [
{
role: "system",
content: `Jesteś asystentem. Klasyfikuj intencję użytkownika
w jedną z 4 akcji. Bądź zwięzły.`,
},
{ role: "user", content: userMessage },
],
tools: [
{
type: "function",
function: {
name: "execute_action",
description: "Wykonaj akcję na podstawie intencji użytkownika",
parameters: {
type: "object",
properties: {
action: {
type: "string",
enum: ["send_email", "create_task", "search_docs", "none"],
},
args: { type: "object", additionalProperties: true },
reasoning: { type: "string" },
},
required: ["action", "args", "reasoning"],
},
},
},
],
tool_choice: { type: "function", function: { name: "execute_action" } },
});
const toolCall = completion.choices[0].message.tool_calls?.[0];
if (!toolCall) throw new Error("No tool call returned");
// WAŻNE: waliduj output modelu zanim go użyjesz
const parsed = TaskSchema.parse(JSON.parse(toolCall.function.arguments));
return parsed;
}
Trzy rzeczy, które robią różnicę:
-
tool_choice: { type: "function", ... }— wymusza, żeby model zawsze wywołał tę funkcję. Bez tego model może odpowiedzieć tekstem zamiast JSON-em. -
z.parse()na output — model może zwrócić JSON, który nie pasuje do schematu. Walidacja pozwala to złapać. Bez niej masz bugi, które objawiają się w produkcji losowo. -
reasoningw output — pole wymuszone w schemacie. Model musi uzasadnić swoją decyzję, co zmniejsza halucynacje. Bez tego model zgaduje akcję losowo, z tym — myśli przed decyzją.
Pułapka: function calling + streaming
Function calling nie streamuje się dobrze. Output jest albo pełny JSON, albo nic. Jeśli potrzebujesz streamingu (long-form text), użyj dual call: pierwszy call zwraca akcję (function calling), drugi zwraca tekst (streaming). Dwa razy droższe, ale jedyny sposób na łączenie obu.
Embeddings: zamiana tekstu na wektory
Embeddings to fundament wyszukiwania semantycznego i RAG.
Zamieniają tekst na 1536-wymiarowy wektor (dla text-embedding-3-small).
Teksty o podobnym znaczeniu mają podobne wektory.
Generowanie embeddingów (cache!)
import { createHash } from "crypto";
import { Redis } from "@upstash/redis";
const redis = Redis.fromEnv();
async function getEmbedding(text: string): Promise<number[]> {
// Cache hit: 0ms, $0
const cached = await redis.get<number[]>(`emb:${hashText(text)}`);
if (cached) return cached;
// Cache miss: 200ms, $0.00000002
const response = await openai.embeddings.create({
model: "text-embedding-3-small",
input: text,
});
const vector = response.data[0].embedding;
await redis.set(`emb:${hashText(text)}`, vector, { ex: 60 * 60 * 24 * 30 });
return vector;
}
function hashText(text: string): string {
return createHash("sha256").update(text).digest("hex").slice(0, 16);
}
Cache'owanie embeddingów jest kluczowe dla kosztów. Jeśli user pyta "jak działa X" dwa razy — drugi embedding to darmowy cache hit. W moich aplikacjach 60-80% zapytań to cache hit.
Magazynowanie: pgvector vs Pinecone
Dla 95% polskich SaaS-ów: pgvector w Supabase wystarcza.
-- Migration: enable pgvector
CREATE EXTENSION IF NOT EXISTS vector;
-- Table with embeddings
CREATE TABLE documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
content TEXT NOT NULL,
metadata JSONB DEFAULT '{}',
embedding vector(1536),
created_at TIMESTAMPTZ DEFAULT now()
);
-- Index for fast similarity search
CREATE INDEX documents_embedding_idx
ON documents
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);
import { createClient } from "@supabase/supabase-js";
const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_KEY!
);
async function findSimilarDocuments(
queryEmbedding: number[],
matchThreshold = 0.7,
matchCount = 5
) {
const { data, error } = await supabase.rpc("match_documents", {
query_embedding: queryEmbedding,
match_threshold: matchThreshold,
match_count: matchCount,
});
if (error) throw error;
return data;
}
// SQL function
// CREATE FUNCTION match_documents(
// query_embedding vector(1536),
// match_threshold float,
// match_count int
// ) RETURNS SETOF documents AS $$
// SELECT * FROM documents
// WHERE 1 - (documents.embedding <=> query_embedding) > match_threshold
// ORDER BY documents.embedding <=> query_embedding
// LIMIT match_count;
// $$ LANGUAGE sql;
<=> to operator odległości cosinusowej w pgvector. Im bliżej 0,
tym bardziej podobne. Threshold 0.7 = "dość podobne".
RAG pipeline: production-ready
RAG to połączenie retrieval (wyszukiwanie semantyczne) + generation (LLM). Wzorzec, który używam w większości aplikacji:
async function ragQuery(userQuestion: string): Promise<string> {
// 1. Embedding pytania (cache'owany)
const questionEmbedding = await getEmbedding(userQuestion);
// 2. Retrieval: top-5 najbardziej podobnych fragmentów
const relevantDocs = await findSimilarDocuments(questionEmbedding, 0.7, 5);
if (relevantDocs.length === 0) {
return "Nie znam odpowiedzi na to pytanie. Mogę pomóc w inny sposób?";
}
// 3. Konstrukcja kontekstu z cytatami
const context = relevantDocs
.map((doc, i) => `[${i + 1}] ${doc.content}`)
.join("\n\n");
// 4. Prompt z kontekstem i instrukcją cytowania
const completion = await openai.chat.completions.create({
model: "gpt-4o-mini",
messages: [
{
role: "system",
content: `Jesteś asystentem. Odpowiadaj TYLKO na podstawie
dostarczonego kontekstu. Jeśli kontekst nie zawiera odpowiedzi,
powiedz to wprost. ZAWSZE cytuj źródło używając numeru [N] przy
każdym fakcie.`,
},
{
role: "user",
content: `Kontekst:\n${context}\n\nPytanie: ${userQuestion}`,
},
],
temperature: 0.2, // niska = bardziej deterministyczny
});
return completion.choices[0].message.content!;
}
Trzy elementy, które robią różnicę:
-
Threshold similarity 0.7 — poniżej tego dokumenty są szumem. Bez thresholda model dostaje random dokumenty i halucynuje.
-
Numeracja cytatów [N] — model cytuje źródła. Użytkownik widzi skąd info. Możesz potem zweryfikować w UI ("Źródło: dokument 3").
-
Temperatura 0.2 — dla RAG chcesz deterministyczne odpowiedzi, nie kreatywne. Temperatura 0.7+ daje halucynacje, bo model "domyśla się" gdy kontekst jest niepełny.
Kiedy RAG NIE działa
RAG nie jest magiczną różdżką. Nie działa gdy:
- Dokumenty są nieaktualne — RAG zwróci stary, sprzeczny z
rzeczywistością content. Rozwiązanie: regularnie re-embeduj
dokumenty, dodaj
dateModifiedjako filter. - Pytanie jest zbyt ogólne — top-5 fragmentów nie pokrywa tematu. Rozwiązanie: query expansion (przeformułuj pytanie na 3 warianty, szukaj każdego).
- Knowledge base jest mały (< 100 dokumentów) — lepiej wkleić wszystko do promptu, RAG jest overengineering.
Koszty w produkcji: realne liczby
Dla aplikacji 10k użytkowników / miesiąc, 3 pytania / user:
| Komponent | Koszt / miesiąc | |-----------|-----------------| | Embeddings (cache 70% hit) | $0.30 | | Vector storage (Supabase) | $0 (w darmowym planie) | | GPT-4o-mini (RAG, avg 1k tokenów) | $45 | | GPT-4o (precyzyjne odpowiedzi) | $300 | | Total (mini) | $45-50 | | Total (full) | $300-350 |
Wniosek: GPT-4o-mini wystarcza do 80% zastosowań RAG. Dopiero gdy potrzebujesz niuansów (prawne, medyczne) — przeskakuję na GPT-4o.
Debugging: 5 narzędzi, które używam codziennie
- LangSmith — tracing dla OpenAI, pełna widoczność prompts i responses. Płatne ($39/m) ale warte.
- Helicone — proxy OpenAI, logowanie tokenów i kosztów. Darmowy tier wystarcza do startu.
- OpenAI Playground — testowanie promptów bez kodu, porównywanie modeli obok siebie.
- pgvector Studio (lokalny GUI) — przeglądanie wektorów w bazie, sprawdzanie czy embedding ma sens.
- Własny eval set — 20-30 pytań z oczekiwanymi odpowiedziami, uruchamiam po każdej zmianie promptu. Bez tego optymalizujesz na ślepo.
Co dalej
Ten post to fundament. Kolejne kroki:
- Function calling + persystencja — agenty, które pamiętają poprzednie wywołania
- RAG z re-ranking — drugi model poprawia ranking retrieval
- Streaming + function calling — dwa modele w tandemie dla UX
Jeśli budujesz aplikację z OpenAI API i potrzebujesz review architektury — napisz do mnie. Mam sprawdzone wzorce dla 8 różnych verticals (e-commerce, edukacja, B2B lead gen, HR, support, content generation, code assistance, document analysis).
Powiązane posty:
Najczęściej zadawane pytania
Czym różni się RAG od zwykłego promptu z kontekstem?
Jaki model embeddingów OpenAI wybrać w 2025?
Ile kosztuje RAG w produkcji?
Czy potrzebuję Pinecone czy mogę użyć PostgreSQL?
Powiązane posty
- aiAI
Multi-agent systemy AI w 2025: architektura, narzędzia, pułapki
Jak budować produkcyjne systemy multi-agent w 2025: Hermes-style orkiestracja, LangChain, autogen, evaluacja. Praktyczny przewodnik z kodem i realnymi pułapkami.
5 min - aiAI
GEO vs SEO: jak optymalizować treści pod wyszukiwarki AI w 2025
Generative Engine Optimization to nowa dyscyplina SEO. Jak pisać treści, które cytuje ChatGPT, Perplexity i Gemini. Praktyczne techniki z przykładami kodu i danymi.
5 min