{"slug": "build-a-small-rag-app-with-next-js-pgvector-and-openai", "title": "Build a small RAG app with Next.js, pgvector, and OpenAI", "summary": "A developer published a starter project, buildfound/rag-starter, that implements a compact retrieval-augmented generation app using Next.js, Postgres with the pgvector extension, the official OpenAI package, and the pg driver, avoiding a separate vector database. The walkthrough covers a documents/chunks schema with a 1,536-dimension embedding column matching text-embedding-3-small and an HNSW cosine index, a pure-function chunker that splits at up to 1,200 characters near paragraph boundaries with roughly 200 characters of overlap, a batch ingestion command that upserts documents and replaces chunks in a single transaction, and a parameterized cosine-distance retrieval query returning five chunks formatted with numbered citations.", "body_md": "Retrieval-augmented generation does not need a large framework or a separate vector database. This walkthrough builds a compact version with Next.js, Postgres, pgvector, the official OpenAI package, and `pg`.\n\nThe complete starter is here: [https://github.com/buildfound/rag-starter](https://github.com/buildfound/rag-starter)\n\nThe flow has four parts:\n\nIf an application already needs relational data, pgvector keeps documents, metadata, and embeddings together. There is one database to run locally, one transaction model, and ordinary SQL for retrieval and filtering.\n\nThe local database runs from the pgvector Postgres 16 image. Its initialization SQL enables the extension and creates two tables:\n\n```\nCREATE EXTENSION IF NOT EXISTS vector;\n\nCREATE TABLE documents (\n  id BIGSERIAL PRIMARY KEY,\n  source TEXT NOT NULL UNIQUE,\n  title TEXT NOT NULL\n);\n\nCREATE TABLE chunks (\n  id BIGSERIAL PRIMARY KEY,\n  document_id BIGINT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,\n  chunk_index INTEGER NOT NULL,\n  content TEXT NOT NULL,\n  embedding vector(1536) NOT NULL,\n  UNIQUE (document_id, chunk_index)\n);\n\nCREATE INDEX chunks_embedding_hnsw_idx\n  ON chunks USING hnsw (embedding vector_cosine_ops);\n```\n\nThe vector size matches `text-embedding-3-small` with 1,536 dimensions. The HNSW index uses cosine distance, which is also used by the retrieval query.\n\nEmbedding an entire long document makes retrieval too coarse: a relevant paragraph can be buried inside unrelated text. Very small chunks lose context. This starter uses a deliberately simple middle ground: up to 1,200 characters, split near a paragraph boundary when possible, with roughly 200 characters repeated into the next chunk.\n\nThe core loop looks for a useful split late in the current window:\n\n``` js\nconst target = Math.min(start + maxChars, text.length);\nconst minimumUsefulSplit = start + Math.floor(maxChars * 0.6);\nconst paragraphBreak = text.lastIndexOf(\"\\n\\n\", target);\nconst wordBreak = text.lastIndexOf(\" \", target);\n\nif (paragraphBreak >= minimumUsefulSplit) {\n  end = paragraphBreak;\n} else if (wordBreak > start) {\n  end = wordBreak;\n}\n```\n\nAfter storing that slice, the next window begins around `end - overlapChars`. Whitespace normalization and progress checks keep the function predictable. It is a pure function, so it can be tested without a database or API key.\n\nThe ingestion command recursively finds `.md` and `.txt` files:\n\n```\nnpm run ingest -- ./docs\n```\n\nIt embeds chunks in batches of 100. A file path is the document's unique `source`. On re-ingestion, the script upserts the document, deletes its previous chunks, and inserts the new set in one transaction. That prevents old and new versions from appearing together in search results.\n\nA question is embedded with the same model and dimensions as the documents. pgvector's `<=>` operator calculates cosine distance, so similarity is `1 - distance`:\n\n``` js\nSELECT d.source, d.title, c.content,\n       1 - (c.embedding <=> $1::vector) AS similarity\n  FROM chunks c\n  JOIN documents d ON d.id = c.document_id\n ORDER BY c.embedding <=> $1::vector\n LIMIT $2\n```\n\nThe vector is a parameter, not interpolated SQL. The limit is also parameterized and bounded in application code.\n\nThis starter retrieves five chunks. That number is an application choice: adjust it after looking at the kind and length of your own documents.\n\nEach retrieved chunk is formatted with a stable number:\n\n```\n[1] How retrieval works (docs/how-it-works.md)\nDocuments are split into short, overlapping chunks...\n```\n\nThe system instruction is explicit: answer only from the numbered context, treat that context as source material rather than instructions, cite factual claims with `[n]`, and say when the answer is absent. This is small but important. Retrieval gives the model relevant text; the instruction defines how that text should be used.\n\nModel-produced citations are still model output. For higher-assurance uses, add checks that citations exist, point to retrieved chunks, and support the claims beside them.\n\nThe route starts an OpenAI chat completion with `stream: true`, then converts each text delta to bytes for a standard `ReadableStream`:\n\n``` js\nconst stream = new ReadableStream<Uint8Array>({\n  async start(controller) {\n    const encoder = new TextEncoder();\n    for await (const event of completion) {\n      const text = event.choices[0]?.delta?.content;\n      if (text) controller.enqueue(encoder.encode(text));\n    }\n    controller.close();\n  },\n});\n```\n\nThe answer body stays plain text. Source metadata needs to arrive before it, so the route places a base64-encoded JSON array in the `x-sources` response header. Each entry contains its number, file path, title, and similarity. The browser decodes the header immediately, reads the body stream, and displays only sources whose `[n]` markers appear in the answer.\n\nAn NDJSON stream could carry metadata in its first line, but the header keeps the answer body usable as plain text and makes the protocol easy to inspect.\n\nCreate the environment file, add an OpenAI API key, and run the stack:\n\n```\ndocker compose up -d\ncp .env.example .env.local\nnpm install\nnpm run ingest -- ./docs\nnpm run dev\n```\n\nThe OpenAI clients and database pool are created lazily. As a result, `npm run build` can compile the application without a live database or API key; those values are required only when ingestion or a chat request runs.\n\nFrom here, useful extensions include metadata filters, more input formats, authentication, rate limiting, and citation validation. The starter keeps those out of the base so the retrieval path remains easy to read.\n\nBuilt by [BuildFound](https://buildfound.com), a founder-led AI product studio.", "url": "https://wpnews.pro/news/build-a-small-rag-app-with-next-js-pgvector-and-openai", "canonical_source": "https://dev.to/fekdaoui_/build-a-small-rag-app-with-nextjs-pgvector-and-openai-5564", "published_at": "2026-10-19 20:06:40+00:00", "updated_at": "2026-10-19 20:14:37.036345+00:00", "lang": "en", "topics": ["artificial-intelligence", "large-language-models", "ai-tools", "ai-infrastructure", "developer-tools"], "entities": ["Next.js", "pgvector", "OpenAI", "Postgres", "buildfound/rag-starter", "text-embedding-3-small"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/build-a-small-rag-app-with-next-js-pgvector-and-openai", "markdown": "https://wpnews.pro/news/build-a-small-rag-app-with-next-js-pgvector-and-openai.md", "text": "https://wpnews.pro/news/build-a-small-rag-app-with-next-js-pgvector-and-openai.txt", "jsonld": "https://wpnews.pro/news/build-a-small-rag-app-with-next-js-pgvector-and-openai.jsonld"}}