Embedding Semantische Suche: detaillierte Erklärung mit Beispielen

Zu diesem Artikel gibt es ein kurzes Video:YouTubeTikTok

1. Übersicht

Wenn Sie einen Chatbot fragen: „Mein Flug wurde storniert, wird das Hotelgeld erstattet?“, muss das RAG-System die richtige Policy-Zeile über „Reiseunterbrechung” finden — selbst wenn die Frage kein Wort enthält, das 100% mit dem Originaltext übereinstimmt. Dieser Artikel erklärt den Embedding-Mechanismus: Text in Vektoren umwandeln, per semantischer Distanz abgleichen, und so speichern, dass die LLM den Originaltext noch nachverfolgbar kann. Danach können Sie selbst eine einfache semantische Such-Pipeline in Java schreiben und mit Tests belegen, dass sie funktioniert.

2. Was ist Embedding

Ein Embedding-Modell nimmt eine Textpassage und gibt einen Vektor zurück — eine Folge von Dezimalzahlen, meist einige hundert bis tausend Dimensionen. Dieser Vektor ist keine „komprimierte Stichwortliste”, sondern ein Punkt im Raum vieler Dimensionen, wo Texte mit ähnlicher Bedeutung Vektoren haben, die sich räumlich nahekommen.

public record EmbeddingVector(float[] values) {
    public int dimension() {
        return values.length;
    }
}

EmbeddingVector ist nur ein Wrapper für float[], aber es hilft Ihnen, zu vermeiden, float[] zwischen verschiedenen Modellen durcheinander zu werfen — ein sehr häufiger Fehler, wenn das System größer wird.

2.1. Warum Sie Embedding nicht wie eine Karte mit klaren Beschriftungen denken sollten

Auf einer echten Karte haben Sie Längen- und Breitengrad mit Beschriftungen wie „Hanoi”, „Da Nang”. Bei Embedding hat jede einzelne Dimension nicht ein Label wie „Hotel”, „Flug”, „Erstattung”. Bedeutung entsteht nur durch das kombinierte Muster aller Vektor-Komponenten. Daher können Sie eine einzelne Dimension nicht ablesen und verstehen, wofür sie steht — Sie können nur den ganzen Vektor mit anderen Vektoren vergleichen.

Das ist wichtig, denn es erklärt, warum Sie nicht einfach einen Vektor kürzen sollten (etwa nur die ersten 100 von 1536 Dimensionen behalten, um Speicher zu sparen) — das zerstört das Muster und die Suchgenauigkeit sinkt stark.

3. Abgleich nach semantischer Distanz

Zwei Vektoren sind sich „nah”, wenn man sie per Cosine-Ähnlichkeit misst — je näher an 1, desto ähnlicher in der Bedeutung.

public final class VectorMath {

    public static double cosineSimilarity(float[] a, float[] b) {
        if (a.length != b.length) {
            throw new IllegalArgumentException(
                "Vektor-Dimensionen stimmen nicht überein: %d vs %d".formatted(a.length, b.length));
        }
        double dot = 0, normA = 0, normB = 0;
        for (int i = 0; i < a.length; i++) {
            dot += a[i] * b[i];
            normA += a[i] * a[i];
            normB += b[i] * b[i];
        }
        return dot / (Math.sqrt(normA) * Math.sqrt(normB));
    }
}
  • Zeile if (a.length != b.length): fängt sofort den häufigen Fehler — zwei Vektoren aus verschiedenen Modellen vergleichen (etwa 768 vs. 1536 Dimensionen) würde einen Runtime-Fehler werfen, der schwer zu debuggen ist, wenn Sie nicht früh checken.
  • dot, normA, normB: berechnen das Skalarprodukt und die Vektorlänge nach der Standard-Cosine-Formel.

Hinweis: zwei Modelle mit gleicher Dimensionszahl bedeutet nicht, dass sie kompatibel sind. Modell A und Modell B geben beide einen 1536-dimensionalen Vektor aus, aber ihr semantischer Raum unterscheidet sich — Quervergleiche ergeben sinnlose Ergebnisse, obwohl der Code nicht fehlschlägt.

4. Ein Embedding-Modell simulieren, um ohne echte API zu üben

Um das Beispiel sofort laufen zu lassen, nutze ich einen simulierten EmbeddingClient basierend auf Stichwort-Häufigkeiten in „Themen” (travel, password, billing). In der Praxis würden Sie OpenAI, Cohere oder ein Open-Source-Modell nehmen, aber die Schnittstelle bleibt gleich.

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-openai-spring-boot-starter</artifactId>
    <version>1.0.0</version>
</dependency>
public interface EmbeddingClient {
    EmbeddingVector embed(String text);
}
public class KeywordEmbeddingClient implements EmbeddingClient {

    private static final List<String> TRAVEL_WORDS =
        List.of("Flug", "Stornierung", "Hotel", "Reise", "Flughafen");
    private static final List<String> PASSWORD_WORDS =
        List.of("Passwort", "Anmeldung", "Konto", "Zurücksetzen", "Vergessen");
    private static final List<String> BILLING_WORDS =
        List.of("Erstattung", "Zahlung", "Rechnung", "Kosten", "Gebühr");

    @Override
    public EmbeddingVector embed(String text) {
        String normalized = text.toLowerCase();
        float travel = score(normalized, TRAVEL_WORDS);
        float password = score(normalized, PASSWORD_WORDS);
        float billing = score(normalized, BILLING_WORDS);
        return new EmbeddingVector(new float[] { travel, password, billing });
    }

    private float score(String text, List<String> keywords) {
        long matches = keywords.stream().filter(text::contains).count();
        return matches / (float) keywords.size();
    }
}

KeywordEmbeddingClient nutzt nur 3 Dimensionen (travel/password/billing) zur leichteren Beobachtung — echtes Embedding lernt mit hunderten von Dimensionen aus Daten, aber die Idee „Text mit ähnlicher Bedeutung → Vektoren nah beieinander” ist die gleiche.

5. Embedding mit Metadaten speichern, damit die LLM den Originaltext rückverfolgen kann

Vektoren helfen nur, Textpassagen zu finden, aber die LLM braucht immer noch den Originaltext zum Antworten. Daher muss jeder Record im Vector Store das Embedding mit der ursprünglichen Chunk und Metadaten verbinden.

public record DocumentChunk(
    String chunkId,
    String documentId,
    String text,
    String section,
    LocalDate effectiveDate,
    int version,
    EmbeddingVector embedding
) {}
  • chunkId und documentId: ermöglichen es, das vollständige Dokument nachzurufen, wenn Sie Quellen anzeigen oder auf eine neue Version aktualisieren müssen.
  • section, effectiveDate, version: helfen, vor semantischem Abgleich zu filtern — etwa nur in gültigen Policies suchen, alte abgelaufene Versionen ignorieren.
  • text: muss immer gespeichert werden, denn die LLM kann Vektoren nicht „lesen” — sie braucht den echten Wortlaut, um Antworten zu generieren.
public class InMemoryVectorStore {

    private final List<DocumentChunk> chunks = new ArrayList<>();

    public void add(DocumentChunk chunk) {
        chunks.add(chunk);
    }

    public List<DocumentChunk> findNearest(EmbeddingVector query, int topK) {
        return chunks.stream()
            .sorted(Comparator.comparingDouble(
                (DocumentChunk c) -> -VectorMath.cosineSimilarity(
                    query.values(), c.embedding().values())))
            .limit(topK)
            .toList();
    }
}

findNearest sortiert Chunks nach Cosine-Ähnlichkeit absteigend (das - dreht den Comparator um) und nimmt die topK nächsten Ergebnisse — das ist der „Retrieval”-Schritt in RAG.

6. Vollständiges Beispiel: unterschiedlich formulierte Frage, aber richtige Antwort

public class SemanticSearchDemo {

    public static void main(String[] args) {
        EmbeddingClient embedder = new KeywordEmbeddingClient();
        InMemoryVectorStore store = new InMemoryVectorStore();

        store.add(new DocumentChunk(
            "chunk-1", "policy-travel", "Erstattungsrichtlinie bei Flugstornierung oder Reiseunterbrechung.",
            "Reisestörung", LocalDate.of(2026, 1, 1), 2,
            embedder.embed("Erstattungsrichtlinie bei Flugstornierung oder Reiseunterbrechung.")
        ));

        store.add(new DocumentChunk(
            "chunk-2", "policy-account", "Anleitung zum Passwort zurücksetzen bei vergessenem Konto.",
            "Kontowiederherstellung", LocalDate.of(2025, 6, 1), 1,
            embedder.embed("Anleitung zum Passwort zurücksetzen bei vergessenem Konto.")
        ));

        String question = "Mein Flug wurde storniert, wird das Hotelgeld erstattet?";
        EmbeddingVector queryVector = embedder.embed(question);

        List<DocumentChunk> results = store.findNearest(queryVector, 1);
        System.out.println(results.get(0).text());
    }
}

Das Programm gibt aus:

Erstattungsrichtlinie bei Flugstornierung oder Reiseunterbrechung.

Die Frage enthält nicht die Wörter „Richtlinie” oder „Reiseunterbrechung”, aber da die Frage und der Text sich räumlich nahekommen (hohe travel-Dimension bei beiden), wählt das System den richtigen Chunk aus — und ignoriert völlig den Chunk über Passwort zurücksetzen.

7. Mit Tests absichern

class SemanticSearchTest {

    private final EmbeddingClient embedder = new KeywordEmbeddingClient();
    private final InMemoryVectorStore store = new InMemoryVectorStore();

    @BeforeEach
    void setUp() {
        store.add(new DocumentChunk(
            "chunk-1", "policy-travel",
            "Erstattungsrichtlinie bei Flugstornierung oder Reiseunterbrechung.",
            "Reisestörung", LocalDate.of(2026, 1, 1), 2,
            embedder.embed("Erstattungsrichtlinie bei Flugstornierung oder Reiseunterbrechung.")
        ));
        store.add(new DocumentChunk(
            "chunk-2", "policy-account",
            "Anleitung zum Passwort zurücksetzen bei vergessenem Konto.",
            "Kontowiederherstellung", LocalDate.of(2025, 6, 1), 1,
            embedder.embed("Anleitung zum Passwort zurücksetzen bei vergessenem Konto.")
        ));
    }

    @Test
    void shouldMatchTravelPolicyEvenWithoutSharedWords() {
        String question = "Mein Flug wurde storniert, wird das Hotelgeld erstattet?";
        EmbeddingVector queryVector = embedder.embed(question);

        List<DocumentChunk> results = store.findNearest(queryVector, 1);

        assertThat(results)
            .hasSize(1)
            .first()
            .extracting(DocumentChunk::documentId)
            .isEqualTo("policy-travel");
    }

    @Test
    void shouldRejectComparingDifferentDimensions() {
        float[] a = { 1f, 0f, 0f };
        float[] b = { 1f, 0f };

        assertThatThrownBy(() -> VectorMath.cosineSimilarity(a, b))
            .isInstanceOf(IllegalArgumentException.class)
            .hasMessageContaining("stimmen nicht überein");
    }
}

Der erste Test bestätigt das Kernverhalten: eine unterschiedlich formulierte Frage findet trotzdem den richtigen Chunk. Der zweite Test stellt sicher, dass Sie nicht versehentlich zwei inkompatible Vektoren vergleichen — dieser Fehler tritt leicht auf, wenn Sie das Embedding-Modell wechseln und vergessen, alle alten Daten neu zu embedden.

8. Häufige Fehler und wann man Embedding nicht nutzen sollte

  • Embedding-Modell wechseln ohne Daten neu zu embedden: alte und neue Vektoren liegen in unterschiedlichen Räumen, Abgleiche ergeben lautlos falsche Ergebnisse — es gibt keinen Exception, der Sie warnt.
  • Chunks zu lang oder zu kurz: lange Chunks vermischen viele Ideen im Vektor, sinken die Genauigkeit; zu kurze Chunks verlieren Kontext. Üblicherweise sind 200–500 Token ein guter Startpunkt, müssen aber je nach Fachbereich angepasst werden.
  • Vergessen, Metadaten zu speichern: ohne documentId/version können Sie ein Dokument nicht löschen oder aktualisieren, wenn sich die Policy ändert.
  • Embedding für Abgleiche nutzen, die exakt sein müssen: wenn Sie eine genaue Bestellnummer wie ORD-2026-00123 suchen müssen, sind Volltext-Suche oder Index nach exaktem Schlüssel immer noch schneller und korrekter — Embedding glänzt bei semantischer Suche, nicht bei exaktem String-Abgleich.

Hinweis: bei kleinen Datenmengen (einige hundert Chunks) reicht der InMemoryVectorStore oben aus. Bei Millionen Chunks brauchen Sie eine spezialisierte Vector Database (pgvector, Pinecone, Milvus…) mit Approximate Nearest Neighbor Index (ANN), um nicht jeden Vektor nacheinander zu vergleichen.

9. Fazit

Embedding wandelt Text in Vektoren um, damit Maschinen „Bedeutung” vergleichen können, nicht nur Stichwort-Übereinstimmung — das ist die Grundlage aller RAG-Systeme. Der kritische Punkt: Query und Dokumente müssen den gleichen, kompatiblen Encoder nutzen, jeder Vektor muss mit Metadaten gebunden sein um den Originaltext zu rückverfolgen, und gleiche Dimensionszahl bedeutet nicht automatisch Kompatibilität zwischen zwei Modellen. Aus dem Java-Beispiel oben können Sie KeywordEmbeddingClient durch ein echtes Embedding-Modell (via Spring AI oder direkte API) ersetzen — der Rest, Cosine-Ähnlichkeit, Vector Store, Metadaten — bleibt gleich.

ragembeddingvector-searchspring-aillm