
무엇이 AI 시스템을 프로덕션 수준으로 만드는가? 파트 2: RAG 인제스션 파이프라인 설계
요약
프로덕션 수준의 RAG 시스템을 구축하기 위한 핵심 요소인 인제스션(Ingestion) 파이프라인 설계 방법을 다룹니다. 데이터의 최신성을 유지하고 중복 및 오류를 방지하기 위해 단일 책임 원칙을 적용한 아키텍처를 제안합니다.
핵심 포인트
- RAG 시스템의 신뢰성은 검색 품질뿐만 아니라 인제스션 레이어에 의해 결정됨
- 오래된 벡터, 중복 데이터, 로드 실패 등 인제스션 단계의 오류가 시스템 부패의 주원인
- Loader, Splitter, Embedder, VectorStore로 구성된 단일 책임 컴포넌트 설계
- 유지보수성을 위해 오케스트레이터(Coordinator)를 최대한 가볍게 유지할 것
프로덕션급 AI 시스템 구축에 관한 시리즈의 일부입니다. 파트 1에서는 엔터프라이즈 RAG 프로젝트의 전반적인 형태를 다루었습니다. 이 포스트는 인제스션 (Ingestion) — 그 위에 구축된 모든 것이 신뢰할 수 있는지 여부를 결정하는 레이어에 관한 것입니다.
RAG 관련 글에서는 검색 (Retrieval) 품질이 모든 관심을 받습니다. 청킹 (Chunking) 전략, 리랭커 (Rerankers), 프롬프트 포맷 (Prompt formats) 등이 그것입니다. 하지만 인제스션 (Ingestion) — 문서를 벡터 스토어 (Vector store)에 한 번에 정확하게 넣고, 소스 문서가 변경됨에 따라 최신 상태를 유지하는 책임을 지는 시스템 부분에 대해서는 쓰는 사람이 거의 없습니다.
이는 메워야 할 격차입니다. 왜냐하면 인제스션 (Ingestion)은 대부분의 실제 RAG 시스템이 조용히 부패하는 지점이기 때문입니다. 잘못된 검색 로직 때문이 아니라, 오래된 벡터 (Stale vectors), 조용한 중복 (Silent duplicates), 그리고 6개월 전에 로드에 절반쯤 실패했지만 아무도 알아차리지 못한 문서들 때문입니다. 이 포스트는 이를 방지하기 위해 우리가 도달한 아키텍처와 각 구성 요소 뒤에 숨겨진 논리에 관한 것입니다.
파이프라인의 형태
핵심적으로, 인제스션 (Ingestion)은 하나의 얇은 코디네이터 (Coordinator)에 의해 오케스트레이션되는 네 가지 단일 책임 구성 요소로 이루어져 있습니다.
IngestionPipeline은 이 네 가지를 순차적으로 호출하는 얇은 오케스트레이터 (Orchestrator)이며, 해당 순서 외에는 자체적인 로직을 보유하지 않습니다.
각 클래스는 정확히 한 가지 작업만 수행하며 다른 클래스의 내부 구현에 대해서는 알지 못합니다:
Loader— 파일 경로를 입력받아 파싱된Document객체를 출력합니다. 파일 형식 → 파서 (Parser) 매핑만을 소유하며 그 외의 것은 다루지 않습니다.Splitter— 문서를 입력받아 청크 (Chunks)를 출력합니다. 포맷에 구애받지 않으며(Format-agnostic), 파일 확장자를 절대 보지 않습니다.Embedder— 청크를 입력받아 벡터 (Vectors)를 출력합니다. 모델 라이프사이클 (Model lifecycle), 배치 (Batching), 그리고 임베딩 API (Embedding API)를 위한 재시도 정책 (Retry policy)을 소유합니다.QdrantVectorStore— Qdrant와 통신하는 유일한 요소입니다. 컬렉션 라이프사이클 (Collection lifecycle)과 포인트 저장 (Point storage)을 소유합니다.IngestionPipeline— 위 항목들을 순차적으로 실행합니다. 그 이상은 없습니다.
마지막 제약 조건이야말로 장기적인 유지보수 측면에서 실제로 중요한 부분입니다. 바로 오케스트레이터(orchestrator)를 의도적으로 가볍게 유지하는 것입니다. 재시도 로직(retry logic), 형식 분기(format branching), 또는 캐싱 전략(caching strategy)이 IngestionPipeline.ingest() 내부로 스며들기 시작하는 순간, 이는 로직이 잘못된 위치에 있다는 신호입니다. 오케스트레이터의 역할은 이미 올바르게 작동하는 네 가지 컴포넌트의 순서를 정하는 것입니다. 만약 오케스트레이터가 컴포넌트 중 하나를 보완해야 한다면, 그 컴포넌트는 실제로 완성된 것이 아닙니다.
이러한 단일 책임 분리(single-responsibility split) 덕분에 이 포스트의 나머지 내용이 가능해집니다. 아래의 모든 아키텍처 결정은 다른 클래스를 건드리지 않고도 단 하나의 클래스 변경만으로 구현할 수 있었습니다.
문서 로드 실패는 에러 경로가 아닌, 일급 객체(first-class) 결과로 다뤄져야 합니다
실제 기업용 문서 세트는 깔끔하지 않습니다. OCR 레이어가 없는 스캔된 PDF, 기술적으로는 유효하지만 추출할 내용이 없는 파일, 부분적으로만 파싱되는 문서 등이 존재합니다. "이 파일에서 사용 가능한 콘텐츠가 전혀 생성되지 않음"을 예외적인 에러로 취급하는 파이프라인은 일반적인 데이터에서 충돌을 일으키거나, 더 나아가 동일한 배치(batch) 내의 무관한 파일들까지 죽게 만드는 방식으로 실패를 전파하게 됩니다.
여기서의 설계 결정은 다음과 같습니다: 추출 가능한 콘텐츠가 0인 상태는 실패가 아니라 유효한 종료 상태(terminal state)입니다. 이는 두 개의 레이어에서 독립적으로 확인되고 단락 회로(short-circuited) 처리됩니다:
Loader는 파싱된 내용을 검사하고, 문서가 완전히 비어 있는지 또는 부분적으로 비어 있는지 여부를 예외(exception)가 아닌 로깅(logging)을 통해 표시합니다. 이를 통해 원인을 세 단계 아래에서 설명되지 않은 '0'으로 발견하는 대신, 트레이스(trace)에서 즉시 확인할 수 있습니다.Splitter의 출력은 입력이 비어 있을 경우 자연스럽게 빈 청크 리스트(empty chunk list)가 되며,IngestionPipeline.ingest()는 이를 정당한 조기 반환(early return)으로 취급합니다. 즉, 임베딩(embedding)과 저장(storage)을 건너뛰고, 이를 기록한 뒤 다음으로 넘어갑니다. 예외를 던지거나 배치를 중단하지 않습니다.
대안인 '빈 콘텐츠에 대해 예외를 발생시키는 것(raising on empty content)'은 ingest()를 호출하는 모든 호출자가 "이 특정 종류의 실패는 사실 괜찮다"라는 예외 처리를 별도로 구현하도록 강제하며, 이는 책임의 소재를 역전시킵니다. 실제 문서를 위해 구축된 파이프라인은 저하된 입력(degraded input)을 예외적인 상황이 아닌 일상적인 상황으로 예상해야 합니다.
배치 결함 격리(Batch fault isolation): 잘못된 문서 하나가 나머지 499개를 망치게 두어서는 안 됩니다
엔터프라이즈 규모에서는 인제스션(ingestion) 실행 시 수백 또는 수천 개의 파일이 포함된 디렉토리를 처리합니다. 해당 배치 내의 무언가는 반드시 형식이 잘못되어 있을 것입니다. 손상된 PDF, 인코딩 문제, 혹은 잘린 다운로드 파일 등이 그 예입니다. 여기서 아키텍처적인 질문은 그 파일 하나가 실패했을 때, 그 실패가 격리되는지 아니면 재앙적인 결과를 초래하는지 여부입니다.
설계 원칙: process_file은 절대로 예외를 자신 너머로 전파하지 않습니다. 이 함수는 예외를 포착(catch)하고, 전체 컨텍스트(단순한 문자열 형태의 에러 메시지가 아닌, 실제 디버깅이 가능하도록 트레이스백(traceback)을 포함)와 함께 로그를 남기며, 성공(succeeded), 실패(failed), 또는 건너뜀(skipped) 상태를 보고합니다. 배치 루프는 결과와 상관없이 계속 진행됩니다.
def process_file(pipeline, manifest, file_path, collection_name, source_type=None) -> str:
...
try:
...
물론 실행 전체 결과는 여전히 정직하게 전달되어야 합니다. 파일의 40%가 조용히 실패한 채로 "완료"되는 배치 작업은, 요란하게 충돌(crash)하며 멈추는 작업보다 더 나쁩니다. 따라서 엔트리 포인트(entry point)는 성공/실패/건너뜀 횟수를 명시적으로 추적하며, 무언가 실패했을 경우 0이 아닌 종료 코드(non-zero exit code)로 종료합니다. 그래야만 CI, cron, 또는 다운스트림의 오케스트레이터(orchestrator)가 잘못된 깨끗한 종료 코드를 읽는 대신, 실행 상태가 저하되었음을 실제로 감지할 수 있습니다.
이는 인제스션에만 국한된 것이 아닌 일반적인 원칙입니다: 의미 있는 최소 단위(파일 하나)에서 실패를 격리하고, 중요한 최대 단위(전체 실행)에서 정직하게 보고하십시오.
실제 엔터프라이즈 문제: 문서는 변경되며, 모든 것을 다시 임베딩하는 것은 확장성이 없습니다
이 단계는 단순히 작동하는 데모와 실제로 살아있는 문서 세트를 대상으로 운영할 수 있는 시스템을 구분 짓는 인제스션 (Ingestion) 과정입니다. 실제로 정책 문서, 사양서 (specs), 실행 지침서 (runbooks) 등은 지속적으로 업데이트됩니다. 프로덕션 수준의 인제스션 파이프라인은 다음 세 가지 질문에 올바르게 답할 수 있어야 합니다.
- 파일이 변경되지 않았다면, 재임베딩 (re-embed) 비용을 지불하지 않고 피할 수 있는가?
- 파일이 변경되었다면, 깔끔하게 교체되는가, 아니면 이전 벡터와 새 벡터가 모두 검색 가능한 상태로 남게 되는가?
- 파일이 삭제되면, 해당 콘텐츠가 결국 검색되지 않게 되는가?
단순한 (naive) 설계는 이 세 가지 모두를 틀리게 처리합니다. 만약 인제스션 시마다 포인트 ID (point IDs)가 무작위로 생성된다면, 변경되지 않은 코퍼스 (corpus)에 대해 파이프라인을 다시 실행할 때 모든 문서를 중복 생성하게 됩니다. 즉, 내용은 같지만 새로운 무작위 ID가 부여되어 마치 새로운 문서인 것처럼 삽입됩니다. 이를 정기적인 일정에 따라 반복하면, 벡터 스토어 (vector store)는 변경되지 않은 콘텐츠의 복사본으로 가득 차게 되며, 실제로 업데이트된 문서들은 오래되고 구식인 자신의 이전 버전 바로 옆에 놓이게 됩니다. 두 버전 모두 완전히 검색 가능하며, 둘을 구분할 수 있는 방법은 없습니다. 결과적으로 검색 품질 (retrieval quality)은 서서히, 그리고 눈에 띄지 않게 저하됩니다.
콘텐츠 주소 지정 저장소 (Content-addressable storage): 결정론적 ID
해결책은 저장 계층 (storage layer)에서 시작됩니다. 청크 (chunk)당 무작위 ID를 사용하는 대신, 청크의 내용 자체로부터 결정론적 (deterministic)으로 ID를 유도하십시오.
source = chunk.metadata.get("source", "")
point_id = str(uuid5(NAMESPACE_URL, f"{source}::{idx}"))
uuid5는 무작위 추출이 아니라 해시 (hash)입니다. 즉, 동일한 (source, chunk index) 쌍은 항상 동일한 ID를 생성합니다. 동일한 파일을 다시 인제스션하면, 해당 청크들은 이전에 점유했던 것과 정확히 동일한 포인트에 배치됩니다. ID를 기준으로 삽입 또는 덮어쓰기를 수행하는 Qdrant의 업서트 (upsert) 시맨틱 (semantics)이 나머지를 처리합니다. 즉, 새 벡터가 기존 벡터를 제자리에서 교체합니다. 중복이 발생하지 않으며, 결정적으로 이를 수행하기 위해 별도의 조회 (lookup) 단계가 필요하지 않습니다. ID 계산 자체가 이전에 무엇이 있었든 간에 충돌을 보장하기 때문입니다.
결정론적 ID (Deterministic IDs)만으로는 문서가 축소되는 (shrinks) 경우를 처리할 수 없습니다. 만약 새 버전의 청크 (chunks) 수가 더 적다면, ID 덮어쓰기만으로는 기존의 뒤에 남은 청크들을 건드릴 수 없기 때문입니다. 따라서 교체 (replacement) 작업은 소스 파일 (source file) 범위를 지정한 명시적인 정리 (cleanup) 단계와 쌍을 이룹니다:
def delete_by_source(self, collection_name, source):
self.client.delete(
collection_name=collection_name,
...
ingest()는 파일의 새로운 청크를 저장하기 전에 조건 없이 이 함수를 호출합니다. 이는 새 버전이 청크를 전혀 생성하지 않는 경우를 포함하므로, 문서가 완전히 비워지더라도 고립된 벡터 (orphaned vectors)가 남지 않도록 합니다. 결정론적 ID는 일반적인 케이스를 효율적으로 처리하며, 소스별 삭제 (delete-by-source)는 그 외의 모든 상황을 잡아내는 보증 장치 역할을 합니다.
변경되지 않은 것에 대한 비용 지불을 피하기 위한 매니페스트 (manifest)
결정론적 ID와 소스별 삭제는 정확성 (correctness) — 즉, 중복이나 고립된 데이터가 없는 상태 — 을 해결합니다. 하지만 비용 (cost) 문제를 해결하지는 못합니다. 모든 파일은 변경 여부와 관계없이 매 실행 시마다 전체가 로드되고, 청크로 분할되며, 다시 임베딩 (re-embedded)됩니다. 엔터프라이즈 규모에서는 벡터 저장소 (vector storage)가 아니라 임베딩 API 호출이 비용이 많이 드는 부분입니다.
해결책은 비용이 많이 드는 경로 앞에 위치하는 가벼운 변경 감지 (change-detection) 레이어, 즉 파일별로 마지막으로 인제션 (ingested)한 버전의 콘텐츠 해시 (content hash)를 기억하는 매니페스트 (manifest)를 두는 것입니다.
file_hash = compute_file_hash(file_path) # 원시 바이트의 SHA-256
previous_hash = manifest.get(source)
...
매니페스트(manifest) 자체도 이 시스템의 다른 모든 요소와 동일한 방식으로 저장됩니다. 즉, 파일 경로에서 유도된 결정론적 ID(deterministic ID)를 키로 사용하여, 별도의 작은 전용 컬렉션 내의 Qdrant 포인트(points)로 저장됩니다. 새로운 인프라나 벡터 스토어(vector store)와 일관성을 유지하기 위한 별도의 데이터베이스는 필요하지 않습니다. 파일당 하나의 레코드가 생성되며, 여기에는 경로, 콘텐츠 해시(content hash), 청크 수(chunk count), 타임스탬프(timestamp)가 포함됩니다. 이 포인트들의 벡터 필드는 사용되지 않는 플레이스홀더(placeholder)입니다. 매니페스트는 유사도 검색(similarity search)을 수행하지 않으며, 단지 다른 모든 데이터와 동일한 스토어에 거주하는 단순한 키-값 조회(key-value lookup) 역할을 수행할 뿐입니다.
이 두 메커니즘 사이의 상호작용이 실제 설계의 핵심입니다: 매니페스트는 재인제스션(re-ingest)을 수행할지 여부를 결정하며, 결정론적 ID와 소스별 삭제(delete-by-source) 방식은 재인제스션이 발생했을 때 중복 생성되는 대신 깔끔하게 교체되도록 보장합니다. 어느 하나만으로는 충분하지 않습니다. 결정론적 ID가 없는 매니페스트는 변경된 내용이라도 여전히 중복을 발생시킬 것이고, 매니페스트가 없는 결정론적 ID는 변경 여부와 상관없이 매 실행마다 전체 재임베딩(re-embedding) 비용을 지불해야 할 것입니다.
이 설계에서 간과하기 쉬운 결과 중 하나는 다음과 같습니다: 매니페스트와 벡터 스토어는 동일한 기저 상태(underlying state)에 대한 두 가지 표현이며, 반드시 함께 리셋되어야 합니다. 매니페스트를 비우지 않고 벡터 컬렉션만 삭제하면, 벡터가 방금 삭제되었음에도 불구하고 다음 실행 시 모든 파일이 "변경되지 않음"으로 보고됩니다. 즉, 두 스토어는 독립적으로 움직이는 것이 아니라 반드시 발맞추어(lockstep) 움직여야 합니다.
컬렉션 라이프사이클(Collection lifecycle)은 파일 단위가 아닌 실행 단위의 결정입니다
더 미묘한 질문이 있습니다: 타겟 컬렉션은 실제로 언제 생성(또는 전체 리셋을 위해 삭제)되는가? 가장 단순한 방식은 파일별 인제스트(ingest) 호출 내부에 배치하여, 방어적인 차원에서 매 파일마다 확인하고 생성하는 것입니다. 하지만 이는 잘못된 범위(scope)입니다. 컬렉션이 존재해야 하는지, 혹은 전체 리셋이 필요한지는 문서마다 재평가해야 하는 사실이 아니라, 한 번 결정되면 유지되는 실행(run)에 관한 사실이기 때문입니다.
파이프라인의 파일별 ingest() 호출은 컬렉션(collection)이 이미 존재한다고 가정합니다. 컬렉션을 보장하는 작업 — 그리고 요청이 있을 경우 이를 먼저 삭제(wiping)하는 작업 — 은 파일에 손을 대기 전, 배치 실행(batch run)의 시작 시점에 정확히 단 한 번만 수행되어야 합니다. 이는 앞서 언급한 매니페스트(manifest) 지점과 일치합니다. 즉, 설정(setup) 및 파괴적 작업(destructive operations)은 배치 수준의 관심사이며, 파일별 인제스션(ingestion)은 자신의 환경이 이미 올바르다고 가정할 수 있는 더 좁은 범위의 작업입니다.
이질적인 코퍼스(Heterogeneous corpora): 인제스션 시점에 소스별로 콘텐츠 태깅하기
엔터프라이즈 문서 세트는 단일한 형태의 뭉치인 경우가 드뭅니다. 서로 다른 폴더는 종종 의미적으로 매우 다른 콘텐츠를 나타냅니다. 예를 들어, 검증된 참조 자료와 메모용 낙서, 혹은 한 팀의 문서와 다른 팀의 문서가 구분될 수 있습니다. 파이프라인은 이를 쿼리(query) 시점에 추론하는 대신, 인제스션(ingestion) 시점에 태깅합니다. 즉, 파일이 어느 폴더에서 왔는지에 따라 모든 청크(chunk)의 메타데이터에 source_type을 부착합니다. 이 정보는 4가지 핵심 구성 요소 중 어느 것도 그 이유를 알 필요 없이, 저장된 페이로드(payload)까지 그대로 전달됩니다.
이는 작은 메커니즘이지만 하류(downstream) 단계에서는 큰 목적을 가집니다. 사후에 "이 청크가 어디에서 왔는가"를 다시 유도할 필요 없이, 나중에 필터링된 검색(filtered retrieval)과 다양한 콘텐츠 카테고리에 걸친 검색 품질 평가(retrieval quality evaluation)를 가능하게 만드는 핵심 요소입니다.
이를 통해 얻는 것과 얻지 못하는 것
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기