feat: initial RAG stack with README and env example
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
# OpenAI API Key (https://platform.openai.com/api-keys)
|
||||
OPENAI_API_KEY=sk-proj-...
|
||||
|
||||
# PostgreSQL Passwort
|
||||
POSTGRES_PASSWORD=dein-sicheres-passwort
|
||||
@@ -0,0 +1,7 @@
|
||||
```
|
||||
```
|
||||
postgres/
|
||||
ollama/
|
||||
open-webui/
|
||||
*.sql
|
||||
.env
|
||||
@@ -0,0 +1,111 @@
|
||||
# apply4jobs RAG Stack
|
||||
|
||||
KI-basierter Chat-Assistent für apply4jobs.de – beantwortet Recruiter-Fragen zu Sebastian Fröhlich auf Basis seiner Bewerbungsunterlagen.
|
||||
|
||||
## Architektur
|
||||
|
||||
\`\`\`
|
||||
apply4jobs.de (Next.js)
|
||||
└── ChatWidget → https://ai.apply4jobs.de/api/v1/chat/completions
|
||||
└── FastAPI RAG-Bridge (Port 8000)
|
||||
├── multilingual-e5-small (Embeddings)
|
||||
├── pgvector / PostgreSQL (Vektordatenbank)
|
||||
└── OpenAI GPT-4o-mini (Generierung)
|
||||
\`\`\`
|
||||
|
||||
## Stack
|
||||
|
||||
| Service | Image | Port |
|
||||
|---------|-------|------|
|
||||
| PostgreSQL + pgvector | pgvector/pgvector:pg16 | 5432 |
|
||||
| Ollama | ollama/ollama:latest | 11434 |
|
||||
| Open WebUI | ghcr.io/open-webui/open-webui:main | 3000 |
|
||||
| RAG API | python:3.11-slim (custom) | 8000 |
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
- Docker + Docker Compose v2
|
||||
- Ubuntu 22.04 LTS
|
||||
- Domain mit SSL (Let's Encrypt)
|
||||
- OpenAI API Key (GPT-4o-mini)
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Repository klonen
|
||||
|
||||
\`\`\`bash
|
||||
git clone <repo-url> /opt/apply4jobs
|
||||
cd /opt/apply4jobs
|
||||
\`\`\`
|
||||
|
||||
### 2. Umgebungsvariablen konfigurieren
|
||||
|
||||
\`\`\`bash
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
\`\`\`
|
||||
|
||||
\`\`\`env
|
||||
OPENAI_API_KEY=sk-proj-...
|
||||
POSTGRES_PASSWORD=dein-sicheres-passwort
|
||||
\`\`\`
|
||||
|
||||
### 3. Stack starten
|
||||
|
||||
\`\`\`bash
|
||||
docker compose up -d
|
||||
\`\`\`
|
||||
|
||||
### 4. Ollama-Modelle pullen
|
||||
|
||||
\`\`\`bash
|
||||
docker exec ollama ollama pull llama3.2:3b
|
||||
docker exec ollama ollama pull nomic-embed-text
|
||||
\`\`\`
|
||||
|
||||
### 5. Datenbank importieren
|
||||
|
||||
\`\`\`bash
|
||||
docker exec -i postgres psql -U anythingllm -d anythingllm < backup.sql
|
||||
\`\`\`
|
||||
|
||||
## RAG API Endpunkte
|
||||
|
||||
| Methode | Endpoint | Beschreibung |
|
||||
|---------|----------|-------------|
|
||||
| GET | /health | Health Check |
|
||||
| GET | /v1/models | Verfügbare Modelle |
|
||||
| POST | /retrieve | Kontext aus pgvector abrufen |
|
||||
| POST | /v1/chat/completions | OpenAI-kompatibler Chat-Endpunkt |
|
||||
|
||||
## Kosten
|
||||
|
||||
| Posten | Kosten/Monat |
|
||||
|--------|-------------|
|
||||
| Hetzner CPX42 | ~22 € |
|
||||
| OpenAI GPT-4o-mini | ~1-3 € |
|
||||
| **Gesamt** | **~23-25 €** |
|
||||
|
||||
## Wartung
|
||||
|
||||
\`\`\`bash
|
||||
# Logs anzeigen
|
||||
docker compose logs -f rag-api
|
||||
|
||||
# Container neu starten
|
||||
docker compose restart rag-api
|
||||
|
||||
# Nach Code-Änderungen neu bauen
|
||||
docker compose up -d --build rag-api
|
||||
|
||||
# SSL-Zertifikat testen
|
||||
certbot renew --dry-run
|
||||
\`\`\`
|
||||
|
||||
## Sicherheitshinweise
|
||||
|
||||
- .env niemals ins Git-Repository committen
|
||||
- DSGVO: Datenschutzhinweis auf apply4jobs.de erforderlich
|
||||
- Chat-Logs werden nicht persistiert
|
||||
- Hetzner Rechenzentrum Deutschland = DSGVO-konform
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg16
|
||||
container_name: postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: anythingllm
|
||||
POSTGRES_PASSWORD: any0203thing78llm
|
||||
POSTGRES_DB: anythingllm
|
||||
volumes:
|
||||
- ./postgres:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "127.0.0.1:5432:5432"
|
||||
|
||||
ollama:
|
||||
image: ollama/ollama:latest
|
||||
container_name: ollama
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./ollama:/root/.ollama
|
||||
ports:
|
||||
- "127.0.0.1:11434:11434"
|
||||
|
||||
open-webui:
|
||||
image: ghcr.io/open-webui/open-webui:main
|
||||
container_name: open-webui
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- ollama
|
||||
environment:
|
||||
- OLLAMA_BASE_URL=http://ollama:11434
|
||||
volumes:
|
||||
- ./open-webui:/app/backend/data
|
||||
ports:
|
||||
- "127.0.0.1:3000:8080"
|
||||
|
||||
rag-api:
|
||||
build: ./rag-api
|
||||
container_name: rag-api
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- postgres
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- "127.0.0.1:8000:8000"
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: apply4jobs-network
|
||||
@@ -0,0 +1,10 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY main.py .
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
from sentence_transformers import SentenceTransformer
|
||||
import psycopg2
|
||||
import httpx
|
||||
import json
|
||||
import os
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["https://apply4jobs.de", "https://www.apply4jobs.de", "http://localhost:3000"],
|
||||
allow_methods=["POST", "GET", "OPTIONS"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
model = SentenceTransformer("intfloat/multilingual-e5-small")
|
||||
|
||||
DB_CONFIG = {
|
||||
"host": "postgres",
|
||||
"port": 5432,
|
||||
"dbname": "anythingllm",
|
||||
"user": "anythingllm",
|
||||
"password": "any0203thing78llm"
|
||||
}
|
||||
|
||||
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "")
|
||||
OPENAI_MODEL = "gpt-4o-mini"
|
||||
|
||||
SYSTEM_PROMPT = """Du bist ein professioneller Assistent für Sebastian Fröhlich.
|
||||
Beantworte ausschließlich Fragen zu seiner Person, seinen Fähigkeiten,
|
||||
Projekten und Berufserfahrung. Nutze nur die bereitgestellten Dokumente.
|
||||
Antworte auf Deutsch oder Englisch je nach Sprache des Recruiters.
|
||||
Wenn du eine Frage nicht aus den Dokumenten beantworten kannst, sage das ehrlich.
|
||||
Antworte immer in vollständigen, professionellen Sätzen."""
|
||||
|
||||
def get_context(query: str, top_k: int = 5) -> str:
|
||||
query_text = f"query: {query}"
|
||||
embedding = model.encode(query_text).tolist()
|
||||
|
||||
conn = psycopg2.connect(**DB_CONFIG)
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT metadata->>'text'
|
||||
FROM anythingllm_vectors
|
||||
WHERE namespace = 'mein-workspace'
|
||||
ORDER BY embedding <=> %s::vector
|
||||
LIMIT %s
|
||||
""", (embedding, top_k))
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
return "\n\n---\n\n".join([row[0] for row in rows])
|
||||
|
||||
|
||||
class Message(BaseModel):
|
||||
role: str
|
||||
content: str
|
||||
|
||||
class ChatRequest(BaseModel):
|
||||
model: str = OPENAI_MODEL
|
||||
messages: list[Message]
|
||||
stream: bool = False
|
||||
|
||||
class QueryRequest(BaseModel):
|
||||
query: str
|
||||
top_k: int = 5
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/v1/models")
|
||||
def list_models():
|
||||
return {
|
||||
"object": "list",
|
||||
"data": [{
|
||||
"id": "sebastian-rag",
|
||||
"object": "model",
|
||||
"created": 1700000000,
|
||||
"owned_by": "apply4jobs"
|
||||
}]
|
||||
}
|
||||
|
||||
|
||||
@app.post("/retrieve")
|
||||
def retrieve(req: QueryRequest):
|
||||
context = get_context(req.query, req.top_k)
|
||||
return {"context": context, "system_prompt": SYSTEM_PROMPT}
|
||||
|
||||
|
||||
@app.post("/v1/chat/completions")
|
||||
async def chat(req: ChatRequest):
|
||||
user_message = next(
|
||||
(m.content for m in reversed(req.messages) if m.role == "user"), ""
|
||||
)
|
||||
|
||||
context = get_context(user_message)
|
||||
|
||||
enriched_messages = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": f"{SYSTEM_PROMPT}\n\n## Relevante Dokumente:\n{context}"
|
||||
},
|
||||
*[{"role": m.role, "content": m.content} for m in req.messages]
|
||||
]
|
||||
|
||||
async def stream_response():
|
||||
async with httpx.AsyncClient(timeout=60) as client:
|
||||
async with client.stream(
|
||||
"POST",
|
||||
"https://api.openai.com/v1/chat/completions",
|
||||
headers={
|
||||
"Authorization": f"Bearer {OPENAI_API_KEY}",
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
json={
|
||||
"model": OPENAI_MODEL,
|
||||
"messages": enriched_messages,
|
||||
"stream": True
|
||||
}
|
||||
) as response:
|
||||
async for chunk in response.aiter_bytes():
|
||||
yield chunk
|
||||
|
||||
if req.stream:
|
||||
return StreamingResponse(
|
||||
stream_response(),
|
||||
media_type="text/event-stream"
|
||||
)
|
||||
else:
|
||||
async with httpx.AsyncClient(timeout=60) as client:
|
||||
response = await client.post(
|
||||
"https://api.openai.com/v1/chat/completions",
|
||||
headers={
|
||||
"Authorization": f"Bearer {OPENAI_API_KEY}",
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
json={
|
||||
"model": OPENAI_MODEL,
|
||||
"messages": enriched_messages,
|
||||
"stream": False
|
||||
}
|
||||
)
|
||||
return response.json()
|
||||
@@ -0,0 +1,5 @@
|
||||
fastapi
|
||||
uvicorn
|
||||
psycopg2-binary
|
||||
pgvector
|
||||
sentence-transformers
|
||||
Reference in New Issue
Block a user