Reading Time: 10 minutes

Ammettiamolo, cercare quell’informazione precisa tra mucchi di documenti e manuali in formato Markdown può diventare una specie di sport estremo. Più di una volta mi sono ritrovato a fare slalom tra file su file, in una corsa contro il tempo che sembrava più un’impresa da atleti di parkour che una semplice ricerca. Cosi ho deciso di sperimentare un sistema RAG personalizzato grazie a LangChain. L’idea? Rendere tutto questo girovagare tra i documenti non solo più sopportabile, ma addirittura efficace e, perché no, un po’ più divertente.

Che cosa è RAG?

RAG (Retrieval Augmented Generation) opera attraverso un processo bifase essenziale per la gestione e l’interpretazione delle informazioni. Nella prima fase, di fronte a una domanda o richiesta, il sistema attiva una ricerca mirata all’interno di database o un set di dati specifici, che può spaziare da testi scritti a grafici. Successivamente, nella seconda fase, le informazioni così recuperate alimentano un modello generativo. Questo modello, elaborando i dati acquisiti, è in grado di formulare una risposta o un’analisi che non solo risulta pertinente al contesto dato ma è anche costruita con coerenza e precisione. Per implementare efficacemente la tecnica RAG (Retrieval Augmented Generation), ci avvaliamo di Langchain, un framework per facilitare lo sviluppo e l’implementazione di applicazioni di intelligenza artificiale (AI). Langchain offre una serie di librerie e moduli che permettono agli sviluppatori di integrare facilmente capacità di ricerca, elaborazione e generazione del linguaggio nei loro progetti.

 Document Loaders

Per chattare con i nostri dati, è essenziale caricarli in un formato che permetta la loro elaborazione. A questo scopo, utilizziamo i Document Loader di LangChain, che facilitano l’accesso e la conversione dei dati da un’ampia gamma di formati e fonti.

Questi Loader sono progettati per gestire le specificità dell’accesso ai dati da sorgenti diverse, quali:

  • Siti web
  • Database
  • YouTube
  • Twitter
  • Hacker News
  • E anche fonti come Figma, Notion, o servizi come Stripe.

Potete trovare maggiori dettagli sulla documentazione ufficiale di LangChain.

 

Utilizzo di UnstructuredMarkdownLoader

Per lavorare con documenti in formato Markdown, facciamo affidamento su UnstructuredMarkdownLoader. Implementiamo una classe DocumentManager per caricare efficacemente una directory contenente i documenti Markdown:

from langchain_community.document_loaders import DirectoryLoader
from langchain_community.document_loaders import UnstructuredMarkdownLoader

class DocumentManager:
    def __init__(self, directory_path, glob_pattern="./*.md"):
        self.directory_path = directory_path
        self.glob_pattern = glob_pattern
        self.documents = []
        self.all_sections = []
    
    def load_documents(self): loader = DirectoryLoader(self.directory_path, 
glob=self.glob_pattern, show_progress=True,
loader_cls=UnstructuredMarkdownLoader) self.documents = loader.load()

La classe DocumentManager permette il caricamento di documenti Markdown da una directory specificata. Attraverso l’uso di DirectoryLoader e UnstructuredMarkdownLoader, la classe è in grado di:

  1. Accedere alla directory: individua la directory dove si trovano i documenti Markdown.
  2. Filtrare i file: utilizza il glob_pattern fornito (di default, tutti i file .md) per selezionare i documenti da caricare.
  3. Caricare i documenti: importa i file filtrati per l’elaborazione successiva.

 

Text Splitters

I Text Splitters giocano un ruolo fondamentale nell’elaborazione del testo, permettendoci di suddividere documenti complessi in parti più gestibili. La sfida principale in questo processo è preservare le relazioni semantiche tra i segmenti, per non perdere le relazioni significative tra i blocchi.

Prendiamo come esempio la descrizione della scheda grafica GeForce RTX 4090:

  • Parte 1: GeForce RTX 4090, oltre la velocità
  • Parte 2: 24 GB di memoria per offrire l’esperienza definitiva per giocatori e creativi.

Una suddivisione inadeguata del testo potrebbe separare informazioni strettamente correlate, come nel caso delle specifiche della GeForce RTX 4090, compromettendo la chiarezza dell’informazione. Per questo motivo, è cruciale affidarsi a Text Splitters avanzati, capaci di riconoscere e mantenere intatte le unità di significato all’interno del testo.

LangChain propone diverse soluzioni per affrontare questa sfida, visti in nostri documenti in md utilizzeremo il MarkdownHeaderTextSplitter. Per approfondire le diverse tipologie di splitter disponibili, potete trovare maggiori informazioni sulla documentazione ufficiale di LangChain.

 

 

 

 # Class DocumentManager

from langchain.text_splitter import MarkdownHeaderTextSplitter

    def split_documents(self):
        headers_to_split_on = [("#", "Header 1"), 
("##", "Header 2"),
("###", "Header 3"),
("####", "Header 4")] text_splitter = MarkdownHeaderTextSplitter(headers_to_split_on=headers_to_split_on) for doc in self.documents: sections = text_splitter.split_text(doc.page_content) self.all_sections.extend(sections)

Embeddings e Vector Store

Dopo aver diviso i nostri documenti in frammenti più piccoli attraverso il processo di text splitting, il passaggio successivo è trasformare questi frammenti in una forma che sia facilmente interrogabile e comparabile. Qui entrano in gioco gli embedding e i database vettoriali. Utilizzando OpenAIEmbeddings, trasformiamo ogni frammento di testo in un embedding vettoriale. Gli embedding sono rappresentazioni numeriche dense che catturano la semantica del testo; testi con significati simili risultano in vettori simili nello spazio degli embedding. Ciò consente operazioni sofisticate come la ricerca semantica e il clustering tematico.

Per memorizzare e gestire efficacemente questi embedding, impieghiamo Chroma un Database vettoriale open-source (ho scelto di utilizzare Chroma per la sua facilità di implementazione, consentendo la creazione di un vector store permanente direttamente all’interno della cartella del progetto. Questo elimina la necessità di configurazioni complesse o l’uso di container Docker).

Con Chroma possiamo organizzare gli embedding in modo che possano essere rapidamente recuperati attraverso query basate sulla similarità. Questo approccio trasforma una collezione di testi in un database interrogabile, dove possiamo trovare documenti o frammenti rilevanti per una data query confrontando gli embedding della query con quelli presenti nell’archivio.

Langchain integra svariati Vectore Sore, per una lista completta consultate LangChain

Importiamo OpenAIEmbeddings e Chroma in una classe EmbeddingManager cosi da automatizzare il processo di creazione e memorizzazione degli embedding. Inizializziamo EmbeddingManager con i frammenti di testo e un path per la persistenza dei dati. Invocando il metodo create_and_persist_embeddings, ogni frammento viene trasformato in un embedding attraverso OpenAIEmbeddings e memorizzato in Chroma.

from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import Chroma

class EmbeddingManager:
    def __init__(self, all_sections, persist_directory='db'):
        self.all_sections = all_sections
        self.persist_directory = persist_directory
        self.vectordb = None
        
    # Method to create and persist embeddings
    def create_and_persist_embeddings(self):
        # Creating an instance of OpenAIEmbeddings
        embedding = OpenAIEmbeddings()
        # Creating an instance of Chroma with the sections and the embeddings
        self.vectordb = Chroma.from_documents(documents=self.all_sections, 
embedding=embedding,
persist_directory=self.persist_directory)
# Persisting the embeddings self.vectordb.persist()

Retriver

Il retriver rappresenta il cuore della nostro RAG (Retrieval Augmented Generation). Questo strumento serve ad individuare e recuperare i documenti più pertinenti rispetto a una data query, sfruttando il vector store Chroma che contiene gli embedding dei documenti. La ricerca si basa sulla similarità degli embedding, confrontando quelli della query con quelli dei documenti per identificare i più adatti al tema della domanda. Quando affrontiamo il compito di rispondere a domande basate sui nostri documenti, la sfida più significativa risiede spesso nel recupero efficace delle informazioni pertinenti. Un recupero inadeguato può facilmente tradursi in risposte inaccurate o incomplete.

Noi utilizzeremo Conversational Retrieval Chain. Questa catena non si limita a recuperare i documenti più pertinenti rispetto all’ultima query ma sfrutta la storia della conversazione per migliorare la qualità delle risposte fornite. Questo processo si articola in tre fasi principali:

  1. Riformulazione della Query: La catena modifica la query iniziale per includere il contesto fornito dalla storia della conversazione, consentendo al sistema di “ricordare” le richieste precedenti e di formulare risposte più accurate.
  2. Recupero dei Documenti Rilevanti: Utilizzando un ‘retriever’, la catena cerca documenti pertinenti alla query riformulata. Questo recupero si basa su una ricerca di similarità, facendo affidamento su rappresentazioni vettoriali del testo per identificare i documenti che meglio corrispondono al tema della domanda.
  3. Generazione della Risposta: infine, la catena chiede a un Large Language Model (LLM) di generare una risposta basata sui documenti recuperati e sulla query riformulata. Questo passaggio combina il contesto dei documenti rilevanti con la domanda per produrre una risposta coerente e informativa.
from langchain_openai import OpenAI
from dotenv import load_dotenv
from langchain.chains import ConversationalRetrievalChain
import os
load_dotenv()

# Set the OpenAI API key from the environment variable
os.environ["OPENAI_API_KEY"] = os.getenv("OPENAI_API_KEY")


class ConversationalRetrievalAgent:
    # Initialize the ConversationalRetrievalAgent with a vector 
database and a temperature for the OpenAI model def __init__(self, vectordb, temperature=0.5): self.vectordb = vectordb self.llm = OpenAI(temperature=temperature) self.chat_history = [] # Method to get the chat history as a string def get_chat_history(self, inputs): res = [] for human, ai in inputs: res.append(f"Human:{human}\nAI:{ai}") return "\n".join(res) # Method to set up the bot def setup_bot(self): # Create a retriever from the vector database retriever = self.vectordb.as_retriever(search_kwargs={"k": 4}) # Create a ConversationalRetrievalChain from the OpenAI model and the retriever self.bot = ConversationalRetrievalChain.from_llm( self.llm, retriever, return_source_documents=True,
get_chat_history=self.get_chat_history )

 

 

Prompt and templates

Sebbene il nostro ConversationalRetrievalChain faccia già uso della cronologia delle chat, ho scelto di implementare un ulteriore livello di personalizzazione attraverso un sistema di prompt e template personalizzabile. Questa decisione mira ad ottimizzare l’interazione con il nostro modello e di migliorare ulteriormente la pertinenza e l’accuratezza delle risposte fornite.

    # Class ConversationalRetrievalAgent:

    def generate_prompt(self, question):
        if not self.chat_history:
            # If it is the first question, use a specific template without 
# previous conversation context
            prompt = f"You are an assistant for question-answering tasks. 
Use the following pieces of retrieved context to answer the question.
If you don't know the answer, just say that you don't know.
\nQuestion: {question}\nContext: \nAnswer:" else:
            # If it is the first question, use a specific template without 
# previous conversation context
context_entries = [f"Question: {q}\nAnswer: {a}" for q, a in self.chat_history[-3:]] context = "\n\n".join(context_entries) prompt = f"Using the context provided by recent conversations,
answer the new question in a concise and informative.
Limit your answer to a maximum of three sentences.
\n\nContext of recent conversations:\n{context}\n\nNew question: {question}\n\Answer:" return prompt

Conversazione

In fine implementeremo il metodo ask_question, all’interno del nostro ConversationalRetrievalAgent. Questo metodo incapsula il processo attraverso il quale si riceve una domanda, si elabora la richiesta, e fornisce una risposta pertinente e informata.

    # Class ConversationalRetrievalAgent
   
    def ask_question(self, query):
        prompt = self.generate_prompt(query)

        result = self.bot.invoke({"question": prompt, "chat_history": self.chat_history})

        self.chat_history.append((query, result["answer"]))

        return result["answer"]

 

 

Orchestrazione del Sistema RAG

Adesso creeremo un orchestratore per il nostro sistema RAG personalizzato, collegando i componenti fondamentali in un flusso operativo coeso:

  1. Inizzializziamo il DocumentManager, che carica e suddivide i documenti Markdown presenti nella directory specificata.
  2. Utilizzo dell’EmbeddingManager che prende i frammenti di testo e crea rappresentazioni vettoriali (embeddings) per ciascuno, memorizzandole in un database vettoriale.
  3. Attivazione del ConversationalRetrievalAgent il quale utilizza questi embeddings per alimentare una catena di recupero conversazionale, che permette di rispondere a domande specifiche sfruttando il contesto fornito dai documenti.
from DocumentManager import DocumentManager
from EmbeddingManager import EmbeddingManager
from ConversationalRetrievalAgent import ConversationalRetrievalAgent

def main():
    # Initialising and loading documents
    doc_manager = DocumentManager('./marckdown_folder')
    doc_manager.load_documents()
    doc_manager.split_documents()

    # Creation and persistence of embeddings
    embed_manager = EmbeddingManager(doc_manager.all_sections)
    embed_manager.create_and_persist_embeddings()

    # Setup and use of conversation bots
    bot = ConversationalRetrievalAgent(embed_manager.vectordb)
    bot.setup_bot()
    print(bot.ask_question("Question one"))
    print(bot.ask_question("Question two"))
    print(bot.ask_question("Question three"))

if __name__ == "__main__":
    main()

Conclusioni

In sintesi, l’integrazione di un sistema RAG personalizzato con LangChain per esplorare documentazioni complesse dimostra un vasto potenziale per la personalizzazione e l’ottimizzazione. Sebbene ci sia ampio spazio per affinare ulteriormente il sistema, questo articolo ha voluto offrire un’idea generale di come sia possibile implementare una soluzione efficace e relativamente semplice per il recupero avanzato delle informazioni. L’obiettivo era illustrare la facilità con cui possiamo migliorare l’accesso e l’analisi di grandi quantità di dati, aprendo la strada a future esplorazioni in questo campo dinamico. Se desideri vedere l’intera implementazione puoi controllare il mio repository GitHub dove troverai tutto il codice per questo esempio.