RAG mit PostgreSQL, pgvector und Ollama: das VPI-Beispiel Schritt für Schritt (Ubuntu, macOS & Windows/WSL)

Schüler mit Fahrrad vor vergilbter Schulordnung fragt sich, ob er am Hof biken darf

Große Sprachmodelle (LLMs) formulieren hervorragend – aber sie kennen unsere Daten nicht. Fragt man ein LLM, wie sich der Preis von Tiefkühlpizza im Dezember 2023 in Österreich entwickelt hat, bekommt man bestenfalls eine Schätzung, schlimmstenfalls eine erfundene Zahl. Retrieval-Augmented Generation (RAG) löst dieses Problem: Bevor das LLM antwortet, suchen wir die passenden Fakten aus einer eigenen Datenbank und geben sie dem Modell als Kontext mit.

In diesem Artikel bauen wir ein komplettes, lokal laufendes RAG-System am Beispiel des österreichischen Verbraucherpreisindex (VPI) – Schritt für Schritt, jeweils für Linux (Ubuntu) und OSX und sogar Windows (naja, WSL).

Alles läuft lokal, kein Cloud-Dienst, keine API-Keys: Alles läuft auf dem eigenen Rechner… der hoffentlich viel Speicher für seine GPU hat 😉

Inhalt

Was ist RAG?

RAG besteht aus drei Teilen:

  • Retrieval – Die Frage wird in einen Vektor (Embedding) umgewandelt. In einer Vektordatenbank werden die inhaltlich ähnlichsten Textstücke gesucht.
  • Augmented – Diese Textstücke werden zusammen mit der Frage in den Prompt geschrieben.
  • Generation – Das LLM formuliert die Antwort ausschließlich auf Basis dieser Quellen.

Der Trick: Ähnliche Texte haben ähnliche Vektoren. Ein Embedding-Modell übersetzt Sätze in Zahlenreihen (bei uns 384 Zahlen), und die Kosinus-Distanz zwischen zwei Vektoren misst, wie ähnlich die Bedeutung ist. PostgreSQL kann mit der Erweiterung pgvector genau solche Vektoren speichern und blitzschnell durchsuchen.

Embeddings oder Trigramme? RAG vs. pg_trgm

PostgreSQL kann mit der Extension pg_trgm schon lange unscharf suchen. Dabei wird Text in Dreiergruppen von Buchstaben zerlegt – aus „hof“ wird {" h"," ho",hof,"of "}. Die Ähnlichkeit zweier Texte ist der Anteil gemeinsamer Trigramme. Wozu dann Embeddings?

Im RAG-Kontext sind Trigramme genau der Teil, den Embeddings nicht gut ersetzen:

  • Tippfehler, Eigennamen, Produktcodes, ISINs, Teilstrings: Hier sind Trigramme stark, Embeddings oft schwach.
  • Synonyme und Umschreibungen („Kfz“ und „Fahrzeug“): Hier sind Embeddings stark, Trigramme nutzlos.

Beispiel: die Schulordnung

Illustration: Ein Schüler mit Fahrrad steht vor einer vergilbten Schulordnung („Am Schulhof ist das Radfahren tunlichst zu unterlassen!“) und denkt: „Darf ich am Hof biken?“
Gleiche Bedeutung, kaum gemeinsame Buchstaben: Hier hilft keine Textsuche, sondern ein Embedding.

In der Schulordnung steht:

Am Schulhof ist das Radfahren tunlichst zu unterlassen!

Ein Schüler fragt: „darf ich am hof biken?“ Für einen Menschen ist sofort klar, dass die Regel die Antwort ist. Die Frage teilt mit ihr aber kaum Buchstabenfolgen: „biken“ ≠ „Radfahren“, „hof“ ist nur ein Teil von „Schulhof“.

Trigramme (direkt in psql):

CREATE EXTENSION IF NOT EXISTS pg_trgm;

SELECT similarity('Am Schulhof ist das Radfahren tunlichst zu unterlassen!',
                  'darf ich am hof biken?');          -- 0.154

SELECT similarity('Am Schulhof ist das Radfahren tunlichst zu unterlassen!',
                  'Die Mensa öffnet um 11 Uhr.');     -- 0.026

Embeddings (Python):

Voraussetzung: Für diesen Teil braucht man Python mit dem Paket sentence-transformers. Beides richten wir erst in Schritt 4 ein. Wer selbst nachrechnen will, kommt also nach Schritt 5 hierher zurück – zum Verstehen genügt die Tabelle weiter unten.

from sentence_transformers import SentenceTransformer, util

regel = "Am Schulhof ist das Radfahren tunlichst zu unterlassen!"
for modell in ["all-MiniLM-L6-v2", "paraphrase-multilingual-MiniLM-L12-v2"]:
    m = SentenceTransformer(modell)
    for text in ["darf ich am hof biken?", "Die Mensa öffnet um 11 Uhr."]:
        a, b = m.encode([regel, text])
        print(modell, text, round(float(util.cos_sim(a, b)), 3))

Was macht dieser Code?

  • SentenceTransformer(modell) lädt ein Embedding-Modell. Beim ersten Aufruf wird es von Hugging Face heruntergeladen, danach liegt es lokal im Cache.
  • m.encode([regel, text]) wandelt die beiden Sätze in je einen Vektor aus 384 Zahlen um – das Embedding.
  • util.cos_sim(a, b) berechnet die Kosinus-Ähnlichkeit der beiden Vektoren: Werte nahe 1 bedeuten „gleiche Bedeutung“, Werte nahe 0 „nichts miteinander zu tun“.
  • Die beiden Schleifen wiederholen das für zwei Modelle und zwei Vergleichssätze: die passende Frage und einen unpassenden Satz über die Mensa.

Die gemessenen Ähnlichkeiten (0 = nichts gemeinsam, 1 = identisch):

Vergleichpg_trgmall-MiniLM-L6-v2paraphrase-multilingual-MiniLM-L12-v2
Regel ↔ „darf ich am hof biken?“0.1540.4360.563
Regel ↔ „Die Mensa öffnet um 11 Uhr.“ (unpassend)0.0260.3090.099
AT0000A0E9W5 ↔ AT0000A0E9W6 (zwei verschiedene ISINs)0.7140.9690.994

Was die Zahlen zeigen:

  • Umschreibung: Für Trigramme ist die Frage kaum ähnlicher als ein Satz über die Mensa (nur „am“ und „hof“ überlappen). Das Embedding erkennt dagegen „biken“ ≈ „Radfahren“ und „hof“ ≈ „Schulhof“. Beim mehrsprachigen Modell ist der Abstand zum unpassenden Satz deutlich (0.563 vs. 0.099). Das englisch trainierte all-MiniLM-L6-v2 trennt bei deutschen Alltagssätzen schwächer (0.436 vs. 0.309). Für deutsche Freitexte lohnt sich daher ein mehrsprachiges Modell. In unserem VPI-Beispiel funktioniert das kleine Modell gut, weil alle Texte nach demselben Muster gebaut sind.
  • Codes: Für Embeddings sind zwei ISINs, die sich in einem Zeichen unterscheiden, praktisch identisch (0.99). Es sind aber zwei verschiedene Wertpapiere. Bei Kennungen ist nur der exakte Treffer richtig. Den liefern ein Vergleich mit = oder LIKE oder ein Trigramm-Index, bei dem der exakte Treffer mit 1.0 ganz oben steht.

all-MiniLM-L6-v2 oder paraphrase-multilingual-MiniLM-L12-v2?

Beide Modelle stammen aus der Bibliothek Sentence Transformers und liefern Vektoren mit 384 Dimensionen. Sie unterscheiden sich darin, womit sie trainiert wurden und wie groß sie sind:

all-MiniLM-L6-v2paraphrase-multilingual-MiniLM-L12-v2
SprachenEnglischüber 50 Sprachen, darunter Deutsch
Trainingsdatenüber 1 Milliarde englische SatzpaareParaphrasen; ein englisches Lehrer-Modell wurde mit übersetzten Satzpaaren auf andere Sprachen übertragen
Transformer-Schichten6 (das „L6“ im Namen)12 (das „L12“ im Namen)
Parameterca. 23 Millionenca. 118 Millionen
Größe auf der Platteca. 90 MBca. 470 MB
Wortschatz (Tokens)ca. 30.000, englischca. 250.000, mehrsprachig
Maximale Textlänge256 Tokens128 Tokens
Vektorlänge384384

Den Unterschied sieht man schon daran, wie die Modelle deutsche Wörter in Tokens zerlegen. Das englische Modell kennt „Radfahren“ nicht und zerhackt es in sinnlose Stücke, das mehrsprachige erkennt die Wortteile:

Wortall-MiniLM-L6-v2paraphrase-multilingual-MiniLM-L12-v2
Radfahrenra · df · ah · renRad · fahren
Schulhofsc · hul · hofSchul · hof
bikenbike · nbike · n

Faustregel: Für englische Texte oder sehr gleichförmige Texte ist das kleine Modell die schnellere Wahl. Für deutsche Freitexte – Schulordnungen, E-Mails, Fragen in Alltagssprache – ist das mehrsprachige Modell deutlich treffsicherer. Es braucht dafür rund fünfmal so viel Speicher, rechnet langsamer und verarbeitet nur halb so lange Textstücke.

Weil beide Modelle 384 Dimensionen liefern, passt die Tabellenspalte vector(384) für beide. Mischen darf man sie trotzdem nicht: Vektoren verschiedener Modelle sind nicht vergleichbar. Wer das Modell wechselt, muss alle Texte neu einbetten (Skript 02 und 03) und auch die Fragen mit dem neuen Modell kodieren (Skript 07).

In der Praxis kombiniert man deshalb beides zur hybriden Suche: Embeddings für die Bedeutung, Trigramme bzw. exakte Filter für Namen, Codes und Tippfehler. Unser Abfrageskript macht das bereits mit dem Datumsfilter (siehe Schritt 11). Ein Trigramm-Index auf die Texte wäre die nächste Ausbaustufe: CREATE INDEX ON vpi_embeddings USING gin (text gin_trgm_ops);

Das VPI-Beispiel

Datengrundlage ist alle_produkte.csv: 91 Produkte × 36 Monate (Jänner 2023 bis Dezember 2025) mit Min-/Max-/Median-Preisen sowie den Indizes VPI (Gesamt und Nahrungsmittel), Importpreisindex (IMPI) und Erzeugerpreisindex (EPI). Aus jeder Zeile erzeugen wir einen deutschen Beschreibungstext, zum Beispiel:

Woher die Daten kommen: vom Preisradar der Statistik Austria. Das Skript webscraping.py holt für jedes Produkt die Zeitreihen aus den JSON-Endpunkten des Preisradars (…/preisradar/data/ep/<productId>.json). Daraus entsteht pro Produkt eine Einzeldatei csv_output/<productId>_unified.csv mit Preisen und den jeweils verfügbaren Indizes (VPI des Produkts, der Unter- und der Produktgruppe, Gesamtindex, Import-, Erzeuger-, Großhandels- und Agrarpreisindex). Welche Indizes es gibt, ist je Produkt verschieden – csv_output/merge.py bildet sie deshalb auf einheitliche Spalten ab und fügt alle 91 Einzeldateien zu alle_produkte.csv zusammen.

Pizza, tiefgekühlt (330 Gramm) kostete im März 2024 im Median 2.76 € (Spanne: 1.67 – 3.36 €). Gegenüber dem Vormonat entspricht das einem leichten Anstieg von +0.8%. Der VPI-Gesamtindex liegt bei 123.7 (+0.49% ggü. Vormonat), der Index der Produktgruppe (VPI 0111) bei 128.5 (+0.31% ggü. Vormonat). Die Preisentwicklung liegt im Einklang mit der allgemeinen Inflation. Lieferkette: Der Importpreisindex blieb stabil (+0.00%), der Erzeugerpreisindex blieb stabil (+0.49%).

Diese 3.276 Texte werden zu Vektoren, landen in PostgreSQL/pgvector und werden bei jeder Frage durchsucht. Die Pipeline:

csv_output/<productId>_unified.csv   (91 Einzeldateien vom Preisradar)
    │  merge.py
    ▼
alle_produkte.csv
    │  01_csv_zu_text.py          (Regeln → deutscher Text pro Produkt & Monat)
    ▼
texte.csv
    │  02_text_zu_vektoren.py     (all-MiniLM-L6-v2 → 384-dim. Embeddings)
    ▼
vektoren.pkl
    │  03_vektoren_nach_pgvector.py
    ▼
PostgreSQL + pgvector  (Tabelle vpi_embeddings, Rolle raguser)
    ▲
    │  07_rag_abfrage.py
Frage → Embedding → Datumsfilter + Kosinus-Suche (Top 7) → Qwen 3 (Ollama) → Antwort
KomponenteWerkzeug
VektordatenbankPostgreSQL 18 + pgvector
Embedding-Modellall-MiniLM-L6-v2 (sentence-transformers, 384 Dimensionen)
LLMQwen 3 8B, lokal über Ollama (als vpi-qwen)
ProgrammiersprachePython 3 (pandas, psycopg2, openai-Client)

Schritt 1: PostgreSQL installieren

Wir verwenden PostgreSQL 18 aus den offiziellen Quellen von postgresql.org.

Ubuntu

Wir nehmen das offizielle APT-Repository von postgresql.org (Anleitung), weil die Ubuntu-Pakete oft älter sind:

sudo apt update
sudo apt install -y postgresql-common
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh   # richtet apt.postgresql.org ein
sudo apt install -y postgresql-18

✅ Test:

pg_lsclusters
sudo -u postgres psql -c "SELECT version();"

Erwartet: 18 main 5432 online … bzw. PostgreSQL 18.x on x86_64-pc-linux-gnu …

macOS

Auf dem Mac installieren wir über Homebrew (brew.sh; weitere Varianten: postgresql.org/download/macosx):

brew install postgresql@18
brew services start postgresql@18

# postgresql@18 ist "keg-only" → Pfad setzen (Apple Silicon und Intel)
echo 'export PATH="$(brew --prefix)/opt/postgresql@18/bin:$PATH"' >> ~/.zshrc
source ~/.zshrc

Hinweis: Unter Homebrew heißt der Superuser wie dein macOS-Benutzer, nicht postgres.

✅ Test:

brew services list | grep postgresql
psql postgres -c "SELECT version();"

Erwartet: postgresql@18 started bzw. PostgreSQL 18.x on aarch64-apple-darwin …

Windows (WSL 2)

Unter Windows läuft alles in Ubuntu unter WSL 2. Der Befehl heißt wsl, nicht wsl2. PowerShell als Administrator:

wsl --update
wsl --install -d Ubuntu-26.04

Beim ersten Start legt Ubuntu einen Linux-Benutzer an. Ab dann gilt in jedem Schritt die Ubuntu-Variante.

✅ Test:

# PowerShell
wsl --version
wsl -l -v
# in WSL (Ubuntu)
lsb_release -ds
pg_lsclusters
sudo -u postgres psql -c "SELECT version();"

Erwartet: Ubuntu-26.04 mit VERSION 2, Ubuntu 26.04 LTS, 18 main 5432 online und PostgreSQL 18.x on x86_64-pc-linux-gnu.

Alles Weitere zu Windows – systemd, Speicher, pgAdmin, listen_addresses, pg_hba.conf – steht im Kapitel Windows: das Ganze mit WSL 2.


Schritt 2: pgvector installieren

pgvector bringt den Datentyp vector und die Abstandsoperatoren <-> (euklidisch), <#> (Skalarprodukt) und <=> (Kosinus).

Ubuntu

Das Paket kommt aus dem Repository von Schritt 1:

sudo apt install -y postgresql-18-pgvector

✅ Test:

sudo -u postgres psql -c "SELECT name, default_version FROM pg_available_extensions WHERE name = 'vector';"

Erwartet: eine Zeile vector | 0.8.7

macOS

brew install pgvector
brew services restart postgresql@18

✅ Test:

psql postgres -c "SELECT name, default_version FROM pg_available_extensions WHERE name = 'vector';"

Erwartet: eine Zeile vector | 0.8.7


Schritt 3: Rolle raguser und Datenbank vpi anlegen

Die Skripte sollen nicht als Superuser laufen. Sie bekommen eine eigene Rolle raguser, der die Datenbank vpi gehört. Nur die Extension vector muss einmal ein Superuser anlegen.

Superuser-Shell öffnen:

# Ubuntu
sudo -u postgres psql

# macOS (Homebrew)
psql postgres

Dann in psql (Passwort anpassen):

CREATE ROLE raguser WITH LOGIN PASSWORD 'rag123';
CREATE DATABASE vpi OWNER raguser;

\c vpi
CREATE EXTENSION IF NOT EXISTS vector;
\dx
\q

Seit PostgreSQL 15 gehört das Schema public dem Datenbank-Eigentümer. raguser darf dort Tabellen anlegen, ohne weitere GRANTs.

✅ Test:

# 1) Login als raguser über TCP (so verbinden sich auch die Python-Skripte)
psql "postgresql://raguser:rag123@localhost:5432/vpi" -c "SELECT current_user, current_database();"

# 2) Vektor-Rechnung: Kosinus-Distanz zweier Vektoren
psql "postgresql://raguser:rag123@localhost:5432/vpi" -c "SELECT '[1,2,3]'::vector <=> '[1,2,4]'::vector AS kosinus_distanz;"

# 3) Darf raguser Tabellen mit Vektor-Spalte anlegen?
psql "postgresql://raguser:rag123@localhost:5432/vpi" -c "CREATE TABLE t_test (v vector(3)); DROP TABLE t_test;"

Erwartet: raguser | vpi, Distanz ca. 0.0085, CREATE TABLE und DROP TABLE ohne Fehler.


Schritt 4: Python-Umgebung einrichten

Ubuntu

sudo apt install -y python3 python3-venv python3-pip

macOS

brew install python@3.12

Beide Systeme

Projektordner anlegen und die Workshop-Dateien hineinkopieren. Die Skripte gibt es hier als Download: vpi_workshop_skripte.zip (01–07, webscraping.py, csv_output/merge.py, Modelfile, .env.example). Dazu kommen die Datendateien csv_output/<productId>_unified.csv vom Preisradar-Export. Dann:

mkdir -p ~/vpi_workshop/csv_output
cd ~/vpi_workshop
python3 -m venv venv
source venv/bin/activate
pip install --upgrade pip
pip install pandas sentence-transformers psycopg2-binary openai python-dotenv

✅ Test:

python3 --version
python3 -c "import pandas, psycopg2, openai, torch; print('Pakete OK, torch', torch.__version__)"
python3 -c "import psycopg2; c = psycopg2.connect('postgresql://raguser:rag123@localhost:5432/vpi'); print('DB OK'); c.close()"

Erwartet: Python ≥ 3.10, Pakete OK, torch 2.x und DB OK.


Schritt 5: Embedding-Modell laden

all-MiniLM-L6-v2 ist klein (ca. 90 MB), läuft schnell auf der CPU und liefert Vektoren mit 384 Dimensionen. Beim ersten Aufruf wird es automatisch heruntergeladen.

✅ Test:

# Python starten
python3

Dann am Python-Prompt (>>>) einfügen:

from sentence_transformers import SentenceTransformer, util
m = SentenceTransformer('all-MiniLM-L6-v2')
a, b, c = m.encode(['Der Preis von Butter stieg stark', 'Butter wurde deutlich teurer', 'Das Wetter ist schön'])
print('Dimension:', len(a))
print('Butter/Butter :', round(float(util.cos_sim(a, b)), 3))
print('Butter/Wetter :', round(float(util.cos_sim(a, c)), 3))

exit()

Erwartet: Dimension: 384. Die beiden Butter-Sätze sind einander deutlich ähnlicher als Butter und Wetter.


Schritt 6: Ollama und Qwen 3 installieren

Ollama führt LLMs lokal aus und bietet eine OpenAI-kompatible API unter http://localhost:11434/v1. Qwen 3 8B braucht ca. 8 GB freien RAM.

Ubuntu

curl -fsSL https://ollama.com/install.sh | sh     # richtet auch einen systemd-Dienst ein

✅ Test:

systemctl status ollama --no-pager

Erwartet: active (running)

macOS

brew install ollama
brew services start ollama
# alternativ: App von https://ollama.com/download installieren und starten

✅ Test:

brew services list | grep ollama

Erwartet: ollama started

Beide Systeme: Modell laden und eigenes Profil anlegen

Das Modelfile legt eine niedrige Temperatur, die Kontextgröße (num_ctx) und einen System-Prompt fest. num_ctx 8192 ist wichtig: Sieben deutsche Quellen plus Frage passen sonst auf Rechnern mit wenig RAM nicht in den Standard-Kontext.

FROM qwen3:8b

PARAMETER temperature 0.1
PARAMETER top_p 0.9
PARAMETER top_k 20
PARAMETER num_ctx 8192

SYSTEM """Du bist ein Datenanalyst für Konsumentenpreise in Österreich.
Antworte kurz, faktisch und mit konkreten Zahlen."""
ollama pull qwen3:8b                         # ca. 5 GB
cd ~/vpi_workshop
ollama create vpi-qwen -f Modelfile

✅ Test:

ollama list
ollama run vpi-qwen "Antworte mit einem Wort: Wie heißt die Hauptstadt von Österreich?"
curl -s http://localhost:11434/v1/models

Erwartet: vpi-qwen und qwen3:8b in der Liste, die Antwort Wien (evtl. mit <think>-Block davor), JSON mit beiden Modellen.


Schritt 7: Zugangsdaten in .env eintragen

Die Skripte 02, 03 und 07 lesen Datenbank, Ollama-Adresse und Modellnamen aus der Datei .env im Projektordner (per python-dotenv). Zugangsdaten stehen so an einer Stelle und nicht im Code:

cd ~/vpi_workshop
cp .env.example .env
nano .env        # Passwort anpassen, falls in Schritt 3 ein anderes gewählt wurde

Inhalt von .env:

DB_URL=postgresql://raguser:rag123@localhost:5432/vpi
OLLAMA_URL=http://localhost:11434/v1
LLM_MODELL=vpi-qwen
EMBED_MODEL=all-MiniLM-L6-v2

Fehlt die Datei, verwenden die Skripte genau diese Werte als Standard. EMBED_MODEL muss in Skript 02 (Texte einbetten) und 07 (Fragen einbetten) dasselbe sein – deshalb steht es nur hier.

✅ Test:

python3 -c "from dotenv import load_dotenv; import os; load_dotenv(); print(os.getenv('DB_URL'))"
psql "$(grep ^DB_URL .env | cut -d= -f2-)" -c "SELECT current_user;"

Erwartet: postgresql://raguser:rag123@localhost:5432/vpi und raguser.


Schritt 8: CSV → Texte

Zuerst die 91 Einzeldateien zu alle_produkte.csv zusammenführen:

cd ~/vpi_workshop/csv_output && source ../venv/bin/activate
python merge.py
cd ..

✅ Test:

wc -l csv_output/alle_produkte.csv
head -1 csv_output/alle_produkte.csv

Erwartet: 91 Einzeldateien → alle_produkte.csv: 3276 Zeilen, 91 Produkte, dann 3277 Zeilen und eine Kopfzeile mit den Spalten VPI_produkt … IMPI, EPI_PB, GHPI, API samt *_code.

01_csv_zu_text.py liest die CSV ein, berechnet die Preisänderung zum Vormonat und formuliert daraus deutsche Sätze (z. B. „Δ > +5 % → starker Anstieg“).

cd ~/vpi_workshop && source venv/bin/activate
python 01_csv_zu_text.py

✅ Test:

wc -l csv_output/texte.csv
head -c 600 csv_output/texte.csv

Erwartet: 3277 Zeilen (3.276 Texte + Kopfzeile) und lesbare deutsche Sätze.


Schritt 9: Texte → Vektoren

python 02_text_zu_vektoren.py      # ca. 30 Sekunden

✅ Test:

# Python starten
python3

Dann am Python-Prompt (>>>) einfügen:

import pickle
df = pickle.load(open('csv_output/vektoren.pkl', 'rb'))
print(len(df), 'Einträge, Dimension', df['embedding'].iloc[0].shape)

exit()

Erwartet: 3276 Einträge, Dimension (384,)


Schritt 10: Vektoren → PostgreSQL/pgvector

03_vektoren_nach_pgvector.py legt die Tabelle vpi_embeddings mit der Spalte embedding vector(384) und einem IVFFlat-Index an und importiert alle Einträge.

python 03_vektoren_nach_pgvector.py

Der Index wurde auf der leeren Tabelle angelegt. Nach dem Import bauen wir ihn einmal neu:

psql "postgresql://raguser:rag123@localhost:5432/vpi" -c "REINDEX INDEX vpi_embeddings_embedding_idx;"

✅ Test:

psql "postgresql://raguser:rag123@localhost:5432/vpi"

Dann am psql-Prompt (vpi=>) einfügen:

-- Anzahl der Zeilen
SELECT count(*) FROM vpi_embeddings;

-- Semantische Suche direkt in SQL: die 5 nächsten Nachbarn von Eintrag 1
SELECT product_id, date,
       round((1 - (embedding <=> (SELECT embedding FROM vpi_embeddings WHERE id = 1)))::numeric, 3) AS aehnlichkeit
FROM vpi_embeddings
ORDER BY embedding <=> (SELECT embedding FROM vpi_embeddings WHERE id = 1)
LIMIT 5;

\q

Erwartet: 3276 Zeilen. In der zweiten Abfrage steht Eintrag 1 mit Ähnlichkeit 1.000 ganz oben.

Achtung: Jeder Lauf des Skripts fügt alle Zeilen erneut ein. Vor einem zweiten Lauf: psql "postgresql://raguser:rag123@localhost:5432/vpi" -c "TRUNCATE vpi_embeddings;"


Schritt 11: Die RAG-Abfrage

07_rag_abfrage.py verbindet alles. Bei jeder Frage:

  • erkennt es per Regex ein Datum („Dezember 2023“, „im Jahr 2024“, „2023-12“, auch „Jänner“) und filtert die Tabelle vorab danach (hybride Suche: SQL-Filter + Vektorsuche),
  • wandelt die Frage mit demselben Embedding-Modell in einen Vektor um,
  • holt mit ORDER BY embedding <=> frage_vektor LIMIT 7 die sieben ähnlichsten Texte,
  • schreibt diese als nummerierte Quellen in den Prompt und lässt vpi-qwen antworten – mit der Anweisung, sich ausschließlich auf die Quellen zu stützen.

Beim ersten Start wandelt das Skript die Spalte date von TEXT in DATE um und legt einen Datumsindex an.

Qwen 3 „denkt“ vor jeder Antwort in einem <think>-Block, bei einer einfachen Frage mehrere hundert Tokens. Für RAG bringt das nichts, kostet aber Zeit. Das Skript schaltet es deshalb beim Aufruf ab:

response = client.chat.completions.create(
    model=LLM_MODELL,
    messages=[...],
    temperature=0.2,
    extra_body={"reasoning_effort": "none"},  # Qwen 3 ohne <think>-Block (schneller)
)

Gemessen auf einem Mac: 3 statt 118 Ausgabe-Tokens für eine Ein-Wort-Antwort. Im Modelfile lässt sich das nicht einstellen – PARAMETER think false lehnt Ollama ab, und /no_think im System-Prompt wirkt unter Ollama nicht. Der System-Prompt aus dem Modelfile gilt übrigens nur bei ollama run; das Skript schickt seinen eigenen, und der ersetzt ihn.

python 07_rag_abfrage.py "Welches Produkt hatte im Dezember 2023 den höchsten Preisanstieg?"

# interaktiver Modus (Beenden mit exit)
python 07_rag_abfrage.py

✅ Test:

psql "postgresql://raguser:rag123@localhost:5432/vpi" -c "\d vpi_embeddings"

Erwartet: Erkanntes Datum: Dezember 2023, sieben Treffer aus 2023-12 und eine deutsche Antwort mit Produkten und Prozentzahlen. \d vpi_embeddings zeigt für date den Typ date.

Weitere Fragen zum Ausprobieren:

  • Wie hat sich der Butterpreis im Jahr 2024 entwickelt?
  • Bei welchen Produkten ist der Importpreisanstieg noch nicht im Konsumentenpreis angekommen?
  • Welche Produkte wurden im Jänner 2025 billiger?

Gesamttest: alles auf einmal

cd ~/vpi_workshop && source venv/bin/activate
# Python starten
python3

Dann am Python-Prompt (>>>) einfügen:

import psycopg2
from sentence_transformers import SentenceTransformer
from openai import OpenAI

m = SentenceTransformer('all-MiniLM-L6-v2')
print('[1/3] Embedding: dim =', m.encode('Test').shape[0])

conn = psycopg2.connect('postgresql://raguser:rag123@localhost:5432/vpi')
cur = conn.cursor()
cur.execute('SELECT count(*) FROM vpi_embeddings')
print('[2/3] pgvector: Zeilen =', cur.fetchone()[0])
conn.close()

client = OpenAI(base_url='http://localhost:11434/v1', api_key='x')
r = client.chat.completions.create(model='vpi-qwen', messages=[{'role': 'user', 'content': 'Sag OK'}])
print('[3/3] Ollama: OK')
print('ALLES BEREIT!')

exit()

Windows: das Ganze mit WSL 2

Unter Windows läuft das System nicht nativ, sondern im Windows-Subsystem für Linux (WSL 2): einer kleinen Linux-VM mit echtem Ubuntu 26.04. Darin funktionieren Schritt 1–11 (Ubuntu) unverändert. Dieses Kapitel beschreibt nur, was unter Windows zusätzlich zu tun ist – vor allem den Zugriff von Windows aus, etwa mit pgAdmin.

Empfohlener Weg (den wir hier durchgehend verwenden):

  • Alles in WSL: PostgreSQL 18 + pgvector, Python-venv, Ollama und die Skripte laufen in Ubuntu 26.04 unter WSL 2. Die Skripte verbinden sich wie unter Linux mit localhost:5432 und localhost:11434 – kein Code ändert sich.
  • Windows 11 (22H2 oder neuer): Netzwerkmodus mirrored. Windows und WSL teilen sich dann 127.0.0.1; pgAdmin unter Windows erreicht PostgreSQL in WSL über 127.0.0.1:5432.
  • Windows 10 (oder Windows 11 ohne Mirrored-Modus): Standardmodus NAT mit localhost-Forwarding – funktioniert für pgAdmin in aller Regel ebenfalls über 127.0.0.1:5432.
  • An der PostgreSQL-Konfiguration ändern wir im Normalfall nichts: listen_addresses = 'localhost' und die Standard-pg_hba.conf reichen. Erst wenn man über die WSL-IP-Adresse verbinden muss, sind Änderungen nötig (siehe W4).
  • Das Projekt liegt im Linux-Dateisystem unter ~/vpi_workshop, nicht unter /mnt/c/….

Jeder Codeblock sagt im ersten Kommentar, wo er läuft: # PowerShell (Windows) oder # in WSL (Ubuntu).

W1: WSL 2 und Ubuntu 26.04 installieren

Voraussetzung: Windows 10 ab Version 2004 oder Windows 11, Virtualisierung im BIOS/UEFI aktiv. PowerShell als Administrator:

# PowerShell (als Administrator)
# WSL selbst auf den aktuellen Stand bringen
wsl --update

# Ubuntu 26.04 LTS installieren (verfügbare Namen: wsl --list --online)
wsl --install -d Ubuntu-26.04
# danach Windows neu starten, falls verlangt

Nach dem Neustart fragt Ubuntu nach einem Linux-Benutzernamen und Passwort; das Passwort braucht man später für sudo. Gibt es schon eine WSL-1-Distribution: vorher wsl --set-default-version 2.

✅ Test:

# PowerShell
wsl --version
wsl -l -v

Erwartet: WSL-Version 2.x und Ubuntu-26.04 mit VERSION 2. Fehlt wsl --version: wsl --update. Steht dort VERSION 1: wsl --set-version Ubuntu-26.04 2.

W2: systemd aktivieren und .wslconfig anlegen

systemd: Die Ubuntu-Schritte verwenden systemctl. Dafür muss in WSL systemd laufen. Prüfen:

# in WSL (Ubuntu)
cat /etc/wsl.conf

Fehlt der Abschnitt, ergänzen wir ihn:

# in WSL (Ubuntu)
sudo tee -a /etc/wsl.conf > /dev/null <<'EOF'
[boot]
systemd=true
EOF

Speicher und Netzwerkmodus: Die Einstellungen der WSL-VM stehen in %UserProfile%\.wslconfig (anfangs nicht vorhanden). Standardmäßig bekommt WSL die Hälfte des RAMs. Für Qwen 3 8B empfehlen wir memory=10GB bei 16 GB RAM und memory=16GB ab 32 GB.

networkingMode=mirrored gibt es erst ab Windows 11 22H2. Unter Windows 10 die Zeile weglassen, dann gilt NAT.

# PowerShell (als normaler Benutzer) – öffnet bzw. erzeugt die Datei im Editor
notepad "$env:USERPROFILE\.wslconfig"
# Inhalt von %UserProfile%\.wslconfig
[wsl2]
memory=10GB
# nur Windows 11 22H2+; unter Windows 10 diese Zeile weglassen
networkingMode=mirrored

Die Datei muss .wslconfig heißen, nicht .wslconfig.txt (in Notepad „Alle Dateien“ wählen). Alternativ setzt die App „WSL Settings“ dieselben Werte.

Die Änderungen gelten erst nach einem Neustart der WSL-VM:

# PowerShell
wsl --shutdown
# ca. 8 Sekunden warten, dann Ubuntu wieder öffnen
wsl -d Ubuntu-26.04

✅ Test:

# in WSL (Ubuntu)
ps -p 1 -o comm=           # welcher Prozess ist PID 1?
systemctl is-system-running
free -h                    # Gesamtspeicher der WSL-VM
wslinfo --networking-mode  # nur bei neueren WSL-Versionen vorhanden

Erwartet: systemd als PID 1, running (oder degraded – in WSL meist harmlos), bei free -h etwa der eingestellte Speicher, bei wslinfo mirrored bzw. nat. Fehlt wslinfo: Im Mirrored-Modus zeigt ip addr dieselben Adressen wie ipconfig unter Windows.

W3: PostgreSQL 18 und pgvector in WSL

Hier gibt es nichts Windows-Spezifisches: Schritt 1–3 (Ubuntu) samt Tests in der WSL-Shell ausführen.

Stolperstein: Ein natives Windows-PostgreSQL (z. B. vom EDB-Installer) belegt ebenfalls Port 5432. Dann landen Windows-Programme dort statt in WSL (siehe W4). Vorher prüfen:

# PowerShell
Get-Service *postgres*

Läuft ein Dienst wie postgresql-x64-16: stoppen und auf manuellen Start stellen (PowerShell als Administrator: Stop-Service postgresql-x64-16; Set-Service postgresql-x64-16 -StartupType Manual).

✅ Test:

# in WSL (Ubuntu)
pg_lsclusters
systemctl is-enabled postgresql
sudo ss -ltnp | grep 5432
psql "postgresql://raguser:rag123@localhost:5432/vpi" -c "SELECT extversion FROM pg_extension WHERE extname = 'vector';"

Erwartet: 18 main 5432 online, enabled, ss zeigt 127.0.0.1:5432 und [::1]:5432, pgvector 0.8.x. Nach wsl --shutdown und erneutem Öffnen muss der Cluster wieder online sein – sonst läuft systemd nicht (W2).

W4: Zugriff von Windows – pgAdmin, listen_addresses und pg_hba.conf

Die Python-Skripte laufen in WSL und brauchen W4 nicht. W4 betrifft Programme unter Windows wie pgAdmin 4, die von außen auf die Datenbank in WSL zugreifen. Dabei greifen drei Ebenen ineinander:

  • Netzwerk Windows ↔ WSL: Kommt das TCP-Paket überhaupt bei der VM an? (Mirrored-Modus, localhost-Forwarding, WSL-IP, Firewall)
  • listen_addresses in postgresql.conf: Auf welchen Adressen in der VM nimmt PostgreSQL Verbindungen an?
  • pg_hba.conf: Darf dieser Client (Adresse, Benutzer, Datenbank) sich mit dieser Methode anmelden?

a) Der Normalfall: pgAdmin über 127.0.0.1

In pgAdmin unter Object → Register → Server…, Reiter Connection:

FeldWertHinweis
Host name/address127.0.0.1Bewusst nicht localhost – siehe unten
Port5432
Maintenance databasevpiDie Standardvorgabe postgres geht auch, vpi ist aber die Datenbank, die uns interessiert
UsernameraguserNicht postgres – siehe unten
Passwordrag123„Save password“ nur auf dem eigenen Rechner

Das funktioniert ohne Änderung an PostgreSQL: Im Mirrored-Modus teilen sich Windows und WSL 127.0.0.1, im NAT-Modus leitet WSL localhost-Verbindungen weiter. Die Verbindung kommt also von einer Loopback-Adresse – und dafür erlaubt die Standard-pg_hba.conf den Login mit Passwort.

Die drei klassischen Stolpersteine dabei:

  • localhost wird unter Windows oft zu ::1 (IPv6). Im Mirrored-Modus wird ::1 zwischen Windows und WSL nicht unterstützt. Deshalb 127.0.0.1 eintragen.
  • Der Superuser postgres hat kein Passwort. Unter Ubuntu meldet er sich nur lokal per peer an; über TCP braucht es ein Passwort. Also raguser verwenden oder bewusst eines setzen: sudo -u postgres psql -c "\password postgres".
  • Ein Windows-PostgreSQL „gewinnt“ still. Läuft unter Windows ein eigener Dienst auf 5432, verbindet sich pgAdmin mit diesem – ohne Fehlermeldung. Symptom: raguser oder vpi fehlen angeblich. Der nächste Test zeigt, wer auf 5432 lauscht.

✅ Test:

# PowerShell
Test-NetConnection 127.0.0.1 -Port 5432

# Wer lauscht auf Port 5432?
netstat -ano | findstr :5432
Get-NetTCPConnection -LocalPort 5432 -State Listen |
  Select-Object LocalAddress, LocalPort, OwningProcess,
    @{n='Prozess'; e={(Get-Process -Id $_.OwningProcess).ProcessName}}

Erwartet: TcpTestSucceeded : True. Ein Prozess postgres in der Liste wäre ein Windows-PostgreSQL. Im NAT-Modus erscheint ein WSL-Hilfsprozess, im Mirrored-Modus eventuell kein Eintrag.

Der Beweis, dass pgAdmin in WSL gelandet ist – im Query Tool (Datenbank vpi):

✅ Test:

-- pgAdmin, Query Tool, Datenbank vpi
SELECT version(), inet_server_addr(), inet_client_addr(), current_user;

Erwartet: PostgreSQL 18.x on x86_64-pc-linux-gnu (Linux – ein Windows-PostgreSQL meldet … windows … Visual C++), current_user = raguser, inet_client_addr() = 127.0.0.1.

b) listen_addresses – wann 'localhost' reicht und wann nicht

Der Parameter heißt listen_addresses – Plural. Mit listen_address = '*' startet PostgreSQL nach dem nächsten Neustart nicht mehr (Log: unrecognized configuration parameter "listen_address"). Bei einem Reload wird die Zeile nur ignoriert.

Die Datei ist /etc/postgresql/18/main/postgresql.conf. Dort steht #listen_addresses = 'localhost' – das ist auch der eingebaute Standard.

Situationlisten_addressesBegründung
Skripte in WSL, pgAdmin unter Windows im Mirrored-Modus'localhost' (Standard)Windows verbindet über die gemeinsame Loopback-Adresse 127.0.0.1
pgAdmin unter Windows im NAT-Modus, localhost-Forwarding funktioniert (Test in a) erfolgreich)'localhost' (Standard)Das Forwarding bedient auch Ports, die in der VM nur auf localhost gebunden sind
NAT-Modus, aber Verbindung nur über die WSL-IP möglich (Forwarding defekt, abgeschaltet, durch VPN/Sicherheitssoftware gestört, oder Zugriff von einer anderen VM bzw. einem Docker-Container)'*' oder 'localhost,172.x.y.z'Die Verbindung kommt jetzt auf der eth0-Adresse der VM an, nicht auf Loopback

Sicherheit: '*' lauscht auf allen Schnittstellen. Im NAT-Modus ist die VM nur vom eigenen Rechner erreichbar. Im Mirrored-Modus hat WSL die echten Adressen des Rechners und wäre aus dem LAN erreichbar – dort nie '*' setzen. Eine fest eingetragene WSL-IP kann sich nach jedem Neustart ändern.

Ändern (nur im NAT-Modus mit Zugriff über die WSL-IP):

# in WSL (Ubuntu)
sudo nano /etc/postgresql/18/main/postgresql.conf
#   Zeile suchen und ändern zu:
#   listen_addresses = '*'          # Achtung: Plural! "listen_addresses"

# listen_addresses wirkt NUR nach einem Neustart (ein Reload genügt nicht)
sudo systemctl restart postgresql

Wurde der Wert einmal per ALTER SYSTEM gesetzt, steht er in postgresql.auto.conf und hat Vorrang vor postgresql.conf. Woher der aktive Wert kommt, zeigt pg_settings:

✅ Test:

# in WSL (Ubuntu)
pg_lsclusters
sudo ss -ltnp | grep 5432
sudo -u postgres psql -c "SHOW listen_addresses;"
sudo -u postgres psql -c "SELECT setting, sourcefile, sourceline FROM pg_settings WHERE name = 'listen_addresses';"

Erwartet: Cluster online. Mit 'localhost' zeigt ss 127.0.0.1:5432 und [::1]:5432, mit '*' 0.0.0.0:5432 und [::]:5432. sourcefile nennt die Datei (leer = Standard). Ist der Cluster down: sudo tail -n 20 /var/log/postgresql/postgresql-18-main.log.

c) pg_hba.conf – wer darf sich wie anmelden?

listen_addresses entscheidet, ob eine Verbindung ankommt. Ob sie angemeldet wird, entscheidet /etc/postgresql/18/main/pg_hba.conf. Die Standardzeilen unter Ubuntu:

# TYPE  DATABASE        USER            ADDRESS                 METHOD
local   all             postgres                                peer
local   all             all                                     peer
host    all             all             127.0.0.1/32            scram-sha-256
host    all             all             ::1/128                 scram-sha-256
# (darunter noch Zeilen für "replication")
  • local = Unix-Socket (ohne -h). Methode peer: Der Linux-Benutzer muss wie die Rolle heißen, kein Passwort.
  • host = TCP/IP. Methode scram-sha-256: Anmeldung mit Passwort.
  • Es gilt die erste passende Zeile. Scheitert sie, wird keine weitere probiert.

Daraus folgt ein Fehler, über den fast jeder stolpert:

# in WSL (Ubuntu), als Linux-Benutzer z. B. "anna"
psql -U raguser -d vpi
# psql: error: … FATAL:  Peer authentication failed for user "raguser"

Ohne -h geht psql über den Unix-Socket → peer → Linux-Benutzer anna ≠ raguser. Lösung: über TCP verbinden, also psql -h localhost -U raguser -d vpi oder die URL aus der Anleitung. Die peer-Zeile nicht ändern.

Eine zusätzliche Zeile braucht man nur, wenn Windows über die WSL-IP im NAT-Modus verbindet. Dann meldet PostgreSQL: FATAL: no pg_hba.conf entry for host "172.30.96.1", user "raguser", database "vpi".

Das tatsächliche Subnetz ermitteln:

# in WSL (Ubuntu)
ip route
#   default via 172.30.96.1 dev eth0 proto kernel
#   172.30.96.0/20 dev eth0 proto kernel scope link src 172.30.98.229
#   → Subnetz 172.30.96.0/20, Windows-Host = 172.30.96.1, WSL-IP = 172.30.98.229
# PowerShell – Gegenprobe von Windows aus
ipconfig            # Adapter "vEthernet (WSL)" bzw. "vEthernet (WSL (Hyper-V firewall))"
wsl hostname -I     # IP-Adresse der WSL-VM (großes I!)

Das Subnetz vergibt Windows; es kann sich nach einem Neustart ändern (meist in 172.16.0.0/12, manchmal 192.168.x.x). Darum geben wir den ganzen Block frei – aber nur für vpi, nur für raguser und nur mit Passwort. Die Zeile kommt ans Ende, unter die Standardzeilen:

# in WSL (Ubuntu)
sudo nano /etc/postgresql/18/main/pg_hba.conf
#   am Ende ergänzen:
# TYPE  DATABASE  USER     ADDRESS          METHOD
host    vpi       raguser  172.16.0.0/12    scram-sha-256

# pg_hba.conf wird mit einem Reload neu eingelesen (kein Neustart nötig)
sudo systemctl reload postgresql
#   oder in psql als Superuser:  SELECT pg_reload_conf();

Niemals trust – auch nicht zum Testen: Damit kommt jeder ohne Passwort als beliebige Rolle hinein, auch als postgres. Ebenso wenig 0.0.0.0/0.

In pgAdmin dann die WSL-IP (wsl hostname -I) als Host eintragen. Weil sie sich ändern kann, ist das nur eine Notlösung – Mirrored-Modus oder localhost-Forwarding sind bequemer.

✅ Test:

# in WSL (Ubuntu)
sudo -u postgres psql -c "SELECT line_number, type, database, user_name, address, netmask, auth_method, error FROM pg_hba_file_rules;"
# PowerShell – Verbindung über die WSL-IP (nur NAT-Modus mit listen_addresses='*')
Test-NetConnection (wsl hostname -I).Trim().Split(' ')[0] -Port 5432

Erwartet: pg_hba_file_rules zeigt die neue Zeile host | {vpi} | {raguser} | 172.16.0.0 | 255.240.0.0 | scram-sha-256. Die Spalte error muss überall leer sein – sonst gilt die ganze Datei nicht und die alten Regeln bleiben aktiv. Test-NetConnection: TcpTestSucceeded : True.

d) Firewall im Mirrored-Modus

Ab Windows 11 22H2 filtert die Hyper-V-Firewall den Verkehr zur WSL-VM. Für pgAdmin auf demselben Rechner ist keine Regel nötig. Soll ein anderer Rechner im LAN zugreifen (für den Workshop nicht empfohlen), nur diesen Port freigeben – nicht pauschal -DefaultInboundAction Allow:

# PowerShell (als Administrator) – nur falls Zugriff aus dem LAN wirklich gewollt ist
New-NetFirewallHyperVRule -Name "WSL-PostgreSQL" -DisplayName "WSL PostgreSQL 5432" `
  -Direction Inbound -VMCreatorId '{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}' `
  -Protocol TCP -LocalPorts 5432

Übersicht: Symptom → Ursache → Lösung

SymptomUrsacheLösung
pgAdmin: connection refused / Timeout auf localhost:5432WSL-VM läuft nicht (sie beendet sich nach einiger Zeit ohne offenes Terminal) oder localhost wird zu ::1 aufgelöstUbuntu-Fenster öffnen bzw. wsl -d Ubuntu-26.04; in pgAdmin 127.0.0.1 statt localhost
pgAdmin verbindet, aber role "raguser" does not exist oder Datenbank vpi fehltEin natives Windows-PostgreSQL belegt Port 5432 und „gewinnt“Get-NetTCPConnection -LocalPort 5432; Windows-Dienst stoppen oder anderen Port verwenden; Kontrolle mit SELECT version();
password authentication failed for user "postgres"postgres hat unter Ubuntu kein Passwort (nur peer lokal)Als raguser verbinden oder bewusst \password postgres setzen
In WSL: Peer authentication failed for user "raguser"psql ohne -h → Unix-Socket → Regel local … peerpsql -h localhost -U raguser -d vpi bzw. die Verbindungs-URL verwenden
no pg_hba.conf entry for host "172.…"Verbindung über die WSL-IP (NAT); keine passende host-ZeileZeile host vpi raguser 172.16.0.0/12 scram-sha-256 + systemctl reload postgresql
Verbindung über WSL-IP: connection refusedlisten_addresses = 'localhost' lauscht nicht auf eth0listen_addresses = '*' + systemctl restart postgresql (nur NAT-Modus)
Gestern ging es über die WSL-IP, heute nicht mehrDie WSL-IP (und ggf. das Subnetz) hat sich nach dem Neustart geändertwsl hostname -I neu abfragen; besser Mirrored-Modus bzw. 127.0.0.1 verwenden
PostgreSQL startet nach Änderung nicht mehr (pg_lsclusters: down)Unbekannter Parameter (Tippfehler wie listen_address) oder sonstiger Syntaxfehler in postgresql.confLog lesen (/var/log/postgresql/postgresql-18-main.log), Zeile korrigieren, systemctl restart postgresql
Änderung an pg_hba.conf wirkt nichtKein Reload, eine frühere Zeile passt zuerst, oder Syntaxfehler (dann gelten die alten Regeln)SELECT * FROM pg_hba_file_rules; prüfen (Reihenfolge, Spalte error), dann pg_reload_conf()
Änderung an listen_addresses wirkt nichtNur Reload statt Restart, oder Wert per ALTER SYSTEM in postgresql.auto.conf überschriebensudo systemctl restart postgresql; sourcefile in pg_settings prüfen

W5: Python-Umgebung und Projektdateien

Python wie in Schritt 4–5 (Ubuntu) in der WSL-Shell einrichten. Windows-spezifisch ist nur, wo die Dateien liegen:

  • Das Projekt gehört ins Linux-Dateisystem: ~/vpi_workshop (= /home/<linux-user>/vpi_workshop). Unter /mnt/c/… liegen die Dateien auf der Windows-Partition und jeder Zugriff geht über eine Dateisystembrücke – pip install, das Laden des Embedding-Modells und das Schreiben von vektoren.pkl werden dort deutlich langsamer.
  • Auch das venv muss in WSL angelegt werden. Ein unter Windows erzeugtes venv (mit Scripts\python.exe) funktioniert in Linux nicht – und umgekehrt.
  • Dateien von Windows nach WSL bringen: im Explorer die Adresse \\wsl.localhost\Ubuntu-26.04\home\<linux-user>\vpi_workshop eingeben (ältere Variante: \\wsl$\Ubuntu-26.04\…) und die Dateien hineinziehen – oder in WSL mit cp von /mnt/c kopieren. Umgekehrt öffnet explorer.exe . in WSL das aktuelle Verzeichnis im Windows-Explorer.
  • Mit Windows-Editoren bearbeitete Dateien haben oft Windows-Zeilenenden (CRLF). Für Python, die CSV und das Modelfile ist das unkritisch; Shell-Skripte brechen damit aber mit $'\r': command not found ab (Abhilfe: sudo apt install dos2unix, dos2unix datei.sh). Bequem und sauber: VS Code mit der Erweiterung „WSL“ und in WSL code . aufrufen.
# in WSL (Ubuntu) – Beispiel: Workshop-Dateien liegen unter Windows im Download-Ordner
mkdir -p ~/vpi_workshop
cp -r /mnt/c/Users/<WindowsName>/Downloads/vpi_workshop/. ~/vpi_workshop/   # Skripte, .env.example, Modelfile, csv_output/
cd ~/vpi_workshop

# danach Schritt 4 und 5 (Ubuntu): apt install python3-venv …, python3 -m venv venv, pip install …

✅ Test:

# in WSL (Ubuntu)
cd ~/vpi_workshop && pwd && ls
source venv/bin/activate
which python
python3 -c "import pandas, psycopg2, openai, torch; print('Pakete OK, torch', torch.__version__)"
python3 -c "import psycopg2; c = psycopg2.connect('postgresql://raguser:rag123@localhost:5432/vpi'); print('DB OK'); c.close()"

Erwartet: pwd zeigt /home/<linux-user>/vpi_workshop (nicht /mnt/c/…), die Skripte, .env.example und der Ordner csv_output sind da, which python zeigt …/venv/bin/python, dazu Pakete OK und DB OK. Danach den Test aus Schritt 5 ausführen.

W6: Ollama – in WSL (empfohlen) oder als Windows-App

Empfehlung: Ollama in WSL installieren, genau wie in Schritt 6 (Ubuntu). Dann bleibt alles in einer Umgebung, die Skripte behalten http://localhost:11434/v1, und es gibt keine Firewall-Fragen.

  • NVIDIA-Grafikkarte: WSL 2 reicht die GPU an Linux durch. Nötig ist nur ein aktueller NVIDIA-Treiber unter Windows (mit WSL-Unterstützung, siehe CUDA in WSL). In WSL keinen Linux-NVIDIA-Treiber installieren (nvidia-driver-…) – das überschreibt die von Windows bereitgestellten Bibliotheken unter /usr/lib/wsl/lib. Ein separates CUDA-Toolkit braucht Ollama nicht.
  • Ohne (unterstützte) GPU läuft Qwen 3 8B auf der CPU – langsamer, aber funktionsfähig. Dann ist der memory-Wert aus W2 entscheidend. Die GPU-Unterstützung für AMD- und Intel-Grafik in WSL ist eingeschränkt und hängt von Karte und Treiber ab; wer eine AMD-Karte nutzen will, ist mit der Windows-App (Alternative unten) oft besser bedient.
  • Mit systemd (W2) richtet das Installationsskript einen Dienst ollama ein, der mit der WSL-VM startet.

✅ Test:

# in WSL (Ubuntu)
nvidia-smi                       # nur bei NVIDIA-GPU
systemctl is-active ollama
curl -s http://localhost:11434/api/version
ollama run vpi-qwen "Antworte mit einem Wort: Wie heißt die Hauptstadt von Österreich?"
ollama ps

Erwartet: nvidia-smi zeigt die Grafikkarte (bei NVIDIA), active, JSON mit der Version, Antwort Wien. ollama ps zeigt 100% GPU oder 100% CPU. Danach die Tests aus Schritt 6.

Alternative: die Ollama-App für Windows (ollama.com/download/windows) – sinnvoll, wenn Ollama dort schon läuft oder die GPU nur unter Windows unterstützt wird. Dann nicht zusätzlich Ollama in WSL installieren (Port-Konflikt im Mirrored-Modus).

  • Mirrored-Modus (Windows 11): Die Windows-App lauscht auf 127.0.0.1:11434, und WSL erreicht sie unter derselben Adresse. Die Skripte bleiben unverändert. Modell und Profil werden in PowerShell angelegt (das Modelfile dazu nach Windows kopieren): ollama pull qwen3:8b und ollama create vpi-qwen -f Modelfile.
  • NAT-Modus (Windows 10): Aus WSL ist Windows nur über die Host-IP erreichbar, und die Windows-App lauscht standardmäßig nur auf 127.0.0.1. Man muss daher (1) unter Windows die Umgebungsvariable OLLAMA_HOST=0.0.0.0:11434 setzen (Ollama im Infobereich beenden, „Umgebungsvariablen für dieses Konto bearbeiten“, Variable anlegen, Ollama neu starten), (2) eine Firewall-Regel anlegen, die Port 11434 nur für das WSL-Netz öffnet – 0.0.0.0 macht Ollama sonst im ganzen LAN erreichbar, ohne jede Authentifizierung – und (3) in den Skripten localhost:11434 durch die Host-IP ersetzen. Weil sich diese IP ändern kann, ist das der umständlichste Weg.
# PowerShell (als Administrator) – nur NAT-Modus mit Windows-Ollama
New-NetFirewallRule -DisplayName "Ollama fuer WSL" -Direction Inbound -Protocol TCP `
  -LocalPort 11434 -RemoteAddress 172.16.0.0/12 -Action Allow
# in WSL (Ubuntu) – Host-IP ermitteln und in .env eintragen
WINHOST=$(ip route show default | awk '{print $3}')
echo $WINHOST
sed -i "s#^OLLAMA_URL=.*#OLLAMA_URL=http://$WINHOST:11434/v1#" ~/vpi_workshop/.env

✅ Test:

# in WSL (Ubuntu)
# Mirrored-Modus:
curl -s http://localhost:11434/api/version
# NAT-Modus:
curl -s http://$(ip route show default | awk '{print $3}'):11434/api/version
grep OLLAMA_URL ~/vpi_workshop/.env

Erwartet: JSON mit der Ollama-Version. Connection refused: OLLAMA_HOST nicht gesetzt oder Ollama nicht neu gestartet. Timeout: Firewall-Regel fehlt oder WSL-Subnetz außerhalb von 172.16.0.0/12.

W7: Pipeline ausführen und Gesamttest

Jetzt folgen Schritt 7–11 (Ubuntu) und der Gesamttest unverändert in der WSL-Shell:

# in WSL (Ubuntu)
cd ~/vpi_workshop && source venv/bin/activate
cp -n .env.example .env            # Zugangsdaten (Schritt 7)
(cd csv_output && python merge.py)
python 01_csv_zu_text.py
python 02_text_zu_vektoren.py
python 03_vektoren_nach_pgvector.py
psql "postgresql://raguser:rag123@localhost:5432/vpi" -c "REINDEX INDEX vpi_embeddings_embedding_idx;"
python 07_rag_abfrage.py "Welches Produkt hatte im Dezember 2023 den höchsten Preisanstieg?"

Zum Schluss der Test von der Windows-Seite aus, ohne WSL-Fenster:

✅ Test:

# PowerShell
# 1) Ports erreichbar? (Ollama-Port 11434 nur bei Ollama in WSL bzw. Windows-App im Mirrored-Modus)
Test-NetConnection 127.0.0.1 -Port 5432
Test-NetConnection 127.0.0.1 -Port 11434

# 2) Datenbank und Pipeline in WSL von Windows aus anstoßen
#    -e startet das Programm direkt (ohne Linux-Shell), dadurch bleiben die Argumente intakt;
#    <linux-user> durch den eigenen Linux-Benutzernamen ersetzen
wsl -d Ubuntu-26.04 -e psql "postgresql://raguser:rag123@localhost:5432/vpi" -c "SELECT count(*) FROM vpi_embeddings;"
wsl -d Ubuntu-26.04 --cd /home/<linux-user>/vpi_workshop -e ./venv/bin/python 07_rag_abfrage.py "Wie hat sich der Butterpreis im Jahr 2024 entwickelt?"

Erwartet: zweimal TcpTestSucceeded : True, count = 3276 und eine deutsche Antwort mit Quellen. In pgAdmin liefert SELECT count(*) FROM vpi_embeddings; ebenfalls 3276 – Windows und WSL sehen dieselbe Datenbank.

Tipp: Die WSL-VM fährt nach einiger Zeit ohne offene Sitzung herunter, mit ihr PostgreSQL und Ollama; pgAdmin meldet dann connection refused. Ein Ubuntu-Terminal öffnen (oder wsl -d Ubuntu-26.04) startet alles wieder.

Fehlerbehebung

ProblemLösung
password authentication failed for user "raguser"Passwort neu setzen: ALTER ROLE raguser PASSWORD 'rag123'; (als Superuser)
role "postgres" does not exist (macOS)Bei Homebrew ist dein macOS-Benutzer der Superuser: psql postgres
extension "vector" is not availableUbuntu: postgresql-18-pgvector installieren; macOS: brew install pgvector und PostgreSQL neu starten
permission denied to create extension "vector"Die Extension muss ein Superuser in der Datenbank vpi anlegen (Schritt 3)
psql: command not found (macOS)PATH aus Schritt 1 setzen und Terminal neu öffnen
Verbindung zu Ollama verweigertUbuntu: sudo systemctl start ollama; macOS: brew services start ollama oder ollama serve
Antworten sind langsamErste Antwort lädt das Modell in den RAM (10–30 s); ohne GPU braucht Qwen 3 8B einige Sekunden pro Antwort
Doppelte Treffer in der SucheSkript 03 wurde mehrfach ausgeführt → TRUNCATE vpi_embeddings; und neu importieren

Fazit

Mit PostgreSQL, pgvector, einem kleinen Embedding-Modell und einem lokalen LLM entsteht in einer Stunde ein vollständiges RAG-System, das Fragen zu echten österreichischen Preisdaten mit nachvollziehbaren Quellen beantwortet. Die eigene Rolle raguser sorgt dafür, dass die Anwendung nur das darf, was sie braucht. Das Muster lässt sich auf beliebige eigene Daten übertragen: Texte erzeugen, einbetten, in pgvector speichern – und das LLM nur noch formulieren lassen.