Skip to content

Repository files navigation

RAG Postgres Starter

Node NestJS PostgreSQL License: MIT

A production-shaped starter kit for multi-tenant Retrieval-Augmented Generation built with NestJS and PostgreSQL/pgvector. Ingest text, embed it, run cosine similarity search, and chat over your own documents with cited sources. It is provider-agnostic: point it at any OpenAI-compatible endpoint (OpenAI, Ollama, NVIDIA NIM, vLLM) by changing two environment variables.

Architecture

flowchart LR
    UI[Demo UI / API client] -->|X-Tenant-Id| API[NestJS API]

    subgraph API[NestJS API]
        DOC[Documents module]
        SR[Search module]
        CH[Chat module]
        EMB[AI provider service]
    end

    DOC -->|chunk + embed| EMB
    SR -->|embed query| EMB
    CH -->|retrieve top-k| SR
    CH -->|answer + sources| EMB

    EMB -->|/embeddings, /chat/completions| LLM[(OpenAI-compatible endpoint)]
    DOC -->|insert chunks + vectors| PG[(PostgreSQL + pgvector)]
    SR -->|cosine search| PG
Loading

Documents are chunked with configurable size/overlap, each chunk is embedded and stored as a vector column, and every row is scoped by tenant_id. Search embeds the query and ranks chunks by cosine distance using an ivfflat index. Chat retrieves the top-k chunks, injects them into the system prompt, and returns the model's answer alongside the source chunks it was given.

Quick Start

# 1. Configure your provider
cp .env.example .env
# edit .env: set OPENAI_API_KEY (and OPENAI_BASE_URL if not using OpenAI)

# 2. Bring up Postgres (pgvector) + the API
#    The migration in db/migrations runs automatically on first boot.
docker compose up --build

The API listens on http://localhost:3000 and serves the demo UI at the same address.

Ingest a document

curl -X POST http://localhost:3000/documents \
  -H "Content-Type: application/json" \
  -H "X-Tenant-Id: acme" \
  -d '{"title":"Refund Policy","content":"Refunds are issued within 14 days of purchase. Digital goods are non-refundable once downloaded."}'

Semantic search

curl "http://localhost:3000/search?q=how%20long%20for%20a%20refund&k=3" \
  -H "X-Tenant-Id: acme"

Chat with cited sources

curl -X POST http://localhost:3000/chat \
  -H "Content-Type: application/json" \
  -H "X-Tenant-Id: acme" \
  -d '{"message":"How long do I have to request a refund?"}'

Response shape:

{
  "answer": "You have 14 days from purchase to request a refund [Refund Policy].",
  "sources": [
    { "documentId": "", "documentTitle": "Refund Policy", "content": "Refunds are issued…", "score": 0.89 }
  ]
}

API

Method Path Description
POST /documents Ingest text: chunk, embed, store. Returns chunk count.
GET /documents List documents for the tenant with chunk counts.
DELETE /documents/:id Delete a document and its chunks (cascade).
GET /search?q=&k= Top-k semantic search (cosine), scoped by tenant.
POST /chat Retrieve context and answer with cited sources.
GET /health Liveness probe.

All routes read the tenant from the X-Tenant-Id header (defaults to default).

Environment variables

Variable Default Description
PORT 3000 HTTP port.
DATABASE_URL PostgreSQL connection string (required).
OPENAI_BASE_URL https://api.openai.com/v1 Base URL of the OpenAI-compatible endpoint.
OPENAI_API_KEY API key / bearer token for the endpoint (required).
EMBEDDING_MODEL text-embedding-3-small Model used to embed documents and queries.
EMBEDDING_DIMENSIONS 1536 Vector size; must match the model and the vector(N) column.
CHAT_MODEL gpt-4o-mini Model used to generate answers.
CHUNK_SIZE 1000 Chunk length in characters.
CHUNK_OVERLAP 150 Overlap between consecutive chunks, in characters.

Works with any OpenAI-compatible provider

Only OPENAI_BASE_URL, OPENAI_API_KEY, and the model names change.

OpenAI

OPENAI_BASE_URL=https://api.openai.com/v1
OPENAI_API_KEY=sk-...
EMBEDDING_MODEL=text-embedding-3-small
EMBEDDING_DIMENSIONS=1536
CHAT_MODEL=gpt-4o-mini

Ollama (local, free)

OPENAI_BASE_URL=http://localhost:11434/v1
OPENAI_API_KEY=ollama
EMBEDDING_MODEL=nomic-embed-text
EMBEDDING_DIMENSIONS=768
CHAT_MODEL=llama3.1

When changing EMBEDDING_DIMENSIONS, update vector(1536) in db/migrations/001_init.sql to match before first boot.

NVIDIA NIM

OPENAI_BASE_URL=https://integrate.api.nvidia.com/v1
OPENAI_API_KEY=nvapi-...
EMBEDDING_MODEL=nvidia/nv-embedqa-e5-v5
EMBEDDING_DIMENSIONS=1024
CHAT_MODEL=meta/llama-3.1-70b-instruct

Local development

npm install
npm test          # unit tests, no database required
npm run build     # compile to dist/
npm run start:dev # watch mode (needs Postgres + provider configured)

The unit tests cover the chunker, the prompt builder, and the chat flow with a mocked provider, so they run without a database or network.

Design notes

  • pg driver, not an ORM. pgvector operators (<=>, ::vector) and ivfflat indexes are awkward through Prisma; raw parameterised SQL keeps the vector path explicit and portable. The schema lives in db/migrations/001_init.sql.
  • Provider behind an interface. AiProvider is an injectable token; the concrete OpenAiProviderService uses fetch with no SDK, so swapping backends is a config change and tests mock the interface.
  • Tenant isolation is enforced in every query via tenant_id, resolved from the X-Tenant-Id header by a param decorator.
  • Multi-stage Docker build ships only dist/ and production dependencies.

🇪🇸 Documentación en español

RAG Postgres Starter es un kit de inicio, con forma de producto real, para generación aumentada por recuperación (RAG) multi-tenant, construido con NestJS y PostgreSQL/pgvector. Permite ingestar texto, generar sus embeddings, hacer búsqueda semántica por similitud coseno y conversar sobre tus propios documentos con las fuentes citadas.

Es agnóstico del proveedor: funciona con cualquier endpoint compatible con la API de OpenAI (OpenAI, Ollama, NVIDIA NIM, vLLM) cambiando solo dos variables de entorno.

Cómo funciona

Cada documento se divide en fragmentos (chunks) con tamaño y solape configurables; cada fragmento se convierte en un embedding y se guarda en una columna vector de pgvector, siempre asociado a un tenant_id. La búsqueda genera el embedding de la consulta y ordena los fragmentos por distancia coseno usando un índice ivfflat. El chat recupera los k fragmentos más relevantes, los inyecta en el prompt del sistema y devuelve la respuesta del modelo junto con las fuentes usadas.

Endpoints

  • POST /documents — ingesta texto: lo fragmenta, genera embeddings y lo almacena.
  • GET /documents — lista los documentos del tenant.
  • DELETE /documents/:id — elimina un documento y sus fragmentos (en cascada).
  • GET /search?q=...&k=5 — búsqueda semántica top-k por similitud coseno.
  • POST /chat — recupera contexto y responde citando las fuentes.

El tenant se toma del header X-Tenant-Id (por defecto default).

Arranque rápido

cp .env.example .env     # configura OPENAI_API_KEY (y OPENAI_BASE_URL si no usas OpenAI)
docker compose up --build

La API queda en http://localhost:3000 y ahí mismo sirve una mini interfaz de demo para ingestar texto y chatear.

Decisiones de diseño

  • Se usa el driver pg con SQL parametrizado en lugar de un ORM, porque los operadores de pgvector (<=>, ::vector) y los índices ivfflat son incómodos con Prisma.
  • El proveedor de IA está detrás de una interfaz inyectable (AiProvider), de modo que cambiar de backend es solo configuración y los tests lo mockean.
  • El aislamiento entre tenants se aplica en cada consulta mediante tenant_id.
  • Los tests unitarios (chunker, armado del prompt y flujo de chat con proveedor mockeado) corren sin base de datos.

Autor: Marlon Duvan Morales Arboleda — github.com/duvan096 · woltftech.com

Licencia MIT.

About

Multi-tenant RAG starter kit — NestJS + PostgreSQL/pgvector, works with any OpenAI-compatible provider (OpenAI, Ollama, NVIDIA NIM)

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages