대부분의 RAG 프로토타입은 내부적으로 보면 다 비슷비슷합니다. 누군가 PDF를 파이프라인에 넣고, 텍스트를 깔끔하게 512토큰 단위의 청크로 자른 뒤, 벡터 데이터베이스에 쏟아붓고 나면 작업이 끝났다고 생각합니다. 단순한 시연용 데모에서는 이것이 인상적으로 보일 수 있습니다. 하지만 실제 운영 환경에서는 무너집니다.
고정된 크기의 청크는 무엇을 자르는지 상관하지 않습니다. 법률 계약서의 면책 조항 중간을 툭 잘라버릴 수도 있습니다. 서로 관련 없는 다섯 개의 API 엔드포인트를 동일한 컨텍스트 윈도우에 몰아넣어 모델을 노이즈로 뒤덮어 버릴 수도 있습니다. 또한 불필요하게 더 많은 파편을 검색하게 만들어 지연 시간을 늘리고 토큰을 낭비하게 만듭니다. 그 결과는 불완전한 답변, 환각 현상, 그리고 사용자의 불만으로 이어집니다.
우리는 검색 레이어를 완전히 해체하고 처음부터 다시 구축했습니다. 그 결과, 지연 시간은 40% 줄이면서 재현율(recall)은 95%에 달하는 시스템을 만들었습니다. 저희가 정확히 어떻게 했는지 소개합니다.
왜 고정된 청크 방식은 운영 환경에서 실패하는가
512토큰 기본값은 설계 의도가 아닙니다. 이는 초기 임베딩 모델의 컨텍스트 윈도우 크기와 라이브러리의 편리한 기본값에서 비롯된 부산물일 뿐입니다. 구현하기는 쉽지만, 이를 그대로 믿고 의존하는 것은 재앙입니다.
문서는 균일하지 않습니다. 법률 조항은 명확한 구분 없이 700토큰 이상 이어질 수 있습니다. 이를 512토큰에서 잘라버리면 두 개의 고립된 파편이 생성됩니다. 변호사나 컴플라이언스 담당자가 책임 한도에 대해 물으면, 시스템은 의무 사항의 절반만 반환합니다. 그러면 언어 모델은 누락된 절반을 환각으로 채워 넣거나, 더 나쁘게는 책임 한도 자체가 존재하지 않는다고 부정해 버립니다.
API 문서는 정반대의 문제를 겪습니다. 500토큰짜리 청크 하나가 인증 헤더, 에러 코드, 속도 제한(rate limits), 웹훅 스키마가 포함된 모듈 전체를 삼켜버릴 수 있습니다. 개발자가 AUTH_4027을 처리하는 방법을 물으면, 검색기는 관련 없는 함수들이 뒤섞인 결과물을 내놓습니다. 모델은 이를 일반적인 뭉텅이로 평균화하여 답변할 수밖에 없습니다.
잘못된 청킹은 지연 시간도 늘립니다. 파편이 부실하면 주제를 포괄하기 위해 더 큰 top-k 값이 필요합니다. 청크가 많아지면 프롬프트가 길어집니다. 프롬프트가 길어지면 생성 속도가 느려지고 비용은 높아집니다. 사용자 경험은 수많은 작은 문제들이 쌓여 서서히 무너집니다.
문서에 맞는 청크 전략
우리는 토큰 수를 세는 것을 멈추고 문서의 내용을 읽기 시작했습니다. 적절한 청킹 전략은 소스 문서의 구조에 달려 있습니다.
법률 문서에는 조항을 인식하는 경계(clause-aware boundaries)를 가진 재귀적 문자 청킹(recursive character chunking)이 필요합니다. 스플리터는 계층 구조를 존중해야 합니다. 먼저 섹션 헤더를 찾고, 그다음 번호가 매겨진 단락, 그다음 자연스러운 문장 끊김을 찾습니다. 하위 조항을 절단하거나 의무 문구를 청크 간에 나누지 않아야 합니다. 면책에 관한 구절을 검색할 때, 조항 전체와 한도, 그리고 예외 사항까지 한꺼번에 가져올 수 있어야 합니다.
API 문서는 구조 인식 청킹(structure-aware chunking)을 요구합니다. 토큰 예산이 아니라 함수 정의를 기준으로 파싱합니다. 각 청크에는 완전한 함수 시그니처, 매개변수 설명, 그리고 바로 인접한 에러 처리 노트가 포함되어야 합니다. 개발자가 특정 메서드를 검색하면, 임의의 분할 지점에 걸린 파편이 아니라 전체 계약 내용을 전달받게 됩니다.
고객 지원 티켓은 노이즈가 많고 비선형적입니다. 하나의 스레드가 버그 보고로 시작해 해결 방법을 제시하고, 내부 에스컬레이션 노트로 끝날 수도 있습니다. 의미론적 청킹(semantic chunking)은 문장 간의 임베딩 유사도를 측정하여 주제의 전환을 감지합니다. 자연스러운 주제 경계에서만 끊어지도록 하여, 로그인 실패에 대한 대화가 결제 주기 관련 후속 질문과 섞이지 않도록 합니다.
**위키(Wikis)**가 가장 어려웠습니다. 위키는 방대하고, 서로 링크되어 있으며, 느슨하게 조직되어 있습니다. 우리는 에이전트 기반 청킹(agentic chunking)을 사용했습니다. 경량 LLM이 페이지를 읽고 주제적 일관성에 따라 분할 지점을 결정하는 방식입니다. 데이터 수집 시 비용은 약간 더 들지만, 결과물인 청크는 그 자체로 완결성을 갖추며 즉시 검색에 사용할 수 있습니다. 배포 모범 사례에 관한 페이지는 임의의 텍스트 블록이 아니라 사전 점검, 롤백 절차, 모니터링 설정과 같은 논리적 단위로 나뉩니다.
하이브리드 검색: 키워드와 벡터의 결합
밀집 벡터 검색(Dense vector search)은 의미를 이해합니다. 하지만 정확한 문자열 매칭에는 취약합니다. 사용자가 AUTH_4027과 같은 정확한 에러 코드나 "Stark Industries"와 같은 고객 이름을 검색할 경우, 벡터 임베딩은 개념적 근접성을 최적화하기 때문에 문자 수준의 정확도를 놓칠 수 있습니다.
BM25를 통한 순수 키워드 검색은 그 반대의 결함이 있습니다. AUTH_4027은 완벽하게 찾아내지만, "인증 실패(authorization failure)"와 "로그인 거부(login denied)" 사이의 개념적 연결 고리는 놓치게 됩니다.
We run both in parallel. BM25 and vector search operate independently over the same corpus. Their result lists are merged using Reciprocal Rank Fusion, which reorders candidates by balancing their positional ranks. You do not need calibrated weights. You simply get the precision of exact match and the intuition of semantic search in a single ranked list.
Then we add a cross-encoder reranker. This is a separate model that scores each passage against the original query, producing a relevance signal far finer than either retriever alone. It adds about 50 milliseconds of latency. It increases recall by 15 percent. If you care about answer quality, that trade is non-negotiable.
Query Expansion: Fix the Search Before It Starts
Bad queries are the dirty secret of every retrieval system. Users do not write like your embedding space. They type "it broke." They paste truncated stack traces. They use internal jargon your index has never seen.
We transform the query before it ever touches the index. First, we expand a single query into three to five diverse search terms. If the original is "payment failed," we also search for "transaction error," "billing declined," and "charge unsuccessful
