Astrazioni di alto livello offerte da biblioteche simili lama-index E Langchain hanno semplificato lo sviluppo dei sistemi di Retrieval Augmented Generation (RAG). Tuttavia, una profonda comprensione dei meccanismi sottostanti che abilitano queste librerie rimane cruciale per qualsiasi ingegnere di machine learning che mira a sfruttare appieno il loro potenziale. In questo articolo ti guiderò attraverso il processo di sviluppo di un sistema RAG da zero. Farò anche un ulteriore passo avanti e creeremo un'API per flask containerizzata. L'ho progettato per essere altamente pratico: questa procedura dettagliata è ispirata a casi d'uso della vita reale, garantendo che le informazioni ottenute non siano solo teoriche ma immediatamente applicabili.
Panoramica dei casi d'uso — Questa implementazione è progettata per gestire un'ampia gamma di tipi di documenti. Sebbene l'esempio attuale utilizzi molti piccoli documenti, ciascuno dei quali raffigura singoli prodotti con dettagli quali SKU, nome, descrizione, prezzo e dimensioni, l'approccio è altamente adattabile. Sia che l'attività riguardi l'indicizzazione di una biblioteca diversificata di libri, l'estrazione di dati da contratti estesi o qualsiasi altro insieme di documenti, il sistema può essere personalizzato per soddisfare le esigenze specifiche di questi diversi contesti. Questa flessibilità consente la perfetta integrazione ed elaborazione di diversi tipi di informazioni.
Nota veloce – questa implementazione funzionerà esclusivamente con dati di testo. È possibile seguire passaggi simili per convertire immagini in incorporamenti utilizzando un modello multimodale come CLIP, su cui è quindi possibile indicizzare ed eseguire query.
- Delinea la struttura modulare
- Preparare i dati
- Chunking, indicizzazione e recupero (funzionalità principali)
- Componente LLM
- Costruisci e distribuisci l'API
- Conclusione
L'implementazione ha quattro componenti principali che possono essere scambiati.
- Dati di testo
- Modello di incorporamento
- LLM
- Negozio di vettori
L'integrazione di questi servizi nel tuo progetto è altamente flessibile e ti consente di personalizzarli in base alle tue esigenze specifiche. In questa implementazione di esempio, inizio con uno scenario in cui i dati iniziali sono in formato JSON, che fornisce comodamente i dati come stringa. Tuttavia, potresti incontrare dati in vari altri formati come PDF, e-mail o fogli di calcolo Excel. In questi casi è essenziale “normalizzare” questi dati convertendoli in un formato stringa. A seconda delle esigenze del tuo progetto, puoi convertire i dati in una stringa in memoria o salvarli in un file di testo per un ulteriore perfezionamento o elaborazione a valle.
Allo stesso modo, le scelte del modello di incorporamento, dell'archivio vettoriale e del LLM possono essere personalizzate per soddisfare le esigenze del tuo progetto. Che tu abbia bisogno di un modello più piccolo o più grande, o magari di un modello esterno, la flessibilità di questo approccio ti consente di scambiare semplicemente le opzioni appropriate. Questa funzionalità plug-and-play garantisce che il tuo progetto possa adattarsi a vari requisiti senza modifiche significative all'architettura principale.
Ho evidenziato i componenti principali in grigio. In questa implementazione il nostro archivio vettoriale sarà semplicemente un file JSON. Ancora una volta, a seconda del caso d'uso, potresti voler utilizzare semplicemente un archivio vettoriale in memoria (Python dict) se stai elaborando solo un file alla volta. Se hai bisogno di rendere persistenti questi dati, come facciamo per questo caso d'uso, puoi salvarli in un file JSON localmente. Se hai bisogno di archiviare centinaia di migliaia o milioni di vettori avrai bisogno di un archivio di vettori esterno (Pinecone, Azure Cognitive Search, ecc…).
Come accennato in precedenza, questa implementazione inizia con i dati JSON. Ho usato GPT-4 e Claude per generarlo sinteticamente. I dati contengono descrizioni di prodotto per diversi mobili, ciascuno con il proprio SKU. Ecco un esempio:
{
"MBR-2001": "Traditional sleigh bed crafted in rich walnut wood, featuring a curved headboard and footboard with intricate grain details. Queen size, includes a plush, supportive mattress. Produced by Heritage Bed Co. Dimensions: 65\"W x 85\"L x 50\"H.",
"MBR-2002": "Art Deco-inspired vanity table in a polished ebony finish, featuring a tri-fold mirror and five drawers with crystal knobs. Includes a matching stool upholstered in silver velvet. Made by Luxe Interiors. Vanity dimensions: 48\"W x 20\"D x 30\"H, Stool dimensions: 22\"W x 16\"D x 18\"H.",
"MBR-2003": "Set of sheer linen drapes in soft ivory, offering a delicate and airy touch to bedroom windows. Each panel measures 54\"W x 84\"L. Features hidden tabs for easy hanging. Manufactured by Tranquil Home Textiles.","LVR-3001": "Convertible sofa bed upholstered in navy blue linen fabric, easily transitions from sofa to full-size sleeper. Perfect for guests or small living spaces. Features a sturdy wooden frame. Produced by SofaBed Solutions. Dimensions: 70\"W x 38\"D x 35\"H.",
"LVR-3002": "Ornate Persian area rug in deep red and gold, hand-knotted from silk and wool. Adds a luxurious touch to any living room. Measures 8' x 10'. Manufactured by Ancient Weaves.",
"LVR-3003": "Contemporary TV stand in matte black with tempered glass doors and chrome legs. Features integrated cable management and adjustable shelves. Accommodates up to 65-inch TVs. Made by Streamline Tech. Dimensions: 60\"W x 20\"D x 24\"H.",
"OPT-4001": "Modular outdoor sofa set in espresso brown polyethylene wicker, includes three corner pieces and two armless chairs with water-resistant cushions in cream. Configurable to fit any patio space. Produced by Outdoor Living. Corner dimensions: 32\"W x 32\"D x 28\"H, Armless dimensions: 28\"W x 32\"D x 28\"H.",
"OPT-4002": "Cantilever umbrella in sunflower yellow, featuring a 10-foot canopy and adjustable tilt for optimal shade. Constructed with a sturdy aluminum pole and fade-resistant fabric. Manufactured by Shade Masters. Dimensions: 120\"W x 120\"D x 96\"H.",
"OPT-4003": "Rustic fire pit table made from faux stone, includes a natural gas hookup and a matching cover. Ideal for evening gatherings on the patio. Manufactured by Warmth Outdoor. Dimensions: 42\"W x 42\"D x 24\"H.",
"ENT-5001": "Digital jukebox with touchscreen interface and built-in speakers, capable of streaming music and playing CDs. Retro design with modern technology, includes customizable LED lighting. Produced by RetroSound. Dimensions: 24\"W x 15\"D x 48\"H.",
"ENT-5002": "Gaming console storage unit in sleek black, featuring designated compartments for systems, controllers, and games. Ventilated to prevent overheating. Manufactured by GameHub. Dimensions: 42\"W x 16\"D x 24\"H.",
"ENT-5003": "Virtual reality gaming set by VR Innovations, includes headset, two motion controllers, and a charging station. Offers a comprehensive library of immersive games and experiences.",
"KIT-6001": "Chef's rolling kitchen cart in stainless steel, features two shelves, a drawer, and towel bars. Portable and versatile, ideal for extra storage and workspace in the kitchen. Produced by KitchenAid. Dimensions: 30\"W x 18\"D x 36\"H.",
"KIT-6002": "Contemporary pendant light cluster with three frosted glass shades, suspended from a polished nickel ceiling plate. Provides elegant, diffuse lighting over kitchen islands. Manufactured by Luminary Designs. Adjustable drop length up to 60\".",
"KIT-6003": "Eight-piece ceramic dinnerware set in ocean blue, includes dinner plates, salad plates, bowls, and mugs. Dishwasher and microwave safe, adds a pop of color to any meal. Produced by Tabletop Trends.",
"GBR-7001": "Twin-size daybed with trundle in brushed silver metal, ideal for guest rooms or small spaces. Includes two comfortable twin mattresses. Manufactured by Guestroom Gadgets. Bed dimensions: 79\"L x 42\"W x 34\"H.",
"GBR-7002": "Wall art set featuring three abstract prints in blue and grey tones, framed in light wood. Each frame measures 24\"W x 36\"H. Adds a modern touch to guest bedrooms. Produced by Artistic Expressions.",
"GBR-7003": "Set of two bedside lamps in brushed nickel with white fabric shades. Offers a soft, ambient light suitable for reading or relaxing in bed. Dimensions per lamp: 12\"W x 24\"H. Manufactured by Bright Nights.",
"BMT-8001": "Industrial-style pool table with a slate top and black felt, includes cues, balls, and a rack. Perfect for entertaining and game nights in finished basements. Produced by Billiard Masters. Dimensions: 96\"L x 52\"W x 32\"H.",
"BMT-8002": "Leather home theater recliner set in black, includes four connected seats with individual cup holders and storage compartments. Offers a luxurious movie-watching experience. Made by CinemaComfort. Dimensions per seat: 22\"W x 40\"D x 40\"H.",
"BMT-8003": "Adjustable height pub table set with four stools, featuring a rustic wood finish and black metal frame. Ideal for casual dining or socializing in basements. Produced by Casual Home. Table dimensions: 36\"W x 36\"D x 42\"H, Stool dimensions: 15\"W x 15\"D x 30\"H."
}
In uno scenario reale, possiamo estrapolare questo dato a milioni di SKU e descrizioni, molto probabilmente tutti residenti in luoghi diversi. Lo sforzo di aggregare e organizzare questi dati sembra banale in questo scenario, ma in generale i dati in circolazione dovrebbero essere organizzati in una struttura come questa.
Il passaggio successivo è semplicemente convertire ogni SKU nel proprio file di testo. In totale ci sono 105 file di testo (SKU). Nota: puoi trovare tutti i dati/codici collegati nel mio GitHub in fondo all'articolo.
Ho utilizzato questo prompt per generare i dati e inviarli numerose volte:
Given different "categories" for furniture, I want you to generate a synthetic 'SKU' and product description.Generate 3 for each category. Be extremely granular with your details and descriptions (colors, sizes, synthetic manufacturers, etc..).
Every response should follow this format and should be only JSON:
{<SKU>:<description>}.
- master bedroom
- living room
- outdoor patio
- entertainment
- kitchen
- guest bedroom
- finished basement
Per andare avanti, dovresti avere una directory con file di testo contenenti le descrizioni dei prodotti con gli SKU come nomi di file.
Spezzatura
Dato un pezzo di testo, dobbiamo suddividerlo in modo efficiente in modo che sia ottimizzato per il recupero. Ho provato a modellarlo dopo il lama-index FraseSplitter classe.
import re
import os
import uuid
from transformers import AutoTokenizer, AutoModeldef document_chunker(directory_path,
model_name,
paragraph_separator='\n\n',
chunk_size=1024,
separator=' ',
secondary_chunking_regex=r'\S+?(\.,;!?)',
chunk_overlap=0):
tokenizer = AutoTokenizer.from_pretrained(model_name) # Load tokenizer for the specified model
documents = {} # Initialize dictionary to store results
# Read each file in the specified directory
for filename in os.listdir(directory_path):
file_path = os.path.join(directory_path, filename)
base = os.path.basename(file_path)
sku = os.path.splitext(base)(0)
if os.path.isfile(file_path):
with open(file_path, 'r', encoding='utf-8') as file:
text = file.read()
# Generate a unique identifier for the document
doc_id = str(uuid.uuid4())
# Process each file using the existing chunking logic
paragraphs = re.split(paragraph_separator, text)
all_chunks = {}
for paragraph in paragraphs:
words = paragraph.split(separator)
current_chunk = ""
chunks = ()
for word in words:
new_chunk = current_chunk + (separator if current_chunk else '') + word
if len(tokenizer.tokenize(new_chunk)) <= chunk_size:
current_chunk = new_chunk
else:
if current_chunk:
chunks.append(current_chunk)
current_chunk = word
if current_chunk:
chunks.append(current_chunk)
refined_chunks = ()
for chunk in chunks:
if len(tokenizer.tokenize(chunk)) > chunk_size:
sub_chunks = re.split(secondary_chunking_regex, chunk)
sub_chunk_accum = ""
for sub_chunk in sub_chunks:
if sub_chunk_accum and len(tokenizer.tokenize(sub_chunk_accum + sub_chunk + ' ')) > chunk_size:
refined_chunks.append(sub_chunk_accum.strip())
sub_chunk_accum = sub_chunk
else:
sub_chunk_accum += (sub_chunk + ' ')
if sub_chunk_accum:
refined_chunks.append(sub_chunk_accum.strip())
else:
refined_chunks.append(chunk)
final_chunks = ()
if chunk_overlap > 0 and len(refined_chunks) > 1:
for i in range(len(refined_chunks) - 1):
final_chunks.append(refined_chunks(i))
overlap_start = max(0, len(refined_chunks(i)) - chunk_overlap)
overlap_end = min(chunk_overlap, len(refined_chunks(i+1)))
overlap_chunk = refined_chunks(i)(overlap_start:) + ' ' + refined_chunks(i+1)(:overlap_end)
final_chunks.append(overlap_chunk)
final_chunks.append(refined_chunks(-1))
else:
final_chunks = refined_chunks
# Assign a UUID for each chunk and structure it with text and metadata
for chunk in final_chunks:
chunk_id = str(uuid.uuid4())
all_chunks(chunk_id) = {"text": chunk, "metadata": {"file_name":sku}} # Initialize metadata as dict
# Map the document UUID to its chunk dictionary
documents(doc_id) = all_chunks
return documents
Il parametro più importante qui è “chunk_size”. Come puoi vedere, stiamo usando il file trasformatori libreria per contare il numero di token in una determinata stringa. Pertanto, il Chunk_size rappresenta il numero di token in un blocco.
Ecco la ripartizione di ciò che accade all'interno della funzione:
Per ogni file nella directory specificata â†'
- Dividere il testo in paragrafi:
– Dividere il testo immesso in paragrafi utilizzando un separatore specificato. - Dividere i paragrafi in parole:
– Per ogni paragrafo, dividilo in parole.
– Crea blocchi di queste parole senza superare il numero di token specificato (chunk_size). - Perfeziona blocchi:
– Se un pezzo supera il valore Chunk_size, suddividilo ulteriormente utilizzando un'espressione regolare basata sulla punteggiatura.
– Unisci sotto-pezzi se necessario per ottimizzare la dimensione del pezzo. - Applica sovrapposizione:
– Per sequenze con più blocchi, creare sovrapposizioni tra di loro per garantire la continuità contestuale. - Compila e restituisce blocchi:
– Esegui il loop su ogni blocco finale, assegnagli un ID univoco che mappa il testo e i metadati di quel blocco e infine assegna questo dizionario del blocco all'ID del documento.
In questo esempio, dove stiamo indicizzando numerosi documenti più piccoli, il processo di suddivisione in blocchi è relativamente semplice. Ogni documento, essendo breve, richiede una segmentazione minima. Ciò contrasta nettamente con gli scenari che coinvolgono testi più estesi, come l’estrazione di sezioni specifiche da lunghi contratti o l’indicizzazione di interi romanzi. Per soddisfare una varietà di dimensioni e complessità dei documenti, ho sviluppato il file document_chunker
funzione. Ciò ti consente di inserire i tuoi dati, indipendentemente dalla loro lunghezza o formato, e applicare lo stesso efficiente processo di suddivisione in blocchi. Che tu abbia a che fare con descrizioni concise di prodotti o ampie opere letterarie, il document_chunker
garantisce che i dati siano adeguatamente segmentati per un'indicizzazione e un recupero ottimali.
Utilizzo:
docs = document_chunker(directory_path='/Users/joesasson/Desktop/articles/rag-from-scratch/text_data',
model_name='BAAI/bge-small-en-v1.5',
chunk_size=256)keys = list(docs.keys())
print(len(docs))
print(docs(keys(0)))
Out -->
105
{'61d6318e-644b-48cd-a635-9490a1d84711': {'text': 'Gaming console storage unit in sleek black, featuring designated compartments for systems, controllers, and games. Ventilated to prevent overheating. Manufactured by GameHub. Dimensions: 42"W x 16"D x 24"H.', 'metadata': {'file_name': 'ENT-5002'}}}
Ora abbiamo una mappatura con un ID documento univoco, che punta a tutti i blocchi in quel documento, ogni blocco ha il proprio ID univoco che punta al testo e ai metadati di quel blocco.
I metadati possono contenere coppie chiave/valore arbitrarie. Qui sto impostando il nome del file (SKU) come metadati in modo da poter risalire ai risultati dei nostri modelli fino al prodotto originale.
Indicizzazione
Ora che abbiamo creato l'archivio documenti, dobbiamo creare l'archivio vettori.
Potresti averlo già notato, ma stiamo usando BAY/bge-small-it-v1.5 come il nostro modello di incorporamento. Nella funzione precedente lo usiamo solo per la tokenizzazione, ora lo useremo per vettorizzare il nostro testo.
Per prepararci alla distribuzione, salviamo il tokenizzatore e il modello localmente.
from transformers import AutoModel, AutoTokenizermodel_name = "BAAI/bge-small-en-v1.5"
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModel.from_pretrained(model_name)
tokenizer.save_pretrained("model/tokenizer")
model.save_pretrained("model/embedding")
def compute_embeddings(text):
tokenizer = AutoTokenizer.from_pretrained("/model/tokenizer")
model = AutoModel.from_pretrained("/model/embedding")inputs = tokenizer(text, return_tensors="pt", padding=True, truncation=True)
# Generate the embeddings
with torch.no_grad():
embeddings = model(**inputs).last_hidden_state.mean(dim=1).squeeze()
return embeddings.tolist()
def create_vector_store(doc_store):
vector_store = {}
for doc_id, chunks in doc_store.items():
doc_vectors = {}
for chunk_id, chunk_dict in chunks.items():
# Generate an embedding for each chunk of text
doc_vectors(chunk_id) = compute_embeddings(chunk_dict.get("text"))
# Store the document's chunk embeddings mapped by their chunk UUIDs
vector_store(doc_id) = doc_vectors
return vector_store
Tutto quello che abbiamo fatto è stato semplicemente convertire i blocchi nell'archivio documenti in incorporamenti. Puoi collegare qualsiasi modello di incorporamento e qualsiasi archivio di vettori. Poiché il nostro archivio vettoriale è solo un dizionario, tutto ciò che dobbiamo fare è inserirlo in un file JSON per persistere.
Recupero
Ora proviamolo con una query!
def compute_matches(vector_store, query_str, top_k):
"""
This function takes in a vector store dictionary, a query string, and an int 'top_k'.
It computes embeddings for the query string and then calculates the cosine similarity against every chunk embedding in the dictionary.
The top_k matches are returned based on the highest similarity scores.
"""
# Get the embedding for the query string
query_str_embedding = np.array(compute_embeddings(query_str))
scores = {}# Calculate the cosine similarity between the query embedding and each chunk's embedding
for doc_id, chunks in vector_store.items():
for chunk_id, chunk_embedding in chunks.items():
chunk_embedding_array = np.array(chunk_embedding)
# Normalize embeddings to unit vectors for cosine similarity calculation
norm_query = np.linalg.norm(query_str_embedding)
norm_chunk = np.linalg.norm(chunk_embedding_array)
if norm_query == 0 or norm_chunk == 0:
# Avoid division by zero
score = 0
else:
score = np.dot(chunk_embedding_array, query_str_embedding) / (norm_query * norm_chunk)
# Store the score along with a reference to both the document and the chunk
scores((doc_id, chunk_id)) = score
# Sort scores and return the top_k results
sorted_scores = sorted(scores.items(), key=lambda item: item(1), reverse=True)(:top_k)
top_results = ((doc_id, chunk_id, score) for ((doc_id, chunk_id), score) in sorted_scores)
return top_results
IL compute_matches
La funzione è progettata per identificare i blocchi di testo top_k più simili a una determinata stringa di query da una raccolta memorizzata di incorporamenti di testo. Ecco una ripartizione:
- Incorpora la stringa di query
- Calcola la somiglianza del coseno. Per ogni blocco viene calcolata la somiglianza del coseno tra il vettore della query e il vettore del blocco. Qui,
np.linalg.norm
calcola la norma euclidea (norma L2) dei vettori, necessaria per il calcolo della similarità del coseno. - Gestire la normalizzazione e calcolare il prodotto scalare. La somiglianza del coseno è definita come:
4. Ordina e seleziona i punteggi. I punteggi vengono ordinati in ordine decrescente e vengono selezionati i risultati top_k
Utilizzo:
matches = compute_matches(vector_store=vec_store,
query_str="Wall-mounted electric fireplace with realistic LED flames",
top_k=3)# matches
(('d56bc8ca-9bbc-4edb-9f57-d1ea2b62362f',
'3086bed2-65e7-46cc-8266-f9099085e981',
0.8600385118142513),
('240c67ce-b469-4e0f-86f7-d41c630cead2',
'49335ccf-f4fb-404c-a67a-19af027a9fc2',
0.7067269230771228),
('53faba6d-cec8-46d2-8d7f-be68c3080091',
'b88e4295-5eb1-497c-8536-59afd84d2210',
0.6959163226146977))
# plug the top match document ID keys into doc_store to access the retrieved content
docs('d56bc8ca-9bbc-4edb-9f57-d1ea2b62362f')('3086bed2-65e7-46cc-8266-f9099085e981')
# result
{'text': 'Wall-mounted electric fireplace with realistic LED flames and heat settings. Features a black glass frame and remote control for easy operation. Ideal for adding warmth and ambiance. Manufactured by Hearth & Home. Dimensions: 50"W x 6"D x 21"H.',
'metadata': {'file_name': 'ENT-4001'}}
Dove ogni tupla ha l'ID del documento, seguito dall'ID del pezzo, seguito dal punteggio.
Fantastico, funziona! Tutto ciò che resta da fare è connettere il componente LLM ed eseguire un test end-to-end completo, quindi siamo pronti per l'implementazione!
Per migliorare l'esperienza dell'utente rendendo interattivo il nostro sistema RAG, utilizzeremo il file llama-cpp-python
biblioteca. Il nostro setup utilizzerà un modello di parametri mistral-7B con quantizzazione GGUF a 3 bit, una configurazione che fornisce un buon equilibrio tra efficienza computazionale e prestazioni. Sulla base di test approfonditi, questo modello ha dimostrato di essere molto efficace, soprattutto quando utilizzato su macchine con risorse limitate come il mio Mac M2 da 8 GB. Adottando questo approccio, ci assicuriamo che il nostro sistema RAG non solo fornisca risposte precise e pertinenti, ma mantenga anche un tono colloquiale, rendendolo più coinvolgente e accessibile per gli utenti finali.
Breve nota sulla configurazione di LLM localmente su un Mac: la mia preferenza è utilizzare anaconda o miniconda. Assicurati di aver installato una versione arm64 e segui le istruzioni di configurazione per “metal” dalla libreria, Qui.
Ora è abbastanza facile. Tutto quello che dobbiamo fare è definire una funzione per costruire un prompt che includa i documenti recuperati e la query dell'utente. La risposta del LLM verrà inviata all'utente.
Ho definito le funzioni seguenti per trasmettere in streaming la risposta testuale da LLM e costruire il nostro prompt finale.
from llama_cpp import Llama
import sysdef stream_and_buffer(base_prompt, llm, max_tokens=800, stop=("Q:", "\n"), echo=True, stream=True):
# Formatting the base prompt
formatted_prompt = f"Q: {base_prompt} A: "
# Streaming the response from llm
response = llm(formatted_prompt, max_tokens=max_tokens, stop=stop, echo=echo, stream=stream)
buffer = ""
for message in response:
chunk = message('choices')(0)('text')
buffer += chunk
# Split at the last space to get words
words = buffer.split(' ')
for word in words(:-1): # Process all words except the last one (which might be incomplete)
sys.stdout.write(word + ' ') # Write the word followed by a space
sys.stdout.flush() # Ensure it gets displayed immediately
# Keep the rest in the buffer
buffer = words(-1)
# Print any remaining content in the buffer
if buffer:
sys.stdout.write(buffer)
sys.stdout.flush()
def construct_prompt(system_prompt, retrieved_docs, user_query):
prompt = f"""{system_prompt}
Here is the retrieved context:
{retrieved_docs}
Here is the users query:
{user_query}
"""
return prompt
# Usage
system_prompt = """
You are an intelligent search engine. You will be provided with some retrieved context, as well as the users query.
Your job is to understand the request, and answer based on the retrieved context.
"""
retrieved_docs = """
Wall-mounted electric fireplace with realistic LED flames and heat settings. Features a black glass frame and remote control for easy operation. Ideal for adding warmth and ambiance. Manufactured by Hearth & Home. Dimensions: 50"W x 6"D x 21"H.
"""
prompt = construct_prompt(system_prompt=system_prompt,
retrieved_docs=retrieved_docs,
user_query="I am looking for a wall-mounted electric fireplace with realistic LED flames")
llm = Llama(model_path="/Users/joesasson/Downloads/mistral-7b-instruct-v0.2.Q3_K_L.gguf", n_gpu_layers=1)
stream_and_buffer(prompt, llm)
Output finale che viene restituito all'utente:
“In base al contesto recuperato e alla domanda dell'utente, il caminetto elettrico Hearth & Home con fiamme LED realistiche corrisponde alla descrizione. Questo modello misura 50 pollici di larghezza, 6 pollici di profondità e 21 pollici di altezza e viene fornito con un telecomando per un facile utilizzo.
Ora siamo pronti per implementare il nostro sistema RAG. Segui la sezione successiva e convertiremo questo codice quasi-spaghetti in un'API utilizzabile per gli utenti.
Per estendere la portata e l'usabilità del nostro sistema, lo impacchetteremo in un'applicazione Flask containerizzata. Questo approccio garantisce che il nostro modello sia incapsulato all'interno di un contenitore Docker, fornendo stabilità e coerenza indipendentemente dall'ambiente informatico.
Dovresti aver scaricato il modello di incorporamento e il tokenizzatore sopra. Posizionali allo stesso livello del codice dell'applicazione, dei requisiti e del Dockerfile. Puoi scaricare il LLM Qui.
Dovresti avere la seguente struttura di directory:
app.py
from flask import Flask, request, jsonify
import numpy as np
import json
from typing import Dict, List, Any
from llama_cpp import Llama
import torch
import logging
from transformers import AutoModel, AutoTokenizerapp = Flask(__name__)
# Set the logger level for Flask's logger
app.logger.setLevel(logging.INFO)
def compute_embeddings(text):
tokenizer = AutoTokenizer.from_pretrained("/app/model/tokenizer")
model = AutoModel.from_pretrained("/app/model/embedding")
inputs = tokenizer(text, return_tensors="pt", padding=True, truncation=True)
# Generate the embeddings
with torch.no_grad():
embeddings = model(**inputs).last_hidden_state.mean(dim=1).squeeze()
return embeddings.tolist()
def compute_matches(vector_store, query_str, top_k):
"""
This function takes in a vector store dictionary, a query string, and an int 'top_k'.
It computes embeddings for the query string and then calculates the cosine similarity against every chunk embedding in the dictionary.
The top_k matches are returned based on the highest similarity scores.
"""
# Get the embedding for the query string
query_str_embedding = np.array(compute_embeddings(query_str))
scores = {}
# Calculate the cosine similarity between the query embedding and each chunk's embedding
for doc_id, chunks in vector_store.items():
for chunk_id, chunk_embedding in chunks.items():
chunk_embedding_array = np.array(chunk_embedding)
# Normalize embeddings to unit vectors for cosine similarity calculation
norm_query = np.linalg.norm(query_str_embedding)
norm_chunk = np.linalg.norm(chunk_embedding_array)
if norm_query == 0 or norm_chunk == 0:
# Avoid division by zero
score = 0
else:
score = np.dot(chunk_embedding_array, query_str_embedding) / (norm_query * norm_chunk)
# Store the score along with a reference to both the document and the chunk
scores((doc_id, chunk_id)) = score
# Sort scores and return the top_k results
sorted_scores = sorted(scores.items(), key=lambda item: item(1), reverse=True)(:top_k)
top_results = ((doc_id, chunk_id, score) for ((doc_id, chunk_id), score) in sorted_scores)
return top_results
def open_json(path):
with open(path, 'r') as f:
data = json.load(f)
return data
def retrieve_docs(doc_store, matches):
top_match = matches(0)
doc_id = top_match(0)
chunk_id = top_match(1)
docs = doc_store(doc_id)(chunk_id)
return docs
def construct_prompt(system_prompt, retrieved_docs, user_query):
prompt = f"""{system_prompt}
Here is the retrieved context:
{retrieved_docs}
Here is the users query:
{user_query}
"""
return prompt
@app.route('/rag_endpoint', methods=('GET', 'POST'))
def main():
app.logger.info('Processing HTTP request')
# Process the request
query_str = request.args.get('query') or (request.get_json() or {}).get('query')
if not query_str:
return jsonify({"error":"missing required parameter 'query'"})
vec_store = open_json('/app/vector_store.json')
doc_store = open_json('/app/doc_store.json')
matches = compute_matches(vector_store=vec_store, query_str=query_str, top_k=3)
retrieved_docs = retrieve_docs(doc_store, matches)
system_prompt = """
You are an intelligent search engine. You will be provided with some retrieved context, as well as the users query.
Your job is to understand the request, and answer based on the retrieved context.
"""
base_prompt = construct_prompt(system_prompt=system_prompt, retrieved_docs=retrieved_docs, user_query=query_str)
app.logger.info(f'constructed prompt: {base_prompt}')
# Formatting the base prompt
formatted_prompt = f"Q: {base_prompt} A: "
llm = Llama(model_path="/app/mistral-7b-instruct-v0.2.Q3_K_L.gguf")
response = llm(formatted_prompt, max_tokens=800, stop=("Q:", "\n"), echo=False, stream=False)
return jsonify({"response": response})
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5001)
Dockerfile
# Use an official Python runtime as a parent image
FROM --platform=linux/arm64 python:3.11# Set the working directory in the container to /app
WORKDIR /app
# Copy the requirements file
COPY requirements.txt .
# Update system packages, install gcc and Python dependencies
RUN apt-get update && \
apt-get install -y gcc g++ make libtool && \
apt-get upgrade -y && \
apt-get clean && \
rm -rf /var/lib/apt/lists/* && \
pip install --no-cache-dir -r requirements.txt
# Copy the current directory contents into the container at /app
COPY . /app
# Expose port 5001 to the outside world
EXPOSE 5001
# Run script when the container launches
CMD ("python", "app.py")
Qualcosa di importante da notare: stiamo impostando la directory di lavoro su “/app” nella seconda riga del Dockerfile. Pertanto, qualsiasi percorso locale (modelli, vettore o archivio documenti) deve avere il prefisso “/app” nel codice dell'applicazione.
Inoltre, quando esegui l'app nel contenitore (su un Mac), non sarà in grado di accedere alla GPU, vedi Questo filo. Ho notato che di solito ci vogliono circa 20 minuti per ottenere una risposta utilizzando la CPU.
Costruisci ed esegui:
docker build -t <image-name>:<tag> .
docker run -p 5001:5001 <image-name>:<tag>
L'esecuzione del contenitore avvia automaticamente l'app (vedi l'ultima riga del Dockerfile). Ora puoi accedere al tuo endpoint al seguente URL:
http://127.0.0.1:5001/rag_endpoint
Chiama l'API:
import requests, jsondef call_api(query):
URL = "http://127.0.0.1:5001/rag_endpoint"
# Headers for the request
headers = {
"Content-Type": "application/json"
}
# Body for the request.
body = {"query": query}
# Making the POST request
response = requests.post(URL, headers=headers, data=json.dumps(body))
# Check if the request was successful
if response.status_code == 200:
return response.json()
else:
return f"Error: {response.status_code}, Message: {response.text}"
# Test
query = "Wall-mounted electric fireplace with realistic LED flames"
result = call_api(query)
print(result)
# result
{'response': {'choices': ({'finish_reason': 'stop', 'index': 0, 'logprobs': None, 'text': ' Based on the retrieved context, the wall-mounted electric fireplace mentioned includes features such as realistic LED flames. Therefore, the answer to the user\'s query "Wall-mounted electric fireplace with realistic LED flames" is a match to the retrieved context. The specific model mentioned in the context is manufactured by Hearth & Home and comes with additional heat settings.'}), 'created': 1715307125, 'id': 'cmpl-dd6c41ee-7c89-440f-9b04-0c9da9662f26', 'model': '/app/mistral-7b-instruct-v0.2.Q3_K_L.gguf', 'object': 'text_completion', 'usage': {'completion_tokens': 78, 'prompt_tokens': 177, 'total_tokens': 255}}}
Voglio ricapitolare tutti i passaggi necessari per arrivare a questo punto e il flusso di lavoro per adattarlo a eventuali dati/incorporamenti/LLM.
- Passa la tua directory di file di testo al file
document_chunker
funzione per creare l'archivio documenti. - Scegli il tuo modello di incorporamenti. Salvalo localmente.
- Converti archivio documenti in archivio vettoriale. Salva entrambi localmente.
- Scarica LLM dall'hub HF.
- Spostare i file nella directory dell'app (file JSON del modello di incorporamento, LLM, doc store e vec store).
- Costruisci ed esegui il contenitore Docker.
Essenzialmente si può ridurre a questo: utilizzare il file build
notebook per generare doc_store e vector_store e inserirli nella tua app.
GitHub Qui. Grazie per aver letto!
Fonte: towardsdatascience.com