0xEtern4lW0lf
IA local

LangChain sem mágica: montando uma chain RAG com LCEL

7 min de leitura
#LangChain#LCEL#RAG#prompt engineering#alucinação#Python

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:

  1. 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.
  2. 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.