Q&A-AI-Layer über dem Wiki — Architektur¶
Lokales LLM beantwortet PoE-Fragen auf Basis der .md-Wissensdatenbank, kein API-Cost, alles auf eigener Infra.
Hardware + Endpunkt¶
| Wert | |
|---|---|
| LXC | 192.168.0.196 (separat von Wiki/PoB-LXC .191) |
| GPU | RTX 3090 24GB |
| Service | llama-swap (Port 11434) — vollständig OpenAI-kompatibel |
| API-Style | OpenAI Standard /v1/... |
| GPU-Manager | shared mit ComfyUI + Faster-Whisper, dynamisches VRAM |
Migration von Ollama → llama-swap (Mai 2026): Endpunkte sind jetzt strict OpenAI-Standard. Modelle werden nicht mehr per ollama pull verwaltet, sondern in /opt/llama-swap/config.yaml registriert.
Verfügbare Modelle (llama-swap)¶
| Use-Case | Endpoint | Modell-ID |
|---|---|---|
| Chat / RAG-Antwort | POST /v1/chat/completions |
qwen2.5:32b-instruct (general) |
| Coding-Frage | POST /v1/chat/completions |
qwen3.6-code:35b-a3b |
| Embeddings | POST /v1/embeddings |
nomic-embed-text (768-dim) |
| Vision (Bild-Analyse) | POST /v1/chat/completions mit image_url |
openbmb/minicpm-v4.5 |
Endpoint-Test verifiziert (2026-05-06): POST /v1/chat/completions mit qwen2.5:32b-instruct antwortet sauber, ~700ms für kurze Antwort.
API-Aufruf¶
Non-Streaming (für Backend-Test)¶
import requests
r = requests.post(
'http://192.168.0.196:11434/v1/chat/completions',
json={
'model': 'qwen2.5:32b-instruct',
'messages': [{'role': 'user', 'content': 'Hallo'}],
'max_tokens': 200
}
)
print(r.json()['choices'][0]['message']['content'])
Streaming (für Live-Chat-UI)¶
const response = await fetch('http://192.168.0.196:11434/v1/chat/completions', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
model: 'qwen2.5:32b-instruct',
messages: [{role:'user', content:'Erzähl eine kurze Geschichte'}],
stream: true
})
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const {value, done} = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
for (const line of chunk.split('\n')) {
if (line.startsWith('data: ') && line !== 'data: [DONE]') {
const json = JSON.parse(line.slice(6));
const delta = json.choices[0].delta.content;
if (delta) console.log(delta);
}
}
}
Response-Format ist Standard-OpenAI-SSE (data: {json}\n\n Lines, Endmarker data: [DONE]).
Embedding-Endpoint (llama-swap)¶
Status: nomic-embed-text ist konfiguriert und live.
OpenAI-Standard, supports batched input:
import requests
r = requests.post('http://192.168.0.196:11434/v1/embeddings', json={
'model': 'nomic-embed-text',
'input': ['chunk text 1', 'chunk text 2', 'chunk text 3'] # Liste = batch
})
embeddings = [item['embedding'] for item in r.json()['data']]
# 768-dim vector je Chunk
Single-Input geht auch ('input': 'one text').
RAG-Pipeline (geplant)¶
User-Frage
│
▼
┌───────────────────────────┐
│ 1. Embed Frage │ → POST /v1/embeddings (~50ms)
│ via nomic-embed-text │ (OpenAI-Standard)
└───────────┬───────────────┘
│
▼
┌───────────────────────────┐
│ 2. Vector-Search │ → SQLite + sqlite-vec
│ Top-5 Chunks aus KB │ auf .191 (Backend-LXC)
└───────────┬───────────────┘
│
▼
┌───────────────────────────┐
│ 3. Retrieval-Cutoff │ → Wenn beste Distanz > 0.5:
│ (Anti-Halluzination) │ hardcoded "kein Treffer",
│ │ skip LLM komplett
└───────────┬───────────────┘
│
▼
┌───────────────────────────┐
│ 4. LLM-Call mit System- │ → POST /v1/chat/completions
│ Prompt + Chunks + │ stream: true
│ Frage │
└───────────┬───────────────┘
│
▼
┌───────────────────────────┐
│ 5. SSE-Stream zur UI │ → Token-für-Token rendering
│ + Quellen-Anker │ im Browser
└───────────────────────────┘
System-Prompt (3-Schicht-Constraint)¶
Schicht 1: Retrieval-Cutoff (s.o.) — fängt off-topic Fragen vor LLM ab. Schicht 2: System-Prompt unten. Schicht 3: Output-Sanity-Check (optional, wenn Antwort verdächtige Phrasen wie "im Allgemeinen" enthält → Disclaimer).
Du bist der Assistent von "PoE Wisdom" — einer Wissensdatenbank
zu Path of Exile 1 (Patch 3.28 Mirage).
REGELN (strikt):
1. Beantworte AUSSCHLIESSLICH Fragen zu Path of Exile 1.
Bei Off-Topic (PoE 2, andere Spiele, Allgemeines, Politik):
antworte EXAKT "Ich beantworte nur Fragen zu Path of Exile 1."
2. Nutze NUR die unten gelieferten KONTEXT-Auszüge als Faktenquelle.
Erfinde NIEMALS Item-Namen, Werte, Drop-Quellen, Mechanik-Details.
3. Wenn die Antwort nicht im KONTEXT steht: sage
"Diese Information ist nicht in der Wissensdatenbank."
4. Antworte auf Deutsch, präzise, kurz.
5. Zitiere am Ende die Quell-Datei (z.B. "Quelle: mechanics/ailments.md").
KONTEXT:
{top_5_chunks_aus_sqlite_vec}
FRAGE: {user_input}
Implementations-Stack¶
| Komponente | Wo | Tech |
|---|---|---|
| Indexer-Script | lokal/scripts/build_rag_index.py |
Python: parse .md → Markdown-aware chunking → POST embeddings → SQLite |
| Vector-Store | /var/lib/pob/rag.sqlite auf .191 |
SQLite + sqlite-vec extension |
| Backend-Endpoint | /api/qa auf .191 |
Node.js (erweitert feedback-server.mjs) |
| LLM-Call | Backend → http://192.168.0.196:11434 | Cross-LXC HTTP, kein TLS |
| Frontend | /qa/ auf .191 (oder integriert in /wiki/ Hero) |
Vanilla TS + SSE-Reader |
Chunking-Strategie¶
Da .md strukturiert sind:
- Split per H2/H3 (jede ## Section = Chunk)
- Headers als Prefix im Chunk-Text: [mechanics/ailments.md > Damaging Ailments > Ignite (Fire DoT)]
- Max-Size: ~500 Tokens. Falls Section länger: weiter splitten an Absätzen
- Min-Size: ~50 Tokens. Falls kürzer: mit Nachbar-Section mergen
- Metadata pro Chunk: file_path, section_path, start_line, end_line
Ziel: ~800-1500 Chunks total für 105 Files.
SQLite-Schema (geplant)¶
CREATE TABLE chunks (
id INTEGER PRIMARY KEY,
file_path TEXT NOT NULL,
section_path TEXT,
start_line INTEGER,
end_line INTEGER,
text TEXT NOT NULL,
text_hash TEXT UNIQUE -- für inkrementelles Reindex
);
CREATE VIRTUAL TABLE chunk_embeddings USING vec0(
chunk_id INTEGER PRIMARY KEY,
embedding FLOAT[768]
);
Reindex bei .md-Änderung: nur Chunks mit neuen Hashes neu embedden, alte droppen. Atomic-Replace via tmp-DB + rename.
UI-Integration (zwei Optionen)¶
Option A: Eigene /qa/-Page¶
Separater Chat (wie ChatGPT.com), Volltextsuche bleibt im Wiki. Klare Trennung "ich-frage-die-AI" vs "ich-browse-Docs".
Option B (bevorzugt): Hero-Such-Box am Landing wird hybrid¶
User tippt Frage → Frontend macht parallel: 1. Lunr-Suche → zeigt Treffer-Liste links ("📄 Wiki-Treffer") 2. RAG-Call → zeigt LLM-Antwort rechts ("🤖 KI-Antwort") User bekommt beides; weiß sofort ob's Sinn macht zu lesen oder die KI-Synthese reicht.
Rate-Limit + Anti-Spam¶
- Reuse vom Feedback-Endpoint: Honeypot, Rate-Limit per IP, Time-on-page-Check
- Zusätzlich für
/api/qa: max 5 Fragen/Minute pro IP (Token-Cost) - Max 300 Tokens in Response (statt unbegrenzt)
- nginx-Cap auf 4 KB Request-Body (sonst Prompt-Injection-Risiko)
Status-Tracker¶
- Endpoint-Test (chat) —
qwen2.5:32b-instructantwortet - llama-swap-Migration — alle
/v1/*Endpoints OpenAI-konform -
nomic-embed-textkonfiguriert (keinollama pullmehr nötig — alles in/opt/llama-swap/config.yaml) - Indexer-Script (Python) —
.mdchunken + Batch-Embeddings via/v1/embeddings - sqlite-vec auf .191 installieren
- Backend
/api/qa(Node.js, SSE-Stream, RAG) - Frontend Chat-UI (Hybrid-Suchbox)
- nginx Route + Rate-Limit
- System-Prompt Live-Test mit 10-15 PoE-Fragen
- Output-Quality-Bewertung
Eigenheiten von llama-swap (wichtig für Backend-Code)¶
- Race Condition mit ComfyUI: wenn parallel Bild generiert wird → HTTP 502 für ~5 Sekunden. Backend muss Retry-Logik mit Exponential-Backoff (max 3 Retries, 2s/4s/8s).
- TTL 30 Min für Embedding-Modell (länger als LLMs, weil Indexer in Bursts arbeitet → Modell bleibt warm zwischen Batches).
- Auto-Swap zwischen embed↔chat → kleine Latenz (~1-2s) beim ersten Switch nach Inaktivität. Im Indexer: alle Embeddings AM STÜCK ziehen, dann erst Chat.
- Embedding-Dim: 768 — Schema in sqlite-vec entsprechend setzen.
- Modell-Verwaltung: kein
pullmehr — Modelle werden in/opt/llama-swap/config.yamlals Aliases zu lokalen GGUFs registriert. Modell-Adds sind Ops-Aufgabe auf .196.
Migration-Diff Ollama → llama-swap¶
| Alt (Ollama) | Neu (llama-swap) |
|---|---|
POST /api/embeddings mit {model, prompt} |
POST /v1/embeddings mit {model, input} |
POST /api/generate oder /api/chat |
POST /v1/chat/completions |
| Single embedding per Call | Batch-Input (Array) supported |
ollama pull <model> |
/opt/llama-swap/config.yaml editieren |
ollama list |
GET /v1/models (OpenAI-Standard) |
→ Backend-Code wird einfacher, weil Standard-OpenAI-SDK direkt funktioniert (Node openai package mit baseURL: http://192.168.0.196:11434/v1).
Modell-Entscheidung Begründung¶
Qwen 2.5 32B Instruct (Q4_K_M, 20 GB) gewählt weil: - Real getestet von dt. Usern auf 178 Dokumenten mit ~100% Success-Rate (paperless-gpt #17) - Höchste MMLU-Scores in der Liga (80% vs Mistral Nemo 68%) - Fitt in 24 GB VRAM mit Headroom für Embeddings + Context - Native Multilingual-Support, sehr stark in Deutsch
Alternative falls Qualität nicht reicht: Qwen 2.5 32B Q5_K_M (23 GB, höhere Quality). Alternative falls Speed wichtiger als Quality: Qwen 2.5 14B Q5_K_M (10 GB, ~doppelte tok/s).
Siehe Dubesor LLM Benchmark + paperless-gpt Discussion #17 für Belege.
Querverweise¶
wiki_deployment.md— Wiki-Setup wo der Q&A-Layer angedockt wirdcoverage.md— Was die KB beantworten kann (= was die AI weiß)pob_analysis.md— PoB-Webapp die ebenfalls auf .191 läuft (gleicher Backend-Service)