Building a Resume Parser with LLMs: A Step-by-Step Guide — Part I
Have you ever wondered how to build a system that can automatically analyze resumes and extract meaningful information from them? In this post, I’ll walk you through my journey of building a resume analysis system powered by Large Language Models (LLMs).
The Problem
As a data engineer who’s also been involved in hiring, I understand how labor-intensive it is to manually sift through resumes. Each resume comes in a unique format with varied sections and methods of presenting information. Imagine leveraging automation to transform these diverse documents into structured, actionable data, streamlining the hiring process and enabling data-driven decision-making.
The Solution
I built a Python-based resume analysis system that:
- Takes PDF resumes as input
- Extracts text from these resumes
- Uses LLMs (specifically Google’s Gemini models) to extract structured information
- Outputs the results in a clean JSON format
Let me walk you through how I built it.
Architecture Overview
I designed the system with modularity and extensibility in mind. Here’s the high-level architecture:
- Resume Class: The core data model that stores all the extracted information
- ResumeProcessor: Manages the overall process of analyzing resumes
- BaseExtractor: An abstract base class that defines the interface for all extractors
- Specialized Extractors: Classes that extract specific types of information (profile, skills, education, etc.)
- LLM Service: A centralized service for interacting with language models
Step 1: Setting Up the Project Structure
First, I set up a clean project structure to organize the code:
.
├── config.py # Configuration settings and API keys
├── extractors/ # Package for specialized extractors
│ ├── __init__.py # Package exports
│ ├── base_extractor.py # Base class for all extractors
│ ├── profile_extractor.py # Profile information extractor
│ ├── skills_extractor.py # Skills extractor
│ ├── education_extractor.py # Education extractor
│ ├── experience_extractor.py # Work experience extractor
│ └── yoe_extractor.py # Years of experience extractor
├── llm_service.py # Centralized LLM service
├── main.py # Main application entry point
├── models.py # Pydantic models (including Resume class)
├── utils/ # Utility functions
│ ├── __init__.py # Package exports
│ └── common.py # Common utility functions
├── requirements.txt # Project dependencies
└── .env.example # Example environment variables fileStep 2: Defining Data Models
Next, I defined the data models for our application using Pydantic. This gives us type safety and validation out of the box.
Get Gaurav Kumar’s stories in your inbox
Join Medium for free to get updates from this writer.
Here’s a simplified version of our Resume model:
class Resume(BaseModel):
"""A complete resume with all extracted information."""
# Profile information
name: Optional[str] = None
contact_number: Optional[str] = None
email: Optional[str] = None
# Skills
skills: List[str] = Field(default_factory=list)
# Education
educations: List[Education] = Field(default_factory=list)
# Work Experience
work_experiences: List[WorkExperience] = Field(default_factory=list)
# Years of Experience
YoE: Optional[str] = NoneStep 3: Building the LLM Service
The heart of the system is the LLM service, which uses Google’s Gemini models through the LangChain framework:
def extract_with_llm(pydantic_model, prompt_template, input_variables, input_data):
"""
Extract information from text using a language model.
"""
parser = JsonOutputParser(pydantic_object=pydantic_model)
prompt = PromptTemplate(
template=prompt_template,
input_variables=input_variables,
partial_variables={"format_instructions": parser.get_format_instructions()}
)
llm = get_llm() # Gets the LLM instance (Gemini)
chain = prompt | llm | parser
result = chain.invoke(input_data)
return resultThis function is the cornerstone of our extraction process. It:
- Creates a JSON output parser based on our Pydantic model
- Formats a prompt template with the input data
- Sends the prompt to the LLM
- Parses the LLM’s response into our Pydantic model
Step 4: Creating the Extractor Framework
To make the system modular and extensible, I created a base extractor class that all specialized extractors inherit from:
class BaseExtractor(ABC):
"""
Base class for all extractors.
"""
@abstractmethod
def get_model(self) -> Type[BaseModel]:
"""Get the Pydantic model for the extractor."""
pass
@abstractmethod
def get_prompt_template(self) -> str:
"""Get the prompt template for the extractor."""
pass
@abstractmethod
def process_output(self, output: Any) -> Dict[str, Any]:
"""Process the output from the LLM."""
pass
def extract(self, extracted_text: str) -> Dict[str, Any]:
"""Extract information from the resume."""
input_data = self.prepare_input_data(extracted_text)
output = llm_service.extract_with_llm(
self.get_model(),
self.get_prompt_template(),
self.get_input_variables(),
input_data
)
return self.process_output(output)This abstract base class defines the interface that all extractors must implement, while providing default implementations for common functionality.
Step 5: Implementing Specialized Extractors
With the framework in place, I implemented specialized extractors for different types of information:
Skills Extractor
class SkillsExtractor(BaseExtractor):
"""Extractor for skills."""
def get_model(self) -> Type[BaseModel]:
return Skills
def get_prompt_template(self) -> str:
return """
You are an assistant that extracts a list of skills mentioned in the text below.
Only focus on Skills section of the below text.
Return your output as a JSON object with the below schema.
{format_instructions}Text:
{text}
"""
def process_output(self, output: Any) -> Dict[str, Any]:
return self.process_list_output(output, "skills")The beauty of this approach is that each extractor only needs to define:
- The Pydantic model it uses
- The prompt template specifically designed for its extraction task
- How to process the output
I implemented similar extractors for profile information, education history, work experience, and years of experience.
Step 6: Building the Resume Processor
The ResumeProcessor class ties everything together:
class ResumeProcessor:
"""Class for processing resumes and extracting information."""
def __init__(self, resume_dir="./Resumes", output_dir="./Results"):
self.resume_dir = resume_dir
self.output_dir = output_dir
# Create extractors
self.profile_extractor = ProfileExtractor()
self.skills_extractor = SkillsExtractor()
self.education_extractor = EducationExtractor()
self.experience_extractor = ExperienceExtractor()
self.yoe_extractor = YoeExtractor()The process_resume method is where the magic happens:
def process_resume(self, pdf_file_path):
"""Process a single resume file."""
# Extract text from the resume
extracted_text = read_file(pdf_file_path)
# Extract information concurrently
with concurrent.futures.ThreadPoolExecutor() as executor:
future_profile = executor.submit(self.profile_extractor.extract, extracted_text)
future_skills = executor.submit(self.skills_extractor.extract, extracted_text)
future_education = executor.submit(self.education_extractor.extract, extracted_text)
future_experience = executor.submit(self.experience_extractor.extract, extracted_text)
future_yoe = executor.submit(self.yoe_extractor.extract, extracted_text)
# Get results
profile = future_profile.result()
skills = future_skills.result()
education = future_education.result()
experience = future_experience.result()
yoe = future_yoe.result()
# Create a Resume object from the extracted information
resume = Resume.from_extractors_output(
profile, skills, education, experience, yoe, pdf_file_path
)
return resumeI’m using Python’s concurrent.futures to run the extractors in parallel, which significantly speeds up the processing time.
Step 7: Running the System
Finally, the main.py file brings everything together:
def main():
# Set up logging
setup_logging()
# Create a resume processor
processor = ResumeProcessor(
resume_dir=config.RESUME_DIR,
output_dir=config.OUTPUT_DIR
)
# Process all resumes
processed_count, error_count = processor.process_all_resumes()
logging.info(f"Resume processing complete. Processed: {processed_count}, Errors: {error_count}")if __name__ == "__main__":
main()The Results
The system outputs a JSON file for each resume, containing all the extracted information in a structured format:
{
"name": "John Doe",
"contact_number": "+1-123-456-7890",
"email": "john.doe@example.com",
"skills": [
"Python",
"Machine Learning",
"Data Analysis",
"SQL",
"JavaScript"
],
"educations": [
{
"institution": "University of Example",
"start_date": "2015-09",
"end_date": "2019-05",
"location": "Boston, MA",
"degree": "Bachelor of Science in Computer Science"
}
],
"work_experiences": [
{
"company": "Tech Company Inc.",
"start_date": "2019-06",
"end_date": "2023-03",
"location": "San Francisco, CA",
"role": "Software Engineer"
}
],
"YoE": "4 years"
}Challenges and Lessons Learned
Building this system wasn’t without challenges:
- Prompt Engineering: Getting the right prompts for each extractor was crucial. I had to iterate several times to get the prompts that would reliably extract the information we needed.
- Error Handling: Resumes come in all shapes and sizes, and sometimes the extraction fails. Building robust error handling was essential for a production-ready system.
- Concurrency: Running the extractors in parallel was a significant optimization, but it required careful handling of concurrent API calls.
- Cost Management: LLM API calls aren’t free, so I had to be mindful of the number of calls we were making. Caching common requests helped reduce costs.
Future Improvements
There are several ways to enhance this system:
- Support for Different Resume Formats: Currently, the system only processes PDF files. Adding support for DOCX, TXT, and other formats would make it more versatile.
- More Extractors: Adding extractors for additional information like projects, certifications, references, and other resume sections would provide more comprehensive analysis.
- Web Interface: Building a web interface for uploading resumes and viewing results would make the system more user-friendly, allowing non-technical users to take advantage of the system.
- Multi-language Support: Currently, the system works best with English resumes. Supporting other languages would make it more versatile for international use.
Setting Up the Google Gemini API Key
This project uses Google’s Gemini API for natural language processing. Here’s how to get set up with a free API key:
- Visit Google AI Studio
- Sign in with your Google account
- Navigate to the “Get API key” option
- Create a new API key
- Copy the API key and add it to your
.envfile asGOOGLE_API_KEY
Free Tier Limitations
Google offers a generous free tier for the Gemini API that’s suitable for testing and small-scale projects. Here’s what you get with the free tier:
- Cost: Input and output tokens are completely free
- Models: Access to models like Gemini 1.5 Flash and Gemini 2.0 Flash
- Rate Limits:
- 15 requests per minute (RPM) for Gemini 2.0 Flash
- 1,000,000 tokens per minute (TPM)
- 1,500 requests per day (RPD)
For the Resume Analysis system, our implementation reveals actual token usage metrics:
- Average tokens per resume: ~7,700 tokens total
- Token distribution: ~96% prompt tokens, ~4% completion tokens
With these metrics, the free tier allows processing of:
- Maximum 130 resumes per minute within the token limit (1,000,000 ÷ 7,700)
- Up to 1,500 resumes per day due to the daily request limit
This is more than adequate for personal use, small businesses, or even medium-sized recruiting operations. Our implementation stores detailed token usage information in separate JSON log files, providing valuable analytics on API consumption while keeping the resume output files clean with only the relevant extracted data.
Conclusion
Building a resume analysis system with LLMs was a fascinating project that showcases the power of modern AI. By combining the structured approach of object-oriented programming with the natural language understanding capabilities of LLMs, we can create systems that extract meaningful information from unstructured text.
The modular architecture makes the system easy to extend and maintain, and the use of concurrent processing makes it efficient. Whether you’re a hiring manager looking to streamline your process or a developer interested in applying LLMs to real-world problems, I hope this project provides some inspiration.
If you’re interested in implementing a similar system, the complete code is available on my GitHub repository. Feel free to fork it, contribute, or adapt it to your own needs!
I will be posting more about the future improvements of the project. Stay Tuned!
Happy coding!








