Sorgfältiger als ChatGPT: Embeddings mit Azure OpenAI, Qdrant und Rust

Seite 3: Datenbanken zum Speichern von Vektordaten

Inhaltsverzeichnis

Für das Beispiel lohnt es sich schon aus Kostengründen, die aus den Einträgen der Wissensdatenbank erstellten Embeddings zu speichern. Sie bei jeder Anfrage neu zu generieren, würde viel zu lange dauern und hohe Kosten verursachen. Theoretisch ist es möglich, die Vektoren in einer relationalen Datenbank, einer NoSQL-Datenbank oder in einfachen Dateien zu speichern. Um anhand eines Embedding-Vektors ähnliche Einträge zu suchen, müsste eine Anwendung jedoch die Distanzen des Vektors zu allen gespeicherten Embeddings ausrechnen und die Ergebnisse nach der Distanz sortieren. Bei mehr als 1500-dimensionalen Vektoren und zahlreichen Einträgen in der Wissensdatenbank entsteht eine Menge Rechenarbeit. Erforderlich ist ein spezialisiertes System, das zum einen Vektordistanzen effizient berechnen kann und zum anderen über Indexfunktionen verfügt, um performantes Suchen über große Sammlungen von Vektoren durchzuführen.

Dabei helfen spezialisierte Vektordatenbanken oder auf Vektorsuche spezialisierte Erweiterungen bestehender Datenbanksysteme wie das auf der Microsoft Build 23 angekündigte Vector Search in Azure CosmosDB. Vektordatenbanken sind darauf optimiert, vieldimensionale Vektoren zu speichern und bieten optimierte Suchfunktionen an. OpenAI führt auf seiner Webseite einige Vektordatenbanken auf, darunter die aus Deutschland stammende Qdrant. Sie bietet sich für die Integration in das Beispielprojekt an. Qdrant ist eine in Rust programmierte Open-Source-Vektordatenbank, die Unternehmen lokal betreiben oder als verwaltetes Cloud-Service von Qdrant beziehen können. Im Februar 2023 erschien das erste Major Release 1.0 von Qdrant, und kurz darauf erhielt das Berliner Start-up eine Seed-Finanzierung. Die Qdrant Cloud befindet sich jedoch noch in der Betaphase.

In der Cloud-Variante ist die geografische Region zum Speichern der Daten wählbar. Unter anderem steht ein Rechenzentrum in der EU zur Auswahl. Beim Verfassen dieses Artikels hat Qdrant nur Hosting in der AWS-Cloud von Amazon angeboten, aber Azure und die Google Cloud Platform sollen bald folgen. Zum Lernen und für Prototypen bietet Qdrant eine kostenlose Variante des Cloud-Angebots an, die Einschränkungen bezüglich der zu speichernden und zu verarbeiteten Daten mitbringt. Wer Qdrant im eigenen Rechenzentrum betreiben möchte, greift am besten auf das passende Docker Image zurück.

Eine vollständige Erläuterung aller Funktionen von Qdrant stellt der Hersteller in einer umfangreichen Dokumentation zur Verfügung. Für das flexible Entwickeln von Software mit Qdrant existieren neben einer REST- und einer gRPC-API spezielle Bibliotheken für die Programmiersprachen Python, Rust und Go. Bei den Tests und Recherchen hat sich ergeben, dass der Python-Client am weitesten fortgeschritten ist.

Wer Qdrant in Kombination mit OpenAI in Python programmieren möchte, findet gute Einstiegsbeispiele im zugehörigen Abschnitt der Beispielsammlung OpenAI Cookbook. Der Beispielcode für diesen Artikel ist primär in Rust geschrieben, und der Rust-Client von Qdrant funktionierte reibungslos. Leider gibt es dafür momentan lediglich eine spärliche Dokumentation und weit weniger Codebeispiele als für den Python-Client.

Qdrant gruppiert Embeddings in Collections. Alle Vektoren in einer Collection müssen dieselbe Dimensionalität aufweisen und benötigen die Angabe des zu verwendenden Distanzermittlungsalgorithmus. Für das Zusammenspiel mit dem LLM von OpenAI genügt "Dot Product", da es aufgrund der normalisierten Vektoren mit der Länge 1 der "Cosine Distance" entspricht. Qdrant kennt vor allem für das Indizieren der Vektoren eine Vielzahl weiterer Einstellungen. Die Beschreibung der Einstellungen findet sich in der Dokumentation.

Zu jedem Embedding-Vektor lassen sich in Qdrant ein Primärschlüssel (Zahlenwert oder UUID) und Key-Value-Paare als Metadaten speichern. Der Primärschlüssel ist wichtig, um später Bezüge zwischen dem Vektor und externen Datenbanken wie der Wissensdatenbank im Beispiel herstellen zu können. Anwendungen können die Metadaten bei Abfragen der Vektordatenbank zum Filtern verwenden.

Folgender Codeausschnitt importiert einen Abschnitt aus der JSON-Datei des SQuAD-Datensatzes in Qdrant:

async fn import_embeddings(
  file: String,
  collection_name: String,
  section: String,
  model: String,
) -> Result<()> {
  // Read one section of the SQuAD dataset JSON file
  let file = File::open(file)?;
  let reader = BufReader::new(file);
  let records: Root = serde_json::from_reader(reader)?;
  let section = records
    .data
    .iter()
    .enumerate()
    .filter(|(_, r)| r.title == section)
    .collect::<Vec<_>>()[0];

  // Create Qdrant client
  let client = create_qdrant_client().await?;

  // Iterate through all the knowledge base
  // entries. Each entry from the SQuAD dataset
  // contains a section of a Wikipedia article.
  for (ix, r) 
    in section.1.paragraphs.iter().enumerate() {
    // Build the payload that is stored together 
    // with the embedding vector. The payload can 
    // consist of a number of key/value pairs.
    // For the values, Qdrant supports a number 
    // of data types including strings, numeric
    // values, structured data types, and lists.
    let payload: Payload = vec![
      ("section", section.1.title.as_str().into()),
      ("ix", Value::from(ix as i64)),
    ]
    .into_iter()
    .collect::<HashMap<_, Value>>()
    .into();

    // Generate the embedding vector using 
    // Azure OpenAI services.
    // Technically, this is a simple HTTP request.
    let res =
       generate_embeddings(r.context,
                           model.clone()).await?;

    // Add the embedding vector to Qdrant.
    let vector = res.data[0].embedding.clone();
    let points = vec![PointStruct::new(
    // Build a primary key with which we can 
    // correlate the entry in the SQuAD dataset 
    // and our embedding vector.
      (section.0 * 1000 + ix) as u64, 
      vector,
      payload,
    )];
    client.upsert_points(collection_name.as_str(),
                         points, None).await?;
  }

  Ok(())
}

async fn create_qdrant_client() -> 
  Result<QdrantClient> {
  let mut config = QdrantClientConfig::from_url(
    env::var("QDRANT_URL")?.as_str()
  );
  config.set_api_key(env::var("QDRANT_PAT")?.as_str());
  QdrantClient::new(Some(config)).await
}

In der Praxis ist es möglich, den Code zu optimieren, da Qdrant für größere Importprozesse mehrere Embeddings pro API Call hochladen kann.

Nachdem die Beispielanwendung die Embeddings der Wissensdatenbankeinträge inklusive zugehöriger Metadaten in die Vektordatenbank importiert, ist der nächste Schritt die Suchfunktion. Wenn die Anwendung eine Frage erhält, ermittelt sie dazu den Embedding-Vektor und sucht auf Basis der Vektordistanzen mithilfe von Qdrant jene Wissensdatenbankartikel, die inhaltlich am besten zu der Frage passen.

Das Suchen mit Qdrant ist komfortabel: Man ruft die Suchfunktion des Qdrant-Clients mit dem Embedding-Vektor der Frage plus eventueller Filterkriterien für die Metadaten auf. Beispielsweise lässt sich darüber festlegen, dass Qdrant nur die mit einem bestimmten Schlagwort versehenen Wissensdatenbankeinträge berücksichtigt. Bei der Abfrage akzeptiert die Datenbank weitere Parameter wie die gewünschte Anzahl der Vektoren im Ergebnis. Das Resultat der Abfrage ist eine absteigend sortierte Liste von Embeddings mit ihren Primärschlüsseln und Metadaten, die dem gegebenen Embedding-Vektor am ähnlichsten sind. Die Beispielanwendung verwendet den Primärschlüssel der Ergebnis-Embeddings, um den Bezug zur Wissensdatenbank herzustellen und von dort die Textabschnitte zum Generieren der Antwort zu holen.

Es wäre in der Praxis möglich, der KI nicht nur den ähnlichsten Wissensdatenbankartikel zur Analyse zu übergeben, sondern mehrere zu verwenden. Neue LLMs wie GPT-4 sind für diese Vorgehensweise besonders geeignet, da sie lange Texte als Eingabe erlauben. Das Standardmodell von GPT 4 erlaubt 8.000 Token. Daneben steht ein erweitertes Modell mit 32.000 Token zur Verfügung. Bei langen zu analysierenden Texten gilt es die höheren Kosten zu berücksichtigen, da Azure OpenAI nach Token-Anzahl abrechnet.

Der letzte Schritt in der Beispielanwendung ist das Generieren der Antwort. Das hat nichts mehr mit Embeddings zu tun. Stattdessen kommt die reguläre Text-Completion-API von OpenAI zum Einsatz (siehe Abbildung 5). Beim Schreiben des Artikels war das fortgeschrittenste in Produktion befindliche LLM dafür "text-davinci-003". Das jüngste LLM "gpt-4" ist in der Preview-Phase.

Eine Abfrage von "text-davinci-003" über die Azure OpenAI Services kostet aktuell 1,8111 Cent pro 1000 Token. Die oben dargestellte Beispielabfrage nach dem Schmelzpunkt des fiktiven Elements Froxium verbraucht rund 150 Token, kostet daher in etwa 0,27 ct. "text-davinci-003" ist jedoch nicht in der Lage, die Frage "Given the above facts, at what temperature does Froxium change from a solid to a liquid?" zu beantworten. Erst wenn man explizit nach dem im Text erwähnten Schmelzpunkt ("melting point") fragt, erhält man eine Antwort.

Das *gpt-35-turbo* Modell und das neueste GPT-4-Modell beantworten auch die ursprüngliche Frage ohne explizit erwähnten Schmelzpunkt korrekt. "gpt-35-turbo" ist mit 0,19 Cent pro 1000 Token kostengünstig. GPT-4-Abfragen sind deutlich teurer. Unsere Beispielabfrage verursacht dabei Kosten von rund 3 bis 4 Cent. In der Praxis müssen Unternehmen auf Projektbasis eine gute Balance von Kosten und geforderter Funktionsweise finden.