Building A Contract Clause Rag With Checksum-Guarded Citations
Written by
Nova Neural
The problem that pulled me in
I ran into a surprisingly painful failure mode while building an Enterprise RAG (Retrieval-Augmented Generation) system for legal contracts: the retrieval step would sometimes surface a similar clause from the wrong section—even when the contract had changed since indexing.
What made it weird was that the answer still looked plausible. The system wasn’t “hallucinating” in the usual sense; it was citing the wrong text. I needed a way to ensure that every retrieved passage was verifiably the exact text the answer referenced—down to byte-level changes.
So I built a niche pattern I now rely on: checksum-guarded citations for contract clauses. The core idea is simple:
- When indexing, store a checksum for each clause chunk (hash of normalized text).
- When retrieving, only accept passages whose checksum matches what I expect for that clause ID.
- In the response, attach citations that include both clause ID and checksum, so the answer is tied to immutable evidence.
Below is the full, working walkthrough with code.
What this pattern does (in practical terms)
Index-time
- Split a contract into clause-level chunks.
- Normalize text (so minor whitespace differences don’t break hashes).
- Compute
sha256(normalized_text). - Store:
clause_idchecksumembeddingof the chunk texttext
Query-time
- Retrieve candidate chunks by vector similarity.
- Verify each candidate:
- recompute checksum from its stored text (sanity check)
- optionally compare to an expected checksum map for stricter guarantees
- Generate an answer that includes citations like:
Clause CL-9 (sha256: ...)
This prevents “citation drift” when the underlying contract text changes after indexing, or when ingestion pipelines duplicate/merge chunks.
Working example: checksum-guarded clause retrieval
This example uses:
scikit-learnfor embeddings (fast and local)numpyfor vector searchhashlibfor checksums- a small “fake LLM” step that extracts the best cited clause text (so the code runs without external APIs)
Install dependencies
pip install scikit-learn numpy
Step 1: Clause chunking + checksum computation
I represent a contract as clause objects. In a real system you’d parse PDFs/HTML, but the chunking + hashing logic stays the same.
import hashlib import re from dataclasses import dataclass @dataclass class Clause: clause_id: str raw_text: str def normalize_for_hash(text: str) -> str: """ Normalize contract text for stable hashing: - collapse whitespace - strip leading/trailing space - lowercase (contracts are usually case-stable, but this reduces accidental drift) """ text = text.lower() text = re.sub(r"\s+", " ", text).strip() return text def sha256_text(text: str) -> str: normalized = normalize_for_hash(text) return hashlib.sha256(normalized.encode("utf-8")).hexdigest() # Demo contract clauses = [ Clause("CL-3", "The Supplier warrants that the Services will conform in all material respects to the applicable specifications."), Clause("CL-7", "Either party may terminate this Agreement for cause upon written notice if the other party materially breaches this Agreement."), Clause("CL-9", "All payments shall be made within thirty (30) days of receipt of an undisputed invoice."), ]
Why this normalization matters: if you compute a checksum on raw text, harmless differences like extra spaces or line breaks can cause checksums to change, defeating the purpose of stability.
Step 2: Build an in-memory “RAG index” with embeddings
I’ll store:
textclause_idchecksumembedding vector
For simplicity, I use TF‑IDF vectors. In production, you’d use a real embedding model, but checksum guarding works the same.
import numpy as np from sklearn.feature_extraction.text import TfidfVectorizer # Build index records class ClauseIndex: def __init__(self, clauses): self.records = [] self.vectorizer = TfidfVectorizer() self.texts = [c.raw_text for c in clauses] # Fit embedding model self.matrix = self.vectorizer.fit_transform(self.texts).toarray() for i, c in enumerate(clauses): checksum = sha256_text(c.raw_text) self.records.append({ "clause_id": c.clause_id, "text": c.raw_text, "checksum": checksum, "embedding": self.matrix[i], }) def retrieve(self, query: str, top_k: int = 2): q_vec = self.vectorizer.transform([query]).toarray()[0] # cosine similarity (manual) denom = (np.linalg.norm(self.matrix, axis=1) * (np.linalg.norm(q_vec) + 1e-12)) sims = (self.matrix @ q_vec) / denom top_idx = sims.argsort()[::-1][:top_k] candidates = [self.records[i] for i in top_idx] return candidates index = ClauseIndex(clauses)
Step 3: Add checksum verification to retrieval
Here’s the guardrail: recompute checksum from stored text and compare to the stored checksum.
def verify_candidate(candidate: dict) -> bool: """ Verifies that stored checksum matches the checksum of stored text. This catches accidental mutation in-memory or during ingestion. """ recomputed = sha256_text(candidate["text"]) return recomputed == candidate["checksum"] def retrieve_with_guard(index: ClauseIndex, query: str, top_k: int = 2): candidates = index.retrieve(query, top_k=top_k) verified = [] for c in candidates: ok = verify_candidate(c) if ok: verified.append(c) return verified verified = retrieve_with_guard(index, "How long do payments have to be made?") verified
Step 4: Generate a response with checksum-backed citations
Since we’re not calling an actual LLM here, I’ll implement a small “answer composer” that:
- picks the top verified clause
- returns its text
- includes a citation that contains both
clause_idandchecksum
def answer_with_citations(query: str, index: ClauseIndex, top_k: int = 2) -> dict: verified = retrieve_with_guard(index, query, top_k=top_k) if not verified: return { "answer": "No verified clause passages available for citation.", "citations": [] } best = verified[0] citation = { "clause_id": best["clause_id"], "checksum_sha256": best["checksum"], "text_snippet": best["text"][:120] + ("..." if len(best["text"]) > 120 else "") } return { "answer": best["text"], "citations": [citation] } result = answer_with_citations(index, "When should payments be made after invoice receipt?", top_k=2) result
You should see that the answer is the clause about payment timing, and the citation includes a checksum tied to that exact clause text.
Step 5 (the important part): Simulate a contract update and show the failure mode you avoid
Now I’ll simulate a real-world issue: suppose the contract text for CL-9 changes after indexing (or a new ingestion re-splits chunks). Without checksum verification, retrieval can “work” but citations drift.
I’ll create a mutated index that keeps the old checksum but changes the stored text.
# Create a mutated copy to simulate ingestion drift mutated_index = ClauseIndex(clauses) # Tamper with stored text for CL-9 for rec in mutated_index.records: if rec["clause_id"] == "CL-9": rec["text"] = "All payments shall be made within fifteen (15) days of receipt of an undisputed invoice." # Intentionally NOT updating rec["checksum"] to simulate drift # Now run retrieval with guard result_mutated = answer_with_citations(mutated_index, "How long do payments have to be made?", top_k=3) result_mutated
What happens:
- The retrieval may still select the clause based on embeddings similarity.
- But checksum verification recomputes the hash from the mutated text and detects mismatch.
- The clause is dropped from verified candidates, so the system refuses to cite it.
That refusal is the feature: it prevents the system from producing a “confident” answer backed by the wrong evidence.
Step 6: Stricter mode with an expected checksum registry (optional but powerful)
If your contracts have stable IDs (like CL-9), you can store an “expected checksum map” generated at indexing time and compare against it at query time.
# Expected checksum registry (from the original indexing state) expected_checksum = {rec["clause_id"]: rec["checksum"] for rec in index.records} def retrieve_with_expected_registry(index: ClauseIndex, query: str, expected_checksum_map: dict, top_k: int = 3): candidates = index.retrieve(query, top_k=top_k) verified = [] for c in candidates: # 1) verify internal consistency if not verify_candidate(c): continue # 2) verify against the registry expected = expected_checksum_map.get(c["clause_id"]) if expected is None: continue if c["checksum"] != expected: continue verified.append(c) return verified def answer_with_expected_registry(index: ClauseIndex, query: str, expected_checksum_map: dict, top_k: int = 3) -> dict: verified = retrieve_with_expected_registry(index, query, expected_checksum_map, top_k=top_k) if not verified: return {"answer": "No verified clause passages available.", "citations": []} best = verified[0] return { "answer": best["text"], "citations": [{ "clause_id": best["clause_id"], "checksum_sha256": best["checksum"] }] } answer_with_expected_registry(mutated_index, "How long do payments have to be made?", expected_checksum, top_k=3)
This turns the checksum from “sanity check” into “contract-level immutability enforcement”.
Where this fits in a real Enterprise RAG pipeline
In production, I typically wire checksum guarding at three points:
-
During indexing
- compute and store
checksumper chunk - store
clause_idandchecksumin a metadata store
- compute and store
-
During retrieval
- verify checksums before allowing citations
- drop any passage that fails verification
-
During answer generation
- include citations with
clause_id + checksum - avoid returning answers tied to unverified evidence
- include citations with
Even if the rest of the system uses advanced embedding models, rerankers, or hybrid search, checksum guarding is the last-mile integrity layer.
Conclusion
I built checksum-guarded citations for Enterprise RAG by hashing normalized clause text at index time, verifying those checksums during retrieval, and only then allowing citations. In practice, this prevented a nasty and realistic failure mode: plausible answers backed by the wrong clause after contract updates or ingestion drift. The result is a RAG system that doesn’t just retrieve relevant text—it retrieves verifiably correct evidence.