A multi-tenant RAG (Retrieval-Augmented Generation) chatbot with per-user document isolation, optional cross-encoder reranking, and real-time streaming responses.
Upload Query
│ │
▼ ▼
File (PDF/TXT) User question
│ │
▼ ▼
Text Extraction ChromaDB vector search
(pypdf / open) (filtered by tenant_id)
│ │
▼ ▼
Chunking Candidate chunks
(RecursiveCharacterTextSplitter) │
500 tokens, 50 overlap ▼
│ [Optional] Cross-encoder reranking
▼ (ms-marco-MiniLM-L-6-v2)
ChromaDB insert │
(+ tenant_id metadata) ▼
Top 3 relevant chunks
│
▼
LLM prompt (context + question)
│
▼
Streaming response to user
- User uploads a PDF or TXT file (via web UI or API)
- Text is extracted using
pypdf(PDF) or direct read (TXT) - Content is split into 500-token chunks with 50-token overlap using LangChain's
RecursiveCharacterTextSplitterwithtiktoken(o200k_baseencoding) - Each chunk is stored in ChromaDB with a
tenant_idmetadata tag and a deterministic MD5-based ID
- User asks a question
- ChromaDB performs semantic similarity search, filtered by the user's
tenant_id-- only the user's own documents are searched - Without reranking: top 3 results are returned directly
- With reranking: 10 candidates are fetched, scored by a cross-encoder, and the top 3 are returned
- Retrieved chunks are assembled into a context block (max 3000 chars) and sent to the LLM with a system prompt restricting answers to the provided context
Every document chunk in ChromaDB carries a tenant_id metadata field matching the uploading user's UUID. Queries include a where={"tenant_id": "..."} filter, ensuring users can only retrieve their own uploaded documents. This is enforced at the ChromaDB query level, not just the application layer.
Authentication is JWT-based (HS256, 30-minute expiry). The web UI uses a shared anonymous tenant ID.
When RERANKER_ENABLED=true, the pipeline fetches more candidates from ChromaDB (default 10) and passes them through a cross-encoder (cross-encoder/ms-marco-MiniLM-L-6-v2) that scores each (query, document) pair for relevance. Only the top-scoring results (default 3) are sent to the LLM.
This improves accuracy over pure vector similarity because cross-encoders see the full query-document pair rather than independent embeddings.
| Variable | Default | Description |
|---|---|---|
RERANKER_ENABLED |
false |
Enable reranking |
RERANKER_MODEL |
cross-encoder/ms-marco-MiniLM-L-6-v2 |
Cross-encoder model |
RERANKER_INITIAL_RESULTS |
10 |
Candidates fetched from ChromaDB |
RERANKER_FINAL_RESULTS |
3 |
Results returned after reranking |
| Layer | Technology |
|---|---|
| Web Framework | FastAPI |
| ASGI Server | Uvicorn |
| Vector Database | ChromaDB |
| LLM Provider | OpenAI (GPT-4o-mini) |
| Text Splitting | LangChain RecursiveCharacterTextSplitter |
| Token Counting | tiktoken (o200k_base) |
| PDF Parsing | pypdf |
| Reranking | sentence-transformers (CrossEncoder) |
| Relational DB | PostgreSQL 16 (optional) |
| Password Hashing | bcrypt (SHA-256 pre-hash) |
| JWT Auth | PyJWT (HS256) |
| Frontend | Vanilla JS + Tailwind CSS |
| Python | 3.13 |
| Linting | Ruff |
- Python 3.13+
- uv package manager
- OpenAI API key
uv synccp .env.example .envEdit .env -- the only required variable is OPENAI_API_KEY.
# Terminal 1: ChromaDB
make db
# Terminal 2: App (http://localhost:8001)
make runFor user authentication, chat history persistence, and file tracking:
docker compose up postgresWithout PostgreSQL, the app runs in anonymous mode with no auth and no session history.
| Variable | Default | Description |
|---|---|---|
OPENAI_API_KEY |
(required) | OpenAI API key |
OPENAI_MODEL_NAME |
gpt-4o-mini |
LLM model |
LOCAL_DATABASE |
false |
Use local persistent ChromaDB (./chroma_db) |
CHROMA_HOST |
localhost |
ChromaDB host |
CHROMA_PORT |
8000 |
ChromaDB port |
CHROMA_DATABASE_NAME |
knowledge_db |
ChromaDB database |
CHROMA_COLLECTION_NAME |
knowledge |
ChromaDB collection |
DATABASE_URL |
(empty = disabled) | PostgreSQL connection string |
JWT_SECRET_KEY |
change-me-in-production |
JWT signing secret |
JWT_ALGORITHM |
HS256 |
JWT algorithm |
JWT_EXPIRATION_MINUTES |
30 |
Token lifetime |
| Method | Path | Description |
|---|---|---|
POST |
/v1/authentication/register |
Create account { name, email, password } |
POST |
/v1/authentication/login |
Sign in { email, password } |
| Method | Path | Description |
|---|---|---|
POST |
/v1/sessions |
Create a new chat session |
GET |
/v1/sessions |
List all sessions for the user |
GET |
/v1/sessions/{id}/messages |
Get message history for a session |
| Method | Path | Description |
|---|---|---|
POST |
/v1/ask |
Ask a question { question, session_id } |
POST |
/v1/ask/stream |
Ask with SSE streaming { question, session_id } |
| Method | Path | Description |
|---|---|---|
POST |
/v1/upload |
Upload PDF/TXT file (multipart form) |
| Method | Path | Description |
|---|---|---|
GET |
/ |
Single-page app with login, sessions, and chat |
The project follows Hexagonal Architecture (ports and adapters):
app.py FastAPI entry point + DI wiring
routes/
v1/ JSON API (auth, sessions, ask, upload)
web.py Web UI routes
domain/
entities/ User, Session, Message, File
repositories/ Repository interfaces
services/ Business logic
knowledge.py Ingestion, chunking, storage, retrieval
ask.py LLM interaction + message persistence
authentication.py Login/register with JWT
session.py Session CRUD
extractor.py Text extraction dispatch
infra/
database.py ChromaDB client setup
ports/ Abstract interfaces
model.py LLM port
splitter.py Text splitting port
reranker.py Reranking port
extractor.py Text extraction port
database.py SQL database port
password_hasher.py Hashing port
token_provider.py JWT port
adapters/ Concrete implementations
openai_model.py OpenAI via LangChain
langchain_splitter.py RecursiveCharacterTextSplitter
cross_encoder_reranker.py sentence-transformers CrossEncoder
pdf_extractor.py pypdf
txt_extractor.py Direct file read
postgres_adapter.py psycopg2 + auto-migration
bcrypt_password_hasher.py bcrypt + SHA-256
pyjwt_token_provider.py PyJWT
repository/postgres/ Repository implementations
All adapters are behind port interfaces, making it straightforward to swap implementations (e.g., replace OpenAI with a local LLM, or ChromaDB with another vector store).
The SPA at http://localhost:8001 provides:
- Login / Register screens for user authentication
- Sessions screen with an Upload button (PDF/TXT) and + New Chat button
- Chat screen with real-time streaming responses via SSE