Réponse évolutive aux questions sur de grands documents avec LangChain et Vertex AI PaLM
Published on
Introduction à la réponse aux questions sur de grands documents avec LLMs
La réponse aux questions (QA) est une tâche clé du traitement du langage naturel qui vise à répondre automatiquement aux questions posées par les humains en langage naturel. Bien que les grands modèles de langage (LLM) comme PaLM aient montré d'impressionnantes capacités de réponse aux questions, ils sont limités par la quantité de contexte pouvant tenir dans leur limite de jetons (généralement quelques milliers de jetons). Cela pose un défi pour la réponse aux questions sur de grands documents pouvant s'étendre sur plusieurs pages.
Dans cet article, nous explorerons comment construire un système de QA évolutif pour les grands documents en combinant le framework LangChain avec l'API Vertex AI PaLM de Google. Nous aborderons plusieurs méthodes, notamment :
- Stuffing - Pousser le document complet en tant que contexte
- Map-Reduce - Diviser les documents en morceaux et les traiter de manière parallèle
- Affiner - Raffiner itérativement une réponse sur des morceaux de document
- Recherche de similarité - Utiliser des embeddings vectoriels pour trouver des morceaux pertinents
Nous comparerons les points forts et les limites de chaque approche. Le code complet est disponible dans ce bloc-notes Colab.
Voyons comparer les métriques pour chaque méthode sur notre document d'échantillon de 50 pages :
Méthode | Documents pertinents | Appels LLM | Total des jetons | Qualité de la réponse |
---|---|---|---|---|
Stuffing | 3 pages | 1 | 8432 | Bonne |
Map-Reduce | 50 pages | 51 | 63019 | Correcte |
Affiner | 50 pages | 50 | 71209 | Bonne |
Recherche de similarité | 4 pages | 5 | 5194 | Grande |
L'approche de recherche de similarité est capable de trouver une réponse de haute qualité avec 10 fois moins de pages, d'appels LLM et de jetons par rapport aux méthodes utilisant le document complet. Cet écart se creuserait encore plus sur des ensembles de données plus grands.
Étape 1. Configuration de LangChain pour la réponse aux questions sur de grands documents
Tout d'abord, installez les dépendances requises, y compris le SDK Vertex AI, LangChain et ChromaDB :
!pip install google-cloud-aiplatform langchain==0.0.323 chromadb==0.3.26 pypdf
Importez les bibliothèques clés :
from langchain.document_loaders import PyPDFLoader
from langchain.llms import VertexAI
from langchain.chains.question_answering import load_qa_chain
from langchain.text_splitter import CharacterTextSplitter
from langchain.vectorstores import Chroma
from langchain.embeddings import VertexAIEmbeddings
Chargez le modèle de texte PaLM et le modèle d'embeddings :
vertex_llm_text = VertexAI(model_name="text-bison@001")
vertex_embeddings = VertexAIEmbeddings(model_name="textembedding-gecko@001")
Étape 2. Chargement des documents
Pour cet exemple, nous utiliserons un livre blanc PDF sur MLOps. Téléchargez-le et chargez le texte à l'aide de PyPDFLoader :
pdf_url = "https://services.google.com/fh/files/misc/practitioners_guide_to_mlops_whitepaper.pdf"
pdf_loader = PyPDFLoader(pdf_file)
pages = pdf_loader.load_and_split()
Cela divise le PDF en pages que nous pouvons utiliser comme documents de base.
Étape 3. Remplissage des documents
L'approche la plus simple consiste à insérer le texte intégral du document dans la fenêtre de contexte du LLM. Configurez un modèle de prompt :
prompt_template = """Répondez à la question aussi précisément que possible en utilisant le contexte fourni.
Si la réponse ne se trouve pas dans le contexte, indiquez "réponse non disponible dans le contexte" \n\n
Contexte : \n {contexte}?\n
Question : \n {question} \n
Réponse :
"""
prompt = PromptTemplate(
template=prompt_template, input_variables=["contexte", "question"]
)
Chargez une chaîne de question/réponse :
stuff_chain = load_qa_chain(vertex_llm_text, chain_type="stuff", prompt=prompt)
Ensuite, exécutez-la sur une question :
question = "Qu'est-ce que l'expérimentation ?"
contexte = "\n".join(str(p.page_content) for p in pages[:7])
stuff_answer = stuff_chain(
{"input_documents": pages[7:10], "question": question}, return_only_outputs=True
)
Cela fonctionne, mais cela est limité par la taille du contexte que le modèle peut gérer (quelques milliers de jetons). Le remplissage du document complet de 50 pages atteint cette limite :
try:
print(stuff_chain(
{"input_documents": pages[7:], "question": question},
return_only_outputs=True))
except Exception as e:
print("The code failed since it won't be able to run inference on such a huge context")
Étape 4. Map-Reduce
Pour étendre aux documents plus volumineux, nous pouvons les diviser en morceaux, exécuter la réponse aux questions sur chaque morceau, puis agréger les résultats. LangChain fournit une chaîne de map-reduce pour gérer cela.
Tout d'abord, définissez des prompts séparés pour la question et la combinaison :
question_prompt_template = """
Répondez à la question aussi précisément que possible en utilisant le contexte fourni. \n\n
Contexte : \n {contexte} \n
Question : \n {question} \n
Réponse :
"""
question_prompt = PromptTemplate(
template=question_prompt_template, input_variables=["contexte", "question"]
)
combine_prompt_template = """Étant donné le contenu extrait et la question, créez une réponse finale.
Si la réponse ne se trouve pas dans le contexte, indiquez "réponse non disponible dans le contexte". \n\n
Résumés : \n {résumés}?\n
Question : \n {question} \n
Réponse :
"""
combine_prompt = PromptTemplate(
template=combine_prompt_template, input_variables=["résumés", "question"]
)
Chargez la chaîne de map-reduce en spécifiant les prompts de question et de combinaison :
map_reduce_chain = load_qa_chain(
vertex_llm_text,
chain_type="map_reduce",
return_intermediate_steps=True,
question_prompt=question_prompt,
combine_prompt=combine_prompt,
)
map_reduce_outputs = map_reduce_chain({"input_documents": pages, "question": question})
Cela exécute QA sur chaque page individuellement, puis combine les résultats dans une dernière étape. Nous pouvons inspecter les résultats intermédiaires :
for doc, out in zip(
map_reduce_outputs["input_documents"], map_reduce_outputs["intermediate_steps"]
):
print(f"Page : {doc.metadata['page']}")
print(f"Réponse : {out}")
L'approche map-reduce est scalable à de grands documents et fournit un aperçu de l'origine des informations. Cependant, certaines informations peuvent parfois être perdues lors de l'étape finale de combinaison.
Étape 5. Affiner
L'approche d'affinement vise à atténuer la perte d'informations en affinant itérativement une réponse. Elle commence par une réponse initiale sur le premier fragment, puis l'affine avec chaque fragment ultérieur.
Définissez une invite d'affinement qui intègre la réponse existante et un nouveau contexte :
refine_prompt_template = """
La question d'origine est : \n {question} \n
La réponse fournie est : \n {existing_answer}\n
Affinez la réponse existante si nécessaire avec le contexte suivant : \n {context_str} \n
Étant donné le contenu extrait et la question, créez une réponse finale.
Si la réponse ne se trouve pas dans le contexte, indiquez "réponse non disponible dans le contexte". \n\n
"""
refine_prompt = PromptTemplate(
input_variables=["question", "existing_answer", "context_str"],
template=refine_prompt_template,
)
Chargez une chaîne d'affinement :
refine_chain = load_qa_chain(
vertex_llm_text,
chain_type="refine",
return_intermediate_steps=True,
question_prompt=initial_question_prompt,
refine_prompt=refine_prompt,
)
Exécutez-le sur l'ensemble du document :
refine_outputs = refine_chain({"input_documents": pages, "question": question})
Inspectez les étapes intermédiaires pour voir la réponse s'affiner :
for doc, out in zip(
refine_outputs["input_documents"], refine_outputs["intermediate_steps"]
):
print(f"Page : {doc.metadata['page']}")
print(f"Réponse : {out}")
L'approche d'affinement aide à préserver les informations sur l'ensemble du document. Mais elle nécessite encore le traitement de l'ensemble du document de manière linéaire.
Étape 6. Recherche de similarité
Pour améliorer l'efficacité, nous pouvons d'abord utiliser des embeddings pour trouver uniquement les fragments les plus pertinents pour une question donnée. Cela évite de devoir traiter l'ensemble du document.
Créez un index vectoriel des fragments de document en utilisant ChromaDB :
vector_index = Chroma.from_documents(pages, vertex_embeddings).as_retriever()
Récupérez les fragments les plus pertinents pour la question :
docs = vector_index.get_relevant_documents(question)
Exécutez la chaîne map-reduce uniquement sur ces fragments pertinents :
map_reduce_embeddings_outputs = map_reduce_chain(
{"input_documents": docs, "question": question}
)
print(map_reduce_embeddings_outputs["output_text"])
Cela permet de trouver une réponse de haute qualité tout en traitant uniquement un petit sous-ensemble du document complet. L'approche de recherche de similarité offre le meilleur équilibre entre précision et efficacité.
Conclusion
Dans cet article, nous avons présenté plusieurs approches de question-réponse sur de grands documents en utilisant LangChain et Vertex AI PaLM. Alors que le "simple stuffing" peut fonctionner pour de petits documents, les approches map-reduce et d'affinement sont nécessaires pour passer à l'échelle des données plus importantes.
Cependant, la méthode la plus efficace et la plus efficace consiste d'abord à utiliser la recherche de similarité vectorielle pour trouver uniquement les passages les plus pertinents pour une question donnée. Cela réduit la quantité de texte que le LLM doit traiter tout en produisant des réponses de haute qualité.
La combinaison de la recherche de similarité, des chaînes de QA de LangChain et des puissants LLM comme PaLM permet de construire des systèmes d'extraction d'information évolutifs basés sur des collections de grands documents. Vous pouvez commencer avec le code complet de ce notebook.
Vous souhaitez connaître les dernières actualités sur LLM ? Consultez le dernier classement LLM !