- envs/environment-docling.yml (arm64, Python 3.12) and envs/environment-local_rag.yml (x86_64, Python 3.10) with exact pip versions and create commands in the header - README and EINSTIEG: how to create the environments; x86 tested for chat, search and indexing (search about 2.3 s under Rosetta) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
259 lines
12 KiB
Markdown
259 lines
12 KiB
Markdown
# Local RAG – Kafka Chat
|
||
|
||
Web chat with three conversation partners on Kafka's *Die Verwandlung*, built on LangChain, FAISS and either **Ollama** (local), **OpenAI** (Responses API) or the **OpenAI Realtime API**. The corpus search is a tool the LLM calls when it needs evidence.
|
||
|
||
A short German quick start is in [EINSTIEG.md](EINSTIEG.md).
|
||
|
||
---
|
||
|
||
## Environments
|
||
|
||
| Conda env | Architecture | Use |
|
||
|---|---|---|
|
||
| `docling` | arm64 (Apple Silicon), Python 3.12, torch 2.14 | Default. Also needed for Docling (`prepare_corpus.py`). |
|
||
| `local_rag` | x86_64 (Intel Mac or Rosetta), Python 3.10, torch 2.2.2 | Alternative for x86. Chat, search and indexing tested; Docling does not run. Search is slower under Rosetta (about 2.3 s instead of about 1 s). |
|
||
|
||
**bge-m3 on x86**
|
||
|
||
- **The problem:** macOS x86 is limited to torch 2.2.2. `transformers` refuses to load `.bin` weights below torch 2.6, and the main branch of bge-m3 only ships `pytorch_model.bin`.
|
||
- **The fix:** [local_rag.py](local_rag.py) loads the official safetensors conversion (`EMBEDDING_REVISION`) in both environments. The weights are identical and so are the vectors, so the same indexes work everywhere.
|
||
|
||
**New environment**
|
||
|
||
[envs/](envs/) holds one file per environment with exact versions, exported from the working environments. The header of each file has the exact commands.
|
||
|
||
```bash
|
||
CONDA_SUBDIR=osx-arm64 conda env create -f envs/environment-docling.yml
|
||
```
|
||
|
||
```bash
|
||
CONDA_SUBDIR=osx-64 conda env create -f envs/environment-local_rag.yml
|
||
```
|
||
|
||
`CONDA_SUBDIR` forces the architecture; without it, conda on Apple Silicon may pick the wrong one. [requirements.txt](requirements.txt) lists only the direct dependencies, without versions.
|
||
|
||
**Ollama**
|
||
|
||
Ollama must be running and the model pulled once:
|
||
|
||
```bash
|
||
ollama pull gemma4:12b
|
||
```
|
||
|
||
**OpenAI** (ChatGPT and Realtime)
|
||
|
||
Put the key into a `.env` file in the project folder. It is git-ignored; protect it with `chmod 600`. An exported `OPENAI_API_KEY` also works and takes precedence.
|
||
|
||
```
|
||
OPENAI_API_KEY=sk-...
|
||
```
|
||
|
||
Without a key, both OpenAI entries in the model menu are greyed out.
|
||
|
||
---
|
||
|
||
## Run the chat
|
||
|
||
```bash
|
||
cd /Users/Philipp/Desktop/workspace/diverses/local_rag && conda activate docling && uvicorn server:app --port 8010
|
||
```
|
||
|
||
Open http://localhost:8010.
|
||
|
||
- **Startup takes about a minute.** It loads the embedding model and the reranker and builds the BM25 indexes, so the first question is as fast as any other.
|
||
- **`--reload`** restarts the server on code changes. Without it, every change to a `.py` file needs a restart; for `templates/chat.html`, reloading the page is enough.
|
||
|
||
The header has two menus:
|
||
- **Gegenüber:** Forscherin / Zeitgenosse / Autor.
|
||
- **Modell:** Ollama / ChatGPT / Realtime Mini. ChatGPT is the default and falls back to Ollama without a key.
|
||
|
||
---
|
||
|
||
## How it works
|
||
|
||
```
|
||
Browser (templates/chat.html)
|
||
│ POST /api/chat {message, history, persona, llm_provider}
|
||
▼
|
||
server.py ── picks the persona (personas.py) and the RAG instance (provider × vector store)
|
||
▼
|
||
LocalRAG.query_with_history (local_rag.py)
|
||
│ system prompt + history + question to the LLM, search tool offered
|
||
│ ┌─ LLM calls search_kafka_corpus(query) (at most MAX_TOOL_ROUNDS = 3 times)
|
||
│ │ retrieve(): FAISS (bge-m3) + BM25 → Reciprocal Rank Fusion → 25 candidates
|
||
│ │ → cross-encoder bge-reranker-v2-m3 → best 5 chunks
|
||
│ └─ LLM answers from the excerpts, citing source and page
|
||
▼
|
||
answer + retrieved chunks + search queries → browser
|
||
```
|
||
|
||
- **Personas:** Each persona has its own system prompt, its own tool description and its own vector store.
|
||
- The researcher searches the whole corpus: the primary text and the secondary literature.
|
||
- The contemporary and Kafka himself search only *Die Verwandlung*.
|
||
- **Shared resources:** The embedding model, reranker and BM25 index exist once per process and are shared by all RAG instances, whatever the provider. They are loaded at startup with `LocalRAG.preload()`.
|
||
- **Realtime:** [realtime_chat.py](realtime_chat.py) wraps the OpenAI Realtime API (WebSocket, text only) as a LangChain chat model. The tool loop is therefore the same for all providers. It opens one session per LLM call.
|
||
|
||
### Files
|
||
|
||
| File | Content |
|
||
|---|---|
|
||
| [server.py](server.py) | FastAPI app, model names, RAG instances, startup preload, console trace |
|
||
| [personas.py](personas.py) | the three personas: UI texts, images, system prompts, tool description, vector store |
|
||
| [local_rag.py](local_rag.py) | model loading, retrieval (`retrieve`), tool loop (`query_with_history`), indexing (`add_documents`) |
|
||
| [realtime_chat.py](realtime_chat.py) | `ChatOpenAIRealtime`, the Realtime API as a LangChain model |
|
||
| [templates/chat.html](templates/chat.html) | UI, persona and model menus |
|
||
| `templates/visuals/` | images (`*.png` originals, `*.jpg` web versions, `*_avatar.jpg`) |
|
||
| [add_pdfs.py](add_pdfs.py) | build a vector store from a PDF folder |
|
||
| [prepare_corpus.py](prepare_corpus.py), [docling_loader.py](docling_loader.py), [split_spreads.py](split_spreads.py) | Docling pipeline: split double-page scans, OCR where needed, structured extraction |
|
||
| [eval_retrieval.py](eval_retrieval.py) | retrieval quality on a goldset (Recall@k, MRR, optional LLM judge) |
|
||
| [grobid_qa.py](grobid_qa.py) | extraction quality checks (GROBID, Docling, pypdf) |
|
||
|
||
---
|
||
|
||
## Configuration reference
|
||
|
||
### Personas – [personas.py](personas.py)
|
||
|
||
Each persona is one entry in `PERSONAS`.
|
||
|
||
| Field | Effect |
|
||
|---|---|
|
||
| `system_prompt` | role, tone, rules for searching and citing |
|
||
| `tool_description` | what the LLM is told about the search tool (default: `SEARCH_TOOL_DESCRIPTION`) |
|
||
| `vectorstore` | which index is searched (`VECTORSTORE_PATH` or `PRIMARY_VECTORSTORE_PATH`) |
|
||
| `label`, `topic`, `name`, `role`, `place`, `status`, `greeting`, `suggestions` | UI texts |
|
||
| `image`, `image_position`, `image_alt`, `avatar` | images |
|
||
|
||
- The search rules shared by the contemporary and Kafka are in `_TOOL_RULES`.
|
||
- The researcher's prompt is `SYSTEM_PROMPT` in `local_rag.py`.
|
||
- `PUBLIC_FIELDS` lists what the UI receives; system prompts stay on the server.
|
||
|
||
### Models – [server.py](server.py) and [local_rag.py](local_rag.py)
|
||
|
||
| What | Current | Where |
|
||
|---|---|---|
|
||
| Ollama model | `gemma4:12b` (thinks by default, `reasoning` not set) | `OLLAMA_MODEL` (server.py), `ChatOllama(...)` (local_rag.py) |
|
||
| OpenAI model | `gpt-6.1-sol` | `OPENAI_MODEL` (server.py) |
|
||
| OpenAI reasoning effort | `low`, via the Responses API | `ChatOpenAI(..., reasoning_effort="low", use_responses_api=True)` (local_rag.py) |
|
||
| Realtime model | `gpt-realtime-2.1-mini` | `REALTIME_MODEL` (server.py) |
|
||
| Default in the model menu | ChatGPT | `selected` in chat.html |
|
||
|
||
GPT-6 models accept function tools on Chat Completions only with `reasoning_effort="none"`, and gpt-6.1-sol does not support `none` at all. Tool calls therefore go through the Responses API.
|
||
|
||
### Retrieval – module constants in [local_rag.py](local_rag.py)
|
||
|
||
| Constant | Current | Meaning |
|
||
|---|---|---|
|
||
| `RETRIEVAL_K` | 5 | chunks per search handed to the LLM |
|
||
| `RETRIEVAL_MODE` | `hybrid` | `dense` (vectors only), `hybrid` (vectors + BM25, RRF), `union` (BM25 adds candidates only) |
|
||
| `CANDIDATES_K` | 25 | candidates before reranking; 25 was faster than 50 and not worse on either goldset |
|
||
| `RERANKER_MODEL` | `BAAI/bge-reranker-v2-m3` | `None` disables reranking; runs in fp16 on the GPU (MPS/CUDA), fp32 on the CPU |
|
||
| `MAX_TOOL_ROUNDS` | 3 | searches per message before the LLM must answer |
|
||
| `SEARCH_TOOL_NAME` / `SEARCH_TOOL_DESCRIPTION` | `search_kafka_corpus` / text | tool name, default tool description |
|
||
| `QUERY_ADDITIONAL_INSTRUCTIONS` | empty | text appended to the search query; it lowered recall, so it is left empty |
|
||
|
||
### Index – rebuild required after changing these
|
||
|
||
| Constant | Current |
|
||
|---|---|
|
||
| `EMBEDDING_MODEL` / `EMBEDDING_REVISION` | `BAAI/bge-m3`, safetensors revision (normalized vectors) |
|
||
| `CHUNK_SIZE` / `CHUNK_OVERLAP` | 2000 / 400 characters |
|
||
| `PDF_LOADER` | `pypdf`; `docling` was not better in the goldset comparison |
|
||
| `CHUNK_HEADERS` | `False` |
|
||
|
||
Each index stores its settings in `index_info.json`. `LocalRAG` refuses to load an index built with a different embedding model.
|
||
|
||
---
|
||
|
||
## Build the vector stores
|
||
|
||
[add_pdfs.py](add_pdfs.py) indexes every PDF under a folder, including subfolders. With `CLEAR_VECTORSTORE_FIRST = True` (the default in the script), the target folder is deleted and rebuilt; set it to `False` to merge into an existing index.
|
||
|
||
Whole corpus, used by the researcher:
|
||
|
||
```bash
|
||
python add_pdfs.py --data data --index ./vectorstore_pypdf_2000 --loader pypdf --no-headers
|
||
```
|
||
|
||
Primary text only, used by the contemporary and Kafka. `data_primary/` contains just a link to `data/Kafka_Die_Verwandlung_.pdf`:
|
||
|
||
```bash
|
||
python add_pdfs.py --data data_primary --index ./vectorstore_primary_pypdf_2000 --loader pypdf --no-headers
|
||
```
|
||
|
||
Other options: `--loader docling` (needs `prepare_corpus.py` output, `--processed`), `--chunk-size`, `--chunk-overlap`.
|
||
|
||
- **Docling pipeline (arm64 env only):** `python prepare_corpus.py [pdf_dir]` writes to `corpus_processed/data_split`. Options: `--only "name1;name2"`, `--no-split`, `--layout`, `--no-headings`.
|
||
- **Not in git:** the corpus PDFs (`data/`, `data_primary/`), vector stores (`vectorstore*/`), extraction output and `.env`.
|
||
|
||
---
|
||
|
||
## Measure retrieval quality
|
||
|
||
```bash
|
||
python eval_retrieval.py --goldset eval/goldset_kafka_human.csv --index ./vectorstore_pypdf_2000 --report eval/my_report.txt
|
||
```
|
||
|
||
- Options: `--mode dense|hybrid|union`, `--no-rerank`, `--judge MODEL`.
|
||
- `--judge` lets an OpenAI model check whether the answer is in the top 5. It needs the key; verdicts are cached in `eval/judge_cache.json`.
|
||
- Current numbers (whole corpus, hybrid, 25 candidates, fp16 reranker):
|
||
|
||
| Goldset | R@5 | MRR |
|
||
|---|---|---|
|
||
| human (30 questions) | 0.73 | 0.68 |
|
||
| synthetic (43 questions) | 0.98 | 0.79 |
|
||
|
||
---
|
||
|
||
## API
|
||
|
||
| Endpoint | Purpose |
|
||
|---|---|
|
||
| `POST /api/chat` | Body: `message`, `history` (list of `{role, content}`), `persona` (`researcher`, `contemporary`, `kafka`), `llm_provider` (`ollama`, `openai`, `realtime`; API default `ollama`) |
|
||
| `GET /api/personas` | default persona and the public fields of all personas |
|
||
| `GET /api/providers` | which providers are available (`openai` and `realtime` need a key at startup) |
|
||
| `GET /api/health` | server status and whether all vector stores are loaded |
|
||
|
||
**Response of `POST /api/chat`**
|
||
|
||
| Field | Meaning |
|
||
|---|---|
|
||
| `answer` | the answer (empty on error) |
|
||
| `error` | error message or `null` |
|
||
| `retrieved` | chunks the LLM saw: `content`, `source`, `page`, `score` (reranker relevance, higher is better) |
|
||
| `searches` | the queries the LLM sent to the search tool (empty if it did not search) |
|
||
|
||
---
|
||
|
||
## Debugging and timing
|
||
|
||
The server terminal prints, per question:
|
||
|
||
```
|
||
[RAG] User question: '…'
|
||
[Zeit] LLM-Aufruf 1: 6.86s (Suche angefordert)
|
||
[RAG] Tool search '…': 5 chunk(s)
|
||
[Zeit] Suche 1.37s (davon Reranker 1.21s)
|
||
[Zeit] LLM-Aufruf 2: 15.31s (Antwort)
|
||
[Zeit] Gesamt 23.54s
|
||
```
|
||
|
||
After that it prints the retrieved chunks (source, page, score, preview) and the full answer, with provider, model and persona.
|
||
|
||
In the browser console, each answer logs a collapsed group `[RAG] Retrieved N chunk(s)` with the full chunk texts.
|
||
|
||
Where to look when an answer is wrong:
|
||
- **Wrong chunks:** tune retrieval, i.e. `CANDIDATES_K`, `RETRIEVAL_MODE` or the reranker, and measure with `eval_retrieval.py`.
|
||
- **Right chunks but a bad answer, or searching when it should not (or the reverse):** edit the persona's `system_prompt` or `tool_description`.
|
||
|
||
To try retrieval without the server:
|
||
|
||
```python
|
||
from local_rag import LocalRAG
|
||
rag = LocalRAG(vectorstore_path="./vectorstore_primary_pypdf_2000")
|
||
for doc, score in rag.retrieve("Wo versteckt sich Gregor?", k=5):
|
||
print(doc.metadata.get("page"), round(score, 3), doc.page_content[:80])
|
||
```
|
||
|
||
`python local_rag.py` still runs an old demo against `./vectorstore` and is not maintained.
|