RAG ohne OpenAI: BentoML, OctoAI und Milvus
Dieser Artikel wurde ursprünglich in The New Stack veröffentlicht und wird hier mit Genehmigung erneut veröffentlicht.
Erweiterte Optionen für Retrieval Augmented Generation können die Abhängigkeit von Entwicklerinnen und Entwicklern von OpenAI beseitigen
ChatGPT rückte KI im Jahr 2023 in den Fokus des öffentlichen Bewusstseins. Mittlerweile gibt es jedoch viele weitere Optionen, sodass wir nicht mehr an OpenAI gebunden sind. Dies ist der dritte Beitrag in einer Blogreihe darüber, wie Sie Retrieval Augmented Generation-Apps (RAG) mit LLMs erstellen können, die nicht OpenAIs GPT sind. Hier finden Sie Teil 1 und Teil 2. Das GitHub-Repo für dieses Projekt ist hier zu finden.
In diesem Tutorial verwenden wir BentoML, um Embeddings bereitzustellen, OctoAI, um das LLM zu erhalten, und Milvus als unsere Vektordatenbank. Wir behandeln:
Bereitstellung von Embeddings mit BentoML
Einfügen Ihrer Daten in eine Vektordatenbank für RAG
Erstellen Ihrer Milvus-Collection
Parsen und Einbetten Ihrer Daten zum Einfügen
Einrichten Ihres LLM für RAG
Dem LLM Anweisungen geben
Ein RAG-Beispiel
Zusammenfassung des BOM dot COM: BentoML, OctoAI und Milvus für RAG
Bereitstellung von Embeddings mit BentoML
Wir können Satz-Embeddings verwenden, die über BentoML mithilfe seines Sentence Transformers Embeddings-Repositorys bereitgestellt werden. Lassen Sie uns kurz durchgehen, was in diesem Repo vor sich geht. Das Wichtigste ist die Datei service.py. Im Grunde startet sie einen Server und stellt darauf einen API-Endpunkt bereit. Innerhalb des API-Endpunkts lädt sie all-MiniLM-L6-v2 von Hugging Face und verwendet es, um Embeddings zu erstellen.
Dieses Repository startet einen Server und gibt uns einen Endpunkt, den wir unter <http://localhost:3000> ansprechen können. Um diesen Endpunkt zu nutzen, importieren wir bentoml und starten einen HTTP-Client mit dem nativen Objekttyp SyncHTTPClient.
import bentoml
bento_client = bentoml.SyncHTTPClient("http://localhost:3000")
Sobald wir mit dem Client verbunden sind, erstellen wir eine Funktion, die eine Liste von Embeddings aus einer Liste von Strings abruft. Ein Punkt, den man beachten sollte, ist, dass ich die Listen in jeweils 25 Strings aufteile. Das liegt vor allem daran, dass wir einen synchronen Endpunkt verwenden. Das Aufteilen der String-Liste macht die Aufrufe weniger rechenintensiv und vermeidet Timeouts.
Nachdem wir die Liste in Abschnitte mit 25 Elementen aufgeteilt haben, rufen wir den oben erstellten bento_client auf, um diese Sätze zu encodieren. Der BentoML-Client gibt eine Liste von Vektoren zurück, effektiv eine Liste von Listen. Wir nehmen jeden dieser Vektoren und hängen ihn an unsere leere Embedding-Liste an. Am Ende dieser Schleife geben wir die endgültige Liste der Embeddings zurück.
Wenn die Textliste nicht mehr als 25 Strings enthält, rufen wir einfach die Methode encode des Clients auf der übergebenen Liste von Strings auf.
def get_embeddings(texts: list) -> list:
if len(texts) > 25:
splits = [texts[x:x+25] for x in range(0, len(texts), 25)]
embeddings = []
for split in splits:
embedding_split = bento_client.encode(
sentences = split
)
for embedding in embedding_split:
embeddings.append(embedding)
return embeddings
return bento_client.encode(
sentences=texts,
)
Einfügen Ihrer Daten in eine Vektordatenbank für RAG
Nachdem unsere Einbettungsfunktion vorbereitet ist, können wir unsere Daten für das Einfügen in Milvus für unsere RAG-Anwendung vorbereiten. Der erste Schritt in diesem Abschnitt besteht darin, Milvus zu starten und eine Verbindung herzustellen. Im oben verlinkten Repository befindet sich eine docker-compose.yml-Datei. Sie finden außerdem das Milvus Docker Compose auf dieser Dokumentationsseite.
Wenn Sie Docker installiert und dieses Repo heruntergeladen haben, sollten Sie docker compose up -d ausführen können, um Milvus zu starten. Sobald Ihr Milvus-Server läuft, ist es an der Zeit, eine Verbindung zu ihm herzustellen. Für diesen Teil importieren wir einfach das Modul connections und rufen connect mit dem Host (localhost oder 127.0.0.1) und dem Port (19530) auf. Der folgende Codeblock definiert außerdem zwei Konstanten – einen Sammlungsnamen und die Dimension. Sie können einen beliebigen Sammlungsnamen wählen. Die Dimensionsgröße stammt von der Größe des Einbettungsmodells all-MiniLM-L6-v2.
from pymilvus import connections
COLLECTION_NAME = "bmo_test"
DIMENSION = 384
connections.connect(host="localhost", port=19530)
Ihre Milvus-Sammlung erstellen
Das Erstellen einer Sammlung in Milvus umfasst zwei Schritte: erstens das Definieren des Schemas und zweitens das Definieren des Index. Für diesen Abschnitt benötigen wir vier Module: FieldSchema definiert ein Feld, CollectionSchema definiert eine Sammlung, DataType sagt uns, welche Art von Daten in einem Feld enthalten sein wird, und Collection ist das Objekt, das Milvus zum Erstellen von Sammlungen verwendet.
Wir können hier das gesamte Schema für die Sammlung definieren. Oder wir können einfach die zwei notwendigen Teile definieren: id und embedding. Wenn es dann an der Zeit ist, das Schema zu definieren, übergeben wir einen Parameter, enabled_dynamic_field, der es uns ermöglicht, beliebige Felder einzufügen, solange wir auch die Felder id und embedding haben. Dadurch können wir das Einfügen von Daten in Milvus genauso behandeln, wie wir eine NoSQL-Datenbank wie MongoDB behandeln würden. Als Nächstes erstellen wir einfach die Sammlung mit dem zuvor angegebenen Namen und Schema.
from pymilvus import FieldSchema, CollectionSchema, DataType, Collection
# id and embedding are required to define
fields = [
FieldSchema(name="id", dtype=DataType.INT64, is_primary=True, auto_id=True),
FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=DIMENSION)
]
# "enable_dynamic_field" lets us insert data with any metadata fields
schema = CollectionSchema(fields=fields, enable_dynamic_field=True)
# define the collection name and pass the schema
collection = Collection(name=COLLECTION_NAME, schema=schema)
Nachdem wir unsere Sammlung erstellt haben, müssen wir den Index definieren. Im Hinblick auf die Suche definiert ein „Index“, wie wir unsere Daten für den Abruf abbilden werden. Wir verwenden HSNW (hierarchical navigable small worlds), um unsere Daten für dieses Projekt zu indizieren. Wir müssen außerdem definieren, wie wir die Vektordistanz messen. In diesem Beispiel verwenden wir das Skalarprodukt oder IP.
Jeder der 11 Indextypen, die in Milvus angeboten werden, hat einen anderen Satz von Parametern. Für HNSW haben wir zwei Parameter zum Abstimmen: „M“ und „efConstruction“. „M“ ist die obere Grenze für den Grad eines Knotens in jedem Graphen, und „efConstruction“ ist der explorative Faktor, der während der Indexerstellung verwendet wird.
Aus praktischer Sicht führen sowohl höhere Werte für „M“ als auch für „efConstruction“ zu einer besseren Suche. Ein höherer „M“-Wert bedeutet, dass der Index mehr Speicher belegt. Ein höherer „efConstruction“-Wert bedeutet, dass der Aufbau des Index länger dauert. Du musst mit diesen Werten experimentieren, um die besten Werte zu finden.
Sobald der Index definiert ist, erstellen wir den Index für ein ausgewähltes Feld, in diesem Fall embedding. Dann rufen wir load auf, um die Collection in den Speicher zu laden.
index_params = {
"index_type": "HNSW", # one of 11 Milvus indexes
"metric_type": "IP", # L2, Cosine, or IP
"params": {
"M": 8, # higher M = consumes more memory but better search quality
"efConstruction": 64 # higher efConstruction = slower build, better search
},
}
# pass the field to index on and the parameters to index with
collection.create_index(field_name="embedding", index_params=index_params)
# load the collection into memory
collection.load()
Parsen und Einbetten deiner Daten zum Einfügen
Nachdem Milvus bereit ist und die Verbindung hergestellt wurde, können wir Daten in unsere Vektordatenbank einfügen. Aber zuerst müssen wir die Daten für das Einfügen vorbereiten. Für dieses Beispiel haben wir eine Reihe von txt-Dateien, die im data-Ordner des Repos verfügbar sind. Wir teilen diese Daten in Chunks auf, betten sie ein und speichern sie in Milvus.
Beginnen wir damit, eine Funktion zu erstellen, die diesen Text in Chunks aufteilt. Es gibt viele Möglichkeiten zum Chunking, aber für dieses Beispiel machen wir es naiv. Die folgende Funktion nimmt eine Datei, liest sie als String ein und teilt sie dann an jeder neuen Zeile auf. Sie gibt die neu erstellte Liste von Strings zurück.
# naively chunk on newlines
def chunk_text(filename: str) -> list:
with open(filename, "r") as f:
text = f.read()
sentences = text.split("n")
return sentences
Als Nächstes verarbeiten wir jede der Dateien, die wir haben. Wir erhalten eine Liste aller Dateinamen und erstellen eine leere Liste, um die gechunkten Informationen zu speichern. Dann durchlaufen wir alle Dateien und führen die obige Funktion für jede aus, um ein naives Chunking jeder Datei zu erhalten. Bevor wir die Chunks speichern, müssen wir sie bereinigen.
Wenn du dir ansiehst, wie eine einzelne Datei in Chunks aufgeteilt wird, wirst du viele leere Zeilen sehen, und leere Zeilen wollen wir nicht. Einige Zeilen bestehen nur aus Tabs oder anderen Sonderzeichen. Um diese zu vermeiden, erstellen wir eine leere Liste und speichern nur die Chunks oberhalb einer bestimmten Länge. Der Einfachheit halber können wir sieben Zeichen verwenden.
Sobald wir eine bereinigte Liste von Chunks aus jedem Dokument haben, können wir unsere Daten speichern. Wir erstellen ein Dictionary, das jede Liste von Chunks dem Namen des Dokuments zuordnet, in diesem Fall dem Namen der Stadt. Dann hängen wir all diese an die leere Liste an, die wir oben erstellt haben.
import os
cities = os.listdir("data")
# store chunked text for each of the cities in a list of dicts
city_chunks = []
for city in cities:
chunked = chunk_text(f"data/{city}")
cleaned = []
for chunk in chunked:
if len(chunk) > 7:
cleaned.append(chunk)
mapped = {
"city_name": city.split(".")[0],
"chunks": cleaned
}
city_chunks.append(mapped)
Da nun ein Satz gechunkter Texte für jede Stadt bereitsteht, ist es Zeit, einige Embeddings zu erstellen. Milvus kann eine Liste von Dictionaries entgegennehmen, um sie in eine Collection einzufügen, daher können wir mit einer weiteren leeren Liste beginnen. Für jedes der oben erstellten Dictionaries müssen wir eine Liste von Embeddings erhalten, die zur Liste der Sätze passt.
Wir tun dies, indem wir die Funktion get_embeddings, die wir im Abschnitt mit BentoML erstellt haben, direkt auf jedes Element der Liste von Chunks anwenden. Jetzt müssen wir sie einander zuordnen. Da die Liste der Embeddings und die Liste der Sätze über den Index übereinstimmen sollten, können wir mit enumerate durch eine der beiden Listen iterieren, um sie einander zuzuordnen.
Wir ordnen sie einander zu, indem wir ein Dictionary erstellen, das einen einzelnen Eintrag in Milvus repräsentiert. Jeder Eintrag enthält das Embedding, den zugehörigen Satz und die Stadt. Es ist optional, die Stadt einzubeziehen, aber nehmen wir sie auf, damit wir sie verwenden können. Beachte, dass es nicht nötig ist, eine id in diesen Eintrag aufzunehmen. Das liegt daran, dass wir uns dafür entschieden haben, die id automatisch zu inkrementieren, als wir oben das Schema erstellt haben.
Wir fügen jeden dieser Einträge der Liste hinzu, während wir sie durchlaufen. Am Ende haben wir eine Liste von Dictionaries, wobei jedes Dictionary einen einzeiligen Eintrag für Milvus repräsentiert. Anschließend können wir diese Einträge einfach in unsere Milvus-Collection einfügen. Der letzte Schritt hier ist, die Einträge zu flushen, damit wir mit der Indizierung beginnen können.
entries = []
for city_dict in city_chunks:
embedding_list = get_embeddings(city_dict["chunks"]) # returns a list of lists
# now match texts with embeddings and city name
for i, embedding in enumerate(embedding_list):
entry = {"embedding": embedding,
"sentence": city_dict["chunks"][i], # poorly named cuz it's really a bunch of sentences, but meh
"city": city_dict["city_name"]}
entries.append(entry)
collection.insert(entries)
collection.flush()
Richte dein LLM für RAG ein
Jetzt holen wir uns unser LLM und machen uns bereit, loszulegen. Und mit loslegen meine ich: etwas RAG machen. Um diesen Abschnitt genau so durchzuführen, brauchst du ein Konto bei OctoAI. Du kannst auch ein beliebiges LLM deiner Wahl als Ersatz einsetzen.
In diesem ersten Codeblock laden wir einfach unsere Umgebungsvariablen, extrahieren unser OctoAI-API-Token und starten deren Client.
from dotenv import load_dotenv
load_dotenv()
os.environ["OCTOAI_TOKEN"] = os.getenv("OCTOAI_API_TOKEN")
from octoai.client import Client
octo_client = Client()
Dem LLM Anweisungen geben
Es gibt zwei Dinge, die das LLM wissen muss, um RAG durchzuführen: die Frage und den Kontext. Wir können beides auf einmal übergeben, indem wir eine Funktion erstellen, die zwei Strings entgegennimmt: die Frage und den Kontext. Mit dieser Funktion verwenden wir die Chat Completion des OctoAI-Clients, um ein LLM aufzurufen. Für dieses Beispiel verwenden wir das feinabgestimmte Mixtral-Modell von Nous Research.
Wir geben diesem Modell zwei „messages“, die angeben, wie es sich verhalten soll. Zuerst geben wir dem LLM eine Nachricht, die ihm mitteilt, dass es eine Frage des Benutzers ausschließlich auf Grundlage des gegebenen Kontexts beantwortet. Als Nächstes sagen wir ihm, dass es einen Benutzer geben wird, und übergeben einfach die Frage.
Die anderen Parameter dienen dazu, das Modellverhalten abzustimmen. Wir können die maximale Anzahl an Tokens steuern und wie „kreativ“ sich das Modell verhält.
Die Funktion gibt dann die Ausgabe des Clients im JSON-Format zurück.
def dorag(question: str, context: str):
completion = octo_client.chat.completions.create(
messages=[
{
"role": "system",
"content": f"You are a helpful assistant. The user has a question. Answer the user question based only on the context: {context}"
},
{
"role": "user",
"content": f"{question}"
}
],
model="nous-hermes-2-mixtral-8x7b-dpo",
max_tokens=512,
presence_penalty=0,
temperature=0.1,
top_p=0.9,
)
return completion.model_dump()
Ein RAG-Beispiel
Jetzt sind wir bereit. Es ist Zeit, eine Frage zu stellen. Wir könnten das wahrscheinlich tun, ohne eine Funktion zu erstellen, aber eine Funktion macht es schön und wiederholbar. Diese Funktion nimmt einfach eine Frage entgegen und führt dann RAG aus, um sie zu beantworten.
Wir beginnen damit, die Frage mit demselben Embedding-Modell einzubetten, das wir zum Einbetten der Dokumente verwendet haben. Als Nächstes führen wir eine Suche in Milvus aus. Beachte, dass wir die Frage im Listenformat an die Funktion get_embeddings übergeben und dann die ausgegebene Liste direkt in den Abschnitt data unserer Milvus-Suche einfügen. Das liegt einfach an der Art, wie die Funktionssignaturen eingerichtet sind; es ist einfacher, sie wiederzuverwenden, als mehrere Funktionen neu zu schreiben.
Innerhalb unseres Suchaufrufs müssen wir außerdem noch ein paar weitere Parameter angeben. anns_field teilt Milvus mit, auf welchem Feld eine approximate nearest neighbor search (ANNS) durchgeführt werden soll. Außerdem müssen wir einige Parameter für den Index übergeben. Stelle sicher, dass der Metriktyp mit dem übereinstimmt, den wir zum Erstellen des Index verwendet haben, in diesem Fall IP. Außerdem müssen wir einen passenden Indexparameter verwenden, in diesem Fall ef, oder Explorationsfaktor.
Ein höherer ef bedeutet eine längere Suchzeit, aber einen höheren Recall. Du kannst damit experimentieren; ef kann bis 2048 gehen; wir verwenden 16 für Geschwindigkeit und Einfachheit. In diesem Datensatz gibt es nur Tausende von Einträgen. Als Nächstes übergeben wir außerdem einen limit-Parameter, der uns sagt, wie viele Ergebnisse wir von Milvus zurückbekommen möchten; für dieses Beispiel können wir einfach fünf nehmen.
Der letzte Suchparameter definiert, welche Felder wir von unserer Suche zurückerhalten möchten. Für dieses Beispiel können wir einfach sentence abrufen, also das Feld, in dem wir unseren Textabschnitt gespeichert haben. Sobald wir unsere Suchergebnisse zurückerhalten haben, müssen wir sie verarbeiten. Milvus gibt eine Entität mit hits darin zurück, also nehmen wir einfach den „sentence“-Wert aus allen fünf Treffern und verbinden sie mit einem Punkt, sodass daraus ein Listenabsatz entsteht.
Dann übergeben wir die vom Benutzer gestellte Frage zusammen mit diesem Absatz an die Funktion dorag, die wir oben erstellt haben, und geben einfach die Antwort zurück.
def ask_a_question(question):
embeddings = get_embeddings([question])
res = collection.search(
data=embeddings, # search for the one (1) embedding returned as a list of lists
anns_field="embedding", # Search across embeddings
param={"metric_type": "IP",
"params": {"ef": 16}},
limit = 5, # get me the top 5 results
output_fields=["sentence"] # get the sentence/chunk and city
)
sentences = []
for hits in res:
for hit in hits:
sentences.append(hit.entity.get("sentence"))
context = ". ".join(sentences)
return dorag(question, context)
print(ask_a_question("What state is Cambridge in?")["choices"][0]["message"]["content"])
Für die Beispielfrage, in welchem Bundesstaat Cambridge liegt, können wir einfach die gesamte Antwort von OctoAI ausgeben. Wenn wir uns jedoch die Zeit nehmen, sie zu parsen, sieht es einfach schöner aus, und es sollte uns sagen, dass Cambridge in Massachusetts liegt.
Zusammenfassung von BOM dot COM: BentoML, OctoAI und Milvus für RAG
Dieses Beispiel hat gezeigt, wie du RAG ohne OpenAI oder ein Framework umsetzen kannst. Beachte, dass wir im Gegensatz zu einigen unserer früheren Beispiele auch weder LangChain noch LlamaIndex verwendet haben. Diesmal war unser Stack BOM.COM — BentoML, OctoAI und Milvus. Wir haben die Serving-Funktionen von BentoML genutzt, um einen Embedding-Modell-Endpunkt bereitzustellen, die LLM-Endpunkte von OctoAI, um auf ein Open-Source-Modell zuzugreifen, und Milvus als unsere Vektordatenbank.
Es gibt viele Möglichkeiten, die Reihenfolge zu strukturieren, in der wir diese verschiedenen Puzzleteile verwenden. Für dieses Beispiel haben wir damit begonnen, einen lokalen Server mit BentoML hochzufahren, um ein Embedding-Modell von Hugging Face zu hosten. Als Nächstes haben wir mithilfe von Docker Compose eine lokale Milvus-Instanz hochgefahren.
Wir haben eine einfache Methode verwendet, um unsere Daten, die aus Wikipedia gescraped wurden, in Chunks aufzuteilen. Dann haben wir diese Chunks genommen und sie an unser auf BentoML gehostetes Embedding-Modell übergeben, um die Vektor-Embeddings zu erhalten, die in Milvus abgelegt werden sollten. Mit allen Vektor-Embeddings in Milvus waren wir vollständig bereit, RAG durchzuführen.
Das LLM, das wir dieses Mal gewählt haben, war das Nous Hermes fine-tuned Mixtral model, eines von vielen Open-Source-Modellen, die auf OctoAI verfügbar sind. Wir haben zwei Funktionen erstellt, um RAG zu ermöglichen. Eine Funktion, die die Frage und den Kontext an das LLM übergab, dorag, und eine weitere Funktion, die die Benutzerfrage einbettete, Milvus durchsuchte und dann die Suchergebnisse zusammen mit der Frage an die ursprüngliche RAG-Funktion übergab. Am Ende haben wir unser RAG mit einer einfachen Frage als Plausibilitätsprüfung getestet.
Weiterlesen

Zilliz Cloud Enterprise Vector Search Powers High-Performance AI on AWS
Zilliz Cloud on AWS powers secure, scalable, ultra-fast vector search for enterprise AI apps, with BYOC, sub-10ms latency, and zero-DevOps simplicity.

Balancing Precision and Performance: How Zilliz Cloud's New Parameters Help You Optimize Vector Search
Optimize vector search with Zilliz Cloud’s level and recall features to tune accuracy, balance performance, and power AI applications.

Cosmos World Foundation Model Platform for Physical AI
NVIDIA's Cosmos platform enables safe, digital twin training of GenAI models for physical applications, overcoming data scarcity and safety challenges.



