Zum Hauptinhalt springen
Lektion 21 von 22 · Embeddings, RAG und semantische Suche

Feinabstimmung von Embeddings

Lektion 21 von 22 Aktualisiert am 29. Juli 2026 Entwickler
Auf dieser Seite
  1. Lernziele
  2. Wann Feinabstimmung?
  3. Ansatz 1: Linearer Adapter (Matryoshka)
  4. Ansatz 2: Feinabstimmung mit Sentence Transformers
  5. Vorbereitung eines hochwertigen Datensatzes
  6. Bewertung der Feinabstimmung
  7. Zusammenfassung

Lernziele

  • Verstehen, wann eine Feinabstimmung von Embeddings notwendig ist
  • Ein Datensatz aus Paaren für das Training vorbereiten
  • Feinabstimmung mit leichten Adaptern implementieren

Wann Feinabstimmung?

Beginnen wir mit der schlechten Nachricht, die eigentlich gut ist: In den meisten Fällen funktionieren vorab trainierte Embedding-Modelle einwandfrei, und eine Feinabstimmung ist eine Verschwendung von Aufwand. Nur drei Situationen rechtfertigen dies wirklich. Die erste ist ein Bereich mit hochspezialisiertem Vokabular – Medizin, Recht, Industrie –, in dem das generische Modell nicht weiß, dass „MI” und „Myokardinfarkt” dasselbe bedeuten. Die zweite ist das Erreichen einer Sättigungsgrenze: Sie haben das Chunking optimiert, hybride Suche und Reranking hinzugefügt, und die Metriken bewegen sich nicht. Die dritte ist einfach, dass Sie annotierte Textpaare zur Hand haben, ähnlich oder unähnlich, die in Ihren Suchprotokollen ungenutzt liegen.

Umgekehrt sollten Sie die Feinabstimmung überspringen, wenn Sie weniger als tausend annotierte Paare haben: Das Training wird Ihre Beispiele auswendig lernen, ohne zu generalisieren. Überspringen Sie sie auch, wenn die Leistung bereits zufriedenstellend ist – ein Gewinn von zwei Punkten bei der Recall-Rate lohnt nicht die Wartung eines zusätzlichen Modells. Und vor allem prüfen Sie, ob besseres Chunking oder ein besserer Prompt das Problem nicht lösen würden: Es ist fast immer günstiger und schneller.

Ansatz 1: Linearer Adapter (Matryoshka)

Der wirtschaftlichste Ansatz lässt das Embedding-Modell unberührt. Sie lassen es seine Vektoren erzeugen, trainieren dann eine kleine Projektionsschicht darüber, die den Raum verzerrt, um Dinge, die in Ihrem Bereich nah beieinander liegen müssen, auch im Vektorraum näher zusammenzubringen. Das Modell von OpenAI bleibt eine Black Box, die über die API aufgerufen wird; nur einige hunderttausend Parameter werden trainiert, auf der CPU, in Minuten.

Das Training stützt sich auf Tripel: eine Abfrage, ein Dokument, das diese beantwortet, und ein Dokument, das dasselbe Thema abdeckt, aber die Frage nicht beantwortet. Schauen Sie sich die medizinischen Beispiele unten an – das negative Beispiel für „Nebenwirkungen von Paracetamol” ist kein Text über Fußball, sondern ein Text über Paracetamol, das in Apotheken verkauft wird. Dies wird als „hard negative” bezeichnet, und genau das lehrt dem Modell etwas: Ein offensichtliches negatives Beispiel korrigiert nichts, da das generische Modell es bereits korrekt klassifiziert hat.

import numpy as np
from openai import OpenAI
from sklearn.model_selection import train_test_split

client = OpenAI()

# Datensatz aus Paaren (Abfrage, positives_Dokument, negatives_Dokument)
triplets = [
    {
        "query": "paracetamol side effects",
        "positive": "Paracetamol can cause liver disorders...",
        "negative": "Paracetamol is available in pharmacies..."
    },
    {
        "query": "children's aspirin dosage",
        "positive": "For children aged 6 to 12, the recommended dose...",
        "negative": "Aspirin was discovered in the 19th century..."
    },
    # ... weitere Tripel
]

def prepare_embeddings(triplets: list[dict]) -> dict:
    """Generiere Embeddings für die Tripel."""
    all_texts = []
    for t in triplets:
        all_texts.extend([t["query"], t["positive"], t["negative"]])

    response = client.embeddings.create(
        input=all_texts,
        model="text-embedding-3-large"
    )
    embs = np.array(
        [d.embedding for d in sorted(response.data, key=lambda x: x.index)]
    )

    n = len(triplets)
    return {
        "queries": embs[0::3],
        "positives": embs[1::3],
        "negatives": embs[2::3],
    }

Der Adapter selbst ist ein Netz mit zwei Schichten, das die 3.072 Dimensionen des Modells auf 256 projiziert, mit abschließender Normalisierung, sodass die Ausgabevektoren weiterhin durch kosinähnliche Ähnlichkeit vergleichbar bleiben. Die Verlustfunktion ist eine triplet margin loss: Sie ist null, sobald die Abfrage um mindestens einen Rand von 0,2 näher am positiven als am negativen Beispiel liegt, und andernfalls wird proportional bestraft. Mit anderen Worten: Sie lehren dem Modell nur, was es noch nicht verstanden hat, und das Training stabilisiert sich natürlich, sobald die Tripel gut getrennt sind.

import torch
import torch.nn as nn
import torch.optim as optim

class EmbeddingAdapter(nn.Module):
    """Projektionsschicht zur Anpassung von Embeddings."""
    def __init__(self, input_dim: int = 3072, output_dim: int = 256):
        super().__init__()
        self.projection = nn.Sequential(
            nn.Linear(input_dim, output_dim),
            nn.ReLU(),
            nn.Linear(output_dim, output_dim),
        )

    def forward(self, x):
        projected = self.projection(x)
        return projected / projected.norm(dim=-1, keepdim=True)

def triplet_loss(anchor, positive, negative, margin=0.2):
    """Triplet margin loss."""
    dist_pos = 1 - torch.cosine_similarity(anchor, positive)
    dist_neg = 1 - torch.cosine_similarity(anchor, negative)
    return torch.relu(dist_pos - dist_neg + margin).mean()

def train_adapter(data: dict, epochs: int = 50):
    """Trainiere den Adapter an Tripeln."""
    model = EmbeddingAdapter()
    optimizer = optim.Adam(model.parameters(), lr=1e-3)

    queries = torch.tensor(data["queries"], dtype=torch.float32)
    positives = torch.tensor(data["positives"], dtype=torch.float32)
    negatives = torch.tensor(data["negatives"], dtype=torch.float32)

    for epoch in range(epochs):
        optimizer.zero_grad()

        q_proj = model(queries)
        p_proj = model(positives)
        n_proj = model(negatives)

        loss = triplet_loss(q_proj, p_proj, n_proj)
        loss.backward()
        optimizer.step()

        if (epoch + 1) % 10 == 0:
            print(f"Epoch {epoch + 1}/{epochs}, Loss: {loss.item():.4f}")

    return model

# Trainieren
data = prepare_embeddings(triplets)
adapter = train_adapter(data)

# Speichern
torch.save(adapter.state_dict(), "medical_adapter.pt")

Ein operativer Punkt, den Sie nicht übersehen dürfen: Am Tag, an dem Sie diesen Adapter bereitstellen, müssen alle Vektoren, die bereits in Ihrer Datenbank gespeichert sind, erneut durch die Projektion laufen, sonst würden Sie Vektoren aus zwei verschiedenen Räumen vergleichen. Planen Sie eine vollständige Neuindizierung des Korpus für Ihren Produktions-Einsatz.

Ansatz 2: Feinabstimmung mit Sentence Transformers

Wenn der Adapter nicht ausreicht, müssen Sie das Embedding-Modell selbst ändern, was ein offenes Modell erfordert, das Sie hosten – hier BAAI/bge-large-en-v1.5. Sentence Transformers macht dies zugänglich: Sie beschreiben Beispiele als Textpaare mit einem kontinuierlichen Label, wobei 1,0 bedeutet „diese beiden Texte sollten nah beieinander liegen” und 0,2 bedeutet „sie sehen oberflächlich ähnlich aus, drängen Sie sie auseinander”. Die CosineSimilarityLoss drückt dann die berechnete kosinähnliche Ähnlichkeit in Richtung des bereitgestellten Labels.

Drei Epochen und hundert Warmup-Schritte sind konservative Werte, und aus gutem Grund: Auf einem bescheidenen Datensatz zerstört zu langes Training das allgemeine Wissen des Modells, sodass es an Ihren Beispielen hervorragend, überall sonst aber mittelmäßig ist.

from sentence_transformers import (
    SentenceTransformer, InputExample, losses
)
from torch.utils.data import DataLoader

# Laden eines Basismodells
model = SentenceTransformer("BAAI/bge-large-en-v1.5")

# Vorbereiten von Trainingsbeispielen
train_examples = [
    InputExample(
        texts=["paracetamol side effects",
               "Paracetamol can cause liver disorders"],
        label=1.0
    ),
    InputExample(
        texts=["paracetamol side effects",
               "Paracetamol is sold in pharmacies"],
        label=0.2
    ),
]

train_dataloader = DataLoader(train_examples, shuffle=True, batch_size=16)
train_loss = losses.CosineSimilarityLoss(model)

# Trainieren
model.fit(
    train_objectives=[(train_dataloader, train_loss)],
    epochs=3,
    warmup_steps=100,
    output_path="./finetuned_medical_model"
)

# Nutzung des feinabgestimmten Modells
embeddings = model.encode([
    "abdominal pain after taking medication",
    "adverse drug effects"
])

Vorbereitung eines hochwertigen Datensatzes

Hier liegt das eigentliche Hindernis: Niemand hat tausend annotierte Paare herumliegen. Die Abhilfe besteht darin, sie aus dem zu bauen, was Sie bereits haben, nämlich Ihren Dokumenten. Sie reichen jedes Dokument dem LLM ein und bitten es, die Fragen zu generieren, auf die es präzise antwortet, und Sie erhalten Abfrage-Dokument-Paare ohne manuelle Annotation. Eine Temperatur von 0,7 stellt sicher, dass die drei generierten Fragen für dasselbe Dokument nicht drei Varianten desselben Satzes sind, und lstrip reinigt die Nummerierung, die das Modell spontan hinzufügt.

def generate_training_pairs(
    documents: list[str],
    n_per_doc: int = 3
) -> list[dict]:
    """Generiere Abfrage-Dokument-Paare für das Training."""
    pairs = []

    for doc in documents:
        response = client.chat.completions.create(
            model="gpt-5.6-terra",
            messages=[{
                "role": "user",
                "content": (
                    f"Here is a document:\n\n{doc[:500]}\n\n"
                    f"Generate {n_per_doc} questions that this document "
                    f"answers precisely. One question per line."
                )
            }],
            temperature=0.7
        )
        questions = response.choices[0].message.content.strip().split("\n")

        for q in questions[:n_per_doc]:
            q = q.strip().lstrip("0123456789.-) ")
            if q:
                pairs.append({"query": q, "document": doc})

    return pairs

Bewertung der Feinabstimmung

Training mit sinkendem Verlust beweist nichts: Die einzige Frage, die zählt, ist, ob die Suche sich verbessert hat. Die Funktion unten misst recall@5 am selben Satz von Abfragen, mit und ohne den Adapter, indem Sie einfach adapter=None übergeben, um die Basislinie zu erhalten. Sowohl das Korpus als auch die Abfragen laufen durch die Projektion, wenn diese bereitgestellt wird, was die Bedingung ist, dass sich beide Vektoren im selben Raum befinden.

Führen Sie diesen Vergleich immer vor dem Einsatz durch. Es kommt regelmäßig vor, dass ein Adapter, der an zu wenigen Tripeln trainiert wurde, die Recall-Rate verschlechtert, und ohne Messung würden Sie eine Regression versenden, denkend, Sie hätten eine Verbesserung geliefert.

def evaluate_before_after(
    queries: list[str],
    relevant_docs: list[list[str]],
    corpus: list[str],
    adapter=None
):
    """Vergleiche die Leistung vor und nach der Feinabstimmung."""
    resp = client.embeddings.create(
        input=corpus, model="text-embedding-3-large"
    )
    corpus_embs = np.array(
        [d.embedding for d in sorted(resp.data, key=lambda x: x.index)]
    )

    if adapter:
        corpus_embs_adapted = adapter(
            torch.tensor(corpus_embs, dtype=torch.float32)
        ).detach().numpy()
    else:
        corpus_embs_adapted = corpus_embs

    recall_scores = []
    for query, relevants in zip(queries, relevant_docs):
        q_resp = client.embeddings.create(
            input=query, model="text-embedding-3-large"
        )
        q_emb = np.array(q_resp.data[0].embedding)

        if adapter:
            q_emb = adapter(
                torch.tensor(q_emb, dtype=torch.float32).unsqueeze(0)
            ).detach().numpy()[0]

        scores = corpus_embs_adapted @ q_emb
        top_5 = set(np.argsort(scores)[-5:])
        relevant_ids = set(
            i for i, d in enumerate(corpus) if d in relevants
        )
        recall = len(top_5 & relevant_ids) / len(relevant_ids)
        recall_scores.append(recall)

    return np.mean(recall_scores)

Zusammenfassung

  • Die Feinabstimmung von Embeddings verbessert die Leistung in spezialisierten Bereichen
  • Ein linearer Adapter ist die einfachste Methode und oft ausreichend
  • Sentence Transformers ermöglicht eine tiefere Feinabstimmung des gesamten Modells
  • Ein Minimum von 1.000 annotierten Paaren wird empfohlen
  • Bewerten Sie immer vor und nach der Feinabstimmung, um den Gewinn zu validieren