RAG

서론

AI 기술의 등장은 개인의 생산성뿐 아니라 기업의 경쟁 방식까지 빠르게 바꾸고 있습니다.

많은 조직이 AX(AI Transformation)를 기치로 내걸고 상품 개발과 가치 창출 전 과정에 AI를 도입하려는 움직임을 보이고 있습니다.

그러나 이러한 흐름과 달리, 현업에서 AI를 바로 실무에 투입하는 일은 생각보다 쉽지 않습니다.

가장 큰 이유는 업무의 핵심이 도메인 지식에 있기 때문입니다. 엔터프라이즈 문서, 코드베이스, 규정·정책, 사내 위키처럼 내부에 축적된 지식이 실제 의사결정과 개발 품질을 좌우합니다.

반면 ChatGPT나 Claude 같은 범용 LLM은 일반적 지식에는 강하지만, 사내 고유 맥락에는 어둡고 때로는 근거 없는 추측(환각) 을 하기도 합니다.

여기에 보안·컴플라이언스 제약 때문에 내부 문서를 외부 서비스에 그대로 올리기 어렵고, LLM의 한정된 컨텍스트 윈도우 탓에 긴 문서를 단순 붙여넣기 하는 방식도 곧 한계에 부딪힙니다.

이런 제약을 실용적으로 풀어 주는 방법이 RAG(Retrieval-Augmented Generation) 입니다.

RAG는 흔히 “학습되지 않은 최신 정보나 전용 데이터를 넣어 모델을 부스팅한다” 정도로 오해되곤 합니다.

이번 글에서는 RAG의 개념과 흔한 오해를 바로잡고, 실습을 통해 “평범한 LLM → 환각”에서 “RAG 적용 LLM → 근거 기반 정답”으로 전환되는 과정을 단계별로 확인해보겠습니다.

RAG

약어 뜻 (R-A-G)

Retrieval: 어딘가에서 필요한 정보를 가져옵니다.

Augmented: 가져온 정보를 이용해 컨텍스트를 보강합니다.

Generation: 보강된 컨텍스트를 바탕으로 답을 생성합니다. 조금 더 구체적으로

  1. Retrieval — 어디서 무엇을 가져오나요?

한 줄 요약: 임베딩된 텍스트 벡터를 벡터 DB에서 유사도로 찾아 가져옵니다.

벡터는 숫자 배열입니다. 예: [0.12, 0.90, 0.03, …] 이 길이(원소 개수)가 차원 수입니다.

임베딩(Embedding)은 문장/문서 조각을 고정 차원 벡터로 바꾸는 과정입니다. 의미가 비슷하면 벡터도 가까워지도록 학습되어 있습니다.

검색 흐름:

문서를 청크로 나눠 임베딩 → 벡터 DB에 저장합니다.

질문을 임베딩 → DB의 벡터들과 코사인 유사도 등으로 비교합니다.

가장 유사한 청크 몇 개(top-k)를 회수(retrieve) 합니다.

참고: 벡터 DB는 Chroma, Qdrant 등 다양한 선택지가 있습니다.

  1. Augmented — 무엇을 ‘증강’하나요?

증강 대상은 모델이 아니라 어디까지나 프롬프트의 컨텍스트입니다.

회수한 청크를 근거(Context) 로 프롬프트에 첨부하여, 모델이 그 근거를 바탕으로 답하도록 유도합니다.

즉, 논리력·추론력 자체를 강화하는 개념이 아니며, 지식 접근성을 높여 환각을 줄이는 방식입니다.

  1. Generation — 어떻게 답을 만드나요?

LLM이 “질문 + 보강된 컨텍스트” 를 입력으로 받아 답변을 생성합니다.

프롬프트에 “컨텍스트 밖이면 ‘모릅니다’라고 답하라” 같은 규칙을 넣어 근거 기반 답변을 유도합니다.

LLM과 RAG

RAG에 대한 중요한 점 중 하나는 RAG와 LLM이 상관이 없다는 것입니다.

Description

위 다이어그램에서 볼 수 있듯이, 질의가 주어졌을 때 리트리버는 즉시 LLM에 전송하지 않습니다.

반드시 임베딩 → 벡터 DB 유사도 검색이라는 과정을 먼저 거칩니다.

이 과정의 결과로 강화된 컨텍스트가 만들어지고, 이를 바탕으로 다른 질의(최종 프롬프트) 를 구성한 뒤 그 질의를 LLM에 전달합니다. 이것이 RAG의 원리입니다.

또한 리트리버를 동작시키는 데에는 GPU 자원이 필수적이지 않으며, OpenAI API도 필요하지 않습니다.

병렬로 동작시켜도 LLM 자체의 성능에는 영향을 주지 않습니다.

실제로 확인해보자

이번 실습에서는 LM Studio를 활용하였습니다.

LM Studio는 각종 LLM 모델들을 OpenAI가 제공하는 API 의 형태로 서버를 구동시켜주는 오픈소스 프로그램입니다.

Description

Postman 과 같은 http 테스팅 도구를 통해서 AI 모델과 통신이 가능합니다.

모델로는 gpt-oss-20b를 사용하였습니다.

Raw 모델

# ---------- LLM & 체인 ----------
def build_chain(vectordb: FAISS, k: int) -> Tuple[ChatOpenAI, RetrievalQA]:
    llm = ChatOpenAI(
        model=CHAT_MODEL,
        openai_api_key=API_KEY,
        openai_api_base=BASE_URL,
        temperature=0.0,
    )
    qa_chain = RetrievalQA.from_chain_type(
        llm=llm,
        retriever=vectordb.as_retriever(search_kwargs={"k": k}),
        return_source_documents=True,
        chain_type="stuff",
    )
    return llm, qa_chain

def run_plain(llm: ChatOpenAI, question: str) -> str:
    return llm.invoke(question).content

def main():
    args = parse_args()
    doc_path = Path(args.doc)

    print(run_plain(llm, args.question), end="\n\n")

다음과 같은 질의를 구성하였습니다.

python main.py -q “In league of legends, is there a particular example where red side is at a disadvantage? Answer in a concise manner with maximum of 3 sentences” –preview

Description

RAG를 사용하지 않은 결과는 다음과 같이 나타났습니다.

Description

해당 이슈가 발견된 것은 비교적 최근이었기 때문에, LLM 모델은 알고있는 지식 내에서만 대답하고 기대한 내용은 답변에 포함되지 않았습니다.

RAG 적용

이번에는 RAG를 적용해서 응답을 생성해보겠습니다.

가장 최근 리그 오브 레전드 패치노트를 policy.txt 라는 텍스트로 입력하였습니다.


# ---------- LM Studio Embeddings (한 건씩) + 동시성 ----------
class LMStudioEmbeddings(Embeddings):
    def __init__(self, model: str, base_url: str, api_key: str, timeout: int = 60, workers: int = 4):
        self.model = model
        self.base_url = base_url.rstrip("/")
        self.api_key = api_key
        self.timeout = timeout
        self._headers = {"Authorization": f"Bearer {self.api_key}"}
        self.workers = max(1, workers)

    def _embed_one(self, text: str) -> List[float]:
        url = f"{self.base_url}/embeddings"
        payload = {"model": self.model, "input": text}
        r = requests.post(url, headers=self._headers, json=payload, timeout=self.timeout)
        r.raise_for_status()
        return r.json()["data"][0]["embedding"]

    def embed_query(self, text: str) -> List[float]:
        return self._embed_one(text)

    def embed_documents(self, texts: List[str]) -> List[List[float]]:
        # 병렬 임베딩(로컬 자원과 LM Studio 설정에 맞게 workers 조절)
        results = [None] * len(texts)
        with ThreadPoolExecutor(max_workers=self.workers) as ex:
            futs = {ex.submit(self._embed_one, t): i for i, t in enumerate(texts)}
            for f in as_completed(futs):
                i = futs[f]
                results[i] = f.result()
        return results


def main():
    args = parse_args()
    doc_path = Path(args.doc)

    # 강제 재빌드 옵션 시 manifest 지우기
    if args.rebuild and MANIFEST_PATH.exists():
        MANIFEST_PATH.unlink(missing_ok=True)

    # 인덱스 로드/생성
    vectordb, embeddings, _ = build_or_load_index(
        doc_path=doc_path,
        chunk_size=args.chunk_size,
        overlap=args.overlap,
        k=args.k,
        workers=args.workers
    )
    llm, qa_chain = build_chain(vectordb, k=args.k)

    preview_augmented_prompt(qa_chain, args.question, k=args.k)

추가적으로, 관련 컨텍스트를 리트리버가 불러온 결과를 확인하기 위해서 preview_augmented_prompt라는 함수를 구성하였습니다.


def preview_augmented_prompt(qa_chain: RetrievalQA, question: str, k: int = 2) -> List[Document]:
    docs = qa_chain.retriever.get_relevant_documents(question)
    context_str = "\n\n---\n\n".join(d.page_content for d in docs)
    prompt = qa_chain.combine_documents_chain.llm_chain.prompt
    messages = prompt.format_messages(context=context_str, question=question)

    print("\n====[ Augmented Chat Messages Preview ]====")
    for m in messages:
        role = getattr(m, "type", "human").upper()
        print(f"\n[{role}]\n{m.content}")
    return docs

전체 코드는 여기서 확인할 수 있습니다.

결과

Description

원하는 결과를 생성했고, 관련된 컨텍스트도 정확하게 추출한 결과를 관찰할 수 있습니다.


생각해보기

상용 AI 도구들을 활용하면서 원하는 답을 못 얻을 때 웹 링크나 PDF를 붙여 넣어 해결해 보셨을 것입니다.

이는 일부 제품이 링크나 파일에서 내용을 추출해 프롬프트 컨텍스트에 반영하기 때문입니다.

다만 이런 방식은 사용자가 벡터 DB의 크기/차원, 검색 방식을 세밀히 조정하기 어렵고, 컨텍스트가 커질수록 응답이 느려지거나 품질이 떨어질 수 있습니다.

실제로 컨텍스트의 크기가 커질수록 응답의 품질은 낮아진다는 연구 결과도 존재합니다.

Description

따라서 무작정 컨텍스트를 늘린다고 해서 항상 좋은 결과를 얻을 수 있는 것은 아닙니다.

이 문제를 완화하기 위해 Sliding Context Window와 같은 기법을 적용하기도 합니다.

자체 RAG나 AI 업무 활용을 고민한다면 200페이지 분량의 PDF를 몽땅 업로드하고 싶은 마음이 들겠지만, 리트리버가 높은 유사도를 찾기 쉬운 구조로 데이터를 정리해 두는 일이 훨씬 중요합니다.

그 대표적인 방법 가운데 하나로 질의 분해 기법을 소개합니다.

질의 분해 기법

우리가 보유한 대부분의 데이터(AI에 입력할 데이터)는 “A는 B이다”와 같은 형태로 구성되어 있습니다.

하지만 실제로 던지는 질문에는 배경과 의도가 함께 담겨 있는 경우가 많습니다.

예를 들어, LLM에게 다음과 같은 질의가 들어온다고 가정해 보겠습니다.

Description

리그 오브 레전드라는 도메인에 대해 RAG DB를 구축한다면 어떤 데이터를 담게 될까요?

잭스라는 챔피언의 패치 내역, 승률, 스탯처럼 명확한 사실 기반 정보가 중심이 될 것입니다.

그러나 위 질의는 사실을 직접적으로 서술하지 않고 있습니다.

따라서 벡터 유사도 측면에서 원하는 컨텍스트를 찾지 못할 가능성이 커집니다.

분해

그래서 이런 질의를 A는 B다 형태의 서브 질의로 분해합니다.

  • 잭스는 올해 월드 챔피언십에서 상단 공격로 메타를 정의하는 챔피언입니다.

  • 잭스는 올해 롤드컵에서 메타 챔피언으로 평가됩니다.

  • 잭스는 일반 게임에서도 이미 충분히 강력합니다.

  • 잭스는 솔로 랭크에서 높은 성능을 보이는 챔피언입니다.

  • 잭스의 강점은 특정한 상황에서 더욱 잘 드러나도록 조정되었습니다.

  • 잭스는 밸런스 조정을 통해 숙련자에게 더 적합한 챔피언이 되었습니다.

  • 잭스는 우수한 실력을 가진 선수의 손에서 더 강력해집니다.

  • 잭스는 호흡이 맞는 팀과 함께할 때 더욱 빛을 발합니다.

이 과정은 일반적으로 LLM이 대신 수행합니다.

이렇게 생성한 질의를 활용하면 벡터 유사도를 더 높게 유지하면서도 비싼 GPU 자원을 쓰지 않는 훌륭한 최적화 전략이 됩니다.

업데이트:

댓글남기기