수많은 문서 후보가 검색과 재정렬 과정을 거쳐 소수의 문서로 좁혀지는 3D 장면
AI 활용

RAG 구축기 2: RAG에 대한 이해 - 검색을 하자

RAG가 사용자 질문을 임베딩하고 Dense·Sparse 검색으로 후보를 찾은 뒤, Reranker로 정확한 문서를 골라 LLM에 전달하는 과정을 쉽게 설명합니다.

지난 글에서는 RAG에 문서를 저장하는 과정에 대해서 알아봤다. 이번 게시글에서는 사용자의 질문을 임베딩하고, 관련 문서를 검색한 뒤, LLM이 답변하는 과정을 알아보고자 한다.

문서 추출과 저장은 활용할 수 있는 기법이 비교적 많아 기준을 세우기 수월하다. 다만 문서의 형태와 규모에 따라 비용·속도·품질의 병목은 여전히 생길 수 있다. 어떻게 추출하고 어떤 방식으로 저장할지 기준을 정해야 하며, 여러 트레이드오프 상황에 어떻게 대처할지도 계획해야 한다.

하지만 프로덕션 환경에서 사용자가 실제로 질문하고 답변을 받기 시작하면 또 다른 병목이 생긴다. 우리는 10페이지짜리 문서 하나가 아니라 수십 권에서 수천 권에 달하는 분량의 문서를 기준으로 RAG를 구축했다는 사실을 잊지 말아야 한다.

이 많은 문서 중에서 사용자의 질문과 관련된 내용을 어떻게 빠르고 정확하게 찾을 것인가?

이제 여기서부터 검색이 시작된다.

사용자 질문을 임베딩하기

사용자가 질문하면 먼저 질문 내용을 임베딩한다. 지난 글에서 문서를 컴퓨터가 비교할 수 있는 숫자 배열로 바꿔 저장한다는 것을 알아봤다. 이번에는 사용자의 질문을 같은 방식으로 숫자 배열로 바꾼다고 이해하면 된다.

예를 들어 사용자가 “Galaxy Fold6 액정 수리 비용이 얼마인가요?” 라고 질문했다고 가정하자.

RAG는 이 문장을 그대로 검색하는 것이 아니라, 임베딩 모델을 이용해 검색 가능한 숫자 배열로 변환한다. 미리 숫자 배열로 저장해 둔 문서들과 사용자의 질문을 비교하기 위해서다.

중요한 점은 문서와 질문이 서로 비교할 수 있는 같은 임베딩 공간에 있어야 한다는 것이다. 문서는 A 방식으로 숫자를 만들고 질문은 전혀 다른 B 방식으로 숫자를 만들면, 두 숫자를 제대로 비교하기 어렵다.

그럼 이 숫자를 이용해 사용자 질문과 유사한 문서를 어떻게 찾을까?

사용자 질문과 유사한 답을 어떻게 찾을 것인가?

지난 글에서 Dense Embedding과 Sparse Embedding을 함께 사용하는 Hybrid Search에 대해서 알아봤다. 간단하게 다시 정리하면 Dense Embedding은 말은 달라도 뜻이 비슷한 문서를 찾는 방식이다. 반면 Sparse Embedding은 제품명, 모델명, 오류 코드처럼 정확한 단어가 중요한 검색에 강하다. Dense는 말의 뜻을 이해하는 사람이고, Sparse는 정확한 단어에 집중하는 사람이라고 이해하면 된다.

우리가 일반적으로 사용해 온 검색은 키워드 매칭 방식에 가깝다. BM25가 대표적인 키워드 검색 알고리즘이다. “Galaxy Fold6 액정 수리”라고 검색하면 Galaxy, Fold6, 액정, 수리 같은 단어가 들어간 문서를 우선적으로 찾는다. 제품명이나 모델 번호처럼 정확한 단어를 검색할 때는 이 방식이 유용하다.

하지만 같은 의미를 다른 단어로 표현하면 문제가 생길 수 있다. “Galaxy Fold6 액정 수리”를 검색했는데 문서에는 “Galaxy Fold6 디스플레이 리퍼”라고 적혀 있다고 생각해보자. 두 문장은 비슷한 내용을 말하고 있지만, 사용된 단어가 다르다는 이유로 키워드 검색에서는 관련 문서를 놓칠 수 있다.

이러한 어휘 불일치 문제를 보완하기 위해 단어가 아니라 문장의 의미를 비교하는 Semantic Search를 함께 사용할 수 있다.

BERT에서 Sentence-BERT로

Semantic Search를 설명할 때 자주 등장하는 모델이 BERT다. BERT를 통해 텍스트의 앞뒤 문맥을 함께 이해하고, 문장이 어떤 의미인지 표현할 수 있게 됐다. 뒤에서 Cross-encoder 개념을 설명하겠지만, BERT는 Cross-encoder 방식으로 문장 유사도 검색에 사용하면서 질문과 문서를 하나로 묶어 관련성을 계산한다.

“Galaxy Fold6 액정 수리”라는 질문과 “Galaxy Fold6 디스플레이 리퍼”라는 문서를 함께 넣고 두 내용이 얼마나 관련 있는지 계산하는 방식이다. 문서가 하나라면 큰 문제가 없다. 하지만 검색 대상이 수천 권에 달한다면 질문과 각각의 문서를 계속 조합해 BERT를 실행해야 한다.

여기서 치명적인 속도 문제가 발생한다. 다시 말하지만 우리는 10페이지짜리 문서가 아니라 수십 권에서 수천 권에 달하는 문서를 기준으로 RAG를 구축하고 있다. 이 문제를 해결하기 위해 등장한 대표적인 방식이 Sentence-BERT와 같은 Bi-encoder 구조다.

Bi-encoder

Bi-encoder는 질문과 문서를 각각 독립적으로 벡터로 변환하는 구조다. 문서를 저장할 때 모든 문서를 미리 벡터로 만들어 둔다. 사용자가 질문하면 질문만 한 번 벡터로 변환한 뒤, 이미 저장된 문서 벡터와 비교한다.

기존 방식이 사용자의 질문을 수천 개의 문서와 하나씩 함께 읽는 것이라면, Bi-encoder는 문서의 특징을 미리 정리해 두고 질문과 가까운 문서를 찾는 방식이다. 문서가 수천 권 있어도 질문을 처리하는 인코더는 질문마다 한 번 실행된다. 이후에는 미리 저장된 벡터 중 질문 벡터와 가까운 것을 검색하면 된다.

그럼 “Bi-encoder와 Dense Embedding은 무엇이 다른가?”라는 생각이 들 수 있다. Dense Embedding은 텍스트의 의미를 고정된 길이의 숫자 벡터로 표현한 결과물이다. Bi-encoder는 질문과 문서를 각각 독립적으로 처리해 그 결과물을 만드는 모델 구조다. 다시 말해 Dense Embedding은 결과물의 형태이고, Bi-encoder는 질문과 문서를 처리하는 구조다.

“임베딩이 텍스트를 숫자로 변환하는 것 아니었어?”라고 생각할 수 있지만, 우리가 지금까지 사용한 Dense 검색의 구조가 대부분 Bi-encoder 방식이었다고 이해하면 된다.

빠르게 찾았지만 정확하지 않을 수 있다

Bi-encoder를 사용하면 대규모 문서에서 의미가 비슷한 후보를 빠르게 찾을 수 있다.

하지만 질문과 문서를 각각 하나의 벡터로 압축하기 때문에 후보 사이의 세밀한 차이를 구분하는 데에는 한계가 있다. Dense와 Sparse를 결합한 Hybrid Search로 후보를 넓게 찾더라도 상위 후보의 순서가 항상 완벽한 것은 아니다.

예를 들어 “갤럭시 폴드6 액정 교체”라는 질문이 들어왔다고 생각해보자. Bi-encoder는 “Galaxy Fold6 액정 수리” 문서를 관련 후보로 올릴 수 있다. 하지만 극단적인 예시로 Galaxy라는 단어 때문에 은하수에 관한 문서가 후보에 섞이는 것처럼, 전혀 다른 내용이 함께 올라올 수도 있다.

검색 결과에 관련 문서가 들어 있기는 하지만, 가장 위에 있어야 할 문서가 아래에 있거나 필요 없는 문서가 상위에 섞일 수 있다는 것이다.

그래서 빠르게 찾은 후보를 다시 정밀하게 검사하는 과정이 필요하다.

Reranker와 Cross-encoder

Bi-encoder가 많은 문서 중에서 관련 있어 보이는 후보를 빠르게 찾는 역할이라면, Reranker는 그 후보들의 순서를 다시 정하는 역할을 한다. 대표적으로 Cross-encoder를 Reranker에 사용할 수 있다. Cross-encoder는 사용자 질문과 후보 문서를 하나로 묶어 함께 읽는다. 그리고 다음과 같은 질문을 던진다고 이해하면 된다.

“이 문서 안에 사용자가 찾는 답이 실제로 포함되어 있는가?”

전체 문서를 Cross-encoder로 비교하면 다시 속도 문제가 생긴다. 그래서 먼저 Bi-encoder로 후보를 빠르게 찾은 다음, 줄어든 후보만 Cross-encoder로 자세히 확인한다. 예를 들어 수천 개의 청크 중 Bi-encoder가 관련 있어 보이는 청크 100개를 찾았다고 생각해보자. Cross-encoder는 그 100개를 다시 읽고 점수를 매긴 뒤, 가장 관련성이 높은 10개를 상위에 배치한다.

이 후보군을 다시 평가하고 순서를 정하는 과정이 Reranking이다. Cross-encoder는 Reranker를 구현하는 대표적인 모델 구조라고 생각하면 된다.

최근에는 LLM 기반 Reranker나 Learning-to-Rank 같은 여러 방식도 활용되고 있다. 하지만 전체 구조는 빠르게 후보를 많이 찾고, 그중 관련성이 높은 문서를 정밀하게 고르는 방식에서 크게 달라지지 않는다.

검색 방식 정리

지금까지의 검색 과정을 간단하게 정리하면 다음과 같다.

사용자가 질문하면 먼저 질문을 숫자로 변환한다. Dense Retriever에서는 Bi-encoder를 이용해 질문과 의미가 비슷한 문서를 빠르게 찾는다. Hybrid Search를 사용한다면 Sparse 또는 BM25 검색도 함께 실행한다. Dense는 의미가 비슷한 문서를 찾고, Sparse 검색은 정확한 제품명이나 모델명, 오류 코드가 포함된 문서를 찾는다.

두 검색 결과는 RRF와 같은 방식을 이용해 하나의 후보 목록으로 합칠 수 있다. 이후 Reranker가 후보 문서를 다시 읽고 관련성이 높은 순서대로 재정렬한다.

마지막으로 선별된 상위 청크와 사용자 질문, 시스템 프롬프트를 LLM에 전달한다. 그제야 LLM이 답변한다. 쉽게 말하면 다음과 같은 구조다. "빠르게 많이 찾는다 → 정확한 문서를 다시 고른다 → 고른 문서를 바탕으로 답변한다"

RAG 전체 구조 정리

  1. Parser
    • PyMuPDF4LLM 등으로 PDF의 텍스트와 구조를 Markdown이나 JSON 형태로 추출한다.
    • 스캔 문서와 이미지 중심의 문서는 OCR 또는 VLM을 사용할 수 있다.
  2. Chunking
    • MarkdownHeaderTextSplitter로 제목과 문서 구조를 기준으로 나눌 수 있다.
    • 너무 큰 구간은 Recursive Splitter로 다시 나눌 수 있다.
    • Semantic Chunking을 이용해 의미가 변하는 지점을 기준으로 나누는 방법도 있다.
  3. Embedding
    • 나눈 청크를 Bi-encoder 계열의 Dense Embedding 모델로 숫자 벡터로 변환한다.
    • Hybrid Search를 사용한다면 BM25용 키워드 인덱스나 SPLADE 같은 Sparse 검색 구조도 준비한다.
    • 변환한 벡터와 청크 원문을 Vector DB에 저장한다.
  4. 사용자 질문 발생
    • 사용자 질문을 Dense 검색에 사용할 숫자 벡터로 변환한다.
    • Hybrid Search라면 키워드 또는 Sparse 검색에 필요한 처리도 함께 진행한다.
  5. Retrieval
    • Dense Retriever와 Sparse Retriever가 각각 관련 후보를 찾는다.
    • 두 검색 결과를 RRF 등의 방식으로 결합한다.
    • Bi-encoder는 이 과정에서 Dense Retrieval 경로를 담당한다.
  6. Reranker
    • Cross-encoder 등을 이용해 사용자 질문과 후보 청크의 관련성을 다시 계산한다.
    • 많은 후보 중에서 LLM에 전달할 소수의 상위 청크를 골라낸다.
  7. LLM 답변
    • 선별된 청크와 사용자 질문, 시스템 프롬프트를 LLM에 전달한다.
    • LLM은 전달받은 문서를 근거로 답변을 생성한다.

글을 마치며

비슷한 기법과 설명이 많아 헷갈리기 쉽지만, 목적은 하나만 기억하면 된다.

얼마나 빠르고 정확하게 답변할 것인가?

이번 글에서는 RAG를 기준으로 설명했지만, 우리가 살펴본 내용은 검색 알고리즘과 추천 알고리즘에서도 사용되는 여러 기법의 표면을 이해한 것에 가깝다. 그리고 이 환경은 계속 발전하고 있다.

Anthropic은 2024년 9월 Contextual Retrieval을 공개했다. LLM이 각 청크가 전체 문서의 어느 부분에 해당하는지 짧은 맥락을 만들고, 그 맥락을 청크 앞에 붙이는 방식이다. Anthropic의 자체 평가에서 Top-20 검색 실패율은 Contextual Embedding 적용 시 35%, Contextual BM25 결합 시 49%, Reranking까지 결합했을 때 67% 감소했다.

다만 각 청크의 맥락을 생성하기 위해 LLM을 사용하므로 추가적인 비용과 처리 시간이 발생한다. 검색 품질을 높이는 기법이 생기면 그 기법 때문에 새로운 비용과 속도 문제가 생기는 셈이다.

실무에서는 모든 청크에 무조건 맥락을 붙이기보다, 이 청크가 검색에 필요한 정보를 담고 있는지 먼저 판별한 뒤 의미 있는 청크만 Contextual Retrieval의 대상으로 삼는 방식도 활용할 수 있다.

RAG를 구축하면서 계속 마주치는 문제도 결국 같다. 빠르게 찾으면 정확도가 아쉬워지고, 정확하게 확인하려면 비용과 시간이 늘어난다.

결국 RAG를 구축한다는 것은 어떤 기법 하나를 선택하는 일이 아니다. 우리 서비스에서 어느 정도의 속도와 비용으로, 어느 수준의 검색 정확도를 만들 것인지 결정하는 일에 가깝다.

이 글이 도움이 되었나요?