Local AI Agent for Semantic Job Filtering and Matching
A technical guide to building an autonomous AI agent using embeddings, vector search (Qdrant), and local LLMs for semantic scoring of job descriptions.
Dr. Alan Mercer
Principal AI Researcher & RAG Architect
Dr. Alan Mercer is an artificial intelligence researcher specializing in LLM evaluation, domain-specific embeddings fine-tuning, and robust multi-agent systems orchestration. He develops next-generation semantic search and AI agent tools.
Anyone who has tried to set up automated job searches using keywords on popular platforms (LinkedIn, Indeed, etc.) knows the pain of inaccurate matching. A standard text search for the keyword “SRE” brings up jobs for Senior SRE Architects, entry-level 24/7 support system administrators with low pay, or even sales managers in SRE-related companies.
Keywords do not capture context, seniority level, real technical expectations, or a company’s cultural code. Solving this problem on a fundamentally new level is possible through Semantic Search using vector embeddings and autonomous AI agents.
In this article, we will design and implement a local AI agent in Python from scratch. It will automatically fetch jobs, convert them into multi-dimensional vectors, match them with your detailed candidate profile in a Qdrant vector database, and make a final decision on relevance using a local Large Language Model (e.g., Llama 3 via Ollama).
1. Solution Architecture
Our agent consists of three key modules:
- Embedding Engine: Converts the textual job description into a vector (typically of dimension 384, 768, or 1024) using a local embedding model. We will use
mxbai-embed-largeorall-MiniLM-L6-v2. - Vector Database: A Qdrant database where a vector footprint of your ideal profile is stored (desired experience, technologies, project examples). We perform a Cosine Similarity search to instantly weed out 90% of irrelevant noise.
- LLM Decision Core (Local AI Analyst): If the cosine similarity exceeds a threshold value (e.g., 0.75), the full text of the job and your resume are passed to a local LLM for detailed analysis and a final decision: “MATCH / PARTIAL / NO_MATCH”.
2. Step 1: Preparing the Environment and Infrastructure
We will run the Qdrant vector database and the local LLM in Docker containers to ensure complete isolation and data safety.
Launch Qdrant:
docker run -d -p 6333:6333 -p 6334:6334 \
-v qdrant_storage:/qdrant/storage \
qdrant/qdrant:latest
Launch Ollama with Llama 3 and the embedding model:
# Run Ollama container (with GPU support if available)
docker run -d --gpus=all -v ollama:/root/.ollama -p 11434:11434 --name ollama ollama/ollama
# Download models
docker exec -it ollama ollama run llama3:8b-instruct-q4_K_M
docker exec -it ollama ollama pull mxbai-embed-large
3. Step 2: Implementing Embedding and Semantic Search in Python
Install the necessary dependencies on the host:
pip install qdrant-client sentence-transformers requests
Let’s write the Python module to work with Qdrant and generate vectors using sentence-transformers:
from qdrant_client import QdrantClient
from qdrant_client.http import models
from sentence_transformers import SentenceTransformer
class VectorSearchEngine:
def __init__(self, collection_name="candidate_profiles"):
# Initialize local Qdrant client
self.client = QdrantClient("http://localhost:6333")
self.collection_name = collection_name
# Load a lightweight and fast local embedding model
# It converts text to a vector of dimension 384
self.model = SentenceTransformer('sentence-transformers/all-MiniLM-L6-v2')
# Create a collection in Qdrant if it doesn't exist
self._ensure_collection()
def _ensure_collection(self):
collections = self.client.get_collections().collections
exists = any(c.name == self.collection_name for c in collections)
if not exists:
self.client.create_collection(
collection_name=self.collection_name,
vectors_config=models.VectorParams(
size=384, # Dimension of all-MiniLM-L6-v2
distance=models.Distance.COSINE # Cosine distance
)
)
print(f"[+] Qdrant collection created: {self.collection_name}")
def index_profile(self, profile_id, profile_text, metadata=None):
"""
Indexes your profile (resume + preferences) into the database.
"""
vector = self.model.encode(profile_text).tolist()
self.client.upsert(
collection_name=self.collection_name,
points=[
models.PointStruct(
id=profile_id,
vector=vector,
payload={"text": profile_text, **(metadata or {})}
)
]
)
print(f"[+] Profile #{profile_id} successfully vectorized and saved.")
def search_similar_jobs(self, vacancy_text, limit=1):
"""
Searches for job matches against the saved candidate profile.
"""
query_vector = self.model.encode(vacancy_text).tolist()
results = self.client.search(
collection_name=self.collection_name,
query_vector=query_vector,
limit=limit,
with_payload=True
)
return results
4. Step 3: Implementing the Local LLM Decision Filter
If the Qdrant vector search returns a Similarity Score above the threshold (e.g., 0.40 for this compact embedding model), we send the job description text to the local AI agent for a final common-sense check.
Create the file agent_matcher.py:
import requests
import json
from agent_vector import VectorSearchEngine
OLLAMA_URL = "http://localhost:11434/api/generate"
SYSTEM_PROMPT = """
You are an intelligent AI job filter. Your task is to compare the candidate's profile with the job description and output a strict decision.
Output the result in JSON format with the following fields:
1. "match_status": A string that takes the values "MATCH" (suitable), "PARTIAL" (partially suitable, some controversial points), or "NO_MATCH" (not suitable at all).
2. "reasoning": A brief technical explanation of your decision (2-3 sentences).
3. "missing_skills": A list of critical skills or requirements from the job description that are missing from the candidate's profile.
Be strict and realistic. Do not make false assumptions.
"""
def evaluate_vacancy_with_llm(candidate_profile, vacancy_text):
prompt = f"""
### CANDIDATE PROFILE:
{candidate_profile}
### JOB DESCRIPTION:
{vacancy_text}
Please perform a deep analysis and provide the JSON according to the instructions.
"""
payload = {
"model": "llama3",
"prompt": prompt,
"system": SYSTEM_PROMPT,
"stream": False,
"format": "json" # Request Ollama to return a valid JSON directly
}
try:
response = requests.post(OLLAMA_URL, json=payload, timeout=60)
response_data = response.json()
return json.loads(response_data['response'])
except Exception as e:
print(f"[-] Error querying local LLM: {e}")
return None
def process_new_vacancy(engine, vacancy_title, vacancy_text):
print(f"\n[*] Analyzing new vacancy: {vacancy_title}...")
# 1. Semantic search by vector
search_results = engine.search_similar_jobs(vacancy_text)
if not search_results:
print("[-] Candidate profile not found in database.")
return
best_match = search_results[0]
score = best_match.score
candidate_profile_text = best_match.payload["text"]
print(f"[+] Cosine Similarity Score: {score:.4f}")
# Set an empirical threshold for semantic matching
THRESHOLD = 0.38
if score < THRESHOLD:
print("[-] Rejected: Vector match score is too low.")
return
# 2. Deep analysis with local LLM
print("[*] High similarity! Running local LLM validator...")
verdict = evaluate_vacancy_with_llm(candidate_profile_text, vacancy_text)
if verdict:
print("\n=== AI AGENT VERDICT ===")
print(f"Status: {verdict.get('match_status')}")
print(f"Reasoning: {verdict.get('reasoning')}")
print(f"Missing Skills: {', '.join(verdict.get('missing_skills', []))}")
print("=========================")
else:
print("[-] Failed to get verdict.")
# Demonstration launch
if __name__ == "__main__":
engine = VectorSearchEngine()
# Index our profile first
my_profile = """
Experience: Senior DevOps/SRE Engineer, over 6 years of experience.
Key skills: Kubernetes, Terraform, Ansible, Docker, Prometheus, VictoriaMetrics, CI/CD (GitLab, GitHub Actions).
Programming languages: Python (scripting, FastAPI), Go (basic level, writing microservices).
Interests: Infrastructure performance optimization, building monitoring and alerting systems.
Not interested: Legacy Windows server support, manual deployment to VMs without automation.
"""
engine.index_profile(profile_id=1, profile_text=my_profile)
# Test a good job match
good_job = """
We are looking for a Senior SRE for an ambitious project.
You will scale our Kubernetes clusters in AWS, write Infrastructure as Code with Terraform,
and configure microservice monitoring using Prometheus and VictoriaMetrics.
Experience writing automation in Python is highly welcome.
"""
process_new_vacancy(engine, "Senior SRE (Kubernetes/AWS)", good_job)
# Test a bad job match
bad_job = """
Required: L1 Technical Support Specialist.
Duties: receiving calls from customers, creating tickets in Jira, manually restarting Windows servers via RDP.
Confident PC user.
"""
process_new_vacancy(engine, "Support Engineer L1", bad_job)
5. Benefits of the Local Approach for Job Seekers
- Absolute Privacy (Zero Data Leak): Your resumes, personal preferences, and job views are never sent to third-party OpenAI or Anthropic servers. The entire cycle occurs locally.
- Zero Inference Cost (Free of Charge): You can analyze tens of thousands of jobs daily without spending a cent on API tokens. Your only costs are electricity and compute power.
- Integration Flexibility: You can integrate this agent as a subscriber to a NATS message queue within our distributed platform (JobHunterBot) to process job streams in real time.
Conclusion
Creating a semantic agent elevates job search automation to a smart, new level. The combination of fast vector search for broad filtering and deep analysis by a local language model enables a highly accurate, personal job radar that runs autonomously 24/7.
Read more AI for job search guides on the MindFire blog.
Related Posts
System Prompt Engineering for Tailoring Resumes to Job Descriptions
How to write the perfect system prompt for LLMs to adapt resumes for ATS requirements without fabricating experience or hallucinations.
Optimizing Local LLM Inference with llama.cpp and Quantization
How to run high-throughput LLMs on consumer-grade GPU and CPU hardware using GGUF quantization levels, batch sizes, and context window tuning.
Implementing Real-time Token Streaming in FastAPI and SvelteKit
Designing a high-concurrency event stream endpoint (Server-Sent Events) to stream markdown tokens directly from LLM engines to interactive web UIs.
Guardrailing LLM APIs Against Advanced Prompt Injection Attacks
Implementing robust defensive system boundaries, semantic validation layers, and input-output token checkers to keep generative APIs safe.
Fine-Tuning Sentence Transformers for Specialized Domains
A step-by-step tutorial on training bi-encoders on domain-specific corpora using Multiple Negatives Ranking Loss to drastically improve cosine accuracy.
On this page
Category
ai-guidesRelated Posts
System Prompt Engineering for Tailoring Resumes to Job Descriptions
How to write the perfect system prompt for LLMs to adapt resumes for ATS requirements without fabricating experience or hallucinations.
Optimizing Local LLM Inference with llama.cpp and Quantization
How to run high-throughput LLMs on consumer-grade GPU and CPU hardware using GGUF quantization levels, batch sizes, and context window tuning.
Implementing Real-time Token Streaming in FastAPI and SvelteKit
Designing a high-concurrency event stream endpoint (Server-Sent Events) to stream markdown tokens directly from LLM engines to interactive web UIs.
Guardrailing LLM APIs Against Advanced Prompt Injection Attacks
Implementing robust defensive system boundaries, semantic validation layers, and input-output token checkers to keep generative APIs safe.
Fine-Tuning Sentence Transformers for Specialized Domains
A step-by-step tutorial on training bi-encoders on domain-specific corpora using Multiple Negatives Ranking Loss to drastically improve cosine accuracy.