All work

AI AutomationCase study

Private AI Chat for Company Documents: A RAG Deployment

I deployed and customised a self-hosted RAG chat that searches a company's documents and databases first, then answers only from what it found, with the source shown.

Role
Deployment, customisation and operations; demo video
Client
Real estate developer (confidential)
Year
2026
Status
Live, loaded with demo data
  • RAG
  • FastAPI
  • ChromaDB
  • Ollama
  • Redis
  • ARQ
  • Docker Compose
  • Nginx
  • Let's Encrypt
  • Google Cloud
  • OpenRouter
  • DeepSeek
  • SQLite
  • Tailwind CSS
Demo video: private AI chat for company documents

The goal

A real estate developer wanted people to ask questions in plain English and get answers from the company's own data: sales leads, customer queries, finance records, inventory and engineering documents. Every answer had to show where it came from, and the data had to stay on a server the company controls.

The system is built on the EuroRAG starter kit (I compare it with RAGFlow in Part 1 of my Production RAG series). My job was to turn that starter kit into a working, branded deployment: server, data sources, tuning and the problems that only show up once real data goes in.

demo documents indexed (PDF, DOCX, TXT)
33
demo database rows across 14 tables
748
Docker services behind one reverse proxy
5
fixes to the starter kit's connectors
3

How it answers

The AI does not answer from memory. A question is turned into numbers, the vector database finds the most similar pieces of the company's documents, and only those pieces go to the language model with the question. The answer streams back with the source documents listed.

Browser → Nginx (HTTPS) → App (FastAPI)
                            │  question → embedding (Ollama) → search (ChromaDB)
                            │  top matching chunks + question → chat model
                            ▼
                         answer + sources

Worker (ARQ) ← Redis queue: reads documents, splits them, creates embeddings, stores them

The search combines meaning-based and keyword search, and the chat runs in a strict mode: it answers only from the retrieved text, and says it has no information when the documents do not cover the question. That is the same idea I explain in Part 4 of the series.

The stack, and why

Everything runs as Docker containers on one Google Cloud server, with only the reverse proxy open to the internet. The app, the background worker, the vector database, the embedding model and the queue each have their own container, and they restart on their own after a reboot.

Connecting the data

Three kinds of source, all from the admin screen:

Making it the client's own

I did not edit the starter kit's templates. It loads a custom folder first, so the login page, the navigation, the logo, fonts and colours all live in one folder outside the core code. The core pages use a default blue for every button and focus ring, so I remapped that one colour scale in the base template instead of changing eight core files. Every page extends that template, so the whole app took on the brand colours at once, and future updates of the starter kit will pick them up too.

Problems I hit and fixed

Database rows were rejected as unsupported files

Symptom
Every database row showed as Failed with an 'unsupported file type' error.
Root cause
Each row was saved as a document with no file extension, and the document processor refuses a blank one.
Gave each row document a .txt name. Old failed rows stayed in the list, so I told the client they were safe to delete.

A local database was blocked by the EU-only check

Symptom
The SQLite source was refused when the strict EU setting was on.
Root cause
The check looked for an EU domain in the connection string, and a local file has no domain at all.
Treated a local SQLite file as compliant, since its data never leaves the server.

The crawler crashed on its own depth setting

Symptom
A crawl failed with a type error as soon as it started.
Root cause
The admin screen saved the maximum depth as text, and the crawler compared it with a number.
Converted the depth and page limit to numbers when the crawler reads them.

The worker showed as unhealthy while working fine

Symptom
The worker container was marked unhealthy in the status list even though it was processing jobs.
Root cause
The default health check calls a web address, and the worker is a background job runner with no web server.
Replaced the check with a simple test that the main process is alive, in an override file that has to be recreated on a fresh server.

Per-user document privacy had no effect

Symptom
After setting documents to be visible per user, a new account's chat still returned another account's documents as sources.
Root cause
A value in the settings file only reaches a container if the compose file lists it, and this one was missing for both the app and the worker. The app silently used its default, which searches everyone's documents.
Added the variable to both services, then checked it from inside the running container.

Tuning the answers

Two limits I documented for the client instead of hiding them:

What I did

The ideas behind this project are in my free Production RAG video series, and I build assistants on real data in Milo, a real estate assistant and self-hosted AI agents for a team chat.

Want your documents to answer questions?

If your company's knowledge is spread across folders, files, databases and websites, I can set up a private chat like this, with the sources shown for every answer. Tell me about your documents on the contact page.