Zum Inhalt

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-instruct antwortet
  • llama-swap-Migration — alle /v1/* Endpoints OpenAI-konform
  • nomic-embed-text konfiguriert (kein ollama pull mehr nötig — alles in /opt/llama-swap/config.yaml)
  • Indexer-Script (Python) — .md chunken + 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 pull mehr — Modelle werden in /opt/llama-swap/config.yaml als 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 wird
  • coverage.md — Was die KB beantworten kann (= was die AI weiß)
  • pob_analysis.md — PoB-Webapp die ebenfalls auf .191 läuft (gleicher Backend-Service)