łata największą bolączkę potężnych modeli językowych: nie znają Twojej
własnej bazy wiedzy. Zapytany o szczegół z firmowej dokumentacji, model bez
kontekstu zacznie zgadywać. RAG zamienia to w kontrolowany proces — najpierw
szukasz właściwego fragmentu, potem każesz modelowi odpowiedzieć wyłącznie na
jego podstawie.
Zanim przejdziemy do implementacji — warto wiedzieć, że RAG nie zawsze wymaga pisania kodu. Google NotebookLM to gotowy produkt zbudowany na tej samej zasadzie: wgrywasz dokumenty, a przy każdym pytaniu system sam odnajduje odpowiednie fragmenty i podaje je modelowi jako kontekst. Różnica polega na tym, że w NotebookLM nie masz wglądu w to, jak działa ten proces — nie możesz zmienić sposobu cięcia tekstu, wybrać innego modelu ani wpiąć tego w swoją aplikację. Ten artykuł jest dla sytuacji, gdy potrzebujesz RAG wewnątrz własnego produktu, a nie jako osobne narzędzie.
Tnij długie teksty na fragmenty po ok. 400–800 tokenów.
Zamień fragmenty na (np. modelem
OpenAI).
Zapisz wektory w bazie danych (np. pgvector na start).
Przy zapytaniu użytkownika wyszukaj najbliższe fragmenty.
Sklej znalezione fragmenty w prompt z pytaniem i podaj taniemu LLM (np.
Claude Haiku 4.5).
Streamuj odpowiedź do klienta przez Vercel AI SDK.
Architektura RAG — jak płynie zapytanie
Code
Użytkownik pyta: "Jak zresetować hasło w firmowym systemie?" ↓1. Zapytanie zamieniamy na wektor (embedding) przez API OpenAI — ciąg 1536 liczb ↓2. Szukamy w bazie najbliższych fragmentów (np. wpis 'reset-hasla.mdx') ↓3. Doklejamy znalezione fragmenty jako kontekst do promptu i wysyłamy do LLM ↓4. Model odpowiada na podstawie kontekstu i streamuje wynik do chatu
Jakie narzędzia wziąć
Next.js — front i backend ( routes) w jednym projekcie.
Vercel AI SDK — spoiwo do strumieniowania odpowiedzi do przeglądarki, bez ręcznego zarządzania połączeniem.
OpenAI Embeddings — tanie i skuteczne generowanie wektorów z tekstu.
Baza wektorowa — Supabase z pgvector na MVP; Upstash Vector albo Pinecone przy większym ruchu produkcyjnym.
Zod — walidacja i typowanie danych wejściowych.
Etap 1: cięcie i obróbka danych
Długich artykułów nie wciśniesz w całości do prompt, dlatego tekst tniemy na mniejsze fragmenty (chunki):
Code
// lib/rag/chunker.tsinterface Chunk { content: string metadata: { source: string title: string chunkIndex: number }}export function chunkText( text: string, source: string, title: string, chunkSize: number = 500, // domyślnie ok. 500 słów na fragment, overlap: number = 100, // nachodzenie fragmentów, by nie ucinać kontekstu w pół zdania,): Chunk[] { const words = text.split(/\s+/) const chunks: Chunk[] = [] let i = 0 let chunkIndex = 0 while (i < words.length) { const chunk = words.slice(i, i + chunkSize).join(' ') chunks.push({ content: chunk, metadata: { source, title, chunkIndex }, }) i += chunkSize - overlap chunkIndex++ } return chunks}
Etap 2: generowanie embeddingów
Code
// lib/rag/embeddings.tsimport { openai } from '@ai-sdk/openai'import { embed, embedMany } from 'ai'export async function generateEmbedding(text: string): Promise<number[]> { const { embedding } = await embed({ model: openai.embedding('text-embedding-3-small'), // tani model w zupełności wystarcza do bazy wiedzy value: text, }) return embedding}export async function generateEmbeddings(texts: string[]): Promise<number[][]> { const { embeddings } = await embedMany({ model: openai.embedding('text-embedding-3-small'), values: texts, }) return embeddings}
Etap 3: baza wektorowa w Supabase
Baza musi umieć przechowywać i przeszukiwać wektory. Supabase robi to przez rozszerzenie vector (pgvector):
Code
-- Uruchom w edytorze SQL w Supabase:create extension if not exists vector;create table documents ( id bigserial primary key, content text not null, metadata jsonb, embedding vector(1536) -- wymiar zgodny z text-embedding-3-small);-- Indeks ivfflat przyspiesza wyszukiwanie po podobieństwiecreate index on documents using ivfflat (embedding vector_cosine_ops) with (lists = 100);-- Funkcja zwracająca najbliższe fragmenty dla zapytaniacreate or replace function match_documents( query_embedding vector(1536), match_threshold float default 0.7, match_count int default 5)returns table ( id bigint, content text, metadata jsonb, similarity float)language sql stableas $$ select id, content, metadata, 1 - (embedding <=> query_embedding) as similarity from documents where 1 - (embedding <=> query_embedding) > match_threshold order by embedding <=> query_embedding limit match_count;$$;
Etap 4: indeksowanie dokumentów
Skrypt, który raz przepuszcza Twoje treści przez chunker i embeddingi, a wynik zapisuje do bazy:
Code
// scripts/index-documents.ts — uruchamiasz raz, by zasilić bazęimport { chunkText } from '@/lib/rag/chunker'import { generateEmbeddings } from '@/lib/rag/embeddings'import { createClient } from '@supabase/supabase-js'import fs from 'fs'import path from 'path'const supabase = createClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!,)async function indexDocuments() { // Bierzemy gotowe artykuły .mdx z dysku const articlesDir = path.join(process.cwd(), 'content/blog') const files = fs.readdirSync(articlesDir).filter((f) => f.endsWith('.mdx')) for (const file of files) { const content = fs.readFileSync(path.join(articlesDir, file), 'utf-8') const title = file.replace('.mdx', '') // Tniemy na fragmenty const chunks = chunkText(content, file, title) console.log(`Plik ${file}: ${chunks.length} fragmentów.`) // Generujemy embeddingi dla wszystkich fragmentów naraz const embeddings = await generateEmbeddings(chunks.map((c) => c.content)) // Zapisujemy do bazy const records = chunks.map((chunk, i) => ({ content: chunk.content, metadata: chunk.metadata, embedding: embeddings[i], })) const { error } = await supabase.from('documents').insert(records) if (error) console.error(`Błąd indeksowania pliku ${file}:`, error) } console.log('Baza wektorowa gotowa.')}indexDocuments()
Etap 5: endpoint API ze streamingiem
Główny kontroler: przyjmuje pytanie, znajduje kontekst i streamuje odpowiedź modelu:
Code
// app/api/rag/route.tsimport { openai } from '@ai-sdk/openai'import { convertToModelMessages, streamText, type UIMessage } from 'ai'import { generateEmbedding } from '@/lib/rag/embeddings'import { createClient } from '@supabase/supabase-js'const supabase = createClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!,)export async function POST(req: Request) { const { messages }: { messages: UIMessage[] } = await req.json() const lastMessage = messages.at(-1) const lastMessageText = lastMessage?.parts .filter((part) => part.type === 'text') .map((part) => part.text) .join('\n') ?? '' if (!lastMessageText.trim()) { return Response.json({ error: 'Puste zapytanie.' }, { status: 400 }) } // 1. Zamieniamy pytanie użytkownika na wektor const queryEmbedding = await generateEmbedding(lastMessageText) // 2. Szukamy najbliższych fragmentów w bazie const { data: documents } = await supabase.rpc('match_documents', { query_embedding: queryEmbedding, match_threshold: 0.7, match_count: 5, }) // 3. Sklejamy znalezione fragmenty w kontekst const context = documents ?.map((doc: any) => `[Źródło: ${doc.metadata.title}]\n${doc.content}`) .join('\n\n---\n\n') // 4. Każemy modelowi odpowiedzieć wyłącznie na podstawie kontekstu const result = streamText({ model: openai('gpt-4o'), system: `Jesteś asystentem bazy wiedzy StriveLab. Odpowiadaj wyłącznie na podstawie kontekstu poniżej. Jeśli odpowiedzi nie ma w kontekście, napisz wprost, że nie znalazłeś informacji. Nie zmyślaj.KONTEKST:${context || 'Brak pasującego kontekstu w bazie.'}`, messages: await convertToModelMessages(messages), }) return result.toUIMessageStreamResponse()}
Etap 6: front — interfejs chatu
Code
'use client'import { useState } from 'react'import { DefaultChatTransport } from 'ai'import { useChat } from '@ai-sdk/react'export default function KnowledgeBase() { const [input, setInput] = useState('') // Vercel AI SDK obsługuje połączenie z endpointem RAG const { messages, sendMessage, status } = useChat({ transport: new DefaultChatTransport({ api: '/api/rag' }), }) const isLoading = status === 'submitted' || status === 'streaming' function handleSubmit(event: React.FormEvent<HTMLFormElement>) { event.preventDefault() if (!input.trim()) return sendMessage({ text: input }) setInput('') } return ( <div className="mx-auto max-w-2xl p-6"> <h1 className="mb-2 text-2xl font-bold"> Zapytaj asystenta bazy wiedzy StriveLab </h1> <p className="mb-6 text-gray-500"> Pytaj o technologie, SEO i usługi opisane na stronie. </p> <div className="mb-6 min-h-[200px] space-y-4"> {messages.map((m) => ( <div key={m.id} className={`rounded-lg p-4 ${ m.role === 'user' ? 'ml-8 bg-blue-50' : 'mr-8 bg-gray-50' }`} > {m.parts.map((part, index) => part.type === 'text' ? ( <p key={`${m.id}-${index}`} className="whitespace-pre-wrap"> {part.text} </p> ) : null, )} </div> ))} </div> <form onSubmit={handleSubmit} className="flex gap-2"> <input value={input} onChange={(event) => setInput(event.target.value)} placeholder="O czym jest ta strona w kontekście SEO?" className="flex-1 rounded-lg border px-4 py-3" /> <button type="submit" disabled={isLoading} className="rounded-lg bg-blue-600 px-6 py-3 text-white disabled:opacity-50" > Zapytaj </button> </form> </div> )}
Bezpieczne automatyzacje procesów i agenci AI w n8n, Make i Claude.
RAG to architektura aplikacji, która ogranicza halucynacje modelu językowego. Zanim system odpowie na pytanie, najpierw przeszukuje wektorową bazę danych w poszukiwaniu najtrafniejszych fragmentów Twojej dokumentacji, artykułów albo FAQ, a dopiero potem przekazuje je modelowi jako kontekst. Dzięki temu LLM odpowiada na podstawie Twojej wiedzy firmowej, bez kosztownego i powolnego fine-tuningu.
RAG czy fine-tuning — co wybrać?
Jeśli Twoje dane stale się zmieniają (piszesz bloga, dodajesz FAQ, rozbudowujesz bazę wiedzy), wybierz RAG. Możesz w każdej chwili dodać lub usunąć dokument, a zmiana jest widoczna od razu. Fine-tuning zostaw na sytuacje, w których modelowi brakuje wrodzonej wiedzy — specyficznego słownictwa branżowego albo tonu marki. RAG jest tańszy, prostszy we wdrożeniu i transparentny: widzisz, z jakiego źródła pochodzi odpowiedź. W 2026 to RAG jest podstawą, a fine-tuning dodatkiem.
Na jakim stacku postawić RAG w Next.js w 2026?
Trzy poziomy. (1) Budżetowe MVP: Next.js na froncie, Vercel AI SDK, tanie OpenAI Embeddings (text-embedding-3-small) i wektorowa baza Supabase (pgvector) — koszt od zera do ~50 USD/mies. (2) Produkcja: zamieniasz bazę na Pinecone albo Upstash Vector i opcjonalnie dokładasz Cohere Rerank dla trafniejszych wyników. (3) Enterprise: wyszukiwanie hybrydowe (BM25 + wektory), własne algorytmy chunkowania, pipeline ewaluacji (np. Ragas) i monitoring (np. LangSmith). Zacznij od MVP i skaluj w miarę potrzeb.
Ile kosztuje utrzymanie RAG?
Trzy składniki. (1) Embeddingi — grosze: text-embedding-3-small kosztuje ok. 0,02 USD za milion tokenów. (2) Wektorowa baza — Supabase i Pinecone mają darmowe tiery wystarczające na start. (3) Model odpowiadający (główny koszt) — średnie użycie Claude Haiku 4.5 albo GPT-4o-mini to od ułamków centa do centa za zapytanie z kontekstem. Przy ruchu rzędu 10 tys. pytań miesięcznie zapłacisz orientacyjnie 10–100 USD.
Jakiej wielkości chunki (fragmenty tekstu) działają najlepiej?
Złoty środek to zwykle 200–800 tokenów. Krótsze (200–400) dają wyższą precyzję wyszukiwania, ale gubią szerszy kontekst. Dłuższe (do 1000) lepiej oddają wywód, ale trudniej je trafnie dopasować do zapytania. Dla dokumentacji i FAQ sprawdza się przedział 400–600 tokenów, z overlapem (nachodzeniem końcówek fragmentów) rzędu 50 tokenów. Warto eksperymentować na własnych danych.
Kiedy odświeżać i przeindeksowywać wektory w bazie?
Najlepiej po każdej istotnej zmianie treści źródłowej. Dwie taktyki. (1) Webhook — gdy zapisujesz artykuł w CMS (np. Sanity), zapis wyzwala ponowne wygenerowanie wektora przez Server Action; to najefektywniejszy tryb. (2) Pełna przebudowa (cron) — np. raz dziennie regenerujesz embeddingi dla wszystkich dokumentów; kosztuje więcej tokenów, ale ratuje przy niespójnej bazie. W praktyce webhook do bieżących zmian plus okresowy cron jako siatka bezpieczeństwa.
Czym są embeddingi i dlaczego są kluczowe w RAG?
Embedding to zakodowanie tekstu jako wektora liczb (np. 1536 wymiarów), który reprezentuje znaczenie semantyczne. Teksty o podobnym sensie mają wektory blisko siebie w przestrzeni. Dzięki temu pytanie „Jak zresetować hasło?" matematycznie zbliża się do fragmentu dokumentacji „Proces resetowania poświadczeń w firmie", nawet jeśli nie mają wspólnych słów. Zwykły Ctrl+F nigdy by tego nie znalazł — embeddingi szukają po znaczeniu, nie po dosłownym dopasowaniu.
O autorze
Maciej Sala
Maciej Sala — Product Manager i Frontend Developer z bogatym doświadczeniem w marketingu internetowym oraz SEO. Na co dzień pracuje z Reactem, Next.js i TypeScriptem, a ostatnio także z Astro i narzędziami do automatyzacji procesów AI. Sprawnie łączy perspektywę produktową z praktycznym podejściem do kodu. Przez kilka lat był związany z branżą gier wideo jako project manager i game designer. Absolwent historii na Uniwersytecie Jagiellońskim oraz studiów podyplomowych z marketingu internetowego na AGH w Krakowie. Po godzinach trenuje na siłowni, maluje figurki i rozwija własne projekty side-projecty.