cd ../back to blog
$Guide//June 4, 2026//8 min read

Embeddings und semantische Suche: der praktische Leitfaden

So baust du semantische Suche und RAG: Embeddings über Brievios OpenAI-kompatiblen /v1/embeddings-Endpunkt erzeugen, mit Kosinus-Ähnlichkeit scoren, in pgvector speichern.

Die Stichwortsuche matcht Strings. Die semantische Suche matcht Bedeutung — „I forgot my login" findet den Artikel mit dem Titel „Resetting your password", obwohl die beiden kein einziges Wort teilen. Der Mechanismus dahinter sind Embeddings: Ein Modell verwandelt jeden Textbaustein in einen Vektor aus ein paar Tausend Zahlen, und Texte mit ähnlicher Bedeutung landen in diesem Raum nah beieinander. Erzeuge diese Vektoren einmal, speichere sie, und du kannst alles nach Relevanz ranken — die Retrieval-Hälfte jedes RAG-Systems.

Brievio stellt /v1/embeddings als nahtlos OpenAI-kompatiblen Endpunkt bereit, sodass der SDK-Call identisch zu dem ist, was du ohnehin schreibst — nur der base_url ändert sich. Embeddings sind günstig, du zahlst ehrliche Token-Zahlen, und fehlgeschlagene 4xx/5xx-Calls kosten nichts. Dieser Beitrag ist der praktische Weg: Vektoren erzeugen, sie mit der Kosinus-Ähnlichkeit scoren, sie mit pgvector speichern und abfragen und das Ergebnis in eine Retrieval-Pipeline einbauen — mitsamt den Fallstricken, die wirklich wehtun.

Embeddings erzeugen

Ein Call. Übergib eine Liste von Strings, bekomme eine Liste von Vektoren in derselben Reihenfolge zurück. Bündle immer — ein paar Hundert Inputs pro Request kosten pro Token genauso viel wie einzeln, ersparen dir aber Hunderte von Round Trips:

embed.py
# Embeddings mit dem OpenAI-SDK erzeugen — gleicher Call, anderer base_url.
from openai import OpenAI

client = OpenAI(
    api_key="sk-brievio-...",
    base_url="https://api.brievio.com/v1",
)

EMBED_MODEL = "text-embedding-3-large"   # ein Modell wählen und dabei bleiben

def embed(texts: list[str]) -> list[list[float]]:
    # Bündle bis zu ein paar Hundert Inputs pro Call — weit weniger Round Trips,
    # gleicher Preis pro Token. Die Antwort behält die Reihenfolge der Inputs bei.
    resp = client.embeddings.create(model=EMBED_MODEL, input=texts)
    return [row.embedding for row in resp.data]

vecs = embed(["How do I reset my password?", "Where is my invoice?"])
print(len(vecs), "x", len(vecs[0]))     # 2 x 3072  (modellabhängige Dimension)

# usage wird ehrlich gemeldet — Embeddings werden wie jeder andere Call pro Token abgerechnet
print(resp.usage.prompt_tokens, "tokens billed")

Die Vektorlänge (seine Dimension) ist durch das Modell festgelegt — üblich sind 768, 1536 oder 3072. Höhere Dimensionen erfassen ein klein wenig mehr Nuance, kosten aber mehr beim Speichern und Vergleichen. Die wichtigste Regel überhaupt: ein einziges Embedding-Modell wählen und niemals mischen. Ein Vektor aus dem einen Modell und ein Vektor aus einem anderen leben in verschiedenen, inkompatiblen Räumen — sie zu vergleichen erzeugt nur Rauschen. Welche Embedding-Modelle es gibt und mit welchen Dimensionen, zeigt der aktuelle Modellkatalog; den Preis pro Modell findest du auf der Pricing-Seite.

Scoren mit der Kosinus-Ähnlichkeit

Um die nächsten Treffer zu finden, vergleichst du den Query-Vektor mit deinen gespeicherten Vektoren. Die Standardmetrik ist die Kosinus-Ähnlichkeit: Sie misst den Winkel zwischen zwei Vektoren und ignoriert deren Länge, kümmert sich also um die Richtung (Bedeutung) statt um den Betrag. 1.0 bedeutet gleiche Richtung, 0 bedeutet unzusammenhängend:

search.py
# Semantische Suche = Query einbetten, gegen jeden gespeicherten Vektor scoren,
# den nächsten zurückgeben. Die Scoring-Funktion ist die Kosinus-Ähnlichkeit.
import numpy as np

def cosine(a: np.ndarray, b: np.ndarray) -> float:
    # 1.0 = identische Richtung, 0 = unzusammenhängend, -1 = entgegengesetzt.
    return float(a @ b / (np.linalg.norm(a) * np.linalg.norm(b)))

# Viele Embedding-Modelle liefern bereits Vektoren der Länge 1 zurück. Tun sie das,
# reduziert sich die Kosinus-Ähnlichkeit auf ein einfaches Skalarprodukt — günstiger im großen Maßstab:
def cosine_unit(a: np.ndarray, b: np.ndarray) -> float:
    return float(a @ b)

query = np.array(embed(["I forgot my login"])[0])
corpus = np.array(embed(documents))                 # (N, dim)

scores = corpus @ query / (
    np.linalg.norm(corpus, axis=1) * np.linalg.norm(query)
)
top = scores.argsort()[::-1][:5]                    # Indizes der 5 besten Treffer
for i in top:
    print(round(float(scores[i]), 3), documents[i][:60])

Zwei praktische Anmerkungen. Erstens: Viele Embedding-Modelle liefern bereits Vektoren der Länge 1 zurück — tun sie das, ist die Kosinus-Ähnlichkeit nur noch ein Skalarprodukt, und genau das macht die Brute-Force-Suche über Zehntausende von Vektoren schnell genug, um ganz auf eine Datenbank zu verzichten. Zweitens: Die Brute-Force-Schleife ist bis in den niedrigen zweistelligen Tausenderbereich an Vektoren völlig in Ordnung. Darüber hinaus wird das Durchscannen jedes Vektors bei jeder Query langsam, und du willst einen echten Vektorspeicher mit Index.

Speichern und Abfragen mit pgvector

Liegen deine Daten ohnehin schon in Postgres, brauchst du keine separate Vektordatenbank — die pgvector-Erweiterung fügt einen vector-Spaltentyp und Nearest-Neighbour-Operatoren hinzu. Du bettest jeden Chunk einmal ein, speicherst den Vektor neben seinem Text und lässt Postgres die Suche erledigen:

store.sql
-- pgvector macht aus Postgres einen Vektorspeicher. Einmal aktivieren, dann
-- das Embedding jedes Chunks neben seinem Text und seinen Metadaten ablegen.
CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE chunks (
    id        bigserial PRIMARY KEY,
    doc_id    text NOT NULL,
    content   text NOT NULL,
    embedding vector(3072)            -- muss zur Dimension deines Modells passen
);

-- Approximate-Nearest-Neighbour-Index. <=> ist die Kosinus-Distanz in pgvector
-- (0 = am nächsten). Den Index NACH dem Bulk-Load anlegen, nicht davor.
CREATE INDEX ON chunks
    USING hnsw (embedding vector_cosine_ops);

-- Retrieval: das Query-Embedding als Parameter ($1) übergeben und die
-- nächsten Zeilen nehmen. Aufsteigend nach Distanz = ähnlichste zuerst.
SELECT id, doc_id, content, 1 - (embedding <=> $1) AS similarity
FROM   chunks
WHERE  doc_id = ANY($2)               -- optionaler Metadaten-Vorfilter
ORDER  BY embedding <=> $1
LIMIT  5;

Der Operator <=> ist die Kosinus-Distanz (0 = identisch), also liefert dir 1 - (embedding <=> $1) einen Ähnlichkeits-Score zurück. Der hnsw-Index macht die Suche approximativ, aber schnell — für die meisten Retrieval-Workloads ist der winzige Recall-Kompromiss unsichtbar, und du baust ihn nach dem Bulk-Load, niemals davor. Die optionale WHERE-Klausel ist die stille Superkraft: nach Tenant, Dokument, Sprache oder Datum filtern, bevor die Vektorsuche läuft, sodass du nie einen Nachbarn lieferst, den der Nutzer gar nicht sehen darf.

In RAG einbauen

Retrieval-Augmented Generation sind zwei zusammengenähte Schritte: semantische Suche, um relevanten Kontext zu finden, dann ein Generierungsmodell, das mit diesem Kontext antwortet. Die Retrieval-Seite ist alles oben. Die Generierungsseite ist ein ganz normaler Chat-Completion — und hier paarst du ein günstiges Embedding-Modell mit einem starken Reasoning-Modell:

  • Erst chunken, dann einbetten. Teile Dokumente in Passagen von grob ein paar Hundert Tokens mit etwas Überlappung, bette jede ein und speichere sie. Beim Chunking investieren die meisten Teams zu wenig: zu große Chunks vergraben die Antwort im Rauschen; zu kleine verlieren den Kontext, der sie erst bedeutungsvoll macht. Stimme es auf deine eigenen Daten ab.
  • Zur Query-Zeit abrufen. Bette die Frage des Nutzers mit demselben Modell ein, hole die Top-3-bis-8-Chunks und füge ihren Text als Kontext in den Prompt ein.
  • Die Antwort generieren. Schicke diesen Kontext samt Frage an ein leistungsfähiges Modell — Claude oder Gemini über denselben Brievio-Endpunkt — und weise es an, nur aus dem bereitgestellten Kontext zu antworten und anzugeben, welchen Chunk es verwendet hat.

Die Rechnung geht auf, weil die beiden Hälften völlig unterschiedliche Preispunkte haben. Das Einbetten deines gesamten Korpus ist ein einmaliger, kostengünstiger Batch-Job; ein erneutes Einbetten passiert nur, wenn sich Inhalte ändern. Die Kosten pro Query werden vom Generierungsschritt dominiert, nicht vom Retrieval — und genau deshalb bewegen die Techniken zur Kostenkontrolle aus unserem Playbook zur Kostenoptimierung von KI-APIs (die statischen Anweisungen per Prompt Caching cachen, den Output deckeln, pro Aufgabe das richtige Modell wählen) weit mehr als alles auf der Embedding-Seite.

Die Fallstricke, die wirklich wehtun

  • Vektoren sind über Modelle hinweg nicht austauschbar. Wechselst du das Embedding-Modell, musst du den gesamten Korpus neu einbetten — Query- und gespeicherte Vektoren müssen aus demselben Modell stammen, sonst sind die Scores bedeutungslos. Behandle die Modellwahl als Schema-Entscheidung.
  • Die Dimension ist ein Kompromiss aus Speicher und Geschwindigkeit. Ein 3072-dimensionaler Vektor ist viermal so viele Bytes wie ein 768-dimensionaler und langsamer zu vergleichen. Größer ist für deine Aufgabe nicht automatisch besser — miss den Recall auf deinen eigenen Queries, bevor du für das größte Modell zahlst.
  • Chunking-Qualität schlägt Modellqualität. Die meisten schlechten RAG-Antworten lassen sich auf schlechte Chunks zurückführen, nicht auf ein schwaches Embedding-Modell. Respektiere die Dokumentstruktur — trenne an Überschriften und Absätzen, nicht an einer blinden Zeichenzahl.
  • Die semantische Suche kann exakte Begriffe verfehlen. Produkt-SKUs, Fehlercodes und Namen werden manchmal besser von der Stichwortsuche getroffen. In der Praxis sind die stärksten Systeme hybrid: Sie kombinieren die Vektorähnlichkeit mit einem klassischen Volltext- oder BM25-Score.
  • Auch Embeddings haben ein Kontextlimit. Text jenseits des Input-Limits des Modells wird stillschweigend abgeschnitten — dein „Embedding" repräsentiert dann nur den ersten Teil eines zu langen Chunks. Halte Chunks bequem unter dem Limit.

Das Fazit zum Mitnehmen

Die semantische Suche besteht aus vier beweglichen Teilen: einem Embedding-Modell, einer Ähnlichkeitsmetrik, einem Speicher und einer Chunking-Strategie. Wähle ein Embedding-Modell und bleibe dabei; nimm die Kosinus-Ähnlichkeit; beginne mit Brute-Force in NumPy und steige auf den HNSW-Index von pgvector um, sobald dein Korpus einem einzelnen Scan entwächst; und investiere dein Tuning-Budget ins Chunking, denn dort wird die Retrieval-Qualität gewonnen oder verloren. Über Brievio ist der Embedding-Call das OpenAI-SDK, das du schon kennst, mit einer geänderten Zeile, auf ehrlichen Token-Zahlen abgerechnet, und er sitzt direkt neben den Claude- und Gemini-Modellen, die du für den Generierungsschritt nutzen wirst — ein Key, ein base_url, die ganze RAG-Pipeline. Die Endpunkt-Referenz und ein lauffähiger Quickstart leben in den Docs.