LangChain sem mágica: montando uma chain RAG com LCEL
Sexto e último post técnico da série sobre o Konhecia. Temos infraestrutura, API, persistência, modelos locais e busca vetorial. Falta costurar tudo.
Vou ser direto sobre o LangChain: ele tem má fama merecida. A versão antiga era um
emaranhado de classes Chain que escondiam o que estava acontecendo, e depurar era
sofrimento. O LCEL (LangChain Expression Language) é a resposta a isso, e mudou
minha opinião — não porque faz mais, mas porque esconde menos.
O | não é mágica
A base do LCEL é um protocolo, o Runnable. Qualquer coisa que implemente
invoke, ainvoke, stream e astream é um Runnable — e Runnables se compõem
com |.
chain = prompt | model | parser
Se você já usou pipe no shell, o modelo mental é literalmente esse: a saída de um é
a entrada do próximo. prompt recebe um dicionário e devolve mensagens; model
recebe mensagens e devolve uma resposta; parser recebe a resposta e devolve
str.
O ganho real não é a sintaxe bonita. É que a chain inteira herda a interface:
resposta = await chain.ainvoke({...}) # resposta completa
async for pedaco in chain.astream({...}): # token a token
...
Você não escreve duas implementações, uma síncrona e uma com streaming. Escreve uma chain e escolhe como consumi-la. Isso é o que justifica a dependência — implementar streaming à mão através de três camadas dá muito mais trabalho do que parece.
Montando o prompt
Um ChatPromptTemplate monta a lista de mensagens que vai ao modelo. Três blocos:
prompt = ChatPromptTemplate.from_messages([
("system", system_prompt), # persona e regras
MessagesPlaceholder("history"), # turnos anteriores
("human", HUMAN_PROMPT_TEMPLATE), # contexto recuperado + pergunta
])
O MessagesPlaceholder é o que dá memória ao agente. Ele reserva um espaço que
recebe uma lista de mensagens em tempo de execução — o histórico que veio do
Postgres, convertido para o formato do LangChain:
def _message_to_langchain(message: Message) -> BaseMessage:
if message.role == MessageRole.USER:
return HumanMessage(content=message.content)
return AIMessage(content=message.content)
A janela é limitada de propósito (RAG_HISTORY_WINDOW=6). Histórico ilimitado
estoura a janela de contexto e, muito antes disso, degrada a resposta — turnos
antigos e irrelevantes competem com o contexto recuperado pela atenção do modelo.
E o bloco humano junta contexto e pergunta:
HUMAN_PROMPT_TEMPLATE = (
"Contexto recuperado da base de conhecimento:\n{context}\n\n"
"Pergunta do usuario: {question}"
)
O {context} é a concatenação dos chunks, cada um rotulado com procedência:
blocks.append(f"[Fonte: {source}, pagina {page}]\n{doc.page_content}")
Esse rótulo faz diferença dupla: ajuda o modelo a se referir às fontes na resposta, e ajuda você a ler o prompt final quando algo dá errado.
O prompt anti-alucinação (e as duas reescritas)
Aqui está a parte que consumiu mais iterações do projeto. Fundamentar respostas em documentos é fácil de descrever e difícil de garantir.
A primeira versão era o óbvio: “responda apenas com base no contexto; se não souber, diga que não encontrou”. Não funcionou de forma confiável. O modelo hedgeava.
Hedge é o comportamento de não recusar nem responder — ele especula com qualificador. “Não há detalhes específicos sobre isso, mas provavelmente o procedimento envolve…”, “pode-se inferir que…”. Formalmente não mentiu; disse que estava inferindo. Na prática, entregou uma informação inventada com aparência de resposta — que é pior que alucinar sem aviso, porque soa cuidadoso.
A correção foi proibir explicitamente o padrão linguístico, não só o comportamento abstrato:
- PROIBIDO usar linguagem especulativa como "pode-se inferir", "provavelmente",
"presumivelmente", "e possivel que", "deve ser" ou qualquer variacao que
apresente uma suposicao como se fosse fato. Se voce perceber que esta prestes a
especular, pare e declare que aquela parte da informacao nao esta disponivel no
material, em vez de arriscar um palpite.
E o caso da resposta parcial, que é o mais comum na vida real — o contexto responde metade da pergunta:
- Se o contexto responder apenas PARTE da pergunta, responda somente a parte
coberta pelo contexto e declare explicitamente que o restante nao consta no
material disponivel. Nunca preencha a lacuna com suposicao.
A lição geral: instrução vaga produz comportamento vago. “Não invente” é vago. “Não use estas construções, e ao perceber que ia usá-las, faça isto” é acionável — e mensurável, porque dá para testar.
Duas camadas, uma frase de recusa
A arquitetura anti-alucinação final tem duas camadas independentes:
- Recusa seca, no código. Se nenhum chunk atinge o limiar de relevância, a frase de recusa é devolvida sem chamar o LLM. Rápido, barato, sem chance de improviso.
- Recusa instruída, no prompt. Mesmo com chunks acima do limiar, o modelo é instruído a usar a mesma frase quando o contexto não responde de fato.
Elas existem porque, como vi no post sobre busca vetorial, o limiar é permissivo demais para ser a única defesa. A camada 1 pega o caso fácil; a camada 2 pega o caso em que os chunks pontuaram bem e ainda assim não servem.
O detalhe que amarra o desenho: as duas convergem para a mesma frase
configurável, vinda do .env. Uma única constante, um único comportamento
observável, e um único ponto para o código detectar que houve recusa.
O bug que um teste pegou
Com duas camadas, surge um problema de classificação: como o código sabe que o modelo recusou?
A resposta ingênua é comparar a saída com a frase de recusa. Foi o que fiz. E estava errado, porque o modelo nem sempre devolve a frase isolada — às vezes ele a embute no fim de uma explicação:
“Não há detalhes específicos sobre isso no material. Portanto, baseado apenas no contexto disponibilizado, [frase de recusa]”
Com igualdade exata, isso não era reconhecido como recusa. E a consequência é
séria: a resposta era marcada como grounded: true e devolvia as fontes —
citando um documento que não continha a informação. O sistema estava dando aval
de procedência a uma não-resposta. É o pior resultado possível num agente cujo
propósito inteiro é ser confiável.
A correção é uma linha; encontrá-la é que foi o trabalho:
if settings.persona_fallback_message in answer_text: # substring, não igualdade
return RagAnswer(answer=settings.persona_fallback_message, sources=[], grounded=False)
E virou teste, com nome que explica o cenário:
def test_classify_answer_detects_fallback_embedded_in_longer_text():
hedged_refusal = (
"Nao ha detalhes especificos sobre isso no material. Portanto, baseado "
f"apenas no contexto disponibilizado, {settings.persona_fallback_message}"
)
result = _classify_answer(hedged_refusal, relevant_docs, settings)
assert result.grounded is False
assert result.sources == []
Streaming sem vazar fontes
A versão em streaming tem uma sutileza que decorre direto do bug acima.
Você só consegue classificar a resposta depois de ter o texto inteiro. Mas o streaming, por definição, emite antes de terminar. Se as fontes fossem enviadas junto com os tokens, uma resposta que acabou sendo recusada já teria vazado citações pelo caminho.
A solução é acumular enquanto emite, e classificar no fim:
chunks: list[str] = []
async for piece in chain.astream({...}):
chunks.append(piece)
if piece: # o Ollama pode emitir um chunk final vazio
yield ("token", piece)
full_text = "".join(chunks)
yield ("final", _classify_answer(full_text, relevant_docs, settings))
Por isso o evento done do SSE só carrega sources e grounded no final — não é
detalhe de protocolo, é consequência de correção.
(E sim, aquele if piece é real: o Ollama emite um chunk final vazio só com
metadados. Sem a guarda, o cliente recebe um evento token com texto em branco.)
Quando não usar LangChain
Para fechar honesto. LangChain vale a pena quando você quer streaming uniforme, troca de provedor de modelo sem reescrever, e as integrações prontas de vector store e loaders. Foi o caso aqui.
Não vale quando a tarefa é uma chamada direta a um modelo. Se você vai só mandar um prompt e ler a resposta, use o SDK do provedor. LangChain no meio disso é uma camada de indireção que só adiciona dependência, superfície de API para aprender e um lugar a mais para o bug se esconder.
A pergunta útil é: “quantos Runnables diferentes eu realmente vou compor?”. Se a resposta for “um”, não é chain — é uma função.
Com isso a série técnica fecha. O próximo post é a retrospectiva do projeto: o ambiente onde foi construído, os 19 testes contra infraestrutura real, o que deu certo e o que eu faria diferente.