Jak zbudować aplikację RAG w Next.js z lokalnym modelem AI
Praktyczny przewodnik po budowie prywatnej aplikacji RAG w Next.js: od podziału dokumentów i embeddingów po wyszukiwanie kontekstu i odpowiedź lokalnego modelu.

RAG, czyli generowanie wspomagane wyszukiwaniem, pozwala modelowi językowemu odpowiadać na podstawie wskazanych dokumentów zamiast polegać wyłącznie na wiedzy zapisanej podczas treningu. W praktyce aplikacja najpierw odnajduje fragmenty materiałów związane z pytaniem, a dopiero potem przekazuje je modelowi jako kontekst.
W tym poradniku zbudujemy prosty, lokalny przepływ RAG w Next.js. Dokumenty, embeddingi i generowanie odpowiedzi mogą pozostać na Twoim komputerze. Nie oznacza to automatycznie bezpieczeństwa klasy korporacyjnej, ale daje kontrolę nad tym, gdzie trafiają dane i z jakich komponentów korzysta aplikacja.
Co zbudujemy
Minimalna aplikacja będzie wykonywać pięć kroków:
- pobierze przygotowane fragmenty dokumentów,
- utworzy embedding pytania,
- porówna go z embeddingami dokumentów,
- wybierze najbardziej zbliżone fragmenty,
- wyśle pytanie i znaleziony kontekst do lokalnego modelu.
Całość można później rozwinąć o przesyłanie plików, bazę wektorową, autoryzację, cytowanie źródeł i ocenę jakości odpowiedzi.
Czego potrzebujesz
- projektu Next.js korzystającego z App Routera,
- Node.js 20 lub nowszego,
- zainstalowanej Ollamy,
- modelu do generowania odpowiedzi,
- modelu embeddingowego, na przykład
embeddinggemma.
Ollama udostępnia lokalne API, dlatego Next.js może komunikować się z modelami tak samo jak z inną usługą HTTP. Oficjalna dokumentacja Ollamy rekomenduje do embeddingów między innymi embeddinggemma, qwen3-embedding i all-minilm.
Przykładowe modele pobierzesz poleceniami:
ollama pull gemma3:4b
ollama pull embeddinggemmaMniejszy model będzie łatwiejszy do uruchomienia na laptopie. Większy zwykle poprawi jakość odpowiedzi, ale potrzebuje więcej RAM-u lub VRAM-u.
Jak działa architektura RAG
RAG składa się z dwóch osobnych procesów.
Indeksowanie wykonujesz po dodaniu lub zmianie dokumentów. Tekst jest czyszczony, dzielony na fragmenty, zamieniany na embeddingi i zapisywany wraz z treścią oraz metadanymi.
Wyszukiwanie i generowanie odbywa się po pytaniu użytkownika. Pytanie również zamieniasz na embedding, porównujesz z indeksem, a najlepsze fragmenty dołączasz do promptu.
Rozdzielenie tych procesów jest ważne. Nie należy tworzyć embeddingów wszystkich dokumentów przy każdym pytaniu, ponieważ niepotrzebnie zwiększa to czas odpowiedzi.
Krok 1: przygotuj dokumenty
Na potrzeby prototypu użyjemy tablicy krótkich fragmentów. W prawdziwym projekcie mogą pochodzić z instrukcji, artykułów, bazy wiedzy albo plików PDF.
type KnowledgeChunk = {
id: string;
source: string;
content: string;
embedding?: number[];
};
const documents: KnowledgeChunk[] = [
{
id: "returns",
source: "polityka-zwrotow.md",
content: "Klient może zwrócić produkt w ciągu 30 dni od dostawy.",
},
{
id: "shipping",
source: "dostawa.md",
content: "Standardowa dostawa na terenie Polski trwa od 2 do 4 dni roboczych.",
},
];Każdy fragment powinien mieć stabilny identyfikator i informację o źródle. Dzięki temu aplikacja może później wskazać użytkownikowi, skąd pochodzi odpowiedź.
Krok 2: wybierz rozsądny chunking
Nie istnieje jeden idealny rozmiar fragmentu. Zbyt małe części tracą kontekst, a zbyt duże utrudniają trafne wyszukiwanie i zajmują więcej okna kontekstowego.
Dla dokumentacji tekstowej dobrym punktem startowym jest:
- 300–600 tokenów na fragment,
- 10–20% nakładania się sąsiednich fragmentów,
- dzielenie najpierw po nagłówkach i akapitach,
- zachowanie tytułu dokumentu w metadanych.
Nie tnij tekstu mechanicznie w połowie zdania. Struktura dokumentu zwykle zawiera więcej informacji niż sam limit znaków.
Krok 3: utwórz embeddingi
Embedding jest liczbową reprezentacją znaczenia tekstu. Podobne znaczeniowo pytania i fragmenty powinny znajdować się blisko siebie w przestrzeni wektorowej.
async function createEmbedding(input: string): Promise<number[]> {
const response = await fetch("http://127.0.0.1:11434/api/embed", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: "embeddinggemma",
input,
}),
});
if (!response.ok) {
throw new Error(`Embedding request failed: ${response.status}`);
}
const data = (await response.json()) as { embeddings: number[][] };
return data.embeddings[0] ?? [];
}Podczas indeksowania wywołaj tę funkcję dla każdego fragmentu i zapisz wynik. Dokumentacja Ollama Embeddings opisuje endpoint oraz modele przeznaczone do wyszukiwania semantycznego.
Krok 4: wyszukaj najbardziej podobne fragmenty
W małym prototypie wystarczy podobieństwo cosinusowe. Przy tysiącach lub milionach fragmentów lepiej użyć bazy wektorowej z indeksowaniem przybliżonym.
function cosineSimilarity(a: number[], b: number[]): number {
if (a.length !== b.length || a.length === 0) return 0;
let dot = 0;
let normA = 0;
let normB = 0;
for (let i = 0; i < a.length; i += 1) {
dot += a[i]! * b[i]!;
normA += a[i]! ** 2;
normB += b[i]! ** 2;
}
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
}
function retrieve(
queryEmbedding: number[],
chunks: Required<KnowledgeChunk>[],
limit = 3,
) {
return chunks
.map((chunk) => ({
...chunk,
score: cosineSimilarity(queryEmbedding, chunk.embedding),
}))
.sort((a, b) => b.score - a.score)
.slice(0, limit);
}Sam wynik podobieństwa nie gwarantuje trafności. W produkcji warto ustalić minimalny próg, testować różne wartości limit i mierzyć, czy potrzebny fragment rzeczywiście znalazł się wśród zwróconych wyników.
Krok 5: utwórz Route Handler w Next.js
W App Routerze żądanie możesz obsłużyć w pliku app/api/ask/route.ts. Next.js opisuje Route Handlers jako mechanizm do tworzenia własnych endpointów z użyciem standardowych interfejsów Request i Response.
export async function POST(request: Request) {
const { question } = (await request.json()) as { question?: string };
if (!question || question.length > 1_000) {
return Response.json({ error: "Invalid question" }, { status: 400 });
}
const queryEmbedding = await createEmbedding(question);
const matches = retrieve(queryEmbedding, indexedDocuments, 3);
const context = matches
.map((item, index) => `[${index + 1}] ${item.content}`)
.join("\n\n");
const response = await fetch("http://127.0.0.1:11434/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: "gemma3:4b",
stream: false,
messages: [
{
role: "system",
content:
"Odpowiadaj wyłącznie na podstawie przekazanego kontekstu. " +
"Jeśli kontekst nie zawiera odpowiedzi, powiedz, że nie wiesz.",
},
{
role: "user",
content: `Kontekst:\n${context}\n\nPytanie: ${question}`,
},
],
}),
});
if (!response.ok) {
return Response.json({ error: "Model unavailable" }, { status: 503 });
}
const result = await response.json();
return Response.json({
answer: result.message?.content ?? "",
sources: matches.map(({ source, score }) => ({ source, score })),
});
}To przykład edukacyjny, a nie kompletny endpoint produkcyjny. Trzeba jeszcze dodać limit wielkości żądania, rate limiting, kontrolę dostępu, timeouty i obsługę błędów.
Jak ograniczyć halucynacje
RAG nie usuwa halucynacji automatycznie. Model nadal może zignorować dokument albo połączyć poprawny fragment z błędnym wnioskiem.
Najważniejsze zabezpieczenia to:
- instrukcja pozwalająca modelowi powiedzieć „nie wiem”,
- odrzucanie wyników poniżej ustalonego progu podobieństwa,
- zwracanie nazw źródeł razem z odpowiedzią,
- testy pytań, na które baza nie zawiera odpowiedzi,
- oddzielenie treści dokumentów od instrukcji systemowych,
- ochrona przed prompt injection zapisanym w dokumentach.
Warto oceniać osobno jakość wyszukiwania i jakość odpowiedzi. Jeśli właściwy fragment nie został odnaleziony, zmiana modelu generującego niewiele pomoże.
Kontekst, pamięć i wydajność
W aplikacji lokalnej limit kontekstu ma bezpośredni wpływ na pamięć. Według dokumentacji Ollamy większe okno kontekstowe zwiększa wymagania pamięciowe, a narzędzia agentowe i programistyczne mogą potrzebować znacznie dłuższego kontekstu niż prosty czat.
Nie przekazuj modelowi wszystkich znalezionych dokumentów „na wszelki wypadek”. Lepsze rezultaty często daje kilka trafnych fragmentów niż kilkadziesiąt luźno związanych.
Kiedy dodać bazę wektorową
Tablica w pamięci wystarczy do nauki i małego demo. Baza wektorowa zaczyna mieć sens, gdy:
- dokumentów jest więcej niż można wygodnie przechowywać w pamięci procesu,
- indeks musi przetrwać restart aplikacji,
- potrzebujesz filtrowania po użytkowniku, dacie lub typie dokumentu,
- aktualizacje mają odbywać się bez przebudowania całego indeksu,
- aplikacja działa na kilku instancjach.
Wybór konkretnej bazy jest mniej ważny niż spójność embeddingów. Dokumenty i pytania muszą być kodowane tym samym modelem oraz zgodną wersją konfiguracji.
Checklista przed wdrożeniem
- Dokumenty mają źródło, identyfikator i datę aktualizacji.
- Chunking respektuje nagłówki i akapity.
- Embeddingi dokumentów nie są liczone przy każdym pytaniu.
- Endpoint sprawdza typ i długość danych wejściowych.
- Użytkownik widzi źródła wykorzystane w odpowiedzi.
- Aplikacja potrafi odmówić odpowiedzi przy słabym kontekście.
- Dane różnych użytkowników są od siebie odseparowane.
- Zestaw testowy obejmuje pytania trafne, niejednoznaczne i bez odpowiedzi.
Czy lokalny RAG zawsze jest najlepszy
Nie. Lokalny model daje większą kontrolę nad danymi i kosztami pojedynczego zapytania, ale przenosi na Ciebie odpowiedzialność za sprzęt, aktualizacje, bezpieczeństwo oraz obserwowalność.
Najlepszym pierwszym krokiem jest mały, mierzalny prototyp. Zbuduj kilkadziesiąt pytań testowych, sprawdź trafność wyszukiwania i dopiero później wybieraj większy model lub bardziej rozbudowaną bazę. W RAG jakość dokumentów i procesu wyszukiwania zwykle ma większe znaczenie niż efektowny interfejs czatu.


