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.
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
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.
# 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 --buildThe API listens on http://localhost:3000 and serves the demo UI at the same address.
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."}'curl "http://localhost:3000/search?q=how%20long%20for%20a%20refund&k=3" \
-H "X-Tenant-Id: acme"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 }
]
}| 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).
| 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. |
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-miniOllama (local, free)
OPENAI_BASE_URL=http://localhost:11434/v1
OPENAI_API_KEY=ollama
EMBEDDING_MODEL=nomic-embed-text
EMBEDDING_DIMENSIONS=768
CHAT_MODEL=llama3.1When changing
EMBEDDING_DIMENSIONS, updatevector(1536)indb/migrations/001_init.sqlto 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-instructnpm 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.
pgdriver, not an ORM. pgvector operators (<=>,::vector) andivfflatindexes are awkward through Prisma; raw parameterised SQL keeps the vector path explicit and portable. The schema lives indb/migrations/001_init.sql.- Provider behind an interface.
AiProvideris an injectable token; the concreteOpenAiProviderServiceusesfetchwith 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 theX-Tenant-Idheader by a param decorator. - Multi-stage Docker build ships only
dist/and production dependencies.
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.
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.
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).
cp .env.example .env # configura OPENAI_API_KEY (y OPENAI_BASE_URL si no usas OpenAI)
docker compose up --buildLa API queda en http://localhost:3000 y ahí mismo sirve una mini interfaz de demo para ingestar texto y chatear.
- Se usa el driver
pgcon SQL parametrizado en lugar de un ORM, porque los operadores de pgvector (<=>,::vector) y los índicesivfflatson 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.