
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.
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:
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))
[-0.24873008 0.6574714 -0.16815187 -0.04999075 0.00677169 0.53792506 0.29733992 0.39668652 0.1200243 -0.23748413] 384
Pooling
Wie ist das Embedding entstanden? Untersuchen wir die Form Tensors im Last Hidden State:
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?