LedgerlineInteligência contratual com recuperação avaliada
Arquitetura de referência para responder perguntas sobre milhares de contratos com citação obrigatória da cláusula de origem e recusa explícita quando a evidência é fraca.
- Disciplina
- IA & Machine Learning
- Tecnologias
- Python · PostgreSQL · pgvector · LLM API · TypeScript
- Demonstra
- Recuperação híbrida
- Respostas com citação verificada
- Avaliação contínua de RAG
- Privacidade antes do modelo
O desafio
- 01
Contratos têm numeração de cláusulas, nomes de partes e valores exatos. Busca puramente semântica erra justamente nesses termos.
- 02
Uma resposta sem fonte é inútil para um jurídico: cada afirmação precisa apontar para o trecho exato do documento.
- 03
Trocar de modelo ou de estratégia de chunking não pode ser decisão no escuro. A qualidade da recuperação precisa ser medida antes da geração.
Restrições
- Documentos com dados pessoais
- Respostas auditáveis
- Custo por consulta previsível
Arquitetura
Componentes
- Contracts
- PDFs e DOCX, incluindo digitalizados.
- Parse + OCR
- Extrai texto preservando a hierarquia de cláusulas.
- PII redaction
- Mascara dados pessoais antes de qualquer chamada externa.
- Clause chunker
- Um trecho por cláusula, com o caminho hierárquico como metadado.
- Hybrid index
- PostgreSQL com pgvector para embeddings e busca textual para termos exatos.
- Question
- Pergunta em linguagem natural do usuário.
- Retrieve + RRF
- Busca vetorial e textual em paralelo, combinadas por Reciprocal Rank Fusion.
- Rerank
- Reordena os candidatos e aplica um limiar mínimo de relevância.
- Cited answer
- O modelo só pode afirmar o que está nos trechos citados. Sem evidência, recusa.
- Eval set
- Perguntas de referência com as cláusulas corretas: mede recall@k e fidelidade.
Decisões de engenharia
D1Busca híbrida em vez de apenas vetorial
- Escolha
- Vetorial + full-text, combinadas por Reciprocal Rank Fusion.
- Alternativa descartada
- Apenas embeddings.
- Por quê
- “Cláusula 14.2” e “multa de 2%” são termos exatos. Embeddings aproximam significado, mas confundem números e identificadores.
D2Chunking pela estrutura do documento
- Escolha
- Um trecho por cláusula, com o caminho (Seção 4 › 4.3) como metadado.
- Alternativa descartada
- Janelas fixas de N tokens com sobreposição.
- Por quê
- Janelas fixas cortam cláusulas ao meio e misturam obrigações diferentes no mesmo trecho, o que gera citações enganosas.
D3Recusar é um resultado válido
- Escolha
- Abaixo do limiar de relevância, o sistema responde que não encontrou base no documento.
- Alternativa descartada
- Sempre gerar uma resposta.
- Por quê
- Para um time jurídico, uma resposta confiante e errada custa mais do que nenhuma resposta.
D4Avaliação antes de trocar qualquer peça
- Escolha
- Conjunto de perguntas de referência rodando no CI a cada mudança de modelo, prompt ou chunking.
- Alternativa descartada
- Avaliação manual pontual.
- Por quê
- Sem medida, melhorias de prompt viram regressões silenciosas na recuperação.
Detalhes de implementação
A ingestão roda como job assíncrono: cada documento passa por extração, mascaramento de dados pessoais e segmentação por cláusula antes de ser indexado. Reprocessar um documento é idempotente, porque o identificador do trecho deriva do documento e do caminho da cláusula.
Na consulta, as buscas vetorial e textual rodam em paralelo e são combinadas por posição (RRF), o que dispensa calibrar escalas de score diferentes. O reranker aplica o limiar que decide entre responder e recusar.
O modelo recebe apenas os trechos aprovados, cada um com um identificador. A resposta é validada: toda citação precisa existir entre os trechos enviados, ou a resposta é descartada.
def reciprocal_rank_fusion(rankings: list[list[str]], k: int = 60) -> list[tuple[str, float]]:
"""Merge ranked lists by position, so incompatible score scales never matter."""
scores: dict[str, float] = {}
for ranking in rankings:
for position, chunk_id in enumerate(ranking, start=1):
scores[chunk_id] = scores.get(chunk_id, 0.0) + 1.0 / (k + position)
return sorted(scores.items(), key=lambda item: item[1], reverse=True)
fused = reciprocal_rank_fusion([vector_hits, lexical_hits])
evidence = [c for c, _ in rerank(question, fused[:40]) if c.relevance >= MIN_RELEVANCE]
if not evidence:
return Answer.refusal("No supporting clause found in the indexed contracts.")Resultados
Não aplicável. Por ser uma demonstração interna, não há métricas de produção verificadas a relatar, e não publicamos números estimados como se fossem resultados.
Tem um problema parecido?
Conte o contexto. Discutimos a arquitetura antes de qualquer proposta comercial.
Próximo estudoQuorum IndexerIndexador EVM resistente a reorganizações