← 모든 기록RAG 튜토리얼 →

RAG 05. 이력서와 공고 연결하기: 기술명보다 경험 근거를 확인하자

가상 이력서와 공고를 기술별로 연결하고 프로젝트·수강·계획을 구분합니다. 점수나 채용 결정 없이 검토할 근거와 빈칸을 보여 줍니다.

2026.10.1518분 읽기

이력서에 “Kubernetes를 공부할 계획”이라고 적었다고 해서 배포 경험을 증명한 것은 아닙니다. 이번에는 채용 공고의 요구사항과 이력서 근거를 연결하되, 프로젝트 경험·수강 기록·학습 계획을 구분하는 도구를 만듭니다.

가상 채용 공고의 기술 요구사항이 이력서 프로젝트 근거와 연결되고 학습 계획은 별도로 남는 개념도

그림은 개념도이며 실제 지원자 평가나 채용 결과를 나타내지 않습니다.

오늘 만들 결과

가상 공고의 Python·SQL·API 요구사항에는 관련 프로젝트 문장을 연결합니다. Kubernetes는 학습 계획만 있으므로 “프로젝트 근거 미발견”으로 남깁니다. Docker 수강 기록도 프로젝트 경험으로 바꾸지 않습니다.

이 프로그램은 지원자 본인이 자료를 정리하는 자기 점검 도구입니다. 합격 가능성, 적합도 점수, 후보자 순위, 채용 결정을 만들지 않습니다. 문서에서 근거를 못 찾았다는 사실은 그 사람이 능력이 없다는 뜻도 아닙니다.

RAG에서 생성 모델에 전달할 근거를 찾는 단계에 해당합니다. 이번 편은 요구사항별 검색과 근거 연결까지만 구현합니다. LLM 호출, 외부 전송, 실제 이력서 수집은 없습니다.

1. 먼저 문장의 역할을 나누기

이력서의 모든 문장을 같은 검색 후보로 취급하면 “관심”, “수강”, “실제 수행”이 섞입니다. 그래서 각 근거에 kind를 붙입니다.

  • project: 작성자가 프로젝트 수행 사례로 분류한 기록
  • course: 학습·수강 기록
  • plan: 앞으로 할 일이나 관심

이 분류는 실제 경험을 독립적으로 검증한 판정이 아닙니다. 실습용 원문에 사람이 붙인 메타데이터입니다. 자유 형식 이력서를 읽고 이 종류를 자동 추론하는 기능은 구현하지 않습니다.

Python 3.12만 필요합니다. 별도 패키지나 이전 편 파일 없이 빈 폴더에서 진행하세요. profile.json으로 저장합니다.

{
  "label": "가상 지원자의 자기 점검 자료",
  "evidence": [
    {"id": "e1", "section": "프로젝트 A", "kind": "project", "skills": ["파이썬", "SQL"], "text": "가상 도서 관리 실습에서 Python으로 CSV를 읽고 SQL 조회문을 작성했습니다."},
    {"id": "e2", "section": "프로젝트 B", "kind": "project", "skills": ["REST API"], "text": "실습용 일정 API에서 요청 검증과 오류 응답을 구현했습니다."},
    {"id": "e3", "section": "학습 계획", "kind": "plan", "skills": ["K8s"], "text": "Kubernetes 배포를 다음 학습 주제로 정했습니다. 아직 배포 실습 기록은 없습니다."},
    {"id": "e4", "section": "학습 기록", "kind": "course", "skills": ["Docker"], "text": "가상 강좌에서 Docker 이미지와 컨테이너 개념을 학습했습니다."}
  ]
}

다음은 job.json입니다. skills는 사람이 공고를 읽고 정리한 핵심 기술 목록입니다. 목록 안의 기술을 모두 확인해야 하는 요구사항으로 취급합니다.

{
  "label": "가상 공고: 백엔드 실습 프로젝트",
  "requirements": [
    {"id": "r1", "text": "Python과 SQL을 사용한 프로젝트 설명", "skills": ["Python", "SQL"]},
    {"id": "r2", "text": "API 오류 응답을 구현한 사례", "skills": ["API"]},
    {"id": "r3", "text": "Kubernetes 배포 경험 설명", "skills": ["Kubernetes"]},
    {"id": "r4", "text": "Docker를 사용한 프로젝트 설명", "skills": ["Docker"]}
  ]
}

이름, 나이, 성별, 연락처 같은 정보는 근거 연결에 필요 없으므로 넣지 않았습니다. 이 예제의 사람·프로젝트·공고는 모두 가상입니다.

2. 요구사항별로 근거 연결하기

evidence_match.py로 저장합니다.

import argparse
import json
from pathlib import Path

ALIASES = {'파이썬': 'python', 'rest api': 'api', 'k8s': 'kubernetes'}


def canonical(skill):
    key = ' '.join(skill.lower().split())
    return ALIASES.get(key, key)


def load_inputs(profile_path, job_path):
    profile = json.loads(Path(profile_path).read_text(encoding='utf-8'))
    job = json.loads(Path(job_path).read_text(encoding='utf-8'))
    evidence, requirements = profile['evidence'], job['requirements']
    for rows in (evidence, requirements):
        if len({row['id'] for row in rows}) != len(rows):
            raise ValueError('같은 자료 안의 id는 중복될 수 없습니다.')
        if any(not row['skills'] or not row['text'].strip() for row in rows):
            raise ValueError('기술 목록과 원문이 필요합니다.')
    if any(row['kind'] not in {'project', 'course', 'plan'} for row in evidence):
        raise ValueError('경험 종류는 project, course, plan 중 하나여야 합니다.')
    return evidence, requirements


def match_requirements(evidence, requirements):
    rows = []
    for requirement in requirements:
        skills = list(dict.fromkeys(canonical(s) for s in requirement['skills']))
        links = {}
        for skill in skills:
            related = [row for row in evidence if skill in {canonical(s) for s in row['skills']}]
            links[skill] = {
                'experience': [row for row in related if row['kind'] == 'project'],
                'context': [row for row in related if row['kind'] != 'project'],
            }
        missing = [skill for skill, found in links.items() if not found['experience']]
        rows.append({'requirement': requirement, 'links': links, 'missing': missing})
    return rows


def render(rows):
    output = ['자기 점검용 근거 연결: 적합도 점수나 합격 판단을 만들지 않습니다.']
    for row in rows:
        requirement = row['requirement']
        output.append(f"\n[{requirement['id']}] {requirement['text']}")
        for skill, found in row['links'].items():
            if found['experience']:
                for evidence in found['experience']:
                    output.append(f"  {skill}: [{evidence['id']}] {evidence['section']} | {evidence['text']}")
            else:
                output.append(f'  {skill}: 문서에서 프로젝트 근거 미발견')
            for evidence in found['context']:
                output.append(f"  참고({evidence['kind']}): [{evidence['id']}] {evidence['text']}")
        if row['missing']:
            output.append('  확인 질문: ' + ', '.join(row['missing']) + ' 관련 프로젝트 기록이 따로 있나요?')
        else:
            output.append('  확인 질문: 연결된 원문이 요구사항의 범위와 깊이를 실제로 설명하나요?')
    return '\n'.join(output)


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument('--profile', default='profile.json')
    parser.add_argument('--job', default='job.json')
    args = parser.parse_args()
    evidence, requirements = load_inputs(args.profile, args.job)
    print(render(match_requirements(evidence, requirements)))


if __name__ == '__main__':
    main()

canonical은 표기 차이를 줄입니다. 파이썬은 python, REST API는 api, K8s는 kubernetes로 바꿉니다. 미리 작성한 작은 사전이지 언어 모델이나 임베딩이 아닙니다. 사전에 없는 기술은 소문자 형태를 그대로 사용합니다.

match_requirements는 한 요구사항을 통째로 “일치” 처리하지 않고 기술별로 근거를 찾습니다. Python 근거가 있어도 SQL이 빠졌다면 SQL은 missing에 남습니다. 여러 프로젝트가 서로 다른 기술을 설명하는 경우에도 각 근거를 따로 보관할 수 있습니다.

또한 기술 표기가 맞더라도 kind가 plan이나 course이면 context에만 넣습니다. 원문을 버리지는 않되 프로젝트 경험의 자리에 쓰지 않습니다. 이 구분은 증거가 말하는 범위를 유지하려는 장치입니다.

3. 실제 실행 결과 읽기

python evidence_match.py

로컬에서 실제 얻은 전체 출력입니다.

자기 점검용 근거 연결: 적합도 점수나 합격 판단을 만들지 않습니다.

[r1] Python과 SQL을 사용한 프로젝트 설명
  python: [e1] 프로젝트 A | 가상 도서 관리 실습에서 Python으로 CSV를 읽고 SQL 조회문을 작성했습니다.
  sql: [e1] 프로젝트 A | 가상 도서 관리 실습에서 Python으로 CSV를 읽고 SQL 조회문을 작성했습니다.
  확인 질문: 연결된 원문이 요구사항의 범위와 깊이를 실제로 설명하나요?

[r2] API 오류 응답을 구현한 사례
  api: [e2] 프로젝트 B | 실습용 일정 API에서 요청 검증과 오류 응답을 구현했습니다.
  확인 질문: 연결된 원문이 요구사항의 범위와 깊이를 실제로 설명하나요?

[r3] Kubernetes 배포 경험 설명
  kubernetes: 문서에서 프로젝트 근거 미발견
  참고(plan): [e3] Kubernetes 배포를 다음 학습 주제로 정했습니다. 아직 배포 실습 기록은 없습니다.
  확인 질문: kubernetes 관련 프로젝트 기록이 따로 있나요?

[r4] Docker를 사용한 프로젝트 설명
  docker: 문서에서 프로젝트 근거 미발견
  참고(course): [e4] 가상 강좌에서 Docker 이미지와 컨테이너 개념을 학습했습니다.
  확인 질문: docker 관련 프로젝트 기록이 따로 있나요?

첫 요구사항에서 e1이 두 번 나오는 것은 한 원문이 Python과 SQL을 각각 뒷받침하는 후보로 연결되었기 때문입니다. 요구사항 전체가 충족되었다고 판정한 것은 아닙니다. 공고가 요구하는 깊이, 실제 수행 범위, 원문의 사실 여부는 사람이 확인해야 합니다.

두 번째 요구사항은 API가 연결되지만, 코드가 “오류 응답 구현”이라는 세부 능력을 자동 검증한 것도 아닙니다. 현재 검색 조건은 사람이 붙인 기술 태그입니다. 근거를 좁혀 주는 기능과 기술 수준을 판단하는 기능을 혼동하지 않도록 출력에 검토 질문을 남깁니다.

마지막 두 항목의 참고(plan), 참고(course)도 읽어 보세요. 같은 기술명이 있어도 관련 문장의 역할에 따라 다른 칸에 남습니다. 단어 일치만으로 점수를 합산하는 방식보다 검토할 내용을 구체적으로 보여 줄 수 있습니다.

4. 빠진 근거를 숨기지 않는 테스트

test_evidence.py로 저장합니다.

import copy
import unittest
from evidence_match import canonical, load_inputs, match_requirements, render


class EvidenceTests(unittest.TestCase):
    def setUp(self):
        self.evidence, self.requirements = load_inputs('profile.json', 'job.json')

    def test_aliases(self):
        self.assertEqual(canonical(' REST   API '), 'api')
        self.assertEqual(canonical('K8s'), 'kubernetes')

    def test_multiple_required_skills(self):
        row = match_requirements(self.evidence, self.requirements)[0]
        self.assertEqual(row['missing'], [])
        self.assertEqual(row['links']['python']['experience'][0]['id'], 'e1')
        self.assertEqual(row['links']['sql']['experience'][0]['id'], 'e1')

    def test_plan_is_not_project(self):
        row = match_requirements(self.evidence, self.requirements)[2]
        self.assertEqual(row['missing'], ['kubernetes'])
        self.assertEqual(row['links']['kubernetes']['context'][0]['id'], 'e3')

    def test_course_is_not_project(self):
        row = match_requirements(self.evidence, self.requirements)[3]
        self.assertEqual(row['missing'], ['docker'])

    def test_partial_match_keeps_missing_skill(self):
        evidence = copy.deepcopy(self.evidence)
        evidence[0]['skills'] = ['Python']
        row = match_requirements(evidence, self.requirements)[0]
        self.assertEqual(row['missing'], ['sql'])

    def test_unknown_skill_has_no_evidence(self):
        job = [{'id': 'r5', 'text': 'Rust 사례', 'skills': ['Rust']}]
        row = match_requirements(self.evidence, job)[0]
        self.assertEqual(row['missing'], ['rust'])
        self.assertIn('프로젝트 기록이 따로 있나요?', render([row]))


if __name__ == '__main__':
    unittest.main()

python -m unittest -v

Python 3.12.14에서 6개 테스트가 통과했습니다. 표기 정규화, 복수 기술, 계획과 수강 기록의 분리, 일부 기술만 있는 경우, 모르는 기술을 확인했습니다. 개인의 실제 역량이나 채용 성과를 검증한 테스트가 아닙니다.

특히 test_partial_match_keeps_missing_skill이 중요합니다. 한 요구사항에 여러 조건이 있을 때 검색 결과 하나만 보고 전체 조건을 만족했다고 처리하는 오류를 막습니다.

5. 실무 자료로 확장할 때의 경계

이 설계의 약점은 메타데이터 품질에 있습니다. SQL을 쓰지 않은 프로젝트에 SQL 태그를 붙이면 그릇된 근거가 연결됩니다. 원문과 태그를 함께 검토하고, 작성자가 수정할 수 있어야 합니다. 태그만 따로 저장하고 원문을 잃으면 이 오류를 발견하기 더 어렵습니다.

“3년 이상”처럼 기간이 들어간 공고도 이 코드는 계산하지 않습니다. 같은 기간에 수행한 프로젝트를 더해서 경력 연수를 부풀릴 수 있으므로 별도의 데이터 모델과 검토가 필요합니다. 근무 기간·직책·성과 같은 내용은 원문에 없으면 만들지 마세요.

실제 이력서를 다룬다면 파일과 검색 색인 모두에 개인정보가 들어갈 수 있습니다. 본인 자료라도 외부 모델로 보낼 때는 전송 범위와 사용 목적을 확인해야 합니다. 이 글의 로컬 검색은 외부 전송을 하지 않습니다.

직접 바꿔 볼 것

  1. 가상 프로젝트를 하나 더 추가해 Kubernetes 근거를 연결해 보세요. 학습 계획 e3도 참고 자료로 계속 남는지 확인합니다.
  2. Python과 SQL을 서로 다른 프로젝트로 나눠 저장하세요. 하나의 문장으로 합치지 않고 각 id가 유지되는지 테스트합니다.
  3. 요구사항에 “Docker 또는 Podman”을 넣고 any_of라는 별도 조건을 설계해 보세요. 지금의 모든 기술 확인 규칙과 섞이지 않게 해야 합니다.
  4. skills에 태그는 있지만 원문에 관련 설명이 없는 사례를 하나 만드세요. 자동으로 합격시키는 대신, 사람이 태그를 검토할 수 있는 경고를 설계해 보세요.

좋은 근거 연결 도구는 그럴듯한 점수를 만드는 데서 끝나지 않습니다. 어떤 주장에 어떤 문장을 연결했는지, 아직 확인할 것이 무엇인지 드러내야 합니다.

참고 자료

RAG 실전 포트폴리오 05/25

이전: 04. 유튜브 자막 Q&A: 답변 근거에 시간 구간 붙이기

다음: 06. 고객지원 답변 만들기: 정책 안내와 상담원 확인 나누기

01편부터 읽기

RAGPython근거검색이력서
← 다른 기록 읽기