Retrieval-Augmented Generation (RAG) to architektura aplikacji AI, w której LLM dostaje kontekst pobrany z zewnętrznej bazy wiedzy (wektorowej) zamiast polegać tylko na wiedzy z pretrainingu. Ogranicza halucynacje, pozwala dodać aktualne i prywatne dane bez fine-tuningu. ł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 Embeddingi to wektory liczbowe reprezentujące znaczenie semantyczne tekstu — teksty o podobnym znaczeniu mają wektory blisko siebie w przestrzeni wielowymiarowej. (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 (API 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.
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.