Veröffentlicht am

Sentence Embeddings für Vektordatenbanken

Semantisches Retrieval basiert auf Vektoren und Vektordatenbanken. Prominenteste Anwendung ist RAG – Retrieval Augmented Generation.
Wie verwandelt man einen Text in einen Vektor? In diesem Artikel beleuchten wir verschiedene Möglichkeiten.

Embeddings: Texte als Vektoren darstellen

Die Abbildung veranschaulicht das Vorgehen: Unser Text wird einem Modell präsentiert und als Ausgabe erhalten wir einen Vektor.

Schematische Darstellung Text zu Vektor

Je nach Anwendungsfall werden wir mit unterschiedlichen Embeddings arbeiten:

Bei Texten unterscheiden wir zwei Hauptkategorien:

Word Embeddings: Diese erkennen die semantische Bedeutung von Wörtern. Ähnliche Wörter, wie beispielsweise Synonyme, liegen im Vektorraum näher beisammen, als unähnliche Wörter.

Sentence Embeddings: Diese gehen weiter und sind in der Lage, die semantische Bedeutung eines Satzes oder Texts zu erfassen. Sie unterstützen dabei, Dokumente zu finden, die zur Suchfrage semantisch ähnlich sind. Semantisch ähnliche Sätze liegen im Vektorraum nah beieinander.

Wir konzentrieren uns in diesem Artikel auf Sentence Embeddings. Der Artikel Semantische Suche und Abstandsmetriken untersucht, welche Rolle Abstände zwischen Vektoren bei der Vektorsuche spielen.

Sentence Embeddings mit OpenAI

Das API von OpenAI ist einfach zu bedienen. Als erstes beschaffen wir einen API-Key und setzen diesen im folgenden Code-Snippet ein:

import requests
import json
api_key = ‘...’

def generate_embeddings_openai(text):
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
}

data = {
"input": text,
"model": "text-embedding-ada-002",
"encoding_format": "float"
}

response = requests.post("https://api.openai.com/v1/embeddings", 
headers=headers, data=json.dumps(data))
return response.json()

text = "Heute ist ein schöner Tag"
embeddings = generate_embeddings_openai(text)

print(embeddings['data'][0]['embedding'][:10])

Die Ausgabe zeigt die 10 ersten Dimensionen des Embedding-Vektors:

[0.013132129, 0.023169499, 0.008860657, -0.037466574, 
0.004586243, 0.011372942, -0.010637495, 0.0057717822, 
-0.0012745284, -0.026240723]

Wir ermitteln die Länge des Vektors:

len(embeddings['data'][0]['embedding'])
1536

Das Modell text-embedding-ada-002 von OpenAI liefert also 1536-dimensionale Vektoren – ganz unabhängig davon, wie lang unser Text war.

text-embedding-ada-002 ist eines von vielen Modellen, die OpenAI anbietet.

OpenAI ist kostenpflichtig und wer längere Zeit damit experimentieren möchte, belastet bald die Kreditkarte.

Es gibt viele Alternativen.

Huggingface Modelle

Auf Huggingface werden Open-Source-Modelle gehostet. Uns interessieren Sentence-Transformer-Modelle.
Auch hier kommen wir mit wenigen Zeilen zu einem Embedding:

import requests
from sentence_transformers import SentenceTransformer
import torch

MODEL_NAME = 'all-MiniLM-L6-v2'

model = SentenceTransformer(MODEL_NAME)

Im Unterschied zu OpenAI laden wir das Modell auf unser Laptop und führen die Embedding-Methode lokal aus – schicken unseren Text also nicht zu einem Anbieter wie OpenAI.

Wir wählen das Modell all-MiniLM-L6-v2, eines von vielen, das auf Huggingface gehostet wird.

Wir können herausfinden, ob wir auf dem Laptop mit der GPU arbeiten:

if torch.cuda.is_available():
model = model.to(torch.device("cuda"))
print(model.device)

Für unser Mini-Beispiel reicht eine CPU bestens.

Jetzt generieren wir das Embedding:

text = "Heute ist ein schöner Tag"
embeddings = generate_embeddings_pytorch(text)

Und wir schauen wiederum die ersten 10 Dimensionen sowie die Gesamtlänge des Vektors an:

print(embeddings[:10])
print(len(embeddings))
[-0.05164861 0.13550845 -0.03804124 -0.02148658 0.00825853 0.10632604
0.06337947 0.0586526 0.02057826 -0.03433402]
384

Bei dieser Variante erhalten wir also Vektoren mit 384 Dimensionen.

Das geht ganz schmerzlos, nicht wahr.

Beide API-Calls, die wir bis hierher betrachtet haben, vereinfachen das Leben und nehmen uns viele Entscheidungen ab. Wir müssen uns lediglich für das Modell entscheiden – und es gibt viele Modelle, sie wurden auf verschiedenen Texten mit unterschiedlichen Zielen trainiert.

Schauen wir eine komplexere aber flexiblere Variante an, wie wir zu Sentence Embeddings kommen:

Huggingface mit PyTorch

Mit der Python-Library PyTorch können wir die Art und Weise steuern,  wie die Sentence-Embeddings erstellt werden:

from transformers import AutoTokenizer, AutoModel
import torch

text = "Heute ist ein schöner Tag."
model_name='sentence-transformers/all-MiniLM-L6-v2'

Wir verwenden wiederum dasselbe Modell von Huggingface, wie im ersten Beispiel.

Als erstes wenden wir einen Tokenizer an. Dabei müssen wir denselben Tokenizer verwenden, der beim Training des gewählten Modells eingesetzt wurde. Wir können den Tokenizer beim Modell ermitteln und gleich auf unseren Text anwenden – mit return_tensors="pt" bestellen wir das Ergebnis im PyTorch-Format.

tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModel.from_pretrained(model_name)

inputs = tokenizer(text, return_tensors="pt")
inputs

Hier der Output des Tokenizers:

{'input_ids': [101, 2002, 10421, 21541, 16417, 8040, 27406, 2099, 6415, 1012, 102], 'token_type_ids': [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 'attention_mask': [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]}

Interessant ist die erste Liste: diese enthält die Tokens – das Bild zeigt schematisch, wie ein Tokenizer funktioniert:

Tokenizer für Sentence Embeddings

Der Input Text wird nach einem bestimmten Algorithmus – dem Tokenizer – in Wörter und falls nötig, in Subwörter zerhackt. Ergebnis ist eine Liste von Tokens. Wie ein Tokenizer genau funktioniert, werden wir in einem späteren Artikel beleuchten. An dieser Stelle geht es darum, den Zusammenhang zwischen dem Tokenizer und den Embeddings herauszuarbeiten.

Die Tokens sind durchnummeriert und liegen in einer Tabelle vor. Das Ergebnis des Tokenizers ist eine Liste mit diesen Tokennummern. Was wir sehen, ist jeweils die Tokennummer. Dazu gehören auch spezielle Tokens, die das Modell verwendet, beispielsweise [CLS], [SEP].

Aus unserem Satz mit 5 Wörtern sind jetzt also 11 Tokens geworden: Wörter, Subwörter und die speziellen Tokens. Wir beleuchten Tokenizers und im übernächsten Abschnitt noch näher.

Jetzt generieren wir das Embedding:

with torch.no_grad():
    outputs = model(**inputs)

Das Ergebnis finden wir im Tensor outputs.last_hidden_state.
Schauen wir die Form des Tensors an:

print(outputs.last_hidden_state.shape)
torch.Size([1, 11, 384])

outputs.last_hidden_state ist ein Tensor mit den folgenden Dimensionen:

  • batch_size: Anzahl verarbeiteter Texte. In unserem Beispiel wurde 1 Text verarbeitet, batch_size ist also 1.
  •  sequence_length: Anzahl Tokens im verarbeiteten Text, inklusive derjenigen Tokens die der Tokenizer hinzugefügt hat (beispielsweise [CLS], [SEP], in unserem Beispiel sind es 11 Tokens.
  •  hidden_size: die Größe des Hidden Layers. Sie hängt vom gewählten Modell ab. In unserem Beispiel: 384

Wir möchten jedoch nur einen einzelnen Vektor haben. Das erreichen wir mit dem folgenden Befehl. Im nächsten Abschnitt zum Pooling beleuchten wir den Befehl näher.

embeddings = outputs.last_hidden_state.mean(dim=1).squeeze().numpy()

Wir schauen uns wieder die 10 ersten Dimensionen des Vektors sowie dessen Gesamtlänge an:

print(embeddings[:10])
print(len(embeddings))

Pooling

Wie ist das Embedding entstanden? Untersuchen wir die Form Tensors im Last Hidden State:

Der Tensor des Last Hidden State und der Effekt des Poolings

Die Batch-Size ist bei uns eins, weil wir nur einen Satz verwendet haben.

Um aus diesem Tensor einen einzelnen Vektor zu erhalten, konzentrieren wir uns auf die Dimension 1 – diese enthält einen 384-dimensionalen Vektor für jedes der 11 Tokens.

Für unseren Satz aggregieren wir die Informationen der Tokens auf einen Vektor der Länge hidden size, also 384.

embeddings = outputs.last_hidden_state.mean(dim=1).squeeze().numpy()

Mit .mean(dim=1) bilden wir den Durschnitt im Tensor dieser zweiten Dimension (also dim=1) entlang. Damit bilden wir den Durchschnitt der Hidden States über alle Token in der Sequenz.

Wir aggregieren also die Information aller Tokens in einen einzelnen Vektor für den Input-Text. Damit erhalten wir immer einen Vektor derselben Dimension, unabhängig davon, wie groß er Input-Text ursprünglich war. Man spricht von ‘Pooling‘.

Die Codezeile nimmt also die Ausgabe des Transformer-Modells, konzentriert sich auf den letzten Hidden State, führt ein Mean-Pooling über die Wörter durch, um einen einzelnen Sentence-Embedding-Vektor zu erhalten.

Wir können auch das Maximum bilden, oder die Summe oder einfach das letzte Token nehmen oder einen eigenen Attention Pooling Mechanismus bauen, der für unsere Texte am besten geeignet ist.

Oder wir verlassen uns einfach auf den vereinfachten API-Aufruf im Abschnitt Huggingface Modelle.

Der Tokenizer

Untersuchen wir den Tokenizer noch genauer. Wir haben ja schon gesehen, wie er geladen wird und jetzt untersuchen wir, welcher Tokenizer verwendet wird:

tokenizer = AutoTokenizer.from_pretrained(model_name)
print(tokenizer)
BertTokenizerFast(name_or_path='sentence-transformers/all-MiniLM-L6-v2', vocab_size=30522, model_max_length=512, is_fast=True, 
padding_side='right', truncation_side='right', special_tokens={'unk_token': '[UNK]', 
'sep_token': '[SEP]', 'pad_token': '[PAD]', 'cls_token': '[CLS]', 'mask_token': '[MASK]'}, clean_up_tokenization_spaces=True), added_tokens_decoder={
0: AddedToken("[PAD]", rstrip=False, lstrip=False, single_word=False, normalized=False, special=True),
100: AddedToken("[UNK]", rstrip=False, lstrip=False, single_word=False, normalized=False, special=True),
101: AddedToken("[CLS]", rstrip=False, lstrip=False, single_word=False, normalized=False, special=True),
102: AddedToken("[SEP]", rstrip=False, lstrip=False, single_word=False, normalized=False, special=True),
103: AddedToken("[MASK]", rstrip=False, lstrip=False, single_word=False, normalized=False, special=True),
}

Wir sehen, dass der BertTokenizerFast verwendet wird und dass er ein Vokabular von 30522 Tokens umfasst. Die speziellen Tokens werden zusammen mit ihrem Index benannt.
[SEP], beispielsweise, hat Index 102.

Das gesamte Vokabular können wir auch laden:

vocab = tokenizer.get_vocab()

und ausgeben – hier nur die ersten Einträge:

for token, token_id in list(vocab.items())[:10]:
    print(f'{token}: {token_id}')
##的: 30442
ann: 5754
goldberg: 18522
merton: 28169
##ich: 7033
nowadays: 13367
authored: 8786
♦: 1626
teaches: 12011
sabha: 11200

Wir sehen hauptsächlich englischsprachige Tokens. Unser Text ist aber deutsch.

Schauen wir uns die Tokens an, die im Beispiel ermittelt wurden:

for id in inputs['input_ids'][0]:
    for token, token_id in list(vocab.items()): 
        if (token_id == id.item()):
            print(f'{token}: {token_id}')
[CLS]: 101
he: 2002
##ute: 10421
ist: 21541
ein: 16417
sc: 8040
##hone: 27406
##r: 2099
tag: 6415
.: 1012
[SEP]: 102

Nur die wenigsten Wörter sind ganz geblieben. Das englische ‘tag’ und das deutsche ‘Tag’ haben eine völlig unterschiedliche Bedeutung.

Wir würden ein deutschsprachiges Vocabular vorziehen: Hier ein Beispiel:

from transformers import BertTokenizerFast

# Den Tokenizer des deutschen BERT Models laden
tokenizer = BertTokenizerFast.from_pretrained('bert-base-german-dbmdz-cased')

# Das Vokabular laden
vocab = tokenizer.get_vocab()

# Die ersten 10 Einträge anschauen
for token, token_id in list(vocab.items())[:10]: 
    print(f'{token}: {token_id}')
Beute: 17401
Dunkelheit: 22903
geborgen: 29079
Erl: 8179
Straub: 30300
##utzer: 17308
Fan: 11085
Museen: 14632
Statue: 22955
Elektrom: 22179

Für einen deutschsprachigen Text liegt es nahe, ein Modell zu verwenden, das mit einem deutschen Vokabular trainiert wurde.

Deutschsprachiges Modell verwenden

from transformers import AutoModel, AutoTokenizer

tokenizer = AutoTokenizer.from_pretrained("dbmdz/bert-base-german-cased")
model = AutoModel.from_pretrained("dbmdz/bert-base-german-cased")
text = "Heute ist ein schöner Tag."
inputs = tokenizer(text, return_tensors="pt")

with torch.no_grad(): 
outputs = model(**inputs)

print(outputs.last_hidden_state.shape)
embeddings = outputs.last_hidden_state.mean(dim=1).squeeze().numpy()

type(embeddings)
list(embeddings)[:10]

Schauen wir den tokenisierten Text an:

print(inputs)
{'input_ids': tensor([[  102,  4275,   215,   143, 16460,  1143,   566,   103]]), 
'token_type_ids': tensor([[0, 0, 0, 0, 0, 0, 0, 0]]), 
'attention_mask': tensor([[1, 1, 1, 1, 1, 1, 1, 1]])}

Schlagen wir die IDs im Vokabular nach:

for id in inputs['input_ids'][0]:
    for token, token_id in list(vocab.items()): 
        if (token_id == id.item()):
            print(f'{token}: {token_id}')
[CLS]: 102
Heute: 4275
ist: 215
ein: 143
schöner: 16460
Tag: 1143
.: 566
[SEP]: 103

Das entspricht unserem Satz.

Die Embeddings poolen wir aus dem letzten Hidden Layer wie schon in den vorherigen Beispielen erläutert.

Fazit und Ausblick

Wir haben gesehen, wie wir aus einem Text einen Vektor, also ein Embedding generieren, um dieses in einer geeigneten Datenbank zu speichern. Dabei haben wir vorgefertigte API-Calls bei einem Anbieter wie OpenAI verwendet und einfache API-Aufrufe mit heruntergeladenen Open-Source-Modellen Huggingface verwendet. Mit  einer Library wie PyTorch eröffnen sich uns viele Wahlmöglichkeiten. Wir haben Tokenizer und Pooling mit Beispielen betrachtet.

Dabei bleiben viele Fragen offen:

  • Wie unterteilen wir einen Text in geeignete Teile – Chunks – um mit der semantischen Suche erfolgreich zu sein?
  • Welches ist das am besten geeignete Modell für unseren Text?
  • Gibt es bessere Poolings.?
  • Wie speichern wir die Vektoren in einer Vektordatenbank?
  • Wie wird die Vektordatenbank abgefragt?
  • Wie bewerten wir die beste Konfiguration für unsere Suche?
  • LLM-Tipps & Fachglossar

    Abonniere meinen Newsletter, erhalte regelmäßig Tipps und Tricks über den produktiven Einsatz von LLMs und ich schenke dir mein umfangreiches Fachglossar Von AI-Engineering bis Zero-Shot

  • Chatbot als Lernassistent
  • Prompt Engineering Personas und Wiederholungen
  • AI-Engineering-Fachglossar
  • EBook Tutorial: Cluster aus virtuellen Maschinen
  • Ebook: Apache ZooKeeper
  • Ebook: Realtime Streaming Pipelines
  • LSM-Trees: Log Structured Merge Trees
  • Aufbau einer Enterprise Search
  • Zeit Stream Analytics
  • B-Tree-Index in Datenbanken
  • Ordering Guarantee in Apache Kafka
  • CAP Theorem
  • MapReduce Funktionale Programmierung
  • Konzepte des HDFS
  • Optimistisches Concurrency Control