11 min read ai-guides

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.

Local AI Agent for Semantic Job Filtering and Matching
D

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:

  1. 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-large or all-MiniLM-L6-v2.
  2. 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.
  3. 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

  1. 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.
  2. 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.
  3. 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.