implementing_rag_in_a_django_app_cover
Pedro Costa

Pedro Costa

14 Out 2024 9 min read

Implementando RAG em um aplicativo Django: um guia simples

O objetivo deste post é implementar alguns dos principais usos atuais de large language models (LLMs) em um aplicativo Django comum, especificamente a técnica chamada Retrieval-Augmented Generation (RAG), que permite que os usuários conversem com seus documentos. Antes de mergulhar na implementação, preciso explicar alguns dos blocos de construção básicos do RAG, ou seja, embeddings.

Embeddings

Embeddings são essencialmente vetores multidimensionais de números de ponto flutuante com um tamanho fixo que contêm informações de alguma forma representando o conteúdo e o contexto.

[[0.011513561010360718,
  -0.02314218506217003,
  -0.0171588733792305,
  -0.03912165388464928,
  -0.021027889102697372,
  0.02629205398261547,
  -0.03854633495211601,
  ...]]

Embedding truncado da palavra "Amazon"

Um aspecto interessante dos embeddings é que, ao armazená-los em um banco de dados, podemos realizar operações entre eles para medir a similaridade entre os conteúdos. Por exemplo, ao converter o termo 'Amazon' em um embedding e compará-lo com os embeddings de 'Brazil' e 'Food' no seu espaço vetorial, a distância entre 'Amazon' e 'Brazil' é menor do que entre 'Amazon' e 'Food', indicando que 'Amazon' e 'Brazil' são mais similares.

from langchain_openai import OpenAIEmbeddings
from pgvector.django import CosineDistance

from project.app.models import MyDocument

embeddings_function = OpenAIEmbeddings()

embedding1 = embeddings_function.embed_documents(["Brazil"])[0]
embedding2 = embeddings_function.embed_documents(["Food"])[0]
MyDocument.objects.annotate(distance=CosineDistance("embedding", embedding1)).order_by("distance")[0].distance
0.17931762111467797
MyDocument.objects.annotate(distance=CosineDistance("embedding", embedding2)).order_by("distance")[0].distance
0.1892012486575828

Isso é chamado de busca de similaridade semântica e será responsável pela parte de "retrieval" do RAG. Em essência, armazenamos cada documento como um embedding e então o recuperamos por meio de busca de similaridade mais tarde. No entanto, para armazenar esses embeddings e executar a recuperação, precisamos de um banco de dados vetorial; para isso, usaremos o pgvector.

Pgvector

Atualmente, há vários bancos de dados de vetores diferentes sendo usados, como Chroma ou LanceDB. Como um dos bancos de dados mais comuns usados ​​em aplicativos Django é o Postgres, em vez de adicionar um novo banco de dados ao seu aplicativo atual, uma boa alternativa é usar o pgvector. O Pgvector é uma extensão do Postgres que fornece os recursos de um banco de dados de vetores, permitindo que você armazene embeddings e execute uma pesquisa de similaridade semântica. Para instalar o pgvector, você deve seguir estas instruções e, em seguida, executar este comando dentro da sua instância do Postgres:

CREATE EXTENSION vector;

e então, instale o módulo python pgvector

pip install pgvector

Depois disso, já é possível armazenar os embeddings. Porém, em vez de fazer isso manualmente, vamos salvá-los usando modelos do Django. Para salvar os embeddings no banco de dados, é simples; basta criar ou atualizar um modelo e adicionar um campo chamado VectorField com o número de dimensões que seu embedding terá, dessa maneira:

from Django.db import models
from pgvector.django import VectorField

class MyDocument(models.Model):
    # ...
    embedding = VectorField(dimensions=1536)  # Supondo que estamos usando o modelo de embedding text-embedding-ada-002.

Depois disso, basta criar e executar a migração do banco de dados usando os seguintes comandos:

python manage.py makemigrations
python manage.py migrate

Agora, com a capacidade de armazenar embeddings usando o ORM do Django, podemos aproveitar o pgvector para armazenar e calcular a similaridade dos embeddings no banco de dados Postgres.

No entanto, antes de fazermos isso, precisamos converter o conteúdo dos documentos em embeddings em primeiro lugar. Para esta tarefa, usaremos o Langchain.

Langchain

Langchain é um framework que nos dá ferramentas para construir aplicações web que podem interagir com LLMs, incluindo módulos e classes que podem gerar embeddings. Um deles é OpenAIEmbeddings, que usa um dos modelos do OpenAI para gerar os embeddings. Para este exemplo, usaremos o modelo padrão (text-embedding-ada-002) que gera um vetor com 1536 dimensões.

pip install langchain langchain-openai
from langchain_openai import OpenAIEmbeddings

embeddings_function = OpenAIEmbeddings()
# Substitua 'example text' pelo seu conteúdo real
embeddings = embeddings_function.embed_documents(['example text'])  

Agora que configuramos nosso pipeline de geração de embeddings usando o Langchain, precisamos carregar os documentos.

Para fazer isso, usaremos um dos carregadores de PDF fornecidos pela Langchain, que é o PyMuPDF. O carregador lerá o conteúdo do PDF e gerará uma lista de documents (instâncias da classe Document do langchain) que podemos então converter em embeddings.

pip install PyMuPDF
from langchain_community.document_loaders import PyMuPDFLoader

from project.app import MyDocument

file_path = "example.pdf"
loader = PyMuPDFLoader(file_path)
documents = loader.load() 

O retorno de loader.load() vai ser esse:

[Document(metadata={'source': 'example.pdf', 'file_path': 'example.pdf', 'page': 0, 'total_pages': 1, 'format': 'PDF 2.0', 'title': '', 'author': '', 'subject': '', 'keywords': '', 'creator': '', 'producer': '', 'creationDate': 'D:20241009145551Z', 'modDate': 'D:20241009145551Z', 'trapped': ''}, page_content='Just an example \n')]

Agora, nós podemos iterar a lista de documents para extrair o page_content e gerar os embeddings:

documents_content = [document.page_content for document in documents]
embeddings = embeddings_function.embed_documents(documents_content) 

E então podemos salvar os embeddings no modelo dessa maneira.

for embedding, document in zip(embeddings, documents):
    MyDocument.objects.create(
        embedding=embedding, 
        source=file_path, 
        content=document.page_content
    )

Como podemos ver, adicionamos novos campos, source e content, ao modelo MyDocument. Isso nos permitirá recuperar a URL original e o conteúdo da fonte de cada documento mais tarde.

Após preencher o banco de dados com os embeddings, podemos começar a implementar o principal recurso deste aplicativo: o RAG.

RAG

A técnica RAG envolve injetar o conteúdo de documentos no contexto dos modelos LLM. No entanto, devido às limitações no número de tokens que podem ser incluídos no contexto, é necessário recuperar apenas os documentos mais relevantes em relação ao input do usuário — essa é a fase de recuperação do RAG.

Como estamos criando um aplicativo de chatbot, criaremos um RAG conversacional. Para fazer isso, usaremos os módulos langchain apropriados para desenvolver nossa função.

from langchain_openai import ChatOpenAI
from langchain.chains.combine_documents import create_stuff_documents_chain
from langchain.chains.history_aware_retriever import create_history_aware_retriever
from langchain.chains.retrieval import create_retrieval_chain
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables.history import RunnableWithMessageHistory
from langchain_core.chat_history import BaseChatMessageHistory
from langchain_core.messages import BaseMessage
from langchain_core.pydantic_v1 import BaseModel
from langchain_core.pydantic_v1 import Field

from django.conf import settings
from project.app.retrievers import DocumentRetriever

class InMemoryHistory(BaseChatMessageHistory, BaseModel):
    """Implementação em memória do histórico de mensagens de chat."""

    messages: list[BaseMessage] = Field(default_factory=list)

    def add_message(self, message: BaseMessage) -> None:
        self.messages.append(message)

    def clear(self) -> None:
        self.messages = []


store = {}

def conversational_rag(
    condense_question_prompt_template,
    system_prompt_template,
):
    llm = ChatOpenAI(api_key=settings.OPENAI_API_KEY)

    condense_question_prompt = ChatPromptTemplate.from_messages(
        [
            ("system", condense_question_prompt_template),
            ("placeholder", "{chat_history}"),
            ("human", "{input}"),
        ],
    )

    history_aware_retriever = create_history_aware_retriever(
        llm,
        DocumentRetriever(max_results=4),
        condense_question_prompt,
    )

    qa_prompt = ChatPromptTemplate.from_messages(
        [
            ("system", system_prompt_template),
            ("placeholder", "{chat_history}"),
            ("human", "{input}"),
        ],
    )

    qa_chain = create_stuff_documents_chain(
        llm=llm,
        prompt=qa_prompt,
    )

    convo_qa_chain = create_retrieval_chain(history_aware_retriever, qa_chain)

    def get_session_history(session_id):
        if session_id not in store:
            store[session_id] = InMemoryHistory()
        return store[session_id]

    return RunnableWithMessageHistory(
        runnable=convo_qa_chain,
        get_session_history=get_session_history,
        input_messages_key="input",
        history_messages_key="chat_history",
        output_messages_key="answer",
    )

Aqui podemos ver que estamos recebendo dois parâmetros para prompting: condense_question_prompt_template e system_prompt_template. O primeiro é um prompt usado para condensar ou reformular a pergunta original do usuário, obtido por meio de uma chamada à LLM.

condense_question_prompt_template = """
    Dado um histórico da conversa e a última pergunta do usuário
    que pode fazer referência ao contexto no histórico da conversa,
    formule uma pergunta independente que possa ser entendida
    sem o histórico da conversa. NÃO responda à pergunta,
    apenas reformule-a se necessário e, caso contrário, retorne-a como está.

    """

Já o segundo, define como o modelo deve responder as perguntas com base no contexto (os documentos recuperados).

system_prompt_template = """
    Você é um assistente para responder perguntas.

    Você deve responder com base no contexto fornecido e no histórico da conversa.

    Se você não tiver nenhum contexto, apenas diga "Não sei".

    Contexto: {context}

    """

O uso do condense_question_prompt_template é necessário em casos em que o input não tem um termo específico para um assunto, como por exemplo, "Dê-me mais informações sobre isso". Sem saber o contexto, é impossível determinar a que "isso" se refere, tornando mais difícil encontrar documentos semelhantes no banco de dados. Para resolver esse problema, o LLM usa o contexto da conversa atual para reformular o input do usuário e recuperar documentos relevantes.

Este prompt é usado na chamada de history_aware_retriever, que recebe uma instância de DocumentRetriever.

history_aware_retriever = create_history_aware_retriever(
    llm,
    DocumentRetriever(max_results=4),
    condense_question_prompt,
)

O retriever é implementado desta forma:

from langchain_core.callbacks import CallbackManagerForRetrieverRun
from langchain_core.documents import Document
from langchain_core.retrievers import BaseRetriever
from pgvector.django import CosineDistance
from langchain_openai import OpenAIEmbeddings

from django.conf import settings
from project.app.models import MyDocument


class DocumentRetriever(BaseRetriever):
    max_results: int

    class Config:
        arbitrary_types_allowed = True

    def _get_relevant_documents(
        self,
        query: str,
        *,
        run_manager: CallbackManagerForRetrieverRun,
    ) -> list[Document]:

        embeddings_function = OpenAIEmbeddings(api_key=settings.OPENAI_API_KEY)
        embeddings = embeddings_function.embed_documents([query]) 
        documents = MyDocument.objects.annotate(
            distance=CosineDistance("embedding", embeddings[0])
        ).order_by("distance")
        return [
            Document(
                page_content=document.content,
            )
            for document in documents[:self.max_results]
        ]

Esta classe define como recuperar documentos e define os parâmetros para o objeto Document, que é então chamado internamente pelo history_aware_retriever.

Nesta implementação, um queryset é criado utilizando a métrica CosineDistance para calcular a distância (ou similaridade) entre o embedding da pergunta e os embeddings armazenados no banco de dados. Os resultados são ordenados com base no valor da distância, onde as menores distâncias indicam maior similaridade entre os documentos. (Observação: para entender melhor por que a distância do cosseno é usada, consulte este artigo.)

O outro parâmetro, max_results, determina quantos documentos são recuperados da lista e, neste caso, estamos usando os 4 principais documentos.

Após definir o history_aware_retriever, precisamos definir a funcão responsável por de fato responder ao "input" do usuário, o callable qa_chain que utiliza o parâmetro system_prompt_template.

qa_prompt = ChatPromptTemplate.from_messages(
    [
        ("system", system_prompt_template),
        ("placeholder", "{chat_history}"),
        ("human", "{input}"),
    ],
)

qa_chain = create_stuff_documents_chain(
    llm=llm,
    prompt=qa_prompt,
)

Em seguida, vamos combinar ambos os callables em um único, chamado convo_qa_chain, da seguinte forma:

convo_qa_chain = create_retrieval_chain(history_aware_retriever, qa_chain)

Na parte final, utilizamos convo_qa_chain como argumento para a classe RunnableWithMessageHistory. Esta classe é responsável por adicionar um histórico da conversa ao contexto do LLM.

RunnableWithMessageHistory(
    runnable=convo_qa_chain,
    get_session_history=get_session_history,
    input_messages_key="input",
    history_messages_key="chat_history",
    output_messages_key="answer",
)

O segundo parâmetro, get_session_history, recebe uma função responsável por recuperar o histórico da conversa com base no session_id, que será passado posteriormente quando a função conversation_rag for chamada.

def get_session_history(session_id):
    if session_id not in store:
        store[session_id] = InMemoryHistory()
    return store[session_id]

Neste exemplo, estamos armazenando a conversa na memória, mas em aplicações de produção, é recomendado usar um banco de dados como o Redis.

O parâmetro history_messages_key, define o nome da variável que contém o histórico da conversa e será usada no template. Os parâmetros input_messages_key e output_messages_key correspondem à variável de "input" do template (a pergunta do usuário) e a chave do dict com a resposta gerada pelo LLM.

Com a instância pronta, agora podemos retornar e mostrar como utilizar tudo isso em uma view.

View

Na parte final, definimos uma view simples que recebe uma solicitação POST com a pergunta do usuário e passa o session_id como argumento.

def ask_ai(request):
    user_question = request.POST.get("question")
    chat_session_id = request.POST.get("chat_session_id")

    if not user_question or not chat_session_id:
        return HttpResponseBadRequest()

    condense_question_prompt_template = """
    Dado um histórico de bate-papo e a última pergunta do usuário
    que pode fazer referência ao contexto no histórico de bate-papo,
    formule uma pergunta independente que possa ser entendida
    sem o histórico de bate-papo. NÃO responda à pergunta,
    apenas reformule-a se necessário e, caso contrário, retorne-a como está.

    """

    system_prompt_template = """
    Você é um assistente para responder perguntas.

    Você deve responder com base no contexto fornecido e no histórico da conversa.

    Se você não tiver nenhum contexto, apenas diga "Não sei".

    Contexto: {context}

    """

    async def message_stream():
        data = {
            "input": user_question,
        }
        rag_chain = conversational_rag(
            condense_question_system_template,
            system_prompt_template,
        )
        async for chunk in rag_chain.astream(
            data,
            {"configurable": {"session_id": chat_session_id}},
        ):
            yield chunk.get("answer", "")

    response = StreamingHttpResponse(message_stream(), content_type="text/event-stream")
    response["Cache-Control"] = "no-cache"
    response["X-Accel-Buffering"] = "no"
    return response

Nesta view, estamos retornando o resultado como um StreamingHttpResponse, que melhora a experiência do usuário ao permitir o envio de partes da resposta à medida que são geradas, em vez de aguardar a conclusão total do processamento. Esse comportamento é semelhante ao funcionamento do ChatGPT.

Para atingir esse efeito, precisamos passar uma função que retorna um gerador. A função message_stream é responsável por chamar a função conversational_rag, que retorna uma instância de RunnableWithMessageHistory que definimos anteriormente. Em seguida, chamamos o método astream nesta instância, que retorna a resposta em pedaços.

Com tudo isso definido, agora podemos escrever uma função JavaScript para consumir a resposta no frontend e atingir o efeito desejado.

async function askChatbot(question) {
      const formData = new FormData();
      formData.append("question", question);
      formData.append("chat_session_id", `{{ chat_session_id }}`);

      const response = await fetch(`{% url "app:ask_ai" %}`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/x-www-form-urlencoded',
          'X-CSRFToken': `{{ csrf_token }}`
        },
        body: new URLSearchParams(formData)
      });

      if (!response.ok) {
        throw new Error('Network response was not ok');
      }

      const reader = response.body.getReader();
      const decoder = new TextDecoder();
      let answer = '';
      while (true) {
        const {
          done,
          value
        } = await reader.read();
        if (done) break;

        let messageChunk = decoder.decode(value, {
          stream: true
        });
        // Concatena os pedaços da resposta
        answer += messageChunk; 
      }
}

Chatbot using RAG

RAG usando o arquivo do livro "The pragmatic programmer"

Conclusão

A arquitetura do aplicativo que construímos é relativamente simples e fornece uma boa base para desenvolvimento futuro. No entanto, ainda há muito espaço para melhorias, e esta implementação não cobre a construção do frontend.

Dito isso, seguindo estas etapas, você deve ser capaz de adicionar alguns recursos interessantes a um aplicativo Django existente, incluindo interfaces de conversação, processamento de linguagem natural e recursos de interação do usuário mais avançados.

Referências