Files
localRAG/README.md
T
Philipp MockandClaude Opus 5.5 9d7670f8ad Add pinned conda environments for arm64 and x86
- 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>
2026-10-26 07:49:17 +01:00

259 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.