Automated resume parsing powers modern HR technology platforms and candidate application engines, including RiddleTech's Job Application Assistant. Converting unstructured binary documents (PDF, DOCX, RTF) into clean, queryable JSON schemas presents complex text processing challenges.
Resumes vary wildly in visual layout—ranging from single-column chronological formats to multi-column graphics, embedded tables, and custom typography. In this technical deep dive, we walk through building a multi-stage Python resume parsing pipeline featuring text extraction, regex metadata extraction, and Named Entity Recognition (NER).
1. The Multi-Stage Parsing Pipeline
An enterprise-grade document extraction pipeline operates in four distinct stages:
- Stage 1 (Binary Text Extraction): Converts raw PDF/DOCX byte streams into layout-aware UTF-8 text.
- Stage 2 (Layout & Section Boundary Analysis): Identifies structural headings (e.g., "Education", "Work Experience", "Skills") using bounding-box spatial coordinates and regex heuristics.
- Stage 3 (Entity Extraction & Taxonomy Matching): Extracts contact details, email addresses, phone numbers, and technical skills using Regex patterns and spaCy NLP entity recognition models.
- Stage 4 (JSON Normalization & Schema Validation): Validates extracted metadata against JSON Schema definitions for database ingestion.
2. Binary Text Extraction (PDF & DOCX)
Different file formats require specialized extraction tools. For PDF documents, standard string extraction fails when text is laid out in multi-column tables. We utilize pdfminer.six to extract spatial bounding boxes (x, y coordinates) and reconstruct natural reading order:
import re
from io import StringIO
from docx import Document
from pdfminer.high_level import extract_text_to_fp
from pdfminer.layout import LAParams
def extract_text_from_pdf(pdf_path: str) -> str:
"""Extract layout-preserved plain text from PDF using pdfminer.six."""
output_string = StringIO()
with open(pdf_path, 'rb') as fin:
laparams = LAParams(line_margin=0.5, word_margin=0.1)
extract_text_to_fp(fin, output_string, laparams=laparams)
return output_string.getvalue()
def extract_text_from_docx(docx_path: str) -> str:
"""Extract structured text from Microsoft Word DOCX files."""
doc = Document(docx_path)
full_text = []
for para in doc.paragraphs:
if para.text.strip():
full_text.append(para.text)
for table in doc.tables:
for row in table.rows:
row_data = [cell.text.strip() for cell in row.cells if cell.text.strip()]
if row_data:
full_text.append(" | ".join(row_data))
return "\n".join(full_text)
3. Regex & NLP Rule Engines for Metadata Extraction
Contact information (email addresses, phone numbers, LinkedIn URLs, GitHub profiles) follows predictable pattern rules. We apply compiled regular expressions to extract contact metadata efficiently:
EMAIL_REGEX = re.compile(r'[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+')
PHONE_REGEX = re.compile(r'\(?\+?\d{1,4}\)?[\s.-]?\d{3,4}[\s.-]?\d{3,4}[\s.-]?\d{3,9}')
LINKEDIN_REGEX = re.compile(r'linkedin\.com/in/[a-zA-Z0-9_-]+')
GITHUB_REGEX = re.compile(r'github\.com/[a-zA-Z0-9_-]+')
def parse_contact_metadata(raw_text: str) -> dict:
emails = EMAIL_REGEX.findall(raw_text)
phones = PHONE_REGEX.findall(raw_text)
linkedin = LINKEDIN_REGEX.findall(raw_text)
github = GITHUB_REGEX.findall(raw_text)
return {
"email": emails[0] if emails else None,
"phone": phones[0] if phones else None,
"linkedin_url": f"https://www.{linkedin[0]}" if linkedin else None,
"github_url": f"https://www.{github[0]}" if github else None,
}
4. Skill Taxonomy Matching via Spacy NER
Extracting candidate skills (e.g. "PostgreSQL", "Asyncio", "Docker", "React") requires contextual token matching against an established technical taxonomy database to avoid false positives:
import spacy
from spacy.matcher import PhraseMatcher
# Load light SpaCy language model
nlp = spacy.load("en_core_web_sm")
TECHNICAL_SKILLS_TAXONOMY = [
"Python", "PostgreSQL", "Docker", "Redis", "Nginx", "Asyncio",
"FastAPI", "React", "TypeScript", "Kubernetes", "AWS", "Linux"
]
def extract_candidate_skills(raw_text: str) -> list:
doc = nlp(raw_text)
matcher = PhraseMatcher(nlp.vocab, attr="LOWER")
# Create phrase patterns for skill taxonomy
patterns = [nlp.make_doc(skill) for skill in TECHNICAL_SKILLS_TAXONOMY]
matcher.add("SKILL_PATTERNS", patterns)
matches = matcher(doc)
found_skills = set()
for match_id, start, end in matches:
found_skills.add(doc[start:end].text)
return list(found_skills)
Privacy Architecture Note: Local resume parsing executes entirely within client memory. No candidate credentials or document contents are transmitted to external AI APIs, ensuring complete local data privacy.
Conclusion
Building a scalable resume extraction pipeline requires decoupling file-level text extraction from structural parsing and skill taxonomy matching. Combining pdfminer.six, regex rule engines, and spaCy PhraseMatchers delivers fast, accurate document extraction for modern recruiting tools.