9. Construa Seu Primeiro Sistema RAG
Todas as aplicações que construímos até agora dependiam apenas do conhecimento pré-treinado do LLM. É por isso que ele não conseguia responder perguntas sobre informações que o LLM nunca aprendeu—como os documentos internos da sua empresa ou manuais de produtos.
RAG (Retrieval-Augmented Generation - Geração Aumentada por Recuperação) resolve este problema. Quando uma pergunta do usuário chega, ele primeiro recupera documentos relevantes, depois passa o conteúdo recuperado junto com a pergunta para o LLM para que ele possa responder com base nesse conteúdo. Você está combinando a capacidade de raciocínio do LLM com o conhecimento dos seus documentos.
Neste capítulo, construiremos um pipeline RAG completo desde a preparação de documentos (carregamento, divisão em chunks, embedding) até a geração de respostas baseadas em recuperação. O sistema finalizado recupera documentos relevantes quando uma pergunta chega, depois os passa junto com a pergunta para o LLM para que ele responda com base nesse conteúdo do documento. Ele responde com precisão quando a informação está nos documentos, e honestamente diz "Não sei" quando não está—esta é a essência de um RAG confiável.
9.1) Compreendendo RAG
9.1.1) O Problema: LLMs Não Conhecem Seus Dados
LLMs são treinados em dados da internet como Wikipedia, artigos de notícias e código público. Eles não sabem sobre os documentos internos da sua empresa ou o contrato que você recebeu ontem. Então eles não conseguem responder perguntas como:
- "Qual é a política de férias da nossa empresa?"
- "Resuma o relatório de vendas deste trimestre"
- "Quais são os termos de reembolso no contrato que acabei de receber?"
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(model="gpt-5-mini")
# Perguntar sobre um documento privado que o LLM nunca viu
response = llm.invoke("Qual é a política de reembolso da Acme Corp?")
print(response.content)Saída:
Não tenho informações específicas sobre a política de reembolso da Acme Corp. Recomendo
verificar o site oficial deles ou entrar em contato diretamente com a equipe de suporte ao cliente
para obter as informações mais precisas e atualizadas.Neste exemplo, o LLM honestamente diz que não sabe. (Ou ele pode alucinar uma resposta que soa plausível.)
Mas e se fornecermos o documento da política de reembolso junto com a pergunta? O LLM daria uma resposta precisa baseada no conteúdo fornecido. Esta é a ideia central por trás do RAG.
9.1.2) Como Devemos Fornecer o Documento?
A abordagem mais simples é copiar e colar o documento inteiro no prompt. Isso na verdade funciona bem para documentos curtos.
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(model="gpt-5-mini")
# Na realidade, isso seria muito mais longo, mas vamos assumir que o seguinte é o documento completo
document_text = """
Política de Reembolso (Vigente a partir de Janeiro de 2026):
- Reembolso total dentro de 30 dias da compra com recibo original.
- Após 30 dias, apenas crédito na loja.
- Produtos digitais não são reembolsáveis após o download.
- Itens defeituosos podem ser devolvidos a qualquer momento para reembolso total.
"""
response = llm.invoke(
f"""Por favor, responda com base no seguinte documento:
{document_text}
Pergunta: Qual é a política de reembolso para produtos digitais?"""
)
print(response.content)Saída:
Produtos digitais não são reembolsáveis após o download.
...Isso funciona bem para documentos curtos. Mas e se o documento for muito grande? Isso causa os seguintes problemas sérios:
1. Limites da Janela de Contexto: LLMs têm um número limitado de tokens que podem processar de uma vez. Para o GPT-5-mini, são 400K tokens. No entanto, toda a documentação da sua empresa pode facilmente exceder isso. Mesmo que caiba, as respostas ficam mais lentas e menos precisas à medida que o contexto cresce.
2. Custo: APIs de LLM cobram por token. Enviar o documento inteiro quando apenas um ou dois parágrafos são necessários faz os custos dispararem.
3. Degradação da Precisão: Quando você inclui o documento inteiro, a informação que você realmente precisa fica enterrada em conteúdo irrelevante. A atenção do LLM é desviada por informações não relacionadas, degradando a qualidade da resposta.
RAG resolve todos os três problemas recuperando e fornecendo apenas as partes relevantes do documento.
9.1.3) Ideia Central: Recuperar Partes Relevantes e Fornecê-las com a Pergunta
A essência do RAG é simples: Antes de enviar a pergunta para o LLM, primeiro encontre as partes relevantes dos seus documentos e forneça-as junto com a pergunta.
Veja como funciona:
- Usuário faz uma pergunta.
- O sistema recupera (Retrieval) conteúdo relevante do armazenamento de documentos.
- O conteúdo recuperado é adicionado (Augmentation) ao prompt junto com a pergunta.
- O LLM gera (Generation) uma resposta baseada no conteúdo recuperado.
Estas três etapas são de onde RAG (Retrieval-Augmented Generation) obtém seu nome.
9.1.4) Como Recuperamos Conteúdo Relevante? (Limitações da Correspondência de Palavras-chave)
A etapa de recuperação é crucial para o RAG. Você precisa fornecer conteúdo relevante para obter respostas adequadas. Então, como recuperamos conteúdo relacionado à pergunta?
O método mais simples é correspondência de palavras-chave: encontrar documentos que contenham palavras da pergunta. Por exemplo, se alguém pergunta "Qual é a política de reembolso para produtos digitais?" você procuraria documentos contendo as palavras "reembolso", "digitais" e "produtos".
Mas a correspondência de palavras-chave tem uma fraqueza crítica: ela só pode encontrar correspondências exatas de palavras.
Digamos que você tenha um documento de política de reembolso com este conteúdo:
"Reembolso total disponível dentro de 30 dias da compra."
O que acontece quando um usuário pergunta "Como faço para receber meu dinheiro de volta?" Este documento não será recuperado. O documento não contém a frase "receber meu dinheiro de volta". Humanos entendem que "reembolso" e "receber meu dinheiro de volta" têm o mesmo significado, mas a busca por palavras-chave só corresponde palavras, então ela falha em encontrá-lo.
A busca por palavras-chave só corresponde palavras. Mesmo quando o significado é o mesmo, se as palavras diferem, ela não encontrará.
A solução é busca semântica. E o que torna isso possível são os embeddings.
9.1.5) Embeddings: Convertendo Texto em Vetores Numéricos
Embeddings representam o significado do texto como uma lista de números (um vetor). Quando você insere texto em um modelo de embedding, ele o converte em um vetor de centenas a milhares de números.
from langchain_openai import OpenAIEmbeddings
embeddings_model = OpenAIEmbeddings(model="text-embedding-3-small")
# Incorporar uma única frase
vector = embeddings_model.embed_query("Como faço para receber meu dinheiro de volta?")
print(f"Dimensões do vetor: {len(vector)}")
print(f"Primeiros 5 valores: {vector[:5]}")Saída:
Dimensões do vetor: 1536
Primeiros 5 valores: [0.0123, -0.0456, 0.0789, -0.0234, 0.0567]Dimensão é o número de valores que compõem o vetor. O modelo text-embedding-3-small representa todo texto como 1.536 números.
Por que precisamos de tantos números? Porque cada dimensão captura diferentes aspectos do significado:
- Algumas dimensões podem distinguir "ação/estado"
- Outras podem representar graus de "concreto/abstrato"
- Ainda outras podem indicar sentimento "positivo/negativo"
- ... (1.536 características semânticas—embora não possamos realmente interpretar o que cada dimensão representa)
Assim como coordenadas 2D (x, y) representam um ponto em um plano, um vetor de 1.536 dimensões representa um ponto em um "espaço de significado" de 1.536 dimensões. Mais dimensões permitem distinções mais finas no significado.
Significados semelhantes estão localizados próximos no espaço de significado. "Método de reembolso" e "receber dinheiro de volta" usam palavras diferentes, mas porque têm significados semelhantes, eles são colocados próximos no espaço de significado.
9.1.6) Busca Semântica: Significado Semelhante, Distância Mais Próxima
Uma vez que você converteu tanto documentos quanto consultas em vetores, você pode encontrar os documentos mais relevantes medindo similaridade entre vetores. Isso é chamado de busca semântica — buscar por similaridade semântica em vez de correspondência de palavras-chave.
A medida de similaridade mais comum é similaridade de cosseno, que mede o ângulo entre dois vetores. Quando vetores apontam em direções semelhantes, a similaridade é maior. Mais próximo de 1.0 significa significado muito semelhante, enquanto mais próximo de 0 significa baixa relevância.
Vamos realmente calcular isso:
from langchain_openai import OpenAIEmbeddings
import numpy as np
embeddings_model = OpenAIEmbeddings(model="text-embedding-3-small")
# Incorporar a consulta e dois documentos candidatos
query_vec = embeddings_model.embed_query("Qual é o período de reembolso?")
doc1_vec = embeddings_model.embed_query("Reembolso total disponível dentro de 30 dias da compra.") # Relacionado
doc2_vec = embeddings_model.embed_query("Nosso escritório está localizado no centro de Seattle.") # Não relacionado
def cosine_similarity(a, b):
"""Calcula a similaridade de cosseno entre dois vetores."""
return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))
sim1 = cosine_similarity(query_vec, doc1_vec)
sim2 = cosine_similarity(query_vec, doc2_vec)
print(f"Consulta vs doc 'reembolso': {sim1:.4f}")
print(f"Consulta vs doc 'escritório': {sim2:.4f}")Saída:
Consulta vs doc 'reembolso': 0.6415
Consulta vs doc 'escritório': 0.1706(Valores reais podem variar por modelo)
O documento de reembolso pontua muito mais alto. O modelo de embedding entende que "período de reembolso" e "reembolso total dentro de 30 dias" são semanticamente relacionados. Esta é a busca semântica, e é o mecanismo central do RAG.
9.1.7) Visão Geral do Pipeline RAG
Combinando os conceitos que aprendemos, criamos o seguinte pipeline RAG:
O pipeline consiste em duas fases:
Ingestão de Conhecimento (realizada uma vez inicialmente, ou quando documentos mudam):
- Carregamento de Documentos: Extrair dados de texto de várias fontes (Markdown, PDF, etc.).
- Divisão de Texto (Chunking): Dividir documentos longos em chunks menores para melhorar a precisão da recuperação e cumprir os limites de entrada do LLM.
- Conversão em Vetor (Embedding): Usar um modelo de embedding para converter chunks em vetores numéricos baseados em significado.
- Armazenamento de Vetores: Armazenar os vetores convertidos e o texto original em um banco de dados vetorial (indexação).
Recuperação e Geração de Resposta (realizada para cada pergunta do usuário):
- Embedding da Pergunta: Converter a pergunta do usuário em um vetor numérico usando o mesmo modelo usado durante a ingestão.
- Busca por Similaridade (Recuperação): Extrair os top-K chunks que são semanticamente mais próximos do vetor da pergunta do banco de dados vetorial.
- Aumento do Prompt: Combinar a pergunta original com os chunks recuperados para aumentar o prompt.
- Geração de Resposta: O LLM referencia os chunks fornecidos para gerar uma resposta fundamentada.
9.2) Carregamento e Divisão de Documentos
Esta seção cobre as duas primeiras etapas da fase de ingestão de conhecimento do pipeline RAG:
- Carregamento de Documentos: Leitura de dados de texto de arquivos
- Divisão de Texto (Chunking): Quebrar dados de texto em pedaços pequenos e pesquisáveis
Na próxima seção (9.3), aprenderemos como converter esses chunks em vetores e armazená-los.
9.2.1) Carregando Documentos de Arquivos
O primeiro passo em um pipeline RAG é carregar documentos em objetos Python. LangChain fornece carregadores de documentos — classes que suportam uma variedade de formatos de arquivo. Os principais carregadores são:
TextLoader: Arquivos de texto simples (.txt) e Markdown (.md)PyPDFLoader: Arquivos PDF (.pdf), carregados página por páginaCSVLoader: Arquivos CSV (.csv), com cada linha carregada como um documento separadoUnstructuredMarkdownLoader: Arquivos Markdown (.md), com consciência estrutural (cabeçalhos, listas, etc.)
Independentemente de qual carregador você use, o resultado é sempre retornado como uma lista de objetos Document. Cada Document tem dois atributos principais:
page_content: O conteúdo de texto do documentometadata: Um dicionário contendo meta-informações como caminho do arquivo e número da página
Neste tutorial, usaremos TextLoader para carregar arquivos Markdown.
Preparando Documentos de Exemplo
Primeiro, vamos criar alguns documentos de exemplo para trabalhar. Crie uma pasta data/docs/ no seu projeto e adicione os seguintes arquivos:
mkdir -p data/docsCrie data/docs/refund_policy.md:
# Política de Reembolso
**Data de Vigência**: 1º de Janeiro de 2026
## Devoluções Padrão
Todos os produtos físicos podem ser devolvidos dentro de 30 dias da compra para reembolso total.
O recibo original ou e-mail de confirmação do pedido é necessário. Os itens devem estar em sua
embalagem original e em condição não utilizada.
Após 30 dias, devoluções são aceitas apenas para crédito na loja. O crédito na loja não expira.
## Produtos Digitais
Produtos digitais (licenças de software, e-books, cursos online) não são reembolsáveis
uma vez que o link de download ou acesso tenha sido ativado. Se você tiver problemas técnicos
impedindo o acesso, entre em contato com o suporte dentro de 7 dias para uma substituição ou reembolso.
## Itens Defeituosos
Itens defeituosos podem ser devolvidos a qualquer momento para reembolso total ou substituição.
Por favor, inclua uma descrição do defeito. Custos de envio para devoluções de defeituosos
são cobertos pela empresa.
## Serviços de Assinatura
Assinaturas mensais podem ser canceladas a qualquer momento. Reembolsos são proporcionais com base nos
dias restantes no ciclo de cobrança. Assinaturas anuais podem ser reembolsadas integralmente
dentro dos primeiros 14 dias. Após 14 dias, nenhum reembolso está disponível, mas o acesso continua até o final do período de cobrança.Crie data/docs/shipping_info.md:
# Informações de Envio
## Envio Nacional
Envio padrão (5-7 dias úteis): Grátis em pedidos acima de $50, caso contrário $5.99.
Envio expresso (2-3 dias úteis): $12.99.
Envio noturno (próximo dia útil): $24.99.
## Envio Internacional
Pedidos internacionais são enviados via correio aéreo rastreado. Os tempos de entrega variam por
destino, tipicamente 10-21 dias úteis. Os custos de envio internacional são
calculados no checkout com base no peso e destino.
Taxas alfandegárias e impostos de importação são de responsabilidade do comprador e não estão incluídos no custo de envio.
## Rastreamento de Pedidos
Todos os pedidos incluem um número de rastreamento enviado por e-mail dentro de 24 horas do envio.
Rastreie seu pedido através do link de rastreamento no seu e-mail ou através do site da transportadora.
## Pacotes Perdidos ou Danificados
Se seu pacote for perdido ou chegar danificado, entre em contato com o suporte dentro de 48 horas.
Enviaremos uma substituição sem custo adicional. Para itens danificados, por favor
forneça fotos do dano e da embalagem.Agora carregue esses arquivos usando TextLoader:
from pathlib import Path
from langchain_community.document_loaders import TextLoader
# Carregar todos os arquivos .md do diretório data/docs
docs_dir = Path("data/docs")
for md_file in docs_dir.glob("*.md"):
loader = TextLoader(str(md_file), encoding="utf-8")
docs = loader.load()
if docs: # Verificar que o arquivo não está vazio
doc = docs[0] # Arquivo único = Document único
print(f"Arquivo: {doc.metadata['source']}")
print(f"Comprimento: {len(doc.page_content)} caracteres")
print(f"Prévia: {doc.page_content[:80]}...")
print()Saída:
Arquivo: data/docs/refund_policy.md
Comprimento: 1166 caracteres
Prévia: # Política de Reembolso
...
Arquivo: data/docs/shipping_info.md
Comprimento: 972 caracteres
Prévia: # Informações de Envio
...Nota:
TextLoaderrecebe um único caminho de arquivo como entrada, mas retornaList[Document]para uma interface consistente com outros carregadores. (Por exemplo,PDFLoaderretorna múltiplos Documents — um por página.)
9.2.2) Dividindo Documentos em Chunks: Chunking
Os dois documentos acima são intencionalmente curtos para fins deste tutorial. Em aplicações reais, você frequentemente trabalhará com documentos que têm centenas ou milhares de páginas. Se você incorporar um documento inteiro como um único vetor, milhares de conceitos ficam comprimidos em um — tornando impossível recuperar com precisão o que você realmente precisa.
Chunking é o processo de dividir documentos em pedaços pequenos e significativos. O objetivo é simples: quando um usuário faz uma pergunta, apenas os parágrafos específicos diretamente relevantes para a resposta devem ser recuperados — não o documento inteiro.
O tamanho do chunk afeta diretamente tanto a recuperação quanto a qualidade da resposta:
- Muito grande: Múltiplos tópicos se misturam em um chunk, tornando os embeddings menos precisos e a recuperação mais difícil. Mesmo quando o chunk certo é encontrado, conteúdo irrelevante é passado para o LLM, degradando a qualidade da resposta.
- Muito pequeno: O LLM pode não receber informação suficiente para responder corretamente. Por exemplo, se apenas a frase "Envio padrão é $5.99" for recuperada, o LLM não pode saber que isso só se aplica a pedidos abaixo de $50.
- No ponto certo: Cada chunk cobre um tópico com contexto suficiente, permitindo recuperação e respostas precisas.
9.2.3) Controlando Tamanho e Sobreposição de Chunks
Para dividir documentos em chunks, você precisa de um divisor de texto. Um divisor de texto é uma ferramenta LangChain que quebra documentos longos em pedaços menores. Escolher o divisor certo é importante.
RecursiveCharacterTextSplitter: Tenta múltiplos separadores em ordem hierárquica para preservar o máximo de contexto possível. O divisor mais amplamente usado para propósitos gerais.CharacterTextSplitter: Divide em um único separador (padrão:\n\n). Adequado para documentos com estrutura simples.MarkdownHeaderTextSplitter: Divide em cabeçalhos Markdown (#,##). Eficaz quando você quer preservar a estrutura de índice do documento.
Por que RecursiveCharacterTextSplitter é eficaz?
Este divisor funciona tentando separadores da maior para a menor unidade para encontrar o melhor ponto de divisão. A ordem padrão é a seguinte (pode ser alterada via parâmetro separators):
parágrafo (\n\n) → quebra de linha (\n) → palavra ( )
Ele sempre tenta dividir na maior unidade significativa primeiro. Se um parágrafo exceder chunk_size, ele volta para quebras de linha, depois palavras. Porque ele sempre encontra o ponto de divisão mais natural em vez de cortar arbitrariamente no meio de uma palavra, os chunks resultantes são mais propensos a conter informação semanticamente completa.
Parâmetros Principais
chunk_size: O número máximo de caracteres por chunk. Por exemplo,chunk_size=400significa que nenhum chunk excederá 400 caracteres.chunk_overlap: O número de caracteres sobrepostos entre chunks adjacentes. Por exemplo,chunk_overlap=80significa que os últimos 80 caracteres de um chunk são repetidos no início do próximo.separators: A lista de separadores usados para dividir o texto, tentados em ordem de prioridade. Se dividir no separador atual excederiachunk_size, o próximo separador é tentado para evitar excederchunk_size.
O que é sobreposição e por que é necessária?
Sobreposição significa que chunks adjacentes compartilham algum conteúdo — o final de um chunk é incluído no início do próximo.
A razão para isso é garantir que cada chunk possa se sustentar sozinho com contexto suficiente. Ao ler um pedaço de um documento sem qualquer conhecimento do que veio antes, pode ser difícil entender por que certo conteúdo está sendo mencionado. A sobreposição mantém o final de um chunk fluindo para o próximo, de modo que qualquer chunk que seja recuperado, o conteúdo lê naturalmente.
Agora vamos realmente dividir um documento:
from langchain_community.document_loaders import TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
# Carregar o documento
loader = TextLoader("data/docs/refund_policy.md", encoding="utf-8")
docs = loader.load()
# Configurar o divisor
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=400,
chunk_overlap=80,
separators=["\n## ", "\n\n", "\n", " "],
)
chunks = text_splitter.split_documents(docs)
print(f"Dividido em {len(chunks)} chunks\n")
for i, chunk in enumerate(chunks):
print(f"--- Chunk {i} (fonte: {chunk.metadata['source']}) ---")
print(f"Comprimento: {len(chunk.page_content)} caracteres")
print(chunk.page_content[:120])
print()Saída:
Dividido em 4 chunks
--- Chunk 0 (fonte: data/docs/refund_policy.md) ---
Comprimento: 374 caracteres
# Política de Reembolso
...
--- Chunk 1 (fonte: data/docs/refund_policy.md) ---
Comprimento: 267 caracteres
## Produtos Digitais
...
--- Chunk 2 (fonte: data/docs/refund_policy.md) ---
Comprimento: 204 caracteres
## Itens Defeituosos
...
--- Chunk 3 (fonte: data/docs/refund_policy.md) ---
Comprimento: 315 caracteres
## Serviços de Assinatura
...Nota: No exemplo acima, nenhuma sobreposição ocorreu. Isso é porque cada parágrafo foi claramente dividido com base no primeiro separador (
\n##) enquanto permanecia dentro dochunk_size. A sobreposição só ocorre quando um parágrafo específico é mais longo que ochunk_sizee deve ser dividido em duas ou mais peças.
9.3) Armazenamento e Recuperação de Vetores com ChromaDB
9.3.1) O Que É um Vector Store?
Um vector store (também chamado de banco de dados vetorial) é um banco de dados otimizado para armazenar e buscar dados usando vetores de embedding. Diferente de um banco de dados tradicional onde você consulta por valores exatos de campo (SELECT * FROM products WHERE category = 'electronics'), um vector store encontra os itens com o significado mais similar à sua consulta.
No RAG, o vector store mantém chunks de documentos junto com seus embeddings. Quando um usuário faz uma pergunta, a pergunta é convertida em um vetor, e o vector store recupera os chunks com os vetores mais similares.
9.3.2) Escolhendo um Vector Store e Configurando ChromaDB
Vector stores populares incluem ChromaDB, Pinecone, Weaviate e pgvector (extensão PostgreSQL). Eles diferem em modelo de hospedagem (local vs. nuvem), escala e complexidade operacional. Para este livro, usaremos ChromaDB — é código aberto, roda inteiramente na sua máquina local sem configuração de servidor, e é útil não apenas para desenvolvimento mas também para cargas de trabalho de produção pequenas a médias.
ChromaDB pode ser usado de várias maneiras:
- Modo local (pip): Instale-o como uma biblioteca Python e use-o imediatamente. Você pode armazenar e carregar dados em um diretório local sem qualquer infraestrutura de servidor separada.
- Servidor standalone (Docker): Execute ChromaDB como um processo de servidor separado. Útil quando múltiplas aplicações precisam compartilhar o mesmo vector store.
- Serviço de nuvem gerenciado (Chroma Cloud): Use ChromaDB como um serviço de nuvem. Chroma Cloud cuida da hospedagem, escalabilidade e manutenção, permitindo que você entregue um serviço estável sem carga de gerenciamento de infraestrutura.
Vamos instalar ChromaDB usando pip:
pip install chromadb langchain-chromachromadb é a biblioteca central do vector store, e langchain-chroma é um pacote de integração que permite usar ChromaDB diretamente dentro da biblioteca LangChain.
9.3.3) Escolhendo o Modelo de Embedding
A primeira coisa a decidir é qual modelo de embedding usar. Vetores de embedding só podem ser comparados quando são gerados pelo mesmo modelo. Portanto, você deve usar o mesmo modelo de embedding tanto para armazenar documentos quanto para consultar.
OpenAI fornece os seguintes modelos de embedding:
| Modelo | Dimensões | Notas |
|---|---|---|
text-embedding-3-small | 1536 | Bom equilíbrio de qualidade e custo |
text-embedding-3-large | 3072 | Maior qualidade, maior custo |
Para este livro, usaremos o modelo text-embedding-3-small da OpenAI. Ele oferece alta eficiência a baixo custo, tornando-o uma escolha prática para busca geral, RAG e projetos conscientes de custos.
from langchain_openai import OpenAIEmbeddings
embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
# Verificar que funciona
test_vector = embedding_model.embed_query("teste")
print(f"Dimensões do embedding: {len(test_vector)}")Saída:
Dimensões do embedding: 1536Nota de custo: Chamadas de API de embedding são muito mais baratas que chamadas de LLM, mas elas incorrem em custos. Ao armazenar documentos no banco de dados (indexação), uma chamada de API é necessária por chunk, e quando um usuário faz uma pergunta (recuperação), uma chamada de API é necessária para a pergunta. Para preços atuais, consulte a página de preços da OpenAI.
9.3.4) Armazenando Chunks no ChromaDB
Agora vamos juntar tudo. Carregaremos documentos, dividiremos em chunks, incorporaremos os chunks e os armazenaremos junto com seus vetores de embedding no ChromaDB.
# ingest.py - Pipeline de ingestão completo
from langchain_community.document_loaders import DirectoryLoader, TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma
# Passo 1: Carregar documentos
# DirectoryLoader: escaneia um diretório e carrega arquivos correspondentes.
# O carregamento real é delegado ao carregador especificado em loader_cls.
loader = DirectoryLoader(
"data/docs/", glob="**/*.md",
loader_cls=TextLoader, loader_kwargs={"encoding": "utf-8"},
)
documents = loader.load()
print(f"Carregados {len(documents)} documentos")
# Passo 2: Dividir em chunks
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=400,
chunk_overlap=80,
separators=["\n## ", "\n\n", "\n", " ", ""],
)
chunks = text_splitter.split_documents(documents)
print(f"Criados {len(chunks)} chunks")
# Passo 3: Criar modelo de embedding
embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
# Passo 4: Criar vector store e ingerir chunks
vector_store = Chroma.from_documents(
documents=chunks,
embedding=embedding_model,
persist_directory="data/chroma_db",
collection_name="company_docs",
)
print(f"Armazenados {len(chunks)} chunks no ChromaDB em data/chroma_db/")Saída:
Carregados 2 documentos
Criados 8 chunks
Armazenados 8 chunks no ChromaDB em data/chroma_db/O método Chroma.from_documents() realiza duas tarefas em uma única chamada:
- Passa os chunks fornecidos via parâmetro
documentsatravés do modelo de embedding para obter vetores de embedding. - Armazena cada chunk junto com seu vetor de embedding no ChromaDB.
9.3.5) Carregando um Vector Store Persistido
Na seção anterior, armazenamos documentos no vector store. Esta operação de armazenamento só precisa ser realizada uma vez inicialmente (ou quando documentos mudam). Depois disso, você pode simplesmente carregar o vector store persistido e usá-lo diretamente.
from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma
# Carregar um vector store persistido — sem necessidade de re-incorporação
embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
vector_store = Chroma(
persist_directory="data/chroma_db",
collection_name="company_docs",
embedding_function=embedding_model,
)
print(f"Carregado vector store com {len(vector_store.get()['ids'])} chunks")Saída:
Carregado vector store com 8 chunksAgora você pode começar a buscar imediatamente simplesmente carregando o vector store persistido, sem precisar re-incorporar seus documentos.
9.3.6) Busca por Similaridade
Com o vector store carregado, você agora pode buscar chunks que são semanticamente similares a uma consulta. O parâmetro top-K especifica quantos resultados retornar:
from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma
embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
vector_store = Chroma(
persist_directory="data/chroma_db",
collection_name="company_docs",
embedding_function=embedding_model,
)
# Buscar chunks relacionados a uma pergunta
query = "Posso devolver um produto digital?"
results = vector_store.similarity_search(query, k=2)
print(f"Consulta: {query}")
print(f"Encontrados {len(results)} resultados\n")
for i, doc in enumerate(results):
print(f"--- Resultado {i + 1} (fonte: {doc.metadata['source']}) ---")
print(doc.page_content[:200])
print()Saída:
Consulta: Posso devolver um produto digital?
Encontrados 2 resultados
--- Resultado 1 (fonte: data/docs/refund_policy.md) ---
## Produtos Digitais
...
--- Resultado 2 (fonte: data/docs/refund_policy.md) ---
# Política de Reembolso
**Data de Vigência**: 1º de Janeiro de 2026
## Devoluções Padrão
...Para esta consulta, o chunk de produtos digitais foi recuperado com a maior similaridade, seguido pelo chunk de política de reembolso.
Você também pode recuperar resultados com suas pontuações de similaridade usando similarity_search_with_score:
results_with_scores = vector_store.similarity_search_with_score(query, k=2)
for doc, score in results_with_scores:
# ChromaDB retorna distância (menor = mais similar)
print(f"Pontuação: {score:.4f} | Fonte: {doc.metadata['source']}")
print(f" {doc.page_content[:200]}...")
print()Saída:
Pontuação: 0.5942 | Fonte: data/docs/refund_policy.md
## Produtos Digitais
...
Pontuação: 0.9577 | Fonte: data/docs/refund_policy.md
# Política de Reembolso
...Note que ChromaDB usa pontuações de distância (menor é mais similar), não pontuações de similaridade (maior é mais similar). O chunk de produtos digitais tem a menor distância de 0.5942, tornando-o o resultado mais relevante.
9.4) Construindo a Cadeia RAG Completa
Agora construiremos um sistema RAG completo: recuperar documentos relevantes primeiro, depois passá-los junto com a pergunta para o LLM para gerar respostas baseadas na informação fornecida.
9.4.1) Projetando o Template de Prompt
A parte mais importante do template de prompt é instruir o LLM a responder baseado apenas no contexto fornecido. Sem esta instrução, o LLM pode ignorar os resultados da busca e fabricar respostas baseadas em seus dados de treinamento.
from langchain_core.prompts import ChatPromptTemplate
rag_prompt = ChatPromptTemplate.from_messages([
("system",
"Você é um representante de atendimento ao cliente. "
"Responda à pergunta do usuário usando APENAS o contexto fornecido. "
"Se o contexto não contiver informação suficiente para responder, "
"diga \"Não tenho informação suficiente para responder a essa pergunta.\"\n\n"
"Contexto:\n{context}"),
("human", "{question}"),
])A mensagem do sistema força o LLM a responder usando apenas o contexto fornecido. Crucialmente, a instrução para dizer "Não tenho informação suficiente" quando o contexto é insuficiente previne o LLM de fabricar respostas plausíveis mas não suportadas.
9.4.2) Construindo a Cadeia RAG
Agora temos todos os componentes prontos. Só precisamos conectar o retriever, template de prompt e LLM.
O sistema RAG completo funcionará da seguinte forma:
- Receber a pergunta do usuário
- Recuperar chunks relevantes do vector store
- Passar os chunks e pergunta para o template de prompt para gerar o prompt
- Gerar uma resposta com o LLM
Vamos conectar a cadeia RAG usando o operador LCEL | do Capítulo 6.
# rag_chain.py - Pipeline RAG completo
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_chroma import Chroma
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
def format_docs(docs):
"""Junta documentos recuperados em uma única string de contexto."""
return "\n\n---\n\n".join(doc.page_content for doc in docs)
def build_rag_chain():
"""Constrói e retorna a cadeia RAG completa."""
# Carregar o vector store
embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
vector_store = Chroma(
persist_directory="data/chroma_db",
collection_name="company_docs",
embedding_function=embedding_model,
)
# Criar um retriever (k=3 significa retornar os 3 melhores chunks)
retriever = vector_store.as_retriever(search_kwargs={"k": 3})
# Definir o prompt
rag_prompt = ChatPromptTemplate.from_messages([
("system",
"Você é um representante de atendimento ao cliente. "
"Responda à pergunta do usuário usando APENAS o contexto fornecido. "
"Se o contexto não contiver informação suficiente para responder, "
"diga \"Não tenho informação suficiente para responder a essa pergunta.\"\n\n"
"Contexto:\n{context}"),
("human", "{question}"),
])
# Inicializar o LLM
llm = ChatOpenAI(model="gpt-5-mini")
# Compor a cadeia usando LCEL
rag_chain = (
{"context": retriever | format_docs, "question": lambda x: x}
| rag_prompt
| llm
| StrOutputParser()
)
return rag_chain
if __name__ == "__main__":
chain = build_rag_chain()
answer = chain.invoke("Posso devolver um produto digital?")
print(answer)Saída:
Produtos digitais não são reembolsáveis uma vez que o link de download ou acesso tenha sido ativado.
Se você tiver problemas técnicos impedindo o acesso, entre em contato com o suporte dentro de 7 dias para uma substituição ou reembolso.Vamos decompor a composição da cadeia passo a passo:
rag_chain = (
{"context": retriever | format_docs, "question": lambda x: x}
| rag_prompt
| llm
| StrOutputParser()
)Quando você chama chain.invoke("Posso devolver um produto digital?"), aqui está o que acontece:
- Passo do dicionário:
retriever | format_docs: Busca no vector store com a pergunta e combina os chunks em uma única stringlambda x: x: Passa a pergunta inalterada- Resultado:
{"context": "chunks recuperados (combinados em uma única string)", "question": "Posso devolver um produto digital?"}
rag_prompt: Preenche os placeholders{context}e{question}no template de prompt com os valores do dicionáriollm: Envia o prompt completo para o LLMStrOutputParser(): Extrai apenas o texto da resposta do LLM
Para mais detalhes sobre como LCEL funciona, veja o Capítulo 6.
9.4.3) Testando com Perguntas Respondíveis e Não Respondíveis
Um sistema RAG deve lidar tanto com perguntas que pode responder (informação existe nos documentos) quanto perguntas que não pode responder (informação não está nos documentos). Vamos testar ambos os cenários:
# test_rag.py - Testar a cadeia RAG com várias perguntas
from rag_chain import build_rag_chain
chain = build_rag_chain()
test_questions = [
# Respondível — informação está nos documentos
"Qual é a política de reembolso para produtos físicos?",
"Quanto custa o envio expresso?",
"Posso devolver um item defeituoso após 6 meses?",
# Não respondível — informação NÃO está nos documentos
"Qual é a política de férias dos funcionários?",
"Quais linguagens de programação são usadas?",
]
for question in test_questions:
print(f"P: {question}")
answer = chain.invoke(question)
print(f"R: {answer}\n")
print("-" * 60)Saída:
P: Qual é a política de reembolso para produtos físicos?
R: Todos os produtos físicos podem ser devolvidos dentro de 30 dias da compra para reembolso total. ...
------------------------------------------------------------
P: Quanto custa o envio expresso?
R: O envio expresso (2–3 dias úteis) custa $12.99.
------------------------------------------------------------
P: Posso devolver um item defeituoso após 6 meses?
R: Sim. Itens defeituosos podem ser devolvidos a qualquer momento para reembolso total ou substituição. ...
------------------------------------------------------------
P: Qual é a política de férias dos funcionários?
R: Não tenho informação suficiente para responder a essa pergunta.
------------------------------------------------------------
P: Quais linguagens de programação são usadas?
R: Não tenho informação suficiente para responder a essa pergunta.
------------------------------------------------------------Os resultados demonstram exatamente o comportamento que queremos:
- Perguntas respondíveis: Fornecem respostas precisas baseadas nos documentos recuperados. O LLM não adiciona informação não contida nos documentos.
- Perguntas não respondíveis: Respondem com "Não tenho informação suficiente para responder a essa pergunta." O LLM identifica corretamente que o contexto recuperado carece de informação relevante e se recusa a fabricar uma resposta.
Este é o poder do RAG. Seu LLM responde perguntas sobre seus dados e honestamente admite quando não sabe. Cada resposta é apoiada por documentos, tornando o sistema muito mais confiável que um LLM padrão.