Skip to main content

Contract Analysis and NLP

The 3 AM Contract Review

It is 3 AM in a midtown Manhattan law firm. A junior associate - three years out of law school, buried under student debt, running on cold coffee - is reviewing the 847th page of a merger agreement. The acquisition closes in six hours. Her task: find every indemnification clause, every change-of-control provision, every representation that could expose the buyer to post-close liability.

She has been at this for fourteen hours. Her eyes slide over the same sentence three times before she registers it. Buried in Section 9.4(b)(ii)(C), sandwiched between two boilerplate paragraphs about governing law, is a carve-out that caps the seller's indemnification obligation at 2million.Thedealisworth2 million. The deal is worth 400 million. The buyer's legal team missed it. She almost missed it. The partner reviewing her work is asleep.

This scenario plays out in law firms, corporate legal departments, and regulatory agencies every day. The global legal services market generates approximately 2.5 billion pages of contracts per year. Fortune 500 companies each manage tens of thousands of active contracts. A single pharmaceutical company can have over 50,000 supplier agreements. Each one represents a binding legal obligation, a potential liability, a risk that needs to be understood.

The answer is not to hire more exhausted junior associates. The answer is to build NLP systems that read contracts the way the best lawyers do - systematically, without fatigue, with consistent attention to the clauses that matter. Not to replace the lawyer, but to ensure the lawyer is spending her cognitive capacity on judgment rather than extraction.

The field of legal NLP has matured dramatically since 2020. The Contract Understanding Atticus Dataset (CUAD) gave the research community a standardized benchmark. Models like LegalBERT brought domain-specific pre-training to the problem. And GPT-4 showed that zero-shot performance on contract tasks could rival fine-tuned specialized models for many clause types. But fine-tuned models still win on precision-critical extractions. The gap between knowing what a contract says and understanding what it means legally has not closed - but for the extraction layer, machines are now consistently better than tired humans.


Why This Exists

Before legal NLP, contract review was purely manual. The review process had three fundamental problems: it was slow, it was expensive, and it was inconsistent. A contract review that takes a senior associate four hours costs a client 2,000to2,000 to 4,000 in billable time. Multiply that across a due diligence exercise with 2,000 contracts and you have 4to4 to 8 million in legal fees - for work that is essentially structured information extraction.

The inconsistency problem is worse than the cost problem. Two lawyers reviewing the same contract will often disagree on whether a clause is "material." Different reviewers flag different risks. Clause categorization depends on individual training and experience. You cannot audit this variance systematically.

Manual review also does not scale. When a company acquires a target with 15,000 supplier contracts, there is no budget to have lawyers read every one. The team samples. Critical clauses get missed. Post-close surprises - change-of-control provisions that terminate agreements, IP assignments that were not completed, warranty carve-outs that shift risk back to the buyer - cost acquirers billions of dollars annually.

NLP for contract analysis solves the extraction layer. It does not provide legal judgment, but it provides systematic, consistent, auditable extraction of the clauses and terms that lawyers need to exercise that judgment on. It compresses four hours of associate time into four seconds of compute time, at consistent quality.


Historical Context

The first wave of contract analytics arrived around 2015 with companies like Kira Systems and eBrevia. These were primarily machine learning systems using rule-based extraction combined with early transformer-adjacent classifiers. They worked, but they required extensive training data per clause type and extensive configuration per client.

The CUAD dataset, published in 2021 by the Atticus Project (a group of legal AI researchers), changed the landscape. CUAD contains 13,000+ contract excerpts from 500 contracts, annotated across 41 distinct legal clause categories. For the first time, researchers had a standardized benchmark for contract NLP.

LegalBERT, published in 2020 by Chalkidis et al., showed that pre-training BERT on a large corpus of legal text (court opinions, contracts, legislation) significantly outperformed standard BERT on legal classification tasks. The model learned legal domain language - terms like "indemnification," "representations and warranties," "material adverse change" - from in-domain text rather than Wikipedia.

GPT-4, in 2023, showed that a sufficiently capable general-purpose model with a well-engineered prompt could match or exceed fine-tuned legal models on many CUAD tasks in zero-shot settings. This did not kill fine-tuning - it still wins on precision-critical tasks, on customized clause types not in CUAD, and in latency-constrained production environments. But it raised the floor for what "baseline performance" means.


Core Concepts

The Contract NLP Task Taxonomy

Contract analysis breaks into five distinct NLP tasks. Understanding these as separate problems is critical for building the right system architecture.

Clause extraction and classification is the most foundational task. Given a contract, find every clause of type X. "Find all indemnification clauses." "Find all limitation of liability provisions." This is a combination of span extraction (where is the clause?) and classification (what type is it?). It maps to the question-answering formulation in CUAD.

Obligation identification goes one level deeper than clause extraction. A contract can contain many clauses; an obligation is a specific commitment by a named party. "Seller shall deliver the goods within 30 days" contains an obligation: Seller, deliver goods, within 30 days. NLP systems extract the obligation triplet: (party, obligation, deadline or condition). This requires named entity recognition, relation extraction, and temporal reasoning.

Risk flagging is classification at the clause level: is this clause favorable, neutral, or unfavorable to our client? Risk depends on perspective (buyer vs seller, licensor vs licensee). A system flags clauses as high/medium/low risk from the specified party's perspective. This requires understanding both the clause content and the legal context.

Party and entity identification extracts the named parties, their defined names in the contract (often called "the Company" or "Licensor"), and ensures that clause-level extraction attributes obligations correctly to the right party.

Date and term extraction pulls effective dates, expiration dates, notice periods, payment timelines, and any temporal condition in the contract. This feeds contract lifecycle management systems that need to know when obligations are due and when contracts expire.

The CUAD Benchmark

CUAD frames contract analysis as a machine reading comprehension task. For each of 41 question types, the model must identify the span of text in the contract that answers the question, or output "None" if no such clause exists.

The 41 CUAD clause types cover the most commercially significant provisions:

CategoryExamples
Parties and termsParties, governing law, effective date, expiration date
IP provisionsIP ownership, license grants, source code escrow
Financial termsRevenue/profit sharing, price restrictions, minimum commitment
TerminationTermination for convenience, notice period, change of control
LiabilityLimitation of liability, uncapped liability, indemnification
OperationalNon-compete, non-solicitation, audit rights, insurance

The CUAD baseline (a fine-tuned RoBERTa-large model) achieves approximately 42% F1 on the full 41-category task in zero-shot settings. Fine-tuned LegalBERT variants achieve 65-70% F1. The difficulty varies enormously by clause type - effective dates are extracted at 90%+ accuracy while complex IP provisions may be below 50%.

Legal language breaks most assumptions that general NLP models make. Four properties make it particularly difficult:

Sentence length. Legal sentences routinely run 200-400 tokens. A single BERT sequence is 512 tokens. A single legal sentence can span a significant fraction of that limit. Strategies include hierarchical encoding, sliding window approaches, and long-context models like Longformer.

Coreference and defined terms. Contracts define terms with capital letters: "the Company," "the Agreement," "the Effective Date." Resolving what each defined term refers to - especially across a 150-page agreement with cross-references - requires document-level coreference resolution that standard sentence-level models miss.

Negation and carve-outs. Legal clauses are often defined by what they exclude. "Indemnification shall not cover losses arising from Indemnitee's gross negligence, except in cases where..." - the critical content is in the exception to the exception. Models trained on general text struggle with nested negation structures.

Jurisdiction-specific meaning. "Material adverse change" means different things in Delaware M&A law, English law, and New York commercial law. A clause that is routine in one jurisdiction can be highly unusual in another. Domain-specific pre-training helps, but jurisdiction-aware modeling requires additional structure.

Fine-Tuning vs Zero-Shot for Contract NLP

The practical question every team faces: fine-tune a legal model or use GPT-4 with a well-engineered prompt?

The answer depends on the use case. Zero-shot GPT-4 with chain-of-thought prompting achieves 60-75% F1 on common CUAD clause types. This is impressive for zero-shot performance and eliminates the need for labeled training data. But it has three problems for production:

  1. Latency: GPT-4 takes 5-30 seconds per contract page. A 200-page contract review takes 15-100 minutes.
  2. Cost: At GPT-4 API pricing, processing 10,000 contracts costs 5,0005,000-50,000 depending on length.
  3. Consistency: LLM outputs can vary across runs. For a legal workflow, the same clause in the same contract should always be classified the same way.

Fine-tuned smaller models (LegalBERT, RoBERTa-base) run at 10-50ms per page, cost fractions of a cent, and are deterministic. The tradeoff is that they require labeled training data (expensive to create) and do not generalize to novel clause types without retraining.

The production pattern that works: use a fine-tuned model for well-defined clause types where you have training data, and use LLM-based extraction for novel or edge-case clauses where labeled data does not exist.


Code Examples

Setting Up a Contract Clause Extractor with LangChain

"""
Contract clause extraction pipeline using LangChain + LegalBERT.
This implements the extraction layer for a contract review system.
"""

from transformers import pipeline, AutoTokenizer, AutoModelForQuestionAnswering
import torch
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_openai import ChatOpenAI
from langchain.schema import HumanMessage, SystemMessage
from typing import List, Dict, Optional
import json
import re

# --- 1. Load a fine-tuned legal QA model ---

class LegalBERTExtractor:
"""
Wraps a fine-tuned QA model (LegalBERT or RoBERTa on CUAD)
for clause extraction from contract text.
"""

def __init__(self, model_name: str = "nlpaueb/legal-bert-base-uncased"):
self.tokenizer = AutoTokenizer.from_pretrained(model_name)
self.model = AutoModelForQuestionAnswering.from_pretrained(model_name)
self.qa_pipeline = pipeline(
"question-answering",
model=self.model,
tokenizer=self.tokenizer,
device=0 if torch.cuda.is_available() else -1,
max_answer_len=512,
handle_impossible_answer=True,
)

def extract_clause(
self,
contract_text: str,
question: str,
threshold: float = 0.3,
) -> Optional[Dict]:
"""
Extract the span of text answering the question.
Returns None if confidence is below threshold (no clause present).
"""
result = self.qa_pipeline(
question=question,
context=contract_text[:3000], # BERT 512-token limit; chunk for real use
)
if result["score"] < threshold:
return None
return {
"answer": result["answer"],
"score": result["score"],
"start": result["start"],
"end": result["end"],
}


# --- 2. CUAD-style questions for key clause types ---

CUAD_QUESTIONS = {
"governing_law": (
"What is the governing law clause or which state/country's law governs the contract?"
),
"effective_date": (
"What is the date on which the contract becomes effective?"
),
"expiration_date": (
"On what date does the contract expire or terminate?"
),
"termination_for_convenience": (
"Is there a provision allowing either party to terminate the contract "
"without cause or for convenience?"
),
"limitation_of_liability": (
"What is the limitation of liability clause and what is the cap on damages?"
),
"indemnification": (
"Does the contract contain an indemnification clause? "
"Which party must indemnify the other?"
),
"ip_ownership": (
"Who owns the intellectual property developed under the contract?"
),
"non_compete": (
"Does the contract contain a non-compete or competitive restriction clause?"
),
"change_of_control": (
"Does the contract contain a change of control provision or "
"assignment restriction triggered by change of control?"
),
"auto_renewal": (
"Does the contract automatically renew? What are the notice requirements to prevent renewal?"
),
}


# --- 3. Full contract extraction pipeline ---

class ContractAnalyzer:
"""
Multi-model contract analysis pipeline.
Uses fine-tuned model for structured extraction,
LLM for risk assessment and narrative summary.
"""

def __init__(self, use_llm_fallback: bool = True):
self.extractor = LegalBERTExtractor()
self.use_llm_fallback = use_llm_fallback
if use_llm_fallback:
self.llm = ChatOpenAI(model="gpt-4o", temperature=0)

# Splitter for chunking long contracts
self.splitter = RecursiveCharacterTextSplitter(
chunk_size=2000,
chunk_overlap=200,
separators=["\n\n", "\n", ". ", " "],
)

def preprocess_contract(self, raw_text: str) -> str:
"""Clean OCR artifacts and normalize whitespace."""
# Remove excessive whitespace
text = re.sub(r"\s+", " ", raw_text)
# Normalize section headers (common OCR issue)
text = re.sub(r"(\d+\.\s+[A-Z][A-Z\s]+)\n", r"\n\1\n", text)
return text.strip()

def extract_all_clauses(self, contract_text: str) -> Dict:
"""
Run all CUAD questions against the contract.
Returns a dictionary of clause type -> extracted text.
"""
text = self.preprocess_contract(contract_text)
results = {}

for clause_type, question in CUAD_QUESTIONS.items():
extraction = self.extractor.extract_clause(text, question)
if extraction is not None:
results[clause_type] = extraction
elif self.use_llm_fallback:
# Fall back to LLM for low-confidence or missing extractions
results[clause_type] = self._llm_extract(text, clause_type, question)

return results

def _llm_extract(
self, contract_text: str, clause_type: str, question: str
) -> Optional[Dict]:
"""
Use GPT-4 to extract clause when fine-tuned model is not confident.
Returns None if clause is not present.
"""
# Use first 8000 chars to stay within context limits for this demo
context = contract_text[:8000]

messages = [
SystemMessage(
content=(
"You are a legal contract analyst. Extract specific clauses from contracts. "
"If the clause does not exist, respond with exactly: NOT_PRESENT. "
"Otherwise, quote the relevant text verbatim."
)
),
HumanMessage(
content=(
f"CONTRACT EXCERPT:\n{context}\n\n"
f"QUESTION: {question}\n\n"
f"Extract the exact text from the contract that answers this question. "
f"If no such clause exists, respond with NOT_PRESENT."
)
),
]

response = self.llm.invoke(messages)
answer = response.content.strip()

if answer == "NOT_PRESENT":
return None
return {"answer": answer, "source": "llm_fallback", "score": None}

def assess_risk(self, extracted_clauses: Dict, party: str = "buyer") -> Dict:
"""
Assess risk of extracted clauses from specified party's perspective.
Returns risk assessments with explanations.
"""
risk_prompts = {
"limitation_of_liability": (
f"From a {party}'s perspective, is this limitation of liability clause "
f"favorable, neutral, or unfavorable? Explain in one sentence."
),
"indemnification": (
f"From a {party}'s perspective, is this indemnification clause "
f"favorable, neutral, or unfavorable? Explain in one sentence."
),
"ip_ownership": (
f"From a {party}'s perspective, does this IP ownership clause "
f"protect your interests? Rate as favorable/neutral/unfavorable."
),
}

risk_results = {}
for clause_type, prompt in risk_prompts.items():
if clause_type in extracted_clauses and extracted_clauses[clause_type]:
clause_text = extracted_clauses[clause_type].get("answer", "")
messages = [
SystemMessage(
content="You are a contract risk analyst. Assess clause risk concisely."
),
HumanMessage(
content=f"CLAUSE: {clause_text}\n\nASSESSMENT QUESTION: {prompt}"
),
]
response = self.llm.invoke(messages)
risk_results[clause_type] = response.content.strip()

return risk_results

def generate_summary(self, extracted_clauses: Dict, contract_name: str) -> str:
"""
Generate a structured contract summary for attorney review.
"""
clause_summary = json.dumps(
{k: v["answer"] if v else "Not present" for k, v in extracted_clauses.items()},
indent=2,
)

messages = [
SystemMessage(
content=(
"You are a senior contract attorney. Generate a concise contract summary "
"for client review. Highlight key terms, obligations, and risk areas. "
"Write in plain English, not legal jargon."
)
),
HumanMessage(
content=(
f"CONTRACT: {contract_name}\n\n"
f"EXTRACTED CLAUSES:\n{clause_summary}\n\n"
f"Generate a 300-word executive summary covering: "
f"(1) key obligations, (2) termination rights, "
f"(3) liability exposure, (4) IP position, (5) renewal terms."
)
),
]

response = self.llm.invoke(messages)
return response.content


# --- 4. Contract comparison: semantic similarity ---

from sentence_transformers import SentenceTransformer
import numpy as np
from sklearn.metrics.pairwise import cosine_similarity

class ContractComparator:
"""
Compare two versions of a contract to identify material changes.
Uses semantic embeddings to find changed clauses.
"""

def __init__(self):
# Legal-specific sentence embedding model
self.model = SentenceTransformer("sentence-transformers/all-mpnet-base-v2")

def _split_into_clauses(self, contract_text: str) -> List[str]:
"""Split contract into clause-level units."""
# Simple heuristic: split on numbered section headers
clauses = re.split(r"\n(?=\d+\.\s)", contract_text)
return [c.strip() for c in clauses if len(c.strip()) > 50]

def compare_versions(
self,
contract_v1: str,
contract_v2: str,
similarity_threshold: float = 0.85,
) -> List[Dict]:
"""
Compare two contract versions clause by clause.
Returns list of changed clauses with similarity scores.
"""
clauses_v1 = self._split_into_clauses(contract_v1)
clauses_v2 = self._split_into_clauses(contract_v2)

# Embed all clauses
embeddings_v1 = self.model.encode(clauses_v1, batch_size=32, show_progress_bar=False)
embeddings_v2 = self.model.encode(clauses_v2, batch_size=32, show_progress_bar=False)

# Find best match for each v2 clause in v1
similarity_matrix = cosine_similarity(embeddings_v2, embeddings_v1)

changes = []
for i, clause_v2 in enumerate(clauses_v2):
best_match_idx = np.argmax(similarity_matrix[i])
best_score = similarity_matrix[i][best_match_idx]

if best_score < similarity_threshold:
changes.append(
{
"v2_clause": clause_v2[:200],
"best_v1_match": clauses_v1[best_match_idx][:200],
"similarity": float(best_score),
"status": "modified" if best_score > 0.5 else "new",
}
)

return changes


# --- 5. Practical usage example ---

def run_contract_analysis(contract_text: str, contract_name: str) -> None:
"""End-to-end contract analysis demonstration."""
analyzer = ContractAnalyzer(use_llm_fallback=True)

print(f"Analyzing contract: {contract_name}")
print("=" * 60)

# Extract all clauses
clauses = analyzer.extract_all_clauses(contract_text)

# Print extraction results
for clause_type, extraction in clauses.items():
if extraction:
answer = extraction.get("answer", "")[:150]
score = extraction.get("score")
score_str = f"{score:.3f}" if score is not None else "LLM"
print(f"\n[{clause_type.upper()}] (confidence: {score_str})")
print(f" {answer}...")
else:
print(f"\n[{clause_type.upper()}] NOT PRESENT")

# Risk assessment
risk = analyzer.assess_risk(clauses, party="buyer")
print("\n--- RISK ASSESSMENT ---")
for clause_type, assessment in risk.items():
print(f"\n{clause_type}: {assessment}")

# Generate summary
summary = analyzer.generate_summary(clauses, contract_name)
print("\n--- EXECUTIVE SUMMARY ---")
print(summary)

CUAD Fine-Tuning Pipeline

"""
Fine-tuning RoBERTa on the CUAD dataset for contract QA.
Uses Hugging Face Trainer with gradient checkpointing for long sequences.
"""

from datasets import load_dataset
from transformers import (
AutoTokenizer,
AutoModelForQuestionAnswering,
TrainingArguments,
Trainer,
DefaultDataCollator,
)
import torch

# Load CUAD dataset
def load_cuad_dataset():
"""Load and prepare CUAD for extractive QA fine-tuning."""
dataset = load_dataset("theatticusproject/cuad")
return dataset

def tokenize_cuad_example(example, tokenizer, max_length=512, stride=128):
"""
Tokenize a CUAD example for extractive QA.
Uses sliding window for long contracts.
"""
tokenized = tokenizer(
example["question"],
example["context"],
max_length=max_length,
truncation="only_second",
stride=stride,
return_overflowing_tokens=True,
return_offsets_mapping=True,
padding="max_length",
)

# Map answer positions to token positions
offset_mapping = tokenized.pop("offset_mapping")
sample_map = tokenized.pop("overflow_to_sample_mapping")

start_positions = []
end_positions = []

for i, offsets in enumerate(offset_mapping):
sample_idx = sample_map[i]
answers = example["answers"]

if len(answers["text"]) == 0:
# No answer (clause not present)
start_positions.append(0)
end_positions.append(0)
continue

# Find the token positions corresponding to the character-level answer span
start_char = answers["answer_start"][0]
end_char = start_char + len(answers["text"][0])

sequence_ids = tokenized.sequence_ids(i)
# Find the context token range
ctx_start = next(j for j, s in enumerate(sequence_ids) if s == 1)
ctx_end = next(
len(sequence_ids) - 1 - j
for j, s in enumerate(reversed(sequence_ids))
if s == 1
)

# Check if answer is in this chunk
if (
offsets[ctx_start][0] > start_char
or offsets[ctx_end][1] < end_char
):
start_positions.append(0)
end_positions.append(0)
else:
start_idx = ctx_start
while start_idx <= ctx_end and offsets[start_idx][0] <= start_char:
start_idx += 1
start_positions.append(start_idx - 1)

end_idx = ctx_end
while end_idx >= ctx_start and offsets[end_idx][1] >= end_char:
end_idx -= 1
end_positions.append(end_idx + 1)

tokenized["start_positions"] = start_positions
tokenized["end_positions"] = end_positions
return tokenized


def train_cuad_model(output_dir: str = "./legal-qa-model"):
"""Train a RoBERTa QA model on CUAD."""
model_name = "roberta-base"
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForQuestionAnswering.from_pretrained(model_name)

dataset = load_cuad_dataset()

# Tokenize dataset
tokenized_dataset = dataset.map(
lambda x: tokenize_cuad_example(x, tokenizer),
batched=False,
remove_columns=dataset["train"].column_names,
)

training_args = TrainingArguments(
output_dir=output_dir,
num_train_epochs=3,
per_device_train_batch_size=8,
per_device_eval_batch_size=16,
learning_rate=2e-5,
weight_decay=0.01,
warmup_ratio=0.1,
fp16=torch.cuda.is_available(),
gradient_checkpointing=True, # Critical for long legal sequences
evaluation_strategy="epoch",
save_strategy="epoch",
load_best_model_at_end=True,
metric_for_best_model="eval_loss",
report_to="none",
)

trainer = Trainer(
model=model,
args=training_args,
train_dataset=tokenized_dataset["train"],
eval_dataset=tokenized_dataset["test"],
tokenizer=tokenizer,
data_collator=DefaultDataCollator(),
)

trainer.train()
trainer.save_model(output_dir)
print(f"Model saved to {output_dir}")
return model, tokenizer

Mermaid Diagrams

Contract Analysis Pipeline Architecture

CUAD Task Types and Model Performance

Zero-Shot vs Fine-Tuned Decision Framework


Production Engineering Notes

Handling Long Contracts

The single biggest practical challenge in contract NLP is length. A commercial agreement can be 150+ pages. An enterprise software agreement with exhibits can be 300+ pages. BERT-based models cap at 512 tokens; even GPT-4 starts degrading on very long contexts.

The standard production approach uses a three-stage pipeline:

Stage 1 - Structural parsing: Use rule-based parsing to identify section boundaries (numbered sections, defined term sections, signature blocks). This creates a document map without running any ML.

Stage 2 - Relevant section retrieval: For each clause type question, use a lightweight retrieval step (BM25 or a small bi-encoder) to identify the top 3-5 sections most likely to contain the answer. This narrows the search space before running the expensive QA model.

Stage 3 - Span extraction: Run the fine-tuned QA model on only the retrieved sections, not the full document.

This three-stage approach reduces inference cost by 70-80% compared to sliding-window approaches that process the entire contract.

OCR Quality and Its Impact on Accuracy

Most enterprise contracts are received as PDFs, and many are scans of physical documents. OCR quality directly impacts extraction accuracy. A word like "indemnification" incorrectly OCR'd as "lndemniflcatlon" will not match model training distribution.

Mitigation strategies:

  • Use high-quality OCR (AWS Textract, Google Document AI) with confidence scores
  • Post-process OCR output with legal vocabulary normalization
  • Flag pages with low OCR confidence for manual review
  • Maintain a legal vocabulary dictionary for common OCR error patterns

Calibration and Confidence Thresholds

A model that says it found a clause with 0.95 confidence and is wrong is worse than a model that says "not found." For legal use, calibrate extraction confidence:

# Calibration check: for each clause type, measure
# precision at each confidence threshold on held-out contracts
def calibrate_thresholds(model, validation_contracts, clause_types):
results = {ct: {"tp": [], "fp": [], "fn": []} for ct in clause_types}
for contract, labels in validation_contracts:
for ct in clause_types:
prediction = model.extract_clause(contract, CUAD_QUESTIONS[ct])
if prediction:
if labels.get(ct): # True positive
results[ct]["tp"].append(prediction["score"])
else: # False positive
results[ct]["fp"].append(prediction["score"])
elif labels.get(ct): # False negative
results[ct]["fn"].append(0.0)
return results

Set thresholds so that precision for each clause type is at or above the attorney review standard (typically 90%+ for high-risk clauses, 80%+ for routine ones).

Audit Trail Requirements

Legal workflows require every extraction to be traceable. When an AI system identifies a clause as a high-risk indemnification provision, there must be a record of exactly which model version produced that output, what input text was processed, and what confidence score was assigned.

Implement structured logging for every extraction:

import structlog
from datetime import datetime

log = structlog.get_logger()

def logged_extraction(contract_id, clause_type, input_text, result, model_version):
log.info(
"clause_extracted",
contract_id=contract_id,
clause_type=clause_type,
model_version=model_version,
confidence=result.get("score") if result else None,
extracted_text=result.get("answer", "NOT_PRESENT") if result else "NOT_PRESENT",
timestamp=datetime.utcnow().isoformat(),
input_hash=hash(input_text), # For reproducibility verification
)

Common Mistakes

:::danger Model output is not legal advice The most dangerous mistake in building contract NLP systems is allowing model outputs to be treated as legal conclusions rather than extraction assistance. A model that extracts "Licensor retains all IP" from a contract has not determined whether that clause is enforceable, whether it conflicts with local law, or whether it was negotiated in bad faith. Every AI-extracted clause needs attorney review before any legal reliance. Build this expectation into the UI with explicit disclaimers and required attorney sign-off workflows. :::

:::danger Hallucinated clause extractions Extractive QA models (BERT-based) can output spans that look like the right clause but are actually from a different section of the contract. LLM-based extraction can hallucinate clause text that does not exist in the contract at all. Always verify: does the extracted text actually appear in the source document? Implement a post-extraction verification step that checks the output is a substring of the input. :::

:::warning Ignoring jurisdiction in risk assessment A limitation of liability clause capping damages at 100Klooksverydifferentina100K looks very different in a 50K SaaS contract versus a $50M services agreement. Risk assessment models that do not incorporate contract value, party size, and jurisdiction will produce misleading risk ratings. Build jurisdiction and deal-size context into risk assessment prompts or models. :::

:::warning Treating CUAD performance as production performance CUAD models are evaluated on a specific set of 500 publicly available contracts. Your enterprise contracts likely differ significantly - different industries, different drafting styles, different jurisdictions. Always evaluate your models on a held-out sample of your actual client contracts, not just on CUAD benchmarks. CUAD F1 scores do not predict performance on your specific contract portfolio. :::

:::warning Chunking at wrong boundaries Chunking long contracts at fixed character boundaries can split a single clause across chunks, causing the model to see incomplete context. Always chunk at paragraph or section boundaries. The RecursiveCharacterTextSplitter with section-aware separators is the minimum viable approach. For production, implement section-aware parsing that respects the document's structural hierarchy. :::


Interview Q&A

Q: How does CUAD frame contract clause extraction as an NLP task, and what are its limitations?

CUAD frames contract review as machine reading comprehension: given a contract and a question (e.g., "What is the governing law?"), find the text span in the contract that answers the question. This formulation works well for defined clause types with clear textual signals. The limitations are significant: CUAD's 41 categories cover standard commercial contracts but miss many industry-specific clauses (financial derivatives, insurance, healthcare contracts have different clause taxonomies). The extractive QA formulation requires the answer to literally appear in the text - it cannot reason about implied obligations or handle clauses that span multiple discontinuous sections. CUAD was also annotated by law students rather than practicing attorneys, introducing label noise for complex clauses.

Q: What is the practical performance gap between fine-tuned LegalBERT and zero-shot GPT-4 on contract extraction tasks?

On common CUAD categories (governing law, effective date, expiration date), zero-shot GPT-4 with chain-of-thought prompting achieves 70-80% F1, while fine-tuned LegalBERT or RoBERTa achieves 85-95% F1. The gap is largest for complex clauses involving nested conditions and cross-references, where fine-tuned models maintain 60-70% F1 and GPT-4 drops to 50-60%. The more important practical gaps are latency (10-50ms vs 5-30 seconds per page) and cost (fractions of a cent vs $0.01-0.10 per page). For production systems processing thousands of contracts daily, fine-tuned models win on economics.

Q: How would you handle a 300-page contract with a BERT-based model that has a 512-token limit?

Three-stage approach: (1) Structural parsing with regex and rule-based tools to identify section boundaries, creating a document map. (2) Retrieval - for each CUAD question, use BM25 or a bi-encoder to rank sections by relevance, selecting the top 3-5 sections as candidate spans. (3) Span extraction - run the fine-tuned QA model only on the retrieved sections. For the 5% of cases where the answer spans section boundaries, maintain overlapping windows. Alternatively, Longformer and BigBird are architecturally designed for long documents (4,096 and 4,096 tokens respectively) with sparse attention. For LLM-based approaches, use hierarchical summarization per section then synthesis.

Q: How do you build a training dataset for a custom clause type not covered by CUAD?

The minimum viable process: (1) Start with 50-100 contracts where the clause type is known to appear frequently. (2) Use GPT-4 to generate candidate extractions at scale. (3) Have practicing attorneys review and correct the GPT-4 output (much faster than labeling from scratch). (4) Use the corrected outputs as training data for a fine-tuned model. This "LLM-assisted labeling" approach reduces attorney labeling time by 60-70% compared to labeling from scratch. Target at least 200-300 positive examples and an equal number of negative examples (contracts where the clause does not appear). Active learning can further reduce labeling cost: train on 200 examples, run inference on 800 more, have attorneys label only the uncertain cases.

Q: How would you design a contract comparison system to identify material changes between two versions?

Multi-stage approach: (1) Clause extraction on both versions using the fine-tuned QA model for all 41 CUAD categories. (2) Direct comparison of extracted clauses for structured fields (dates, liability caps, party names) - simple string or numeric comparison. (3) Semantic similarity for clause-level changes - embed each clause using a sentence transformer and compute cosine similarity between corresponding clauses. Flag clauses below 0.85 cosine similarity as changed. (4) Diff at the sentence level for changed clauses to identify the specific modified language. The key insight is that legal materiality is a legal judgment, not a cosine similarity threshold. The system should surface changes ranked by semantic distance and let attorneys assess materiality.

Q: What calibration approach do you use to set confidence thresholds for different clause types?

Precision-recall calibration on a held-out validation set from the client's actual contract portfolio (not just CUAD). For each clause type, plot the precision-recall curve and identify the threshold that meets the service level agreement. High-stakes clauses (limitation of liability, IP ownership) require high-precision thresholds even at the cost of recall - better to flag a clause for manual review than to miss it. Low-stakes clauses (governing law, effective date) can use lower thresholds because misclassification risk is lower. Calibrate thresholds quarterly as the model is retrained on new data.

© 2026 EngineersOfAI. All rights reserved.