본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 04. 15:05

도서 시리즈를 대상으로 하는 프로덕션급 RAG 구축: 검색(Retrieval), 재순위화(Reranking), 그리고 뼈아픈 교훈들

요약

도서 시리즈를 대상으로 한 프로덕션급 RAG 시스템 구축 사례를 다룹니다. SQLite FTS5를 활용한 어휘 검색의 가치와 RAG를 보완적 레이어로 사용하는 아키텍처 설계 및 검색 파이프라인 구축 경험을 공유합니다.

핵심 포인트

  • 어휘 검색(FTS5)은 벡터 검색의 대체제가 아닌 강력한 보완 도구임
  • 마이크로서비스 구조를 통해 검색 엔진과 RAG 서비스의 결합도를 낮춤
  • 단순 임베딩 기반 검색의 한계를 극복하기 위한 파이프라인 설계 필요성

저는 A Song of Ice and Fire 시리즈 전체, 즉 10권의 도서와 약 66,000개의 단락을 대상으로 하는 검색 및 Q&A 시스템을 구축했습니다. 이 프로젝트의 이름은 Uma Busca de Gelo e Fogo이며, buscadegeloefogo.vercel.app에서 확인하실 수 있습니다.

이 시스템은 두 가지 모드를 제공합니다. 하나는 클래식한 전체 텍스트 검색(Full-text search) 엔진이고, 다른 하나는 자연어로 질문하면 실제 텍스트에 근거하여 답변을 제공하는 RAG(Retrieval-Augmented Generation) 기반의 채팅입니다. 이 글은 두 번째 부분인 검색 파이프라인(Retrieval pipeline), 그 이면에 담긴 결정들, 그리고 처음부터 명백히 맞다고 생각했던 것들을 수정하느라 허비한 민망할 정도의 시간에 대해 다룹니다.

시스템 개요

세 개의 독립적인 마이크로서비스(Microservices):

구성 요소역할스택배포
Backend전체 텍스트 검색 엔진 + RAG 프록시Fastify + SQLite FTS5 + TypeScriptRender (Docker)
...

백엔드(Backend)는 어휘 검색(Lexical search)을 처리하며 프론트엔드와 RAG 마이크로서비스 사이의 프록시(Proxy) 역할도 수행합니다. RAG 서비스는 별도로 존재하며, 연산 집약적(Compute-heavy)이고 나머지 부분과 독립적으로 장애가 발생할 수 있어야 합니다. 만약 RAG가 다운되더라도 검색 엔진은 여전히 작동합니다. 이러한 격리(Isolation) 덕분에 개발 과정에서 여러 번 도움을 받았습니다.

이 글은 전적으로 RAG 서비스에 초점을 맞춥니다.

왜 그냥 FTS5만 사용하지 않는가?

여기서 저는 확고한 의견을 가지고 있습니다. 사람들은 어휘 검색(Lexical retrieval)의 가치를 과소평가하는 경향이 매우 큽니다. 이 정도 규모의 코퍼스(Corpus)라면, unicode61 토크나이저(Tokenizer)를 사용하는 SQLite FTS5는 믿기지 않을 정도로 훌륭합니다. 인프라 오버헤드 없이 약 50MB 파일 내에서 분음 기호(Diacritics) 처리, NEAR를 통한 다중 용어 근접 쿼리(Multi-term proximity queries), 그리고 snippet() 하이라이팅을 모두 처리할 수 있습니다. 저는 너무 많은 RAG 프로젝트들이 잘 설정된 전체 텍스트 검색 엔진으로 이미 문제를 해결할 수 있는지 진지하게 고민하기도 전에 벡터 데이터베이스(Vector databases)를 먼저 찾으려 한다고 생각합니다.

이 프로젝트의 경우, FTS5가 문제의 대부분을 해결합니다. 만약 _"Dracarys"_를 검색하면, FTS5는 모든 관련 단락을 즉시 찾아냅니다. 도서별, 시점 캐릭터(POV character)별 필터링, 컨텍스트 확장까지, 그러면 끝입니다.

하지만 명확한 한계가 존재합니다. 만약 _"존 스노우(Jon Snow)의 형제들은 왜 그를 배신했는가?"_라고 질문한다면, 관련 구절과 깔끔하게 매칭되는 쿼리 용어(query term)가 없습니다. 정답은 여러 장(chapter)에 걸쳐 분산되어 있고, 다양한 방식으로 표현되어 있으며, 단일 단락 내에 명시적으로 언급되지 않습니다. FTS5는 이 지점에서 아무런 도움을 줄 수 없습니다.

그것이 바로 RAG가 해결하는 문제입니다. RAG는 대체제가 아니라, 다른 유형의 질문들을 처리하기 위한 보완적인 레이어(layer)로서 작동합니다.

검색 파이프라인 (The Retrieval Pipeline)

저의 첫 번째 버전은 부끄러울 정도로 순진했습니다. 모든 청크(chunk)를 임베딩(embedding)하고, ChromaDB에 저장한 뒤, 코사인 유사도(cosine similarity)로 조회하면 끝이었습니다. 초기 테스트에서는 간단한 질문들만 던졌기 때문에 괜찮아 보였습니다. 하지만 간접적인 표현을 사용하거나, 정답이 단일 청크에 문자 그대로 명시되어 있지 않은 질문을 던지는 순간 품질이 무너졌습니다. 주제적으로는 인접하지만 사실 관계는 무관한 청크들이 검색되었고, 모델은 그 정보들을 바탕으로 잘못된 답변을 자신 있게 합성해냈습니다.

코사인 유사도만으로는 충분하지 않다는 사실을 받아들이기 전까지, 저는 인정하고 싶지 않을 만큼 긴 시간을 검색 결과물을 뚫어지게 쳐다보며 보냈습니다. 결국 제가 구축한 파이프라인은 다음과 같습니다:

사용자 질문 (User question)
  │
  ├─ 1. 밀집 검색 (Dense retrieval)    → bge-m3 임베딩 (embedding) → ChromaDB (코사인 유사도, 상위 60개)
...

밀집 검색 (Dense Retrieval): bge-m3

임베딩 모델은 BAAI/bge-m3를 사용합니다. 다국어 지원은 타협할 수 없는 조건이었습니다. 코퍼스(corpus)는 포르투갈어로 되어 있지만, 사용자는 영어, 포르투갈어, 또는 때로는 한 문장 안에 두 언어를 섞어서 질문하기 때문입니다. bge-m3는 이를 잘 처리합니다.

BGE 문서를 주의 깊게 읽고 나서야 발견한 한 가지 사실은, 이 모델들이 지시어 튜닝된 (instruction-tuned) 임베딩을 지원한다는 점입니다. 검색을 위해서는 쿼리에 다음과 같은 접두사(prefix)를 사용해야 합니다:

"Represent this sentence for searching relevant passages: {question}"

이것은 단순히 외관상의 문제가 아닙니다. 이 접두사는 모델에게 임베딩이 일반적인 의미론적 유사성(semantic similarity)이 아니라, 구체적으로 문서 검색(document retrieval)에 최적화되어야 함을 알려줍니다. 처음에는 이것이 단순한 상용구(boilerplate)처럼 보여서 건너뛰었습니다. 하지만 그렇지 않았습니다. 접두사를 생략하면 검색 정렬(retrieval alignment) 성능이 눈에 띄게 저하됩니다.

희소 검색 (Sparse Retrieval): BM25

밀집 검색 (Dense retrieval)은 의역 (paraphrase)과 의미적 유사성 (semantic similarity)에는 능숙합니다. 하지만 희귀 단어나 고유 명사 (proper nouns)에 대한 정확한 일치 (exact matching)에는 취약합니다. 판타지 시리즈에서는 이것이 심각한 문제입니다. "Casterly Rock", "Daenerys Stormborn", "R'hllor" — 이러한 것들은 바이-인코더 (bi-encoder)가 우아하게 일반화할 수 있는 개념이 아닙니다. BM25는 이를 정확하게 처리하며, 비용도 사실상 제로에 가깝습니다.

두 방식을 병렬로 실행하는 것은 각 방법론의 명백한 약점을 보완하는 과정입니다.

융합 (Fusion): 상호 순위 융합 (Reciprocal Rank Fusion)

RRF는 점수 정규화 (score normalization)를 요구하지 않고 두 개의 순위 목록을 병합합니다. 공식은 다음과 같습니다:

score(doc) = Σ 1 / (K + rank(doc))

K=60일 때, 어느 한 방법론에 의해서라도 높은 순위를 받은 문서는 강력한 부스트를 받습니다. 두 방법 모두에서 낮은 순위를 받은 문서는 필터링됩니다. 원시 점수 (raw score) 대신 순위 (rank)를 사용하는 이유는 BM25 점수와 코사인 유사도 (cosine similarities)가 완전히 다른 스케일 상에 존재하기 때문입니다. 즉, 단순히 두 값을 더할 수 없습니다. RRF는 이 문제를 완전히 우회합니다.

처음에는 정규화된 점수의 가중 선형 결합 (weighted linear combination)을 시도했습니다. 하지만 결과는 더 나빴고 튜닝하기도 훨씬 어려웠습니다. RRF가 더 단순하고 견고합니다.

재순위화 (Reranking): 크로스-인코더 (Cross-Encoder)

바이-인코더 (bi-encoder)는 쿼리 (query)와 문서 (document)의 임베딩 (embeddings)을 독립적으로 계산하고 코사인 유사도를 통해 비교합니다. 문서 임베딩을 한 번만 계산하여 인덱싱하면 되기 때문에 매우 빠릅니다. 하지만 이는 손실이 있는 근사치 (lossy approximation)이며, 점수를 매기는 동안 쿼리와 문서 토큰 (tokens) 간의 직접적인 상호작용이 없습니다.

크로스-인코더 (cross-encoder)는 다릅니다. 연결된(concatenated) 쿼리와 문서를 입력으로 받아 두 토큰 사이의 완전한 어텐션 (full attention)을 통해 점수를 매깁니다. 이는 유의미하게 더 정확합니다. 또한 속도는 수십 배 더 느리기 때문에, 66,000개의 문서를 대상으로 실행할 수는 없습니다.

해결책은 RRF를 통해 뽑힌 상위 40개의 후보군에 대해서만 실행하는 것입니다. 그 정도 규모라면 충분히 빠르지만, 코퍼스 (corpus) 규모에서는 사용이 불가능할 것입니다. 모델은 BAAI/bge-reranker-v2-m3로, bge-m3와 동일한 계열의 다국어 크로스-인코더입니다.

재순위화가 끝나면, 상위 20개의 청크 (chunks)가 생성 프롬프트 (generation prompt)로 들어갑니다.

청킹 (Chunking): 가장 많은 시간을 허비한 지점

임베딩 파이프라인 (embedding pipeline)은 슬라이딩 윈도우 (sliding window) 방식을 사용하여 약 66,000개의 단락에 대해 실행됩니다: 청크 (chunk)당 5개의 문장, 스트라이드 (stride)는 3입니다. 인접한 청크들은 2개의 문장이 중첩 (overlap)됩니다.

저는 처음부터 이렇게 시작하지 않았습니다. 대부분의 튜토리얼이 보여주는 방식인 고정 문자 수 분할 (fixed character splits)로 시작했는데, 튜토리얼은 정확하기보다는 단순하게 작성되기 마련입니다. 고정 문자 수 분할은 문장을 중간에 잘라버리는 일이 빈번하게 발생합니다. 청크가 문장 중간에서 끝나게 되면, 임베딩 (embedding)은 결론이 나지 않은 생각의 시작 부분만을 포착하게 되며, 이로 인해 검색 (retrieval) 성능이 저하됩니다. 이는 청크를 출력해 보았을 때는 멀쩡해 보이기 때문에 실제로 진단하기가 정말 어려운 방식으로 나타납니다.

NLTK의 sent_tokenize를 사용한 문장 기반 분할 (sentence-based splitting)로 전환하자, 제가 임베딩 모델의 탓으로 돌렸던 일련의 검색 실패 문제들이 해결되었습니다. 이는 저에게 매우 겸허해지는 순간이었습니다.

중첩 윈도우 (overlapping window)를 사용하는 이유는 사용자의 질문에 답이 될 수 있는 단일 문장이 중첩되지 않는 청크의 경계선에 정확히 걸칠 수 있기 때문입니다. 중첩은 각 문장이 서로 다른 주변 문맥 (context)을 가진 여러 청크에 나타나도록 보장함으로써 그 위험을 줄여줍니다. 트레이드오프 (tradeoff)는 중복성 (redundancy)이며, 동일한 콘텐츠가 ChromaDB에 여러 번 나타나게 됩니다. 이 정도 규모의 코퍼스 (corpus)에서는 괜찮은 수준입니다.

프롬프트 엔지니어링 (Prompt Engineering): 내가 확신했던 실수

저의 원래 시스템 프롬프트 (system prompt)는 다음과 같았습니다:

"제공된 문맥 (context)에만 기반하여 답변하십시오. 모른다면 모른다고 말하십시오."

이것은 어디에서나 반복되는 표준적인 조언입니다. 그 논리는 타당합니다. 엄격한 근거 제시 (grounding)는 환각 (hallucination)을 방지하기 때문입니다. 하지만 실제로는 시스템을 실제보다 더 멍청해 보이게 만들었습니다.

문제는 "문맥에서만 답변하라"는 지침이 생성 품질 (generation quality) 보장으로 위장된 검색 품질 (retrieval quality) 보장이라는 점입니다. 검색 파이프라인이 올바른 청크를 찾아낸다면 아주 잘 작동합니다. 하지만 검색이 실패하거나, 잘못된 청크 경계, 임베딩 불일치 (embedding misalignment), 혹은 모델이 잘 처리하지 못하는 방식으로 질문이 구성되는 경우, LLM은 정답이 포함되지 않은 문맥을 보게 되고는 충실하게 _"모릅니다"_라고 답하게 됩니다.

저는 이것이 옳다고 너무 확신한 나머지, 실제 문제는 모델이 검색 실패를 보완할 수 없도록 제가 만들었다는 사실을 깨닫지 못하고 검색 파이프라인(retrieval pipeline)에서 버그를 찾는 데 시간을 허비했습니다. 모델은 관련 지식을 이미 가지고 있었습니다. 제가 모델에게 그렇지 않은 척하라고 명령했을 뿐입니다.

수정된 프롬프트(prompt):

"문맥(context)을 주요 소스로 사용하십시오. 필요한 경우 귀하의 지식을 보충할 수 있습니다. 귀하의 지식을 사용하는 경우, 이를 명시적으로 밝히십시오."

이제 모델은 검색된 텍스트에 근거를 두면서도(grounded), 검색이 누락되었을 때 우아하게 후퇴(fallback)하며, 그렇게 할 때 투명하게 밝힙니다. 이 계약(contract)은 시스템이 실제로 무엇을 보장하는지에 대해 더 정직합니다.

평가 (Evaluation)

이 시스템에는 LLM-as-Judge를 사용하여 네 가지 지표를 측정하는 평가 스크립트가 있습니다:

지표측정 내용
문맥 정밀도 (Context Precision)검색된 청크(chunks) 중 실제로 관련이 있는 비율은 얼마인가?
...

여기서 LLM-as-Judge가 적절한 선택인 이유는 정답 코퍼스(ground truth corpus)가 없기 때문입니다. 이것들은 도서 시리즈에 대한 개방형 질문들이며, BLEU 점수를 계산할 수 있는 단 하나의 정답이 존재하지 않습니다. 이 작업에서 N-gram 중첩 지표(N-gram overlap metrics)는 무의미할 것입니다.

솔직히 말씀드리면, 공유할 만한 세련된 벤치마크(benchmark) 수치는 없습니다. 평가 스크립트는 존재하고 실행되지만, 저는 이를 엄격한 벤치마크보다는 진단 도구로서 더 많이 사용해 왔습니다. 이는 더 체계적으로 만들어야 할 목록 중 하나입니다.

폴백 (Fallback): ChromaDB가 다운되었을 때

Hugging Face Spaces에는 콜드 스타트(cold starts)가 있습니다. 요청이 들어왔을 때 ChromaDB를 사용할 수 없는 경우, 시스템은 자동으로 SQLite 데이터베이스에 대한 직접적인 FTS5 쿼리로 폴백(fallback)합니다. 답변이 LLM에 의해 생성되지는 않겠지만, 사용자는 500 에러 대신 관련 텍스트를 받게 됩니다.

첫 번째 프로덕션 장애가 발생한 후에 추가하는 것이 아니라, 처음부터 이 폴백을 설계한 것은 제가 올바른 순서로 수행한 몇 안 되는 일 중 하나입니다.

제가 다르게 했을 일들

적응형 청킹 (Adaptive chunking). 슬라이딩 윈도우 (Sliding window)는 합리적인 기본값이지만, 서사 구조 (narrative structure)를 완전히 무시합니다. 판타지 소설에서의 문단 구분은 종종 의미 있는 경계를 나타냅니다. 장면(scene)이나 서사 단위(narrative unit)로 청킹하는 것이 그 어떤 검색(retrieval) 미세 조정보다 문맥적 일관성 (context coherence)을 더 잘 개선할 것입니다.

쿼리 확장 (Query expansion). 어떤 질문은 영어로 들어오고, 어떤 질문은 포르투갈어로 들어옵니다. 다국어 검색 (multilingual retrieval) 체계를 완전히 개편하지 않고도, 검색 전 단계에서 번역이나 유의어 확장 (synonym expansion) 단계를 거친다면 교차 언어 쿼리에 대한 재현율 (recall)을 높이는 데 도움이 될 것입니다.

HyDE. 원문 질문을 임베딩 (embedding)하는 대신, LLM에게 그 질문에 답할 수 있는 가상의 구절 (hypothetical passage)을 생성하도록 요청한 뒤 이를 임베딩합니다. 이렇게 생성된 임베딩은 질문을 직접 임베딩했을 때보다 문서 공간 (document space)과 훨씬 더 잘 정렬되는 경우가 많습니다. 아직 이를 구현하지는 않았지만, 간접적이거나 추상적인 질문에 대한 검색 성능을 유의미하게 향상시킬 것으로 기대합니다.

BM25 지속성 (BM25 persistence). BM25 인덱스는 서비스가 시작될 때마다 전체 코퍼스 (corpus)로부터 다시 구축됩니다. 66,000개의 문단 규모에서는 빠르지만, 불필요한 작업입니다. 이를 지속화 (persisting)한다면 큰 비용 없이 시작 시간을 단축할 수 있을 것입니다.

스트리밍 (Streaming). 현재는 전체 응답이 한 번에 반환됩니다. SSE 스트리밍 (SSE streaming)을 도입하면 긴 답변에 대한 체감 지연 시간 (perceived latency)을 극적으로 개선할 수 있습니다.

결론

이 시스템은 buscadegeloefogo.vercel.app에서 라이브로 운영 중입니다. 단순한 키워드 검색이 아니라, 책 전체를 관통하는 실제적인 추론 (reasoning)이 필요한 질문을 던져보며 검색 성능이 어떻게 유지되는지 확인해 보세요.

이 시스템을 구축하며 배운 가장 중요한 점은, RAG의 품질은 파이프라인 (pipeline) 내의 가장 취약한 연결 고리에 의해 결정되며, 그 취약한 고리는 대개 LLM이 아니라는 사실입니다. 그것은 바로 청크 경계 (chunk boundaries)입니다. 검색 전략 (retrieval strategy)입니다. 프롬프트 계약 (prompt contract)입니다. 이 중 어느 것도 프로덕션 환경에서 문제가 발생하기 전까지는 명확하게 드러나지 않습니다.

댓글을 통해 이에 대해 자유롭게 논의해 주시면 감사하겠습니다.

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0