2026-02-27 10:59:36 +01:00
|
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
|
"""
|
|
|
|
|
|
RAG Ingestion Script für apply4jobs.de
|
|
|
|
|
|
Liest Dokumente (MD, TXT, PDF), chunked sie und speichert Embeddings in pgvector.
|
|
|
|
|
|
|
|
|
|
|
|
Späterer Ausbau: Tkinter UI
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
import os
|
|
|
|
|
|
import uuid
|
|
|
|
|
|
import json
|
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
from dotenv import load_dotenv
|
|
|
|
|
|
from openai import OpenAI
|
|
|
|
|
|
import psycopg2
|
|
|
|
|
|
from psycopg2.extras import execute_values
|
|
|
|
|
|
from sshtunnel import SSHTunnelForwarder
|
|
|
|
|
|
import tiktoken
|
|
|
|
|
|
|
|
|
|
|
|
# PDF Support (optional)
|
|
|
|
|
|
try:
|
|
|
|
|
|
from pypdf import PdfReader
|
|
|
|
|
|
PDF_SUPPORT = True
|
|
|
|
|
|
except ImportError:
|
|
|
|
|
|
PDF_SUPPORT = False
|
|
|
|
|
|
|
2026-04-29 10:32:36 +02:00
|
|
|
|
load_dotenv(".env.ingest")
|
2026-02-27 10:59:36 +01:00
|
|
|
|
|
|
|
|
|
|
# --- Konfiguration ---
|
|
|
|
|
|
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
|
|
|
|
|
|
SSH_HOST = os.getenv("SSH_HOST")
|
|
|
|
|
|
SSH_USER = os.getenv("SSH_USER", "root")
|
|
|
|
|
|
SSH_KEY_PATH = os.path.expanduser(os.getenv("SSH_KEY_PATH", "~/.ssh/id_ed25519"))
|
|
|
|
|
|
SSH_KEY_PASSPHRASE = os.getenv("SSH_KEY_PASSPHRASE", None)
|
|
|
|
|
|
DB_HOST = os.getenv("DB_HOST", "127.0.0.1")
|
|
|
|
|
|
DB_PORT = int(os.getenv("DB_PORT", 5432))
|
|
|
|
|
|
DB_NAME = os.getenv("DB_NAME", "anythingllm")
|
|
|
|
|
|
DB_USER = os.getenv("DB_USER", "anythingllm")
|
|
|
|
|
|
DB_PASSWORD = os.getenv("DB_PASSWORD")
|
|
|
|
|
|
NAMESPACE = os.getenv("NAMESPACE", "mein-workspace")
|
|
|
|
|
|
CHUNK_SIZE = int(os.getenv("CHUNK_SIZE", 500))
|
|
|
|
|
|
CHUNK_OVERLAP = int(os.getenv("CHUNK_OVERLAP", 50))
|
|
|
|
|
|
EMBED_MODEL = "intfloat/multilingual-e5-small"
|
|
|
|
|
|
|
|
|
|
|
|
client = OpenAI(api_key=OPENAI_API_KEY)
|
|
|
|
|
|
embed_model = None # lazy load
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- Text-Extraktion ---
|
|
|
|
|
|
|
|
|
|
|
|
def read_file(path: Path) -> str:
|
|
|
|
|
|
"""Liest MD, TXT oder PDF und gibt den Text zurück."""
|
|
|
|
|
|
suffix = path.suffix.lower()
|
|
|
|
|
|
if suffix in (".md", ".txt"):
|
|
|
|
|
|
return path.read_text(encoding="utf-8")
|
|
|
|
|
|
elif suffix == ".pdf":
|
|
|
|
|
|
if not PDF_SUPPORT:
|
|
|
|
|
|
raise ImportError("pypdf nicht installiert: pip install pypdf")
|
|
|
|
|
|
reader = PdfReader(str(path))
|
|
|
|
|
|
return "\n\n".join(page.extract_text() or "" for page in reader.pages)
|
|
|
|
|
|
else:
|
|
|
|
|
|
raise ValueError(f"Nicht unterstütztes Dateiformat: {suffix}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- Chunking ---
|
|
|
|
|
|
|
|
|
|
|
|
def chunk_text(text: str, chunk_size: int = CHUNK_SIZE, overlap: int = CHUNK_OVERLAP) -> list[str]:
|
|
|
|
|
|
"""Teilt Text in überlappende Chunks auf (token-basiert)."""
|
|
|
|
|
|
enc = tiktoken.get_encoding("cl100k_base")
|
|
|
|
|
|
tokens = enc.encode(text)
|
|
|
|
|
|
chunks = []
|
|
|
|
|
|
start = 0
|
|
|
|
|
|
while start < len(tokens):
|
|
|
|
|
|
end = min(start + chunk_size, len(tokens))
|
|
|
|
|
|
chunk_tokens = tokens[start:end]
|
|
|
|
|
|
chunks.append(enc.decode(chunk_tokens))
|
|
|
|
|
|
start += chunk_size - overlap
|
|
|
|
|
|
return chunks
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- Embeddings ---
|
|
|
|
|
|
|
|
|
|
|
|
def get_embeddings(texts: list[str]) -> list[list[float]]:
|
|
|
|
|
|
"""Erstellt Embeddings via multilingual-e5-small (lokal)."""
|
|
|
|
|
|
global embed_model
|
|
|
|
|
|
if embed_model is None:
|
|
|
|
|
|
from sentence_transformers import SentenceTransformer
|
|
|
|
|
|
print(" ⏳ Lade Embedding-Modell (einmalig)...")
|
|
|
|
|
|
embed_model = SentenceTransformer(EMBED_MODEL)
|
|
|
|
|
|
prefixed = [f"passage: {t}" for t in texts]
|
|
|
|
|
|
return embed_model.encode(prefixed).tolist()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- Datenbank ---
|
|
|
|
|
|
|
|
|
|
|
|
def get_db_connection(tunnel_port: int):
|
|
|
|
|
|
return psycopg2.connect(
|
|
|
|
|
|
host="127.0.0.1",
|
|
|
|
|
|
port=tunnel_port,
|
|
|
|
|
|
dbname=DB_NAME,
|
|
|
|
|
|
user=DB_USER,
|
|
|
|
|
|
password=DB_PASSWORD
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def check_existing(cur, source_title: str) -> int:
|
|
|
|
|
|
"""Prüft ob Dokument bereits in der DB vorhanden ist."""
|
|
|
|
|
|
cur.execute(
|
|
|
|
|
|
"SELECT COUNT(*) FROM anythingllm_vectors WHERE metadata->>'title' = %s AND namespace = %s",
|
|
|
|
|
|
(source_title, NAMESPACE)
|
|
|
|
|
|
)
|
|
|
|
|
|
return cur.fetchone()[0]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def delete_existing(cur, source_title: str):
|
|
|
|
|
|
"""Löscht alle Chunks eines Dokuments."""
|
|
|
|
|
|
cur.execute(
|
|
|
|
|
|
"DELETE FROM anythingllm_vectors WHERE metadata->>'title' = %s AND namespace = %s",
|
|
|
|
|
|
(source_title, NAMESPACE)
|
|
|
|
|
|
)
|
|
|
|
|
|
print(f" 🗑️ Bestehende Chunks für '{source_title}' gelöscht.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def insert_chunks(cur, chunks: list[str], embeddings: list[list[float]], source_path: Path):
|
|
|
|
|
|
"""Fügt Chunks mit Embeddings in pgvector ein."""
|
|
|
|
|
|
now = datetime.now().isoformat()
|
|
|
|
|
|
records = []
|
|
|
|
|
|
for chunk, embedding in zip(chunks, embeddings):
|
|
|
|
|
|
metadata = {
|
|
|
|
|
|
"id": str(uuid.uuid4()),
|
|
|
|
|
|
"url": f"file://{source_path.resolve()}",
|
|
|
|
|
|
"text": chunk,
|
|
|
|
|
|
"title": source_path.name,
|
|
|
|
|
|
"docSource": "rag-ingestion-script",
|
|
|
|
|
|
"published": now,
|
|
|
|
|
|
"wordCount": len(chunk.split()),
|
|
|
|
|
|
"chunkSource": str(source_path.resolve()),
|
|
|
|
|
|
}
|
|
|
|
|
|
records.append((
|
|
|
|
|
|
str(uuid.uuid4()),
|
|
|
|
|
|
json.dumps(metadata),
|
|
|
|
|
|
NAMESPACE,
|
|
|
|
|
|
embedding
|
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
|
|
execute_values(
|
|
|
|
|
|
cur,
|
|
|
|
|
|
"""
|
|
|
|
|
|
INSERT INTO anythingllm_vectors (id, metadata, namespace, embedding)
|
|
|
|
|
|
VALUES %s
|
|
|
|
|
|
""",
|
|
|
|
|
|
records,
|
|
|
|
|
|
template="(%s::uuid, %s::jsonb, %s, %s::vector)"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- Haupt-Ingestion ---
|
|
|
|
|
|
|
|
|
|
|
|
def ingest_file(file_path: str, force: bool = False):
|
|
|
|
|
|
"""
|
|
|
|
|
|
Verarbeitet eine einzelne Datei:
|
|
|
|
|
|
1. Text lesen
|
|
|
|
|
|
2. Chunken
|
|
|
|
|
|
3. Embeddings erstellen
|
|
|
|
|
|
4. In pgvector speichern (via SSH Tunnel)
|
|
|
|
|
|
"""
|
|
|
|
|
|
path = Path(file_path).resolve()
|
|
|
|
|
|
if not path.exists():
|
|
|
|
|
|
print(f"❌ Datei nicht gefunden: {path}")
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
print(f"\n📄 Verarbeite: {path.name}")
|
|
|
|
|
|
|
|
|
|
|
|
# Text lesen
|
|
|
|
|
|
try:
|
|
|
|
|
|
text = read_file(path)
|
|
|
|
|
|
print(f" ✅ Text gelesen ({len(text)} Zeichen)")
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
print(f" ❌ Fehler beim Lesen: {e}")
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
# Chunken
|
|
|
|
|
|
chunks = chunk_text(text)
|
|
|
|
|
|
print(f" ✅ {len(chunks)} Chunks erstellt")
|
|
|
|
|
|
|
|
|
|
|
|
# Embeddings
|
|
|
|
|
|
print(f" ⏳ Erstelle Embeddings via OpenAI...")
|
|
|
|
|
|
try:
|
|
|
|
|
|
embeddings = get_embeddings(chunks)
|
|
|
|
|
|
print(f" ✅ {len(embeddings)} Embeddings erstellt")
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
print(f" ❌ Fehler bei Embeddings: {e}")
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
# SSH Tunnel + DB
|
|
|
|
|
|
print(f" ⏳ Verbinde mit Datenbank via SSH Tunnel...")
|
|
|
|
|
|
try:
|
|
|
|
|
|
with SSHTunnelForwarder(
|
|
|
|
|
|
(SSH_HOST, 22),
|
|
|
|
|
|
ssh_username=SSH_USER,
|
|
|
|
|
|
ssh_pkey=SSH_KEY_PATH,
|
|
|
|
|
|
ssh_private_key_password=SSH_KEY_PASSPHRASE,
|
|
|
|
|
|
remote_bind_address=("127.0.0.1", DB_PORT)
|
|
|
|
|
|
) as tunnel:
|
|
|
|
|
|
conn = get_db_connection(tunnel.local_bind_port)
|
|
|
|
|
|
cur = conn.cursor()
|
|
|
|
|
|
|
|
|
|
|
|
# Prüfen ob Dokument bereits existiert
|
|
|
|
|
|
existing = check_existing(cur, path.name)
|
|
|
|
|
|
if existing > 0:
|
|
|
|
|
|
if force:
|
|
|
|
|
|
delete_existing(cur, path.name)
|
|
|
|
|
|
else:
|
|
|
|
|
|
print(f" ⚠️ '{path.name}' bereits in DB ({existing} Chunks). Nutze --force zum Überschreiben.")
|
|
|
|
|
|
cur.close()
|
|
|
|
|
|
conn.close()
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
# Einfügen
|
|
|
|
|
|
insert_chunks(cur, chunks, embeddings, path)
|
|
|
|
|
|
conn.commit()
|
|
|
|
|
|
cur.close()
|
|
|
|
|
|
conn.close()
|
|
|
|
|
|
print(f" ✅ {len(chunks)} Chunks in pgvector gespeichert")
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
print(f" ❌ Datenbankfehler: {e}")
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def ingest_directory(dir_path: str, force: bool = False):
|
|
|
|
|
|
"""Verarbeitet alle MD, TXT und PDF Dateien in einem Verzeichnis."""
|
|
|
|
|
|
path = Path(dir_path).resolve()
|
|
|
|
|
|
if not path.is_dir():
|
|
|
|
|
|
print(f"❌ Verzeichnis nicht gefunden: {path}")
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
files = list(path.glob("*.md")) + list(path.glob("*.txt")) + list(path.glob("*.pdf"))
|
|
|
|
|
|
if not files:
|
|
|
|
|
|
print(f"❌ Keine unterstützten Dateien in: {path}")
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
print(f"\n📁 Verarbeite {len(files)} Dateien aus: {path}")
|
|
|
|
|
|
success = 0
|
|
|
|
|
|
for f in files:
|
|
|
|
|
|
if ingest_file(str(f), force=force):
|
|
|
|
|
|
success += 1
|
|
|
|
|
|
|
|
|
|
|
|
print(f"\n✅ Fertig: {success}/{len(files)} Dateien erfolgreich importiert.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def list_documents():
|
|
|
|
|
|
"""Zeigt alle Dokumente in der Datenbank an."""
|
|
|
|
|
|
print(f"\n📋 Dokumente in Namespace '{NAMESPACE}':")
|
|
|
|
|
|
try:
|
|
|
|
|
|
with SSHTunnelForwarder(
|
|
|
|
|
|
(SSH_HOST, 22),
|
|
|
|
|
|
ssh_username=SSH_USER,
|
|
|
|
|
|
ssh_pkey=SSH_KEY_PATH,
|
|
|
|
|
|
ssh_private_key_password=SSH_KEY_PASSPHRASE,
|
|
|
|
|
|
remote_bind_address=("127.0.0.1", DB_PORT)
|
|
|
|
|
|
) as tunnel:
|
|
|
|
|
|
conn = get_db_connection(tunnel.local_bind_port)
|
|
|
|
|
|
cur = conn.cursor()
|
|
|
|
|
|
cur.execute("""
|
|
|
|
|
|
SELECT metadata->>'title', COUNT(*), MAX(metadata->>'published')
|
|
|
|
|
|
FROM anythingllm_vectors
|
|
|
|
|
|
WHERE namespace = %s
|
|
|
|
|
|
GROUP BY metadata->>'title'
|
|
|
|
|
|
ORDER BY MAX(metadata->>'published') DESC
|
|
|
|
|
|
""", (NAMESPACE,))
|
|
|
|
|
|
rows = cur.fetchall()
|
|
|
|
|
|
cur.close()
|
|
|
|
|
|
conn.close()
|
|
|
|
|
|
|
|
|
|
|
|
if not rows:
|
|
|
|
|
|
print(" Keine Dokumente gefunden.")
|
|
|
|
|
|
else:
|
|
|
|
|
|
print(f" {'Titel':<50} {'Chunks':>6} {'Erstellt'}")
|
|
|
|
|
|
print(" " + "-" * 75)
|
|
|
|
|
|
for title, count, published in rows:
|
|
|
|
|
|
print(f" {(title or 'Unknown'):<50} {count:>6} {published or '-'}")
|
|
|
|
|
|
print()
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
print(f"❌ Fehler: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- CLI ---
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
|
import argparse
|
|
|
|
|
|
|
|
|
|
|
|
parser = argparse.ArgumentParser(
|
|
|
|
|
|
description="RAG Ingestion Script – Dokumente in pgvector importieren"
|
|
|
|
|
|
)
|
|
|
|
|
|
subparsers = parser.add_subparsers(dest="command")
|
|
|
|
|
|
|
|
|
|
|
|
# ingest file
|
|
|
|
|
|
p_file = subparsers.add_parser("file", help="Einzelne Datei importieren")
|
|
|
|
|
|
p_file.add_argument("path", help="Pfad zur Datei (MD, TXT, PDF)")
|
|
|
|
|
|
p_file.add_argument("--force", action="store_true", help="Bestehende Chunks überschreiben")
|
|
|
|
|
|
|
|
|
|
|
|
# ingest directory
|
|
|
|
|
|
p_dir = subparsers.add_parser("dir", help="Verzeichnis importieren")
|
|
|
|
|
|
p_dir.add_argument("path", help="Pfad zum Verzeichnis")
|
|
|
|
|
|
p_dir.add_argument("--force", action="store_true", help="Bestehende Chunks überschreiben")
|
|
|
|
|
|
|
|
|
|
|
|
# list
|
|
|
|
|
|
subparsers.add_parser("list", help="Alle Dokumente in der DB anzeigen")
|
|
|
|
|
|
|
|
|
|
|
|
args = parser.parse_args()
|
|
|
|
|
|
|
|
|
|
|
|
if args.command == "file":
|
|
|
|
|
|
ingest_file(args.path, force=args.force)
|
|
|
|
|
|
elif args.command == "dir":
|
|
|
|
|
|
ingest_directory(args.path, force=args.force)
|
|
|
|
|
|
elif args.command == "list":
|
|
|
|
|
|
list_documents()
|
|
|
|
|
|
else:
|
|
|
|
|
|
parser.print_help()
|