Cum construiești un sistem RAG cu Next.js și Vercel AI SDK

Construiești un sistem RAG complet pe stack-ul tău: Next.js, Vercel AI SDK și MongoDB Atlas. Cod real, pas cu pas, de la ingestion la răspuns.

Andrei Badulescu
Andrei Badulescu18 iunie 2026 · Actualizat 24 august 2026 · 9 min citit
Pipeline RAG ilustrat flat vector: documente, chunk-uri, vectori și bază de date pe fundal charcoal cu accente portocalii.

Un model lingvistic nu știe nimic despre documentele tale interne. RAG (Retrieval-Augmented Generation) rezolvă asta: cauți bucățile relevante din baza ta de cunoștințe, le pui în context și lași modelul să răspundă pe baza lor.

Multe ghiduri adaugă o bază de date vectorială separată — Pinecone, Qdrant sau pgvector. Nu e obligatoriu. Dacă folosești deja MongoDB, Atlas Vector Search ține embeddings în aceeași colecție cu datele tale: mai puțină infrastructură, un singur loc de administrat. Tot stack-ul: Next.js 16, Vercel AI SDK și MongoDB. Cod complet, pas cu pas.

Dacă nu ești sigur ce e RAG sau dacă e alegerea potrivită, citește mai întâi ce este RAG și când îl folosești.

Ce construiești (arhitectura pe scurt)

Sistemul are două fluxuri.

Ingestion (o dată, sau când adaugi documente):

  • Spargi documentul în bucăți mici (chunks).
  • Generezi un embedding pentru fiecare bucată.
  • Stochezi bucata + vectorul în MongoDB.

Query (la fiecare întrebare):

  • Generezi un embedding pentru întrebare.
  • Cauți semantic în Mongo cu $vectorSearch.
  • Pui bucățile găsite în system prompt și apelezi streamText.

Atât. Restul e cod.

Pentru vederea de ansamblu — harta completă de componente, contractele dintre ele și deciziile care se iau o singură dată — vezi arhitectura unui sistem RAG.

Diagramă flat vector cu fluxurile RAG ingestion și query, de la document la chunks, vectori, bază de date și răspuns.

Ce-ți trebuie în stack

  • MongoDB Atlas — vector search rulează doar pe Atlas (clusterul M0 gratuit merge pentru test), nu pe un MongoDB community obișnuit.
  • Cheie OpenAI — pentru embeddings și generare.
  • Pachetele AI SDK:
1npm install ai @ai-sdk/openai @ai-sdk/react
1npm install ai @ai-sdk/openai @ai-sdk/react

Mongoose îl ai deja în proiect. Folosim modelul text-embedding-3-small (1536 dimensiuni) — ieftin și suficient pentru majoritatea cazurilor.

Pasul 1 — Stochezi embeddings în MongoDB

Întâi, un model pentru bucăți. Câmpul embedding e un array de numere.

1// database/embedding.model.ts
2import { model, models, Schema } from "mongoose";
3
4export interface IEmbedding {
5 source: string; // ce document a generat această bucată
6 content: string; // textul bucății
7 embedding: number[]; // 1536 numere de la text-embedding-3-small
8}
9
10const EmbeddingSchema = new Schema<IEmbedding>(
11 {
12 source: { type: String, required: true, index: true },
13 content: { type: String, required: true },
14 embedding: { type: [Number], required: true },
15 },
16 { timestamps: true },
17);
18
19const Embedding =
20 models?.Embedding || model<IEmbedding>("Embedding", EmbeddingSchema);
21
22export default Embedding;
1// database/embedding.model.ts
2import { model, models, Schema } from "mongoose";
3
4export interface IEmbedding {
5 source: string; // ce document a generat această bucată
6 content: string; // textul bucății
7 embedding: number[]; // 1536 numere de la text-embedding-3-small
8}
9
10const EmbeddingSchema = new Schema<IEmbedding>(
11 {
12 source: { type: String, required: true, index: true },
13 content: { type: String, required: true },
14 embedding: { type: [Number], required: true },
15 },
16 { timestamps: true },
17);
18
19const Embedding =
20 models?.Embedding || model<IEmbedding>("Embedding", EmbeddingSchema);
21
22export default Embedding;

Apoi, indexul vectorial. Atenție: acesta nu se creează din Mongoose. Îl definești în Atlas (UI → Atlas Search → Create Index, tip vectorSearch), cu acest JSON:

1{
2 "fields": [
3 {
4 "type": "vector",
5 "path": "embedding",
6 "numDimensions": 1536,
7 "similarity": "cosine"
8 },
9 {
10 "type": "filter",
11 "path": "source"
12 }
13 ]
14}
1{
2 "fields": [
3 {
4 "type": "vector",
5 "path": "embedding",
6 "numDimensions": 1536,
7 "similarity": "cosine"
8 },
9 {
10 "type": "filter",
11 "path": "source"
12 }
13 ]
14}

numDimensions trebuie să fie exact 1536. Dacă schimbi modelul de embedding, schimbi și numărul aici — altfel indexul respinge vectorii. Câmpul filter pe source îți permite mai târziu să cauți doar într-un anumit document.

Pasul 2 — Ingestion: chunking și embedding

Modelul de embedding pierde calitate pe texte lungi. Deci spargi documentul în bucăți. O variantă simplă, cu overlap ca să nu tai ideile la mijloc:

1// lib/rag/chunk.ts
2export function chunkText(text: string, size = 800, overlap = 100): string[] {
3 const clean = text.replace(/s+/g, " ").trim();
4 const chunks: string[] = [];
5 for (let i = 0; i < clean.length; i += size - overlap) {
6 chunks.push(clean.slice(i, i + size));
7 }
8 return chunks;
9}
1// lib/rag/chunk.ts
2export function chunkText(text: string, size = 800, overlap = 100): string[] {
3 const clean = text.replace(/s+/g, " ").trim();
4 const chunks: string[] = [];
5 for (let i = 0; i < clean.length; i += size - overlap) {
6 chunks.push(clean.slice(i, i + size));
7 }
8 return chunks;
9}

Pentru producție, un chunking pe paragrafe sau propoziții dă rezultate mai bune. Pentru un MVP, varianta de mai sus e suficientă.

Acum generezi embeddings pentru toate bucățile dintr-un singur apel cu embedMany și le salvezi:

1// lib/rag/ingest.ts
2import { openai } from "@ai-sdk/openai";
3import { embedMany } from "ai";
4import dbConnect from "@/lib/mongoose";
5import Embedding from "@/database/embedding.model";
6import { chunkText } from "./chunk";
7
8export async function ingestDocument(source: string, raw: string) {
9 await dbConnect();
10
11 const chunks = chunkText(raw);
12
13 const { embeddings } = await embedMany({
14 model: openai.embedding("text-embedding-3-small"),
15 values: chunks,
16 });
17
18 await Embedding.insertMany(
19 chunks.map((content, i) => ({
20 source,
21 content,
22 embedding: embeddings[i],
23 })),
24 );
25
26 return chunks.length;
27}
1// lib/rag/ingest.ts
2import { openai } from "@ai-sdk/openai";
3import { embedMany } from "ai";
4import dbConnect from "@/lib/mongoose";
5import Embedding from "@/database/embedding.model";
6import { chunkText } from "./chunk";
7
8export async function ingestDocument(source: string, raw: string) {
9 await dbConnect();
10
11 const chunks = chunkText(raw);
12
13 const { embeddings } = await embedMany({
14 model: openai.embedding("text-embedding-3-small"),
15 values: chunks,
16 });
17
18 await Embedding.insertMany(
19 chunks.map((content, i) => ({
20 source,
21 content,
22 embedding: embeddings[i],
23 })),
24 );
25
26 return chunks.length;
27}

embedMany trimite toate bucățile odată și păstrează ordinea — embeddings[i] corespunde lui chunks[i].

Pasul 3 — Retrieval: cauți semantic cu $vectorSearch

La query, generezi embedding pentru întrebare cu embed, apoi rulezi un pipeline de agregare. $vectorSearch trebuie să fie prima etapă:

1// lib/rag/retrieve.ts
2import { openai } from "@ai-sdk/openai";
3import { embed } from "ai";
4import dbConnect from "@/lib/mongoose";
5import Embedding from "@/database/embedding.model";
6
7export async function retrieve(query: string, k = 5) {
8 await dbConnect();
9
10 const { embedding } = await embed({
11 model: openai.embedding("text-embedding-3-small"),
12 value: query,
13 });
14
15 return Embedding.aggregate([
16 {
17 $vectorSearch: {
18 index: "embeddings_vector_index",
19 path: "embedding",
20 queryVector: embedding,
21 numCandidates: 100,
22 limit: k,
23 },
24 },
25 {
26 $project: {
27 _id: 0,
28 content: 1,
29 source: 1,
30 score: { $meta: "vectorSearchScore" },
31 },
32 },
33 { $match: { score: { $gte: 0.5 } } },
34 ]);
35}
1// lib/rag/retrieve.ts
2import { openai } from "@ai-sdk/openai";
3import { embed } from "ai";
4import dbConnect from "@/lib/mongoose";
5import Embedding from "@/database/embedding.model";
6
7export async function retrieve(query: string, k = 5) {
8 await dbConnect();
9
10 const { embedding } = await embed({
11 model: openai.embedding("text-embedding-3-small"),
12 value: query,
13 });
14
15 return Embedding.aggregate([
16 {
17 $vectorSearch: {
18 index: "embeddings_vector_index",
19 path: "embedding",
20 queryVector: embedding,
21 numCandidates: 100,
22 limit: k,
23 },
24 },
25 {
26 $project: {
27 _id: 0,
28 content: 1,
29 source: 1,
30 score: { $meta: "vectorSearchScore" },
31 },
32 },
33 { $match: { score: { $gte: 0.5 } } },
34 ]);
35}

Câteva detalii care contează:

  • index e numele exact al indexului din Atlas. Greșești numele → zero rezultate, fără eroare.
  • numCandidates (câți vectori scanează) trebuie să fie mai mare decât limit. 100 e un punct bun de plecare.
  • vectorSearchScore merge de la 0 la 1. Filtrul $gte: 0.5 aruncă bucățile slabe, ca să nu bagi zgomot în context.

Căutare semantică ilustrată flat vector: un vector de interogare portocaliu conectat la cele mai apropiate puncte din spațiu.

Pasul 4 — Generation: streamText cu context

Endpoint-ul de chat. Extragi întrebarea, faci retrieval, pui contextul în system prompt și dai drumul la streaming:

1// app/api/chat/route.ts
2import { openai } from "@ai-sdk/openai";
3import { streamText, convertToModelMessages, type UIMessage } from "ai";
4import { retrieve } from "@/lib/rag/retrieve";
5
6export async function POST(req: Request) {
7 const { messages }: { messages: UIMessage[] } = await req.json();
8
9 const last = messages[messages.length - 1];
10 const query = last.parts
11 .filter((p) => p.type === "text")
12 .map((p) => p.text)
13 .join(" ");
14
15 const context = await retrieve(query);
16 const contextText = context.map((c) => c.content).join("n---n");
17
18 const result = streamText({
19 model: openai("gpt-4o"),
20 system: `Răspunzi strict pe baza contextului de mai jos. Dacă informația nu există în context, spui clar că nu o ai. Nu inventezi.
21
22Context:
23${contextText}`,
24 messages: convertToModelMessages(messages),
25 });
26
27 return result.toUIMessageStreamResponse();
28}
1// app/api/chat/route.ts
2import { openai } from "@ai-sdk/openai";
3import { streamText, convertToModelMessages, type UIMessage } from "ai";
4import { retrieve } from "@/lib/rag/retrieve";
5
6export async function POST(req: Request) {
7 const { messages }: { messages: UIMessage[] } = await req.json();
8
9 const last = messages[messages.length - 1];
10 const query = last.parts
11 .filter((p) => p.type === "text")
12 .map((p) => p.text)
13 .join(" ");
14
15 const context = await retrieve(query);
16 const contextText = context.map((c) => c.content).join("n---n");
17
18 const result = streamText({
19 model: openai("gpt-4o"),
20 system: `Răspunzi strict pe baza contextului de mai jos. Dacă informația nu există în context, spui clar că nu o ai. Nu inventezi.
21
22Context:
23${contextText}`,
24 messages: convertToModelMessages(messages),
25 });
26
27 return result.toUIMessageStreamResponse();
28}

Două lucruri specifice AI SDK 5:

  • convertToModelMessages transformă mesajele din format UI (cele de la useChat) în formatul pe care îl așteaptă modelul.
  • Răspunsul se întoarce cu toUIMessageStreamResponse(). Dacă ai văzut toDataStreamResponse() în tutoriale vechi — a dispărut în v5.

System prompt-ul care îi spune modelului să nu inventeze e partea cea mai importantă. Fără el, modelul completează golurile din memoria lui și pierzi tot rostul RAG-ului.

Pasul 5 — Frontend cu useChat

Componenta de client. În AI SDK 5, useChat vine din @ai-sdk/react, gestionezi tu input-ul, iar mesajele sunt array de parts:

1// app/chat/page.tsx
2"use client";
3
4import { useChat } from "@ai-sdk/react";
5import { useState } from "react";
6
7export default function Chat() {
8 const [input, setInput] = useState("");
9 const { messages, sendMessage, status } = useChat();
10
11 return (
12 <div className="mx-auto flex max-w-2xl flex-col gap-4 p-4">
13 {messages.map((m) => (
14 <div key={m.id}>
15 <strong>{m.role === "user" ? "Tu" : "Asistent"}: </strong>
16 {m.parts.map((part, i) =>
17 part.type === "text" ? <span key={i}>{part.text}</span> : null,
18 )}
19 </div>
20 ))}
21
22 <input
23 value={input}
24 onChange={(e) => setInput(e.target.value)}
25 onKeyDown={(e) => {
26 if (e.key === "Enter" && input.trim()) {
27 sendMessage({ text: input });
28 setInput("");
29 }
30 }}
31 placeholder="Întreabă ceva despre documentele tale..."
32 className="rounded-full border px-4 py-2"
33 />
34 {status === "streaming" && <p>Se generează răspunsul...</p>}
35 </div>
36 );
37}
1// app/chat/page.tsx
2"use client";
3
4import { useChat } from "@ai-sdk/react";
5import { useState } from "react";
6
7export default function Chat() {
8 const [input, setInput] = useState("");
9 const { messages, sendMessage, status } = useChat();
10
11 return (
12 <div className="mx-auto flex max-w-2xl flex-col gap-4 p-4">
13 {messages.map((m) => (
14 <div key={m.id}>
15 <strong>{m.role === "user" ? "Tu" : "Asistent"}: </strong>
16 {m.parts.map((part, i) =>
17 part.type === "text" ? <span key={i}>{part.text}</span> : null,
18 )}
19 </div>
20 ))}
21
22 <input
23 value={input}
24 onChange={(e) => setInput(e.target.value)}
25 onKeyDown={(e) => {
26 if (e.key === "Enter" && input.trim()) {
27 sendMessage({ text: input });
28 setInput("");
29 }
30 }}
31 placeholder="Întreabă ceva despre documentele tale..."
32 className="rounded-full border px-4 py-2"
33 />
34 {status === "streaming" && <p>Se generează răspunsul...</p>}
35 </div>
36 );
37}

sendMessage({ text: input }) trimite întrebarea către /api/chat. Răspunsul curge înapoi token cu token. Gata — ai un sistem RAG funcțional.

Capcane de producție

MVP-ul de mai sus merge. Înainte să-l pui în fața clienților, ai grijă la:

  • Indexul nu e gata instant. După ce-l creezi în Atlas, durează câteva secunde până se construiește. Până atunci, $vectorSearch întoarce zero rezultate. Verifică statusul în Atlas.
  • Nepotrivirea de dimensiuni. text-embedding-3-small are 1536, text-embedding-3-large are 3072. Dacă schimbi modelul fără să refaci indexul și să re-embeddezi tot, totul se rupe în tăcere.
  • Costul la ingestion. Re-embeddarea unei baze mari de documente costă. Salvează vectorii o dată; nu-i regenera la fiecare deployment.
  • Mărimea chunk-ului. Bucăți prea mari diluează relevanța, prea mici pierd contextul. 500–1000 de caractere e un interval rezonabil de la care pornești și măsori.
  • Filtrarea pe sursă. Câmpul filter din index îți permite să restrângi căutarea la un singur document sau client. Esențial pentru aplicații multi-tenant.
  • Evaluarea. Fără un set de întrebări de test, nu știi dacă o schimbare a îmbunătățit sau a stricat retrieval-ul. Măsoară, nu ghici.

Pentru când abordarea asta nu e suficientă, compară cu alternativa în RAG vs fine-tuning. Și dacă vrei tot ecosistemul de implementare, ai mai multe articole în categoria Implementare & Stack Tehnic.

Întrebări frecvente

Pot folosi MongoDB local, fără Atlas?

Nu pentru $vectorSearch. Vector search e o funcție Atlas. Poți rula un deployment Atlas local prin Atlas CLI pentru dezvoltare, dar nu merge pe un MongoDB community obișnuit.

Ce model de embedding aleg?

text-embedding-3-small (1536 dimensiuni) e ieftin și bun pentru majoritatea cazurilor. Treci la text-embedding-3-large doar dacă măsori o nevoie reală de acuratețe mai mare — și nu uita să schimbi numDimensions în index.

Câte rezultate să returnez din retrieval?

Începe cu 5. Prea puține și modelul n-are context; prea multe și umpli fereastra de context cu zgomot și plătești mai mulți tokeni. Ajustează în funcție de mărimea chunk-urilor.

De ce primesc zero rezultate de la $vectorSearch?

Trei cauze frecvente: numele indexului e greșit, indexul încă se construiește, sau numDimensions nu se potrivește cu vectorii salvați. Verifică-le în ordinea asta.

Funcționează cu alt model decât OpenAI?

Da. Vercel AI SDK e agnostic de provider. Schimbi openai(...) cu anthropic(...) sau alt adapter pentru generare. Pentru embeddings, asigură-te că numDimensions din index se potrivește cu modelul ales.

Cât costă să rulezi un astfel de sistem?

Depinde de volumul de documente și de trafic. Costurile principale sunt embeddings (o dată, la ingestion) și apelurile de generare (la fiecare întrebare). Detaliem bugetul în cât costă un sistem RAG.

Pornește

Ai acum un sistem RAG complet pe stack-ul tău, fără infrastructură vectorială separată. Construim astfel de sisteme end-to-end — cu evaluare, filtrare pe surse și deployment în producție — ca parte din serviciul de Integrări AI & RAG. Hai să vorbim despre proiectul tău.

Distribuie
Andrei Badulescu
Despre autor

Andrei Badulescu

Fondator & Software Architect

Construiește sisteme B2B la BaseTech — ERP la comandă, platforme SaaS, agenți AI și arhitecturi programmatic SEO. Scrie despre deciziile tehnice din spatele lor: stack, trade-off-uri și ce ține la scară.

Vezi profilul autorului →
Continuă
Newsletter

Insights pentru companii
care construiesc

Articole noi despre ERP, AI, agenți și pSEO, direct pe email. Fără spam.