04-06-03 — Desafio: arquitetura RAG para 10k PDFs jurídicos
TL;DR
Cenário real: escritório de advocacia com 10.000 contratos em PDF precisa buscar cláusulas específicas, identificar riscos e citar exatamente onde está a informação (página e parágrafo). Não é um RAG genérico — exige OCR de qualidade, chunking que respeite estrutura jurídica, metadata rico (partes, data, tipo de contrato) e compliance LGPD para dados de clientes. Este tópico apresenta o problema, as decisões de arquitetura e um template para você preencher com sua solução.
O cenário
Requisitos não-triviais
- OCR: 40% dos PDFs são escaneados (imagens), não texto searchable. Precisam de OCR antes de qualquer processamento.
- Tabelas: Contratos têm tabelas (cronogramas, valores, partes). Extração de tabelas precisa preservar estrutura.
- Citação com localização: Advogado precisa saber "página 7, cláusula 3.2" — não apenas o texto recuperado.
- Compliance LGPD: Contratos contêm dados pessoais (CPF, endereço das partes). O sistema não pode vazar esses dados para usuários sem autorização.
- Multi-tenant: Advogados diferentes só podem ver contratos dos seus clientes.
Decisões de arquitetura
1. Extração de texto — Azure Document Intelligence
Use o modelo prebuilt-layout do Azure Document Intelligence (ex-Form Recognizer). Ele extrai texto com coordenadas de página, identifica tabelas como objetos estruturados e funciona em PDFs escaneados (OCR nativo).
from azure.ai.formrecognizer import DocumentAnalysisClient
from azure.core.credentials import AzureKeyCredential
client = DocumentAnalysisClient(
endpoint="https://meu-doc-intelligence.cognitiveservices.azure.com",
credential=AzureKeyCredential("SUA_CHAVE"),
)
with open("contrato.pdf", "rb") as f:
poller = client.begin_analyze_document("prebuilt-layout", f)
result = poller.result()
# Extrair texto por parágrafo com número de página
paragrafos = []
for para in result.paragraphs:
paragrafos.append({
"texto": para.content,
"pagina": para.bounding_regions[0].page_number if para.bounding_regions else None,
"role": para.role, # "title", "sectionHeading", "footnote", etc.
})
# Extrair tabelas estruturadas
for table in result.tables:
print(f"Tabela na página {table.bounding_regions[0].page_number}: {table.row_count}x{table.column_count}")
for cell in table.cells:
print(f" [{cell.row_index},{cell.column_index}]: {cell.content}")
2. Chunking para documentos jurídicos
O chunking padrão (tamanho fixo de tokens) quebra cláusulas ao meio — inaceitável. Use chunking semântico baseado na estrutura do documento:
def chunk_contrato_juridico(paragrafos, max_tokens=400):
"""
Chunking que respeita a estrutura de contratos:
- Mantém cláusulas inteiras (identifica por role=sectionHeading)
- Preserva metadata de localização (página, número da cláusula)
- Não quebra cláusulas no meio
"""
chunks = []
chunk_atual = []
tokens_atuais = 0
clausula_atual = "Preâmbulo"
pagina_inicio = 1
for para in paragrafos:
# Nova cláusula detectada
if para["role"] == "sectionHeading":
# Salvar chunk anterior se não vazio
if chunk_atual:
chunks.append({
"texto": " ".join([p["texto"] for p in chunk_atual]),
"clausula": clausula_atual,
"pagina_inicio": pagina_inicio,
"pagina_fim": chunk_atual[-1]["pagina"],
})
chunk_atual = []
tokens_atuais = 0
clausula_atual = para["texto"]
pagina_inicio = para["pagina"]
tokens_para = len(para["texto"].split()) * 1.3 # Estimativa tokens
# Se chunk ficaria grande demais, fechar e começar novo
if tokens_atuais + tokens_para > max_tokens and chunk_atual:
chunks.append({
"texto": " ".join([p["texto"] for p in chunk_atual]),
"clausula": clausula_atual,
"pagina_inicio": pagina_inicio,
"pagina_fim": chunk_atual[-1]["pagina"],
})
chunk_atual = []
tokens_atuais = 0
pagina_inicio = para["pagina"]
chunk_atual.append(para)
tokens_atuais += tokens_para
# Último chunk
if chunk_atual:
chunks.append({
"texto": " ".join([p["texto"] for p in chunk_atual]),
"clausula": clausula_atual,
"pagina_inicio": pagina_inicio,
"pagina_fim": chunk_atual[-1]["pagina"],
})
return chunks
3. Schema do índice AI Search com metadata jurídico
fields = [
SimpleField("id", SearchFieldDataType.String, key=True),
SearchableField("texto", SearchFieldDataType.String, analyzer_name="pt.lucene"),
SimpleField("contrato_id", SearchFieldDataType.String, filterable=True, facetable=True),
SimpleField("cliente_id", SearchFieldDataType.String, filterable=True), # Multi-tenant
SimpleField("tipo_contrato", SearchFieldDataType.String, filterable=True, facetable=True),
# "NDA", "Prestacao_Servicos", "Compra_Venda", "Societario"
SimpleField("clausula", SearchFieldDataType.String, filterable=True),
SimpleField("pagina_inicio", SearchFieldDataType.Int32, filterable=True, sortable=True),
SimpleField("pagina_fim", SearchFieldDataType.Int32, filterable=True),
SimpleField("data_assinatura", SearchFieldDataType.DateTimeOffset, filterable=True, sortable=True),
SimpleField("partes", SearchFieldDataType.Collection(SearchFieldDataType.String), filterable=True),
SimpleField("tem_clausula_lgpd", SearchFieldDataType.Boolean, filterable=True, facetable=True),
SearchField(
"texto_vector",
SearchFieldDataType.Collection(SearchFieldDataType.Single),
searchable=True,
vector_search_dimensions=1536,
vector_search_profile_name="hnswProfile",
),
]
filter=f"cliente_id eq '{cliente_id_do_usuario}'" em toda query. Nunca confie apenas no controle de acesso da interface — filtre no índice.
Template de arquitetura para preencher
Use este template para documentar sua solução antes de implementar:
| Componente | Serviço escolhido | Justificativa | Custo estimado/mês |
|---|---|---|---|
| Armazenamento dos PDFs | Azure Blob Storage / OneLake | ||
| Extração de texto / OCR | Azure Document Intelligence | ||
| Geração de embeddings | text-embedding-3-small / large | ||
| Vector store / índice | Azure AI Search (SKU?) | ||
| LLM para geração | GPT-4o / GPT-4o-mini | ||
| Orquestração | Semantic Kernel / LangChain / custom | ||
| Autenticação / multi-tenant | Azure AD + security filters | ||
| Avaliação de qualidade | RAGAS / DeepEval / Azure Eval |
Estimativa de custo de referência (10k PDFs)
| Item | Volume | Custo estimado |
|---|---|---|
| Document Intelligence (prebuilt-layout) | 10k docs × ~20 páginas = 200k páginas | ~US$ 300 (one-time) |
| Embeddings (text-embedding-3-small) | ~50M tokens (10k docs × 5k tokens) | ~US$ 10 (one-time) |
| Azure AI Search (Standard S1) | 1 réplica, 1 partição, ~500k docs | ~US$ 250/mês |
| Azure OpenAI GPT-4o | ~10k queries/mês × 2k tokens avg | ~US$ 100-300/mês |
| Total estimado | ~US$ 350-600/mês + setup ~US$ 310 |
tem_pii no índice, (3) mascaramento de PII na resposta final para usuários sem perfil de compliance. Documente o fluxo de dados para o DPO.
Fluxo de consulta com citação precisa
def buscar_clausula(query: str, cliente_id: str, top_k: int = 5):
"""
Busca cláusulas com citação precisa (contrato, cláusula, página)
"""
# 1. Gerar embedding da query
embedding = openai_client.embeddings.create(
input=query, model="text-embedding-3-small"
).data[0].embedding
# 2. Hybrid search com security filter (multi-tenant)
results = search_client.search(
search_text=query,
vector_queries=[VectorizedQuery(
vector=embedding,
k_nearest_neighbors=top_k,
fields="texto_vector",
)],
filter=f"cliente_id eq '{cliente_id}'", # CRÍTICO: isolamento por cliente
query_type=QueryType.SEMANTIC,
semantic_configuration_name="default",
top=top_k,
select=["texto", "contrato_id", "clausula", "pagina_inicio", "tipo_contrato"],
)
# 3. Montar contexto com localização
contexto_com_citacoes = []
for r in results:
contexto_com_citacoes.append(
f"[Contrato: {r['contrato_id']} | Tipo: {r['tipo_contrato']} | "
f"Cláusula: {r['clausula']} | Página: {r['pagina_inicio']}]\n"
f"{r['texto']}"
)
# 4. Gerar resposta com instrução de citar fonte
prompt = f"""Responda à pergunta abaixo com base APENAS nos trechos de contratos fornecidos.
Para cada informação, cite o contrato e a página exata no formato [Contrato X, p.Y, Cláusula Z].
Não invente informações que não estejam nos trechos.
PERGUNTA: {query}
TRECHOS:
{chr(10).join(contexto_com_citacoes)}"""
response = openai_client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": prompt}],
temperature=0, # Determinístico para contexto jurídico
)
return response.choices[0].message.content
Como isso se conecta
- 04-03-01 Chunking: o chunking por cláusula jurídica é uma especialização do parent-document retriever
- 04-03-03 Hybrid search: essencial para contratos — termos jurídicos específicos (BM25) + semântica (vetorial)
- 04-04-01 Re-ranking: o semantic ranker do AI Search é crítico para precisão em queries jurídicas ambíguas
- 04-05-02 RAGAS, DeepEval: use faithfulness e context_precision para validar que o sistema cita corretamente
- 05-01 Agentes: a próxima evolução é um agente jurídico que compara cláusulas entre múltiplos contratos automaticamente
Fontes
- Microsoft Learn — Document Intelligence prebuilt-layout model — microsoft.com
- Microsoft Learn — Security trimming in Azure AI Search (multi-tenant) — microsoft.com
- Microsoft Presidio — PII Detection and Anonymization — microsoft.github.io
- Azure AI Search — Pricing Model — microsoft.com