Deterministic Rag For Vendor-Specific Pdfs Using Layout-Aware Chunk Keys
Written by
Nova Neural
The problem I kept tripping over
I was building an Enterprise RAG pipeline (Retrieval-Augmented Generation: a system that fetches relevant text snippets from your documents and feeds them to an LLM) for vendor documentation PDFs. The weird part wasn’t the LLM—it was chunking.
Two runs over the same PDF could produce different chunks because:
- page layout extraction is not stable across PDF generators,
- tables sometimes come through as “text” with unpredictable ordering,
- “the same” paragraph could get split differently depending on whitespace/newlines.
That made retrieval flaky: answers changed even when nothing in the source changed.
So I went hunting for a niche fix: deterministic chunking where every chunk gets a stable key derived from content + location, not from chunk index. I’ll show what I built and why it works.
What I built: layout-aware chunks with deterministic keys
Instead of chunking purely by token counts, I used a layout signal:
- I extract text blocks from the PDF along with their bounding boxes (page number + x/y coordinates + width/height).
- I normalize the text.
- I generate a chunk key as a hash of
(page, rounded_bbox, normalized_text).
Then retrieval uses these keys for:
- stable storage in a vector DB,
- idempotent upserts (same PDF → same chunk IDs),
- consistent traceability (“this answer came from chunk key …”).
End-to-end code: deterministic PDF chunking + RAG retrieval
1) Install dependencies
pip install pymupdf pydantic sentence-transformers faiss-cpu openai
pymupdfextracts layout-aware text.sentence-transformerscreates embeddings (numerical vectors representing text meaning).faiss-cpuis a local vector index.openaicalls an LLM for final generation.
2) Deterministic chunking from PDF
import hashlib import re from typing import List, Dict, Tuple import fitz # PyMuPDF def normalize_text(s: str) -> str: # Collapse whitespace and normalize unicode-ish quirks s = s.replace("\u00a0", " ") s = re.sub(r"\s+", " ", s).strip().lower() return s def rounded_bbox(bbox: Tuple[float, float, float, float], step: float = 1.0) -> Tuple[int, int, int, int]: # Round coordinates so tiny float differences don't change keys x0, y0, x1, y1 = bbox return ( int(round(x0 / step) * step), int(round(y0 / step) * step), int(round(x1 / step) * step), int(round(y1 / step) * step), ) def chunk_key(pdf_path: str, page_num: int, bbox: Tuple[float, float, float, float], text: str) -> str: norm = normalize_text(text) rb = rounded_bbox(bbox, step=1.0) payload = f"{pdf_path}|p{page_num}|bbox{rb}|t{norm}".encode("utf-8") return hashlib.sha256(payload).hexdigest() def extract_layout_chunks(pdf_path: str, min_chars: int = 60) -> List[Dict]: doc = fitz.open(pdf_path) chunks = [] for page_index in range(len(doc)): page = doc[page_index] page_num = page_index + 1 # get_text("blocks") returns list of blocks with bbox and text: # (x0, y0, x1, y1, text, block_no, block_type) blocks = page.get_text("blocks") for b in blocks: x0, y0, x1, y1, block_text = b[0], b[1], b[2], b[3], b[4] if not block_text: continue block_text = block_text.strip() if len(block_text) < min_chars: continue key = chunk_key(pdf_path, page_num, (x0, y0, x1, y1), block_text) chunks.append({ "chunk_key": key, "page": page_num, "bbox": (x0, y0, x1, y1), "text": block_text, }) return chunks
Why these steps matter
- Layout-aware extraction (
get_text("blocks")) is much closer to the PDF’s intent than naive line splitting. - Rounded bounding boxes handle small extraction jitter.
- Content normalization makes the same paragraph hash the same even with trivial whitespace differences.
- The key is stable enough that repeated indexing won’t duplicate chunks.
3) Build embeddings + a local vector index
from sentence_transformers import SentenceTransformer import numpy as np import faiss MODEL_NAME = "sentence-transformers/all-MiniLM-L6-v2" def build_index(chunks: List[Dict]): embedder = SentenceTransformer(MODEL_NAME) texts = [c["text"] for c in chunks] vectors = embedder.encode(texts, normalize_embeddings=True) vectors = np.array(vectors).astype("float32") dim = vectors.shape[1] index = faiss.IndexFlatIP(dim) # Inner product with normalized vectors ~= cosine similarity index.add(vectors) # Store metadata aligned with FAISS row order metadata = [ { "chunk_key": c["chunk_key"], "page": c["page"], "text": c["text"], } for c in chunks ] return index, metadata
4) Deterministic retrieval by similarity
def search(index, metadata, query: str, embedder: SentenceTransformer, top_k: int = 5): qv = embedder.encode([query], normalize_embeddings=True).astype("float32") scores, ids = index.search(qv, top_k) results = [] for score, idx in zip(scores[0], ids[0]): item = metadata[int(idx)] results.append({ "chunk_key": item["chunk_key"], "page": item["page"], "score": float(score), "text": item["text"], }) return results
5) Generate an answer with citations to deterministic chunk keys
This part is a standard RAG pattern:
- retrieve top snippets,
- put them into the prompt,
- ask the LLM to answer using only those snippets,
- include chunk keys as citations.
import os from openai import OpenAI client = OpenAI(api_key=os.environ.get("OPENAI_API_KEY")) def generate_answer(query: str, retrieved: List[Dict]) -> str: context = "\n\n".join( f"[chunk_key: {r['chunk_key']} | page: {r['page']}]\n{r['text']}" for r in retrieved ) prompt = f"""You are a helpful enterprise support assistant. Answer the question using ONLY the provided context snippets. If the answer is not in the context, say you don't know. Question: {query} Context snippets: {context} Return: - A concise answer - A list of cited chunk_keys """ resp = client.chat.completions.create( model="gpt-4.1-mini", messages=[ {"role": "system", "content": "Follow the instructions in the prompt exactly."}, {"role": "user", "content": prompt}, ], temperature=0.0, ) return resp.choices[0].message.content
6) Putting it all together (run it)
def rag_pipeline(pdf_path: str, query: str): chunks = extract_layout_chunks(pdf_path) index, metadata = build_index(chunks) embedder = SentenceTransformer(MODEL_NAME) retrieved = search(index, metadata, query, embedder, top_k=5) print("Top retrieved chunks (deterministic keys):") for r in retrieved: print(f"- score={r['score']:.4f} page={r['page']} key={r['chunk_key'][:12]}...") answer = generate_answer(query, retrieved) return answer if __name__ == "__main__": pdf_path = "vendor_manual.pdf" query = "What is the maximum operating temperature for the Model X actuator?" print(rag_pipeline(pdf_path, query))
What happens when I run it (in practice)
- I re-run indexing on the same
vendor_manual.pdf. - The extracted block count may differ slightly across environments, but the chunk keys for unchanged blocks remain the same.
- Retrieval returns consistent top snippets because vector entries correspond to stable texts (and you can dedupe by chunk_key at ingestion time).
- The LLM answer includes chunk keys that map back to a specific PDF block location.
That’s how I eliminated “mystery answer drift” caused by unstable chunking.
The real enterprise win: idempotent ingestion
In production, the key idea is simple:
- When ingesting a PDF, compute deterministic
chunk_keys. - Upsert into your vector store by
chunk_keyas the primary ID. - If the same content appears again (same vendor doc revision or same extraction), it updates the existing record instead of creating duplicates.
This is where deterministic chunking saves cost and improves trust.
Conclusion
I built deterministic, layout-aware chunking for Enterprise RAG by hashing normalized block text together with rounded bounding boxes and page numbers. That made chunk IDs stable across runs, which in turn made retrieval far less flaky and produced reproducible answers with traceable chunk_key citations.