Pour lancer ce projet vous avez besoin:
- Python 3.9+
- Poetry
Une fois les outils installés, allez dans le dossier /home/ubuntu/public/genai-codelab
cd /home/ubuntu/public/genai-codelab
Un fois dans le dossier, vous pouvez installer les dépendances via la commande:
poetry install
Le suite du codelab aura lieu dans src/app.py
.
Validez que le modèle phi3:3.8b
est bien présent, via la commande:
ollama list
Si il n'est pas présent, vous pouvez le télécharger grâce à la commande:
ollama pull phi3:3.8b
Maintenant que tout est installé, nous allons pouvoir démarrer notre première application.
Afin d'appeler notre modèle, nous allons utiliser LangChain.
LangChain Community contient les intégrations pour les applications tierces comme Ollama.
Dans votre fichier src/app.py
, vous pouvez ajouter l'import suivant :
from langchain_ollama import ChatOllama
Notre modèle est actuellement accessible via l'URL http://localhost:11434
Nous allons créer l'objet permettant d'intéragir avec Ollama via le code suivant:
llm = ChatOllama(
base_url='http://localhost:11434',
model='phi3:3.8b',
)
Une fois cet objet créé nous allons pouvoir intéragir avec le modèle phi3:3.8b
.
Pour cela on déclare un prompt:
prompt = 'Who are you ?'
Puis on invoque le modèle :
response = llm.invoke(prompt)
print(response.content)
Pour exécuter le fichier, exécutez la commande suivante:
poetry run python src/app.py
Et voila! Nous avons effectué notre premier appel.
LangChain fournit un ensemble de fonctions et d'utilitaires permettant de configurer plus finement notre application.
Dans un premier temps, rendons notre application un peu plus vivante. Plutôt que de générer une réponse d'un coup, LangChain nous permet de streamer le flux de la réponse.
Pour cela, ajoutez les imports suivants:
from langchain.callbacks.manager import CallbackManager
from langchain.callbacks.streaming_stdout import StreamingStdOutCallbackHandler
Modifier la création de notre objet llm
pour y ajouter un callback_manager
permettant de streamer la réponse:
llm = ChatOllama(
base_url='http://localhost:11434',
model='phi3:3.8b',
callback_manager= CallbackManager([StreamingStdOutCallbackHandler()])
)
Réexecutez le fichier pour voir la différence:
poetry run python src/app.py
Afin de générer des variations dans les réponses apportées par les modèles, il est possible de faire varier le paramètre temperature
. Il permet de définir le degré de "créativité" du modèle. En effet, la sortie d'un modèle de langage génératif basé sur les transformers est la suite de tokens (que par simplicité nous pouvons considérer comme des mots) qui complète la suite de tokens (ou mots) donnée en input. Grâce aux poids réglés lors du pré-entrainément, le modèle détermine les tokens qui ont la probabilité la plus grande de compléter une suite fournie en entrée. Par défaut, il choisit toujours ceux qui ont la probabilité la plus haute. Agir sur la température permet d'augmenter l'ensemble des tokens choisis, en allant chercher ceux qui ont des probabilités plus basse de survenir.
Ce paramètre est compris entre 0 et 1.
Plus la valeur est proche de 1, plus le modèle va être "créatif", c'est à dire qu'il choisira des mots moins probables de survenir d'après les datasets qui ont servi à l'entrainer.
Plus la valeur est proche de 0, plus le modèle va être déterministe, il choisira toujours le mot avec la probabilité la plus élevée.
Règles de bases
- Pour des tâches de transformation (correction de fautes, extraction de données, conversion de format) on vise une température entre 0 et 0.3
- Pour des tâches d'écriture simple, de résumé, on vise une température proche de 0.5
- Pour des tâches nécessitant de la créativité (marketing, pub), on vise une température entre 0.7 et 1
Pour configurer la température, modifiez la déclaration du modèle:
llm = ChatOllama(
base_url='http://localhost:11434',
model='phi3:3.8b',
temperature=0.5,
callback_manager= CallbackManager([StreamingStdOutCallbackHandler()])
)
Afin d'éviter la répétition, LangChain nous donne la possibilité de variabiliser notre prompt.
Pour cela, ajoutez l'import suivant:
from langchain_core.prompts import ChatPromptTemplate
Déclarez un template:
prompt = ChatPromptTemplate.from_messages([
('system', 'You are a professional regexp instructor'),
('user', 'explain the following regexp {regexp} ')
])
Langchain permet de creér des chain
, un enchaînement de fonction. Les fonctions vont consommer les réponses des fonctions précédentes.
Nous pouvons créer notre chain
via le code suivant:
chain = prompt | llm
Ce code est écrit avec LCEL (LangChain Expression Language).
L'invocation de notre modèle se fait maintenant en appelant:
print(chain.invoke({'regexp': '^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$'}).content)
Il existe différentes techniques permettant de contextualiser les réponses. Une première technique consiste à passer des exemples de question / réponse dans le contexte. On peut faire notre propre template de prompt ou utiliser directement un prompt pré-configuré par LangChain:
from langchain.prompts import FewShotChatMessagePromptTemplate
Dans un premier temps, on commence par définir un ensemble d'exemple qui vont aider notre modèle à répondre:
examples = [
{'animal': 'cow', 'sound': 'moo'},
{'animal': 'cat', 'sound': 'meow'},
{'animal': 'dog', 'sound': 'woof'}
]
On crée ensuite un template de prompt pour y injecter nos exemples:
example_prompt = ChatPromptTemplate.from_messages([
('human', '{animal}'),
('ai', '{sound}')
])
Initialisez le template contenant tous les exemples ainsi que la question:
few_shot_prompt = FewShotChatMessagePromptTemplate(
examples=examples,
example_prompt=example_prompt,
)
Une fois le prompt créé, on peut assembler nos exemples dans un prompt final:
final_prompt = ChatPromptTemplate.from_messages([
('system',
'You are an animal sound expert, able to give the sound an animal does based on the name of the animal'),
few_shot_prompt,
('human', '{input}')
])
Pour invoquer notre modèle:
chain = final_prompt | llm
print(chain.invoke({'input': 'lion'}).content)
Langchain nous propose un ensemble de prompts pré-configurés. Dans cet exemple, nous allons utiliser la chain
: load_summarize_data
qui n'est rien d'autre qu'un template de prompt:
prompt_template = """Write a concise summary of the following:
{text}
CONCISE SUMMARY:"""
Pour résumer un texte, on peut se baser sur la fonction load_summarize_data
from langchain.chains.summarize import load_summarize_chain
L'utilisation de cette chain
se fait de la façon suivante:
chain = load_summarize_chain(llm, chain_type="refine")
En plus de fournir des prompts pré-enregistrés, LangChain fournit également un ensemble de classes utilitaires permettant de charger différents types de données: JSON, CSV, lien web, ...
Pour notre exemple, nous allons utiliser le WebBaseLoader
from langchain_community.document_loaders import WebBaseLoader
Prenons par exemple le contenu d'une page wikipédia: https://fr.wikipedia.org/wiki/Grand_mod%C3%A8le_de_langage
loader = WebBaseLoader('https://fr.wikipedia.org/wiki/Grand_mod%C3%A8le_de_langage')
docs = loader.load()
print(chain.invoke(docs).content)
Cette technique fonctionne pour les documents dont le contenu a une taille suffisamment petite pour être injecté dans le contexte du LLM.
Dans le cas d'un long document, il sera nécéssaire de découper notre document. On peut s'orienter vers des solutions de type RAG
Par défaut, chaque invocation au modèle se comportera comme si c'était la première. Afin de simuler une conversation, il est possible de configurer une mémoire à notre modèle.
Pour se faire, on peut utiliser un template de prompt qui va assembler un historique de nos message à chaque nouvelle inférence.
Langchain nous propose un objet permettant de gérer un historique de messages :
from langchain_community.chat_message_histories import ChatMessageHistory
chat_messages = ChatMessageHistory()
chat_messages.add_user_message('Can you translate I love programming in French')
chat_messages.add_ai_message("J'adore la programmation")
Comme pour résumer un document, langchain nous propose une chain
pré-configurée permettant d'inclure une memoire.
Pour cela, nous allons nous baser sur la classeConversationChain
, et sur la classe ConversationBufferMemory
pour la gestion de la mémoire
from langchain.chains import ConversationChain
from langchain.memory import ConversationBufferMemory
La conversation et la mémoire peuvent être instanciées de la façon suivante:
memory = ConversationBufferMemory(chat_memory=chat_messages)
conversation_chain = ConversationChain(llm=llm, memory=memory)
Et l'inférence se fera via l'appel de la méthode predict
:
conversation_chain.predict(input="what was my previous question ?")
Pour pouvoir échanger avec votre assistant de manière graphique nous vous proposons de créer une interface avec Streamlit.
Créez un fichier dans src/ui.py
avec ce contenu :
import streamlit as st
from langchain.chains.summarize import load_summarize_chain
from langchain_community.document_loaders.web_base import WebBaseLoader
from langchain_community.llms import ollama
from langchain_community.callbacks import StreamlitCallbackHandler
from langchain.chains import ConversationChain
from langchain.memory import ConversationBufferMemory, ChatMessageHistory
from langchain_core.prompts import PromptTemplate, ChatPromptTemplate
from langchain.prompts import FewShotChatMessagePromptTemplate
OLLAMA_URL = "http://localhost:11434"
MODEL = "phi3:3.8b"
llm = ollama.Ollama(base_url=OLLAMA_URL, model=MODEL)
st.title("GenAi Codelab by Zenika")
st.write("")
st.image("./image/schema.png")
tab_simple, tab_template, tab_few_shot, tab_summarize, tab_memory = st.tabs(
[
'Simple prompt',
'Templated prompt',
'Few shot learning',
'Document summarize',
'Memory'
]
)
with tab_simple:
prompt = st.chat_input("What would you like to know ?")
response_callback = StreamlitCallbackHandler(st.container())
if prompt:
llm.invoke(prompt, {"callbacks": [response_callback]})
with tab_template:
st.write("Explique moi cette expression régulière (^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$) ")
prompt = PromptTemplate.from_template(
"Explain me what is the purpose of this regexp {regexp}"
)
chain = prompt | llm
regexp = st.chat_input("RegExp: ")
story_callback = StreamlitCallbackHandler(st.container())
if regexp:
chain.invoke({"regexp": regexp}, {"callbacks": [story_callback]})
with tab_few_shot:
st.write("Quel son fait un animal : ")
examples = [
{'animal': 'cow', 'sound': 'moo'},
{'animal': 'cat', 'sound': 'meow'},
{'animal': 'dog', 'sound': 'woof'}
]
example_prompt = ChatPromptTemplate.from_messages([
('human', '{animal}'),
('ai', '{sound}')
])
few_shot_prompt = FewShotChatMessagePromptTemplate(
examples=examples,
example_prompt=example_prompt,
)
final_prompt = ChatPromptTemplate.from_messages([
('system',
'You are an animal sound expert, able to give the sound an animal does based on the name of the animal'),
few_shot_prompt,
('human', '{input}')
])
animal = st.chat_input("Animal")
few_shot_example_chain = final_prompt | llm
few_shot_callback = StreamlitCallbackHandler(st.container())
if animal:
few_shot_example_chain.invoke(
{"input": animal}, {"callbacks": [few_shot_callback]}
)
with tab_summarize:
st.write("Résume moi le contenu d'un lien (https://fr.wikipedia.org/wiki/Niort)")
link = st.chat_input("Résume moi ce lien")
summarize_callback = StreamlitCallbackHandler(st.container())
if link:
chain = load_summarize_chain(llm, chain_type="refine")
loader = WebBaseLoader(link)
pages = loader.load()
chain.invoke(pages, {"callbacks": [summarize_callback]})
with tab_memory:
st.write("Reprenons notre conversation la ou nous l'avions laissé:")
st.write("Human: Can you translate I love programming in French")
st.write("AI: J'adore la programmation")
messages = ChatMessageHistory()
messages.add_user_message('Can you translate I love programming in French')
messages.add_ai_message("J'adore la programmation")
memory = ConversationBufferMemory(chat_memory=messages)
conversation_chain = ConversationChain(llm=llm, memory=memory)
follow_up = st.chat_input("What did I just ask you ?")
memory_callback = StreamlitCallbackHandler(st.container())
if follow_up:
conversation_chain.predict(input=follow_up, callbacks=[memory_callback])
Maintenant que l'on a quelque chose qui fonctionne, l'objectif va être de donner de plus en plus de contexte à notre modèle afin qu'il génère des réponses associées à notre besoin.
Cette étape est aussi cruciale dès lors qu'un LLM est limité en terme de nombre de tokens fournis en entrée. La RAG mais aussi le résumé de l'historique sont des méthodes pour contourner cette limitation.
Pour mettre en place une RAG, deux grandes étapes sont nécessaires:
-
Dans un premier temps, la préparation des données : indexer les données dans une base de données (avec un support vectoriel)
- Cela nécessite d'extraire les données des documents
- Découper ces données en
chunk
de taille suffisante pour contenir des parties de documents - Calculer les
embeddings
associés à chacun de ces chunks
-
Au moment de la recherche:
- Calculer les embeddings liés à la recherche
- Chercher les chunks associés à cette recherche
- Générer une réponse basée sur le contenu des documents
Pour construire notre base de connaissance, nous allons devoir convertir nos documents / données en embeddings (représentation vectorielle d'un texte). Cette étape nous permettra ainsi de faire de la comparaison entre le vecteur représentant la requête utilisateur et les parties de documents pertinentes.
Il y a différentes librairies permettant de générer des embeddings. Concrètement, on utilise un sous-ensemble de l'architecture d'un LLM. LangChain fournit différentes intégrations pour générer les embeddings en fonction du model utilisé.
Dans notre cas, nous allons utiliser le code suivant:
from langchain_community.embeddings import OllamaEmbeddings
embeddings_generator = OllamaEmbeddings(model = 'openhermes')
Nous pouvons tester que le générateur fonctionne correctement avec le code suivant:
text = 'this is a sentence'
text_embedding = embeddings_generator.embed_query(text)
# Affichage du début de l'embedding
print(text_embedding[:5])
Remarque : le premier run est assez lent, les suivants seront plus rapides
Les embeddings ayant une taille maximale (dépendant du modèle), l'indexation d'un document complet nécessite le découpage
du document en chunk
.
Pour cela, LangChain met à disposition un ensemble de classes permettant de faire du découpage en fonction de critères (nombre de caractères, séparateurs HTML, séparateurs Markdown).
Une fois les embeddings générés, on peut les insérer dans une base de données vectorielle, il en existe plusieurs:
- ChromaDB
- FAISS
- Lance
- Qdrant
- Pinecone
Pour ce codelab, nous allons utiliser Qdrant.
Première étape, installer la base Qdrant en local :
docker pull qdrant/qdrant
docker run -d -p 6333:6333 qdrant/qdrant
Avant de pouvoir indexer un document, il faut le charger. Langchain fournit un ensemble de Loader
permettant de charger tout type de documents (PDF, texte, site web, ...)
Dans notre cas, nous allons nous baser sur des fichiers PDF.
Nous allons d'abord écrire le code de chargement du document dans la base vectorielle.
Créez pour cela un fichier indexer.py
.
Pour charger un document, on peut utiliser le code suivant:
from langchain_community.document_loaders.pdf import UnstructuredPDFLoader
document = UnstructuredPDFLoader("data/Nantes.pdf", strategy="fast").load()
Une fois le document chargé, on peut le découper grace à un TextSplitter
:
from langchain_text_splitters import CharacterTextSplitter
text_splitter = CharacterTextSplitter(
separator="\n\n",
chunk_size=1000,
chunk_overlap=200,
length_function=len,
is_separator_regex=False,
)
docs = text_splitter.split_documents(document)
Puis les indexer via:
from langchain_community.vectorstores.qdrant import Qdrant
QDRANT_URL = "http://localhost:6333"
Qdrant.from_documents(
docs,
embeddings_generator,
url=QDRANT_URL,
collection_name=<INDEX_NAME>,
content_payload_key='page_content',
force_recreate=True
)
Remplacez <INDEX_NAME>
par le nom de votre choix pour votre collection d'indexation
A partir de maintenant, vous pouvez changer de fichier retrieval.py
Afin de d'appeler notre RAG, nous allons avoir besoin d'une connexion à notre base de données QDrant:
from qdrant_client import QdrantClient
QDRANT_URL = "http://localhost:6333"
qdrant_client = QdrantClient(
QDRANT_URL,
prefer_grpc=False
)
Ce client peut ensuite être wrappé dans l'abstraction LangChain
Remplacez <INDEX_NAME>
par le nom de la collection saisi précédemment
from langchain_community.vectorstores.qdrant import Qdrant
qdrant = Qdrant(
client=qdrant_client,
collection_name=<INDEX_NAME>,
embeddings=embeddings_generator
)
Puis nous pouvons créer la chain
permettant d'avoir le lien entre notre prompt d'entrée, la base de données vectorielle, et la réponse:
from langchain.chains.qa_with_sources.retrieval import RetrievalQAWithSourcesChain
rag = RetrievalQAWithSourcesChain.from_chain_type(
llm=llm,
chain_type='stuff',
retriever=qdrant.as_retriever(),
return_source_documents=True
)
La RAG peut ensuite être appelée avec une question de votre choix, de la façon suivante:
response = rag.invoke({"question": "How many person live in Nantes ?"})
print(response['answer'])
print("based on: ")
print(response['source_documents'])