Tenant-scoped retrieval-augmented generation API built with NestJS, Postgres + pgvector, and local Ollama models. Documents stay private per tenant; embeddings and answers never leave your machine.
- Ingest documents for a tenant — chunk text, embed with a local model, store vectors in Postgres.
- Ask questions scoped to that tenant — similarity search over chunks, then answer only from retrieved context.
- Delete documents (and their chunks) by id + tenant.
No cloud LLM is required. Ollama runs embedding and chat locally.
Client
│
├─ POST /documents ──► DocumentsService ──► chunkText
│ │ │
│ ├─ Ollama.embed ─────┘
│ └─ Postgres (rag_documents + rag_chunks)
│
└─ POST /rag/ask ───► RagService
├─ Ollama.embed (question)
├─ pgvector similarity search (tenant-filtered)
└─ Ollama.chat (grounded answer)
| Module | Responsibility |
|---|---|
documents |
Ingest and delete documents |
rag |
Question answering with retrieval |
database |
Postgres pool, schema bootstrap, queries/transactions |
ollama |
Local embed + chat HTTP client |
common |
Text chunking, vector formatting, HTTP request logging |
- Node.js 20+
- Docker (for Postgres + pgvector)
- Ollama with the embed and chat models pulled
ollama pull embeddinggemma
ollama pull gemma3:4b# Start Postgres with pgvector
docker compose up -d
# Install dependencies
npm install
# Configure environment (repo includes a sample .env)
# DATABASE_URL, OLLAMA_*, RAG_* — see Environment below
# Run in watch mode
npm run start:dev- API:
http://localhost:3000 - Swagger:
http://localhost:3000/docs
| Variable | Default | Description |
|---|---|---|
PORT |
3000 |
HTTP port |
DATABASE_URL |
— | Postgres connection string (required) |
OLLAMA_BASE_URL |
http://localhost:11434 |
Ollama server |
OLLAMA_EMBED_MODEL |
embeddinggemma |
Embedding model |
OLLAMA_CHAT_MODEL |
gemma3:4b |
Chat model |
RAG_CHUNK_SIZE |
900 |
Characters per chunk |
RAG_CHUNK_OVERLAP |
150 |
Overlap between chunks |
RAG_TOP_K |
5 |
Max chunks retrieved |
RAG_MIN_SCORE |
0.30 |
Minimum cosine similarity to keep |
Example .env:
PORT=3000
DATABASE_URL=postgresql://rag:rag_password@localhost:5432/rag
OLLAMA_BASE_URL=http://localhost:11434
OLLAMA_EMBED_MODEL=embeddinggemma
OLLAMA_CHAT_MODEL=gemma3:4b
RAG_CHUNK_SIZE=900
RAG_CHUNK_OVERLAP=150
RAG_TOP_K=5
RAG_MIN_SCORE=0.30Ingest a document for a tenant.
{
"tenantId": "acme",
"title": "Employee Handbook",
"source": "handbook.pdf",
"content": "Our remote work policy allows employees to work from home...",
"metadata": { "department": "HR" }
}Flow
- Normalize and split
contentwith overlapping windows (chunkText). - Embed all chunks via Ollama.
- In a DB transaction, insert
rag_documentsthen eachrag_chunksrow with its embedding. - Return
documentIdandchunksCreated.
Delete a document belonging to a tenant. Chunks are removed by ON DELETE CASCADE. Both id and tenant must match (tenant isolation).
Ask a question against a tenant’s indexed documents.
{
"tenantId": "acme",
"question": "What is the remote work policy?",
"topK": 5
}Flow
-
Embed the question.
-
Similarity search with pgvector cosine distance, filtered by
tenantId:1 - (c.embedding <=> $question::vector) AS score WHERE c.tenant_id = $tenant ORDER BY c.embedding <=> $question::vector LIMIT $topK
-
Drop chunks below
RAG_MIN_SCORE. -
If none remain → return an ungrounded “not enough information” response (no chat call).
-
Otherwise build numbered
[SOURCE n]context, call the chat model with a system prompt that forbids outside knowledge and treating retrieved text as instructions, then return{ answer, grounded, sources }.
- Global validation pipe: strip unknown fields, reject extras, transform types.
- Swagger UI at
/docs. - Global
HttpLoggingInterceptorlogs method, path, status, and duration for every request.
On module init:
- Enables
vectorandpgcryptoextensions. - Creates
rag_documentsandrag_chunks(chunks FK to documents with cascade delete). - Adds tenant / document indexes.
query() and transaction() log truncated SQL, param counts, row counts, and timings. Embedding values are not dumped into logs.
Collapses whitespace, then sliding-window splits. Overlap keeps context across chunk boundaries so retrieval is less likely to miss mid-sentence ideas.
toPgVector formats number[] as a Postgres vector literal [0.1,0.2,...] and rejects empty or non-finite values.
embed(inputs)→POST /api/embedchat(system, user)→POST /api/chatwith low temperature (0.1) for grounded answers
Connection / HTTP failures map to Nest BadGatewayException; malformed responses to InternalServerErrorException.
| Layer | What gets logged |
|---|---|
| HTTP interceptor | Request in / response out with status + duration |
| Controllers | Tenant, document id, high-level intent |
| Documents / RAG services | Ingest steps, retrieval match counts, grounded vs ungrounded |
| Database | Query/tx start, success, failure, commit/rollback |
src/
app.module.ts
main.ts
common/
chunk-text.ts
http-logging.interceptor.ts
vector.ts
database/
database.module.ts
database.service.ts
documents/
documents.controller.ts
documents.module.ts
documents.service.ts
dto/ingest-document.dto.ts
ollama/
ollama.module.ts
ollama.service.ts
rag/
rag.controller.ts
rag.module.ts
rag.service.ts
dto/ask-rag.dto.ts
npm run start:dev # watch mode
npm run start:prod # run compiled dist/
npm run build # nest build
npm run lint # eslint
npm test # unit testsdocker-compose.yml runs pgvector/pgvector:pg17 with:
- DB / user:
rag - Password:
rag_password - Port:
5432 - Named volume:
rag_pg_data