RAG без OpenAI: BentoML, OctoAI и Milvus
Эта статья была первоначально опубликована в The New Stack и перепечатана здесь с разрешения.
Расширенные возможности retrieval augmented generation могут устранить зависимость разработчиков от OpenAI
ChatGPT вывел ИИ на передний план общественного внимания в 2023 году. Однако сейчас существует гораздо больше вариантов, поэтому мы больше не привязаны к OpenAI. Это третья запись в серии блогов о том, как можно создавать приложения retrieval augmented generation (RAG) с использованием LLM, которые не являются GPT от OpenAI. Здесь вы можете найти часть 1 и часть 2. Репозиторий GitHub для этого проекта можно найти здесь.
В этом руководстве мы будем использовать BentoML для обслуживания embeddings, OctoAI для получения LLM и Milvus в качестве нашей векторной базы данных. Мы рассмотрим:
Обслуживание embeddings с помощью BentoML
Вставка ваших данных в векторную базу данных для RAG
Создание вашей коллекции Milvus
Парсинг и embedding ваших данных для вставки
Настройка вашей LLM для RAG
Предоставление LLM инструкций
Пример RAG
Краткое изложение BOM dot COM: BentoML, OctoAI и Milvus для RAG
Обслуживание Embeddings с помощью BentoML
Мы можем использовать sentence embeddings, обслуживаемые через BentoML с помощью его репозитория Sentence Transformers Embeddings. Давайте кратко рассмотрим, что происходит в этом репозитории. Главное, на что стоит обратить внимание, — это файл service.py. По сути, он запускает сервер и размещает на нем API endpoint. Внутри API endpoint он загружает all-MiniLM-L6-v2 из Hugging Face и использует его для создания embeddings.
Этот репозиторий запускает сервер и предоставляет нам endpoint, к которому можно обращаться по адресу <http://localhost:3000>. Чтобы использовать этот endpoint, мы импортируем bentoml и запускаем HTTP-клиент, используя нативный тип объекта SyncHTTPClient.
import bentoml
bento_client = bentoml.SyncHTTPClient("http://localhost:3000")
После подключения к клиенту мы создаем функцию, которая получает список embeddings из списка строк. Один момент, на который стоит обратить внимание: я разбиваю списки по 25 строк за раз. В основном это потому, что мы используем синхронный endpoint. Разбиение списка строк делает вызовы менее вычислительно затратными и помогает избежать тайм-аутов.
После разбиения списка на секции по 25 элементов мы вызываем bento_client, созданный выше, чтобы закодировать эти предложения. Клиент BentoML возвращает список векторов, фактически список списков. Мы берем каждый из этих векторов и добавляем их в наш пустой список embeddings. В конце этого цикла мы возвращаем итоговый список embeddings.
Если в списке текстов не более 25 строк, мы просто вызываем метод encode клиента для переданного списка строк.
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,
)
Вставка ваших данных в векторную базу данных для RAG
Теперь, когда наша функция эмбеддингов подготовлена, мы можем подготовить наши данные для вставки в Milvus для нашего RAG-приложения. Первый шаг в этом разделе — запустить Milvus и подключиться к нему. В репозитории, ссылка на который приведена выше, находится файл docker-compose.yml. Вы также можете найти Milvus Docker Compose на этой странице документации.
Если у вас установлен Docker и вы скачали этот репозиторий, вы сможете выполнить docker compose up -d, чтобы запустить Milvus. После того как ваш сервер Milvus будет запущен, пора подключиться к нему. Для этой части мы просто импортируем модуль connections и вызываем connect с хостом (localhost или 127.0.0.1) и портом (19530). Блок кода ниже также определяет две константы — имя коллекции и размерность. Вы можете придумать любое имя коллекции, какое захотите. Размерность берется из размера модели эмбеддингов, all-MiniLM-L6-v2.
from pymilvus import connections
COLLECTION_NAME = "bmo_test"
DIMENSION = 384
connections.connect(host="localhost", port=19530)
Создание вашей коллекции Milvus
Создание коллекции в Milvus включает два шага: сначала определение схемы, а затем определение индекса. Для этого раздела нам нужны четыре модуля: FieldSchema определяет поле, CollectionSchema определяет коллекцию, DataType сообщает нам, какой тип данных будет находиться в поле, а Collection — это объект, который Milvus использует для создания коллекций.
Мы можем определить здесь всю схему для коллекции. Или можем просто определить две необходимые части: id и embedding. Затем, когда придет время определять схему, мы передаем параметр enabled_dynamic_field, который позволяет нам вставлять любые поля, какие захотим, при условии, что у нас также есть поля id и embedding. Это позволяет нам относиться к вставке данных в Milvus так же, как мы относились бы к базе данных NoSQL, такой как MongoDB. Затем мы просто создаем коллекцию с ранее заданными именем и схемой.
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)
Теперь, когда мы создали нашу коллекцию, нам нужно определить индекс. В контексте поиска «индекс» определяет, как мы будем отображать наши данные для извлечения. Мы используем HSNW (hierarchical navigable small worlds) для индексирования наших данных в этом проекте. Нам также нужно определить, как мы будем измерять векторное расстояние. В этом примере мы используем внутреннее произведение, или IP.
У каждого из 11 типов индексов, предлагаемых в Milvus, есть свой набор параметров. Для HNSW у нас есть два параметра для настройки: “M” и “efConstruction”. “M” — это верхняя граница степени узла в каждом графе, а “efConstruction” — это exploratory factor, используемый во время построения индекса.
С практической точки зрения более высокие значения “M” и “efConstruction” оба приводят к лучшему поиску. Более высокое значение “M” означает, что индекс будет занимать больше памяти. Более высокое значение “efConstruction” означает, что построение индекса займет больше времени. Вам придется поэкспериментировать с ними, чтобы найти лучшие значения.
После того как индекс определен, мы создаем индекс по выбранному полю, в данном случае embedding. Затем мы вызываем load, чтобы загрузить коллекцию в память.
index_params = {
"index_type": "HNSW", # один из 11 индексов Milvus
"metric_type": "IP", # L2, Cosine или IP
"params": {
"M": 8, # более высокий M = потребляет больше памяти, но лучшее качество поиска
"efConstruction": 64 # более высокий efConstruction = более медленное построение, лучший поиск
},
}
# передаем поле для индексирования и параметры индексирования
collection.create_index(field_name="embedding", index_params=index_params)
# загружаем коллекцию в память
collection.load()
Парсинг и создание эмбеддингов ваших данных для вставки
Когда Milvus готов и соединение установлено, мы можем вставлять данные в нашу векторную базу данных. Но сначала нам нужно подготовить данные для вставки. В этом примере у нас есть набор файлов txt, доступных в папке data репозитория. Мы разбиваем эти данные на фрагменты, создаем для них эмбеддинги и сохраняем их в Milvus.
Начнем с создания функции, которая разбивает этот текст на фрагменты. Существует много способов выполнять разбиение на фрагменты, но в этом примере мы сделаем это наивно. Функция ниже принимает файл, считывает его как строку, а затем разбивает на каждой новой строке. Она возвращает вновь созданный список строк.
# наивно разбиваем по новым строкам
def chunk_text(filename: str) -> list:
with open(filename, "r") as f:
text = f.read()
sentences = text.split("n")
return sentences
Далее мы обрабатываем каждый из имеющихся файлов. Мы получаем список всех имен файлов и создаем пустой список для хранения разбитой на фрагменты информации. Затем мы проходим циклом по всем файлам и запускаем указанную выше функцию для каждого, чтобы получить наивное разбиение каждого файла. Прежде чем сохранять фрагменты, нам нужно их очистить.
Если вы посмотрите, как разбит отдельный файл, вы увидите много пустых строк, а пустые строки нам не нужны. Некоторые строки — это просто табуляции или другие специальные символы. Чтобы избежать этого, мы создаем пустой список и сохраняем только фрагменты выше определенной длины. Для простоты можно использовать семь символов.
После того как у нас есть очищенный список фрагментов из каждого документа, мы можем сохранить наши данные. Мы создаем словарь, который сопоставляет каждый список фрагментов с именем документа, в данном случае — названием города. Затем мы добавляем все это в пустой список, созданный выше.
import os
cities = os.listdir("data")
# сохраняем разбитый на фрагменты текст для каждого города в списке словарей
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)
Когда набор разбитых на фрагменты текстов для каждого города готов, пора получить эмбеддинги. Milvus может принимать список словарей для вставки в коллекцию, поэтому мы можем начать с еще одного пустого списка. Для каждого из созданных выше словарей нам нужно получить список эмбеддингов, соответствующий списку предложений.
Мы делаем это, напрямую вызывая функцию get_embeddings, которую мы создали в разделе с использованием BentoML, для каждого элемента списка фрагментов. Теперь нам нужно сопоставить их. Поскольку список эмбеддингов и список предложений должны совпадать по индексу, мы можем пройтись с помощью enumerate по любому из списков, чтобы сопоставить их.
Мы сопоставляем их, создавая словарь, представляющий одну запись в Milvus. Каждая запись включает эмбеддинг, связанное предложение и город. Указывать город необязательно, но давайте добавим его, чтобы мы могли его использовать. Обратите внимание, что нет необходимости включать id в эту запись. Это потому, что мы выбрали автоинкремент id, когда создавали схему выше.
Мы добавляем каждую из этих записей в список, проходя по ним в цикле. В конце у нас есть список словарей, где каждый словарь представляет однострочную запись для Milvus. Затем мы можем просто вставить эти записи в нашу коллекцию Milvus. Последний шаг здесь — выполнить flush для записей, чтобы мы могли начать их индексировать.
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()
Настройте ваш LLM для RAG
Теперь давайте получим наш LLM и приготовимся к делу. И под делом я имею в виду RAG. Чтобы выполнить этот раздел в точности, вам нужен аккаунт в OctoAI. Вы также можете выбрать любой LLM на замену.
В этом первом блоке кода мы просто загружаем наши переменные окружения, извлекаем наш API-токен OctoAI и запускаем их клиент.
from dotenv import load_dotenv
load_dotenv()
os.environ["OCTOAI_TOKEN"] = os.getenv("OCTOAI_API_TOKEN")
from octoai.client import Client
octo_client = Client()
Передача инструкций LLM
Есть две вещи, которые LLM должен знать, чтобы выполнять RAG: вопрос и контекст. Мы можем передать их оба одновременно, создав функцию, которая принимает две строки: вопрос и контекст. Используя эту функцию, мы применяем chat completion клиента OctoAI для вызова LLM. В этом примере мы используем дообученную модель Mixtral от Nous Research.
Мы даем этой модели два «сообщения», которые указывают, как она должна себя вести. Сначала мы даем LLM сообщение, сообщающее ему, что он отвечает на вопрос пользователя, основываясь исключительно на предоставленном контексте. Затем мы сообщаем ему, что будет пользователь, и просто передаем вопрос.
Остальные параметры предназначены для настройки поведения модели. Мы можем контролировать максимальное количество токенов и то, насколько «креативно» ведет себя модель.
Затем функция возвращает вывод от клиента в формате 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()
Пример RAG
Теперь мы готовы. Пришло время задать вопрос. Вероятно, мы можем сделать это без создания функции, но функция делает процесс удобным и повторяемым. Эта функция просто принимает вопрос, а затем выполняет RAG, чтобы на него ответить.
Начинаем с преобразования вопроса в эмбеддинг с помощью той же модели эмбеддингов, которую мы использовали для документов. Затем выполняем поиск в Milvus. Обратите внимание, что мы передаём вопрос в функцию get_embeddings в формате списка, а затем напрямую передаём полученный список в секцию data нашего поиска Milvus. Это просто связано с тем, как настроены сигнатуры функций: проще переиспользовать их, чем переписывать несколько функций.
Внутри нашего вызова поиска нам также нужно указать ещё несколько параметров. anns_field сообщает Milvus, по какому полю выполнять приближённый поиск ближайших соседей (ANNS). Нам также нужно передать некоторые параметры для индекса. Убедитесь, что тип метрики совпадает с тем, который мы использовали при создании индекса, в данном случае IP. Также необходимо использовать соответствующий параметр индекса, в данном случае ef, или exploratory factor.
Более высокое значение ef означает более долгое время поиска, но более высокий recall. Вы можете поэкспериментировать с этим: ef может доходить до 2048; мы используем 16 ради скорости и простоты. В этом наборе данных всего тысячи записей. Далее мы также передаём параметр limit, который указывает, сколько результатов нужно получить обратно от Milvus; для этого примера можно просто выбрать пять.
Последний параметр поиска определяет, какие поля мы хотим получить обратно из поиска. Для этого примера мы можем просто получить sentence — поле, которое мы использовали для хранения нашего фрагмента текста. После того как мы получили результаты поиска, их нужно обработать. Milvus возвращает сущность с hits внутри, поэтому мы просто извлекаем “sentence” из всех пяти попаданий и объединяем их точкой, чтобы сформировать абзац-список.
Затем мы передаём вопрос, который задал пользователь, вместе с этим абзацем в функцию dorag, созданную выше, и просто возвращаем ответ.
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"])
Для примера с вопросом о том, в каком штате находится Cambridge, мы можем просто вывести весь ответ от OctoAI. Однако если потратить время и разобрать его, он будет выглядеть аккуратнее, и должен сообщить нам, что Cambridge находится в Massachusetts.
Краткий обзор BOM dot COM: BentoML, OctoAI и Milvus для RAG
В этом примере было показано, как можно делать RAG без OpenAI или фреймворка. Обратите внимание, что, в отличие от некоторых наших предыдущих примеров, мы также не использовали LangChain или LlamaIndex. На этот раз нашим стеком был BOM.COM — BentoML, OctoAI и Milvus. Мы использовали возможности serving в BentoML, чтобы развернуть endpoint модели эмбеддингов, LLM endpoints OctoAI для доступа к open source модели и Milvus в качестве нашей векторной базы данных.
Существует множество способов структурировать порядок, в котором мы используем эти разные части головоломки. В этом примере мы начали с запуска локального сервера с BentoML для размещения модели эмбеддингов из Hugging Face. Затем мы запустили локальный экземпляр Milvus с помощью Docker Compose.
Мы использовали простой метод для разбиения наших данных, которые были извлечены из Wikipedia, на фрагменты. Затем мы взяли эти фрагменты и передали их нашей модели эмбеддингов, размещенной на BentoML, чтобы получить векторные эмбеддинги для помещения в Milvus. Когда все векторные эмбеддинги оказались в Milvus, мы были полностью готовы выполнять RAG.
LLM, которую мы выбрали в этот раз, была Nous Hermes fine-tuned Mixtral model, одна из многих моделей с открытым исходным кодом, доступных на OctoAI. Мы создали две функции для реализации RAG. Одну функцию, которая передавала вопрос и контекст в LLM, dorag, и другую функцию, которая создавала эмбеддинг пользовательского вопроса, выполняла поиск в Milvus, а затем передавала результаты поиска вместе с вопросом в исходную функцию RAG. В конце мы протестировали наш RAG простым вопросом в качестве проверки работоспособности.
Читать далее

Build Multimodal Search for 3D Assets with Tripo and Zilliz Cloud
Generate 3D assets with Tripo, then search them by text, image, and metadata with multimodal embeddings and Zilliz Cloud.

Announcing VDBBench 1.0: Open-Source VectorDB Benchmarking with Your Real-World Production Workloads
Discover VDBBench 1.0, an open-source tool for benchmarking vector databases with real-world production data, streaming ingestion, and concurrent workloads.

Bringing AI to Legal Tech: The Role of Vector Databases in Enhancing LLM Guardrails
Discover how vector databases enhance AI reliability in legal tech, ensuring accurate, compliant, and trustworthy AI-powered legal solutions.



