Building A Contract Clause Rag Guardrail Using Regex-Filter Before Retrieval
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:
- Retrieve top-k relevant chunks by embedding similarity
- Stuff them into a prompt
- 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:
- Detect requested clause intent (“termination notice period” →
TERMINATION_NOTICE) - Apply an allowlist mapping from clause intent → acceptable clause fingerprints
- Filter retrieved chunks by matching
clause_idand validating the clause text with a regex
- Detect requested clause intent (“termination notice period” →
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
SOWorCHANNELbecause they’re semantically similar (“termination notice”). - Guardrail keeps only
MSAchunks and validates the regex fingerprint. - Final answer uses the
30 daysnotice 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
MSAnotice chunk. - It should use the
SOWnotice 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
-
Regex hints must be specific but not brittle.
Too strict, and legitimate clauses fail. Too loose, and leakage slips through. -
Store fingerprint metadata alongside chunks.
Keepingclause_idandregex_hintper chunk avoids guessing at query time. -
Filter before prompt assembly.
Once the model sees restricted text, “forgetting” isn’t guaranteed. -
Log both stages.
I loggedretrievedvscontext_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.