대부분의 RAG 데모는 노트북에서 보면 매우 훌륭해 보입니다. 스크립트에 20페이지짜리 PDF를 입력하고 질문을 던지면, 정확한 문단을 인용하는 것을 볼 수 있죠. 하지만 그 파이프라인을 그대로 프로덕션 환경에 배포하는 순간, 낭만은 끝납니다. 법률 문서는 문장 단위로 반 토막이 납니다. 방대한 API 레퍼런스는 중요한 신호를 불필요한 보일러플레이트 노이즈 속에 파묻어 버립니다. 지연 시간(latency)은 치솟습니다. 사용자는 기다리다 지쳐 떠나버립니다. 저희도 이 벽에 세게 부딪혔습니다. 그래서 저희는 검색(retrieval) 레이어를 완전히 해체하고, 정밀하게 측정 및 조정 가능한 시스템으로 재구축했습니다. 그 결과, 사용자 경험을 슬라이드쇼처럼 느리게 만들지 않으면서도 95%의 재현율(recall)을 달성하는 파이프라인을 구축할 수 있었습니다.

왜 데모용 RAG는 프로덕션에서 무너지는가

취미용 프로젝트부터 초기 단계 제품까지 표준 스택은 놀라울 정도로 획일적입니다. 고정된 토큰 청크, 기성 임베딩(off-the-shelf embeddings), 그리고 단일 벡터 검색 호출이 그것입니다. 이러한 단순함은 매력적이며, 코퍼스(corpus)가 깨끗하고 작으며 구문적으로 예측 가능할 때는 잘 작동합니다. 하지만 프로덕션 데이터는 이 중 어느 것에도 해당하지 않습니다. 512개의 고정된 토큰 청크는 SaaS 계약서의 면책 조항(indemnification clause) 중간을 아무렇지도 않게 잘라버릴 것입니다. 갑자기 검색 레이어는 언어 모델에 법적 의무의 절반만 제공하고는 책임 소재에 관한 질문에 답하라고 요구하게 됩니다. 문맥이 깨졌기 때문에 모델은 환각(hallucination)을 일으킵니다.

방대한 기술 문서는 이 문제를 더욱 악화시킵니다. API 문서는 함수 시그니처, 표, 코드 블록으로 가득 차 있습니다. 고정된 윈도우는 TypeScript 인터페이스의 중간 부분은 캡처할 수 있지만, 그 위의 함수 이름이나 아래의 사용 예시는 놓칠 수 있습니다. 결국 임베딩 벡터는 사용자가 묻고 있는 실제 기능 대신 구문 파편과 인라인 노이즈를 나타내게 됩니다. 쓰레기가 들어가면 환각이 나옵니다 (Garbage in, hallucination out).

토큰 수가 아닌 구조에 따른 청킹

저희가 내린 첫 번째 결정은 청크를 단순히 토큰의 묶음으로 생각하는 것을 그만두는 것이었습니다. 청크는 의미론적 단위(semantic units)입니다. 적절한 전략은 인덱싱하려는 대상이 무엇인지에 따라 전적으로 달라집니다.

법률 문서의 경우, 문서 계층 구조를 존중하는 재귀적 청킹(recursive chunking) 방식으로 전환했습니다. 섹션, 서브섹션, 조항(clause)을 경계로 취급합니다. 조항은 그 자체로 하나의 의미 단위이므로 온전하게 유지됩니다. 만약 조항을 잘라버리면 법적 논리가 유실됩니다.

API 문서의 경우, 구조 인식 청킹(structure-aware chunking)을 통해 함수, 클래스, 엔드포인트를 원자적(atomic) 단위로 취급합니다. 하나의 청크에는 함수 시그니처, 인자(arguments), 독스트링(docstring)이 포함될 수 있습니다. 토큰 카운터가 넘어갔다고 해서 다음 유틸리티 함수로 임의로 넘어가지 않습니다. 이를 통해 임베딩이 특정 기능에 집중되도록 유지합니다.

고객 지원 티켓은 더 복잡합니다. 대화형이며, 스레드 구조를 가지고 있고, 비선형적입니다. 고정된 청크는 동일한 스레드 내의 엔지니어 상태 업데이트와 고객 불만을 한데 묶어 마치 하나의 일관된 단위인 것처럼 취급할 것입니다. 저희는 토큰 예산이 다했을 때가 아니라 주제나 화자가 바뀔 때 나누는 의미론적 청킹(semantic chunking)으로 전환했습니다.

사내 위키는 조직 내에서 가장 정돈되지 않은 데이터인 경우가 많습니다. 서식이 일관되지 않고, 헤더가 누락되었으며, 섹션들이 뒤섞여 있습니다. 이런 경우에는 LLM 기반 청킹(LLM-based chunking)을 사용합니다. 작은 모델이 임베딩을 생성하기 전에 미리 내용을 읽고 논리적 경계를 식별합니다. 글자 수 기반 분할(character split)보다 초기 비용은 더 들지만, 검색 품질이 그 비용을 즉시 상쇄합니다.

하이브리드 검색: 신호 결합하기

벡터 검색은 강력하지만 사각지대가 있습니다. ERR_CONNECTION_REFUSED_0x800과 같은 정확한 에러 코드를 입력하면, 임베딩 공간에서 이들이 가깝게 클러스터링되어 있기 때문에 유사도 검색이 관련 없는 모듈의 문제 해결 가이드를 반환할 수 있습니다. 정확한 일치(exact match)는 중요하며, 벡터 검색만으로는 이를 무시하고 뭉뚱그릴 수 있습니다.

BM25를 이용한 키워드 검색은 정확한 일치 문제를 아주 멋지게 해결합니다. 하지만 개념적 거리(conceptual distance) 문제에는 취약합니다. 사용자가 "부하가 높은 상황에서의 성능 저하"에 대해 묻는다면, BM25는 "트래픽 급증 시 처리량 저하"를 설명하는 진단 노트를 키워드 중복이 부족하다는 이유로 놓칠 것입니다.

저희는 어느 한쪽을 선택하는 대신 두 방식을 병렬로 실행하기 시작했습니다. 벡터 검색과 키워드 검색은 각각 고유한 순위 목록을 반환합니다. 저희는 이를 Reciprocal Rank Fusion(RRF)으로 병합합니다. RRF는 단순하면서도 그 효과가 매우 강력합니다. 각 목록에서 문서가 위치한 지점을 기반으로 점수를 매깁니다. 두 시스템 모두에서 상위에 위치한 문서는 엄청난 가산점을 받습니다. 한 엔진에서만 높게 평가된 문서라도 최종 후보군에 포함될 자격을 얻습니다.

퓨전 이후, 상위 후보군을 크로스 인코더 리랭커(cross-encoder reranker)로 다시 순위를 매깁니다. 이는 공짜가 아닙니다. 약 50밀리초의 연산 시간이 추가됩니다. 하지만 재현율(recall)을 15% 높여줍니다. 크로스 인코더는 전체 쿼리와 각 후보 청크를 함께 평가하여, 바이 인코더(bi-encoder) 임베딩이 결코 도달할 수 없는 훨씬 더 미묘하고 정교한 관련성 점수를 생성합니다. 그 추가된 50밀리초는 충분히 가치 있는 투자입니다. LLM에 쓰레기 같은 컨텍스트 윈도우를 전달하여, 혼란스럽거나 환각을 일으킨 답변을 기다리느라 2초를 허비하는 일을 방지해주기 때문입니다.

검색하기 전에 쿼리를 수정하세요

사용자는 검색 엔지니어처럼 쿼리를 작성하지 않습니다. "앱 고장남"이라고 입력하거나, 알 수 없는 로그 조각을 붙여넣기도 합니다. 모호하고 불분명한 질문을 던지기도 하죠. 이런 가공되지 않은 문자열을 인덱스에 그대로 보내면, 쓰레기 같은 결과가 돌아옵니다.

우리는 검색 엔진에 닿기 전에 모든 쿼리를 변환합니다.

첫째, 쿼리 확장(query expansion)입니다. 시스템은 하나의 짧은 질문으로부터 여러 개의 검색어를 생성합니다. 사용자가 "타임아웃을 어떻게 해결하나요?"라고 물으면, 엔진은 이를 연결 타임아웃, 읽기 타임아웃, 게이트웨이 타임아웃 및 재시도 로직을 포함하도록 확장합니다. 이 방식 하나만으로 우리의 재현율은 78%에서 96%로 올라갔습니다.

둘째, 쿼리 분해(query decomposition)입니다. 복잡한 질문은 더 작은 하위 질문들로 나뉩니다. "90일이 지난 기업 고객의 환불 정책은 무엇이며, 월간 요금제와 어떻게 다른가요?"와 같은 쿼리는 하나의 거대한 임베딩 조회 대신 두 개의 집중된 검색으로 나뉩니다. 각 하위 질문은 독립적으로 인덱스를 조회하며, 결과는 다운스트림에서 다시 하나로 합쳐집니다. 이를 통해 검색 범위를 좁고 정밀하게 유지할 수 있으며, 단일 임베딩이 한꺼번에 수십 개의 개념을 매칭하려 할 때 발생하는 정보 희석 현상을 막을 수 있습니다.

베이지안 검색으로 파이프라인을 튜닝하세요

만약 여전히 청크 크기, 오버랩 비율, 검색 가중치를 수동으로 조정하고 있다면, 성능을 제대로 끌어내지 못하고 있는 것입니다. 우리는 추측하는 것을 멈췄습니다.

우리는 청크 크기, 오버랩 비율, 벡터 대 BM25 가중치, 리랭커 임계값(threshold)이 모두 변수인 검색 공간을 정의했습니다. 그런 다음 베이지안 최적화(Bayesian optimization)를 적용했습니다. 수백 개의 무작위 설정을 그리드 서치(grid-searching)하는 대신, 베이지안 검색은 무엇이 효과적인지에 대한 확률 모델을 구축합니다. 모델은 설정을 제안하고, 재현율과 지연 시간을 관찰한 뒤, 자신의 믿음을 업데이트하고 다음 설정을 제안합니다. 시간이 흐름에 따라, 사람이 수동으로는 절대 찾아낼 수 없는 균형점을 찾아 수렴하게 됩니다.

베이지안 검색은 우리가 시도조차 하지 않았을 조합들을 찾아냈습니다. 더 많은 오버랩을 가진 더 작은 청크, 밀집 벡터 검색(dense vector search)의 가중치를 약간 낮추면서 더 공격적인 리랭커 임계값을 결합하는 방식 등입니다. 이러한 직관적이지 않은 트레이드오프(tradeoffs)를 통해 우리는 더 높은 재현율과 더 낮은 지연 시간을 동시에 얻을 수 있었습니다.

이것은 일회성 설정 작업이 아닙니다. 우리는 매달 하이퍼파라미터 최적화를 다시 실행합니다. 코퍼스는 변하고, 사용자 행동은 바뀝니다. 여러분의 파이프라인은 제자리에 녹슬어 있는 대신 적응해야 합니다.

성과

재구축을 통한 결과는 반박의 여지가 없습니다.

10위권 내 재현율(Recall at position ten)은 78%에서 95%로 상승했습니다. 정답이 지식 베이스에 있는 경우, 20번 중 19번은 정답을 찾아냅니다. 95퍼센타일(95th percentile) 지연 시간은 850밀리초에서 320밀리초로 줄었습니다. 채팅이 답답하지 않고 즉각적으로 느껴집니다.

더 나은 검색은 언어 모델에 더 나은 그라운딩(grounding)을 제공했습니다. 환각 발생률은 12%에서 3%로 떨어졌습니다. 모델이 눈앞에 올바른 컨텍스트를 갖게 되면, 사실을 지어내지 않습니다. 쿼리당 비용은 38% 감소했습니다. 더 빠르고 정교한 검색은 무관한 컨텍스트, 재시도 루프, 그리고 장황하지만 쓸모없는 프롬프트에 낭비되는 토큰을 줄여줍니다.

인프라처럼 구축하세요

프로토타입에서 프로덕션 단계로 넘어가고 있다면, 검색을 단순한 설정이 아닌 인프라 코드로 취급하십시오.