I built this out of frustration with German paperwork. Living in Switzerland means a new German document lands on you almost every day, an invoice, a letter from the Amt, a rental contract, and I got tired of translating them one at a time. What I wanted was simple. Drop in the German document, ask a question in English, get an English answer back, and have that answer point to exactly where in the document it came from. I also wanted all of it to stay on my own machine, since uploading a rental contract or an invoice to someone else's cloud was never an option.
The result is a fully local RAG assistant. Both the embedding and the answer generation run on a local Ollama instance, the vectors live inside Postgres, and nothing ever leaves the machine.
A document is uploaded through the API and a Celery job runs in the background. The job extracts the text, splits it into passages, embeds each passage with a multilingual model, and writes the vectors into pgvector. When a question comes in, it is embedded as well, pgvector finds the closest German passages by cosine distance over an HNSW index, and those passages are passed to the chat model as context. The model reads the German context and answers in English, attributing each part of the answer to the document it came from.
The cross-lingual part is what makes this work. Because the embedding model is multilingual, a German passage and an English question land close together in the same vector space, so retrieval crosses the language barrier without any translation step in between.
Text extraction is handled by Apache Tika, which covers far more than PDFs. It also reads Word, PowerPoint, Excel and scanned images. For scanned documents Tika runs OCR in German, so even a letter from the Amt that was signed by hand and scanned still gets read.
The backend is Django 6 with Django REST Framework, serving JSON only. Settings are class based through django-configurations, with defaults in the code and secrets injected from the environment. Celery and Redis run the ingest jobs. Vectors are stored in Postgres 18 with pgvector. Apache Tika runs as its own service for text extraction. LangChain wires up retrieval and generation against Ollama.
The frontend is a single page Nuxt 4 app with Vuetify 4 and Pinia. It talks to the backend over a /api proxy, so there is no CORS to manage. Answers render live as Markdown while they stream in.
The models run on Ollama. Embeddings use qwen3-embedding:4b, truncated to 1024 dimensions with Matryoshka so it fits the pgvector HNSW index. Generation uses qwen3:14b.
Answers stream in token by token over Server-Sent Events. Conversations are multi-turn, so a follow-up question is rewritten into a standalone question using the chat history before retrieval, which means asking "and the total?" still works. Every conversation is saved and can be reopened from the sidebar, and the last open conversation is restored on reload. A question can be scoped to specific documents by selecting them with the + button, so retrieval only searches what was chosen. The system prompt is hardened against prompt injection, so any instructions buried inside an uploaded document are treated as untrusted data and ignored.
You need uv, Docker, Node and Ollama installed. Ollama runs directly on the host rather than inside Docker, so that it can use Metal acceleration on Apple Silicon. I run this on a MacBook Pro with the M4 Pro chip and 48 GB of RAM, where qwen3:14b runs comfortably. The containers reach Ollama through host.docker.internal.
Pull the two models first.
ollama pull qwen3-embedding:4b
ollama pull qwen3:14b
The simplest way to use it is to bring up the whole stack in Docker. This runs the backend, the Celery worker and the frontend in containers, so nothing is needed on the host beyond Docker and Ollama.
docker compose up
The frontend is served at http://localhost:9002 and the backend API at http://localhost:9000. I kept the ports in the 9xxx range so they do not clash with other things running locally. Postgres is on 9001, Redis on 9003 and Tika on 9004.
bin/setup is for working on the backend directly on the host instead of inside a container, mainly to run the tests and migrations through uv. It installs the backend dependencies, installs the Playwright browser, and starts only the supporting infrastructure in Docker, which is Postgres, Redis and Tika. It deliberately does not start the backend, the worker or the frontend, since those are meant to run on the host. It also applies the migrations and checks that the Ollama models are pulled.
bin/setup
The backend test suite is class based and runs without any environment setup. A separate throwaway Postgres for the tests comes up from its own compose file.
docker compose -f docker-compose.testing.yml up -d
cd backend && uv run pytest
Tests that need a running Ollama are marked and skipped by default. Run them explicitly with uv run pytest -m ollama. The same applies to the tests that exercise real OCR against a running Tika, which run with uv run pytest -m tika. The end to end tests drive a real browser against the running frontend and backend.