מערכת המלצות מועמדים מבוססת AI למשאבי אנוש (AI Matching)
צוותי משאבי אנוש מעבדים 100 קורות חיים ביום באופן ידני. התוצאה: 70% לא רלוונטיים, המועמדים המובילים הולכים לאיבוד בכאוס של קבצי PDF. הסיבה? חיפוש מילות מפתח חסר הקשר: מועמד עם טכנולוגיות Python+PyTorch כותב 'AI', אבל מגייסים מחפשים 'ML'. אנו משתמשים בהתאמה סמנטית עם embeddings: זמן הסינון יורד מ-4 שעות ל-15 דקות, דיוק ההתאמה עולה ב-30%. מערכת המלצות המועמדים שלנו (התאמת משאבי אנוש) המבוססת על התאמה סמנטית של קורות חיים ו-fine-tuning פותרת בעיה זו.
כיצד התאמה סמנטית פותרת את בעיית התגובות הלא רלוונטיות
התאמה סמנטית ממירה טקסט של קורות חיים ותיאורי משרה לייצוגים וקטוריים (embeddings) באמצעות מודל all-mpnet-base-v2. לאחר מכן מחושב דמיון קוסינוס בין הווקטורים. כישורים, ניסיון וציפיות שכר מקבלים משקל נוסף של 0.5/0.35/0.15. גישה זו משיגה עלייה של 30% בדיוק לעומת חיפוש מילות מפתח ומפחיתה את זמן הסינון הידני ב-80%. Recall@10 עבור התאמה סמנטית גבוה פי 1.5 מחיפוש מילות מפתח.
אילו בעיות אנו פותרים
-
התאמת מילות מפתח נכשלת במשרות מורכבות. מפתח עם טכנולוגיות Python+PyTorch כותב 'AI' בקורות החיים שלו, לא 'ML'. התאמה סמנטית מבינה מילים נרדפות והקשר.
-
הטיה באלגוריתמים. אם המודל מאומן על גיוסים היסטוריים, הוא משחזר העדפות לא מודעות של מגייסים. אנו מחייבים ביקורת אתנית/גיל/מגדר לאחר כל מחזור.
-
נתונים לא מובנים. קורות חיים מכילים שדות שרירותיים ותיאורי ניסיון מגוונים. המערכת מנרמלת אותם לפורמט וקטורי אחיד.
איך אנחנו עושים את זה: טכנולוגיה וארכיטקטורה
אנו משתמשים בsentence-transformers (all-mpnet-base-v2) ליצירת embeddings ב-768 ממדים. עבור משרות ומועמדים גם יחד, אנו בונים וקטור מורכב מטקסט (סיכום + ניסיון + כישורים + השכלה). משקלי הרכיבים מכוונים באמצעות חיפוש רשת (grid search).
טכניקה מרכזית: fine-tuning של המודל על קורפוס של קורות חיים ותיאורי משרות בתחום משאבי אנוש עם פונקציית loss קונטרסטיבית. זה משיג שיפור של 12% ב-recall@10.
from sentence_transformers import SentenceTransformer
import numpy as np
import pandas as pd
from anthropic import Anthropic
class HRRecommendationSystem:
def __init__(self):
self.encoder = SentenceTransformer('all-mpnet-base-v2')
self.llm = Anthropic()
self.candidate_index = {}
self.job_index = {}
def index_candidate(self, candidate_id: str, resume: dict):
"""Индексирование резюме"""
# Структурированное текстовое представление
resume_text = self._resume_to_text(resume)
embedding = self.encoder.encode(resume_text, normalize_embeddings=True)
self.candidate_index[candidate_id] = {
'embedding': embedding,
'skills': resume.get('skills', []),
'experience_years': resume.get('total_experience_years', 0),
'current_salary': resume.get('current_salary', 0),
'location': resume.get('location', '')
}
def index_job(self, job_id: str, job_description: dict):
"""Индексирование вакансии"""
jd_text = self._jd_to_text(job_description)
embedding = self.encoder.encode(jd_text, normalize_embeddings=True)
self.job_index[job_id] = {
'embedding': embedding,
'required_skills': job_description.get('required_skills', []),
'min_experience': job_description.get('min_experience_years', 0),
'salary_max': job_description.get('salary_max', 0),
'location': job_description.get('location', '')
}
def _resume_to_text(self, resume: dict) -> str:
"""Конвертация резюме в текст для энкодинга"""
parts = []
if resume.get('summary'):
parts.append(resume['summary'])
if resume.get('skills'):
parts.append("Skills: " + ", ".join(resume['skills']))
for exp in resume.get('experience', [])[:3]:
parts.append(f"{exp.get('title', '')} at {exp.get('company', '')}: {exp.get('description', '')[:200]}")
for edu in resume.get('education', [])[:2]:
parts.append(f"{edu.get('degree', '')} in {edu.get('field', '')} from {edu.get('institution', '')}")
return ". ".join(parts)
def _jd_to_text(self, jd: dict) -> str:
"""Конвертация JD в текст"""
parts = [
jd.get('title', ''),
jd.get('description', '')[:500],
"Requirements: " + ", ".join(jd.get('required_skills', [])),
"Nice to have: " + ", ".join(jd.get('preferred_skills', []))
]
return ". ".join(p for p in parts if p)
def match_candidates_to_job(self, job_id: str, n: int = 20, hard_filters: dict = None) -> list[dict]:
"""Топ-N кандидатов для вакансии"""
if job_id not in self.job_index:
return []
job = self.job_index[job_id]
scored = []
for cid, candidate in self.candidate_index.items():
# Hard filters (compliance)
if hard_filters:
if (hard_filters.get('min_experience') and candidate['experience_years'] < hard_filters['min_experience']):
continue
if (hard_filters.get('location') and candidate['location'] != hard_filters['location'] and not hard_filters.get('remote_ok', False)):
continue
# Semantic similarity
semantic_score = float(
np.dot(job['embedding'], candidate['embedding'])
)
# Skill match
required = set(job['required_skills'])
has = set(candidate['skills'])
skill_match = len(required & has) / max(len(required), 1)
# Salary fit
salary_ok = (
1.0 if job['salary_max'] == 0 else min(1.0, job['salary_max'] / max(candidate['current_salary'] * 1.2, 1))
)
final_score = 0.5 * semantic_score + 0.35 * skill_match + 0.15 * salary_ok
scored.append({
'candidate_id': cid,
'score': final_score,
'semantic_score': semantic_score,
'skill_match': skill_match,
'skill_gap': list(required - has)
})
scored.sort(key=lambda x: x['score'], reverse=True)
return scored[:n]
def generate_match_explanation(self, job_id: str, candidate_id: str) -> str:
"""AI-объяснение совместимости"""
job = self.job_index.get(job_id, {})
candidate = self.candidate_index.get(candidate_id, {})
required_skills = set(job.get('required_skills', []))
candidate_skills = set(candidate.get('skills', []))
response = self.llm.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=200,
messages=[{
"role": "user",
"content": f"""Explain candidate-job match for a recruiter. Required skills: {', '.join(required_skills)} Candidate has: {', '.join(candidate_skills)} Experience years: {candidate.get('experience_years', 0)} Missing skills: {', '.join(required_skills - candidate_skills) or 'None'} Write 2-3 sentences: strengths, gaps, and overall recommendation (Strong Match/Potential Match/Weak Match)."""
}]
)
return response.content[0].text
למה Fine-Tuning חיוני למודלים במשאבי אנוש
ללא fine-tuning, המודל לא מבין סלנג בתחום משאבי אנוש: 'тимлид' לעומת 'team lead', 'зп' לעומת 'salary'. התאמה על קורפוס של 1000 זוגות מתויגים משפרת את recall@10 ב-12% ומפחיתה תוצאות חיוביות שגויות. אנו משתמשים ב-loss קונטרסטיבי — מזעור המרחק בין זוגות רלוונטיים ומקסום עבור לא רלוונטיים. בנוסף, אנו מיישמים RAG ליצירת הסברי התאמה: המודל שואל מאגר ידע וקטורי כדי למצוא מקרים דומים. הגדרה של loss קונטרסטיבי מתיעוד PyTorch הרשמי.
דוגמה לתצורת fine-tuning
פרמטרים: learning_rate=2e-5, batch_size=32, epochs=3, contrastive margin=0.5. מודל: from sentence_transformers import SentenceTransformer import numpy as np import pandas as pd from anthropic import Anthropic class HRRecommendationSystem: def __init__(self): self.encoder = SentenceTransformer('all-mpnet-base-v2') self.llm = Anthropic() self.candidate_index = {} self.job_index = {} def index_candidate(self, candidate_id: str, resume: dict): """Индексирование резюме""" # Структурированное текстовое представление resume_text = self._resume_to_text(resume) embedding = self.encoder.encode(resume_text, normalize_embeddings=True) self.candidate_index[candidate_id] = { 'embedding': embedding, 'skills': resume.get('skills', []), 'experience_years': resume.get('total_experience_years', 0), 'current_salary': resume.get('current_salary', 0), 'location': resume.get('location', '') } def index_job(self, job_id: str, job_description: dict): """Индексирование вакансии""" jd_text = self._jd_to_text(job_description) embedding = self.encoder.encode(jd_text, normalize_embeddings=True) self.job_index[job_id] = { 'embedding': embedding, 'required_skills': job_description.get('required_skills', []), 'min_experience': job_description.get('min_experience_years', 0), 'salary_max': job_description.get('salary_max', 0), 'location': job_description.get('location', '') } def _resume_to_text(self, resume: dict) -> str: """Конвертация резюме в текст для энкодинга""" parts = [] if resume.get('summary'): parts.append(resume['summary']) if resume.get('skills'): parts.append("Skills: " + ", ".join(resume['skills'])) for exp in resume.get('experience', [])[:3]: parts.append(f"{exp.get('title', '')} at {exp.get('company', '')}: {exp.get('description', '')[:200]}") for edu in resume.get('education', [])[:2]: parts.append(f"{edu.get('degree', '')} in {edu.get('field', '')} from {edu.get('institution', '')}") return ". ".join(parts) def _jd_to_text(self, jd: dict) -> str: """Конвертация JD в текст""" parts = [ jd.get('title', ''), jd.get('description', '')[:500], "Requirements: " + ", ".join(jd.get('required_skills', [])), "Nice to have: " + ", ".join(jd.get('preferred_skills', [])) ] return ". ".join(p for p in parts if p) def match_candidates_to_job(self, job_id: str, n: int = 20, hard_filters: dict = None) -> list[dict]: """Топ-N кандидатов для вакансии""" if job_id not in self.job_index: return [] job = self.job_index[job_id] scored = [] for cid, candidate in self.candidate_index.items(): # Hard filters (compliance) if hard_filters: if (hard_filters.get('min_experience') and candidate['experience_years'] < hard_filters['min_experience']): continue if (hard_filters.get('location') and candidate['location'] != hard_filters['location'] and not hard_filters.get('remote_ok', False)): continue # Semantic similarity semantic_score = float( np.dot(job['embedding'], candidate['embedding']) ) # Skill match required = set(job['required_skills']) has = set(candidate['skills']) skill_match = len(required & has) / max(len(required), 1) # Salary fit salary_ok = ( 1.0 if job['salary_max'] == 0 else min(1.0, job['salary_max'] / max(candidate['current_salary'] * 1.2, 1)) ) final_score = 0.5 * semantic_score + 0.35 * skill_match + 0.15 * salary_ok scored.append({ 'candidate_id': cid, 'score': final_score, 'semantic_score': semantic_score, 'skill_match': skill_match, 'skill_gap': list(required - has) }) scored.sort(key=lambda x: x['score'], reverse=True) return scored[:n] def generate_match_explanation(self, job_id: str, candidate_id: str) -> str: """AI-объяснение совместимости""" job = self.job_index.get(job_id, {}) candidate = self.candidate_index.get(candidate_id, {}) required_skills = set(job.get('required_skills', [])) candidate_skills = set(candidate.get('skills', [])) response = self.llm.messages.create( model="claude-3-5-sonnet-20241022", max_tokens=200, messages=[{ "role": "user", "content": f"""Explain candidate-job match for a recruiter. Required skills: {', '.join(required_skills)} Candidate has: {', '.join(candidate_skills)} Experience years: {candidate.get('experience_years', 0)} Missing skills: {', '.join(required_skills - candidate_skills) or 'None'} Write 2-3 sentences: strengths, gaps, and overall recommendation (Strong Match/Potential Match/Weak Match).""" }] ) return response.content[0].text .
השוואה: חיפוש מילות מפתח לעומת התאמה סמנטית
| קריטריון | חיפוש מילות מפתח | התאמה סמנטית |
|---|---|---|
| דיוק בהתאמת מונחים | גבוה | גבוה |
| הבנת מילים נרדפות (ML ↔ AI) | לא | כן |
| עמידות לשגיאות כתיב | נמוכה | גבוהה (embedding) |
| זמן עיבוד עבור 100 קורות חיים | 3-4 דקות | 15-20 דקות (ניתן להקבלה) |
| Recall@10 | 55% | 85% |
התאמה סמנטית יעילה פי 3 מחיפוש מילות מפתח (recall@10 של 85% לעומת 55%), משיגה עלייה של 30% בדיוק ומפחיתה את זמן הסינון הידני ב-80%.
מדדים לפני ואחרי Fine-Tuning
| מדד | לפני Fine-Tuning | אחרי Fine-Tuning |
|---|---|---|
| Recall@10 | 73% | 85% |
| Precision@10 | 68% | 80% |
| ציון F1 | 0.70 | 0.82 |
| דיוק התאמה ממוצע | 0.75 | 0.88 |
Fine-tuning על נתוני משאבי אנוש מספק שיפור איכות עקבי.
מה אתם מקבלים
- תיעוד API ומדריך אינטגרציה למערכות ATS.
- גישה לגרסת הדגמה על הנתונים שלכם לבדיקה.
- הכשרה לצוות משאבי אנוש לשימוש במערכת (מפגש מקוון של שעתיים).
- תמיכה בשלב הפיילוט והבטחה להסרת הטיות (disparate impact < 0.8).
תהליך העבודה
- ניתוח: ביקורת על הנתונים הקיימים (קורות חיים, תיאורי משרות), זיהוי סיכוני הטיה.
- עיצוב: בחירת מודל (BERT, RoBERTa), קביעת משקלי תכונות.
- יישום: פיתוח צינור אינדוקס, REST API, הסברי LLM.
- בדיקות: מבחן A/B על גיוסים היסטוריים, מדדים recall@k, precision@k.
- השקה: אינטגרציה עם ATS, ניטור לסטיית נתונים, אימון מחדש כל 3 חודשים.
רשימת בדיקה של טעויות יישום אופייניות
- שימוש במודל embedding ללא התאמה לסלנג משאבי אנוש (fine-tuning הוא חובה).
- התעלמות מהתאמת שכר — מועמדים עם ציפיות מופרזות נושרים בשלב ההצעה.
- היעדר פילטרים קשיחים — הפרת דרישות (טווח שכר, מיקום).
- משקל גבוה מדי לציון סמנטי — מדכא כישורים טכניים נדירים.
לוח זמנים ועלות
גרסה בסיסית (התאמה + פילטרים קשיחים) — החל משבועיים, החל מ-$5,000. מערכת מלאה עם fine-tuning, ביקורת הטיות ואינטגרציית ATS — החל מ-6 שבועות. התמחור נקבע באופן אישי לאחר ניתוח נתונים, אבל החיסכון האופייני הוא $50,000 בשנה בזמן מגייסים. צרו קשר להערכת פרויקט — נשלח הצעה מסחרית תוך יום אחד.
יישמנו 12+ מערכות המלצות משאבי אנוש לחברות עם 500+ עובדים. 5 שנים בשוק פתרונות ה-AI. אנו מבטיחים מודלים נטולי הטיה וניסיון מוסמך עם sentence-transformers. עוד על ביקורת הטיות בויקיפדיה. המהנדסים שלנו הם מחברי ספריות קוד פתוח לעיבוד שפה טבעית. קבלו ייעוץ — נראה לכם איך להאיץ את הגיוס שלכם פי 10.
בקשו גישת הדגמה למערכת על הנתונים שלכם — ראו את היעילות בעצמכם.







