> ## Documentation Index
> Fetch the complete documentation index at: https://docs.recruitier.com/llms.txt
> Use this file to discover all available pages before exploring further.

# How AI Job Matching Works

> Understand Recruitier's 5-stage AI matching pipeline: embedding generation, search query creation, parallel vector search, match deduplication, and concurrent AI scoring.

## Overview

Recruitier's job matching engine is a multi-stage AI pipeline that takes a candidate's complete profile -- confirmed skills, experience, title, location, and preferences -- and finds the most relevant jobs from a database of thousands of opportunities. It then scores and ranks every match using AI evaluation across four dimensions, producing detailed explanations that tell you exactly why each job is a good fit.

The entire process runs in the background. You start it and continue working while the system handles searching, filtering, and scoring. Real-time progress updates are delivered to your browser via Server-Sent Events (SSE) as each stage completes. A typical matching run processes 50-200 jobs in under 2 minutes.

## The 5-Stage Matching Pipeline

When matching is triggered for a candidate, the system executes five distinct stages in sequence. Each stage builds on the output of the previous one.

<Steps>
  <Step title="Stage 1: Embedding (Analyzing)">
    **What happens**: The system reads the candidate's full profile and generates vector embeddings -- mathematical representations of the candidate's expertise that enable semantic search.

    The matching engine loads the candidate's data from the database, including:

    * CV text (original from upload or synthetic from LinkedIn import)
    * Confirmed skills (only confirmed skills are used)
    * Job title and experience level
    * Location coordinates and search radius
    * Salary expectations and job type/flexibility preferences

    It then generates **three separate vector embeddings** for the candidate's profile:

    | Vector                | Source Data            | What It Captures                                                | Weight in Search |
    | --------------------- | ---------------------- | --------------------------------------------------------------- | ---------------- |
    | **Title vector**      | Job title and headline | Role alignment, seniority level, job function                   | 35%              |
    | **Skills vector**     | Confirmed skill list   | Technical capabilities and tool proficiency                     | 45%              |
    | **Experience vector** | Summary and CV text    | Broader professional context, domain knowledge, work complexity | 20%              |

    These three vectors are stored in the **Qdrant vector database** for fast similarity comparison. If the candidate has been embedded before (`is_embedded = true`), existing vectors are used unless the profile has changed. If this is the first embedding or the profile has been updated, fresh vectors are generated by calling the recommendation service.

    **Why three vectors instead of one?** A single embedding would blur the signal. By separating title, skills, and experience, the system can independently evaluate different aspects of fit. A candidate might have a perfect skills match (Python, FastAPI, PostgreSQL) for a job that has a very different title ("Platform Engineer" vs. "Backend Developer"). The three-vector approach catches these nuanced matches that a single-vector system would miss.

    <Info>
      Vector embeddings are high-dimensional mathematical representations (typically 384-768 dimensions) where similar meanings are close together in vector space. "Python Developer" and "Python Engineer" produce vectors that are very close together, even though the exact words are different. "Python Developer" and "Marketing Manager" produce vectors that are far apart.
    </Info>
  </Step>

  <Step title="Stage 2: Generating Search Queries">
    **What happens**: The AI generates multiple diverse search strategies based on the candidate's profile.

    Rather than running a single search, the system asks the AI (Gemini) to create up to **15 different search query sets**. Each query set targets a different angle of the candidate's capabilities:

    * **Direct title matches** -- Jobs with the same or similar title
    * **Skill-based searches** -- Jobs that require the candidate's key skills in combination
    * **Alternative roles** -- Related positions the candidate could fill based on their skill set
    * **Industry variations** -- The same role in different sectors or company types
    * **Seniority variations** -- Similar roles at slightly different levels

    **Example**: A "Senior Python Developer" with skills in FastAPI, PostgreSQL, and Docker might generate:

    | Query # | Search Strategy      | Example Query                        |
    | ------- | -------------------- | ------------------------------------ |
    | 1       | Direct title         | "senior python developer"            |
    | 2       | Skill combination    | "python backend engineer fastapi"    |
    | 3       | Framework focus      | "python developer django fastapi"    |
    | 4       | Alternative title    | "software engineer python"           |
    | 5       | Infrastructure angle | "python developer docker kubernetes" |
    | 6       | Data angle           | "python developer postgresql data"   |
    | 7       | Lead variation       | "lead python developer"              |
    | 8       | Full-stack angle     | "full-stack python developer"        |
    | ...     | ...                  | ...                                  |

    **Why multiple queries matter**: A single search query can miss jobs that use different terminology for the same role. "Backend Engineer", "Software Developer", "Platform Engineer", and "Application Developer" might all describe positions that a Senior Python Developer would be perfect for. Multiple search strategies cast a wider net and catch opportunities that keyword-only or single-query searching would miss.
  </Step>

  <Step title="Stage 3: Searching (Vector Search)">
    **What happens**: The system executes parallel vector (semantic) searches across the entire job database, applying preference-based filters.

    This is where the three-vector matching architecture comes into play. For the candidate's profile, the system runs **three parallel vector searches** against the job database:

    1. **Title-to-Title search (35% weight)** -- The candidate's title vector is compared against every job's title vector using cosine similarity. This finds jobs with similar role descriptions regardless of exact wording. "Senior Python Developer" matches "Python Backend Engineer" because they are semantically similar.

    2. **Skills-to-Skills search (45% weight)** -- The candidate's skills vector is compared against every job's skills vector. This finds jobs requiring similar technical capabilities. A candidate with \[Python, FastAPI, PostgreSQL] matches a job requiring \[Python, Django, MySQL] because the skill sets overlap significantly in the vector space.

    3. **Experience-to-Description search (20% weight)** -- The candidate's experience vector is compared against every job's full description vector. This captures broader context like domain knowledge, industry experience, work complexity, and organizational expectations.

    Each search also applies **filters** based on the candidate's preferences and data quality requirements:

    | Filter                 | Purpose                                              | Applied When                   |
    | ---------------------- | ---------------------------------------------------- | ------------------------------ |
    | **is\_active**         | Only active, current job listings                    | Always                         |
    | **Recency (6 months)** | Only jobs posted within the last 6 months            | Always                         |
    | **Company name**       | Exclude jobs without a valid company name            | Always                         |
    | **Industry exclusion** | Exclude staffing and recruiting agencies             | Always                         |
    | **Experience level**   | Match candidate's level (junior/medior/senior/lead)  | When set on candidate          |
    | **Location + radius**  | Exclude jobs beyond 2x the preferred radius          | When candidate has coordinates |
    | **Job type**           | Only matching types (full-time, part-time, contract) | When preferences set           |
    | **Flexibility**        | Only matching arrangements (on-site, hybrid, remote) | When preferences set           |

    The search uses a **fetch limit of 400** -- it retrieves up to 400 candidate jobs from the vector database. This is intentionally larger than the display limit of 200 to provide a buffer for post-filtering (deduplication, existing match exclusion, etc.).

    **Why semantic search outperforms keyword search**: A job posting for a "Python Backend Engineer" will match a candidate titled "Senior Python Developer" even though the exact phrase differs. A job requiring "cloud infrastructure experience" will match a candidate whose CV describes "deploying applications on AWS EC2 and ECS" because the embedding model understands these are semantically related. This is dramatically better than keyword matching, which would miss these connections.
  </Step>

  <Step title="Stage 4: Processing (Match Creation)">
    **What happens**: The system collects results from all searches, removes duplicates, and creates match records in the database.

    Since multiple search queries and three parallel vector searches may find the same job, the system performs several processing steps:

    1. **Merge vector scores**: For each job found, the three vector similarity scores are combined using the weighted formula: `combined_score = (title_score * 0.35) + (skills_score * 0.45) + (experience_score * 0.20)`

    2. **Deduplicate results**: The same job may appear in multiple search results. The system keeps only the highest-scoring instance of each unique job.

    3. **Exclude existing matches**: Jobs that already have a `CandidateJobMatch` record for this candidate are excluded. This prevents duplicate match cards in the pipeline.

    4. **Exclude protected matches**: Jobs that are in protected statuses (favorited, applied, contacted, interviewing, offer stage, placed) are identified and their IDs are passed to the exclude list. These matches are NEVER deleted or replaced during re-matching.

    5. **Validate company data**: Each matched job must have a valid company name and company information. Jobs with missing company data are filtered out.

    6. **Create match records**: `CandidateJobMatch` records are created in the database with the initial vector similarity scores and a "pending" status. These records link the candidate to specific jobs and will be updated with AI scores in the next stage.

    **Why deduplication matters**: Without it, a job found by 3 different search queries would appear 3 times in your pipeline. The deduplication ensures each opportunity appears exactly once, with the best available vector similarity score.

    <Note>
      Protected matches are sacrosanct. If you have favorited a job or started interviewing for it, that match will never be removed during re-matching. This is a core design principle -- your pipeline progress is always preserved.
    </Note>
  </Step>

  <Step title="Stage 5: Scoring (AI Evaluation)">
    **What happens**: Every matched job is individually evaluated by Gemini, which produces a detailed score breakdown and natural language explanation.

    For each job match, the AI receives a carefully constructed prompt containing:

    | Input                      | Source                                    | Character Limit            |
    | -------------------------- | ----------------------------------------- | -------------------------- |
    | Candidate CV text          | `cv_text` field (original or synthetic)   | Up to **8,000 characters** |
    | Candidate confirmed skills | `skills_with_confidence` (confirmed only) | Full list                  |
    | Candidate experience level | `experience_level` field                  | Single value               |
    | Job title                  | From the matched job                      | Full text                  |
    | Company name               | From the matched job's company            | Full text                  |
    | Job location               | From the matched job                      | Full text                  |
    | Job description            | Full description from the job listing     | Up to **6,000 characters** |

    The AI evaluates the match across **four scoring dimensions**:

    | Dimension          | Default Weight            | What It Evaluates                                                                                                               |
    | ------------------ | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
    | **Role Fit**       | 30% (`weight_role`)       | How well the candidate's background aligns with the role requirements. Career trajectory, seniority match, role type alignment. |
    | **Skills Fit**     | 35% (`weight_skills`)     | How many required skills the candidate possesses. Identifies matching and missing skills.                                       |
    | **Experience Fit** | 20% (`weight_experience`) | Whether the candidate's years and type of experience match expectations. Industry relevance, project complexity.                |
    | **Secondary Fit**  | 15% (`weight_secondary`)  | Additional factors: education alignment, certifications, industry knowledge, language requirements, culture fit indicators.     |

    Each dimension receives a score from 0 to 1. The AI also produces:

    * **Key matching points** -- The strongest reasons the candidate fits this role (displayed as green badges on match cards)
    * **Potential concerns** -- Issues to consider, such as missing skills or experience gaps (displayed as orange badges)
    * **Natural language explanation** -- A summary of why this match is good or not, with specific evidence from both the CV and job description
    * **Recommendation** -- "apply", "consider", or "skip" based on the final score

    After AI scoring, the **location penalty** is applied as a multiplicative factor (see [Location & Preferences](/candidates/location-and-preferences) for the formula). Jobs are then ranked by their final score, and ranks are assigned (1 = best match).

    **Concurrent scoring for speed**: To keep matching fast even with hundreds of potential jobs, the AI scoring runs **concurrently** -- up to **50 jobs are scored simultaneously** using parallel API calls. This concurrency level is tuned to stay within Gemini's rate limit of 500 requests per minute. Each individual scoring call has a **90-second timeout** to prevent any single slow evaluation from blocking the entire batch.

    A typical matching run scores 50-200 jobs in **under 2 minutes**.

    <Warning>
      **Critical mismatch caps**: If a scoring dimension is extremely low, the overall score is capped to prevent misleadingly high scores:

      * **Role Fit below 0.30**: Overall score capped at **35%** -- even great skills cannot compensate for a completely wrong role
      * **Skills Fit below 0.30**: Overall score capped at **45%** -- a strong role match does not help if the candidate lacks the core skills

      These caps ensure that fundamental mismatches are always surfaced, regardless of how well other dimensions score.
    </Warning>
  </Step>
</Steps>

## The Technology Behind Matching

### Vector Embeddings Explained

At the core of Recruitier's matching is vector embedding technology. Text (job titles, skill lists, descriptions) is converted into high-dimensional mathematical vectors where similar meanings are close together in vector space.

This means "Python Developer" and "Python Engineer" produce vectors that are very close together, even though the words are different. "Python Developer" and "Java Developer" are further apart but still closer than "Python Developer" and "Marketing Manager". The distance between vectors represents semantic similarity.

### Three-Vector Architecture

Rather than using a single embedding for the whole profile, Recruitier uses three separate vectors with different weights:

| Vector         | Weight | Rationale                                                                                                                                                                                                                    |
| -------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Title**      | 35%    | The job title is the most direct signal for role alignment. It captures seniority (Senior, Junior, Lead), domain (Backend, Frontend, Full-Stack), and primary function (Developer, Engineer, Architect).                     |
| **Skills**     | 45%    | Skills are the strongest predictor of technical fit. They carry the most weight because a great title match is meaningless if the skills do not align. A "Senior Developer" without the right technology stack is not a fit. |
| **Experience** | 20%    | The full experience description provides "soft signals" -- domain knowledge, company types, project complexity, industry context. It is noisier than skills or title but adds useful context that the other vectors miss.    |

This weighted approach outperforms single-vector matching because it allows the system to separately evaluate different aspects of fit and combine them intelligently. Research shows that multi-vector approaches improve recall by 15-25% compared to single-vector methods.

### Concurrent AI Scoring Architecture

The scoring stage uses a concurrent execution model to maximize throughput:

* **Batch size**: Up to 50 concurrent AI scoring calls
* **Rate limit**: Stays within Gemini's 500 RPM (requests per minute) limit
* **Timeout**: Each individual scoring call has a 90-second timeout
* **Error handling**: Failed scoring calls are logged but do not block other scores. A job that fails to score receives a default handling rather than crashing the entire batch.
* **Typical throughput**: 50-200 jobs scored in under 2 minutes

This means that even for candidates with very broad skill sets or large search radii that produce many vector matches, the scoring completes quickly.

## Real-Time Progress Updates

During matching, you receive live progress updates directly on the candidate detail page. A progress panel shows each stage with real-time status indicators:

| Stage          | Label             | Description                                     |
| -------------- | ----------------- | ----------------------------------------------- |
| **Analyzing**  | Analyzing Profile | Reading the candidate's skills and experience   |
| **Generating** | Creating Queries  | Building search parameters for the job database |
| **Searching**  | Finding Jobs      | Scanning databases for matching opportunities   |
| **Processing** | Processing        | Deduplicating and preparing match records       |
| **Scoring**    | AI Scoring        | Evaluating each match for quality and fit       |

Each stage shows one of three states: completed (checkmark), currently running (spinner), or pending (circle). The panel also shows a description of what the current stage is doing and a note that matching usually takes less than a minute.

The progress updates are delivered via Server-Sent Events (SSE) using Redis pub/sub. You do not need to refresh the page. You can watch the matching progress in real time, navigate away and come back later, or work on other candidates while matching runs in the background.

## When Matching Runs

Matching is triggered automatically in several scenarios:

| Trigger                    | Type      | What Happens                                                                       |
| -------------------------- | --------- | ---------------------------------------------------------------------------------- |
| **After CV upload**        | Automatic | Full 5-stage pipeline runs after the candidate is created and skills are confirmed |
| **After LinkedIn import**  | Automatic | Full 5-stage pipeline runs after the profile is imported                           |
| **After skill change**     | Automatic | If confirmed skills changed (added/removed), re-matching is triggered              |
| **After location change**  | Automatic | Changes to location, coordinates, or radius trigger re-matching                    |
| **On login (incremental)** | Automatic | Top 5 candidates matched against 50 new jobs each, with 6-hour cooldown            |
| **Manual trigger**         | On demand | You can manually start matching from the candidate profile at any time             |

### Re-Matching Behavior

When matching runs again for a candidate who already has matches:

* **Protected matches are ALWAYS preserved**: Jobs in these statuses are never removed:
  * Favorited
  * Applied
  * Contacted
  * Interviewing
  * Offer Stage
  * Placed
* **Pending matches may be replaced**: Unscored or unacted-upon matches (in "New Matches" status) may be replaced with fresh results
* **New matches are added**: Jobs that were not in the previous results are added as new matches
* **Exclude list**: Protected match job IDs are passed to the matching pipeline so it does not waste resources re-scoring jobs you have already engaged with

<Info>
  Re-matching is designed to be safe. It will never erase your pipeline progress. Only "pending" matches that you have not interacted with are affected. This means you can confidently update a candidate's skills or location knowing that your favorited, contacted, and interviewing matches are safe.
</Info>

### Incremental Matching on Login

To keep match results fresh without manual intervention, Recruitier runs incremental matching when you log in:

| Parameter                | Value                                                                                |
| ------------------------ | ------------------------------------------------------------------------------------ |
| **Trigger**              | User login                                                                           |
| **Cooldown**             | 6 hours (does not run if last run was less than 6 hours ago)                         |
| **Candidates processed** | Top 5 candidates (by activity or priority)                                           |
| **Jobs per candidate**   | Up to 50 new jobs                                                                    |
| **SSE events**           | `login_matching_started`, `incremental_matching_success`, `login_matching_completed` |

This means that every morning when you log in, your most active candidates automatically get matched against jobs that were posted overnight or recently added to the database.

## Matching Configuration

### Scoring Weights

The default scoring weights can be customized per candidate through the candidate profile:

| Weight              | Default | Configurable | Use Case for Adjustment                                         |
| ------------------- | ------- | ------------ | --------------------------------------------------------------- |
| `weight_role`       | 0.30    | Yes          | Increase for roles where industry/domain alignment matters most |
| `weight_skills`     | 0.35    | Yes          | Increase for highly technical roles (ML Engineer, DevOps)       |
| `weight_experience` | 0.20    | Yes          | Increase for leadership or management positions                 |
| `weight_secondary`  | 0.15    | Yes          | Increase for roles where education/certifications are critical  |

All four weights must sum to **1.0**. When you increase one weight, decrease others proportionally.

<Tip>
  For most technical roles, the default weights work well. Only adjust weights when you notice that the standard scoring is not capturing what matters most for a specific type of role. Common adjustments:

  * **Career changers**: Decrease role weight (0.15), increase skills weight (0.50)
  * **Leadership positions**: Increase experience weight (0.30), decrease skills weight (0.25)
  * **Regulated industries**: Increase secondary weight (0.25) to emphasize certifications and compliance
</Tip>

### Fetch and Display Limits

The system has configurable limits for pipeline performance:

| Limit             | Default | Purpose                                                          |
| ----------------- | ------- | ---------------------------------------------------------------- |
| **Fetch limit**   | 400     | Maximum jobs retrieved from vector search (buffer for filtering) |
| **Display limit** | 200     | Maximum matches shown to the user after all processing           |
| **Minimum score** | 0.50    | Jobs below this threshold receive a "skip" recommendation        |

## Advanced

### The Complete Pipeline Data Flow

Here is the full data flow from trigger to results, showing how data moves through each stage:

```
Trigger (CV upload, skill change, location change, manual, login)
    |
    v
Stage 1: Embedding
    Input: candidate.title, candidate.skills (confirmed), candidate.cv_text
    Process: Generate 3 vectors via recommendation service
    Output: Title vector, Skills vector, Experience vector -> stored in Qdrant
    |
    v
Stage 2: Query Generation
    Input: Candidate profile (title, skills, summary, experience level)
    Process: Gemini generates up to 15 diverse search query sets
    Output: Array of search query strings
    |
    v
Stage 3: Vector Search
    Input: 3 candidate vectors + query strings + filter criteria
    Process: Parallel searches in Qdrant with filters
    Output: Up to 400 job candidates with vector similarity scores
    Filters applied: is_active, recency, company name, industry exclusion,
                     experience level, location/radius, job type, flexibility
    |
    v
Stage 4: Match Creation
    Input: Raw search results (potentially duplicated across queries)
    Process: Merge scores (35%/45%/20%), deduplicate, exclude existing matches,
             exclude protected matches, validate company data
    Output: CandidateJobMatch records in database (pending status)
    |
    v
Stage 5: AI Scoring
    Input: Each match's candidate CV (8K chars) + confirmed skills +
           job description (6K chars) + job metadata
    Process: 50 concurrent Gemini calls, 90s timeout each
    Output: Per-match scores (role_fit, skills_fit, experience_fit, secondary_fit),
            key matching points, potential concerns, explanation, recommendation
    Post-process: Apply location penalty, calculate final score, assign ranks
    |
    v
Result: Ranked list of up to 200 scored matches with full explanations
    Delivered via: SSE notification to browser
```

### How the Scoring Prompt Works

The AI scoring uses a carefully engineered prompt (CANDIDATE\_JOB\_SCORING\_PROMPT) that instructs Gemini to:

1. Read the candidate's CV text and confirmed skills
2. Read the job description and requirements
3. Evaluate four dimensions independently (role fit, skills fit, experience fit, secondary fit)
4. Identify key matching points and potential concerns
5. Produce a natural language explanation with evidence
6. Return a structured JSON response

The prompt is designed to produce consistent, calibrated scores. The AI is instructed to:

* Use the full 0-1 range (not cluster around 0.5)
* Provide specific evidence from both the CV and job description
* Flag fundamental mismatches explicitly
* Consider both required and preferred qualifications separately

### Critical Penalty System

The critical mismatch caps serve as a safety net against score inflation:

**Role Fit cap**: When `role_fit < 0.30`, the overall score is capped at **0.35** (35%). This prevents a situation where a Marketing Manager with Python hobby projects gets an 80% match for a Senior Python Developer role just because their skills overlap.

**Skills Fit cap**: When `skills_fit < 0.30`, the overall score is capped at **0.45** (45%). This prevents a situation where a Senior Java Developer gets a high score for a Python/FastAPI role just because their seniority and experience align.

These caps ensure that fundamental mismatches in either role or skills always result in a low overall score, regardless of how well other dimensions score.

### Staffing Agency Exclusion

The matching pipeline automatically excludes jobs posted by staffing and recruiting agencies. This is a deliberate business rule: recruiters using Recruitier do not want to match their candidates to competitor agencies' job listings.

The exclusion is based on the `EXCLUDED_CLIENT_INDUSTRIES` list, which currently includes "Staffing and Recruiting". Companies with this industry classification in the `global_company` table are excluded from search results. Companies without industry data (NULL) are still included -- only explicitly excluded industries are filtered out.

### SSE Event Flow During Matching

The matching pipeline publishes the following SSE events through Redis pub/sub:

| Event                          | When                                       | Data                                                       |
| ------------------------------ | ------------------------------------------ | ---------------------------------------------------------- |
| `matching_started`             | Pipeline begins                            | candidate\_id, candidate\_name                             |
| `matching_stage_update`        | Each stage transition                      | stage\_name, stage\_number, total\_stages, status\_message |
| `matching_completed`           | Pipeline finishes                          | candidate\_id, total\_matches, top\_score                  |
| `login_matching_started`       | Incremental matching begins on login       | user\_id                                                   |
| `incremental_matching_success` | One candidate's incremental match finishes | candidate\_id, new\_matches\_count                         |
| `login_matching_completed`     | All incremental matching finishes          | total\_candidates\_processed                               |

The frontend receives these via the SSEConnectionManager and updates the UI in real time -- progress indicators, match counts, and notifications are all driven by these events.

### Power-User Tips

<Tip>
  **Trigger manual re-matching after weight adjustments.** If you change a candidate's scoring weights (e.g., increasing skills weight for a technical role), the existing match scores were calculated with the old weights. Trigger a manual re-match to recalculate all scores with the new weight configuration.
</Tip>

<Tip>
  **Use the stage-by-stage progress to diagnose issues.** If matching is slow at the "Searching" stage, the candidate may have a very broad profile generating too many vector matches. If it is slow at "Scoring", there are many jobs to evaluate. If it completes quickly but produces few matches, the candidate's preferences may be too restrictive.
</Tip>

<Tip>
  **For candidates with unusual profiles**, consider adjusting weights before running the first match. A career changer from finance to tech with strong Python skills should have their role weight decreased and skills weight increased BEFORE the first matching run. This saves a re-match and produces better initial results.
</Tip>

## Related

* [Understanding Scores](/candidates/matching/understanding-scores) -- Learn how to interpret match scores and dimension breakdowns
* [Candidate Pipeline](/candidates/matching/candidate-pipeline) -- Track matches through your workflow
* [Skills & Expertise](/candidates/skills-and-expertise) -- Improve match quality through accurate skills
* [Location & Preferences](/candidates/location-and-preferences) -- Configure geographic and preference filters
