teachyou.ai academy
← All posts
RAG

RAG for Legal and Compliance Documents: Accuracy Requirements

Pramod Dutta · May 11, 2026 · 15 min read

Why Legal RAG Is a Different Problem

A customer support chatbot that hallucinates a return policy is embarrassing. A legal RAG system that hallucinates a clause, misstates a jurisdiction's statute of limitations, or drops a termination condition from a contract summary can cost a client real money or expose a firm to malpractice risk. This is the core reason rag for legal documents is one of the hardest applied RAG problems in production AI engineering right now, and also one of the most in-demand.

Most RAG tutorials optimize for a single number: retrieval relevance, measured by something like recall@k or a cosine similarity threshold. That is the wrong optimization target for legal and compliance work. In legal contexts, a chunk can be topically relevant and still be dangerously wrong to cite, because the clause was superseded by an amendment three pages later, or because it applies only under a condition stated in a different section entirely. Legal documents are full of cross-references, defined terms, exceptions, and jurisdiction-specific carve-outs. A generic "chunk it, embed it, retrieve top-5, ask the LLM" pipeline will produce answers that sound authoritative and are subtly, or not so subtly, wrong.

This article walks through what actually changes when you build RAG for contracts, policies, regulatory filings, and case law: how to think about accuracy as a first-class requirement, how citation and traceability change your architecture, what chunking and retrieval strategies hold up under adversarial legal review, and how to evaluate the system in a way that a compliance officer will actually trust. We'll use working code examples throughout, not pseudocode.

What "Accuracy" Actually Means in a Legal Context

Before writing a line of retrieval code, define accuracy precisely, because "the model got it right" is not a single criterion in legal work. There are at least four distinct failure modes you need to test for separately:

  • Factual accuracy: Does the generated answer match what the source document actually says, with no invented facts, dates, or numbers?
  • Attribution accuracy: Is the answer traceable to the exact clause, section, or paragraph it came from, down to a page and line if possible?
  • Completeness accuracy: Did the system retrieve and surface *every* relevant clause, including exceptions, amendments, and cross-referenced definitions — or did it miss a carve-out that changes the answer?
  • Currency accuracy: Is the cited version the current, operative version of the document, not a superseded draft or an expired policy?

A system can score well on factual accuracy (it didn't invent anything) while failing badly on completeness (it left out the exception clause that reverses the conclusion). This is why legal teams reject RAG demos that only show a clean "here's the answer" screenshot — they ask "show me where," "show me what you didn't retrieve," and "show me the effective date."

Practically, this means your evaluation harness needs separate metrics and separate test sets for each of these four dimensions, not one aggregate "accuracy" score.

Chunking Strategy: Why Fixed-Size Splitting Fails

The single most common mistake in legal RAG is applying the same chunking strategy used for blog posts or wikis: fixed token windows (say, 512 tokens with 50-token overlap). Legal documents have structural units — clauses, sections, subsections, defined terms — that carry meaning as a whole. Splitting a clause mid-sentence, or separating a clause from the exception that immediately follows it, silently destroys the information you need to answer correctly.

Instead, chunk along the document's actual structure:

import re
from dataclasses import dataclass, field

@dataclass
class LegalChunk:
    text: str
    section_id: str
    parent_section: str | None
    clause_type: str  # e.g. "definition", "obligation", "exception", "termination"
    document_id: str
    version: str
    effective_date: str
    page_number: int
    metadata: dict = field(default_factory=dict)

def split_by_legal_structure(document_text: str, document_id: str,
                              version: str, effective_date: str) -> list[LegalChunk]:
    """Split on numbered section headers like '4.2' or 'Section 7(b)'
    rather than fixed token counts."""
    section_pattern = re.compile(
        r'(?m)^(Section\s+\d+[A-Za-z()]*|(?:\d+\.)+\d*)\s+(.*)$'
    )
    matches = list(section_pattern.finditer(document_text))
    chunks = []

    for i, match in enumerate(matches):
        start = match.start()
        end = matches[i + 1].start() if i + 1 < len(matches) else len(document_text)
        section_id = match.group(1).strip()
        body = document_text[start:end].strip()

        clause_type = classify_clause(body)

        chunks.append(LegalChunk(
            text=body,
            section_id=section_id,
            parent_section=infer_parent(section_id),
            clause_type=clause_type,
            document_id=document_id,
            version=version,
            effective_date=effective_date,
            page_number=estimate_page(start, document_text),
        ))
    return chunks

def classify_clause(text: str) -> str:
    lowered = text.lower()
    if "notwithstanding" in lowered or "except" in lowered:
        return "exception"
    if "shall terminate" in lowered or "termination" in lowered:
        return "termination"
    if '"' in text and "means" in lowered:
        return "definition"
    return "obligation"

def infer_parent(section_id: str) -> str | None:
    parts = section_id.split(".")
    return ".".join(parts[:-1]) if len(parts) > 1 else None

def estimate_page(char_offset: int, full_text: str, chars_per_page: int = 3000) -> int:
    return (char_offset // chars_per_page) + 1

Notice that each chunk carries structural metadata: section_id, parent_section, clause_type, version, and effective_date. This metadata is not decoration — it is what lets you do the retrieval-time filtering and citation work described in the next two sections. Also notice that exception clauses get explicitly tagged. This matters because a naive similarity search will often rank the base obligation clause higher than the exception clause that modifies it, simply because the obligation clause has denser topical overlap with the query.

Retrieval: Hybrid Search Plus Structural Awareness

Pure vector similarity search underperforms on legal text because legal language is precise and repetitive in ways that confuse embeddings. The phrase "reasonable efforts" and "commercially reasonable efforts" are extremely close in embedding space but can mean materially different obligations. Similarly, defined terms (capitalized terms like "Confidential Information") need exact-match handling, because the model must retrieve the definition clause even when the query doesn't mention "definition" at all.

The fix is hybrid retrieval: combine dense vector search with sparse keyword/BM25 search, and add a structural pass that always pulls in related clauses (parent section, referenced definitions, exceptions).

from typing import NamedTuple

class RetrievalResult(NamedTuple):
    chunk: LegalChunk
    score: float
    retrieval_method: str

def hybrid_retrieve(query: str, chunks: list[LegalChunk],
                     vector_index, bm25_index, top_k: int = 8) -> list[RetrievalResult]:
    dense_hits = vector_index.search(query, top_k=top_k * 2)
    sparse_hits = bm25_index.search(query, top_k=top_k * 2)

    combined: dict[str, RetrievalResult] = {}

    for chunk_id, score in dense_hits:
        combined[chunk_id] = RetrievalResult(
            chunk=lookup_chunk(chunk_id, chunks), score=score * 0.6,
            retrieval_method="dense",
        )

    for chunk_id, score in sparse_hits:
        if chunk_id in combined:
            existing = combined[chunk_id]
            combined[chunk_id] = existing._replace(
                score=existing.score + score * 0.4,
                retrieval_method="hybrid",
            )
        else:
            combined[chunk_id] = RetrievalResult(
                chunk=lookup_chunk(chunk_id, chunks), score=score * 0.4,
                retrieval_method="sparse",
            )

    ranked = sorted(combined.values(), key=lambda r: r.score, reverse=True)[:top_k]
    return expand_with_related_clauses(ranked, chunks)

def expand_with_related_clauses(results: list[RetrievalResult],
                                 chunks: list[LegalChunk]) -> list[RetrievalResult]:
    """For every retrieved clause, always pull in its parent section and
    any exception clauses that share the same parent — even if they
    didn't score highly on their own."""
    seen = {r.chunk.section_id for r in results}
    expanded = list(results)

    for r in results:
        siblings = [c for c in chunks
                    if c.parent_section == r.chunk.parent_section
                    and c.clause_type == "exception"
                    and c.section_id not in seen]
        for sibling in siblings:
            expanded.append(RetrievalResult(chunk=sibling, score=r.score * 0.9,
                                             retrieval_method="structural_expansion"))
            seen.add(sibling.section_id)
    return expanded

def lookup_chunk(chunk_id: str, chunks: list[LegalChunk]) -> LegalChunk:
    return next(c for c in chunks if c.section_id == chunk_id)

The expand_with_related_clauses step is the part most teams skip, and it is the part that catches the "we retrieved the obligation but missed the exception" failure mode. It costs a few extra tokens in the context window; it is worth it every time compared to a silently incomplete answer.

Version Control and Document Currency

Legal and compliance documents change constantly: contracts get amended, policies get updated, regulations get revised. A RAG system that indexes documents once and never accounts for supersession will confidently answer questions using a clause that was replaced six months ago. This is arguably the single most damaging failure mode in compliance RAG, because the answer looks completely correct — it's just answering about the wrong point in time.

Handle this with explicit version metadata and a retrieval-time filter that defaults to "current as of today" unless the user asks otherwise:

from datetime import date

def filter_to_current_version(results: list[RetrievalResult],
                                as_of: date = None) -> list[RetrievalResult]:
    as_of = as_of or date.today()
    by_document: dict[str, list[RetrievalResult]] = {}

    for r in results:
        by_document.setdefault(r.chunk.document_id, []).append(r)

    filtered = []
    for doc_id, doc_results in by_document.items():
        # keep only the version whose effective_date is the most recent
        # one on or before `as_of`
        valid = [r for r in doc_results
                 if date.fromisoformat(r.chunk.effective_date) <= as_of]
        if not valid:
            continue
        latest_version = max(r.chunk.version for r in valid)
        filtered.extend([r for r in valid if r.chunk.version == latest_version])

    return filtered

In practice, you should also surface superseded clauses explicitly when a user asks a "what changed" or historical question, rather than hiding them entirely. The goal isn't to delete history — it's to make sure the *default* answer path uses current law and current contract terms, with historical versions available on explicit request.

Citation and Traceability: Every Claim Needs a Pointer

A legal answer without a precise citation is not useful to a lawyer or compliance officer, no matter how fluent it sounds. The generation step needs to be constrained so that every substantive claim in the output maps back to a specific chunk, and ideally a specific section number and page.

The most reliable pattern is to force the model to generate structured output with inline citation markers, then validate those markers programmatically before showing the answer to a user.

import json

CITATION_PROMPT = """You are answering a compliance question using ONLY the
provided source clauses. For every factual statement, add a citation marker
in the form [SECTION:{section_id}] immediately after the statement.
If the provided clauses do not contain enough information to answer fully,
say so explicitly instead of guessing.

Source clauses:
{sources}

Question: {question}

Respond as JSON with fields: "answer" (string with inline [SECTION:x] markers),
"sections_used" (list of section_ids actually cited), "confidence"
("high", "medium", or "low"), and "gaps" (list of strings describing any
information the sources did not cover).
"""

def generate_cited_answer(question: str, results: list[RetrievalResult], llm_client) -> dict:
    sources_text = "\n\n".join(
        f"[{r.chunk.section_id}] ({r.chunk.clause_type}, "
        f"effective {r.chunk.effective_date}, p.{r.chunk.page_number}):\n{r.chunk.text}"
        for r in results
    )
    prompt = CITATION_PROMPT.format(sources=sources_text, question=question)
    raw = llm_client.complete(prompt, response_format="json")
    parsed = json.loads(raw)

    valid_sections = {r.chunk.section_id for r in results}
    cited = set(re.findall(r'\[SECTION:([^\]]+)\]', parsed["answer"]))
    fabricated = cited - valid_sections

    if fabricated:
        parsed["confidence"] = "low"
        parsed["gaps"].append(
            f"Model cited sections not in retrieved context: {fabricated}"
        )

    return parsed

The critical line is the fabricated = cited - valid_sections check. This is a cheap, deterministic guardrail: if the model cites a section number that was never in the retrieved context, that is a hard signal of hallucination, and you should downgrade confidence or refuse to answer rather than silently show the text. This single check catches a surprising fraction of hallucinated citations in practice, because models will sometimes invent plausible-looking section numbers when the retrieved context is thin.

Handling Ambiguity and Refusal Behavior

Legal questions often don't have a single clean answer — "it depends on jurisdiction," "it depends on which version of the agreement governs," "this clause is silent on the scenario you're describing." A RAG system that always produces a confident, single answer is actively worse than one that knows how to say "the source documents don't resolve this" or "this depends on X, which isn't specified in your question."

Build explicit ambiguity detection into your pipeline rather than relying on the model to volunteer uncertainty:

def assess_answer_reliability(parsed_answer: dict, results: list[RetrievalResult]) -> dict:
    reasons = []

    if parsed_answer["confidence"] == "low":
        reasons.append("model flagged low confidence")

    if len(parsed_answer["sections_used"]) == 0:
        reasons.append("no sections were actually cited")

    retrieval_scores = [r.score for r in results if r.chunk.section_id
                         in parsed_answer["sections_used"]]
    if retrieval_scores and max(retrieval_scores) < 0.35:
        reasons.append("best matching source had low retrieval confidence")

    exception_clauses_present = any(
        r.chunk.clause_type == "exception" for r in results
    )
    if not exception_clauses_present:
        reasons.append("no exception clauses found in scope; answer may be incomplete")

    if parsed_answer["gaps"]:
        reasons.append(f"model reported gaps: {parsed_answer['gaps']}")

    return {
        "should_show_disclaimer": len(reasons) > 0,
        "reasons": reasons,
        "recommend_human_review": len(reasons) >= 2,
    }

This kind of reliability scoring turns "the model said X" into "the model said X, with the following caveats, and here's whether a human should look at it before it's relied upon." That distinction is what makes a legal RAG tool something a compliance team can actually adopt, versus something they use once and then distrust permanently.

Evaluation: Testing Against Adversarial Legal Scenarios

Standard RAG evaluation (does the retrieved chunk match the expected chunk) is necessary but not sufficient here. You need an evaluation set specifically built around the failure modes legal review will probe for:

  • Exception-suppression tests: Questions where the correct answer changes entirely if an exception clause is included versus excluded.
  • Superseded-version tests: Questions where an old and a current version of a document both exist in the corpus, checking the system defaults to current.
  • Cross-reference tests: Questions that require combining a definition clause from Section 1 with an obligation in Section 9.
  • Silence tests: Questions the source documents genuinely don't answer, checking the system says so instead of guessing.
  • Jurisdiction tests: Questions where the same clause type exists across multiple jurisdiction-specific documents, checking the system doesn't cross-contaminate.

A minimal evaluation harness scores each of these categories separately:

def evaluate_legal_rag(test_cases: list[dict], pipeline_fn) -> dict:
    results_by_category = {}

    for case in test_cases:
        category = case["category"]
        output = pipeline_fn(case["question"])

        correct = check_answer_matches(output["answer"], case["expected_answer"])
        cited_correct_sections = set(case["expected_sections"]).issubset(
            set(output["sections_used"])
        )
        correctly_flagged_silence = (
            case.get("expects_silence", False) == (len(output["sections_used"]) == 0)
        )

        results_by_category.setdefault(category, []).append({
            "correct": correct,
            "cited_correct_sections": cited_correct_sections,
            "correctly_flagged_silence": correctly_flagged_silence,
        })

    summary = {}
    for category, outcomes in results_by_category.items():
        n = len(outcomes)
        summary[category] = {
            "accuracy": sum(o["correct"] for o in outcomes) / n,
            "citation_accuracy": sum(o["cited_correct_sections"] for o in outcomes) / n,
            "silence_handling": sum(o["correctly_flagged_silence"] for o in outcomes) / n,
        }
    return summary

def check_answer_matches(generated: str, expected: str) -> bool:
    # In production, use an LLM-as-judge with a strict rubric, or a
    # human-reviewed grading pass. A pure string match is shown here
    # only to illustrate the shape of the harness.
    return expected.lower() in generated.lower()

Run this evaluation continuously, not just before launch. Every time the underlying document corpus changes (a new amendment lands, a policy is revised), re-run the exception-suppression and superseded-version categories specifically, since those are the categories most sensitive to corpus updates.

Human-in-the-Loop and Audit Trail Requirements

Even a well-engineered legal RAG system should not be the final word on a compliance question — it should be the research assistant that gets a human reviewer to the right clauses faster, with a defensible trail of what was retrieved and why. Two things matter operationally:

  • Every query and answer should be logged with the exact chunks retrieved, their scores, the document versions used, and the final generated answer, so that if a decision is later questioned, you can reconstruct exactly what the system saw.
  • Escalation thresholds should be explicit and configurable, not buried in prompt engineering. If recommend_human_review is true, route to a human before the answer is treated as final, especially for anything that touches contract obligations, regulatory deadlines, or client-facing advice.

This audit trail is not optional overhead — in regulated industries, it is frequently the actual deliverable that compliance and legal teams are buying when they adopt a RAG tool. The generated answer is the visible output; the traceable, timestamped log of retrieval decisions is what makes the system defensible.

Common Pitfalls Teams Hit in Production

A few failure patterns show up repeatedly once a legal RAG system moves past the demo stage and into real usage by paralegals, compliance analysts, or contract managers:

  • Over-trusting embedding similarity for defined terms. If a contract defines "Affiliate" in Section 1 and uses it forty times, a similarity search will often fail to retrieve the definition clause when the query itself doesn't use the word "definition." Always run a separate exact-match pass for capitalized defined terms and force-include their definition clauses regardless of vector score.
  • Treating OCR output as clean text. Many compliance documents arrive as scanned PDFs. If your ingestion pipeline doesn't validate OCR quality per page, you will silently index garbled section numbers and cross-references, and no amount of downstream prompt engineering fixes that. Add an OCR confidence check at ingestion time and flag low-confidence pages for manual review rather than indexing them as-is.
  • Ignoring document hierarchy across related filings. A master services agreement, its statements of work, and an amendment are three separate documents that must be reasoned about together. If your chunking treats them as unrelated documents with no shared metadata, the system will answer questions about the MSA without realizing an amendment changed the relevant clause.
  • No test coverage for negative cases. Teams often build a solid evaluation set for "questions the system should answer" and completely skip "questions the system should refuse to answer" or "questions where the source documents conflict." Both are common in real compliance work and both need explicit test cases.
  • Conflating retrieval confidence with answer confidence. A high vector similarity score only tells you the chunk is topically close to the query — it says nothing about whether the chunk, once retrieved, was used correctly by the generation step. Track these as two separate metrics rather than assuming one implies the other.

Each of these is cheap to fix if caught early and expensive to fix after a compliance team has already lost trust in the tool. Building the checks in from the start — OCR validation, defined-term exact match, hierarchy-aware metadata, negative test cases, and separated confidence metrics — is significantly less work than retrofitting them later.

Closing Thoughts

Building RAG for legal and compliance documents forces you to confront every corner case that a generic tutorial pipeline glosses over: structural chunking instead of fixed windows, hybrid retrieval instead of pure vector similarity, explicit version control instead of a static index, programmatic citation validation instead of trusting the model's claims, and a reliability scoring layer that knows when to say "get a human." None of this is exotic engineering — it's disciplined application of RAG fundamentals to a domain where the cost of being confidently wrong is unusually high.

If you want to go deeper on the retrieval and generation fundamentals that underpin everything covered here — chunking strategies, embedding choices, hybrid search, and evaluation design — that groundwork is covered in detail in our Introduction to RAG course, and it's the natural starting point before tackling a high-stakes domain like legal and compliance RAG.