대규모 프로덕션 RAG: 매일 10,000개 이상의 리스팅을 처리하며 얻은 교훈
요약
대규모 채용 리스팅을 처리하는 프로덕션 RAG 파이프라인 구축 경험을 바탕으로, 실무에서 직면하는 문제와 해결책을 공유합니다. 특히 문서의 구조를 보존하기 위한 최적의 청킹(chunking) 전략과 전처리 과정의 중요성을 강조합니다.
핵심 포인트
- 고정 크기 청킹은 문서의 의미론적 경계를 파괴하여 검색 노이즈를 유발함
- 재귀적 문자 분할과 중첩(overlap)을 활용해 문맥 손실을 최소화해야 함
- 데이터 소스(ATS 등)의 형식을 통일하는 정규화 전처리가 필수적임
- 청킹 전략은 일반적인 토큰 예산이 아닌 문서 유형에 맞춰 설계해야 함
저는 스테이징 환경에서는 정상적으로 보였지만 실제 부하 상황에서는 다르게 동작하는 RAG 파이프라인을 디버깅하는 데 시간을 보냈습니다. 제가 구축한 채용 게시판은 매일 수천 개의 리스팅을 처리하며, LLM 함수 호출 (function calls)을 사용하여 각 리스팅을 후보자 프로필과 비교하여 점수를 매깁니다. 해당 파이프라인을 안정적이고 비용 효율적으로 만드는 데는 단순히 좋은 벡터 스토어 (vector store)를 선택하는 것 이상의 노력이 필요했습니다. 시스템이 실제로 어디에서 무너지는지를 이해하는 과정이 필요했습니다.
RAG가 데모 단계를 넘어 프로덕션 서비스가 되기 시작할 때, 청킹 (chunking), 임베딩 (embeddings), 벡터 스토어 (vector stores), 비용 제어, 그리고 관찰 가능성 (observability)에 대해 제가 배운 점들을 공유합니다.
청킹 전략: 모든 것을 결정하는 결정적 요소
대부분의 튜토리얼은 청킹을 나중에 조정하는 매개변수로 취급합니다. 하지만 프로덕션 환경에서는 청킹 전략이 첫날부터 임베딩 품질, 검색 정확도, 그리고 비용 구조를 결정합니다.
채용 리스팅의 경우, 저는 작동하는 방식을 찾기 전까지 세 가지 접근 방식을 시도했습니다:
고정 크기 청크 (512 tokens). 간단하지만 이 사용 사례에는 최악이었습니다. 채용 공고는 책임(responsibilities), 자격 요건(qualifications), 복지(benefits)와 같은 섹션으로 구성된 구조화된 문서입니다. 고정된 크기의 청크는 이러한 섹션들을 임의로 분할합니다. 이로 인해 "Python 5년 경력 필수"와 "React 경험 우대" 사이의 의미론적 경계(semantic boundary)를 잃게 됩니다. 검색 결과에 노이즈가 발생합니다.
섹션 헤더를 활용한 의미론적 청킹 (Semantic chunking). 더 나았습니다. 저는 마크다운 헤더와 문단 구분(paragraph breaks)을 기준으로 분할했습니다. 각 청크는 완전한 생각을 보존했습니다. 하지만 일부 섹션은 너무 길었고(자격 요건은 800 tokens까지 이어질 수 있음), 다른 섹션은 너무 짧았습니다(복지는 두 줄 정도일 수 있음). 불균형한 청크 크기는 작은 섹션에 대한 불필요한 임베딩 호출을 초래하고, 큰 섹션에서는 문맥(context)을 놓치게 만들었습니다.
중첩(overlap)을 활용한 재귀적 문자 분할 (Recursive character splitting). 이것이 최종적으로 채택된 방식입니다. 먼저 이중 줄바꿈(double newlines)을 기준으로 분할한 뒤, 단일 줄바꿈(single newlines), 그리고 문장 단위로 분할했습니다. 각 청크(chunk)는 약 400개의 토큰(token)을 목표로 하며 약간의 중첩을 포함합니다. 이 중첩은 매우 중요한데, 문장이 두 개의 청크에 걸쳐 있는 경계 사례(boundary cases)를 포착해주기 때문입니다. 특히 채용 공고(job listings)의 경우, 청킹(chunking)을 수행하기 전에 원본 ATS 출력을 일관된 구조로 정규화(normalize)하는 전처리 단계를 추가했습니다. Greenhouse와 Lever는 서로 다른 형식을 반환합니다. 먼저 정규화를 수행하면 청커(chunker)가 깨끗한 입력을 받게 됩니다.
async function chunkJobListing(text: string): Promise<string[]> {
const normalized = normalizeListing(text) // HTML 제거, 줄바꿈 통일
const splitter = new RecursiveCharacterTextSplitter({
...
핵심 교훈은 다음과 같습니다: 청킹을 설계할 때 일반적인 토큰 예산(token budget)에 맞추지 말고, 문서 유형에 맞춰 설계하십시오. 채용 공고는 자연스러운 구조를 가지고 있습니다. 법률 문서는 다른 구조를 가집니다. 제품 설명은 또 다른 구조를 가집니다. 콘텐츠에 맞춰 청커를 조정하십시오.
임베딩 모델 (Embedding Model): OpenAI vs 로컬 (Local)
처음에는 text-embedding-3-small로 시작했습니다. 잘 작동하고, 신뢰할 수 있으며, API 호출도 매우 간단합니다. 하루에 수천 개의 리스팅이 처리되고 각 리스팅이 여러 개의 청크를 생성함에 따라, 매일 수만 개의 임베딩(embeddings)을 생성하게 되었습니다. 비용은 관리 가능한 수준이었지만 무시할 정도는 아니었습니다.
저는 자체 호스팅(self-hosted) 대안을 테스트했습니다. 애플리케이션이 실행 중인 동일한 AWS EC2 인스턴스에서 Ollama를 통해 Llama 3.1을 실행했습니다. 이 사용 사례에서는 임베딩 품질이 눈에 띄게 떨어졌습니다. 채용 공고에는 "ATS-compliant resume(ATS 준수 이력서)", "Greenhouse integration(Greenhouse 연동)", "equity compensation(주식 보상)"과 같은 도메인 특화 언어(domain-specific language)가 포함되어 있습니다. 로컬 모델은 이러한 차이를 흐릿하게 만들었습니다. 매우 다른 기술 스택을 요구하는 두 개의 리스팅이 결국 유사한 임베딩을 갖게 되었습니다.
트레이드오프(tradeoff)는 명확했습니다. OpenAI의 임베딩은 비용이 들지만 정확한 매칭 결과를 반환했습니다. 자체 호스팅 모델은 무료였지만, 노이즈가 있는 검색(noisy retrieval) 결과를 생성하여 이를 수정하기 위해 더 많은 LLM 호출을 필요로 했습니다. 저는 OpenAI를 유지하기로 했습니다.
도움이 되었던 한 가지 최적화 방법은 임베딩 (Embedding) 요청을 배치 (Batch) 처리한 것입니다. 청크 (Chunk)당 한 번씩 API를 호출하는 대신, 한 번의 호출에 최대 100개의 청크 배열을 보냅니다. OpenAI는 호출 횟수가 아닌 토큰 (Token) 단위로 비용을 청구하므로, 배치를 사용한다고 해서 토큰 비용이 절감되지는 않습니다. 하지만 N번의 순차적인 호출을 한 번의 병렬 호출로 줄여 지연 시간 (Latency)을 단축하며, 스크래핑 (Scraping) 시간 내에 일일 처리량을 완료할 수 있을 만큼 파이프라인을 충분히 빠르게 유지해 줍니다.
벡터 스토어 (Vector Store): Pinecone vs pgvector
두 가지를 모두 평가했고, 결과적으로 저를 놀라게 한 하이브리드 방식을 선택하게 되었습니다.
Pinecone은 설정이 쉽고 쿼리 (Query) 시점에 빠릅니다. 오후 한나절 만에 작동하는 PoC (Proof of Concept)를 만들 수 있었습니다. 하지만 프로덕션 (Production) 규모로 넘어가자 비용이 빠르게 증가했습니다. 제가 필요한 벡터 볼륨을 수용할 수 있는 용량의 Pinecone 포드 (Pod) 인덱스는 매달 제가 지불하고 싶은 금액보다 더 많은 비용이 들었습니다. 또한 과거 매칭을 위해 짧은 기간 이상의 데이터 보존 (Retention)도 필요했습니다.
PostgreSQL 내부의 pgvector는 초기에 더 많은 작업이 필요했습니다. 인덱스 설정, 쿼리 로직, 유지보수 스크립트를 직접 작성해야 했습니다. 하지만 절감 효과는 극적이었습니다. 동일한 EC2 인스턴스에서 실행 중인 기존 PostgreSQL 인스턴스가 추가적인 인프라 비용 없이 벡터 워크로드 (Workload)를 처리했습니다. 적절한 인덱스 구성(적절한 lists 및 probes를 갖춘 IVFFlat)을 사용하면 근사 최근접 이웃 (Approximate Nearest Neighbor) 검색을 수행하기에 충분한 쿼리 성능이 나왔습니다.
CREATE INDEX idx_job_embeddings ON job_chunks
USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
...
진정한 승부처는 트랜잭션 일관성 (Transactional Consistency)이었습니다. Pinecone을 사용할 때는 PostgreSQL과 벡터 스토어 간의 동기화를 관리해야 했습니다. 장애 발생 시 리스팅 (Listing) 데이터가 한쪽에는 있고 다른 쪽에는 없는 상태가 될 수 있었습니다. pgvector를 사용하면 임베딩이 리스팅 데이터와 동일한 데이터베이스에 존재합니다. 하나의 트랜잭션, 하나의 진실의 원천 (Source of Truth). 별도의 조정 (Reconciliation) 스크립트가 필요 없습니다.
저는 새로운 RAG 기능을 프로토타이핑할 때는 Pinecone을 사용합니다. 그리고 프로덕션에는 pgvector를 사용합니다. 이 패턴은 여러 프로젝트에 걸쳐 유지되어 왔습니다.
대규모 LLM 스코어링 (Scoring): 함수 호출 (Function Calls) 및 비용 제어
임베딩 검색(embedding search)은 후보 청크(candidate chunks)를 반환합니다. 그런 다음, 특정 후보 프로필에 대한 각 채용 공고의 관련성(relevance)을 점수 매기기 위해 LLM이 필요합니다. 이 부분이 비용이 많이 드는 단계입니다.
저는 점수 매기기(scoring)를 위해 함수 호출 (function calling) 기능이 있는 GPT-4o를 사용합니다. 함수 스키마(function schema)는 엄격합니다. 모델은 관련성 점수(0-100), 짧은 근거(justification), 그리고 일치하는 기술(skills) 목록을 출력합니다. 이 스키마는 모델이 일치하지 않는 정보를 조작(fabricate)할 수 없도록 강제합니다. 출력되는 모든 기술은 반드시 채용 공고 텍스트 내에 존재해야 합니다.
const scoreFunction: ChatCompletionTool = {
type: 'function',
function: {
...
하루에 수천 개의 공고를 처리할 때, GPT-4o로 각각의 점수를 매기는 것은 지속 가능할 만큼의 비용을 훨씬 초과하게 됩니다.
다음 세 가지 방법이 비용을 낮추었습니다.
OpenAI Batch API. 저는 점수 매기기 작업을 한 번에 수백 개씩 배치(batch)로 보냅니다. Batch API는 상당한 할인 혜택을 제공하며 몇 시간 이내에 완료됩니다. 점수 매기기 파이프라인(scoring pipeline)은 스크래핑(scraping) 시간 동안 밤사이에 실행되므로, 지연 시간(latency)은 문제가 되지 않습니다. 배치는 아침 트래픽 급증이 발생하기 전에 완료됩니다.
캐싱 (Caching). 만약 특정 공고가 이전에 유사한 후보 프로필에 대해 점수가 매겨진 적이 있다면, 캐싱된 결과를 반환합니다. 캐시 키(cache key)는 채용 공고 ID와 후보자의 기술 벡터(skill vector)를 결합한 해시(hash) 값입니다. 반복되는 후보자들에 대해 유의미한 적중률(hit rate)을 보입니다.
모델 계층화 (Model tiering). 모든 공고에 GPT-4o가 필요한 것은 아닙니다. 일반적인 직무(소프트웨어 엔지니어, 영업 대표)에 대한 공고는 GPT-4o-mini로도 충분히 잘 점수를 매깁니다. 저는 정확도가 더 중요한 니치(niche) 직무(컴플라이언스 담당자, 생물 의학 엔지니어)를 위해 GPT-4o를 예약해 둡니다. 라우팅 로직(routing logic)은 간단합니다. 직무 제목이 일반적인 직무 목록과 일치하면 더 저렴한 모델을 사용합니다.
일일 총 LLM 비용이 대폭 감소했습니다. Batch API만으로도 절감액의 상당 부분을 차지했습니다.
관찰 가능성 (Observability): 무엇이 고장 났고 어떻게 발견했는가
파이프라인은 상당한 비율의 리스팅(listing)에서 실패하고 있다는 사실을 발견하기 전까지 한동안 조용히 실행되었습니다. 오류는 치명적이지 않았습니다. 잘못된 형식의 ATS 응답이 청커(chunker)로 하여금 빈 청크(chunk)를 생성하게 만들었고, 임베딩(embedding) 단계에서 이를 조용히 건너뛰면서 해당 리스팅은 검색 결과에 영원히 나타나지 않았습니다. 충돌도, 알림도 없었습니다.
오류 추적을 위해 Sentry를 추가했고, 프론트엔드 세션 리플레이(session replay)를 위해 LogRocket을 추가했습니다. 하지만 진짜 수확은 데이터 수집(ingestion), 정규화(normalization), 청킹(chunking), 임베딩(embedding), 저장(storage), 스코어링(scoring)에 이르기까지 전체 파이프라인을 통해 단일 리스팅을 추적할 수 있는 상관관계 ID(correlation ID)를 포함한 구조화된 로깅(structured logging)을 추가한 것이었습니다.
logger.info('Listing processed', {
listingId: id,
source: 'greenhouse',
...
이를 통해 소스별 파이프라인 상태를 보여주는 대시보드를 구축할 수 있었습니다. 일부 ATS 소스들은 높은 비율로 성공적으로 처리되었습니다. 다른 소스들은 정규화 도구(normalizer)가 처리하지 못하는 HTML이 설명 필드에 반환되는 API 때문에 더 자주 실패했습니다. 저는 정규화 도구를 수정했고, 실패율은 크게 낮아졌습니다.
상관관계 ID가 없었다면, 이 버그를 훨씬 나중에 발견했거나 아예 발견하지 못했을 것입니다.
내가 다르게 했을 점
만약 오늘 이 파이프라인을 다시 시작한다면, Pinecone을 완전히 건너뛰고 바로 pgvector로 갈 것입니다. 설정 비용은 더 높지만, 하나의 데이터베이스를 사용하는 운영상의 단순함은 그만한 가치가 있습니다. 또한 관찰 가능성(observability) 계층을 파이프라인 이후가 아닌, 파이프라인 구축 전에 만들 것입니다.
가장 놀라웠던 점은 작업의 상당 부분이 AI가 아닌 데이터 정규화(data normalization)였다는 사실입니다. 대부분의 버그는 LLM이나 벡터 스토어(vector store)가 아니라, 일관되지 않은 ATS 출력 형식에서 발생했습니다. 데이터 배관(data plumbing) 작업을 올바르게 처리하고 나면 AI 부분은 놀라울 정도로 신뢰할 수 있습니다.
만약 귀하의 팀이 프로덕션 RAG 파이프라인을 구축하면서 동일한 종류의 조용한 실패 모드나 비용 문제를 겪고 있다면, 그것이 바로 제가 도와드릴 수 있는 부분입니다. 무엇이 효과적이었고 무엇이 아니었는지에 대해 의견을 나누게 된다면 기쁘겠습니다.
작성자: Abdul Rehman, 프로덕션 SaaS, MVP 및 AI 자동화를 구축하는 풀스택 AI 엔지니어. 더 자세한 내용은 PrimeStrides에서 확인하세요.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기