- Python 95.8%
- Shell 3.3%
- Dockerfile 0.9%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
The comment justified withholding the embedding role from the cloud with a runtime difference that was unmeasured at the time: "vectors from a different runtime mixed into an existing collection degrade it in a way nothing here would report. Until that equivalence is measured…". It has now been measured against the live key and vega: the same string embedded both ways agrees to cosine 0.99998, against a 0.21-0.37 spread between different strings, and a cloud-embedded query ranked vega-embedded documents correctly in every case. Both sides return 1024 dimensions at L2 norm 1.0. So the hazard the comment described is not there, and leaving the claim in place would have a future reader avoid a cost that does not exist. The decision is unchanged — the cloud still does not write vectors — but it now reads as what it is: one runtime owning the collection, at the price of keyword-only retrieval while the desktop sleeps. The comment also notes that adding the key is the whole change, so the option is visible rather than buried. Behaviour is untouched; this is a comment. Measured on four short Polish strings, not on the ~1000-character prefixed chunks ingest writes. |
||
| deploy/langfuse | ||
| public | ||
| src | ||
| tests | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| .python-version | ||
| docker-compose.yml | ||
| Dockerfile | ||
| entrypoint.sh | ||
| pyproject.toml | ||
| README.md | ||
| uv.lock | ||
DnD Campaign Chatbot
A self-hosted RAG (Retrieval Augmented Generation) chatbot for querying D&D campaign notes. Polish-language content.
Features
- Hybrid search: BM25 (keyword) + vector (semantic) with RRF fusion and optional cross-encoder reranking
- Contextual prefixes: LLM-generated chunk context for better retrieval (Anthropic technique)
- Auto-indexing: Watches content directory, incrementally indexes new/modified markdown files
- Backend failover: Hosts are declared in
backends.ymlin preference order; generation moves to a cloud fallback when no local host answers, and retrieval drops to keyword-only rather than failing - Follow-up questions: A follow-up is rewritten into a standalone query before retrieval, so history does not pollute the search
- REST API: Fast context endpoint for external integrations (used by Nanobot Telegram bot)
- Web UI: Chainlit chat interface with streaming responses
- Offline eval: A hand-written test set measures recall@k and MRR, so retrieval changes are compared rather than guessed at
- Polish language: Optimized for Polish content with wiki-link parsing
Architecture
Markdown files (dnd-summaries/content/)
|
v
+---------+ contextual +--------+
| ingest |---prefixes (LLM)-| Ollama |
| | embeddings |(remote)|
+----+-----+ +--------+
|
v
+----------+
| ChromaDB | (vector store, port 8100 external / 8000 internal)
+----+-----+
|
v
+----------+ hybrid search +--------+
| chat |----LLM response----| Ollama |
|(Chainlit| (streaming) |(remote)|
|+FastAPI) | +--------+
+----------+
port 7860
Which host serves generation and which serves embeddings is resolved at request
time from src/backends.yml, not fixed at startup — the GPU desktop is off most
of the time, and the chain is rebuilt when that changes.
Three Docker Compose services:
| Service | Container | Port | Role |
|---|---|---|---|
chromadb |
dnd-chromadb |
8100:8000 | Vector store (persistent on NAS) |
ingest |
dnd-ingest |
— | Indexes markdown, watches for changes |
chat |
dnd-chat |
7860:7860 | Web UI + REST API |
Quick Start
1. Configure
cp .env.example .env
Edit .env:
CONTENT_PATH=/path/to/your/markdown/content
OLLAMA_BASE_URL=http://your-ollama-host:11434
LLM_MODEL=SpeakLeash/bielik-11b-v3.0-instruct:Q4_K_M
EMBEDDING_MODEL=bge-m3
2. Start
docker compose up -d
3. Use
- Web UI: http://localhost:7860
- Context API: http://localhost:7860/api/context?q=your+question
- Query API: http://localhost:7860/api/query?q=your+question
- Health: http://localhost:7860/api/health
Configuration
All settings via .env file:
| Variable | Default | Description |
|---|---|---|
CONTENT_PATH |
./_content |
Path to markdown files |
OLLAMA_BASE_URL |
http://ollama:11434 |
Ollama server URL |
LLM_MODEL |
gemma3:12b |
Model for chat and contextual prefixes |
EMBEDDING_MODEL |
bge-m3 |
Model for embeddings |
OPENROUTER_API_KEY |
— | Cloud fallback for generation; empty disables the fallback |
OPENROUTER_MODEL |
google/gemini-3.6-flash |
Model used on the fallback |
RETRIEVER_K |
6 |
Number of context chunks to retrieve |
CHUNK_SIZE |
1000 |
Text chunk size for indexing |
CHUNK_OVERLAP |
200 |
Overlap between chunks |
RERANKER_ENABLED |
false |
Enable cross-encoder reranking — see what the reranker buys for the measured trade-off |
CONNECT_TIMEOUT |
3 |
Connect timeout; a powered-off host swallows the SYN, so this is what makes it fail fast |
LLM_READ_TIMEOUT |
180 |
Read timeout for generation — generous, since bielik-11b needs 30-40s |
EMBED_READ_TIMEOUT |
30 |
Read timeout for embeddings |
LOG_LEVEL |
INFO |
Root log level |
.env names the hosts and models; src/backends.yml is what maps them onto
roles, in preference order. Adding another Ollama host is an entry in that file
and no code change. It reads ${VAR} and ${VAR:-default} from the
environment, so .env keeps driving the deployment.
RAG Pipeline
- Ingestion (
src/ingest.py): Markdown → chunks → contextual prefixes via LLM (cached) → embeddings viabge-m3→ ChromaDB - Condensing (
src/rag.py): A follow-up question is rewritten into a standalone query, used for retrieval only - Retrieval (
src/retriever.py): BM25 + vector search → RRF fusion → optional cross-encoder reranking (bge-reranker-v2-m3, off by default). Falls back to keyword-only when no embedding host answers - Generation (
src/rag.py, served bysrc/chat.pyandsrc/cl_app.py): Retrieved chunks as context → LLM streaming response, from the first live backend
API Endpoints
| Endpoint | Speed | Description |
|---|---|---|
GET /api/context?q=... |
~1.5s | RAG chunks only, no LLM — for external integrations. Keeps answering in keyword-only mode |
GET /api/query?q=... |
~30-40s | Full LLM-generated answer with sources; 503 when no LLM backend is live |
GET /api/health |
instant | Live backends, retrieval mode and document count. Always 200 while the process answers — status says whether it is whole or degraded |
Content Format
| Pattern | Type | Example |
|---|---|---|
DD.MM.YYYY.md |
Session notes | 15.11.2025.md |
postacie/*.md |
Character | Thok Darkhide.md |
lokacje/*.md |
Location | Luskan.md |
Wiki-links ([[Name]] and [[Name|Display]]) are resolved during ingestion.
Commands
# Start
docker compose up -d
# Logs
docker compose logs -f chat
docker compose logs -f ingest
# Rebuild after code changes
docker compose build && docker compose up -d
# Force full reindex (delete ChromaDB collection)
curl -X DELETE http://localhost:8100/api/v2/tenants/default_tenant/databases/default_database/collections/dnd_campaign
docker compose restart ingest
# Stop
docker compose down
Development
# Run locally (needs Ollama + ChromaDB running)
uv run python src/ingest.py
uv run python src/chat.py
# Tests (no network, no ChromaDB)
uv run pytest
# Lint
uv run ruff check .
Retrieval eval
CHROMA_URL=http://localhost:8100 \
OLLAMA_BASE_URL=http://192.168.4.184:11434 \
CACHE_DIR=/mnt/nas/docker/dnd-chatbot/ingest_cache \
RERANKER_ENABLED=false \
uv run python src/eval/run_eval.py
Reports recall@k and MRR over src/eval/testset.jsonl, broken down by category
and difficulty, and saves a timestamped JSON to src/eval/results/ so runs stay
comparable. Toggle RERANKER_ENABLED for the A/B.
To force keyword-only retrieval while an embedding host is up, point
OLLAMA_BASE_URL at a dead address — no live embedding backend is exactly what
the degraded path is.
What hybrid search buys (measured)
33 questions against the live collection (310 chunks), reranker off:
| keyword-only | hybrid | |
|---|---|---|
| substring recall@1 | 0.576 | 0.758 |
| substring MRR | 0.712 | 0.853 |
| file recall@3 | 0.576 | 0.788 |
paraphrase recall@6 |
0.875 | 1.000 |
paraphrase MRR |
0.505 | 0.900 |
paraphrase is the category that answers the question: it is the only one where
keyword-only retrieval drops below 1.000 at k=6, because its questions avoid the
notes' own vocabulary. Everywhere the question reuses a proper noun from the
notes, BM25 alone is already at the ceiling — and on literal lookups hybrid
fusion is actually slightly worse (easy_lookup MRR 0.900 → 0.750), because
vector hits displace exact keyword matches near the top.
What the reranker buys (measured)
Same 33 questions, hybrid retrieval, both passes inside the same container:
| reranker off | reranker on | |
|---|---|---|
| substring recall@1 | 0.758 | 0.909 |
| substring recall@6 | 1.000 | 0.970 |
| substring recall@8 | 1.000 | 0.970 |
| substring MRR | 0.853 | 0.935 |
| file recall@1 | 0.424 | 0.576 |
| file MRR | 0.617 | 0.704 |
| latency/query | 0.06 s | 3.27 s |
The gain is at the very top of the ranking, which is what matters when only
RETRIEVER_K chunks reach the model: six questions gain the first position and
one loses it. It also repairs what fusion costs on literal lookups —
easy_lookup MRR goes 0.900 keyword-only → 0.750 hybrid → 0.900 with the
reranker, so the two mechanisms are complementary rather than competing.
The costs are real and worth stating. Latency goes up 55x, which is what puts
/api/context in the 1.5-4s band. And in one question of 33 the cross-encoder
demotes the only relevant chunk from position 5 to position 11: raising
RETRIEVER_K from 6 to 8 does not recover it (recall@8 equals recall@6), so that
one is paid for, not tuned away. Reranking only reorders the fetch_k
candidates, so recall@20 is untouched at 1.000.
Tracing
deploy/langfuse/ holds a prepared self-hosted Langfuse stack for the NUC.
It is written but not deployed — see its README before starting it.
License
MIT