RAG com LangChain: respostas a partir dos seus documentos
Como buscar os trechos certos da sua documentação e usá-los como contexto, para o modelo responder com informação real e admitir quando não sabe.
O modelo não conhece a sua empresa
Pergunte a um LLM qual o prazo de estorno da sua empresa e ele vai responder alguma coisa. Pode até soar convincente, mas ele não tem como saber: essa regra está na sua documentação, não no treinamento dele.
A Parte 6 terminou com um aviso sobre isso. O exemplo de resposta dizia "estorno em até 5 dias úteis". Na documentação que vamos usar aqui, o prazo real é de 3 dias úteis. Um modelo guiado só pelos exemplos repetiria o número errado com toda a confiança.
Existem três saídas:
- Colocar tudo no prompt: funciona com meia dúzia de páginas; com centenas, estoura o contexto e o custo.
- Treinar (fine-tuning) o modelo: caro, lento para atualizar e ainda sem garantia de que ele não vai inventar.
- RAG (Retrieval-Augmented Generation): a cada pergunta, buscar só os trechos relevantes da documentação e colocar esses trechos no prompt.
O RAG é o padrão mais usado em produção justamente por isso: a resposta vem de um texto real, a documentação pode mudar a qualquer hora e cada resposta pode citar de onde tirou a informação.
Como o padrão funciona
O RAG tem duas fases. A indexação prepara os documentos para busca, e roda uma vez (ou quando os documentos mudam). A consulta roda a cada pergunta.
Os embeddings são os mesmos da Parte 6: vetores que representam o significado do texto. Textos sobre o mesmo assunto ficam com vetores próximos, então a busca encontra o trecho certo mesmo quando a pergunta usa palavras diferentes das do documento.
A base de conhecimento
O exemplo usa a documentação de suporte da Nuvem, a empresa fictícia da Parte 6: quatro arquivos Markdown na pasta data/nuvem/, sobre cobrança, cancelamento, nota fiscal e integrações. Um trecho:
# Cobrança e pagamentos
## Cobrança em duplicidade
Quando o cliente é cobrado duas vezes no mesmo mês, o estorno da segunda
cobrança é feito automaticamente em até 3 dias úteis após a abertura do
chamado. No cartão de crédito, o valor aparece como crédito na próxima
fatura. No Pix, o valor volta para a mesma conta de origem.
## Conta bloqueada por falta de pagamento
A conta é bloqueada 10 dias após o vencimento sem pagamento. Depois que o
boleto é pago, a liberação acontece em até 1 dia útil...
São regras específicas (3 dias úteis, 10 dias, 1 dia útil, 15 minutos no Pix) que nenhum modelo tem como adivinhar. Se a resposta trouxer esses números certos, é porque veio da documentação.
Carregar e dividir
Primeiro, cada arquivo vira um Document, o objeto do LangChain que guarda o texto e os metadados. O nome do arquivo vai nos metadados para podermos citar a fonte depois:
from pathlib import Path
from langchain_core.documents import Document
DATA_DIR = Path(__file__).resolve().parents[2] / "data" / "nuvem"
def load_documents(data_dir: Path = DATA_DIR) -> list[Document]:
"""Read every markdown file and keep its file name as the source."""
return [
Document(page_content=path.read_text(encoding="utf-8"), metadata={"source": path.name})
for path in sorted(data_dir.glob("*.md"))
]
Depois, os documentos são divididos em trechos (chunks). Dividir é necessário por dois motivos: a busca fica mais precisa com textos curtos sobre um assunto só, e o prompt recebe só o pedaço relevante, não o arquivo inteiro.
from langchain_text_splitters import MarkdownHeaderTextSplitter, RecursiveCharacterTextSplitter
def split_documents(documents, chunk_size=800, chunk_overlap=100):
by_header = MarkdownHeaderTextSplitter(
headers_to_split_on=[("#", "title"), ("##", "section")],
strip_headers=False,
)
by_size = RecursiveCharacterTextSplitter(chunk_size=chunk_size, chunk_overlap=chunk_overlap)
chunks = []
for doc in documents:
sections = by_header.split_text(doc.page_content)
for section in sections:
section.metadata["source"] = doc.metadata["source"]
chunks.extend(by_size.split_documents(sections))
return chunks
A divisão acontece em duas etapas:
- Por cabeçalho (
MarkdownHeaderTextSplitter): cada seção##vira um trecho. Como a documentação já está organizada por assunto, cada trecho fala de uma coisa só. Os nomes do título e da seção vão para os metadados. - Por tamanho (
RecursiveCharacterTextSplitter): se alguma seção passar de 800 caracteres, ela é quebrada em partes menores, preferindo parágrafos e frases. Ochunk_overlap=100repete um pedaço do fim de um trecho no começo do próximo, para uma frase cortada no meio não perder o sentido.
No exemplo, os 4 arquivos viram 11 trechos, o maior com 365 caracteres, e cada um sabe de onde veio:
chunks = split_documents(load_documents())
chunks[1].metadata
# {'title': 'Cancelamento e reembolso', 'section': 'Reembolso', 'source': 'cancelamento.md'}
Dependência: os divisores de texto ficam no pacote langchain-text-splitters, que não vem junto com o langchain. Instale com pip install langchain-text-splitters (no repositório, ele já está no pyproject.toml).
Embeddings e busca
Com os trechos prontos, cada um vira um vetor e vai para a base vetorial. O as_retriever devolve o objeto que faz a busca: recebe uma pergunta e devolve os k trechos mais parecidos.
from langchain_core.vectorstores import InMemoryVectorStore
from langchain_google_genai import GoogleGenerativeAIEmbeddings
def create_retriever(embeddings=None, k=3, data_dir=DATA_DIR):
if embeddings is None:
embeddings = GoogleGenerativeAIEmbeddings(model="gemini-embedding-001")
chunks = split_documents(load_documents(data_dir))
vectorstore = InMemoryVectorStore.from_documents(chunks, embeddings)
return vectorstore.as_retriever(search_kwargs={"k": k})
O retriever é um Runnable como qualquer outro, então dá para testá-lo sozinho:
retriever = create_retriever(k=3)
docs = retriever.invoke("Fui cobrado duas vezes. Em quanto tempo recebo o estorno?")
docs[0].metadata["section"] # "Cobrança em duplicidade"
O InMemoryVectorStore guarda tudo na memória do processo. É ótimo para aprender e para bases pequenas, mas o índice some quando o programa termina e é refeito a cada execução. Em produção, a troca é por uma base vetorial persistente (pgvector, Chroma, Qdrant e outras); o resto do código não muda, porque todas expõem o mesmo as_retriever.
O prompt
O prompt é onde o RAG ganha ou perde confiança. Ele precisa de três instruções: usar só a documentação, dizer quando a resposta não está lá e citar as fontes.
from langchain_core.prompts import ChatPromptTemplate
NOT_FOUND = "Não encontrei essa informação na documentação."
def create_rag_prompt() -> ChatPromptTemplate:
system = (
"system",
"You are the support assistant of Nuvem. Answer in Portuguese using ONLY "
"the documentation below. If the answer is not in the documentation, "
f'reply exactly: "{NOT_FOUND}" '
"At the end, list the sources you used in the format [file > section].\n\n"
"Documentation:\n{context}",
)
user = ("user", "{question}")
return ChatPromptTemplate.from_messages([system, user])
A frase fixa para "não encontrei" é importante: sem ela, quando a busca não traz nada útil, o modelo tende a responder com o que acha que sabe, e voltamos ao problema do começo. Com uma frase exata, o código ainda consegue detectar esses casos (para registrar, por exemplo, quais perguntas a documentação não cobre).
Montando a chain
Os trechos encontrados precisam virar texto para entrar no prompt. Cada um vai com o seu rótulo de fonte, para o modelo poder citá-lo:
def source_label(doc):
"""Readable reference, e.g. 'cobranca.md > Cobrança em duplicidade'."""
section = doc.metadata.get("section") or doc.metadata.get("title", "")
return f"{doc.metadata['source']} > {section}"
def format_docs(docs):
"""Join the chunks with their source, so the model can cite them."""
return "\n\n---\n\n".join(f"[{source_label(d)}]\n{d.page_content}" for d in docs)
A chain usa o mesmo RunnablePassthrough.assign das partes anteriores: primeiro busca os trechos, depois gera a resposta e, em paralelo, monta a lista de fontes:
from operator import itemgetter
from langchain_core.runnables import RunnableLambda, RunnablePassthrough
def create_rag_chain(llm=None, retriever=None):
llm = llm or init_chat_model(model="gemini-2.5-flash", model_provider="google_genai")
retriever = retriever or create_retriever()
to_prompt_input = RunnableLambda(
lambda x: {"question": x["question"], "context": format_docs(x["docs"])}
)
generate = to_prompt_input | create_rag_prompt() | llm | StrOutputParser()
return (
RunnablePassthrough.assign(docs=itemgetter("question") | retriever)
| RunnablePassthrough.assign(
answer=generate,
sources=lambda x: list(dict.fromkeys(source_label(d) for d in x["docs"])),
)
)
O resultado traz a resposta e o caminho até ela:
chain = create_rag_chain() # a indexação acontece aqui, uma vez
result = chain.invoke({"question": "Fui cobrado duas vezes. Em quanto tempo recebo o estorno?"})
result["answer"] # resposta baseada no trecho de cobrança em duplicidade
result["sources"] # ['cobranca.md > Cobrança em duplicidade', ...]
result["docs"] # os trechos completos, para log ou depuração
Uma observação: sources lista os trechos recuperados, que foram para o prompt. As fontes que o modelo realmente usou aparecem no fim da própria resposta, por causa da instrução no prompt.
python src/main_rag.py
O main_rag.py faz quatro perguntas, e a última ("Vocês têm aplicativo para Android?") não está na documentação de propósito: é o teste da frase de "não encontrei".
Testando a busca
No RAG, a maioria dos erros vem da busca, não do modelo: se o trecho certo não chega ao prompt, nenhum prompt salva a resposta. Por isso vale testar o retriever separadamente.
Nos testes do repositório, rodei a busca com embeddings falsos baseados só nas palavras do texto (sem chamar a API). Mesmo assim, o primeiro trecho veio do documento certo nas cinco perguntas:
| Pergunta | Primeiro trecho recuperado |
|---|---|
| Fui cobrado duas vezes. Em quanto tempo recebo o estorno? | cobranca.md > Cobrança em duplicidade |
| Paguei o boleto e a conta continua bloqueada | cobranca.md > Conta bloqueada por falta de pagamento |
| Meu webhook foi desativado, como reativo? | integracoes.md > Novas tentativas e desativação |
| Posso pedir reembolso depois de cancelar? | cancelamento.md > Como cancelar |
| Como corrijo o CNPJ da nota fiscal? | nota-fiscal.md > Emissão |
Repare nas duas últimas: o documento está certo, mas a seção ideal (Reembolso; Notas antigas e correções) veio em segundo. É por isso que se usa k=3 e não k=1: uma margem para a busca não precisar acertar em cheio. Com embeddings reais, que entendem significado, o resultado tende a ser melhor ainda; com a sua base, monte uma lista de perguntas e o trecho esperado para cada uma e acompanhe quantas acertam.
Boas práticas e cuidados
- Indexe uma vez, consulte muitas. Crie o retriever na inicialização da aplicação, não a cada pergunta: gerar embeddings de todos os trechos toda vez é lento e caro.
- Divida seguindo a estrutura do documento. Cabeçalhos, artigos e seções são fronteiras naturais de assunto. Cortar só por número de caracteres mistura assuntos no mesmo trecho.
- Calibre o tamanho dos trechos. Trechos muito pequenos perdem contexto ("o prazo é de 3 dias", prazo de quê?); muito grandes trazem texto irrelevante para o prompt. Comece em algumas centenas de caracteres e ajuste olhando os resultados.
- Guarde metadados. Arquivo, seção, data de atualização: servem para citar fontes, filtrar a busca e depurar.
- Sempre dê uma saída para "não sei". E registre quando ela acontece: essas perguntas mostram o que falta na documentação.
- Teste a busca antes do modelo. Uma lista de perguntas com o trecho esperado é a avaliação mais barata e mais útil de um RAG.
- Reindexe quando os documentos mudarem. Uma política atualizada no arquivo só vale para o RAG depois de uma nova indexação.
- Trate o conteúdo dos documentos como dado, não como instrução. Se a base aceita textos de fora (e-mails, tickets, páginas da web), alguém pode escrever "ignore as instruções anteriores" dentro de um deles. O prompt deve deixar claro que a documentação é material de consulta.
Resumo e referências
- RAG busca os trechos relevantes da sua documentação e coloca no prompt, em vez de confiar no que o modelo "sabe".
- A indexação (carregar, dividir, gerar embeddings, guardar) roda uma vez; a consulta (buscar, montar o prompt, gerar) roda a cada pergunta.
- Dividir por cabeçalho e depois por tamanho mantém cada trecho sobre um assunto só, com metadados para citar a fonte.
- O prompt deve restringir a resposta à documentação, ter uma frase fixa para "não encontrei" e pedir as fontes.
- A busca é o ponto mais frágil: teste o retriever separadamente.
Até aqui, cada pergunta é independente: o RAG não lembra o que foi perguntado antes. A Parte 8 (Memória e histórico de conversa) resolve isso.