Embedding-Modelle mit sentence-transformers ausführen

1. Überblick

Embedding ist eine Methode, um einen Satz in einen numerischen Vektor umzuwandeln. Zwei Sätze mit ähnlichen Bedeutungen erzeugen zwei Vektoren, die sich nahekommen. Dies ist die Grundlage für semantische Suche, RAG, Clustering von Text und Inhaltsempfehlungen.

In diesem Artikel werden Sie die sentence-transformers-Bibliothek verwenden, um ein Embedding-Modell von Hugging Face direkt auf Ihrem Computer auszuführen. Nach dem Lesen werden Sie wissen, wie Sie Embeddings erstellen, Ähnlichkeit berechnen, semantische Suche durchführen, Tests schreiben und das Modell als HTTP-Service bereitstellen, das von Spring-Boot-Anwendungen aufgerufen wird.

2. Installation

sentence-transformers ist eine Python-Bibliothek, die auf PyTorch läuft. Sie sollten eine separate Virtual Environment erstellen:

python -m venv .venv
source .venv/bin/activate
pip install sentence-transformers

Dieser Befehl zieht torch, transformers und huggingface_hub mit sich. Wenn Ihr Rechner über eine NVIDIA-GPU verfügt, installieren Sie zunächst die CUDA-Version von torch, bevor Sie sentence-transformers installieren.

Wenn Sie ein Modell zum ersten Mal laden, lädt die Bibliothek es vom Hugging Face Hub in ein Cache-Verzeichnis herunter (Standard: ~/.cache/huggingface). Bei den folgenden Malen wird das Modell direkt aus dem Cache gelesen.

3. Erste Embeddings erstellen

Wir starten mit all-MiniLM-L6-v2. Dies ist ein kleines Modell (etwa 90 MB), schnell und geeignet zum Experimentieren mit englischen Texten:

from sentence_transformers import SentenceTransformer

model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")

sentences = [
    "The order was shipped yesterday.",
    "Your package left our warehouse last night.",
    "I would like to change my password.",
]

embeddings = model.encode(sentences)

print(embeddings.shape)
print(model.get_sentence_embedding_dimension())

Erklärung:

  • SentenceTransformer(...) lädt das Modell und Tokenizer vom Hub oder aus dem Cache, wenn vorhanden.
  • model.encode(sentences) nimmt eine Liste von Strings und gibt ein numpy.ndarray zurück. Jeder Satz entspricht einer Zeile.
  • get_sentence_embedding_dimension() gibt die Anzahl der Dimensionen des Vektors an.

Ergebnis:

(3, 384)
384

Sie haben drei Sätze, jeder wird zu einem 384-dimensionalen Vektor. Andere Modelle haben unterschiedliche Dimensionen, zum Beispiel 768 oder 1024.

3.1. Vietnamesischer Text

all-MiniLM-L6-v2 wurde hauptsächlich auf Englisch trainiert. Für vietnamesischen Text sollten Sie ein mehrsprachiges Modell verwenden:

model = SentenceTransformer(
    "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
)

vectors = model.encode([
    "Đơn hàng đã được giao cho đơn vị vận chuyển.",
    "Gói hàng của bạn đã rời kho tối qua.",
])
print(vectors.shape)
(2, 384)

Dieses Modell erzeugt ebenfalls 384-dimensionale Vektoren. Es versteht etwa 50 Sprachen, einschließlich Vietnamesisch.

4. Ähnlichkeit berechnen

Vektoren sind nur nützlich, wenn Sie sie miteinander vergleichen. Das häufigste Maß ist die Cosinus-Ähnlichkeit. Ab Version 3 hat die Bibliothek model.similarity:

from sentence_transformers import SentenceTransformer

model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")

sentences = [
    "The order was shipped yesterday.",
    "Your package left our warehouse last night.",
    "I would like to change my password.",
]
embeddings = model.encode(sentences)

scores = model.similarity(embeddings, embeddings)
print(scores)

model.similarity(a, b) gibt eine torch.Tensor-Matrix zurück, wobei die Zelle [i][j] die Ähnlichkeit zwischen Satz i und Satz j ist. Die Ausgabe sieht etwa so aus (Zahlen können je nach Version leicht abweichen):

tensor([[1.0000, 0.6123, 0.0412],
        [0.6123, 1.0000, 0.0587],
        [0.0412, 0.0587, 1.0000]])

Die Diagonale ist immer 1, da jeder Satz sich selbst vollkommen gleicht. Die beiden Sätze über Versand haben einen hohen Score, obwohl sie kaum übereinstimmende Wörter haben. Der Satz über Passwortänderung ist dagegen praktisch irrelevant.

4.1. Vektoren normalisieren

Wenn Sie Vektoren in eine Vector-Datenbank speichern möchten, normalisieren Sie sie auf die Länge 1:

embeddings = model.encode(sentences, normalize_embeddings=True)
score = embeddings[0] @ embeddings[1]
print(round(float(score), 4))
0.6123

Wenn Vektoren normalisiert sind, gibt das Skalarprodukt (@) den genauen Cosinus-Ähnlichkeitswert. Diese Operation ist effizienter, und viele Datenbanken wie pgvector, Qdrant und Elasticsearch sind speziell dafür optimiert.

5. Semantische Suche

Ein praktisches Problem ist es, die beste Antwort aus einer FAQ-Sammlung für eine Kundenfrage zu finden. Die Funktion util.semantic_search macht dies:

from sentence_transformers import SentenceTransformer, util

model = SentenceTransformer(
    "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
)

faq = [
    "Làm sao để theo dõi đơn hàng?",
    "Chính sách hoàn tiền như thế nào?",
    "Tôi quên mật khẩu đăng nhập.",
    "Phí vận chuyển ra nước ngoài là bao nhiêu?",
]
faq_embeddings = model.encode(faq, convert_to_tensor=True, normalize_embeddings=True)

query = "Hàng của tôi đang ở đâu rồi?"
query_embedding = model.encode(query, convert_to_tensor=True, normalize_embeddings=True)

hits = util.semantic_search(query_embedding, faq_embeddings, top_k=2)[0]
for hit in hits:
    print(f"{hit['score']:.3f}  {faq[hit['corpus_id']]}")

Erklärung:

  • Sie kodieren die gesamte FAQ einmal. In einer echten Anwendung würde dieser Schritt beim Starten oder bei Datenänderungen ausgeführt.
  • convert_to_tensor=True behält das Ergebnis als Tensor, sodass Sie nicht hin und her mit NumPy konvertieren müssen.
  • semantic_search gibt eine Liste für jede Query zurück. Jedes Element ist ein Dict mit corpus_id und score, bereits in absteigender Reihenfolge sortiert.

Beispielausgabe:

0.721  Làm sao để theo dõi đơn hàng?
0.388  Phí vận chuyển ra nước ngoài là bao nhiêu?

Dieser Ansatz reicht für mehrere zehntausend Dokumente im Speicher aus. Mit Millionen von Dokumenten sollten Sie zu einer Vector-Datenbank mit ANN-Index wechseln.

Hinweis: Einige Modelle wie die E5- und BGE-Familie erfordern zusätzliche Präfixe, zum Beispiel "query: " für Fragen und "passage: " für Dokumente. Ohne Präfixe kann die Suchqualität erheblich sinken. Lesen Sie die Modellkarte, bevor Sie das Modell verwenden. Sie können prompt="query: " an encode übergeben.

6. Leistung optimieren

encode hat einige Parameter, die sich direkt auf die Geschwindigkeit auswirken:

import torch
from sentence_transformers import SentenceTransformer

device = "cuda" if torch.cuda.is_available() else "cpu"
model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2", device=device)

documents = [f"Customer review number {i}" for i in range(10_000)]

embeddings = model.encode(
    documents,
    batch_size=64,
    show_progress_bar=True,
    normalize_embeddings=True,
)
print(embeddings.shape, device)
Batches: 100%|██████████| 157/157 [00:09<00:00, 16.8it/s]
(10000, 384) cpu
  • device wählt CPU oder GPU. Auf GPU kann es um ein Vielfaches schneller sein.
  • batch_size ist standardmäßig 32. Auf GPU hilft eine Erhöhung auf 64–256 meist, schneller zu laufen, solange Sie nicht aus VRAM laufen.
  • Geben Sie immer die gesamte Liste an encode. Das mehrfache Aufrufen von encode für einzelne Sätze in einer for-Schleife ist um ein Vielfaches langsamer.

Jedes Modell hat auch ein Längenlimit für die Eingabe, überprüfbar unter model.max_seq_length. Für all-MiniLM-L6-v2 liegt dieser Wert bei 256 Token. Text, der darüber hinausgeht, wird stillschweigend abgeschnitten, ohne Warnung. Daher müssen längere Dokumente vor der Kodierung in Chunks aufgeteilt werden.

7. Tests für Embeddings schreiben

Da Embeddings Fließkommazahlen sind, können Sie nicht auf feste Werte testen. Testen Sie stattdessen Eigenschaften. Installieren Sie pytest:

pip install pytest
# test_embeddings.py
import numpy as np
import pytest
from sentence_transformers import SentenceTransformer


@pytest.fixture(scope="module")
def model():
    return SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")


def test_embedding_has_expected_dimension(model):
    vectors = model.encode(["Order #42 is delayed"])
    assert vectors.shape == (1, 384)


def test_normalized_embedding_has_unit_length(model):
    vector = model.encode("Refund request", normalize_embeddings=True)
    assert np.linalg.norm(vector) == pytest.approx(1.0, abs=1e-5)


def test_similar_sentences_score_higher(model):
    shipping = model.encode("The order was shipped yesterday.")
    package = model.encode("Your package left our warehouse last night.")
    password = model.encode("I would like to change my password.")

    related = model.similarity(shipping, package).item()
    unrelated = model.similarity(shipping, password).item()
    assert related > unrelated

Die Fixture mit scope="module" sorgt dafür, dass das Modell nur einmal für die gesamte Datei geladen wird. Der dritte Test würde einen Fehler auffangen, wenn jemand versehentlich zu einem ungeeigneten Modell wechselt.

8. Modelle aus Spring-Boot-Anwendungen aufrufen

Der Großteil Ihres Backend kann in Java geschrieben sein. Die einfachste Lösung ist, das Modell mit FastAPI als kleinen HTTP-Service zu wrappen und es dann von Spring Boot aus aufzurufen.

8.1. Embedding-Service mit FastAPI

pip install fastapi uvicorn
# app.py
from fastapi import FastAPI
from pydantic import BaseModel
from sentence_transformers import SentenceTransformer

app = FastAPI()
model = SentenceTransformer(
    "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
)


class EmbedRequest(BaseModel):
    texts: list[str]


@app.post("/embed")
def embed(request: EmbedRequest):
    vectors = model.encode(request.texts, normalize_embeddings=True)
    return {
        "embeddings": vectors.tolist(),
        "dimension": model.get_sentence_embedding_dimension(),
    }
uvicorn app:app --port 8000

Das Modell wird einmal beim Starten des Moduls geladen und nicht für jede Anfrage neu geladen. Der Endpoint verwendet def statt async def. Der Grund ist, dass encode eine blockierende Operation ist, und FastAPI führt die def-Funktion in einem eigenen Thread-Pool aus, anstatt die Event Loop zu blockieren.

8.2. Client in Spring Boot

Maven-Abhängigkeit:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>
embedding.base-url=http://localhost:8000
public record EmbedRequest(List<String> texts) {}

public record EmbedResponse(List<List<Float>> embeddings, int dimension) {}

@Service
public class EmbeddingClient {

    private final RestClient restClient;

    public EmbeddingClient(RestClient.Builder builder,
                           @Value("${embedding.base-url}") String baseUrl) {
        this.restClient = builder.baseUrl(baseUrl).build();
    }

    public List<List<Float>> embed(List<String> texts) {
        EmbedResponse response = restClient.post()
                .uri("/embed")
                .body(new EmbedRequest(texts))
                .retrieve()
                .body(EmbedResponse.class);
        return response.embeddings();
    }
}

RestClient (seit Spring Framework 6.1) sendet EmbedRequest als JSON und mappt die Antwort mit Jackson auf den Record EmbedResponse. Sie können das Ergebnis dann speichern oder mit anderen Vektoren vergleichen.

8.3. Client-Test mit MockRestServiceServer

import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.*;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;

class EmbeddingClientTest {

    @Test
    void shouldReturnEmbeddingsFromService() {
        RestClient.Builder builder = RestClient.builder();
        MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build();

        server.expect(requestTo("http://localhost:8000/embed"))
              .andExpect(method(HttpMethod.POST))
              .andExpect(jsonPath("$.texts[0]").value("Đơn hàng bị trễ"))
              .andRespond(withSuccess("""
                      {"embeddings": [[0.1, 0.2, 0.3]], "dimension": 3}
                      """, MediaType.APPLICATION_JSON));

        EmbeddingClient client = new EmbeddingClient(builder, "http://localhost:8000");

        List<List<Float>> result = client.embed(List.of("Đơn hàng bị trễ"));

        assertThat(result).hasSize(1);
        assertThat(result.getFirst()).containsExactly(0.1f, 0.2f, 0.3f);
        server.verify();
    }
}

MockRestServiceServer.bindTo(builder) muss bevor der Builder RestClient erstellt aufgerufen werden. In diesem Test ist das immer der Fall, da der Client nur im Konstruktor build() aufruft. Dadurch kann der Test ohne echten Python-Service laufen.

9. Häufige Fehler

  • Vektoren von mehreren Modellen vermischen. Vektoren aus zwei verschiedenen Modellen liegen in unterschiedlichen Räumen, auch wenn sie die gleiche Dimension haben. Ein Modellwechsel bedeutet, alle Daten erneut zu kodieren.
  • Auf einer Seite vergessen zu normalisieren. Wenn Dokumente normalisiert sind, aber die Query nicht, wird die Skalarprodukt-Berechnung falsch. Verwenden Sie die gleiche encode-Konfiguration für beide Seiten.
  • Text ist zu lang. Der Teil, der max_seq_length überschreitet, wird ohne Warnung abgeschnitten. Zerlegen Sie Dokumente in Chunks, bevor Sie kodieren.
  • Modell bei jeder Anfrage laden. Dies kostet mehrere Sekunden und viel RAM. Laden Sie das Modell nur beim Start.
  • Container ohne Netzwerkzugang. Produktionsumgebungen blockieren oft den Zugang zum Hub. Laden Sie das Modell vor in das Image und setzen Sie HF_HUB_OFFLINE=1.

Hinweis: Embedding kann exakte Suche nicht ersetzen. Für Bestellnummern, Telefonnummern oder SKUs geben Full-Text-Suche oder normale SQL-Abfragen bessere Ergebnisse. In der Praxis ist die Kombination beider (Hybrid Search) meist am effektivsten.

10. Fazit

Sie haben sentence-transformers verwendet, um Embeddings mit nur wenigen Zeilen Code zu erstellen. Sie haben auch die Cosinus-Ähnlichkeit berechnet, semantische Suche für eine FAQ-Sammlung implementiert und batch_size und device für schnellere Ausführung optimiert. Darüber hinaus wissen Sie, wie Sie Embeddings durch ihre Eigenschaften testen und das Modell als HTTP-Service wrappen, das Spring Boot über RestClient aufrufen kann.

Der nächste logische Schritt wäre, die Vektoren in einer Vector-Datenbank wie pgvector zu speichern und sie dann als Retrieval-Komponente in einer RAG-Pipeline zu verwenden.

huggingfacesentence-transformersembeddingpythonspring-boot