pgvector und Hybrid Search

Track KI · M2 RAG, Baustein 04 · ca. 70 Min. (zwei Sitzungen)

Worum es geht

In Lektion 08 hast du Chunks als Vektoren gespeichert und mit numpy durchsucht. Das geht bis zu ein paar hundert Chunks. Danach brauchst du eine Datenbank, die Vektor-Ähnlichkeitssuche selbst kann, statt bei jeder Frage alle Vektoren im Speicher zu vergleichen. In der Quelle ist das pgvector, eine Erweiterung für Postgres: Vektorsuche direkt in der Datenbank, die du schon kennst, ohne ein zweites System.

Dazu kommt ein zweites Problem: Reine Vektorsuche ist überraschend schwach bei exakten Begriffen (Fehlercodes, Artikelnummern). Dort gewinnt die klassische Stichwortsuche (BM25). Hybrid Search kombiniert beides und führt die zwei Trefferlisten zusammen, zum Beispiel mit Reciprocal Rank Fusion (RRF).

Weder Postgres noch pgvector laufen im Browser. Darum gibt es in dieser Lektion zwei Teile. Die SQL-Seite (Tabelle, Operatoren, Index) siehst du als Textblöcke, die nicht im Browser ausführbar sind. Dieselbe Logik (Distanzmaße, Stichwort-Score, RRF, Metadaten-Filter, Index mit Näherungssuche) baust du in reinem Python und numpy nach, klein genug, dass du jede Zahl nachrechnen kannst. Ganz am Ende steht eine Projektaufgabe, die du lokal mit Docker machst.

Details, die nicht in der Quelle stehen, sind mit “(bitte prüfen)” markiert: Prüfe sie vor dem Einsatz in der pgvector-Doku.

Zeitplan ehrlich: etwa 30 Minuten Lesen (fünf Schritte, viel Rechnen) und etwa 40 Minuten für die fünf Übungen, zusammen rund 70 Minuten. Teile es in zwei Sitzungen: Sitzung 1 ist Schritt 1 bis 3 mit Übung 1 und 2, Sitzung 2 ist Schritt 4 und 5 mit Übung 3 bis 5. Die Projektaufgabe kommt danach.

Von JS/TS her gedacht

Idee JS/TS-Welt Hier
Vektorsuche in der DB Pinecone, Qdrant, Weaviate als eigener Dienst pgvector: Postgres-Erweiterung, vector-Spalte in einer normalen Tabelle
Stichwortsuche LIKE '%wort%', Elasticsearch, fuse.js tsvector / BM25 (Quelle), oder eigener Score
Zwei Trefferlisten mischen [...a, ...b] plus Deduplizieren Reciprocal Rank Fusion: nach Rang, nicht nach Score
Sortieren nach Rang arr.sort((x, y) => s[y] - s[x]) sorted(ids, key=lambda i: -score[i])
Index, der schneller, aber ungenau ist Bloom-Filter (kann sich irren) Approximate Nearest Neighbor (ANN): schnell, findet nicht immer alle echten Treffer

Der Bloom-Filter ist nur eine Analogie: Auch er tauscht Genauigkeit gegen Geschwindigkeit. Bei ANN ist der Fehler allerdings ein verpasster Treffer (Recall sinkt), kein falscher.

Konzept

Schritt 1: Die SQL-Seite (nicht im Browser ausführbar)

Das ist das Beispiel der Quelle. Du liest es, ausgeführt wird es erst in der Projektaufgabe am Ende:

-- pgvector-Erweiterung, einmalig
CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE chunks (
    id serial PRIMARY KEY,
    text text NOT NULL,
    embedding vector(1024)
);
CREATE INDEX ON chunks USING hnsw (embedding vector_cosine_ops);

-- Ähnlichste 5 Chunks zu einer Anfrage
SELECT text FROM chunks
ORDER BY embedding <=> '[0.01, -0.02, ...]'
LIMIT 5;

Was steht da?

  • vector(1024): Die Spalte hat eine feste Dimension (dimension). Sie muss zur Länge der Vektoren deines Embedding-Modells passen (Quelle: typisch 1024 bis 3072 Zahlen). Ein Vektor falscher Länge wird abgelehnt (bitte prüfen: genaue Fehlermeldung). Wechselst du das Embedding-Modell, brauchst du meist eine neue Spalte und musst alle Chunks neu einbetten (das kennst du aus Lektion 08).
  • <=>: der Operator für die Kosinus-Distanz (cosine distance). Das ist Distanz, nicht Ähnlichkeit: kleiner ist besser, darum ORDER BY ... ASC (der Standard) und LIMIT 5.
  • vector_cosine_ops im Index: sagt dem Index, welches Distanzmaß er beschleunigen soll. Index und Abfrage müssen dasselbe Maß benutzen, sonst nutzt Postgres den Index nicht (bitte prüfen).
  • hnsw: eine von zwei Index-Arten der Quelle. Die andere ist ivfflat.

Weitere Operatoren, die nicht in der Quelle stehen (bitte prüfen, aktuelle Liste in der pgvector-Doku):

-- <->  euklidischer Abstand (L2)           Index: vector_l2_ops
-- <#>  NEGATIVES Skalarprodukt             Index: vector_ip_ops
-- <=>  Kosinus-Distanz                     Index: vector_cosine_ops

Warum negatives Skalarprodukt? Postgres sortiert aufsteigend, und ein großes Skalarprodukt soll vorne stehen. Mit dem Minuszeichen passt die Sortierrichtung zu den anderen beiden Operatoren: überall gilt “kleiner Wert = näher”.

Die zwei Index-Arten, grob (die Quelle nennt nur die Namen, das Prinzip ist allgemeines Wissen):

Index Idee Einstellung Du bildest es nach als
ivfflat Vektoren werden in Listen (Buckets) um Zentren sortiert. Bei einer Abfrage werden nur die nächsten Listen durchsucht. beim Anlegen lists, bei der Abfrage ivfflat.probes (bitte prüfen) Bucket-Index in Schritt 5
hnsw Mehrschichtiger Graph, die Suche springt von Nachbar zu Nachbar. beim Anlegen m, ef_construction, bei der Abfrage hnsw.ef_search (bitte prüfen) nicht nachgebaut

Beide sind ANN-Indizes (approximate nearest neighbor): Sie sind schnell, finden aber nicht garantiert die wirklich nächsten Nachbarn. Ohne Index vergleicht Postgres jede Zeile (exakte Suche, linear in der Chunk-Zahl). Das ist die Stolperfalle der Quelle, dazu unten mehr.

Schritt 2: Distanz, Ähnlichkeit, Skalarprodukt

Aus Lektion 08 kennst du die Kosinus-Ähnlichkeit (cosine similarity). Die Kosinus-Distanz (cosine distance) ist einfach 1 - Ähnlichkeit: identisch ergibt 0, unabhängig 1, entgegengesetzt 2 (nach der Formel, bitte prüfen, ob pgvector genau so definiert). Wir rechnen alle Maße für einen Vektor a gegen drei Kandidaten durch:

Lies die Ausgabe nach Maß getrennt:

  • Kosinus: b ist perfekt (Distanz 0), obwohl es doppelt so lang ist. d ist fast so gut. c ist unbrauchbar.
  • Skalarprodukt: b gewinnt mit 28.0, d bekommt nur 12.5. Die Länge von b treibt den Wert hoch.
  • L2-Abstand: d ist mit 0.5 am nächsten, b liegt bei 3.742, weil der Unterschied in der Länge zählt. Für L2 ist b “weit weg”, für Kosinus “identisch”.

Wann sind die Maße gleich? Wenn alle Vektoren auf Länge 1 normiert sind. Dann ist das Skalarprodukt gleich der Kosinus-Ähnlichkeit, und die Rangfolge von L2-Abstand, Kosinus und Skalarprodukt stimmt überein (allgemeines Wissen, hier nachgerechnet):

Praktische Folge: Liefert dein Embedding-Modell bereits normierte Vektoren (bei vielen Modellen der Fall, bitte beim Modell prüfen), ist das Skalarprodukt (<#>) der schnellere Weg zum gleichen Ergebnis wie Kosinus. Bei nicht normierten Vektoren gewinnt beim Skalarprodukt der längste, genau die Falle aus Lektion 08.

Schritt 3: Stichwortsuche und warum Vektoren allein nicht reichen

Ein kleines Beispiel mit fünf Chunks. Die Vektoren haben vier von Hand gewählte Achsen: Zähler-Hardware, Fehlermeldung, Ablesen, Abrechnung. Die Chunks A und D unterscheiden sich nur in der Nummer des Fehlercodes. Für ein Embedding-Modell ist E-4711 gegen E-4712 fast derselbe Text (hier simuliert: beide Vektoren liegen dicht beieinander).

Die Vektorsuche setzt D (E-4712) vor A (E-4711): Der Code, nach dem du gefragt hast, steht nur auf Platz 2. Genau das meint die Quelle mit “schwach bei exakten Begriffen”.

Jetzt die Stichwortsuche. Die Quelle nennt BM25. Wir bauen eine einfachere Verwandte nach, deren Kern auch in BM25 steckt: Ein Wort ist wichtig, wenn es selten ist. Für jedes Wort der Frage zählst du, wie oft es im Chunk vorkommt (term frequency, tf), und gewichtest mit der inversen Dokumenthäufigkeit (inverse document frequency, idf):

\[\text{idf}(t) = \ln\frac{N}{\text{df}(t)} \qquad \text{score}(\text{Chunk}) = \sum_{t \in \text{Frage}} \text{tf}(t, \text{Chunk}) \cdot \text{idf}(t)\]

N ist die Zahl aller Chunks, df(t) die Zahl der Chunks, in denen das Wort vorkommt. Ein Wort in allen Chunks hat idf = ln(1) = 0 und zählt nichts. Ein Wort, das in keinem Chunk vorkommt, wird übersprungen (sonst Division durch null).

e-4711 kommt nur in A vor (df=1, idf = ln 5 = 1.609). fehler steht in A und D (ln 2.5 = 0.916). was steht in keinem Chunk (df=0, übersprungen). Jetzt der Score der ganzen Frage:

Nur A hat den Score 1.609. Das Ranking enthält nur Chunks mit Score über 0, die Stichwortsuche liefert hier eine Liste der Länge 1. Zwei Eigenheiten davon, die du kennen musst:

  • Stichwortsuche vergleicht Wörter exakt. zählers oder Zählerstand zählt nicht als zähler. Dort gewinnt die Vektorsuche (Bedeutung statt Buchstaben). Echte Volltextsuche mildert das mit Wortstamm-Reduktion (stemming, bitte prüfen, was Postgres für Deutsch macht).
  • Die Quelle nennt BM25. Das ist dieselbe Idee mit zwei Verbesserungen (allgemeines Wissen, nicht in der Quelle): Häufiges Vorkommen bringt immer weniger zusätzlichen Score (Sättigung), und lange Chunks werden gegenüber kurzen nicht automatisch bevorzugt (Längennormierung). Die Formel (nicht ausgeführt): \(\text{score} = \sum_t \text{idf}(t) \cdot \frac{\text{tf} \cdot (k_1 + 1)}{\text{tf} + k_1 \cdot (1 - b + b \cdot \text{Länge} / \text{Durchschnittslänge})}\). Die Parameter k1 und b haben übliche Standardwerte (bitte in der Doku des genutzten Werkzeugs prüfen). Postgres bringt mit tsvector eine Volltextsuche mit, deren Standard-Ranking nicht BM25 ist (bitte prüfen), die Quelle nennt daneben “ein dediziertes Werkzeug”.

Schritt 4: Reciprocal Rank Fusion

Du hast jetzt zwei Rankings: die Vektorsuche (D, A, B, C, E) und die Stichwortsuche (A). Du kannst ihre Scores nicht addieren: Kosinus liegt zwischen -1 und 1, der Stichwort-Score hat keine feste Obergrenze. Die Skalen passen nicht zusammen.

RRF umgeht das, indem es nur den Rang (rank, Platz 1, 2, 3, …) verwendet. Jeder Chunk bekommt pro Liste, in der er vorkommt, 1 / (k + Rang) und die Beiträge werden addiert. k dämpft den Unterschied zwischen den obersten Plätzen. Ein verbreiteter Wert ist k = 60 (allgemeines Wissen, bitte prüfen, die Quelle nennt nur das Verfahren).

Von Hand für A: In der Vektorliste Platz 2, in der Stichwortliste Platz 1. Also 1/(60+2) + 1/(60+1) = 0.016129 + 0.016393 = 0.032522. D steht nur in der Vektorliste auf Platz 1: 1/61 = 0.016393. Chunks, die in einer Liste fehlen, bekommen von dieser Liste nichts (nicht 0 als Rang). Nachgerechnet:

A liegt jetzt vorn: Es wird in beiden Listen gefunden, das zählt mehr als ein Platz 1 in nur einer. Das ist die Stärke von Hybrid Search: Der Fehlercode kommt von der Stichwortsuche, die Bedeutungs-Treffer (B, C) kommen von den Vektoren. Mit einem kleinen k wächst der Abstand der oberen Plätze, mit großem k zählt der Platz weniger. Mit k=1 wären es A: 0.833 gegen D: 0.5, bei k=60 ist A mit 0.0325 gegen 0.0164 ungefähr doppelt so hoch.

Nach der Zusammenführung kommt in einer RAG-Pipeline oft noch ein dritter Schritt, das Reranking: Ein genaueres, aber langsameres Modell sortiert die besten Kandidaten (zum Beispiel die ersten 20) neu. Das ist Thema der nächsten Lektion. Die Pipeline hat damit drei Stellen: Suche (Vektor und Stichwort), Zusammenführung (RRF) und Reranking.

Gleichstand ist möglich (zwei Chunks mit genau demselben Score). Dann brauchst du eine feste Regel, hier: die kleinere Id zuerst. Das sichert, dass dasselbe Ergebnis bei jedem Lauf herauskommt.

Schritt 5: Metadaten-Filter und Näherungssuche

Metadaten-Filter. In der Praxis soll oft nur ein Teil der Chunks durchsucht werden: nur Dokumente eines Kunden (Mandant, tenant), nur eine Sprache, nur aktuelle Versionen. In SQL ist das eine WHERE-Klausel (Beispiel, nicht ausführbar):

SELECT text FROM chunks
WHERE mandant = 'x'
ORDER BY embedding <=> '[0.3, 1.0, ...]'
LIMIT 2;

Entscheidend ist wann gefiltert wird. Zeigen wir beides am Beispiel: Die Chunks D, A gehören zu Mandant y, die anderen zu Mandant x. Gesucht: die 2 besten Chunks von Mandant x.

Wer nach der Suche filtert, bekommt weniger als k Treffer (hier 0), obwohl es passende Chunks gibt. Mit einem ANN-Index (nächster Absatz) kommt es auf das Zusammenspiel an: Je nachdem, wie die Datenbank filtert, können auch bei WHERE weniger Treffer als LIMIT zurückkommen, weil der Index zuerst nur eine feste Zahl Kandidaten liefert (bei hnsw ist das ef_search, bitte prüfen). Deshalb gilt: Teste Abfragen mit Filter immer mit einem Filter, der selten zutrifft.

Näherungssuche (ANN) mit Bucket-Index. Du baust die Idee von ivfflat nach, in 2 Dimensionen, damit man sie sieht. Die Vektoren sind Punkte auf dem Einheitskreis (jeder Punkt ist ein Winkel). Ein Bucket-Index hat feste Zentren (4 Stück, bei 0, 90, 180, 270 Grad). Jeder Vektor kommt in die Liste seines nächsten Zentrums. Bei einer Abfrage werden nur die probes nächsten Listen durchsucht, nicht alle.

Die Daten kommen aus einem kleinen eigenen Zufallsgenerator, damit sie bei jedem Lauf gleich sind:

Jetzt die exakte Suche (alle 60 Vektoren) gegen die Näherungssuche. Die Abfrage liegt bei 40 Grad, also nahe der Grenze zwischen den Listen 0 und 1:

Mit probes=1 wird nur die Liste des nächsten Zentrums (0 Grad) durchsucht. Zwei der fünf echten Nachbarn liegen aber in der Nachbarliste (90 Grad) und werden nie gesehen: recall = 0.6. Mit probes=2 ist das Ergebnis gleich dem exakten. Mit probes=4 durchsuchst du alles, das ist dann die exakte Suche ohne Beschleunigung.

Recall (hier recall@k) ist der Anteil der wirklichen Top-k, den die Näherungssuche findet. Das ist das Maß für den Preis des Index: Weniger Listen sind schneller und verpassen mehr. Diese Abwägung steckt in jedem ANN-Index. Du stellst sie bei der Abfrage ein (probes, ef_search) und misst sie wie in Lektion 08 mit einem festen Satz Testfragen.

Falle

Falle 1: Kein Index. Ohne hnsw oder ivfflat vergleicht die Datenbank jede Zeile. Die Suche wird linear mit der Chunk-Zahl langsamer, bei einem wachsenden Korpus unbemerkt, bis es plötzlich auffällt (Quelle). Index früh anlegen, nicht erst wenn es spürbar langsam wird. Ob der Index gebaut ist, siehst du in Postgres mit EXPLAIN (bitte prüfen, wie der Plan mit pgvector aussieht).

Falle 2: Index ja, aber Recall nie gemessen. ANN ist ungenau. Wer nur auf die Geschwindigkeit schaut, merkt nicht, dass der Index Treffer verpasst. Miss recall@k gegen die exakte Suche auf einem Satz Testfragen, bevor du probes oder ef_search festlegst.

Falle 3: Filter nach der Suche. Weniger als k oder gar keine Treffer, obwohl es passende Chunks gibt (siehe Beispiel oben).

Falle 4: Distanz mit Ähnlichkeit verwechseln. <=> liefert eine Distanz: klein ist gut. Wer ORDER BY ... DESC schreibt, bekommt die unähnlichsten Chunks. Beim Umrechnen für die Anzeige: 1 - distanz.

Falle 5: Hybrid ohne gemeinsame Skala. Scores aus Vektor- und Stichwortsuche lassen sich nicht einfach addieren. Entweder RRF über Ränge (wie oben) oder zuerst beide Scores auf eine Skala bringen.

Übungen

Übung 1: Code vorhersagen (leicht, ca. 5 Min.)

Gegeben sind q = [3, 4], u = [4, 3] und v = [30, 0]. Du bist der Index und sortierst nach Kosinus-Distanz zu q. Trage ein Tupel mit drei Werten ein: (1) die Kosinus-Distanz (1 - Kosinus-Ähnlichkeit) von q und u, gerundet auf 2 Stellen, (2) das Skalarprodukt von q und v, (3) der Buchstabe des Vektors ("u" oder "v"), der bei der Sortierung nach Kosinus-Distanz vorne liegt. Rechne erst selbst, prüfe dann.

Das Skalarprodukt multipliziert Stelle für Stelle und addiert. Die Kosinus-Distanz teilt zusätzlich durch beide Längen, bevor sie von 1 abzieht. Vergleiche Richtung und Länge von u und v mit q. Die Kosinus-Distanz bewertet nur eines von beiden.

antwort = (0.04, 90.0, "u")
antwort

Übung 2: Selbst schreiben (mittel, ca. 12 Min.)

Schreibe keyword_scores(frage, texte). Sie bekommt die Frage als Text und eine Liste von Chunk-Texten und gibt eine Liste mit einem Score pro Chunk zurück (gleiche Reihenfolge wie texte), nach der Formel aus Schritt 3. Zusätzlich wird der Score jedes Chunks durch die Zahl seiner Tokens geteilt (einfache Längennormierung, damit lange Chunks nicht allein wegen ihrer Länge gewinnen). Ein Chunk ohne Tokens bekommt 0.0. Regeln: Die Funktion tok (zerlegt Text in Wörter, klein geschrieben) steht dir zur Verfügung. Jedes Wort der Frage zählt einmal, auch wenn es mehrfach in der Frage steht. Verglichen werden ganze Wörter (Token), keine Teilstrings. Ein Wort, das in keinem Chunk vorkommt, wird übersprungen. N ist die Zahl der Chunks, idf = math.log(N / df).

Beispiel: keyword_scores("pumpe", ["Die Pumpe läuft", "Pumpe Pumpe", "Ventil"]) gibt ungefähr [0.135, 0.405, 0.0] zurück (df=2, idf = ln 1.5 = 0.405. Erster Chunk: 0.405 / 3 Tokens. Zweiter Chunk: zweimal 0.405, also 0.811 / 2 Tokens).

Tokenisiere zuerst alle Chunks einmal. Welche Zahl pro Wort der Frage brauchst du, bevor du irgendeinen Chunk bewerten kannst? Achte darauf, ob das Wort als eigenes Token vorkommt, nicht als Teilstring. Was passiert bei einem Chunk ohne Tokens?

def keyword_scores(frage, texte):
    toks = [tok(t) for t in texte]
    n = len(toks)
    scores = [0.0] * n
    for wort in set(tok(frage)):
        df = sum(1 for t in toks if wort in t)
        if df == 0:
            continue
        idf = math.log(n / df)
        for i, t in enumerate(toks):
            scores[i] += t.count(wort) * idf
    return [s / len(t) if t else 0.0 for s, t in zip(scores, toks)]
keyword_scores

Übung 3: Selbst schreiben (mittel, ca. 10 Min.)

Schreibe rrf(ranglisten, k=60, gewichte=None). ranglisten ist eine Liste von Listen mit Ids (jede Liste: bester Treffer zuerst, der Rang beginnt bei 1). Rückgabe: eine Liste der Ids, nach RRF-Score absteigend. Bei gleichem Score entscheidet die kleinere Id (Textvergleich). Eine Id, die in einer Liste fehlt, bekommt von dieser Liste nichts. Zusätzlich gibt es gewichtetes RRF: gewichte ist eine Liste mit einer Zahl pro Rangliste, der Beitrag einer Liste ist gewicht / (k + Rang). Ist gewichte None, zählen alle Listen gleich (Gewicht 1).

Beispiele: rrf([["a", "b"], ["b"]], k=10) gibt ["b", "a"] zurück (b: 1/12 + 1/11, a: 1/11). rrf([["a", "b"], ["b", "a"]], k=10) ergibt einen Gleichstand, es gewinnt a. Mit gewichte=[1, 3] gewinnt dagegen b, weil die zweite Liste dreimal so viel zählt.

Sammle pro Id einen Score in einem dict. Jede Liste liefert einen Beitrag aus ihrem Rang. Wie verknüpfst du Liste und Gewicht? Wie sortierst du absteigend nach einer Zahl und bei Gleichstand aufsteigend nach Text?

def rrf(ranglisten, k=60, gewichte=None):
    if gewichte is None:
        gewichte = [1] * len(ranglisten)
    score = {}
    for liste, g in zip(ranglisten, gewichte):
        for rang, i in enumerate(liste, start=1):
            score[i] = score.get(i, 0) + g / (k + rang)
    return [i for i, s in sorted(score.items(), key=lambda x: (-x[1], x[0]))]
rrf

Übung 4: Fehler finden (mittel, ca. 8 Min.)

suche(q, k, mandant) soll die k ähnlichsten Chunks liefern (Liste von Chunk-Nummern, beste zuerst, nach Kosinus-Ähnlichkeit), die beide Bedingungen erfüllen: Sie gehören zum angegebenen Mandanten und sind aktuell (aktuell[i] ist True). Die Funktion läuft ohne Fehlermeldung, liefert aber zu wenige Treffer. Finde den Fehler und repariere ihn. M sind die Vektoren, mandanten die Zuordnung pro Chunk, aktuell die Liste der Aktualitäts-Flags.

Rufe suche(np.array([1.0, 0.0, 0.0]), 3, "beta") auf. Wie viele Treffer erwartest du, wie viele bekommst du? Gehe die Zeilen der Funktion einzeln durch und schau nach jeder Zeile, was in der Variable steht.

def suche(q, k, mandant):
    scores = Mn @ (q / np.linalg.norm(q))
    erlaubt = [i for i in range(len(Mn)) if mandanten[i] == mandant and aktuell[i]]
    erlaubt.sort(key=lambda i: -scores[i])
    return [int(i) for i in erlaubt[:k]]

suche

Übung 5: Selbst schreiben (mittel, ca. 10 Min.)

Ein Bucket-Index wie in Schritt 5, nur größer: 200 Punkte, 8 Listen. Schreibe ann_suche(q, k, probes). Sie gibt ein Tupel (ids, geprueft) zurück: ids sind die k besten Punkt-Nummern (nach Skalarprodukt, bester zuerst) innerhalb der probes nächsten Listen, geprueft ist die Zahl der Punkte, die du dabei verglichen hast (alle Kandidaten der durchsuchten Listen, vor dem Kürzen auf k). Die Daten, Zentren und liste (Bucket-Nummer zu Punktnummern) sind schon aufgebaut.

Gehe vor wie bei ann in Schritt 5. Überlege, an welcher Stelle du die Zahl der Kandidaten kennst, und was bei probes größer als die Zahl der Listen passieren soll.

def ann_suche(q, k, probes):
    nahe = sorted(range(len(zentren)), key=lambda b: -dot2(q, zentren[b]))[:probes]
    kandidaten = [i for b in nahe for i in liste[b]]
    beste = sorted(kandidaten, key=lambda i: -dot2(q, P[i]))[:k]
    return beste, len(kandidaten)
ann_suche

Projektaufgabe: pgvector lokal mit Docker (lokal, nicht im Browser)

Das ist die Übung der Quelle (Baustein 04): chunks-Tabelle mit pgvector anlegen, befüllen und für 3 Testfragen die Top-Treffer aus reiner Vektorsuche gegen eine einfache LIKE-Stichwortsuche vergleichen. Sie läuft lokal im Lernlabor, nicht im Browser. Das Skript installiert nichts und startet kein Docker. Das Skript lernlabor/uebung/ki/ki_09_pgvector_docker.py enthält die Anleitung und den Rahmen. Aufruf:

cd lernlabor && uv run python uebung/ki/ki_09_pgvector_docker.py

Zwei Betriebsarten, das Skript geht ohne Datenbank nie kaputt:

  1. Trockenlauf (Standard): Ist die Umgebungsvariable PGVECTOR_DSN nicht gesetzt oder fehlt das Paket psycopg, läuft eine Simulation in reinem Python mit denselben Chunks und Fragen und zeigt den Vergleich “Vektor gegen LIKE”. Dazu erscheint eine verständliche Meldung, was fehlt.
  2. Echte Datenbank: Du startest selbst einen pgvector-Container (Befehl steht in der Datei, Image-Name bitte prüfen) und setzt PGVECTOR_DSN in deiner Shell. Das Paket psycopg steht noch nicht in der pyproject.toml des Lernlabors, du installierst es bei Bedarf mit uv add psycopg im Ordner lernlabor. Passwörter und Zugangsdaten stehen nie in einer Datei.

Fertig, wenn:

  • Du für alle 3 Testfragen sagen kannst, welcher Chunk bei Vektorsuche und welcher bei LIKE vorne liegt, und bei mindestens einer Frage einen Unterschied erklären kannst.
  • Du EXPLAIN mit und ohne Index verglichen hast (nur mit echter Datenbank, bitte prüfen, wie der Plan aussieht).
  • Du die Tabelle mit vector(4) angelegt hast und weißt, warum die Dimension zur Embedding-Länge passen muss.

Selbstcheck:

Merksatz

Der Index macht die Vektorsuche schnell und etwas ungenau, Hybrid Search macht sie bei exakten Begriffen treffsicher: Lege den Index früh an, miss den Recall, und führe Vektor- und Stichwortsuche über Ränge zusammen.

Prüfstein

Dein RAG-System findet bei der Frage “Was bedeutet Fehler E-4711?” den falschen Chunk (E-4712). Nenne zwei Maßnahmen, mit denen du das beheben würdest, und erkläre, an welcher Stelle der Pipeline (Suche, Zusammenführung, Reranking) jede ansetzt.


Quelle: quellen/kursbuch-lerninhalte.md, Modul M2, Baustein “04 pgvector & Hybrid Search” (Zeilen 541 bis 570). Aus der Quelle stammen: pgvector als Postgres-Erweiterung, vector(1024), <=>, Index mit hnsw oder ivfflat, BM25 über tsvector oder ein dediziertes Werkzeug, Hybrid mit Reciprocal Rank Fusion, die Stolperfalle “ohne Index linear”, die Übung. Über die Quelle hinaus (allgemeines Fachwissen, soweit nicht anders vermerkt): <-> und <#> und ihre Operatorklassen, lists, probes, ef_search, die k=60-Konvention, BM25-Formel, Kosinus-Distanz als 1 - Ähnlichkeit, Verhalten von Filtern bei ANN-Indizes. Alle SQL-Aussagen sind nicht ausgeführt (bitte prüfen, pgvector-Doku). Die Python-Beispiele wurden mit Python 3.13 und numpy ausgeführt, die Vektoren und Texte sind von Hand gewählt und keine echten Embeddings. Der Bucket-Index bildet das Prinzip von ivfflat vereinfacht nach, nicht dessen Implementierung.