Creación de un chatbot conversacional con memoria usando HuggingFace y OpenAI, Streamlit(UI) y ChromaDB como BD vectorial

Creación de un chatbot conversacional con memoria usando HuggingFace y OpenAI, Streamlit(UI) y ChromaDB como BD vectorial

Para crear un chatbot con HuggingFace y OpenAI, necesitaremos utilizar algunas herramientas y bibliotecas diferentes. Aquí hay una descripción general de lo que usaremos:

  • Vectorstores: un vectorstore es una base de datos que se pueden utilizar para recuperar los documentos que más se parecen a una consulta. Usaremos Chroma vectorstore de la biblioteca Langchain para recuperar documentos en función de su similitud con la pregunta de un usuario.

  • Modelos de lenguaje: Usaremos el modelo de lenguaje GPT-3 de OpenAI para generar respuestas a la pregunta de un usuario.

  • Retrieval Chain:

    Una cadena de recuperación es una forma que tiene un asistente de IA de encontrar y utilizar información para responder preguntas. ¡Es como tener un bibliotecario dentro del cerebro del asistente!

    Cuando al asistente le hacen una pregunta, la cadena de recuperación primero revisa todos los libros y artículos que el asistente conoce. Esto es como el bibliotecario que busca en los estantes de la biblioteca los libros adecuados.

    La cadena de recuperación encuentra los libros y páginas más útiles que pueden ayudar a responder la pregunta. Luego lee y comprende esas páginas, tal como un bibliotecario leería partes de diferentes libros.

    Finalmente, la cadena de recuperación combina la información importante de esas fuentes en una buena respuesta a la pregunta original. ¡Es como si el bibliotecario leyera diferentes libros, tomara las partes más útiles y las explicara de una manera sencilla para que yo pudiera entenderlas!

    En resumen, una cadena de recuperación permite al asistente de IA:

    • Busca su conocimiento

    • Encuentre las fuentes más útiles

    • Leer y comprender esas fuentes.

    • Combínalos en una explicación sencilla.

¡Esto hace que el asistente sea muy inteligente al permitirle buscar información tal como lo haría un bibliotecario humano!

  • Streamlit: Streamlit es una biblioteca de Python para crear aplicaciones web interactivas. Usaremos Streamlit para crear una interfaz web simple para nuestro chatbot.

Paso 1: Cargando los datos

El primer paso para construir nuestro chatbot es cargar los datos que usaremos para generar respuestas. En este caso, usaremos un conjunto de archivos JSON que contienen documentación para la biblioteca HuggingFace. Usaremos la biblioteca Langchain para cargar los datos y dividirlos en fragmentos más pequeños que se pueden usar con Chroma vectorstore.

import streamlit as st
from langchain.vectorstores import Chroma
from langchain.chat_models import ChatOpenAI
from langchain.document_loaders import JSONLoader
from langchain.embeddings import HuggingFaceEmbeddings
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain.chains import ConversationalRetrievalChain, RetrievalQA

from utils import get_file_path
from rich.console import Console

console = Console()

def metadata_func(record: dict, metadata: dict) -> dict:
    metadata["title"] = record.get("title")
    metadata["repo_owner"] = record.get("repo_owner")
    metadata["repo_name"] = record.get("repo_name")

    return metadata

def load_documents(path):
    loader = JSONLoader(
        path,
        json_lines=True,  # indica que el archivo es un jsonl
        jq_schema=".",  # indica que el jsonl tiene un solo elemento por linea, más info en <https://python.langchain.com/docs/modules/data_connection/document_loaders/json#common-json-structures-with-jq-schema>
        content_key="text",  # indica que el contenido del documento está en la llave "text"
        metadata_func=metadata_func,  # indica que la función metadata_func se usará para obtener la metadata del documento
    )
    data = loader.load()

    text_splitter = RecursiveCharacterTextSplitter(
        chunk_size=1600, length_function=len, chunk_overlap=160
    )

    return text_splitter.split_documents(data)

En este código, usamos la clase JSONLoader de langchain.document_loaders para cargar documentos desde un archivo JSONL.

  • path: la ruta al archivo JSONL que se va a cargar.

  • json_lines=True : indica que el archivo es un archivo JSONL.

  • jq_schema=".": Indica que el archivo JSONL tiene un objeto JSON por línea.

  • content_key="text": Indica que el contenido de cada documento se almacena en la key "texto".

  • metadata_func=metadata_func: Una función para obtener los metadatos de cada documento.

También tenemos un objeto RecursiveCharacterTextSplitter con los siguientes parámetros:

  • chunk_size=1600: esto establece el tamaño máximo de cada fragmento de texto en 1600 caracteres. Si el texto ingresado es más largo, se dividirá en varios fragmentos.

  • length_function=len: Esto establece la función utilizada para calcular la longitud del texto de entrada. En este caso, utiliza la función incorporada ``len` para contar el número de caracteres.

  • chunk_overlap=160: Esto establece el número de caracteres de superposición entre fragmentos adyacentes. Esto significa que los últimos 160 caracteres de un fragmento se incluirán en el siguiente fragmento, para evitar dividir palabras u oraciones por la mitad.

RecursiveCharacterTextSplitter es una clase de la biblioteca transformers que se utiliza para dividir textos largos en fragmentos más pequeños que pueden ser procesados por modelos con tamaño de entrada limitado. Esto es útil para tareas como la generación de texto o la respuesta a preguntas, donde el texto de entrada puede ser muy largo.

Paso 2: Creación de Chroma Vectorstore

Una vez que hayamos cargado los datos, necesitaremos crear un vectorstore Chroma que podamos usar para recuperar documentos en función de su similitud con la pregunta de un usuario.

def get_chroma_db(embeddings, documents, path, recreate_chroma_db=False):
    if recreate_chroma_db:
        console.print("Creating Chroma DB")
        return Chroma.from_documents(
            documents=documents, embedding=embeddings, persist_directory=path
        )
    else:
        console.print("Loading Chroma DB")
        return Chroma(persist_directory=path, embedding_function=embeddings)

La función get_chroma_db crea o carga un objeto Chroma que se puede usar para buscar documentos similares según sus embeddings. La función toma los siguientes parámetros:

  • embeddings: una función que toma una cadena como entrada y devuelve su incrustación como una matriz numerosa.

  • documentos: una lista de documents para agregar al objeto Chroma.

  • path: la ruta al directorio donde se debe guardar el objeto Chroma.

  • recreate_chroma_db=False: un indicador booleano que indica si se debe crear un nuevo objeto Chroma o cargar uno existente.

Si recreate_chroma_db es True, la función crea un nuevo objeto Chroma llamando al método from_documents de la clase Chroma. Este método toma los parámetros documents, embeddings y persist_directory, que especifican los documentos que se agregarán al objeto Chroma, la función que se usará para calcular los embeddings y el directorio donde se debe persistir el objeto Chroma, respectivamente.

Si recreate_chroma_db es False, la función carga un objeto Chroma existente llamando al constructor Chroma con los parámetros persist_directory y embedding_function, que especifican el directorio donde se conserva el objeto Chromay la función que se usará para calcular los embeddings, respectivamente.

Paso 3: Procesar consultas de usuarios

Una vez que hayamos cargado los datos y creado la BD de vectores Chroma, podemos comenzar a procesar las consultas de los usuarios. Usaremos el modelo de lenguaje OpenAI GPT-3 para generar respuestas a la pregunta de un usuario.

def process_qa_query(query: str, llm: ChatOpenAI, retriever: any):
    qa_chain = RetrievalQA.from_chain_type(
        llm=llm, chain_type="stuff", retriever=retriever
    )
    return qa_chain.run(query)

def process_memory_query(
    query: str, llm: ChatOpenAI, retriever: any, chat_history: any
):
    conversation = ConversationalRetrievalChain.from_llm(
        llm=llm, chain_type="stuff", retriever=retriever
    )
    print(f"the chat history is {chat_history}")
    result = conversation({"question": query, "chat_history": chat_history})
    chat_history.append((query, result["answer"]))
    return result["answer"]

Función process_qa_query

La función process_qa_query toma una consulta, un objeto ChatOpenAI y un objeto retriever como entrada, y devuelve una respuesta. La función realiza una tarea de respuesta a preguntas utilizando la clase RetrievalQA de la biblioteca langchain.chains .

La función toma los siguientes parámetros:

  • query: Consulta a responder.

  • llm: un objeto ChatOpenAI que proporciona acceso a la API de OpenAI para generar respuestas.

  • retriever: un objeto retriever que proporciona acceso a una BD vectorial de documentos para buscar respuestas.

La función crea un objeto RetrievalQA usando el método from_chain_type , que toma los parámetros llm, chain_typey retriever. El parámetro chain_type especifica el tipo de cadena de preguntas y respuestas que se utilizará y, en este caso, está configurado en "stuff".

Luego, el objeto RetrievalQA se utiliza para ejecutar la query, y devuelve una cadena de respuesta.

Función process_memory_query

La función process_memory_query toma una consulta(query), un objeto ChatOpenAI , un objeto retriever y el historial de chat como entrada, y devuelve una respuesta. La función realiza una tarea de recuperación conversacional utilizando la clase ConversationalRetrievalChain de la biblioteca langchain.chains .

La función toma los siguientes parámetros:

  • query: consulta a responder.

  • llm: un objeto ChatOpenAI que proporciona acceso a la API de OpenAI para generar respuestas.

  • retriever: un objeto recuperador que proporciona acceso a una Bd de documentos para buscar respuestas.

  • chat_history: Una lista de tuplas que contienen el historial de chat, donde cada tupla contiene una consulta y su correspondiente respuesta.

La función crea un objeto ConversationalRetrievalChain usando el método from_llm , que toma los parámetros llm, chain_type y retriever . El parámetro chain_type especifica el tipo de cadena de recuperación conversacional que se utilizará y, en este caso, está configurado en "stuff" .

Luego, el objeto ConversationalRetrievalChain se utiliza para generar una respuesta a la consulta, utilizando el método conversation . El método toma un diccionario con las claves "question" y "chat_history" , que especifican la consulta y el historial de chat, respectivamente.

La función agrega la consulta y su respuesta correspondiente a la lista chat_history y devuelve la cadena de respuesta.

Diferencias

Las clases RetrievalQA y ConversationalRetrievalChain en la biblioteca langchain.chains se utilizan para tareas de respuesta a preguntas, pero difieren en su enfoque y funcionalidad.

La clase RetrievalQA se utiliza para responder una sola pregunta a la vez, dado un contexto o un conjunto de documentos. Utiliza un retriever para seleccionar los documentos más relevantes y un modelo de lenguaje para generar una respuesta basada en los documentos seleccionados. La clase RetrievalQA proporciona una interfaz sencilla para realizar tareas de respuesta a preguntas y admite diferentes tipos de recuperadores y modelos de lenguaje.

La clase ConversationalRetrievalChain , por otro lado, se utiliza para responder una secuencia de preguntas en un entorno conversacional, dado un contexto o un conjunto de documentos. Utiliza un retriever para seleccionar los documentos más relevantes y un modelo de lenguaje para generar una respuesta basada en los documentos seleccionados y las conversación anteriores. La clase ConversationalRetrievalChain proporciona una interfaz más compleja para realizar tareas de recuperación conversacional y admite diferentes tipos de recuperadores y modelos de lenguaje.

En resumen, la clase RetrievalQA se utiliza para responder una sola pregunta a la vez, mientras que la clase ConversationalRetrievalChain se utiliza para responder una secuencia de preguntas en un entorno conversacional.

Paso 4: Configurar variables de estado de la app

if "messages" not in st.session_state:
    st.session_state.messages = []

if "chat_history" not in st.session_state:
    st.session_state.chat_history = []

if "vectorstore_chroma" not in st.session_state:
    embeddings = HuggingFaceEmbeddings()
    documents = load_documents(get_file_path())

    st.session_state.vectorstore_chroma = get_chroma_db(
        embeddings, documents, "chroma_docs", recreate_chroma_db=False
    )
    console.print("Chroma DB loaded")

Este código inicializa variables para almacenar mensajes de chat e historial. También carga una base de datos Chroma usando la función get_chroma_db . Si la base de datos no ha sido creada, crea una nueva usando el método from_documents de la clase Chroma . Si se ha creado, lo carga usando el constructor Chroma .

Paso 5: Creación de la interfaz web

Finalmente, usaremos Streamlit para crear una interfaz web simple para nuestro chatbot. La interfaz permitirá a los usuarios ingresar una pregunta y recibir una respuesta del chatbot.

st.title("Chat with HugginFace Docs 🤗")

with st.sidebar:
    st.markdown(
        """
        # Chat with HugginFace Docs 🤗
        This is an example of a chatbot that uses a vectorstore to retrieve documents and a language model to generate answers based on the retrieved documents.
        A Chroma DB is used to retrieve the most relevant documents to the question.
        """
    )
    openai_api_key = st.text_input("OpenAI API Key")
    openai.api_key = openai_api_key

    option = st.selectbox(
        "What kind of chat do you want?", ("Question Answering", "Memory")
    )

with st.chat_message("assistant"):
    st.markdown(
        "hi!! what do you want to ask me about transformers and artificial intelligence?"
    )

for message in st.session_state.messages:
    with st.chat_message(message["role"]):
        st.markdown(message["content"])

if prompt := st.chat_input("What is up?"):
    st.session_state.messages.append({"role": "user", "content": prompt})
    with st.chat_message("user"):
        st.markdown(prompt)

    with st.chat_message("assistant"):
        message_placeholder = st.empty()

        if not openai_api_key.startswith("sk-"):
            st.warning("Please enter your OpenAI API key!", icon="⚠")
            st.stop()

        llm = ChatOpenAI(
            model="gpt-3.5-turbo",
            temperature=0.2,
            max_tokens=200,
            openai_api_key=openai_api_key,
        )
        retriever = st.session_state.vectorstore_chroma.as_retriever(
            search_kwargs={"k": 3}
        )
        chat_history = st.session_state.chat_history
        message_placeholder.markdown("Thinking...")

        if option == "Question Answering":
            console.print("Question Answering")
            response = process_qa_query(prompt, llm, retriever)
        elif option == "Memory":
            console.print("Memory")
            response = process_memory_query(prompt, llm, retriever, chat_history)

        message_placeholder.markdown(response)
    st.session_state.messages.append({"role": "assistant", "content": response})

La función st.title establece el título de la aplicación Streamlit en "Chatear con HugginFace Docs 🤗".

La función st.sidebar crea una barra lateral en la aplicación Streamlit. La barra lateral contiene un título y dos campos de entrada. El primer campo de entrada permite al usuario ingresar su clave API de OpenAI. El segundo campo de entrada permite al usuario seleccionar el tipo de chat que desea tener. Las opciones para el tipo de chat son "Respuesta a preguntas" y "Memoria".

La función st.chat_message se utiliza para representar mensajes en la interfaz de chat. La función st.chat_input se utiliza para obtener información del usuario desde la interfaz de chat.

Cuando el usuario ingresa el prompt al chat_input, primero se comprueba si la clave API de OpenAI es válida y detiene la ejecución si no lo es. Si la clave API es válida, el script llama a la función apropiada para generar una respuesta basada en la entrada del usuario y el tipo de chat seleccionado.

Step 5: Ejecuta la app

poetry run streamlit run hashira/app.py

Y verás el chat ejecutándose de manera local:

Conclusion

En esta blog, exploramos cómo crear un chatbot usando HuggingFace y OpenAI. Hemos utilizado un vectorstore para recuperar documentos en función de su similitud con la pregunta de un usuario y un modelo de lenguaje para generar respuestas a la pregunta del usuario. También utilizamos Streamlit para crear una interfaz web sencilla para nuestro chatbot.

El código está disponible en: github.com/nestorxyz/curso-langchain

Referencias