
단 하나의 개인 정보도 노출하지 않고 GPT-5.5가 이력서를 교정하게 하는 방법
요약
이력서 교정 시 개인정보 노출을 방지하기 위해 로컬에서 데이터를 익명화하는 piighost-proofreader 기술을 소개합니다. LLM이 처리한 내용을 다시 원래 PDF의 위치에 정확히 매핑하는 기술적 도전 과제와 해결 방법을 다룹니다.
핵심 포인트
- 정규 표현식의 한계를 극복하기 위한 훈련된 엔티티 탐지기 사용
- LLM 호출 전 로컬 익명화 및 플레이스홀더 매핑 기술
- 토큰화 방식 차이로 인한 PDF 내 텍스트 재배치 문제 해결
- 사용자 경험을 위한 구조화된 데이터 스트리밍의 중요성
TLDR
중요한 제출을 앞두고 이력서(CV)를 검토하기 위해 LLM (Large Language Model)에 맡길 수 있습니다. 몇 초 만에 오류 목록을 받을 수 있죠. 하지만 그 과정에서 당신의 이름, 주소, 고용주, 날짜 등을 제3자 서비스에 제공하게 됩니다.
piighost-proofreader가 이 문제를 해결합니다. 이력서는 LLM 호출 전에 로컬에서 익명화(Anonymization)되며, 교정된 내용은 원래의 PDF 파일 내 제자리를 찾아갑니다.
LLM은 이름, 날짜, 주소를 절대 볼 수 없습니다.
익명화는 쉬운 부분입니다. 까다로운 부분은 LLM이 Markdown 형식으로만 본 단어를 PDF 내에서 다시 찾아내는 것입니다. 그리고 LLM과 PyMuPDF는 토큰화 (Tokenization) 방식이 서로 다릅니다.
1. 왜 단순히 정규 표현식 (Regex)을 사용하지 않나요?
첫 번째 아이디어: 이력서를 LLM에 보내기 전에 민감한 데이터를 커다란 정규 표현식 (Regex)으로 대체하는 것입니다. 형식이 명확한 이메일이나 전화번호에는 효과가 있습니다. 하지만 그 외의 것들은 불가능합니다.
- 이름은 구별되는 문법적 형태가 없습니다.
Paul Martin은 대문자로 시작하는 그 어떤 두 단어와도 비슷해 보입니다. 텍스트 내의 어떤 것도 정규 표현식에게 이것이 이름이라는 것을 알려주지 않습니다. Orange는 기업 이름인 동시에 과일 이름이기도 합니다.Mars,Apple,Carrefour도 마찬가지입니다.- 이력서의 날짜는 생년월일일 수도, 학위 취득일일 수도, 이직일 수도 있습니다. 형식은 모두 동일합니다.
패턴이 아니라 훈련된 탐지기가 필요합니다. piighost가 이를 제공하며, 호출 방식은 다음과 같습니다:
# src/proofreader/anonymize.py
async def anonymize(self, text: str, *, thread_id: str) -> str:
return await self._call(
...
thread_id는 이력서당 하나의 UUID입니다. 엔티티(Entity) → 플레이스홀더(Placeholder) 매핑은 서버 측에 유지되며 이 ID에 의해 격리됩니다. 즉, 동일한 이름은 매번 나타날 때마다 동일한 플레이스홀더가 됩니다.
2. instructor를 사용하여 오류 스트리밍하기
두 페이지 분량의 이력서에는 약 15개의 오류가 포함되어 있으며, LLM이 이를 찾아내는 데는 몇 초가 걸립니다. 스트리밍 (Streaming)이 없다면 사용자는 그 시간 내내 로딩 화면을 바라보며 기다려야 합니다. 스트리밍을 사용하면 모델이 오류를 생성함에 따라 오류들이 하나씩 나타납니다.
함정: 대부분의 구조화된 출력 (Structured Output) 라이브러리 (LangChain의 with_structured_output, OpenAI Functions, Pydantic AI 등)는 결과물을 전체 단위로 반환합니다. list[Mistake]를 요청하면, 추론 (Inference)이 완료된 후에야 전체 리스트를 받게 됩니다. 객체별로 세분화된 처리가 불가능합니다.
instructor는 정확히 이 문제를 해결합니다. 이 라이브러리의 create_iterable 메서드는 LLM이 실시간으로 스트리밍하는 JSON을 파싱하여, 각 Pydantic 객체가 완성되는 즉시 반환합니다.
# src/proofreader/llm.py
client = instructor.from_litellm(litellm.acompletion)
response = client.chat.completions.create_iterable(
...
겉으로 잘 드러나지 않는 두 가지 복잡한 사항이 있습니다:
-
모드에 따라 프롬프트 (Prompt)가 변경됩니다. LangChain의
with_structured_output를 사용할 때는 LLM에게 Mistakes 리스트를 포함한 래퍼 (Wrapper) 객체를 반환하도록 요청합니다. 반면create_iterable을 사용할 때는 생성 회차마다 단 하나의 Mistake JSON을 생성하도록 요청합니다. 두 프롬프트는 완전히 동일하지 않습니다. 이 프로젝트는 두 방식을 병행하여 유지합니다. Streamlit을 이용한 원샷 (One-shot) 경로에는 LangChain을, FastAPI를 이용한 스트리밍 (Streaming) 경로에는instructor를 사용합니다. -
하위 단계의 SSE (Server-Sent Events) 스트리밍. 생성된 각
Mistake는 즉시 FastAPI 측에서 SSE 이벤트로 재포장되어 프론트엔드로 전송됩니다. 다음 섹션의 위치 표시기 (Locator)는 Mistake별로 작동하므로, 사용자는 빨간색 사각형이 마지막에 한꺼번에 나타나는 것이 아니라 하나씩 차례대로 나타나는 것을 보게 됩니다.
3. PDF 반환: 네 가지 폴백 (Fallback) 전략
instructor가 반환하는 각 Mistake에는 error_text, correction, context_before, 그리고 description이 포함되어 있습니다. 하지만 LLM은 PDF의 픽셀을 단 하나도 본 적이 없습니다. LLM은 추출된 마크다운 (Markdown)을 기반으로 작업했을 뿐입니다. 어떤 필드에도 좌표 정보는 포함되어 있지 않습니다.
또 다른 경우는 사용자가 결과 페이지의 평문(plain text)이 아니라, 원본 PDF 상에서 수정 사항을 확인하고 싶어 하는 경우입니다. 따라서 각 오류에 대해 PDF 내에서 해당 단어를 찾아내야 합니다.
PDF 측면에서는 PyMuPDF를 사용하여 word stream (페이지 내 모든 단어와 그 bbox (포인트 단위의 사각형) 목록)을 가져옵니다. 여기서 문제는 이 목록 내에서 [단어1, 단어2, …] 형태의 윈도우(window)를 찾는 것입니다. 하지만 LLM과 PyMuPDF의 토큰화(tokenization) 방식이 서로 다르고, 타이포그래피용 아포스트로피(apostrophe)가 일치하지 않으며, 2단 구성(two-column)의 이력서에서는 LLM이 context_before를 가끔 환각(hallucination)하기도 합니다.
이에 따라 순차적으로 시도한 네 가지 전략이 있습니다. 각 전략은 이전 전략이 처리하지 못하는 사례를 보완합니다.
# src/proofreader/locator.py
def locate_mistake(mistake: Mistake, *, words: list[Word]) -> LocatedMistake | None:
err_tokens = mistake.error_text.split()
...
이 정확한 순서를 선택한 이유는 다음과 같습니다:
-
Strict (엄격함). 정규화(normalization) 없이
context_before + error_text윈도우가 해당 단어와 거의 일치하는 방식입니다. 가장 이상적인 경우로, LLM이 PDF 내용을 완벽하게 인용하여 정확히 일치하고 모호함이 전혀 없는 상태입니다. -
Tolérant (관용적). LLM이 문장의 첫 단어를 대문자로 표기하거나,
'를’(타이포그래피용 아포스트로피)로 바꾸는 경우를 다룹니다._normalize함수는 전체를 casefold 하고, 따옴표와 타이포그래피용 아포스트로피를 ASCII 버전으로 교체하며, PyMuPDF가 토큰에 붙여버리는 문장 부호를 제거합니다. -
Error-only unique (오류 단독 고유성). 2단 구성의 이력서에서는 LLM이 생성한
context_before가 때때로 잘못된 열에서 가져와지는 경우가 있습니다 (모델들이 다단 구조를 서투르게 선형화하기 때문입니다). 만약error_text가 페이지 내에 단 한 번만 나타난다면, 문맥에 상관없이 해당 단어를 선택합니다. 이는 거의 모든 경우에 충분합니다. -
Substring du stream concaténé (연결된 스트림의 부분 문자열). 까다로운 경우로, LLM에게
d'une은 하나의 단어이지만, PyMuPDF는 이를d'+une으로 토큰화합니다. 이 경우 LLM은 PyMuPDF의 대응하는 토큰 없이error_text="une"를 고립된 단어로 반환할 수 있습니다.
해결책: 페이지의 모든 토큰을 하나의 문자열로 연결(concatenate)한 다음 부분 문자열(substring)을 검색합니다. 이때 _MIN_SUBSTRING_CHARS = 5를 기준으로 필터링을 수행하는데, 그렇지 않으면 error_text="une"가 commune, lacune, tribune 같은 단어 안에서 발견될 수 있기 때문입니다. 잘못된 양성 (False Positives)의 습격이 시작되는 것이죠.
만약 네 가지 방식 중 어느 것도 아무것도 잡아내지 못한다면, 해당 오류는 조용히 사라지는 대신 결과의 "위치 미확인 (Non localisées)" 섹션으로 넘어갑니다. 사용자가 읽을 수는 있지만 빨간색 사각형(하이라이트)이 표시되지 않는 가시적인 오류는, 오류가 다른 곳에 있다고 주장하는 오류보다 덜 심각합니다.
결론
만약 여러분이 이와 유사한 것을 직접 구현한다면, 다음 세 가지를 기억해야 합니다:
- 정규 표현식 (Regex)은 이름, 기업명 또는 날짜를 감지하지 못합니다. 훈련된 탐지기가 필요합니다.
- 구조화된 출력 (Structured Output, 즉 마지막에 전체 리스트를 받는 것이 아니라 실시간으로 Pydantic 객체를 받는 방식)을 스트리밍하고 싶다면, 일반적인 라이브러리만으로는 부족합니다.
instructor가 바로 이를 위해 설계되었습니다. - LLM이 문서(PDF, OCR, 스캔본)에서 추출된 텍스트를 처리할 경우, 좌표 정보가 없는 오류를 반환합니다. 여러분은 사후에 이를 다시 위치화 (Relocalize)해야 하며, 이것이 항상 가능하지는 않다는 점을 받아들여야 합니다.
piighost는 첫 번째 문제를 해결합니다. instructor는 두 번째 문제를 해결합니다. 세 번째 문제는 제가 이 프로젝트를 작성하게 만든 계기가 되었으며, 코드는 공개되어 있습니다.
- 애플리케이션: https://piighost-proofreader.athroniaeth.cloud/
- piighost: github.com/Athroniaeth/piighost, 여기서 사용된 익명화 (Anonymization) 라이브러리입니다.
- piighost-proofreader: github.com/Athroniaeth/piighost-proofreader, 전체 프로젝트이며 온라인 데모와 로케이터 (Locator)가 포함되어 있습니다.
이슈(Issues)와 풀 리퀘스트(PR)는 언제나 환영합니다. 만약 여러분이 LLM으로 개인적인 텍스트를 다루고 있다면, 위의 세 가지 포인트가 아마 공감이 될 것입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기