DEV Community

AJAYA SHRESTHA
AJAYA SHRESTHA

Posted on

Beyond Cosine Similarity: Multi-Faceted Scoring for Smarter Recommendations

Cosine similarity is a widely adopted metric for measuring the similarity between two vectors in high-dimensional space. It's fast, easy to implement, and useful in many applications such as recommendation systems, document clustering, and semantic search.

But in real-world systems, especially in decision-making domains like recruitment or personalized recommendations, relying solely on cosine similarity can produce misleading results. To build smarter, more effective matching systems, we need to go beyond simple vector similarity and design multi-faceted scoring models that combine semantic understanding with concrete, contextual hiring signals.

This article walks through how to enhance cosine similarity with a multi-faceted scoring framework, using a job-candidate matching system as a running example.

Why Cosine Similarity Alone Falls Short

Imagine you’re building a job-matching platform. You compute the similarity between a job description and a candidate profile based on skill embeddings. But here’s the problem:

Scenario

Job Posting: Senior Python Developer

  • Skills: Python, Django, PostgreSQL, Docker, AWS
  • Experience Level: Senior (5+ years)

Candidate A:

  • Skills: Python, Django, PostgreSQL, Docker, AWS, React
  • Experience: 2 years
  • Cosine Similarity: 0.95

Candidate B:

  • Skills: Python, Django, PostgreSQL, Redis, Kubernetes
  • Experience: 6 years
  • Cosine Similarity: 0.87

Despite the lower similarity, Candidate B is a better match. This reveals the limitations of cosine similarity, it captures surface-level similarity in skill vectors but overlooks contextual relevance, such as experience or seniority. That’s where multi-faceted scoring comes in.

Multi-Faceted Scoring: A Composite Architecture

To account for domain-specific constraints and improve relevance, we combine multiple scoring dimensions:

final_score = (
    0.7 * cosine_similarity +
    0.2 * skill_overlap_score +
    0.1 * level_match_score
)
Enter fullscreen mode Exit fullscreen mode

1. Cosine Similarity (70%)

Captures semantic relationships between Candidate Profile and Job requirements:

def cosine_similarity(job_vector, candidate_vector):
    # Measures semantic similarity between embeddings
    return np.dot(job_vector, candidate_vector) / (
        np.linalg.norm(job_vector) * np.linalg.norm(candidate_vector) + 1e-10
    )
Enter fullscreen mode Exit fullscreen mode

2. Skill Overlap Score (20%)

Captures exact matches in skill sets:

def compute_skill_overlap(job_skills, candidate_skills):
    # Percentage of required skills candidate possesses
    job_set = set(map(str.lower, job_skills))
    candidate_set = set(map(str.lower, candidate_skills))
    return len(job_set & candidate_set) / len(job_set) if job_set else 0.0
Enter fullscreen mode Exit fullscreen mode

3. Level Match Score (10%)

Ensures experience level compatibility:

def compute_level_score(job_level, candidate_level):
    # Asymmetric scoring favors slight over-qualification
    levels = ['Entry Level', 'Mid Level', 'Senior Level', 'Top Level']

    job_idx = levels.index(job_level)
    candidate_idx = levels.index(candidate_level)

    diff = candidate_idx - job_idx

    # Asymmetric penalty: slight over-qualification is better than under-qualification
    # Over-qualified or exact match
    if diff >= 0:
        # Gentle penalty
        return max(0, 1 - (diff * 0.2))  
    else:  # Under-qualified
        # Steeper penalty
        return max(0, 1 - (abs(diff) * 0.3))

# Exact match - 1.0
# Slightly over-qualified - gently penalized at 0.2 per step (e.g., 0.8, 0.6, 0.4)
# Slightly under-qualified - more harshly penalized at 0.3 per step (e.g., 0.7, 0.4, 0.1)
Enter fullscreen mode Exit fullscreen mode

Full Implementation: Job Matcher Class

class JobMatcher:
    def __init__(self, weights=None):
        self.weights = weights or {'cosine': 0.7, 'skills': 0.2, 'experience': 0.1}

    def calculate_final_score(self, job_vector, candidate_vector, job_skills, candidate_skills, job_level, candidate_level):

        cosine_sim = self.cosine_similarity(job_vector, candidate_vector)
        skill_score = self.compute_skill_overlap(job_skills, candidate_skills)
        level_score = self.compute_level_score(job_level, candidate_level)

        final_score = (
            self.weights['cosine'] * cosine_sim +
            self.weights['skills'] * skill_score +
            self.weights['level'] * level_score
        )

        return {
            'final_score': final_score,
            'cosine_similarity': cosine_sim,
            'skill_score': skill_score,
            'level_score': level_score
        }
Enter fullscreen mode Exit fullscreen mode

Advanced Strategies for Customization

1. Dynamic Weighting by Job Role

Not all roles should be scored equally. Entry-level jobs may rely more on skills, while leadership roles weigh experience more heavily:

def get_scoring_strategy(job_type):
    strategies = {
        'entry_level': {'cosine': 0.8, 'skills': 0.15, 'level': 0.05},
        'mid_level': {'cosine': 0.7, 'skills': 0.2, 'level': 0.1},
        'senior_level': {'cosine': 0.65, 'skills': 0.2, 'level': 0.15},
        'top_level': {'cosine': 0.7, 'skills': 0.2, 'level': 0.2}
    }
    return strategies.get(job_type, {'cosine': 0.7, 'skills': 0.2, 'level': 0.1})
Enter fullscreen mode Exit fullscreen mode

2. Contextual Scoring: Beyond Skills

Add context-aware dimensions like location, salary expectation, or availability:

def enhanced_scoring(job_data, candidate_data):
    base = calculate_basic_scores(job_data, candidate_data)

    location_score = calculate_location_match(job_data['location'], candidate_data['location'])
    salary_score = calculate_salary_match(job_data['salary_range'], candidate_data['expected_salary'])
    availability_score = calculate_availability(job_data['start_date'], candidate_data['availability'])

    return {
        **base,
        'location_score': location_score,
        'salary_score': salary_score,
        'availability_score': availability_score,
        'final_score': aggregate_weighted_sum(base, location_score, salary_score, availability_score)
    }
Enter fullscreen mode Exit fullscreen mode

Benefits of a Multi-Faceted Approach

Better Real-World Relevance: Incorporates context like experience and domain fit.
Improved Interpretability: Each sub-score can be independently analyzed.
Customizability: Weights and dimensions are flexible and data-driven.
Reduction in False Positives: Multiple dimensions reduce reliance on a single vector match.

Cosine similarity is a solid starting point, but it’s just that: a start. Real-world decision systems demand a deeper, context-rich evaluation framework. A multi-faceted scoring approach enables your system to reflect real business logic and user intent, unlocking more relevant, equitable, and effective recommendations.

Whether you're building a job matching platform, recommendation engine, or personalized search algorithm, integrating multiple signals, semantic, categorical, and contextual, will give your system the precision and flexibility needed to succeed in production environments.

Start with cosine. Scale with context. Optimize with data.

Top comments (0)