Artificial IntelligenceApril 4, 2026

Building A Contract Clause Rag Guardrail Using Regex-Filter Before Retrieval

N

Written by

Nova Neural

I got curious about a very specific failure mode in enterprise Retrieval-Augmented Generation (RAG): even when the retriever finds the “right” clauses, the final answer can still accidentally quote or summarize clauses the user shouldn’t see—especially when the knowledge base contains many similar contracts.

In my case, the risky pattern was clause-level leakage: a contract clause might mention “termination,” but different clauses for different contract types (MSA vs. SOW, enterprise vs. channel) use near-identical wording. So the embeddings retrieved the neighborhood, and the generator filled in the exact clause text—sometimes across the boundary of what the user is authorized to access.

What I built to make this reliable was a pre-retrieval regex filter—a lightweight gate that rejects chunks that don’t match an allowlisted clause identity before they ever reach the model. Below is the concrete setup I used for a “contract clause RAG guardrail” that I can run in a small pipeline.


The niche problem: “termination” clauses that look the same across contracts

Enterprise contract corpora often store many documents in one index. A user might ask:

“What is the termination notice period?”

A naive RAG pipeline typically does:

  1. Retrieve top-k relevant chunks by embedding similarity
  2. Stuff them into a prompt
  3. Generate an answer

The guardrail failure happens because “termination notice period” is semantically similar across many contracts. Even if authorization checks exist, they may only run after generation (too late), or they may be document-level rather than clause-level.

So I needed clause identity to survive the journey from retrieval → generation.


What I implemented: regex-filtered retrieval using clause fingerprints

Core idea

Before retrieval results are used, I apply a regex-based “fingerprint” filter:

  • Each chunk in the index includes lightweight metadata like:
    • contract_type (e.g., MSA, SOW)
    • clause_id (e.g., TERMINATION_NOTICE)
    • termination_regex_hint (a canonical snippet pattern)
  • At query time, I:
    1. Detect requested clause intent (“termination notice period” → TERMINATION_NOTICE)
    2. Apply an allowlist mapping from clause intent → acceptable clause fingerprints
    3. Filter retrieved chunks by matching clause_id and validating the clause text with a regex

This is intentionally old-school: regex isn’t “smart,” but it’s deterministic. Determinism is the point for a guardrail.

Why pre-retrieval filter (not post-filter)

If I wait until after generation, I can’t reliably recover from leakage. If I gate chunks before they become prompt context, the model can’t output what it never saw.


Working example: a tiny in-memory “enterprise RAG” with guardrails

This demo uses:

  • A simple embedding model (via sentence-transformers)
  • A vector store (in-memory)
  • A regex guardrail that filters chunks by clause identity

You can run this end-to-end locally; it doesn’t call external vector DBs.

1) Install dependencies

pip install sentence-transformers numpy

2) Create sample clause chunks

import re from dataclasses import dataclass from typing import Dict, Any, List @dataclass class Chunk: text: str meta: Dict[str, Any] chunks: List[Chunk] = [ Chunk( text="TERMINATION NOTICE: Either party may terminate this MSA upon thirty (30) days' written notice.", meta={ "contract_type": "MSA", "clause_id": "TERMINATION_NOTICE", # Canonical fingerprint: what the clause typically contains "termination_regex_hint": r"thirty\s*\(\s*30\s*\)\s*days'?\s*written\s*notice" } ), Chunk( text="TERMINATION FOR CAUSE: Either party may terminate for material breach with fifteen (15) days to cure.", meta={ "contract_type": "MSA", "clause_id": "TERMINATION_FOR_CAUSE", "termination_regex_hint": r"fifteen\s*\(\s*15\s*\)\s*days\s*to\s*cure" } ), Chunk( text="TERMINATION NOTICE: The SOW may be terminated by the client upon 45 days' written notice.", meta={ "contract_type": "SOW", "clause_id": "TERMINATION_NOTICE", "termination_regex_hint": r"45\s*days'?\s*written\s*notice" } ), Chunk( text="TERMINATION NOTICE: Either party may terminate upon twenty (20) business days' written notice.", meta={ "contract_type": "CHANNEL", "clause_id": "TERMINATION_NOTICE", "termination_regex_hint": r"twenty\s*\(\s*20\s*\)\s*business\s*days'?\s*written\s*notice" } ), ]

I intentionally included multiple TERMINATION_NOTICE clauses so semantic retrieval can easily grab the “wrong” one.


3) Build a minimal vector store + embedding

import numpy as np from sentence_transformers import SentenceTransformer model = SentenceTransformer("all-MiniLM-L6-v2") texts = [c.text for c in chunks] embeddings = model.encode(texts, normalize_embeddings=True) # simple cosine similarity index def retrieve(query: str, top_k: int = 3): q_emb = model.encode([query], normalize_embeddings=True)[0] scores = embeddings @ q_emb idxs = np.argsort(-scores)[:top_k] return [(chunks[i], float(scores[i])) for i in idxs]

4) Add clause-intent detection and the regex guardrail

Clause intent mapping

In a real enterprise system this could be:

  • a classifier,
  • a rules engine,
  • or a small LLM prompt.

For the demo, I’ll use deterministic keyword→clause_id mapping.

def detect_clause_intent(query: str) -> str: q = query.lower() if "termination" in q and "notice" in q: return "TERMINATION_NOTICE" if "termination" in q and "cure" in q: return "TERMINATION_FOR_CAUSE" return "UNKNOWN"

Authorization/allowlist filter (clause fingerprint gate)

The guardrail enforces:

  • clause identity must match (clause_id)
  • text must match the canonical regex fingerprint (termination_regex_hint)
def guardrail_filter( retrieved: List[tuple[Chunk, float]], query: str, allowed_contract_types: List[str], ) -> List[tuple[Chunk, float]]: intent = detect_clause_intent(query) filtered = [] for chunk, score in retrieved: if chunk.meta.get("clause_id") != intent: continue if chunk.meta.get("contract_type") not in allowed_contract_types: continue # Validate the clause's signature text pattern hint = chunk.meta.get("termination_regex_hint") if hint: if not re.search(hint, chunk.text, flags=re.IGNORECASE): continue filtered.append((chunk, score)) return filtered

This is the key: even if the retriever gets close, the guardrail can veto chunks that don’t match the specific clause form the user asked for and the contract types they’re allowed to see.


5) Compose the “RAG answer” prompt context (without leaking context)

In a real system I’d call an LLM. Here I’ll show the exact assembled context and then extract the notice period deterministically to keep the demo fully runnable.

def extract_notice_period(text: str) -> str: # Very small extraction logic for the demo patterns = [ r"thirty\s*\(\s*30\s*\)\s*days'?\s*written\s*notice", r"(\d+)\s*days'?\s*written\s*notice", r"twenty\s*\(\s*20\s*\)\s*business\s*days'?\s*written\s*notice", ] for p in patterns: m = re.search(p, text, flags=re.IGNORECASE) if m: return m.group(0) return "UNKNOWN" def rag_answer(query: str, allowed_contract_types: List[str], top_k: int = 4): retrieved = retrieve(query, top_k=top_k) filtered = guardrail_filter(retrieved, query, allowed_contract_types) # If nothing survives the guardrail, return a safe refusal/empty answer if not filtered: return { "intent": detect_clause_intent(query), "context_used": [], "answer": "No authorized matching clause found.", } # Use top surviving chunk(s) filtered_sorted = sorted(filtered, key=lambda x: -x[1]) context = [c.text for c, _ in filtered_sorted] # Demo answer extraction from the best matching clause best_text = context[0] notice = extract_notice_period(best_text) return { "intent": detect_clause_intent(query), "retrieved": [(c.meta, s) for c, s in retrieved], "context_used": context, "answer": f"Termination notice clause found: {notice}", }

6) Run it: showing exactly what happens

Case A: Allowed only for MSA

result = rag_answer( query="What is the termination notice period?", allowed_contract_types=["MSA"], top_k=4 ) print(result["retrieved"]) print("----") print(result["context_used"]) print("----") print(result["answer"])

Expected behavior:

  • Retriever may return chunks from SOW or CHANNEL because they’re semantically similar (“termination notice”).
  • Guardrail keeps only MSA chunks and validates the regex fingerprint.
  • Final answer uses the 30 days notice clause.

Case B: Allowed only for SOW

result = rag_answer( query="What is the termination notice period?", allowed_contract_types=["SOW"], top_k=4 ) print("----") print(result["context_used"]) print("----") print(result["answer"])

Expected behavior:

  • The system should ignore the MSA notice chunk.
  • It should use the SOW notice clause (45 days).

Case C: Allowed for none

result = rag_answer( query="What is the termination notice period?", allowed_contract_types=[], top_k=4 ) print(result["answer"])

Expected behavior:

  • No chunk survives the filter.
  • The pipeline refuses safely: “No authorized matching clause found.”

Why this works in the real world

This pattern is effective when:

  • You can define a clause identity (like TERMINATION_NOTICE)
  • You can define a signature (regex fingerprint, a canonical phrase template, or even a checksum of a “clause heading”)
  • You need deterministic rejection before the LLM sees the text

It’s not trying to replace embeddings. It’s complementing them with a gate that protects against cross-clause and cross-contract leakage.


Productionizing notes I learned the hard way

  1. Regex hints must be specific but not brittle.
    Too strict, and legitimate clauses fail. Too loose, and leakage slips through.

  2. Store fingerprint metadata alongside chunks.
    Keeping clause_id and regex_hint per chunk avoids guessing at query time.

  3. Filter before prompt assembly.
    Once the model sees restricted text, “forgetting” isn’t guaranteed.

  4. Log both stages.
    I logged retrieved vs context_used. That single diff made debugging much faster.


I built a contract clause RAG guardrail by adding a deterministic regex-based fingerprint filter that runs before generation. Instead of trusting semantic similarity alone, I used clause intent detection plus allowlisted contract types and a clause signature check to prevent prompt context from containing unauthorized or mismatched termination clauses.