RAG 파이프라인 구축하기: SmartQueue를 통해 배운 검색(Retrieval)에 대한 교훈
요약
분산 작업 큐인 SmartQueue에 AI 어시스턴트를 통합하며 겪은 RAG 파이프라인 구축 경험을 공유합니다. 벡터 검색의 한계를 극복하기 위한 아키텍처 결정과 BM25 검색 도입, 그리고 실제 배포 환경에서의 최적화 과정을 다룹니다.
핵심 포인트
- 단순 LLM 호출이 아닌 내부 지식 기반의 RAG 구현 필요성
- BM25 검색과 시스템 프롬프트 조립을 통한 컨텍스트 제공
- ChromaDB 사용 시 발생한 배포 및 리소스 관리 이슈
- 프롬프트 인젝션 방지 및 세션 메모리 관리 전략
이미 Go 언어로 구축하여 IT 지원 티켓을 처리하고 있던 분산 작업 큐(distributed task queue)인 SmartQueue에 AI 어시스턴트를 추가하기로 했을 때, 가장 뻔한 방법은 LLM을 그냥 붙이고 끝내는 것이었습니다. 질문을 입력하면 답변을 받는 식이죠. 하지만 일반적인 LLM은 귀사의 비밀번호 재설정 절차, P1 장애 대응 매뉴얼(runbook), 또는 500달러 이상의 환불에는 관리자 승인이 필요하다는 사실 등을 알지 못합니다. 실제 내부 지식에 기반한 근거(grounding)가 필요했습니다. 이것이 바로 검색 증강 생성 (RAG, retrieval-augmented generation)이 존재하는 이유입니다. 모델이 귀사의 비즈니스를 알고 있다고 믿는 대신, 먼저 귀하의 문서에서 관련 사실을 추출한 다음 이를 컨텍스트 (context)로 모델에 전달하는 것입니다.
이 포스트에서는 해당 파이프라인이 실제로 어떻게 작동하는지, 진행 도중에 제가 뒤집었던 아키텍처 결정(그리고 그 이유), 검색 깊이(retrieval depth) 및 온도(temperature)와 같은 설정값으로 선택한 수치들, 그리고 이 모든 것이 "진정한" RAG라고 할 수 있는지에 대한 솔직한 견해를 다룹니다.
어시스턴트가 실제로 하는 일
SmartQueue Bot은 대시보드의 Queue Health 및 AI Bot 탭 내에 존재합니다. 에이전트(agent)가 티켓을 선택하고 "이 데이터베이스 장애에 대한 즉각적인 조치 단계는 무엇인가요?"와 같은 질문을 던지면, 봇은 IT 운영 매뉴얼(runbooks)의 작은 내부 지식 베이스에 근거하여 답변을 토큰 단위로 스트리밍합니다. 요청 흐름은 다음과 같습니다:
에이전트 질문
|
v
프롬프트 인젝션 체크 (prompt-injection check, 정규식 가드레일)
|
v
10개의 운영 매뉴얼에 대한 BM25 검색 --> 상위 4개 매칭 결과
|
v
시스템 프롬프트 조립: 티켓 컨텍스트 + 운영 매뉴얼 발췌본
|
v
Groq (LLaMA 3.3 70B)를 통해 SSE로 스트리밍, 마지막 10회의 세션 히스토리 포함
|
v
클라이언트로 응답 스트리밍 + Redis 세션 메모리에 기록
텍스트가 모델에 도달하기 전에 세 가지 작업이 수행됩니다. 사용자의 메시지에 프롬프트 인젝션 시도가 있는지 확인하고, 메시지를 지식 베이스에 대한 쿼리로 사용하며, 상위 매칭 결과들을 티켓의 카테고리, 우선순위 및 설명과 함께 시스템 프롬프트에 엮어 넣습니다. 모델은 이러한 프레임워크(framing) 없이는 가공되지 않은 문서를 절대 보지 않습니다. 대신 구조화된 요약본을 보게 됩니다.
내가 번역한 결정: ChromaDB, 그리고 BM25
지식 베이스(knowledge base)의 첫 번째 버전은 기본 ONNX 임베딩 함수(embedding function)를 사용하는 ChromaDB를 사용했습니다. 적절한 벡터 검색(vector search)이 가능했고, PyTorch(torch) 의존성이 없었으며, 이벤트 루프(event loop)를 차단하지 않도록 스레드 풀(thread pool)을 통해 쿼리되었습니다. 이는 교과서적인 RAG 설정이었고, 로컬 환경에서는 잘 작동했습니다. 하지만 전체 스택을 Hugging Face Spaces의 단일 컨테이너로 배포하려고 시도한 순간 무너졌습니다.
배포 방식은 supervisord를 사용하여 Redis, Go API, 두 개의 Go 워커(worker) 복제본, 그리고 FastAPI AI 서비스를 모두 하나의 컨테이너 안에서 실행했으며, 원래는 이들과 함께 별도의 ChromaDB 프로세스도 실행했습니다. 이는 무료 티어 컨테이너의 적은 메모리와 CPU를 두고 다섯 개의 롱러닝 프로세스(long-running processes)가 경쟁하는 구조였고, supervisord는 이들을 올바른 순서로 시작하고 유지하는 역할을 맡았습니다. ChromaDB는 계속해서 시작 시점의 레이스 컨디션(race condition)과 조용한 실패(silent failures)를 일으키는 주범이었습니다. "fix: remove ChromaDB from supervisord" 및 "fix: replace ChromaDB with in-memory BM25 search"와 같은 메시지가 담긴 커밋을 수없이 남긴 끝에, 저는 이를 완전히 제거하기로 결정했습니다.
교체된 코드는 임베딩 모델도, 외부 프로세스도, 네트워크 호출도 없는 순수 Python 코드 약 50줄 정도입니다:
def _bm25_score(query_tokens, doc_tokens, k1=1.5, b=0.75):
avg_dl = sum(len(d) for d in _CORPUS) / len(_CORPUS)
tf = Counter(doc_tokens)
...
이것은 표준 Okapi BM25 공식이며, 매 쿼리마다 메모리 내의 런북 코퍼스(runbook corpus)를 대상으로 새로 계산됩니다. 구축해야 할 인덱스도 없고, 계속 실행해 두어야 할 데몬(daemon)도 없으며, 콜드 스타트(cold start) 시의 임베딩 지연 시간(embedding latency)도 없습니다. 트레이드오프(trade-off)는 분명히 존재합니다. BM25는 오직 용어 중첩(term overlap)에만 매칭되기 때문에, 런북의 문구와 매우 다르게 표현된 쿼리(유의어, 의역 등)는 높은 점수를 받지 못합니다. 하지만 사용자들이 보통 런북에서 사용하는 것과 동일한 어휘("VPN", "비밀번호 재설정", "장애")로 검색하는, 키워드가 밀집된 10개의 짧은 IT 런북이라는 고정된 세트의 경우, 이러한 약점은 실제 상황에서 거의 나타나지 않습니다. 이 규모에서 검색 품질(retrieval quality)보다 더 중요했던 점은 이제 서비스가 매번 안정적으로 시작된다는 것이었습니다.
수치들, 그리고 그 수치들이 의미하는 것
이 파이프라인의 몇몇 상수들은 제가 건드리지 않고 남겨둔 기본값이 아니라, 의도적인 튜닝(tuning) 결정의 결과입니다. 이것은 정밀도(precision)/재현율(recall)/충실도(faithfulness) 점수를 사용하는 RAGAS 스타일의 평가가 아닙니다. 여기에는 평가 하네스(eval harness)가 없으며, 단지 제가 처한 제약 조건(무료 티어 LLM 제공업체, 단일 데모 컨테이너, 그리고 변하지 않는 지식 베이스)을 바탕으로 한 시스템 수준의 튜닝이 있을 뿐입니다.
| 상수 | 값 | 이유 |
|---|---|---|
검색된 문서 (k) | 4 | 800토큰 응답 예산 내에서 프롬프트(prompt)를 비대하게 만들지 않으면서도, 대개 정답을 커버할 수 있는 충분한 런북 컨텍스트(context) |
| ... |
마지막 항목은 깊이 생각해 볼 가치가 있습니다. 이 시스템의 모든 AI 기반 엔드포인트(endpoint)에는 LLM이 아닌 폴백(fallback) 경로가 있습니다. 만약 Groq의 속도 제한(rate-limited)이 걸리거나 다운되면, 분류기(classifier)는 키워드 매칭으로 폴백하고, 추천기(recommender)는 큐 깊이(queue depth)에 기반한 임계값 규칙으로 폴백하며, 봇(bot)은 동일하게 검색된 런북 발췌문으로 구성된 템플릿 응답으로 폴백합니다. 시스템은 실패하는 것이 아니라 성능이 저하(degrade)되도록 설계되었습니다. 이는 유료 SLA(Service Level Agreement) 보장 티어를 사용할 때보다 무료 API 티어에서 실행할 때 훨씬 더 중요합니다.
이것이 실제로 "RAG"인가, 그리고 더 나은가?
엄밀히 말하면, 그렇습니다. 생성하기 전에 검색을 수행하며, 생성 과정이 검색된 내용에 따라 조건화(conditioned)되기 때문입니다. 하지만 이는 RAG가 의미할 수 있는 범위의 아주 좁은 단면일 뿐입니다. 청킹 (chunking)도 없고 (각 런북은 하나의 평면적인 문서로 임베딩됨), 재순위화 (re-ranking) 단계도 없으며, 하이브리드 검색 (hybrid retrieval)도 없고, 주어진 질문에 대해 올바른 런북이 실제로 나타났는지 알려주는 평가 루프 (evaluation loop)도 없습니다. 이는 문제의 규모에 맞게 적절히 조정된 RAG입니다. 즉, 더 정교한 시스템을 구축하는 비용이 이점보다 더 클 수 있는 작고 정적이며 키워드 친화적인 지식 베이스(knowledge base)에 최적화된 형태입니다.
ChromaDB 기반의 BM25가 "더 나았는지" 여부는 무엇을 최적화하느냐에 따라 달라집니다. 더 크고 다양한 코퍼스 (corpus)에서의 검색 품질을 위해서는 임베딩 기반 (embedding-based) 접근 방식이 승리할 것입니다. 질문이 문서 자체의 어휘를 더 이상 재사용하지 않게 되면 BM25의 성능이 저하되기 때문입니다. 하지만 이 배포 환경에서는, 이 정도의 지식 베이스 규모와 호스팅 제약 조건을 고려할 때 벡터 스토어 (vector store)를 제거한 것은 명백히 옳은 결정이었습니다. 이는 한 종류의 배포 실패 가능성을 완전히 제거했으며, 10개의 짧은 문서로는 임베딩을 통해 해결할 필요가 없는 문제에 대한 의존성을 없애주었습니다.
만약 시스템을 재구축하는 대신 확장한다면, 다음 단계의 실질적인 업그레이드는 기본적인 검색 평가 (retrieval eval) (예를 들어, "라벨링된 테스트 질문 세트에 대해 올바른 런북이 상위 4위 안에 포함되었는가" 정도), 더 긴 런북을 더 작은 청크 (chunks)로 나누어 모델이 검색된 슬롯당 더 관련성 높은 텍스트를 얻을 수 있도록 하는 것, 그리고 지식 베이스가 약 50개 문서 이상으로 성장했을 때의 하이브리드 접근 방식이 될 것입니다. 그 정도 규모가 되면, 순수 키워드 중첩 (keyword overlap)만으로는 벡터 검색 (vector search)이 무료로 처리해 주는 의역된 쿼리 (paraphrased queries)를 잡아내기에 충분하지 않게 됩니다.
그것은 제가 별도의 프로젝트인 AskMyDoc에서 진행했던 방향과도 대략적으로 일치합니다. 저는 그 프로젝트에서 BM25와 ChromaDB를 하이브리드 검색기 (hybrid retriever)로 결합하고, 어휘 차이 (vocabulary gap)를 메우기 위해 HyDE 스타일의 쿼리 재작성 (query rewriting)을 추가했으며, 단순히 눈으로 확인하는 대신 검색 품질을 실제로 측정하기 위해 RAGAS 기반의 평가 하네스 (evaluation harness)를 구축했습니다. SmartQueue의 BM25 전용 파이프라인은 10개의 문서로 구성된 단일 컨테이너 기반의 헬프데스크 데모에는 적합한 도구였습니다. 하지만 지식 베이스 (knowledge base)가 10개가 아니라 1,000개의 문서라면 제가 선택할 파이프라인은 아닙니다. 다만 그 차이를 알고, 막연한 예감이 아닌 실제 배포 실패를 통해 이를 정당화할 수 있다는 점이 이 프로젝트가 저에게 준 실제 교훈입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기