RAG Sem OpenAI: BentoML, OctoAI e Milvus
Este artigo foi publicado originalmente em The New Stack e é republicado aqui com permissão.
Opções expandidas de geração aumentada por recuperação podem eliminar a dependência dos desenvolvedores em relação à OpenAI
O ChatGPT trouxe a IA para o centro do conhecimento público em 2023. No entanto, agora há muito mais opções, portanto não estamos mais presos à OpenAI. Esta é a terceira publicação de uma série de blogs sobre como você pode criar aplicações de geração aumentada por recuperação (RAG) usando LLMs que não são o GPT da OpenAI. Aqui você pode encontrar a parte 1 e a parte 2. O repositório GitHub deste projeto pode ser encontrado aqui.
Neste tutorial, usaremos o BentoML para servir embeddings, o OctoAI para obter o LLM e o Milvus como nosso banco de dados vetorial. Abordaremos:
Servir embeddings com BentoML
Inserir seus dados em um banco de dados vetorial para RAG
Criar sua coleção Milvus
Analisar e gerar embeddings dos seus dados para inserção
Configurar seu LLM para RAG
Dar instruções ao LLM
Um exemplo de RAG
Resumo do BOM dot COM: BentoML, OctoAI e Milvus para RAG
Servindo Embeddings com BentoML
Podemos usar embeddings de sentenças servidos por meio do BentoML usando seu repositório Sentence Transformers Embeddings. Vamos abordar brevemente o que está acontecendo com este repositório. O principal a observar é o arquivo service.py. Basicamente, ele inicia um servidor e coloca um endpoint de API nele. Dentro do endpoint de API, ele carrega o all-MiniLM-L6-v2 do Hugging Face e o utiliza para criar embeddings.
Esse repositório inicia um servidor e nos fornece um endpoint para acessar em <http://localhost:3000>. Para usar esse endpoint, importamos bentoml e iniciamos um cliente HTTP usando o tipo de objeto nativo SyncHTTPClient.
import bentoml
bento_client = bentoml.SyncHTTPClient("http://localhost:3000")
Depois de nos conectarmos ao cliente, criamos uma função que obtém uma lista de embeddings a partir de uma lista de strings. Uma coisa a observar é que eu divido as listas em 25 strings por vez. Isso ocorre principalmente porque estamos usando um endpoint síncrono. Dividir a lista de strings torna as chamadas menos custosas computacionalmente e evita timeouts.
Depois de dividir a lista em seções de 25, chamamos o bento_client que criamos acima para codificar essas sentenças. O cliente BentoML retorna uma lista de vetores, efetivamente uma lista de listas. Pegamos cada um desses vetores e os adicionamos à nossa lista vazia de embeddings. Ao final desse loop, retornamos a lista final de embeddings.
Se não houver mais de 25 strings na lista de textos, simplesmente chamamos o método encode do cliente na lista de strings passada.
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,
)
Inserindo Seus Dados em um Banco de Dados Vetorial para RAG
Com nossa função de embedding preparada, podemos preparar nossos dados para inserir no Milvus para nossa aplicação RAG. A primeira etapa desta seção é iniciar e conectar-se ao Milvus. Há um arquivo docker-compose.yml localizado no repositório vinculado acima. Você também pode encontrar o Milvus Docker Compose nesta página da documentação.
Se você tiver o Docker instalado e baixado esse repositório, deverá conseguir executar docker compose up -d para inicializar o Milvus. Depois que seu servidor Milvus estiver ativo, é hora de conectar-se a ele. Para esta parte, simplesmente importamos o módulo connections e chamamos connect com o host (localhost ou 127.0.0.1) e a porta (19530). O bloco de código abaixo também define duas constantes - um nome de coleção e a dimensão. Você pode inventar qualquer nome de coleção que quiser. O tamanho da dimensão vem do tamanho do modelo de embedding, all-MiniLM-L6-v2.
from pymilvus import connections
COLLECTION_NAME = "bmo_test"
DIMENSION = 384
connections.connect(host="localhost", port=19530)
Criando sua coleção Milvus
Criar uma coleção no Milvus envolve duas etapas: primeiro, definir o schema e, segundo, definir o índice. Para esta seção, precisamos de quatro módulos: FieldSchema define um campo, CollectionSchema define uma coleção, DataType nos diz que tipo de dados estará em um campo e Collection é o objeto que o Milvus usa para criar coleções.
Podemos definir o schema inteiro para a coleção aqui. Ou podemos simplesmente definir as duas partes necessárias: id e embedding. Então, quando chegar a hora de definir o schema, passamos um parâmetro, enabled_dynamic_field, que nos permite inserir quaisquer campos que quisermos, desde que também tenhamos os campos id e embedding. Isso nos permite tratar a inserção de dados no Milvus da mesma forma que trataríamos um banco de dados NoSQL como o MongoDB. Em seguida, simplesmente criamos a coleção com o nome e o schema fornecidos anteriormente.
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)
Agora que criamos nossa coleção, precisamos definir o índice. Em termos de busca, um “índice” define como vamos mapear nossos dados para recuperação. Usamos HSNW (hierarchical navigable small worlds) para indexar nossos dados para este projeto. Também precisamos definir como vamos medir a distância vetorial. Neste exemplo, usamos o produto interno ou IP.
Cada um dos 11 tipos de índice oferecidos no Milvus tem um conjunto diferente de parâmetros. Para HNSW, temos dois parâmetros para ajustar: “M” e “efConstruction”. “M” é o limite superior para o grau de um nó em cada grafo, e “efConstruction” é o fator exploratório usado durante a construção do índice.
Do ponto de vista prático, valores mais altos de “M” e “efConstruction” levam ambos a uma busca melhor. Um valor mais alto de “M” significa que o índice ocupará mais memória. Um valor mais alto de “efConstruction” significa que levará mais tempo para construir o índice. Você terá que experimentar esses valores para encontrar os melhores.
Depois que o índice é definido, criamos o índice em um campo escolhido, neste caso, embedding. Em seguida, chamamos load para carregar a coleção na memória.
index_params = {
"index_type": "HNSW", # um dos 11 índices Milvus
"metric_type": "IP", # L2, Cosine ou IP
"params": {
"M": 8, # M mais alto = consome mais memória, mas melhor qualidade de busca
"efConstruction": 64 # efConstruction mais alto = construção mais lenta, melhor busca
},
}
# passa o campo a ser indexado e os parâmetros de indexação
collection.create_index(field_name="embedding", index_params=index_params)
# carrega a coleção na memória
collection.load()
Analisando e Gerando Embeddings dos Seus Dados para Inserção
Com o Milvus pronto e a conexão feita, podemos inserir dados em nosso banco de dados vetorial. Mas precisamos preparar os dados para inserir primeiro. Para este exemplo, temos vários arquivos txt, disponíveis na pasta de dados do repositório. Dividimos esses dados em chunks, geramos embeddings deles e os armazenamos no Milvus.
Vamos começar criando uma função que divide esse texto em chunks. Há muitas maneiras de fazer chunking, mas faremos isso de forma ingênua neste exemplo. A função abaixo recebe um arquivo, lê-o como uma string e depois o divide a cada nova linha. Ela retorna a lista de strings recém-criada.
# divide ingenuamente em novas linhas
def chunk_text(filename: str) -> list:
with open(filename, "r") as f:
text = f.read()
sentences = text.split("n")
return sentences
Em seguida, processamos cada um dos arquivos que temos. Obtemos uma lista de todos os nomes de arquivos e criamos uma lista vazia para conter as informações divididas em chunks. Então, percorremos todos os arquivos e executamos a função acima em cada um para obter uma divisão ingênua de cada arquivo. Antes de armazenarmos os chunks, precisamos limpá-los.
Se você observar como um arquivo individual é dividido em chunks, verá muitas linhas vazias, e não queremos linhas vazias. Algumas linhas são apenas tabulações ou outros caracteres especiais. Para evitá-las, criamos uma lista vazia e armazenamos apenas os chunks acima de um determinado comprimento. Para simplificar, podemos usar sete caracteres.
Depois de termos uma lista limpa de chunks de cada documento, podemos armazenar nossos dados. Criamos um dicionário que mapeia cada lista de chunks para o nome do documento, neste caso, o nome da cidade. Então, acrescentamos todos eles à lista vazia que criamos acima.
import os
cities = os.listdir("data")
# armazena o texto dividido em chunks para cada uma das cidades em uma lista de dicionários
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)
Com um conjunto de textos divididos em chunks para cada cidade pronto, é hora de obter alguns embeddings. O Milvus pode receber uma lista de dicionários para inserir em uma coleção, então podemos começar com outra lista vazia. Para cada um dos dicionários que criamos acima, precisamos obter uma lista de embeddings que corresponda à lista de frases.
Fazemos isso chamando diretamente a função get_embeddings que criamos na seção usando BentoML em cada uma das listas de chunks. Agora, precisamos combiná-los. Como a lista de embeddings e a lista de frases devem corresponder por índice, podemos usar enumerate em qualquer uma das listas para combiná-las.
Nós os combinamos criando um dicionário que representa uma única entrada no Milvus. Cada entrada inclui o embedding, a frase relacionada e a cidade. É opcional incluir a cidade, mas vamos incluí-la só para podermos usá-la. Observe que não há necessidade de incluir um id nesta entrada. Isso porque escolhemos incrementar automaticamente o id quando fizemos o schema acima.
Adicionamos cada uma dessas entradas à lista enquanto iteramos por elas. No final, temos uma lista de dicionários, com cada dicionário representando uma entrada de uma única linha no Milvus. Então podemos simplesmente inserir essas entradas em nossa coleção do Milvus. O último passo aqui é dar um flush nas entradas para podermos começar a indexá-las.
entries = []
for city_dict in city_chunks:
embedding_list = get_embeddings(city_dict["chunks"]) # retorna uma lista de listas
# agora combine textos com embeddings e nome da cidade
for i, embedding in enumerate(embedding_list):
entry = {"embedding": embedding,
"sentence": city_dict["chunks"][i], # mal nomeado pq na verdade é um monte de frases, mas enfim
"city": city_dict["city_name"]}
entries.append(entry)
collection.insert(entries)
collection.flush()
Configure Seu LLM para RAG
Agora, vamos obter nosso LLM e nos preparar para a ação. E por ação, quero dizer fazer um pouco de RAG. Para fazer esta seção exatamente como está, você precisa de uma conta da OctoAI. Você também pode optar por inserir qualquer LLM de sua escolha como substituto.
Neste primeiro bloco de código, simplesmente carregamos nossas variáveis de ambiente, extraímos nosso token da API da OctoAI e iniciamos o cliente deles.
from dotenv import load_dotenv
load_dotenv()
os.environ["OCTOAI_TOKEN"] = os.getenv("OCTOAI_API_TOKEN")
from octoai.client import Client
octo_client = Client()
Dando Instruções ao LLM
Há duas coisas que o LLM precisa saber para fazer RAG: a pergunta e o contexto. Podemos passar ambas de uma vez criando uma função que recebe duas strings: a pergunta e o contexto. Usando esta função, usamos a conclusão de chat do cliente da OctoAI para chamar um LLM. Para este exemplo, usamos o modelo Mixtral com fine-tuning da Nous Research.
Damos a este modelo duas “mensagens” que indicam como ele deve se comportar. Primeiro, damos uma mensagem ao LLM para dizer a ele que ele está respondendo a uma pergunta do usuário com base apenas no contexto fornecido. Em seguida, dizemos a ele que haverá um usuário, e simplesmente passamos a pergunta.
Os outros parâmetros são para ajustar o comportamento do modelo. Podemos controlar o número máximo de tokens e o quão “criativo” o modelo age.
A função então retorna a saída do cliente em um formato 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()
Um Exemplo de RAG
Agora estamos prontos. É hora de fazer uma pergunta. Provavelmente podemos fazer isso sem criar uma função, mas criar uma função torna tudo agradável e repetível. Esta função simplesmente recebe uma pergunta e então faz RAG para respondê-la.
Começamos incorporando a pergunta usando o mesmo modelo de embeddings que usamos para incorporar os documentos. Em seguida, executamos uma busca no Milvus. Observe que passamos a pergunta para a função get_embeddings em formato de lista e, então, passamos a lista gerada diretamente para a seção data da nossa busca no Milvus. Isso acontece apenas por causa da forma como as assinaturas das funções estão configuradas; é mais fácil reutilizá-las do que reescrever várias funções.
Dentro da nossa chamada de busca, também precisamos fornecer mais alguns parâmetros. anns_field informa ao Milvus em qual campo realizar uma busca aproximada de vizinhos mais próximos (ANNS). Também precisamos passar alguns parâmetros para o índice. Certifique-se de que o tipo de métrica corresponda ao que usamos para criar o índice, neste caso IP. Também temos que usar um parâmetro de índice correspondente, neste caso ef, ou fator exploratório.
Um ef mais alto significa maior tempo de busca, mas maior recall. Você pode experimentar com isso; ef pode chegar a 2048; estamos usando 16 por velocidade e simplicidade. Há apenas milhares de entradas neste conjunto de dados. Em seguida, também passamos um parâmetro limit, que nos diz quantos resultados obter de volta do Milvus; para este exemplo, podemos simplesmente usar cinco.
O último parâmetro de busca define quais campos queremos receber de volta da nossa busca. Para este exemplo, podemos obter apenas sentence, que é o campo que usamos para armazenar nosso trecho de texto. Depois que temos nossos resultados de busca de volta, precisamos processá-los. O Milvus retorna uma entidade com hits nela, então simplesmente pegamos a “sentence” de todos os cinco hits e as juntamos com um ponto para que forme um parágrafo em lista.
Então, passamos a pergunta que o usuário fez junto com esse parágrafo para a função dorag que criamos acima e simplesmente retornamos a resposta.
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"])
Para a pergunta de exemplo que pergunta em qual estado Cambridge está, podemos simplesmente imprimir a resposta inteira da OctoAI. No entanto, se dedicarmos tempo para analisá-la, ela fica mais bonita, e deve nos dizer que Cambridge está localizada em Massachusetts.
Resumo do BOM dot COM: BentoML, OctoAI e Milvus para RAG
Este exemplo abordou como você pode fazer RAG sem OpenAI ou um framework. Observe que, ao contrário de alguns dos nossos exemplos anteriores, também não usamos LangChain nem LlamaIndex. Desta vez, nossa stack foi a BOM.COM — BentoML, OctoAI e Milvus. Usamos os recursos de serving da BentoML para servir um endpoint de modelo de embeddings, os endpoints de LLM da OctoAI para acessar um modelo de código aberto e o Milvus como nosso banco de dados vetorial.
Há muitas maneiras de estruturar a ordem em que usamos essas diferentes peças do quebra-cabeça. Neste exemplo, começamos iniciando um servidor local com BentoML para hospedar um modelo de embeddings da Hugging Face. Em seguida, iniciamos uma instância local do Milvus usando Docker Compose.
Usamos um método simples para dividir nossos dados em chunks, que foram extraídos da Wikipedia. Então, pegamos esses chunks e os passamos para nosso modelo de embeddings, hospedado no BentoML, para obter os embeddings vetoriais a serem inseridos no Milvus. Com todos os embeddings vetoriais no Milvus, estávamos totalmente prontos para fazer RAG.
O LLM que escolhemos desta vez foi o modelo Mixtral com fine-tuning Nous Hermes, um dos muitos modelos open source disponíveis na OctoAI. Criamos duas funções para habilitar RAG. Uma função que passava a pergunta e o contexto para o LLM, dorag, e outra função que incorporava a pergunta do usuário, pesquisava no Milvus e então passava os resultados da pesquisa junto com a pergunta para a função RAG original. No final, testamos nosso RAG com uma pergunta simples como verificação de sanidade.
Continue lendo

Zilliz Cloud Update: Tiered Storage, Business Critical Plan, Cross-Region Backup, and Pricing Changes
This release offers a rebuilt tiered storage with lower costs, a new Business Critical plan for enhanced security, and pricing updates, among other features.

Optimizing Embedding Model Selection with TDA Clustering: A Strategic Guide for Vector Databases
Discover how Topological Data Analysis (TDA) reveals hidden embedding model weaknesses and helps optimize vector database performance.

Why Deepseek is Waking up AI Giants Like OpenAI And Why You Should Care
Discover how DeepSeek R1's open-source AI model with superior reasoning capabilities and lower costs is disrupting the AI landscape and challenging tech giants like OpenAI.



