RAG avanzato 01: Recupero da piccolo a grande |  di Sophia Yang, Ph.D.  |  Novembre 2023

 | Intelligenza-Artificiale

Child-Parent RecursiveRetriever e recupero della finestra delle frasi con LlamaIndex

I sistemi RAG (Retrieval-Augmented Generation) recuperano informazioni rilevanti da una determinata base di conoscenza, consentendole così di generare informazioni fattuali, contestualmente rilevanti e specifiche del dominio. Tuttavia, RAG deve affrontare molte sfide quando si tratta di recuperare in modo efficace informazioni rilevanti e generare risposte di alta qualità. In questa serie di post/video sul blog, illustrerò le tecniche RAG avanzate volte a ottimizzare il flusso di lavoro RAG e ad affrontare le sfide dei sistemi RAG ingenui.

La prima tecnica si chiama recupero dal piccolo al grande. Nelle pipeline RAG di base, incorporiamo una grande porzione di testo per il recupero e questa stessa porzione di testo viene utilizzata per la sintesi. Ma a volte l’incorporamento/recupero di grandi porzioni di testo può sembrare non ottimale. Potrebbe esserci molto testo di riempimento in una grande porzione di testo che nasconde la rappresentazione semantica, portando a un recupero peggiore. E se potessimo incorporare/recuperare in base a blocchi più piccoli e più mirati, ma avere comunque un contesto sufficiente affinché LLM possa sintetizzare una risposta? Nello specifico, potrebbe essere vantaggioso disaccoppiare i blocchi di testo utilizzati per il recupero rispetto ai blocchi di testo utilizzati per la sintesi. L’utilizzo di porzioni di testo più piccole migliora la precisione del recupero, mentre porzioni di testo più grandi offrono informazioni più contestuali. Il concetto alla base del recupero da piccolo a grande è quello di utilizzare porzioni di testo più piccole durante il processo di recupero e successivamente fornire la porzione di testo più grande a cui appartiene il testo recuperato nel modello linguistico di grandi dimensioni.

Esistono due tecniche principali:

  1. Pezzi figli più piccoli che si riferiscono a pezzi genitori più grandi: recupera prima i blocchi più piccoli durante il recupero, quindi fa riferimento agli ID principali e restituisce i blocchi più grandi.
  2. Recupero dalla finestra della frase: Recupera una singola frase durante il recupero e restituisce una finestra di testo attorno alla frase.

In questo post del blog approfondiremo le implementazioni di questi due metodi in LlamaIndex. Perché non lo faccio in LangChain? Perché ci sono già molte risorse disponibili su RAG avanzato con LangChain. Preferirei non duplicare lo sforzo. Inoltre, utilizzo sia LangChain che LlamaIndex. È meglio comprendere più strumenti e utilizzarli in modo flessibile.

Puoi trovare tutto il codice in questo taccuino.

Iniziamo con un’implementazione RAG di base con 4 semplici passaggi:

Passaggio 1. Caricamento dei documenti

Utilizziamo un PDFReader per caricare un file PDF e combiniamo ciascuna pagina del documento in un unico oggetto Document.

loader = PDFReader()
docs0 = loader.load_data(file=Path("llama2.pdf"))
doc_text = "\n\n".join((d.get_content() for d in docs0))
docs = (Document(text=doc_text))

Passaggio 2. Analisi dei documenti in blocchi di testo (nodi)

Quindi dividiamo il documento in blocchi di testo, che sono chiamati “Nodi” in LlamaIndex, dove definiamo la dimensione del mandrino come 1024. Gli ID nodo predefiniti sono stringhe di testo casuali, possiamo quindi formattare il nostro ID nodo per seguire un determinato formato.

node_parser = SimpleNodeParser.from_defaults(chunk_size=1024)
base_nodes = node_parser.get_nodes_from_documents(docs)
for idx, node in enumerate(base_nodes):
node.id_ = f"node-{idx}"

Passaggio 3. Selezionare Modello di incorporamento e LLM

Dobbiamo definire due modelli:

  • Il modello di incorporamento viene utilizzato per creare incorporamenti vettoriali per ciascuno dei blocchi di testo. Qui stiamo chiamando il Incorporamento flag modello di Hugging Face.
  • LLM: la query dell’utente e i relativi blocchi di testo vengono inseriti nel LLM in modo che possa generare risposte con il contesto pertinente.

Possiamo raggruppare questi due modelli insieme nel ServiceContext e utilizzarli successivamente nei passaggi di indicizzazione e query.

embed_model = resolve_embed_model(“local:BAAI/bge-small-en”)
llm = OpenAI(model="gpt-3.5-turbo")
service_context = ServiceContext.from_defaults(llm=llm, embed_model=embed_model)

Passaggio 4. Creare indice, retriever e motore di query

Indice, retriever e motore di query sono tre componenti fondamentali per porre domande sui tuoi dati o documenti:

  • L’indice è una struttura dati che ci consente di recuperare rapidamente informazioni rilevanti per una query dell’utente da documenti esterni. Il Vector Store Index prende i blocchi di testo/nodi e quindi crea incorporamenti vettoriali del testo di ogni nodo, pronti per essere interrogati da un LLM.
base_index = VectorStoreIndex(base_nodes, service_context=service_context)
  • Retriever viene utilizzato per recuperare informazioni rilevanti in base alla query dell’utente.
base_retriever = base_index.as_retriever(similarity_top_k=2)
  • Il motore di query è basato sull’indice e sul retriever e fornisce un’interfaccia generica per porre domande sui tuoi dati.
query_engine_base = RetrieverQueryEngine.from_args(
base_retriever, service_context=service_context
)
response = query_engine_base.query(
"Can you tell me about the key concepts for safety finetuning"
)
print(str(response))

Nella sezione precedente abbiamo utilizzato una dimensione fissa di blocco pari a 1024 sia per il recupero che per la sintesi. In questa sezione esploreremo come utilizzare blocchi figlio più piccoli per il recupero e fare riferimento a blocchi genitore più grandi per la sintesi. Il primo passo è creare blocchi figlio più piccoli:

Passaggio 1: crea blocchi figlio più piccoli

Per ciascuno dei blocchi di testo con dimensione 1024, creiamo blocchi di testo ancora più piccoli:

  • 8 blocchi di testo di dimensione 128
  • 4 blocchi di testo di dimensione 256
  • 2 blocchi di testo di dimensione 512

Aggiungiamo il pezzo di testo originale di dimensione 1024 all’elenco dei pezzi di testo.

sub_chunk_sizes = (128, 256, 512)
sub_node_parsers = (
SimpleNodeParser.from_defaults(chunk_size=c) for c in sub_chunk_sizes
)

all_nodes = ()
for base_node in base_nodes:
for n in sub_node_parsers:
sub_nodes = n.get_nodes_from_documents((base_node))
sub_inodes = (
IndexNode.from_text_node(sn, base_node.node_id) for sn in sub_nodes
)
all_nodes.extend(sub_inodes)

# also add original node to node
original_node = IndexNode.from_text_node(base_node, base_node.node_id)
all_nodes.append(original_node)
all_nodes_dict = {n.node_id: n for n in all_nodes}

Quando diamo un’occhiata a tutti i blocchi di testo “all_nodes_dict”, possiamo vedere che molti blocchi più piccoli sono associati a ciascuno dei blocchi di testo originali, ad esempio “node-0”. In effetti, tutti i blocchi più piccoli fanno riferimento al blocco più grande nei metadati con index_id che punta all’ID dell’indice del blocco più grande.

Passaggio 2: crea indice, retriever e motore di query

  • Indice: crea incorporamenti vettoriali di tutti i blocchi di testo.
vector_index_chunk = VectorStoreIndex(
all_nodes, service_context=service_context
)
  • Retriever: la chiave qui è usare a RecursiveRetriever per attraversare le relazioni dei nodi e recuperare i nodi in base a “riferimenti”. Questo retriever esplorerà ricorsivamente i collegamenti dai nodi ad altri retriever/motori di query. Per qualsiasi nodo recuperato, se uno qualsiasi dei nodi è IndexNode, esplorerà il motore di recupero/query collegato e lo interrogherà.
vector_retriever_chunk = vector_index_chunk.as_retriever(similarity_top_k=2)
retriever_chunk = RecursiveRetriever(
"vector",
retriever_dict={"vector": vector_retriever_chunk},
node_dict=all_nodes_dict,
verbose=True,
)

Quando poniamo una domanda e recuperiamo i blocchi di testo più rilevanti, in realtà recupererà il blocco di testo con l’ID del nodo che punta al blocco principale e quindi recupererà il blocco principale.

  • Ora, con gli stessi passaggi di prima, possiamo creare un motore di query come interfaccia generica per porre domande sui nostri dati.
query_engine_chunk = RetrieverQueryEngine.from_args(
retriever_chunk, service_context=service_context
)
response = query_engine_chunk.query(
"Can you tell me about the key concepts for safety finetuning"
)
print(str(response))

Per ottenere un recupero ancora più dettagliato, invece di utilizzare blocchi figlio più piccoli, possiamo analizzare i documenti in un’unica frase per blocco.

In questo caso, le singole frasi saranno simili al concetto di pezzo “figlio” menzionato nel metodo 1. La “finestra” della frase (5 frasi su entrambi i lati della frase originale) sarà simile al concetto di pezzo “genitore”. In altre parole, utilizziamo le singole frasi durante il recupero e passiamo la frase recuperata con la finestra delle frasi al LLM.

Passaggio 1: creare un parser del nodo della finestra della frase

# create the sentence window node parser w/ default settings
node_parser = SentenceWindowNodeParser.from_defaults(
window_size=3,
window_metadata_key="window",
original_text_metadata_key="original_text",
)
sentence_nodes = node_parser.get_nodes_from_documents(docs)
sentence_index = VectorStoreIndex(sentence_nodes, service_context=service_context)

Passaggio 2: crea il motore di query

Quando creiamo il motore di query, possiamo sostituire la frase con la finestra della frase utilizzando MetadataReplacementPostProcessor, in modo che la finestra delle frasi venga inviata al LLM.

query_engine = sentence_index.as_query_engine(
similarity_top_k=2,
# the target key defaults to `window` to match the node_parser's default
node_postprocessors=(
MetadataReplacementPostProcessor(target_metadata_key="window")
),
)
window_response = query_engine.query(
"Can you tell me about the key concepts for safety finetuning"
)
print(window_response)

Il Sentence Window Retrieval è stato in grado di rispondere alla domanda “Puoi parlarmi dei concetti chiave per la messa a punto della sicurezza”:

Qui puoi vedere la frase effettiva recuperata e la finestra della frase, che fornisce più contesto e dettagli.

In questo blog, abbiamo esplorato come utilizzare il recupero da piccolo a grande per migliorare RAG, concentrandoci su Child-Parent RecursiveRetriever e Sentence Window Retrieval con LlamaIndex. Nei futuri post del blog, approfondiremo altri trucchi e suggerimenti. Resta sintonizzato per saperne di più su questo entusiasmante viaggio nelle tecniche RAG avanzate!

. . .

Di Sofia Yang il 4 novembre 2023

Connettiti con me su LinkedIn, TwitterE Youtube e unisciti al DS/ML Club del libro ❤️

Fonte: towardsdatascience.com

Lascia un commento

Il tuo indirizzo email non sarà pubblicato. I campi obbligatori sono contrassegnati *