RAG sans OpenAI : BentoML, OctoAI et Milvus
Cet article a été initialement publié dans The New Stack et est republié ici avec autorisation.
Des options élargies de génération augmentée par récupération peuvent éliminer la dépendance des développeurs envers OpenAI
ChatGPT a placé l’IA au premier plan des connaissances du grand public en 2023. Cependant, il existe désormais bien plus d’options, nous ne sommes donc plus liés à OpenAI. Ceci est le troisième article d’une série de blogs sur la manière dont vous pouvez créer des applications de génération augmentée par récupération (RAG) à l’aide de LLM qui ne sont pas le GPT d’OpenAI. Voici où vous pouvez trouver la partie 1 et la partie 2. Le dépôt GitHub de ce projet peut être trouvé ici.
Dans ce tutoriel, nous utiliserons BentoML pour servir des embeddings, OctoAI pour obtenir le LLM et Milvus comme base de données vectorielle. Nous couvrons :
Servir des embeddings avec BentoML
Insérer vos données dans une base de données vectorielle pour la RAG
Créer votre collection Milvus
Analyser et intégrer vos données pour l’insertion
Configurer votre LLM pour la RAG
Donner des instructions au LLM
Un exemple de RAG
Résumé du BOM point COM : BentoML, OctoAI et Milvus pour la RAG
Servir des embeddings avec BentoML
Nous pouvons utiliser des embeddings de phrases servis via BentoML à l’aide de son dépôt Sentence Transformers Embeddings. Voyons brièvement ce qui se passe avec ce dépôt. L’élément principal à examiner est le fichier service.py. En gros, il lance un serveur et y place un point de terminaison d’API. Dans le point de terminaison d’API, il charge le modèle all-MiniLM-L6-v2 depuis Hugging Face et l’utilise pour créer des embeddings.
Ce dépôt lance un serveur et nous fournit un point de terminaison à appeler à l’adresse <http://localhost:3000>. Pour utiliser ce point de terminaison, nous importons bentoml et lançons un client HTTP à l’aide du type d’objet natif SyncHTTPClient.
import bentoml
bento_client = bentoml.SyncHTTPClient("http://localhost:3000")
Une fois connectés au client, nous créons une fonction qui obtient une liste d’embeddings à partir d’une liste de chaînes. Une chose à noter est que je divise les listes en 25 chaînes à la fois. Cela s’explique surtout par le fait que nous utilisons un point de terminaison synchrone. Diviser la liste de chaînes rend les appels moins coûteux en calcul et évite les délais d’expiration.
Après avoir divisé la liste en sections de 25, nous appelons le bento_client que nous avons créé ci-dessus pour encoder ces phrases. Le client BentoML renvoie une liste de vecteurs, en fait une liste de listes. Nous prenons chacun de ces vecteurs et les ajoutons à notre liste d’embeddings vide. À la fin de cette boucle, nous renvoyons la liste finale des embeddings.
S’il n’y a pas plus de 25 chaînes dans la liste de textes, nous appelons simplement la méthode encode du client sur la liste de chaînes transmise.
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,
)
Insérer vos données dans une base de données vectorielle pour la RAG
Une fois notre fonction d’intégration préparée, nous pouvons préparer nos données à insérer dans Milvus pour notre application RAG. La première étape de cette section consiste à démarrer Milvus et à s’y connecter. Un fichier docker-compose.yml se trouve dans le dépôt lié ci-dessus. Vous pouvez également trouver le Milvus Docker Compose sur cette page de documentation.
Si Docker est installé et que vous avez téléchargé ce dépôt, vous devriez pouvoir exécuter docker compose up -d pour lancer Milvus. Une fois votre serveur Milvus démarré, il est temps de s’y connecter. Pour cette partie, nous importons simplement le module connections et appelons connect avec l’hôte (localhost ou 127.0.0.1) et le port (19530). Le bloc de code ci-dessous définit également deux constantes - un nom de collection et la dimension. Vous pouvez choisir le nom de collection que vous voulez. La taille de la dimension provient de la taille du modèle d’intégration, all-MiniLM-L6-v2.
from pymilvus import connections
COLLECTION_NAME = "bmo_test"
DIMENSION = 384
connections.connect(host="localhost", port=19530)
Création de votre collection Milvus
La création d’une collection dans Milvus implique deux étapes : d’abord, définir le schéma, puis définir l’index. Pour cette section, nous avons besoin de quatre modules : FieldSchema définit un champ, CollectionSchema définit une collection, DataType nous indique quel type de données se trouvera dans un champ et Collection est l’objet que Milvus utilise pour créer des collections.
Nous pouvons définir ici l’ensemble du schéma de la collection. Ou bien, nous pouvons simplement définir les deux éléments nécessaires : id et embedding. Ensuite, lorsqu’il est temps de définir le schéma, nous passons un paramètre, enabled_dynamic_field, qui nous permet d’insérer les champs que nous voulons tant que nous avons également les champs id et embedding. Cela nous permet de traiter l’insertion de données dans Milvus de la même manière que nous traiterions une base de données NoSQL comme MongoDB. Ensuite, nous créons simplement la collection avec le nom et le schéma donnés précédemment.
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)
Maintenant que nous avons créé notre collection, nous devons définir l’index. En termes de recherche, un « index » définit la manière dont nous allons organiser nos données pour la récupération. Nous utilisons HSNW (hierarchical navigable small worlds) pour indexer nos données pour ce projet. Nous devons également définir comment nous allons mesurer la distance vectorielle. Dans cet exemple, nous utilisons le produit interne ou IP.
Chacun des 11 types d’index proposés dans Milvus possède un ensemble de paramètres différent. Pour HNSW, nous avons deux paramètres à ajuster : « M » et « efConstruction ». « M » est la borne supérieure du degré d’un nœud dans chaque graphe, et « efConstruction » est le facteur exploratoire utilisé lors de la construction de l’index.
D’un point de vue pratique, des valeurs plus élevées de « M » et « efConstruction » conduisent toutes deux à une meilleure recherche. Une valeur « M » plus élevée signifie que l’index occupera davantage de mémoire. Une valeur « efConstruction » plus élevée signifie que la construction de l’index prendra plus de temps. Vous devrez faire des essais avec ces paramètres pour trouver les meilleures valeurs.
Une fois l’index défini, nous créons l’index sur un champ choisi, dans ce cas, embedding. Ensuite, nous appelons load pour charger la collection en mémoire.
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()
Analyse et embedding de vos données pour l’insertion
Une fois Milvus prêt et la connexion établie, nous pouvons insérer des données dans notre base de données vectorielle. Mais nous devons d’abord préparer les données à insérer. Pour cet exemple, nous avons un ensemble de fichiers txt, disponibles dans le dossier data du repo. Nous divisons ces données en morceaux, les transformons en embeddings et les stockons dans Milvus.
Commençons par créer une fonction qui découpe ce texte en morceaux. Il existe de nombreuses façons de faire du chunking, mais nous le ferons de manière naïve pour cet exemple. La fonction ci-dessous prend un fichier, le lit sous forme de chaîne, puis le découpe à chaque nouvelle ligne. Elle renvoie la liste de chaînes nouvellement créée.
# 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
Ensuite, nous traitons chacun des fichiers que nous avons. Nous obtenons une liste de tous les noms de fichiers et créons une liste vide pour contenir les informations découpées en morceaux. Puis, nous parcourons tous les fichiers et exécutons la fonction ci-dessus sur chacun d’eux afin d’obtenir un découpage naïf de chaque fichier. Avant de stocker les morceaux, nous devons les nettoyer.
Si vous regardez comment un fichier individuel est découpé, vous verrez de nombreuses lignes vides, et nous ne voulons pas de lignes vides. Certaines lignes ne sont que des tabulations ou d’autres caractères spéciaux. Pour les éviter, nous créons une liste vide et ne stockons que les morceaux au-dessus d’une certaine longueur. Par souci de simplicité, nous pouvons utiliser sept caractères.
Une fois que nous avons une liste nettoyée de morceaux provenant de chaque document, nous pouvons stocker nos données. Nous créons un dictionnaire qui associe chaque liste de morceaux au nom du document, dans ce cas, le nom de la ville. Puis nous ajoutons tous ces éléments à la liste vide que nous avons créée plus haut.
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)
Avec un ensemble de textes découpés en morceaux pour chaque ville prêt à l’emploi, il est temps d’obtenir des embeddings. Milvus peut accepter une liste de dictionnaires à insérer dans une collection, nous pouvons donc commencer avec une autre liste vide. Pour chacun des dictionnaires que nous avons créés ci-dessus, nous devons obtenir une liste d’embeddings correspondant à la liste des phrases.
Nous procédons en appelant directement la fonction get_embeddings que nous avons créée dans la section utilisant BentoML sur chacun des éléments de la liste de fragments. Maintenant, nous devons les faire correspondre. Comme la liste des embeddings et la liste des phrases doivent correspondre par index, nous pouvons utiliser enumerate sur l’une ou l’autre liste pour les faire correspondre.
Nous les faisons correspondre en créant un dictionnaire représentant une entrée unique dans Milvus. Chaque entrée inclut l’embedding, la phrase associée et la ville. Inclure la ville est facultatif, mais faisons-le simplement pour pouvoir l’utiliser. Notez qu’il n’est pas nécessaire d’inclure un id dans cette entrée. C’est parce que nous avons choisi d’auto-incrémenter l’id lorsque nous avons créé le schéma ci-dessus.
Nous ajoutons chacune de ces entrées à la liste au fur et à mesure que nous les parcourons. À la fin, nous avons une liste de dictionnaires, chaque dictionnaire représentant une entrée sur une seule ligne dans Milvus. Nous pouvons ensuite simplement insérer ces entrées dans notre collection Milvus. La dernière étape ici consiste à flush les entrées afin de pouvoir commencer à les indexer.
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()
Configurer votre LLM pour le RAG
Maintenant, récupérons notre LLM et préparons-nous à passer à l’action. Et par action, je veux dire faire du RAG. Pour suivre cette section exactement telle quelle, vous avez besoin d’un compte chez OctoAI. Vous pouvez également choisir d’utiliser n’importe quel LLM de votre choix en remplacement.
Dans ce premier bloc de code, nous chargeons simplement nos variables d’environnement, extrayons notre jeton d’API OctoAI et lançons leur 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()
Donner des instructions au LLM
Il y a deux choses que le LLM doit connaître pour faire du RAG : la question et le contexte. Nous pouvons transmettre les deux à la fois en créant une fonction qui prend deux chaînes : la question et le contexte. En utilisant cette fonction, nous utilisons la complétion de chat du client OctoAI pour appeler un LLM. Pour cet exemple, nous utilisons le modèle Mixtral affiné de Nous Research.
Nous donnons à ce modèle deux « messages » qui indiquent comment il doit se comporter. D’abord, nous envoyons un message au LLM pour lui dire qu’il répond à une question de l’utilisateur en se basant uniquement sur le contexte donné. Ensuite, nous lui indiquons qu’il y aura un utilisateur, et nous transmettons simplement la question.
Les autres paramètres servent à ajuster le comportement du modèle. Nous pouvons contrôler le nombre maximal de tokens et le degré de « créativité » du modèle.
La fonction renvoie ensuite la sortie du client au format JSON.
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()
Un exemple de RAG
Maintenant nous sommes prêts. Il est temps de poser une question. Nous pourrions probablement le faire sans créer de fonction, mais créer une fonction rend cela agréable et reproductible. Cette fonction prend simplement une question en entrée, puis effectue du RAG pour y répondre.
Nous commençons par intégrer la question en utilisant le même modèle d’embedding que celui utilisé pour intégrer les documents. Ensuite, nous exécutons une recherche sur Milvus. Notez que nous passons la question à la fonction get_embeddings sous forme de liste, puis nous passons directement la liste produite dans la section data de notre recherche Milvus. Cela tient simplement à la manière dont les signatures de fonctions sont définies : il est plus facile de les réutiliser que de réécrire plusieurs fonctions.
Dans notre appel de recherche, nous devons également fournir quelques paramètres supplémentaires. anns_field indique à Milvus sur quel champ effectuer une recherche approximative des plus proches voisins (ANNS). Nous devons également transmettre certains paramètres pour l’index. Assurez-vous que le type de métrique correspond à celui que nous avons utilisé pour créer l’index, dans ce cas IP. Nous devons également utiliser un paramètre d’index correspondant, dans ce cas ef, ou facteur d’exploration.
Un ef plus élevé signifie un temps de recherche plus long, mais un rappel plus élevé. Vous pouvez expérimenter avec cela, ef peut aller jusqu’à 2048 ; nous utilisons 16 pour la rapidité et la simplicité. Il n’y a que des milliers d’entrées dans ce jeu de données. Ensuite, nous passons également un paramètre limit qui nous indique combien de résultats récupérer depuis Milvus ; pour cet exemple, nous pouvons simplement choisir cinq.
Le dernier paramètre de recherche définit les champs que nous voulons récupérer dans notre recherche. Pour cet exemple, nous pouvons simplement obtenir la sentence, qui est le champ que nous avons utilisé pour stocker notre fragment de texte. Une fois que nous avons récupéré nos résultats de recherche, nous devons les traiter. Milvus renvoie une entité contenant des hits, nous récupérons donc simplement la “sentence” des cinq résultats et les joignons avec un point afin de former un paragraphe sous forme de liste.
Ensuite, nous passons la question posée par l’utilisateur avec ce paragraphe dans la fonction dorag que nous avons créée ci-dessus et renvoyons simplement la réponse.
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"])
Pour l’exemple de question demandant dans quel État se trouve Cambridge, nous pouvons simplement afficher toute la réponse d’OctoAI. Cependant, si nous prenons le temps de l’analyser, le résultat est simplement plus agréable, et il devrait nous indiquer que Cambridge se trouve dans le Massachusetts.
Résumé du BOM dot COM : BentoML, OctoAI et Milvus pour RAG
Cet exemple a montré comment faire du RAG sans OpenAI ni framework. Notez que, contrairement à certains de nos exemples précédents, nous n’avons pas non plus utilisé LangChain ni LlamaIndex. Cette fois, notre stack était le BOM.COM — BentoML, OctoAI et Milvus. Nous avons utilisé les capacités de service de BentoML pour servir un endpoint de modèle d’embedding, les endpoints LLM d’OctoAI pour accéder à un modèle open source et Milvus comme base de données vectorielle.
Il existe de nombreuses façons de structurer l’ordre dans lequel nous utilisons ces différentes pièces du puzzle. Pour cet exemple, nous avons commencé par lancer un serveur local avec BentoML pour héberger un modèle d’embedding provenant de Hugging Face. Ensuite, nous avons lancé une instance locale de Milvus à l’aide de Docker Compose.
Nous avons utilisé une méthode simple pour découper nos données, qui avaient été extraites de Wikipedia. Ensuite, nous avons pris ces fragments et les avons transmis à notre modèle d’embedding, hébergé sur BentoML, afin d’obtenir les embeddings vectoriels à insérer dans Milvus. Une fois tous les embeddings vectoriels dans Milvus, nous étions entièrement prêts à faire du RAG.
Le LLM que nous avons choisi cette fois-ci était le modèle Mixtral affiné Nous Hermes, l’un des nombreux modèles open source disponibles sur OctoAI. Nous avons créé deux fonctions pour permettre le RAG. Une fonction qui transmettait la question et le contexte au LLM, dorag, et une autre fonction qui transformait la question de l’utilisateur en embedding, interrogeait Milvus, puis transmettait les résultats de recherche avec la question à la fonction RAG originale. À la fin, nous avons testé notre RAG avec une question simple à titre de vérification sanitaire.
Continuer à lire

Zilliz Skills Breakdown: How AI Agents Master Vector Databases
Zilliz's Milvus Skill (pymilvus, 7 files) and Zilliz Cloud Skill (zilliz-cli, 14 modules) bring vector-DB dev and ops into one Claude Code session.

Migrating from S3 Vectors to Zilliz Cloud: Unlocking the Power of Tiered Storage
Learn how Zilliz Cloud bridges cost and performance with tiered storage and enterprise-grade features, and how to migrate data from AWS S3 Vectors to Zilliz Cloud.

Vector Databases vs. Hierarchical Databases
Use a vector database for AI-powered similarity search; use a hierarchical database for organizing data in parent-child relationships with efficient top-down access patterns.



