RAG Come parlare con i tuoi dati

RAG Come comunicare con i tuoi dati

Guida completa su come analizzare i feedback dei clienti utilizzando ChatGPT

Immagine di DALL-E 3

Nelle mie precedenti articoli, abbiamo discusso su come fare Topic Modelling utilizzando ChatGPT. Il nostro compito era quello di analizzare i commenti dei clienti per diverse catene alberghiere e identificare i principali argomenti menzionati per ciascun hotel.

Come risultato di questo Topic Modelling, conosciamo gli argomenti per ciascuna recensione dei clienti e possiamo filtrarli facilmente e approfondire. Tuttavia, nella vita reale, è impossibile avere un set così esaustivo di argomenti che possano coprire tutti i possibili casi d’uso.

Ad esempio, ecco l’elenco di argomenti che abbiamo identificato dai feedback dei clienti in precedenza.

Questi argomenti possono aiutarci a ottenere una panoramica generale dei feedback dei clienti e fare un filtraggio iniziale. Ma supponiamo che vogliamo capire cosa pensano i clienti della palestra o delle bevande a colazione. In quel caso, dovremo andare attraverso molti feedback dei clienti noi stessi, relativi agli argomenti “Servizi dell’hotel” e “Colazione”.

Felizmente, i LLM possono aiutarci con questa analisi e risparmiare molte ore nella lettura delle recensioni dei clienti (anche se potrebbe comunque essere utile ascoltare la voce del cliente personalmente). In questo articolo discuteremo di tali approcci.

Continueremo ad utilizzare LangChain (uno dei framework più popolari per le applicazioni di LLM). Potete trovare una panoramica di base su LangChain nel mio articolo precedente.

Approcci ingenui

Il modo più semplice per ottenere commenti relativi a un argomento specifico è cercare alcune parole specifiche nei testi, come “palestra” o “bevanda”. Ho utilizzato questo approccio molte volte quando ChatGPT non esisteva ancora.

I problemi di questo approccio sono piuttosto evidenti:

  • Potreste ottenere molti commenti non pertinenti su palestre nelle vicinanze o bevande alcoliche nel ristorante dell’hotel. Filtri di questo tipo non sono sufficientemente specifici e non possono tenere conto del contesto, quindi avrete molti falsi positivi.
  • D’altra parte, potreste non avere una copertura abbastanza buona nel frattempo. Le persone tendono a usare parole leggermente diverse per le stesse cose (ad esempio, bevande, rinfreschi, liquidi, succhi, ecc.). Potrebbero esserci errori ortografici. E questo compito potrebbe diventare ancora più complicato se i vostri clienti parlano lingue diverse.

Quindi, questo approccio ha problemi sia con la precisione che con il ricordo. Vi darà un’idea generale della domanda, ma le sue capacità sono limitate.

L’altra soluzione potenziale è utilizzare lo stesso approccio del Topic Modelling: inviare tutti i commenti dei clienti al LLM e chiedere al modello di definire se sono correlati al nostro argomento di interesse (bevande a colazione o palestra). Possiamo persino chiedere al modello di riassumere tutti i feedback dei clienti e fornire una conclusione.

Questo approccio probabilmente funzionerà abbastanza bene. Tuttavia, ha anche i suoi limiti: dovrete inviare tutti i documenti che avete al LLM ogni volta che volete approfondire un determinato argomento. Anche con il filtraggio di alto livello basato sugli argomenti che abbiamo definito, potrebbe essere un’enorme quantità di dati da passare al LLM, e potrebbe essere piuttosto costoso.

Felizmente, c’è un altro modo per risolvere questo compito, ed è chiamato RAG.

Generazione arricchita da recupero

Abbiamo un insieme di documenti (recensioni dei clienti) e vogliamo fare domande relative al contenuto di questi documenti (ad esempio, “Cosa piace ai clienti della colazione?”). Come abbiamo discusso in precedenza, non vogliamo inviare tutte le recensioni dei clienti al LLM, quindi abbiamo bisogno di un modo per definire solo quelle più rilevanti. Quindi, il compito sarà piuttosto semplice: passare la domanda dell’utente e questi documenti come contesto al LLM, e basta.

Un approccio del genere è chiamato Generazione arricchita da recupero o RAG.

Schema dell'autore

Il flusso di lavoro per RAG consiste nelle seguenti fasi:

  • Caricamento dei documenti dalle fonti di dati che abbiamo.
  • Divisione dei documenti in chunk che sono facili da utilizzare successivamente.
  • Archiviazione: spesso vengono utilizzati store di vettori per questo caso d’uso per elaborare i dati in modo efficace.
  • Recupero dei documenti pertinenti alla domanda.
  • Generazione consiste nel passare una domanda e documenti pertinenti a LLM e ottenere la risposta finale.

Avrai probabilmente sentito che OpenAI ha lanciato Assistant API questa settimana, che può eseguire tutti questi passaggi per te. Tuttavia, ritengo che valga la pena passare attraverso l’intero processo per capire come funziona e le sue peculiarità.

Quindi, vediamo tutte queste fasi passo dopo passo.

Caricamento dei documenti

Il primo passo è caricare i nostri documenti. LangChain supporta diversi tipi di documenti, ad esempio CSV o JSON.

Potresti chiederti quale sia il vantaggio di utilizzare LangChain per tipi di dati così basilari. Va senza dire che è possibile analizzare i file CSV o JSON utilizzando le librerie standard di Python. Tuttavia, consiglio di utilizzare l’API di caricamento dati di LangChain poiché restituisce oggetti Document contenenti contenuto e metadati. Sarà più facile utilizzare i Document di LangChain in seguito.

Diamo uno sguardo a esempi di tipi di dati un po’ più complessi.

Spesso abbiamo compiti di analizzare il contenuto delle pagine web, quindi dobbiamo lavorare con HTML. Anche se hai già padroneggiato la libreria BeautifulSoup, potresti trovare utile BSHTMLLoader.

Ciò che è interessante dell’HTML in relazione alle applicazioni di LLM è che molto probabilmente sarà necessario pre-elaborarlo parecchio. Se osservi qualsiasi sito web tramite l’Ispezionatore del browser, noterai molto più testo di quello che vedi nel sito. Viene utilizzato per specificare layout, formattazione, stili, ecc.

Immagine dell'autore, documentazione LangChain

Nella maggior parte dei casi reali, non sarà necessario passare tutti questi dati a LLM. L’intero HTML di un sito potrebbe facilmente superare i 200.000 token (e solo ~10-20% di esso sarà il testo che vedi come utente), quindi sarebbe difficile adattarlo a una dimensione di contesto. Inoltre, queste informazioni tecniche potrebbero rendere il lavoro del modello un po’ più complicato.

Quindi, è abbastanza normale estrarre solo il testo dall’HTML e utilizzarlo per ulteriori analisi. Per farlo, puoi utilizzare il comando qui sotto. Come risultato, otterrai un oggetto Document dove il testo della pagina web si trova nel parametro page_content.

from langchain.document_loaders import BSHTMLLoaderloader = BSHTMLLoader("my_site.html")data = loader.load()

L’altro tipo di dato comunemente utilizzato è il PDF. Possiamo analizzare i PDF, ad esempio, utilizzando la libreria PyPDF. Carichiamo il testo del documento DALL-E 3.

from langchain.document_loaders import PyPDFLoaderloader = PyPDFLoader("https://cdn.openai.com/papers/DALL_E_3_System_Card.pdf")doc = loader.load()

In output, otterrai un insieme di Documenti – uno per ogni pagina. Nei metadati, sia i campi source che page saranno popolati.

Quindi, come puoi vedere, LangChain ti consente di lavorare con una vasta gamma di diversi tipi di documenti.

Torniamo al nostro compito iniziale. Nel nostro dataset, abbiamo un file .txt separato con i commenti dei clienti per ogni hotel. Dobbiamo analizzare tutti i file nella directory e metterli insieme. Possiamo usar DirectoryLoader per farlo.

da langchain.document_loaders import TextLoader, DirectoryLoader
text_loader_kwargs = {'autodetect_encoding': True}
loader = DirectoryLoader('./hotels/london', show_progress=True, loader_cls=TextLoader, loader_kwargs=text_loader_kwargs)
docs = loader.load()
len(docs) = 82

Ho usato anche 'autodetect_encoding': True poiché i nostri testi non sono codificati in standard UTF-8.

Come risultato, abbiamo ottenuto una lista di documenti, uno per ogni file di testo. Sappiamo che ogni documento è composto da singole recensioni dei clienti. Sarà più efficace per noi lavorare con frammenti più piccoli piuttosto che con tutti i commenti dei clienti per un hotel. Quindi, dobbiamo dividere i nostri documenti. Passiamo alla prossima fase e discutiamo la divisione dei documenti in dettaglio.

Dividere i documenti

Il prossimo passo è dividere i documenti. Potresti chiederti perché dobbiamo farlo. I documenti sono spesso lunghi e coprono più argomenti, ad esempio pagine di Confluence o documentazione. Se passiamo testi così lunghi ai LLM, potremmo incontrare problemi in cui il LLM è distolto da informazioni irrilevanti o i testi non si adattano alla dimensione del contesto.

Quindi, per lavorare efficacemente con i LLM, vale la pena definire le informazioni più rilevanti dalla nostra base di conoscenza (insieme di documenti) e passare solo queste informazioni al modello. Ecco perché dobbiamo dividere i nostri documenti in frammenti più piccoli.

La tecnica più comunemente utilizzata per i testi generali è la divisione ricorsiva per carattere. In LangChain, è implementata nella classe RecursiveCharacterTextSplitter.

Cerchiamo di capire come funziona. Prima di tutto, si definisce un elenco prioritizzato di caratteri per il divisore (per impostazione predefinita, è ["\n\n", "\n", " ", ""]). Quindi, il divisore passa attraverso questo elenco e cerca di dividere il documento per caratteri, uno per uno, fino a ottenere frammenti sufficientemente piccoli. Significa che questo approccio cerca di mantenere parti semanticamente vicine insieme (paragrafi, frasi, parole) fino a quando non dobbiamo dividerle per ottenere la dimensione del frammento desiderato.

Proviamo ad usare the Zen of Python per vedere come funziona. Ci sono 824 caratteri, 139 parole e 21 paragrafi in questo testo.

Puoi vedere the Zen of Python se esegui import this.

zen = '''Beautiful is better than ugly.Explicit is better than implicit.Simple is better than complex.Complex is better than complicated.Flat is better than nested.Sparse is better than dense.Readability counts.Special cases aren't special enough to break the rules.Although practicality beats purity.Errors should never pass silently.Unless explicitly silenced.In the face of ambiguity, refuse the temptation to guess.There should be one -- and preferably only one --obvious way to do it.Although that way may not be obvious at first unless you're Dutch.Now is better than never.Although never is often better than *right* now.If the implementation is hard to explain, it's a bad idea.If the implementation is easy to explain, it may be a good idea.Namespaces are one honking great idea -- let's do more of those!'''print('Numero di caratteri: %d' % len(zen))print('Numero di parole: %d' % len(zen.replace('\n', ' ').split(' ')))print('Numero di paragrafi: %d' % len(zen.split('\n')))# Numero di caratteri: 825# Numero di parole: 140# Numero di paragrafi: 21

Usiamo RecursiveCharacterTextSplitter e iniziamo con una dimensione del frammento relativamente grande pari a 300.

from langchain.text_splitter import RecursiveCharacterTextSplitter
text_splitter = RecursiveCharacterTextSplitter(chunk_size = 300, chunk_overlap = 0, length_function = len, is_separator_regex = False)
text_splitter.split_text(zen)

Otterremo tre pezzi: 264, 293 e 263 caratteri. Possiamo vedere che tutte le frasi sono tenute insieme.

Potresti notare un parametro chunk_overlap che potrebbe consentirti di dividere con sovrapposizione. È importante perché passeremo all’LLM alcuni frammenti con le nostre domande, ed è fondamentale avere abbastanza contesto per prendere decisioni basate solo sulle informazioni fornite in ogni frammento.

Schema di autore

Proviamo ad aggiungere chunk_overlap.

text_splitter = RecursiveCharacterTextSplitter(    chunk_size = 300,    chunk_overlap  = 100,    length_function = len,    is_separator_regex = False,)text_splitter.split_text(zen)

Ora, abbiamo quattro divisioni con 264, 232, 297 e 263 caratteri, e possiamo vedere che i nostri frammenti si sovrappongono.

Rendiamo la dimensione del frammento un po’ più piccola.

text_splitter = RecursiveCharacterTextSplitter(    chunk_size = 50,    chunk_overlap  = 10,    length_function = len,    is_separator_regex = False,)text_splitter.split_text(zen)

Ora, abbiamo persino dovuto suddividere alcune frasi più lunghe. Ecco come funziona la suddivisione ricorsiva: dato che dopo la suddivisione per paragrafi ("\n"), i frammenti non sono ancora abbastanza piccoli, lo splitter ha proceduto con " ".

Puoi personalizzare ulteriormente la suddivisione. Ad esempio, potresti specificare length_function = lambda x: len(x.split("\n")) per utilizzare il numero di paragrafi come lunghezza del frammento anziché il numero di caratteri. È anche abbastanza comune suddividere per token perché gli LLM hanno dimensioni di contesto limitate in base al numero di token.

L’altra personalizzazione potenziale è utilizzare altri separatori per preferire la suddivisione per "," invece di " ". Proviamo a usarlo con un paio di frasi.

text_splitter = RecursiveCharacterTextSplitter(    chunk_size = 50,    chunk_overlap  = 0,    length_function = len,    is_separator_regex = False,    separators=["\n\n", "\n", ", ", " ", ""])text_splitter.split_text('''\Se l'implementazione è difficile da spiegare, è una cattiva idea.Se l'implementazione è facile da spiegare, potrebbe essere una buona idea.''')

Funziona, ma le virgole non si trovano nei posti giusti.

Per risolvere questo problema, potremmo utilizzare una regexp con lookback come separatore.

text_splitter = RecursiveCharacterTextSplitter(    chunk_size = 50,    chunk_overlap  = 0,    length_function = len,    is_separator_regex = True,    separators=["\n\n", "\n", "(?<=\, )", " ", ""])text_splitter.split_text('''\Se l'implementazione è difficile da spiegare, è una cattiva idea.Se l'implementazione è facile da spiegare, potrebbe essere una buona idea.''')

Ora è risolto.

Inoltre, LangChain fornisce strumenti per lavorare con il codice in modo che i tuoi testi vengano suddivisi in base a separatori specifici dei linguaggi di programmazione.

Tuttavia, nel nostro caso, la situazione è più semplice. Sappiamo di avere commenti individuali indipendenti delimitati da "\n" in ogni file e dobbiamo solo suddividerli. Purtroppo, LangChain non supporta un caso d’uso così basilare, quindi dobbiamo fare un po’ di “hack” per farlo funzionare come vogliamo.

from langchain.text_splitter import CharacterTextSplittertext_splitter = CharacterTextSplitter(    separator = "\n",    chunk_size = 1,    chunk_overlap  = 0,    length_function = lambda x: 1, # hack - di solito viene utilizzato len     is_separator_regex = False)split_docs = text_splitter.split_documents(docs)len(split_docs) 12890

Puoi trovare ulteriori dettagli su perché abbiamo bisogno di un “hack” qui nel mio articolo precedente su LangChain.

La parte significativa dei documenti è costituita dai metadati, poiché possono fornire maggior contesto su da dove proviene questo frammento. Nel nostro caso, LangChain ha automaticamente popolato il parametro source per i metadati, in modo da sapere a quale hotel è riferito ogni commento.

Ci sono anche altri approcci (ad esempio per l’HTML o il Markdown) che aggiungono titoli ai metadati durante la suddivisione dei documenti. Questi metodi possono essere molto utili se stai lavorando con questi tipi di dati.

Archivi di vettori

Ora abbiamo i testi dei commenti e il passo successivo è imparare come archiviarli in modo efficace in modo da poter ottenere documenti pertinenti alle nostre domande.

Potremmo memorizzare i commenti come stringhe, ma non ci aiuterà a risolvere questo compito: non saremo in grado di filtrare le recensioni dei clienti pertinenti alla domanda. Una soluzione molto più funzionale è archiviare le embedding dei documenti.

Le embedding sono vettori ad alta dimensionalità. Le embedding catturano significati semantici e relazioni tra parole e frasi in modo che i testi semanticamente simili abbiano una distanza più piccola tra di loro.

Utilizzeremo OpenAI Embeddings poiché sono piuttosto popolari. OpenAI consiglia di utilizzare il modello text-embedding-ada-002 poiché ha migliori prestazioni, un contesto più esteso e un prezzo più basso. Come al solito, ha anche i suoi rischi e limitazioni: potenziale pregiudizio sociale e conoscenza limitata sugli eventi recenti.

Prova ad utilizzare le Embedding su esempi di gioco per vedere come funziona.

from langchain.embeddings.openai import OpenAIEmbeddingsembedding = OpenAIEmbeddings()text1 = 'La nostra camera (standard) era molto pulita e ampia.'text2 = 'Il tempo a Londra era meraviglioso.'text3 = 'La camera che avevo era in realtà più grande di quelle trovate negli altri hotel della zona, ed era molto ben arredata.'emb1 = embedding.embed_query(text1)emb2 = embedding.embed_query(text2)emb3 = embedding.embed_query(text3)print('''Distanza 1 -> 2: %.2fDistanza 1 -> 3: %.2fDistanza 2 -> 3: %.2f''' % (np.dot(emb1, emb2), np.dot(emb1, emb3), np.dot(emb2, emb3)))

Possiamo usare np.dot come similarità coseno perché le embedding di OpenAI sono già normalizzate.

Possiamo vedere che il primo e il terzo vettore sono vicini tra loro, mentre il secondo è diverso. La prima e la terza frase hanno significati semantici simili (riguardano entrambe la dimensione della camera), mentre la seconda frase non è vicina, perché parla del tempo. Quindi, le distanze tra le embedding riflettono effettivamente la similarità semantica tra i testi.

Ora sappiamo come convertire i commenti in vettori numerici. La domanda successiva è come dovremmo archiviarli in modo che questi dati siano facilmente accessibili.

Pensiamo al nostro caso d’uso. Il nostro flusso sarà:

  • prendere una domanda,
  • calcolarne l’embedding,
  • trovare gli snippet di documenti più rilevanti relativi a questa domanda (quelli con la distanza più piccola rispetto a questo embedding),
  • infine, passare gli snippet trovati a LLM come contesto insieme alla domanda iniziale.

Il compito regolare per l’archiviazione dei dati sarà trovare i K vettori più vicini (i K documenti più rilevanti). Quindi, dovremo calcolare la distanza (nel nostro caso, Similarità Cosine) tra l’embedding della nostra domanda e tutti i vettori che abbiamo.

I database generici (come Snowflake o Postgres) avranno prestazioni scarse per questo tipo di compito. Ma ci sono database ottimizzati, appositamente per questo caso d’uso, i database di vettori.

Utilizzeremo un database di embedding open source, Chroma. Chroma è un database leggero in memoria, quindi è ideale per il prototipo. Puoi trovare molte altre opzioni per i database di vettori qui.

Prima di tutto, dobbiamo installare Chroma usando pip.

pip install chromadb

Utilizzeremo persist_directory per archiviare i nostri dati localmente e ricaricarli da disco.

from langchain.vectorstores import Chromapersist_directory = 'vector_store'vectordb = Chroma.from_documents(    documents=split_docs,    embedding=embedding,    persist_directory=persist_directory)

Per poter caricare i dati da disco quando ne hai bisogno la prossima volta, esegui il seguente comando.

embedding = OpenAIEmbeddings()vectordb = Chroma(    persist_directory=persist_directory,    embedding_function=embedding)

La inizializzazione del database potrebbe richiedere un paio di minuti poiché Chroma deve caricare tutti i documenti e ottenere i rispettivi embedding usando l’API di OpenAI.

Possiamo vedere che tutti i documenti sono stati caricati.

print(vectordb._collection.count())12890

Ora, potremmo usare la ricerca di similarità per trovare i migliori commenti dei clienti sulla cortesia del personale.

query_docs = vectordb.similarity_search('cortesia del personale', k=3)

I documenti sembrano abbastanza rilevanti alla domanda.

Abbiamo archiviato i commenti dei nostri clienti in un modo accessibile, ed è ora di discutere in dettaglio il recupero dei dati.

Recupero

Abbiamo già usato vectordb.similarity_search per recuperare gli snippet più correlati alla domanda. Nella maggior parte dei casi, questo approccio funzionerà, ma potrebbero esserci alcune sfumature:

  • Mancanza di diversità – Il modello potrebbe restituire testi estremamente simili (addirittura duplicati), che non aggiungono molte nuove informazioni a LLM.
  • Non tenere conto dei metadatisimilarity_search non tiene conto delle informazioni sui metadati che abbiamo. Ad esempio, se cerco i primi 5 commenti per la domanda “colazione a Travelodge Farringdon”, solo tre commenti nel risultato avranno la fonte uguale a uk_england_london_travelodge_london_farringdon.
  • Limitazione della dimensione del contesto – come al solito, abbiamo una dimensione del contesto LLM limitata e dobbiamo adattare i nostri documenti ad essa.

Discutiamo delle tecniche che potrebbero aiutarci a risolvere questi problemi.

Risolvere il problema della diversità – MMR (Maximum Marginal Relevance)

La ricerca di similarità restituisce le risposte più vicine alla tua domanda. Ma per fornire tutte le informazioni al modello, potresti non voler concentrarti solo sui testi più simili. Ad esempio, per la domanda “colazione a Travelodge Farringdon”, le prime cinque recensioni dei clienti potrebbero riguardare il caffè. Se ci concentriamo solo su di esse, ci perderemo altri commenti che menzionano le uova o il comportamento del personale, ottenendo una visione limitata del feedback dei clienti.

Possiamo utilizzare l’approccio MMR (Margine Massimo di Rilevanza) per aumentare la diversità dei commenti dei clienti. Funziona in modo abbastanza semplice:

  • Prima di tutto, otteniamo fetch_k i documenti più simili alla domanda utilizzando similarity_search.
  • Quindi, selezioniamo k i più diversi tra di essi.
Schema dell'autore

Se vogliamo utilizzare MMR, dovremmo utilizzare max_marginal_relevance_search invece di similarity_search e specificare il numero di fetch_k. È meglio mantenere fetch_k relativamente basso in modo da non ottenere risposte non pertinenti in output. E questo è tutto.

query_docs = vectordb.max_marginal_relevance_search('cortesia del personale',     k = 3, fetch_k = 30)

Guardiamo gli esempi per la stessa query. Questa volta abbiamo ottenuto feedback più diversificato. C’è persino un commento con sentiment negativo.

Affrontando la specificità – Recupero assistito da LLM

L’altro problema è che non prendiamo in considerazione i metadati durante il recupero dei documenti. Per risolvere il problema, possiamo chiedere a LLM di suddividere la domanda iniziale in due parti:

  • Filtro semantico basato sui testi dei documenti,
  • Filtro basato sui metadati che abbiamo.

Questo approccio è chiamato “Self Querying”.

Prima di tutto, aggiungiamo un filtro manuale specificando un parametro source con il nome del file relativo all’hotel Travelodge Farringdon.

query_docs = vectordb.similarity_search('colazione a Travelodge Farrigdon',   k=5,  filter = {'source': 'hotels/london/uk_england_london_travelodge_london_farringdon'})

Ora, proviamo a utilizzare LLM per creare automaticamente un tale filtro. Dobbiamo descrivere tutti i nostri parametri di metadati in dettaglio e quindi utilizzare SelfQueryRetriever.

from langchain.llms import OpenAI
from langchain.retrievers.self_query.base import SelfQueryRetriever
from langchain.chains.query_constructor.base import AttributeInfo

metadata_field_info = [
    AttributeInfo(
        name="source",
        description="Tutte le sorgenti iniziano con 'hotels/london/uk_england_london_' \          poi segue la catena di hotel, poi 'london_' e infine la località.",
        type="string",
    )
]
document_content_description = "Recensioni dei clienti per hotel"
llm = OpenAI(temperature=0.1) # bassa temperatura per rendere il modello più fattuale
# di default viene utilizzato 'text-davinci-003'
retriever = SelfQueryRetriever.from_llm(
    llm,
    vectordb,
    document_content_description,
    metadata_field_info,
    verbose=True
)
question = "colazione a Travelodge Farringdon"
docs = retriever.get_relevant_documents(question, k = 5)

Il nostro caso è complicato poiché il parametro source nei metadati è composto da più campi: paese, città, catena di hotel e località. È meglio suddividere tali parametri complessi in parametri più specifici in situazioni del genere, in modo che il modello possa comprendere facilmente come utilizzare i filtri dei metadati.

Tuttavia, con un prompt dettagliato, ha funzionato ed ha restituito solo documenti relativi a Travelodge Farringdon. Ma devo confessare che ci sono volute diverse iterazioni per ottenere questo risultato.

Passiamo alla modalità di debug e vediamo come funziona. Per entrare in modalità di debug, è sufficiente eseguire il seguente codice.

import langchain
langchain.debug = True

Il prompt completo è piuttosto lungo, quindi consideriamo le parti principali. Ecco l’inizio del prompt, che dà al modello una panoramica di ciò che ci aspettiamo e dei criteri principali per il risultato.

Poi, viene utilizzata la tecnica di prompt in few-shot, e al modello vengono forniti due esempi di input e output attesi. Ecco uno degli esempi.

Non stiamo usando un modello di chat come ChatGPT ma un LLM generale (non addestrato sulle istruzioni). È addestrato solo per prevedere i token successivi per il testo. Ecco perché abbiamo concluso il nostro prompt con la nostra domanda e la stringa Structured output: aspettando che il modello fornisca la risposta.

Come risultato, abbiamo ottenuto dal modello la domanda iniziale divisa in due parti: una semantica (colazione) e i filtri dei metadati (source = hotels/london/uk_england_london_travelodge_london_farringdon)

Poi, abbiamo utilizzato questa logica per recuperare i documenti dal nostro archivio di vettori e ottenuto solo i documenti necessari.

Risolvere i limiti di dimensione – Compressione

L’altra tecnica per il recupero che potrebbe essere utile è la compressione. Anche se GPT 4 Turbo ha una dimensione di contesto di 128K token, è comunque limitato. Ecco perché potremmo voler preprocessare i documenti ed estrarre solo le parti rilevanti.

I principali vantaggi sono:

  • Sarai in grado di inserire più documenti e informazioni nel prompt finale poiché saranno condensati.
  • Avrai risultati migliori e più focalizzati perché il contesto non rilevante verrà eliminato durante la preprocessazione.

Questi vantaggi comportano un costo: avrai più chiamate a LLM per la compressione, il che significa una velocità più lenta e un prezzo più alto.

Puoi trovare ulteriori informazioni su questa tecnica in the docs.

Scheme by author

In realtà, possiamo anche combinare le tecniche e utilizzare MMR qui. Abbiamo usato ContextualCompressionRetriever per ottenere i risultati. Inoltre, abbiamo specificato che vogliamo solo tre documenti in restituzione.

from langchain.retrievers import ContextualCompressionRetrieverfrom langchain.retrievers.document_compressors import LLMChainExtractorllm = OpenAI(temperature=0)compressor = LLMChainExtractor.from_llm(llm)compression_retriever = ContextualCompressionRetriever(    base_compressor=compressor,    base_retriever=vectordb.as_retriever(search_type = "mmr",        search_kwargs={"k": 3}))question = "colazione a Travelodge Farringdon"compressed_docs = compression_retriever.get_relevant_documents(question)

Come al solito, capire come funziona sotto il cofano è la parte più eccitante. Se guardiamo le chiamate effettive, ci sono tre chiamate a LLM per estrarre solo informazioni rilevanti dal testo. Ecco un esempio.

Nell’output, abbiamo ottenuto solo una parte della frase relativa alla colazione, quindi la compressione aiuta.

Esistono molti approcci vantaggiosi per il recupero, ad esempio, tecniche dall’NLP classico: SVM o TF-IDF. Diversi retriever potrebbero essere utili in diverse situazioni, quindi ti consiglio di confrontare diverse versioni per il tuo compito e selezionare quella più adatta per il tuo caso d’uso.

Generazione

Finalmente siamo arrivati all’ultima fase: combineremo tutto e genereremo la risposta finale.

Ecco uno schema su come funzionerà tutto:

  • riceviamo una domanda da un utente,
  • recuperiamo i documenti pertinenti per questa domanda dallo store vettoriale utilizzando gli embedding,
  • passiamo la domanda iniziale insieme ai documenti recuperati all’LLM e otteniamo la risposta finale.
Schema dell'autore

In LangChain, potremmo utilizzare la catena RetrievalQA per implementare rapidamente questo flusso.

from langchain.chains import RetrievalQAfrom langchain.chat_models import ChatOpenAIllm = ChatOpenAI(model_name='gpt-4', temperature=0.1)qa_chain = RetrievalQA.from_chain_type(    llm,    retriever=vectordb.as_retriever(search_kwargs={"k": 3}))result = qa_chain({"query": "cosa piace ai clienti dello staff dell'hotel?"})

Diamo un’occhiata alla chiamata a ChatGPT. Come puoi vedere, abbiamo passato i documenti recuperati insieme alla query dell’utente.

Ecco un output del modello.

Possiamo personalizzare il comportamento del modello, modificando il prompt. Ad esempio, potremmo chiedere al modello di essere più conciso.

from langchain.prompts import PromptTemplatetemplate = """Utilizza i seguenti elementi di contesto per rispondere alla domanda alla fine. Se non conosci la risposta, dì semplicemente che non lo sai, non cercare di inventare una risposta. Sii il più conciso possibile nella risposta. Usa una frase per riassumere tutti i punti.______________{context}Domanda: {question}Risposta utile:"""QA_CHAIN_PROMPT = PromptTemplate.from_template(template)qa_chain = RetrievalQA.from_chain_type(    llm,    retriever=vectordb.as_retriever(),    return_source_documents=True,    chain_type_kwargs={"prompt": QA_CHAIN_PROMPT})result = qa_chain({"query": "cosa piace ai clienti dello staff dell'hotel?"})

Questa volta abbiamo ottenuto una risposta molto più breve. Inoltre, poiché abbiamo specificato return_source_documents=True, abbiamo ottenuto un insieme di documenti in risposta. Potrebbe essere utile per il debug.

Come abbiamo visto, tutti i documenti recuperati vengono combinati in un unico prompt per impostazione predefinita. Questo approccio è eccellente e semplice poiché richiama solo una chiamata all’LLM. L’unico limite è che i tuoi documenti devono adattarsi alla dimensione del contesto. Se non lo fanno, è necessario applicare tecniche più complesse.

Diamo un’occhiata a diversi tipi di catene che potrebbero consentirci di lavorare con un numero qualsiasi di documenti. Il primo è MapReduce.

Questo approccio è simile al classico MapReduce: generiamo risposte basate su ciascun documento recuperato (fase di mappatura) e quindi combiniamo queste risposte in quella finale (fase di riduzione).

Schema dell'autore

I limiti di tutti questi approcci sono costo e velocità. Invece di una chiamata all’LLM, devi fare una chiamata per ogni documento recuperato.

Per quanto riguarda il codice, è sufficiente specificare chain_type="map_reduce" per cambiare il comportamento.

qa_chain_mr = RetrievalQA.from_chain_type(    llm,    retriever=vectordb.as_retriever(),    chain_type="map_reduce")result = qa_chain_mr({"query": "cosa piace ai clienti dello staff dell'hotel?"})

Nel risultato, abbiamo ottenuto il seguente output.

Vediamo come funziona utilizzando la modalità di debug. Poiché si tratta di una MapReduce, abbiamo prima inviato ogni documento a LLM e ottenuto la risposta basata su questo frammento. Ecco un esempio di prompt per uno dei frammenti.

Successivamente, combiniamo tutti i risultati e chiediamo a LLM di fornire la risposta finale.

Ecco tutto.

C’è un altro svantaggio specifico dell’approccio MapReduce. Il modello vede ogni documento separatamente e non li ha tutti nello stesso contesto, il che potrebbe portare a risultati peggiori.

Possiamo superare questo svantaggio con il tipo di catena “Refine”. Quindi, esamineremo i documenti in sequenza e permetteremo al modello di affinare la risposta in ogni iterazione.

Schema di autore

Di nuovo, dobbiamo solo cambiare chain_type per testare un altro approccio.

qa_chain_refine = RetrievalQA.from_chain_type(    llm,    retriever=vectordb.as_retriever(),    chain_type="refine")result = qa_chain_refine({"query": "cosa piace ai clienti dello staff dell'hotel?"})

Con la catena “Refine”, otteniamo una risposta un po’ più dettagliata e completa.

Vediamo come funziona utilizzando il debug. Per il primo frammento, stiamo partendo da zero.

Quindi, passiamo la risposta attuale e un nuovo frammento e diamo al modello la possibilità di affinare la sua risposta.

Successivamente, ripetiamo il prompt di affinamento per ciascun documento recuperato rimanente e otteniamo il risultato finale.

Questo è tutto quello che volevo dirti oggi. Facciamo un breve riassunto.

Riassunto

In questo articolo, abbiamo attraversato l’intero processo di generazione migliorata dalla ricerca:

  • Abbiamo esaminato diversi caricatori di dati.
  • Abbiamo discusso possibili approcci alla suddivisione dei dati e alle loro potenziali sfumature.
  • Abbiamo appreso cosa sono le rappresentazioni incorporate e configurato un archivio vettoriale per accedere efficacemente ai dati.
  • Abbiamo trovato diverse soluzioni per le problematiche di recupero e appreso come aumentare la diversità, superare i limiti delle dimensioni del contesto e utilizzare i metadati.
  • Infine, abbiamo utilizzato la catena RetrievalQA per generare la risposta basata sui nostri dati e confrontato diversi tipi di catena.

Queste conoscenze dovrebbero essere sufficienti per iniziare a costruire qualcosa di simile con i tuoi dati.

Grazie mille per aver letto questo articolo. Spero che sia stato illuminante per te. Se hai ulteriori domande o commenti, ti prego di lasciarli nella sezione dei commenti.

Dataset

Ganesan, Kavita e Zhai, ChengXiang. (2011). OpinRank Review Dataset. UCI Machine Learning Repository (CC BY 4.0). https://doi.org/10.24432/C5QW4W

Riferimenti

Questo articolo si basa sulle informazioni dai corsi: