Python으로 RAG를 처음부터 직접 구축하며 배운 것들
요약
LangChain과 같은 고수준 라이브러리의 추상화에서 벗어나, 순수 Python으로 RAG 파이프라인을 직접 구축하며 얻은 기술적 통찰을 공유합니다. 청킹 전략, 공백 정규화, 모듈화된 아키텍처 설계의 중요성을 다룹니다.
핵심 포인트
- 고수준 프레임워크가 숨기는 RAG의 내부 복잡성 이해
- 슬라이딩 윈도우 방식을 이용한 효과적인 텍ext 청킹 전략
- 단일 책임 원칙을 적용한 모듈형 파이프라인 설계
- 데이터 전처리 시 공백 정규화의 중요성
저는 프로덕션 환경에서 LangChain의 RAG 체인을 6개월 동안 사용했습니다. 저는 누가 물어보지 않으면 chunk_overlap이 무엇을 하는지, 코사인 유사도(cosine similarity)가 왜 적절한 거리 측정 지표인지, 또는 nomic-embed-text가 문장을 벡터로 실제로 어떻게 변환하는지 말할 수 없었습니다. 고수준 라이브러리가 이 모든 것을 추상화했기 때문입니다.
그래서 어느 주말에 LangChain 의존성을 삭제하고 순수한 Python으로 약 500줄 분량의 RAG 파이프라인을 처음부터 작성했습니다. 프레임워크도, 마법 같은 것도 없습니다. 텍스트 추출에는 pypdf를 사용했고, 청크(chunk) 생성기는 60줄로 만들었습니다. 벡터 저장소로는 ChromaDB를, 임베딩과 LLM으로는 Ollama를 사용했습니다. 전체 코드는 GitHub에 있으며, 모든 모듈은 200줄 미만이고, 모든 테스트는 결정론적(deterministic)이며, 한 번에 전부 읽을 수 있습니다.
이것은 구축 과정 기록입니다. 튜토리얼이 아니라 — 저를 놀라게 했던 부분과 처음에는 잘못 이해했던 부분들이 담긴 빌드 로그입니다.
왜 직접 만들 필요가 있는가
솔직한 이유는 다음과 같습니다. 저는 LangChain의 RetrievalQA 체인을 사용하고 있었는데, 신뢰할 수 없는 답변을 얻고 있었습니다. 때로는 문서에 언급되지 않은 내용을 모델이
또 다른 이유는 제가 작성한 코드가 500줄에 달하며, 이는 50줄짜리 LangChain 스크립트와 동일한 영역을 다루고 있다는 점입니다. 추가된 450줄은 주석, 타입 힌트 (type hints), 테스트, 그리고 명시적인 에러 핸들링 (error handling)입니다. 그것이 실제 복잡성입니다. LangChain은 이를 숨기지만, 직접 구축하면 그 복잡성을 직면하게 됩니다.
아키텍처 (The architecture)
전체 파이프라인은 각각 한 가지 일만 수행하는 6개의 모듈로 구성됩니다:
[ PDF file ]
|
v
...
각 모듈은 단일 책임 (single responsibility)을 가집니다. 각 모듈은 독립적으로 테스트 가능하며, 다른 모듈을 건드리지 않고도 교체할 수 있습니다. 이것이 코드를 작게 유지할 수 있었던 설계 제약 조건이었으며, "장난감"과 "운영 환경에서 신뢰할 수 있는 것" 사이의 차이를 만든 핵심 요소였습니다.
파트 1 — 청커 (the chunker)
청커 (chunker)는 대부분의 튜토리얼이 건너뛰는 부분입니다. 그들은 "텍스트를 청크 (chunks)로 나누세요"라고 말하고 그냥 넘어갑니다. 하지만 청킹 (chunking)은 나중에 모델이 무엇을 찾을 수 있고 무엇을 찾을 수 없는지를 결정하는 단계입니다. 겹침 (overlap)이 없는 5,000자 크기의 청크는 두 청크의 경계에 걸쳐 있는 질문에 대한 답을 놓치게 될 것입니다. 의미론적 인식 (semantic awareness)이 없는 200자 크기의 청크는 문장을 분리하여 문맥 (context)을 잃게 만들 것입니다.
저는 문자 단위의 겹침 (character-level overlap), 정규화된 공백 (normalized whitespace), 그리고 원본 오프셋 추적 (original-offset tracking)을 사용하는 슬라이딩 윈도우 (sliding-window) 청커를 선택했습니다:
def chunk_text(
text: str,
chunk_size: int = 800,
...
주의 깊게 봐야 할 세 가지 사항이 있습니다.
첫째, **공백 정규화 (whitespace normalization)**는 작아 보이지만 큰 차이를 만드는 요소입니다. PDF 텍스트는 문장 중간의 줄바꿈, 표 셀에서 나온 탭, 마침표 뒤의 이중 공백 등 이상한 공백과 함께 추출됩니다. 가공되지 않은 원문 (raw text)을 기준으로 청킹을 하면, "500자" 청크들의 토큰 (token) 수가 제각각이 됩니다. 먼저 정규화를 수행한다는 것은 chunk_size=800이 실제로 "약 800자의 유용한 문자"를 의미하게 된다는 뜻입니다.
둘째, **100자의 겹침 (overlap)**은 "이것을 찾았다"와 "청크 경계에 걸쳐 있어서 답을 놓쳤다" 사이의 차이를 만듭니다. 문장이 두 청크에 걸쳐 있는 경우, 겹침 구간이 있으면 두 청크 모두 연결 단어들을 포함하게 되므로 코사인 유사도 (cosine similarity)가 어느 쪽과도 일치할 수 있습니다.
셋째, 원본 오프셋 추적 (original-offset tracking) (Chunk 데이터 클래스의 char_start, char_end)은 UI에서 소스 하이라이터 (source highlighter)를 구축하기 전까지는 저에게 꼭 필요할 줄 몰랐던 기능입니다. 이 기능 덕분에 모델이 "구절 4를 참조하세요"라고 말할 때, 원본 PDF에서 해당 구절이 정확히 어떤 글자들로부터 왔는지 사용자에게 보여줄 수 있습니다. 이 기능이 없었다면 문서 전체를 메모리에 저장하고 퍼지 텍스트 매칭 (fuzzy text match)을 수행해야 했을 것입니다. 비용은 청크당 16바이트입니다. 하지만 그 대가로 "이 인용은 환각 (hallucination)이 아니라 실제 데이터이다"라는 확신을 얻을 수 있습니다.
파트 2 — 임베딩 교체 (the embedding swap)
이 프로젝트에서 제가 한 가장 훌륭한 리팩터링 (refactor)은 Embedder를 Protocol로 만든 것입니다. 단 두 줄의 타이핑으로 무한한 유연성을 얻었습니다.
class Embedder(Protocol):
def embed(self, text: str) -> list[float]: ...
def embed_batch(self, texts: list[str]) -> list[list[float]]: ...
이제 테스트를 위해 결정론적 (deterministic) 벡터를 반환하는 FakeEmbedder를 작성할 수도 있고, 프로덕션 (production) 환경을 위해 로컬 Ollama API를 호출하는 OllamaEmbedder를 사용할 수도 있습니다. 파이프라인 (pipeline)은 자신이 어떤 객체와 통신하고 있는지 알 필요도, 신경 쓸 필요도 없습니다. 이것이 바로 프레임워크에 맡기지 않고 직접 구현했을 때의 의존성 주입 (dependency injection)의 모습입니다.
실제 OllamaEmbedder는 20줄에 불과합니다.
class OllamaEmbedder:
"""로컬 Ollama HTTP API를 통한 임베딩. 무료이며 API 키가 필요 없음."""
...
유일한 성능 최적화는 배치 단위 호출 (per-batch call)입니다. 단순한 버전은 청크당 하나의 HTTP 요청을 보내는데, 800개의 청크로 구성된 문서라면 800번의 요청을 보냅니다. 요청당 50ms라고 가정하면 40초가 걸립니다. 배치 처리를 하면 실제 경과 시간 (wall-clock time)은 비슷할지라도, Ollama 측에서 이를 파이프라이닝 (pipelining)할 수 있어 실제 생성 시간을 절반으로 줄일 수 있습니다.
배치 루프를 concurrent.futures.ThreadPoolExecutor가 아닌 순차적 (sequential) 방식으로 구현한 이유는, 스레딩 (threading)을 시도했을 때 Ollama의 HTTP 서버가 부하 상황에서 연결을 끊어버렸기 때문입니다. 순차 방식은 경과 시간 측면에서는 더 느리지만 신뢰할 수 있습니다. 트레이드오프 (trade-offs)인 셈입니다.
파트 3 — 벡터 스토어 (the vector store)
저는 ChromaDB를 사용했습니다. 그것이 최고라서가 아니라, 올바르게 설정하기 가장 쉽기 때문입니다. pip install chromadb를 실행하고 코드 세 줄만 작성하면, 디스크에 영구적으로 저장되고 쿼리가 가능한 코사인 유사도 벡터 스토어 (cosine-similarity-vector-store)를 가질 수 있습니다.
class VectorStore:
"""ChromaDB 컬렉션을 감싸는 얇은 래퍼 (Thin wrapper)."""
...
hnsw:space: cosine 메타데이터가 중요한 단 한 줄입니다. ChromaDB의 기본값은 L2 (유클리드) 거리인데, 이는 정규화된 임베딩 (normalized embeddings)에는 괜찮지만 직관적으로는 맞지 않습니다. 코사인 거리 (Cosine distance)는 "길이를 무시한 벡터 사이의 각도"를 의미하며, 이것이 바로 의미론적 검색 (semantic search)에서 원하는 방식입니다. 같은 의미를 가진 두 문장은 벡터의 길이가 얼마나 길든 상관없이 동일한 방향을 가리키는 벡터를 가져야 합니다.
검색 (search) 메서드에는 한 가지 명확하지 않은 변환 과정이 있습니다. ChromaDB는 거리를 [0, 2] 범위로 반환하며, 저는 이를 [-1, 1] 범위의 유사도 (similarity)로 변환합니다 (표시를 위해 [0, 1]로 클램핑 (clamped) 처리함). similarity = max(0.0, 1.0 - float(dist)) 이 한 줄이 이 파일에 포함된 유일한 수학 연산입니다. 나머지는 모두 이를 연결하는 접착제 (glue) 코드입니다.
similarity = max(0.0, 1.0 - float(dist))
hits.append(
SearchHit(
...
왜 0으로 클램핑 (clamp) 하나요? 이론적으로 코사인 거리는 1보다 클 수 있기 때문입니다 (벡터가 반대 방향을 가리키는 경우). 이 경우 "음수 유사도"가 발생하게 됩니다. UI에 표시할 때 "이 청크는 질문과 -12% 유사합니다"라고 보여주고 싶지는 않을 것입니다. 0으로 클램핑하는 것은 "관련 없음"을 의미하며 정직한 표현입니다.
파트 4 — 프롬프트가 곧 제품의 전부다
프로젝트에서 가장 중요한 20줄은 pipeline.py에 있습니다:
SYSTEM_PROMPT = """당신은 오직 제공된 문서 문맥 (context)에만 기반하여 질문에 답하는 신중한 어시스턴트입니다. 다음 규칙을 엄격히 준수하십시오:
...
저는 이 프롬프트를 여섯 번이나 다시 작성했습니다. 첫 번째 버전은 "문맥에 기반하여 답하라"라고만 되어 있었는데, 모델은 40%의 확률로 즐겁게 사실을 지어냈습니다 (hallucination). 명시적인 번호가 매겨진 규칙과 거절 템플릿 (refusal template)이 포함된 현재 버전은 모델이 사실을 지어내는 경우가 아마 5% 정도일 것입니다. 파이프라인 (pipeline)의 다른 부분은 전혀 변경하지 않고도 환각 (hallucinations) 현상을 8배나 줄인 것입니다.
가장 중요한 단 하나의 문장은 2번입니다: "만약 문맥 (context)에 정답이 포함되어 있지 않다면, '제공된 문서에서 이 내용을 찾을 수 없습니다'라고 말하세요." 이 정확한 거절 템플릿 (refusal template)이 없다면, 모델은 무지를 인정하기보다 차라리 추측을 하려 들 것입니다. 이 문장이 있으면 모델은 "모릅니다"라고 말할 수 있는 안전하고 문법적으로 올바른 방법을 갖게 되며, 내용을 꾸며내는 대신 그 탈출구를 선택하게 됩니다.
두 번째로 중요한 문장은 4번입니다: "어느 구절 번호에서 가져왔는지 언급하세요." 이는 모델이 제가 보낸 구조에 참여하도록 강제합니다. 제가 정답이 반드시 구절 번호를 참조해야 한다고 명령하면, 모델은 3번 구절을 의역하면서 마치 1번 구절에서 나온 것처럼 속일 수 없습니다. 이제 인용 (citations)은 검증 가능한 것이 되었습니다.
세 번째로 중요한 문장은 "아래 문맥에 있는 정보'만' 사용하세요"입니다. 그 단 한 단어 — '만' (ONLY) — 이 대부분의 역할을 수행합니다. 이 단어가 없으면 모델은 문맥을 하나의 제안으로 취급하고 자신의 학습 데이터 (training data)에 의존합니다. 이 단어가 있으면 모델은 문맥을 제약 조건 (constraint)으로 취급합니다.
파트 5 — 내가 틀렸던 것들
비용(대가)이 많이 들었던 순서대로 다섯 가지를 나열합니다.
5.1 PDF 전체를 임베딩 (Embedding) 하기
첫 번째 버전: 저는 40페이지 분량의 PDF 전체를 하나의 문서로 임베딩하고, 단일 벡터 (vector)를 대상으로 질문을 던졌습니다. 결과는 일관되게 나빴습니다. 실제로 무엇을 묻든 상관없이 모든 질문에 대해 똑같이 막연하게 관련된 구절만 반환되었습니다.
왜 그런지 알아내기 위해 세 편의 논문과 교과서 한 장을 읽어야 했습니다. 50,000자의 문서를 임베딩하는 것과 200자의 청크 (chunk)를 임베딩하는 것은 동일한 의미론적 (semantics) 벡터를 생성하지 않습니다. 문서 전체의 벡터는 평균값이며, 평균값은 구체적인 답변을 찾는 데 무용지물입니다. 청킹 (Chunking)은 최적화가 아닙니다. 청킹이 곧 알고리즘입니다.
해결책: 먼저 청킹을 하고, 청크를 임베딩하세요. 지나고 보니 당연한 이야기입니다. 처음 이 사실을 깨닫기까지 부끄러울 정도로 많은 시간이 걸렸습니다.
5.2 기본값으로 L2 거리 (L2 distance) 사용하기
ChromaDB의 기본 거리 측정 지표 (distance metric)는 L2 (Euclidean)입니다. 저는 첫 번째 버전을 기본 설정 그대로 출시했는데, 검색 결과가 "어느 정도 관련은 있지만 정말 그렇지는 않은" 상태였습니다. 거리 측정 지표가 문제라는 것을 깨닫기 전까지, 청커 (chunker)와 임베더 (embedder)를 조정하는 데 두 시간을 허비했습니다.
해결 방법은 단 한 줄입니다. 컬렉션 (collection)을 생성할 때 metadata={\"hnsw:space\": \"cosine\"}를 추가하면 됩니다. 하지만 그 증상은 "청커가 잘못되었다"거나 "임베더가 잘못되었다"는 것과 동일하게 나타납니다. 각 구성 요소가 무엇을 하는지에 대한 강력한 직관이 없다면, 몇 시간 동안 엉뚱한 계층 (layer)을 쫓게 될 수 있습니다.
교훈: 검색 결과가 좋지 않다면, 다른 무엇보다 먼저 거리 측정 지표를 확인하세요. L2와 코사인 (cosine) 유사도를 혼동했을 때 발생하는 비용은, 그것을 찾아봐야 한다는 사실을 알기 전까지는 눈에 보이지 않습니다.
5.3 "항상 답변하는" 반사 작용
시스템 프롬프트 (system prompt)의 첫 번째 버전은 "컨텍스트 (context)를 바탕으로 질문에 답하세요"라고 되어 있었습니다. 모델은 문서가 다루지 않는 질문을 포함하여 모든 질문에 답변했습니다. 예를 들어, 2024년 제품 사양서에 대해 "회사가 설립된 연도는 언제인가요?"라고 물으면, 모델은 2020년에 대한 학습 데이터가 있었기 때문에 사양서에 2020년이라는 내용이 없다는 사실을 무시하고 "2020년"이라고 답변했습니다.
해결 방법은 4부에서 논의한 거절 템플릿 (refusal template)을 사용하는 것입니다. 어려운 점은 프롬프트를 작성하는 것이 아니라, 모델이 근본적으로 신탁 (oracle)이 아니라 완성자 (completer)라는 사실을 받아들이는 것이었습니다. 좋은 프롬프트를 가진 완성자는 유용한 도구입니다. 모호한 프롬프트를 가진 완성자는 환각 엔진 (hallucination engine)입니다.
5.4 재수집 (re-ingest) 시 멱등성 (idempotency) 부재
디버깅을 하는 동안 동일한 PDF에 대해 수집 (ingest) 명령을 세 번 다시 실행했습니다. 실행할 때마다 800개의 새로운 청크 (chunk)가 추가되었습니다. 세 번의 실행 후, 동일한 쿼리에 대해 점수 순으로 정렬된 세 개의 동일한 구절이 반환되었습니다. 답변 자체는 괜찮았지만 (가장 상단의 청크가 올바른 것이었음), UI에는 중복된 내용이 표시되었습니다.
해결 방법: 파일 경로의 해시 (hash) 값으로부터 document_id를 도출하고, 이를 ChromaDB의 청크 ID 접두사 (prefix)로 사용하세요. 동일한 파일을 다시 수집하면 동일한 ID가 생성되며, ChromaDB의 .add() 메서드는 ID에 대해 멱등성 (idempotency)을 가집니다. 이는 단 5줄의 코드로 해결됩니다. 첫날에 바로 작성했어야 했습니다.
5.5 청커(Chunker)를 먼저 테스트하지 않은 것
저는 파이프라인을 하향식(top-down)으로 작성했습니다: PDF → 임베딩(embed) → 저장(store) → 쿼리(query) → 답변(answer). 테스트는 나중에 이루어졌는데, 답변이 틀렸을 때 어느 계층(layer)이 문제인지 알 수 없는 상황이었습니다. 결국 청커(chunker) 테스트를 가장 마지막에 작성하게 되었는데, 이는 순서가 잘못되었습니다.
올바른 순서는 다음과 같습니다: 청커 테스트를 먼저 수행하고(순수 함수(pure functions), I/O 없음, 네트워크 없음, 빠름), 그다음 임베더(embedder)(가짜 객체(fake)와 함께), 그다음 저장소(store)(인메모리(in-memory) ChromaDB 또는 모의 객체(mock)와 함께), 마지막으로 파이프라인(모든 것에 가짜 객체(fakes)를 사용한 통합 테스트(integration test)) 순으로 진행해야 합니다. 테스트를 마지막에 하면, 의도한 코드가 아니라 현재 작성된 코드 그대로에 대한 테스트를 작성하게 됩니다. 테스트가 잡아내지 못했기 때문에, 2주 동안 청크(chunks)의 중첩(overlap) 계산에서 오프 바이 원(off-by-one) 오류가 발생했습니다.
코드 및 실행 방법
전체 소스 코드는 github.com/ZalaAvinash/rag-from-scratch-python에서 확인할 수 있습니다. 14개의 테스트를 통과합니다. CI는 Python 3.11, 3.12, 3.13에서 실행됩니다. MIT 라이선스입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기