Skip to content
This page has been auto-translated and may contain errors.View in English

RAG

docs.scrimba.com

먼저 이름부터 짚어보겠습니다. RAGretrieval-augmented generation(검색 증강 생성)의 줄임말입니다. 질문을 받는 순간 관련된 텍스트를 직접 가져와 모델에 건네주면, 모델은 얼어붙은 기억이 아니라 여러분이 제공한 사실을 바탕으로 답변합니다. 이 장에서는 이 "검색 후 답변" 루프가 어떻게 동작하는지, 그리고 어디서 잘못될 수 있는지 살펴봅니다.

검색 후 생성 패턴

모델은 회사의 도움말 문서나 제품 정보, 학습 이후에 벌어진 일을 알지 못합니다. 그런데도 물어보면 모른다고 인정하거나, 더 나쁘게는 그럴듯하게 들리는 지어낸 답변을 내놓습니다. RAG는 질문을 받는 순간 관련 텍스트를 모델에 직접 건네줌으로써 이 문제를 해결합니다.

이 아이디어는 임베딩을 알고 나면 확실히 이해가 됩니다. 임베딩은 텍스트를 벡터로 바꿔서 얼마나 가까운지 비교할 수 있게 해주는 기법입니다(코사인 유사도는 두 벡터가 얼마나 비슷한 방향을 가리키는지를 측정합니다). **검색 증강 생성**은 일반적인 모델 호출에 두 단계를 추가한 것입니다.

  1. 검색(Retrieve). 사용자의 질문을 받아 문서에서 가장 관련성 높은 부분을 찾아, 상위 후보들을 모읍니다.
  2. 생성(Generate). 그 텍스트들을 프롬프트에 컨텍스트로 넣고, 모델에게 그 컨텍스트만 사용해서 질문에 답하도록 시킵니다.

글을 쓰는 능력은 모델의 일반적인 언어 능력이 담당하고, 사실은 검색해온 텍스트가 제공합니다. 그 결과 여러분의 콘텐츠에 근거를 둔 유창한 답변을 얻을 수 있고, 무엇을 넣을지 직접 통제하기 때문에 최신 정보도 유지할 수 있습니다.

예측 루프의 관점에서 보면 이 방식이 왜 이렇게 잘 통하는지 더 잘 이해할 수 있습니다. RAG가 없으면 모델은 파라미터에 얼어붙어 있는 패턴에서 사실을 기억해내야 하는데, 그 사실이 아예 없을 수도 있어서 자신 있게 추측하는 결과로 이어집니다. RAG를 쓰면 그 사실이 컨텍스트 안에 바로 놓여 있으므로, 모델이 할 일은 "이걸 기억해내라"에서 "눈앞에 있는 걸 읽고 그걸 바탕으로 답하라"로 바뀝니다. 검색은 작업의 성격을 기억에서 읽기로 바꿔줍니다. 시험에 비유하면 명확합니다. 참고 자료 없이 보는 시험은 추측을 부르지만, RAG는 관련 페이지가 책상 위에 펼쳐진 채로 보는 오픈북 시험으로 바꿔줍니다.

Juno검색 후 생성 패턴 RAG는 일반적인 모델 호출에 두 단계를 더한 것입니다. 질문과 가장 관련 있는 문서를 검색하고, 그것을 컨텍스트로 삼아 답변을 생성합니다. 언어는 모델이 담당하고, 사실은 검색해온 텍스트가 담당합니다. 이렇게 하면 모델이 기억이 아니라 여러분의 콘텐츠를 바탕으로 답하게 되고, 이것이 바로 답을 지어내는 걸 막아줍니다. 처음엔 이걸 믿기까지 시간이 좀 걸렸는데, 정말로 오픈북 모델은 클로즈드북 모델보다 추측을 덜 합니다.

모델은 여러분의 도움말 문서나 제품 정보, 학습 마감일 이후의 정보를 갖고 있지 않습니다. 그럼에도 물어보면 거절하거나, 그럴듯하게 읽히는 지어낸 답변을 내놓습니다. **검색 증강 생성**은 요청 시점에 관련 텍스트를 가져와 프롬프트에 넣음으로써 이 간극을 메웁니다. 그러면 모델은 사실을 기억해내는 게 아니라 읽게 됩니다. 이 메커니즘은 임베딩에 기반을 두는데, 여기서 각 텍스트는 벡터가 되고 유사도는 벡터 사이의 거리(코사인 유사도, 즉 두 벡터 사이 각도의 코사인 값)로 측정됩니다.

이 루프는 두 단계로 이루어집니다.

  1. 검색(Retrieve). 질문을 문서들과 같은 벡터 공간에 임베딩하고, 유사도가 가장 높은 청크들을 뽑아냅니다.
  2. 생성(Generate). 그 청크들을 이어붙여 프롬프트의 컨텍스트로 넣고, 모델에게 그것들만 사용해서 답하도록 지시합니다.

검색 단계에는 top-k라는 조절값이 있습니다. 가장 유사한 청크 중 실제로 몇 개를 남길지를 정하는 값입니다. k는 균형의 문제입니다. 너무 작으면 답이 담긴 청크를 놓쳐서 모델이 활용할 게 없어집니다. 너무 크면 느슨하게 관련된 텍스트로 컨텍스트를 채우게 되어, 신호가 희석되고 비용이 늘어나며 lost-in-the-middle 문제도 심해집니다. k값 3~5 정도가 시작하기 적당하며, 실제 질문들을 대상으로 조정해 나가면 됩니다.

핵심은 이렇습니다. 인덱스를 레코드들의 리스트로 구성하되, 각 레코드는 청크 텍스트와 미리 계산해둔 벡터를 함께 담도록 만들고, 프롬프트는 검색해온 컨텍스트가 질문 위쪽에 명확히 구분된 블록으로 자리하도록 구성합니다. 모델의 언어 능력이 글을 쓰고, 검색해온 텍스트가 무엇이 사실인지를 결정합니다.

python
def retrieve(question, index, k=4):
    query_vector = embed(question)
    scored = [
        {"text": item["text"], "score": cosine_similarity(query_vector, item["vector"])}
        for item in index
    ]
    return sorted(scored, key=lambda x: x["score"], reverse=True)[:k]
Juno검색 후 생성 패턴 RAG는 검색 후 생성입니다. 질문을 임베딩하고, 가장 유사한 청크들을 뽑아 프롬프트에 넣고, 그것들을 바탕으로 답합니다. 여기서 중요한 조절값은 top-k, 즉 남길 청크 수입니다. 너무 적으면 답을 놓치고, 너무 많으면 답이 잡음에 묻히면서 비용까지 치르게 됩니다. 3~5 정도로 시작해서 감이 아니라 실제 질문들로 조정하세요.

모델은 여러분의 비공개 자료나 학습 마감일 이후의 정보를 갖고 있지 않으며, 어느 쪽인지 알려주지도 않습니다. 어느 경우든 똑같이 자신감 있는 어조로 답합니다. **검색 증강 생성**은 요청 시점에 관련 텍스트를 가져와 답변의 근거로 삼는 패턴으로, 정확성이 파라미터에 저장된 기억이 아니라 여러분이 통제하는 소스에 좌우되게 만듭니다. 이는 임베딩 위에서 동작합니다. 텍스트를 벡터로 매핑하고, 코사인 유사도(두 벡터 사이 각도의 코사인 값으로, 길이와 무관하게 의미의 가까움을 나타냄)로 순위를 매기는 방식입니다.

검색 후 생성이라는 2단계 틀은 맞는 설명이지만 실제 작업이 어디서 이루어지는지는 가려버립니다. 검색은 생성이라는 탈을 쓴 탐색(search) 문제이며, 전체 시스템의 품질은 바로 여기서, 모델 호출 단계가 아니라 이 단계에서 결정됩니다. 그러니 처음부터 검색-생성을 "검색-순위화-생성"으로 다뤄야 합니다. 왜냐하면 질문을 임베딩하고 가장 가까운 k개를 붙여넣는 단순한 방식은, 하위 레벨에서는 아직 마주치지 않은 실패 유형들, 즉 정확 일치 누락, 거의 중복된 청크가 커버리지를 잡아먹는 문제, 오래된 텍스트가 자신 있게 답하는 문제에서 무너지기 때문입니다.

초반에 몸에 익혀둘 첫 번째 습관은, 이것이 감으로 판단할 대상이 아니라 측정 가능한 시스템이라는 점입니다. 답을 알고 있는 실제 질문 세트와, 그 답을 담고 있어야 할 청크들을 따로 마련해두고, recall@k를 추적하세요. 이는 검색되어야 했던 청크들 중 실제로 상위 k개 안에 들어온 비율입니다. Recall@k는 여러분의 상한선입니다. 올바른 청크가 컨텍스트에 없으면 어떤 프롬프트 기법으로도 되살릴 수 없고, 생성 단계는 검색이 넘겨주지 않은 것에 답할 수 없습니다. 이후 모든 것이 이 수치에 제한을 받으므로, 가장 먼저 측정하고 가장 먼저 손봐야 할 대상입니다.

python
def retrieve(question, index, k=8):
    query_vector = embed(question)
    scored = [
        {"text": it["text"], "score": cosine_similarity(query_vector, it["vector"]), "meta": it["meta"]}
        for it in index
    ]
    # 여기서는 넉넉하게 가져온 뒤, 더 작은 최종 집합으로 재순위화합니다(아래 참고)
    return sorted(scored, key=lambda x: x["score"], reverse=True)[:k]
Juno검색 후 생성 패턴 RAG는 생성이라는 탈을 쓴 탐색 문제입니다. 품질은 모델 호출이 아니라 검색 단계에서 결정되므로, 단순히 임베딩하고 k개를 뽑아 붙여넣는 방식은 정확 일치나 오래된 텍스트 앞에서 무너집니다. 모든 것을 제한하는 수치는 recall@k, 즉 검색되어야 했던 청크 중 실제로 상위 k에 들어온 비율입니다. 올바른 청크가 컨텍스트에 애초에 들어오지 않으면 어떤 프롬프트도 구해줄 수 없습니다. 다른 걸 조정하기 전에 이걸 먼저 측정하세요. 그러지 않으면 엉뚱한 쪽만 다듬고 있는 셈입니다.

청킹

문서 전체를 그대로 임베딩하지는 않습니다. 먼저 더 작은 **청크(chunk)**로 나눈 뒤, 한 문단이나 몇 문장 단위로 잘라서 임베딩합니다. 이유는 두 가지입니다. 첫째, 검색이 더 정밀해집니다. 40페이지짜리 매뉴얼 전체가 아니라 관련 있는 한 문단만 뽑아낼 수 있습니다. 둘째, 청크는 컨텍스트 윈도우에 맞아떨어지는데, 거대한 문서 전체는 그렇지 않을 수 있습니다.

python
# 간단한 청커: 문단 단위로 나눕니다
def chunk(text):
    return [c.strip() for c in text.split("\n\n") if c.strip()]

청크 크기는 균형의 문제이며, 그 이유는 임베딩에서 찾을 수 있습니다. 각 청크는 하나의 벡터로 변환되어 전체 의미를 요약합니다. 청크를 너무 크게 잡으면 여러 주제를 아우르게 되어, 그 하나의 벡터가 모든 주제의 흐릿한 평균값이 되어 어떤 구체적인 질문에도 잘 맞지 않습니다. 너무 작게 잡으면 뜻을 통하게 해주던 주변 맥락을 잃어버려서, 벡터가 조각난 부분만을 가리키게 됩니다. 문단 단위 청크는 각각 하나의 완결된 아이디어를 담고 있습니다. 검색 결과를 보면서 여기서부터 조정해 나가면 됩니다.

Juno청킹 임베딩하기 전에 문서를 대략 문단 크기의 작은 청크로 나눠서 임베딩하세요. 청크가 작을수록 검색이 더 정밀해지고 컨텍스트 윈도우에도 더 잘 맞습니다. 청크 크기는 균형의 문제입니다. 너무 크면 흐릿하고 낭비이며, 너무 작으면 주변 맥락을 잃습니다. 제 답변이 모호하게 나올 때는 거의 항상 청크가 너무 컸습니다.

문서 전체가 아니라 청크 단위로 임베딩하는 이유는, 각 청크가 하나의 벡터가 되어 그 전체 의미를 요약해야 하기 때문입니다. 문서를 문단 크기 단위로 나눠서 각각 임베딩하세요. 청크가 너무 크면 여러 주제가 하나의 벡터에 평균화되어 어느 것과도 뚜렷하게 맞지 않고, 너무 작으면 의미를 이해하는 데 필요했던 맥락을 잃어버립니다.

그러니 **청크 크기**는 고정된 규칙이 아니라 조정 가능한 값으로 다루세요. 몇백 토큰 정도의 청크가 하나의 아이디어를 깔끔하게 담아내는 편입니다. 이 값은 top-k와 서로 맞물려 있습니다. 청크가 커지면 컨텍스트 예산 안에 들어가는 청크 수가 줄어들어, 요청당 다룰 수 있는 범위가 줄어듭니다. 청크가 작아지면 정밀도는 올라가지만, 여러 청크에 걸쳐 있는 아이디어를 재구성하려면 k값을 더 크게 잡아야 합니다.

두 번째 조절값은 **오버랩(overlap)**입니다. 연속된 청크들이 경계 부분에서 텍스트 일부, 예컨대 10~20퍼센트 정도를 공유하도록 하는 것입니다. 오버랩이 없으면 답을 담고 있는 문장이 경계에 걸쳐 나뉠 수 있습니다. 절반은 한 청크에, 나머지 절반은 다음 청크에 속하게 되어 어느 쪽도 제대로 검색되지 않습니다. 오버랩을 두면 중복이 조금 생기고 벡터 수도 약간 늘어나지만, 경계 때문에 두 조각으로 잘려나갈 뻔한 답들을 되살려줍니다.

python
def chunk(text, size=800, overlap=120):
    # 여기서 size와 overlap은 문자 단위입니다. 실제 시스템에서는 토큰 단위로 예산을 잡으세요
    out, start = [], 0
    while start < len(text):
        out.append(text[start:start + size])
        start += size - overlap
    return out
Juno청킹 청크 크기와 오버랩은 한 번 정하면 끝나는 설정값이 아니라 계속 조정해야 할 다이얼입니다. 하나의 아이디어를 담을 정도의 크기, 즉 몇백 토큰 정도를 목표로 삼고, 이 값이 top-k와 맞바꿈 관계에 있다는 걸 기억하세요. 청크가 커지면 들어가는 개수는 줄어듭니다. 경계에 걸친 답이 적어도 한 청크 안에는 온전히 들어가도록 10~20퍼센트 오버랩을 넣으세요. 경계는 검색이 답을 조용히 놓치는 지점입니다.

검색의 recall은 단 하나의 질의도 실행되기 전, 청킹 단계에서 이미 결정됩니다. 각 **청크**는 하나의 벡터가 되어 그 전체 의미를 대변해야 하므로, 어떻게 자르는지가 무엇을 찾을 수 있는지를 결정합니다. 문서에 구조가 있다면 고정된 문자 길이보다 문단이나 섹션 경계로 나누는 게 낫습니다. 하나의 일관된 섹션에 대한 벡터가, 두 섹션의 절반씩을 담은 벡터보다 더 깨끗한 검색 대상이기 때문입니다.

청크 크기와 오버랩은 자유롭게 정할 수 있는 매개변수가 아니라 전체 예산과 맞물려 있습니다. 크기는 top-k, 그리고 컨텍스트 윈도우와 맞바꿈 관계입니다. 큰 청크는 각각 더 많은 맥락을 담지만 들어가는 개수가 줄어 커버리지가 낮아지고, 작은 청크는 정밀도를 높이지만 아이디어가 조각나서 재구성하려면 더 높은 k값이 필요해지며, 이는 토큰 비용을 늘리고 lost-in-the-middle 문제를 다시 불러옵니다. 약 10~20퍼센트의 오버랩은 경계에 걸친 답을 살려주지만, 인덱스에 중복이 생기는 대가를 치릅니다. 이 중복은 뒤에서 문제를 일으킵니다. 거의 동일한 오버랩 청크들이 top-k 자리를 여러 개 차지하면서 커버리지를 갉아먹을 수 있습니다. 재순위화(re-ranking)와 중복 제거가 나중에 필요해지는 이유 중 하나가 바로 이것입니다.

대부분의 사람들이 아프게 겪기 전까지 놓치는 비용이 있습니다. 청킹 전략은 인덱스에 고정되어 버리므로, 이를 바꾸려면 전체 코퍼스를 다시 임베딩하고 다시 인덱싱해야 합니다. 이는 실제 컴퓨팅 비용, 실제 돈, 그리고 이전 벡터와 새 벡터를 섞어서는 안 되는 마이그레이션 기간까지 요구합니다. 청크 크기는 실제 문서와 질의를 대상으로 초반에 결정하고, 별도로 마련한 세트에서 recall@k를 측정하며, 재청킹은 금요일에 슬쩍 배포할 설정 변경이 아니라 신중한 재인덱싱 작업으로 취급하세요.

Juno청킹 가능하다면 구조를 기준으로 청킹하세요. 각 청크는 전체 의미를 대변하는 하나의 벡터이고, 크기는 top-k, 그리고 윈도우와 동시에 맞바꿈 관계에 있습니다. 10~20퍼센트 오버랩은 경계에 걸친 답을 살려주지만, top-k 자리를 잡아먹는 거의 중복된 청크들을 낳기도 합니다. 재순위화가 존재하는 이유 중 하나가 바로 이것입니다. 그리고 청킹 방식은 인덱스에 고정된다는 걸 기억하세요. 바꾸려면 전체를 다시 임베딩하고 재인덱싱해야 하며, 실제 비용과 마이그레이션이 따르는 일이지 단순한 설정 변경이 아닙니다.

벡터는 어디에 저장할까

청크가 몇 개뿐이라면 벡터를 메모리에 두고 임베딩 장에서 다룬 코사인 유사도 함수로 비교하면 됩니다. 뒤에 나오는 예제도 이 방식을 사용합니다. 청크가 수천, 수백만 개라면 이 방식은 느려지고, 그때는 **벡터 스토어**를 쓰게 됩니다. 이는 대규모로 가장 가까운 벡터를 빠르게 찾아내도록 만들어진 데이터베이스입니다.

RAG를 배우는 데는 벡터 스토어가 필요 없고, 지금 당장 무엇을 쓸지 고를 필요도 없습니다. 호스팅 서비스와 라이브러리로 여러 종류가 존재하지만, 중요한 건 개념입니다. 임베딩을 저장해두고 빠르게 검색할 수 있는 곳이라는 점입니다. 일단 메모리에서 시작하고, 컬렉션이 커지면 벡터 스토어로 옮기세요.

Juno벡터는 어디에 저장할까 청크가 몇 개뿐이라면 벡터를 메모리에 두고 직접 비교하세요. 규모가 커지면 벡터 스토어, 즉 대규모로 가장 가까운 벡터를 빠르게 찾아내는 데이터베이스가 필요합니다. RAG를 배우는 데는 벡터 스토어가 필요 없습니다. 메모리에서 시작해서 컬렉션이 커지면 벡터 스토어로 옮기세요. 첫날부터 데이터베이스를 고를 필요는 없습니다.

청크가 몇백 개 정도라면 벡터는 메모리에 두고 질의를 모든 벡터와 하나씩 비교하면 됩니다. 이건 선형 탐색으로, 소규모에서는 괜찮지만 컬렉션이 수천, 수백만 개로 늘어나면 느려집니다. 매번 질의마다 모든 벡터와 비교해야 하기 때문입니다. 이 지점에서 **벡터 스토어**로 옮겨가게 됩니다. 벡터 스토어는 빠른 최근접 탐색(nearest-neighbor search)을 위해 벡터를 인덱싱하는 데이터베이스입니다.

기억해둘 만한 비대칭적인 지점은 "한 번만 임베딩"과 "질의마다 임베딩"의 차이입니다. 문서 청크는 미리 정확히 한 번만 임베딩하고 인덱싱하며, 이 작업은 이후 모든 질문에서 재사용됩니다. 반면 질의는 요청이 올 때마다 한 번씩 임베딩하는데, 이것이 실제 서비스 경로에서 발생하는 유일한 임베딩 비용입니다. 즉 무거운 인덱싱 비용은 선불로 치르고 나중에 나눠서 갚는 셈이고, 질의당 비용은 임베딩 한 번과 검색 한 번뿐입니다. 이 덕분에 RAG는 한 번 구축해두면 서비스하는 데 비용이 적게 듭니다.

패턴을 배우는 데 스토어를 꼭 골라야 하는 건 아닙니다. 먼저 메모리 안의 레코드 리스트로 시작하고, 규모나 지속성이 요구될 때 벡터 스토어로 바꿔 넣으면서 인터페이스는 그대로 유지하세요. 질의 임베딩, 검색, top-k 반환이라는 흐름은 변하지 않습니다.

Juno벡터는 어디에 저장할까 메모리에서는 질의마다 모든 벡터를 훑지만, 이건 컬렉션이 커지기 전까지는 괜찮습니다. 그 이후엔 벡터 스토어가 인덱싱된 최근접 탐색을 제공해줍니다. 이 비대칭을 기억하세요. 문서는 한 번 임베딩되고 인덱싱되어 영원히 재사용되지만, 질의는 매 요청마다 새로 임베딩됩니다. 선불 비용은 한 번 치르고 나눠서 갚는 셈이라 서비스 비용은 낮게 유지됩니다. 메모리에서 시작하고, 규모나 지속성이 요구되면 스토어로 바꿔 넣으세요.

메모리에서는 질의를 모든 청크 벡터와 비교하는 선형 탐색을 하게 됩니다. 정확하지만 질의당 O(n)이라서 수만 개 어딘가에서부터 확장성이 무너집니다. **벡터 스토어**는 이 탐색을 근사 최근접 탐색 인덱스(ANN)로 대체합니다. 모든 벡터를 확인하지 않는 대신 약간의 recall을 희생하고 큰 속도 향상을 얻는 방식입니다. 이 맞바꿈은 조절 가능한 값이며, 프로덕션 규모에서는 인덱스가 가끔 진짜 최근접 벡터를 놓칠 수 있다는 사실을 받아들여야 하고, 이는 곧 recall@k에 그대로 반영됩니다.

"한 번만 임베딩"이라는 비대칭이 비용 구조를 결정합니다. 인덱싱은 코퍼스 전체를 한 번, 큰 규모의 선불 비용으로 임베딩합니다. 서비스는 질의 하나를 임베딩하고 요청당 검색 한 번을 실행합니다. 그러니 안정 상태의 지연 시간 예산은 대략 다음과 같습니다: 질의 임베딩, 인덱스 검색, 그리고 생성 호출. 검색은 보통 가장 작은 비중을 차지하고, 생성 호출이 지연 시간을 지배하며, 질의 임베딩은 검색을 시작하기 전에 반드시 치러야 하는 고정 비용입니다. 각 구간을 측정해볼 가치가 있습니다. 사람들은 실제 지연 시간이 계속 생성 단계에 있었는데도 더 빠른 벡터 스토어를 찾아 나서곤 하기 때문입니다.

그리고 신선도(staleness) 문제가 있습니다. 이는 메모리 기반의 간단한 예제에서는 절대 드러나지 않는 실패 유형입니다. 인덱스는 스냅샷이고, 원본 문서는 계속 바뀝니다. 정책이 변경되거나 문서가 수정되어도 오래된 청크는 여전히 인덱스에 남아 과거를 가리키고 있고, 검색은 이를 완전한 확신을 갖고 서비스합니다. 그러니 인덱스는 한 번 쓰고 끝나는 게 아닙니다. 변경된 문서를 다시 임베딩하는 재인덱싱 경로가 필요하고, 이상적으로는 각 청크에 메타데이터(소스 id, 버전, 타임스탬프)를 붙여서 오래된 항목을 무효화하거나 걸러낼 수 있어야 합니다. 갱신 주기는 여러분의 정보가 얼마나 빨리 바뀌는지에 맞춰 정하고, "인덱스가 최신 상태"라는 것은 확인해야 할 대상이지 그냥 믿고 넘어갈 대상이 아닙니다.

Juno벡터는 어디에 저장할까 벡터 스토어는 정확한 선형 탐색을 근사 최근접 탐색 인덱스로 바꿔줍니다. 더 빠르지만 진짜 최근접 벡터를 놓칠 수 있고, 이는 결국 recall@k에 반영됩니다. 임베딩은 코퍼스에 대해 한 번, 질의마다 한 번씩 이루어지므로, 안정 상태의 지연 시간은 질의 임베딩 더하기 검색 더하기 생성이며, 보통 생성 호출이 지배적입니다. 그러니 스토어를 탓하기 전에 먼저 측정하세요. 간단한 예제에서는 절대 드러나지 않는 함정은 신선도입니다. 인덱스는 스냅샷이고 원본은 계속 바뀌므로, 재인덱싱 경로를 만들고 인덱스가 최신이라고 믿는 대신 청크에 버전과 타임스탬프를 남겨두세요.

근거 확보와 출처 표시

생성 단계는 결국 프롬프트이며, 어떻게 작성하느냐가 RAG가 실제로 지어낸 답변을 줄여주는지를 결정합니다. 두 가지 지시가 핵심적인 역할을 합니다. 모델에게 제공된 컨텍스트에서 답하도록 지시하고, 컨텍스트에 답이 없으면 그렇다고 말하도록 지시하는 것입니다.

python
context_text = "\n\n".join(retrieved_chunks)

system_prompt = f'''아래 컨텍스트만 사용해서 질문에 답하세요.
컨텍스트에 답이 없다면 "그 정보는 갖고 있지 않습니다"라고 말하세요.
답변에서 컨텍스트의 관련 부분을 인용하세요.

컨텍스트:
"""
{context_text}
"""'''

여기에는 앞서 배운 두 가지 내용이 함께 담겨 있습니다. 구분자와 "이것만 사용해서 답하라"는 명시적인 지시는 프롬프팅에서 나온 것입니다. "그 정보는 갖고 있지 않습니다"라고 말할 수 있게 허용하는 것은 환각 방어책입니다. 도망갈 구멍이 있는, 근거에 기반한 모델은 추측만 하도록 내버려진 모델보다 훨씬 덜 지어냅니다. 근거 확보와 도망갈 구멍, 이 둘이 함께 지어낸 답변을 줄여줍니다. 출처를 인용하도록 요청하면 사용자가 확인할 수 있는 근거도 함께 생깁니다.

Juno근거 확보와 출처 표시 생성 단계는 결국 프롬프트이며, 이것이 RAG가 제대로 작동하는지를 결정합니다. 모델에게 제공된 컨텍스트에서만 답하고, 답이 거기 없으면 그렇다고 말하도록 지시하세요. 이런 근거 확보에, 모른다고 인정할 수 있는 허락을 더하면 지어낸 답변이 크게 줄어듭니다. 출처를 인용하도록 요청하면 사용자가 확인할 근거가 생기고, 검색이 실패했을 때 여러분에게도 신호가 됩니다.

검색이 완벽한 컨텍스트를 건네줘도 모델은 여전히 그걸 무시할 수 있습니다. 답변을 컨텍스트에 묶어두는 건 생성 프롬프트이므로, 신중하게 작성해야 합니다. 모델에게 제공된 컨텍스트에서 답하고, 컨텍스트에 답이 없으면 그렇다고 인정하고, 사용한 부분을 인용하도록 지시하세요.

python
context_text = "\n\n".join(f"[{i}] {c}" for i, c in enumerate(retrieved_chunks))

system_prompt = f'''아래 컨텍스트만 사용해서 답하세요.
컨텍스트에 답이 없다면 정확히 이렇게 답하세요: "그 정보는 갖고 있지 않습니다."
사용한 청크 번호를 [2]처럼 인용하세요.

컨텍스트:
"""
{context_text}
"""'''

청크에 번호를 매기면 "출처를 인용하라"는 지시가 프로그램적으로 확인할 수 있는 인용으로 바뀝니다. 모델이 청크 [2]를 가리키면, 청크 [2]가 실제로 그 주장을 뒷받침하는지 확인할 수 있습니다. 이것이 RAG에서 가장 자주 놓치는 실패 유형, 즉 "답은 맞지만 검색된 청크는 틀린" 경우를 잡아내는 방법입니다. 이 경우 모델은 그럴듯하게 들리는 정답을 내놓지만, 제공된 컨텍스트는 그 답을 실제로 뒷받침하지 않습니다. 인용이 없으면 근거에 기반한 답변인지 그저 운 좋은 추측인지 구분할 수 없습니다.

도망갈 구멍은 지시 자체만큼이나 중요합니다. 컨텍스트에서만 답하라는 지시를 받았지만 실패할 수 있는 허락을 받지 못한 모델은, 형편없는 검색 결과에서도 뭔가를 억지로 짜맞춰냅니다. "그 정보는 갖고 있지 않습니다"라는 정확한 문자열처럼 명시적인 도망갈 구멍을 주면, 검색 실패는 자신 있게 지어낸 답변이 아니라 깔끔한 빈틈으로 드러납니다. 여기 나온 메시지 형태는 OpenAI 스타일이고, 시스템 다음에 컨텍스트를 두는 배치 방식은 제공업체마다 다를 수 있지만, 원칙은 그대로 적용됩니다.

Juno근거 확보와 출처 표시 생성 프롬프트가 답변을 컨텍스트에 묶어둡니다. 컨텍스트에서만 답하고, 없으면 인정하고, 청크를 인용하세요. 청크에 번호를 매겨서 인용을 검증 가능하게 만드세요. 그러면 답은 맞지만 검색된 청크가 그걸 뒷받침하지 않는, 은근히 까다로운 실패를 잡아낼 수 있습니다. 모델에게 정확한 도망갈 문자열을 주세요. 그러지 않으면 부실한 검색 결과가 자신 있는 오답으로 짜맞춰집니다. 형태는 제공업체마다 다르지만, 시스템 다음에 컨텍스트를 두는 구조는 유지됩니다.

근거 확보는 검색과 모델 사이의 계약이며, 여러분이 쓸 수 있는 가장 값싼 신뢰성 레버입니다. 그러니 프롬프트를 여기에 투자하세요. 모델에게 제공된 컨텍스트에서 답하고, 컨텍스트가 답을 뒷받침하지 않으면 고정된 포기 문자열을 반환하고, 사용한 청크 id를 인용하도록 지시하세요.

python
context_text = "\n\n".join(f"[{c['id']}] {c['text']}" for c in retrieved)

system_prompt = f'''아래 컨텍스트만 사용해서 답하세요.
컨텍스트가 답을 뒷받침하지 않으면 정확히 이렇게 답하세요: "그 정보는 갖고 있지 않습니다."
각 주장마다 대괄호로 청크 id를 인용하세요. 예: [doc12].

컨텍스트:
"""
{context_text}
"""'''

인용은 장식이 아니라 근거 확보를 측정 가능하게 만드는 수단입니다. 출력에 청크 id가 있으면, 인용된 청크가 실제로 그 주장을 담고 있는지 자동으로 확인할 수 있고, 이를 통해 근거에 기반한 답변과 유창한 추측을 구분할 수 있습니다. 이것은 또한 단순한 RAG를 무력화시키는 실패 유형을 잡아내는 방법입니다. 답이 그럴듯하게 맞아 보이지만 검색된 컨텍스트가 실제로는 이를 뒷받침하지 않는 경우, 즉 검색이 실패해서 모델이 그 빈틈을 파라미터에 저장된 기억으로 채운 경우입니다. id가 없으면 표본 확인만 할 수 있지만, id가 있으면 평가(evals)(정답을 알고 있는 질문 세트를 별도로 마련해두고 자동으로 채점하는 방식)에서 근거 확보 정도를 점수로 매기고 정확성을 하나의 숫자로 지켜볼 수 있습니다.

프로덕션에서 유의할 두 가지가 있습니다. 포기 경로는 저렴하고 명시적으로 허용되어야 합니다. 실패할 권한이 없는 모델은 항상 무언가를 만들어내려 하고, 그러면 부실한 검색 결과는 깔끔한 빈틈이 아니라 자신 있는 오답이 됩니다. 그리고 여기 나온 메시지 구조는 OpenAI SDK 형태이며, 시스템과 사용자 역할을 나누는 방식과 컨텍스트를 붙이는 방식은 제공업체마다 다를 수 있으므로, 이 배치 방식은 이동 가능하다고 보되 정확한 필드 이름은 그렇지 않다고 보세요. 그 아래에 깔린 변치 않는 원칙은 이렇습니다. 모델을 절대 출처로 삼지 말고, 인용하고 확인할 수 있는 출처를 말로 옮기는 역할만 맡기세요.

Juno근거 확보와 출처 표시 근거 확보는 계약입니다. 컨텍스트에서만 답하고, 정해진 문자열로 포기하고, 청크 id를 인용해서 정확성을 눈으로 확인하는 대신 평가에서 점수로 측정할 수 있게 만드세요. 인용은 답이 맞지만 뒷받침되지 않는 경우, 즉 검색이 실패하고 모델이 기억으로 빈틈을 메운 경우를 잡아내는 방법입니다. 포기 경로를 저렴하고 명시적으로 허용해두세요. 그러지 않으면 부실한 검색이 자신 있는 오답으로 변합니다. 형태는 제공업체마다 다르지만 구조는 이동하고, 필드 이름은 이동하지 않습니다.

최근접 탐색을 넘어서: 하이브리드 검색과 재순위화

순수한 벡터 검색은 의미를 기준으로 매칭하며, 대부분의 경우 이게 원하는 방식입니다. 하지만 맹점이 있습니다. 중요한 정확한 단어를 놓칠 수 있다는 점입니다. E_4021 같은 오류 코드나 특정 제품명을 검색하면, 의미 기반 검색은 같은 주제에 관한 청크들을 돌려주면서도 정확한 문자열을 담고 있는 그 청크를 빠뜬 채로 넘길 수 있습니다.

이 문제를 해결하는 방법에는 이름이 있습니다. **하이브리드 검색**입니다. 의미 기반 검색과 예전 방식의 키워드 검색을 결합해서, 정확한 용어와 전반적인 의미 둘 다 반영되도록 하는 것입니다. RAG를 시작하는 데 이걸 굳이 만들 필요는 없습니다. 정확한 용어가 중요하고 순수 벡터 검색이 자꾸 그걸 놓칠 때 도입하세요.

Juno최근접 탐색을 넘어서: 하이브리드 검색과 재순위화 벡터 검색은 의미를 매칭하는데, 이건 누군가 정확한 코드나 제품명을 검색해서 정확한 청크가 빠뜬 채 넘어갈 때까지는 훌륭합니다. 하이브리드 검색은 평범한 키워드 검색을 섞어서 정확한 용어도 반영되게 합니다. 처음에는 없이 시작해도 되고, 정확 일치 실패가 쌓이는 걸 보게 되면 그때 추가하면 됩니다.

최근접 탐색 기반 벡터 검색은 의미를 매칭하며, 이것이 곧 약점이기도 합니다. 오류 코드, SKU, 함수명, 드문 고유명사처럼 정확히 필요한 토큰을 담고 있는 청크보다, 주제적으로만 가까운 청크를 더 위에 두는 경우가 생길 수 있습니다. 임베딩은 이런 것들을 "대략 이 정도 의미 영역"으로 뭉개버리고, 그 과정에서 정확한 문자열은 흐려집니다.

**하이브리드 검색**은 두 개의 검색기를 실행해서 결과를 합칩니다. 의미를 위한 벡터 검색과, 키워드 검색(고전적인 용어 매칭, 흔히 BM25를 씁니다. 질의의 정확한 단어를 얼마나 많이 담고 있는지, 그 단어들이 얼마나 드문지를 기준으로 문서에 점수를 매기는 방식)을 함께 사용합니다. 두 결과에서 후보를 가져와 순위를 결합하면, 정확한 용어든 의미상의 유사성이든 어느 한쪽에서 이긴 청크가 위로 올라올 수 있습니다. 실질적인 이득은 순수 벡터 검색이 헛짚는 바로 그런 질문들에서 recall이 올라간다는 점입니다.

두 번째 기법은 **재순위화(re-ranking)**입니다. 1차로 뽑은 후보들을 더 정확하지만 더 비싼 모델로 다시 채점하는 2차 과정입니다. 패턴은 "넉넉하게 가져온 뒤 좁히기"입니다. 저렴한 방식으로 넉넉한 집합, 이를테면 상위 20개를 검색해온 다음, 재순위화 모델이 각 후보를 질의와 함께 읽고 순서를 다시 매기게 하고, 실제로 보낼 상위 4개만 남깁니다. 1차 검색은 빠르지만 대략적이고, 재순위화 모델은 느리지만 정밀하므로, 짧은 후보 목록에만 실행합니다. 그 결과 재순위화 모델의 비용을 인덱스 전체에 치르지 않고도 더 나은 최종 k개를 얻을 수 있습니다.

Juno최근접 탐색을 넘어서: 하이브리드 검색과 재순위화 순수 벡터 검색은 오류 코드나 SKU 같은 정확한 문자열을 놓칩니다. 임베딩이 이런 것들을 의미 영역 안으로 흐려버리기 때문입니다. 하이브리드 검색은 키워드 매칭(BM25)을 더해서 정확한 용어도 의미와 함께 반영되게 합니다. 재순위화는 "저렴하게 넓게, 그다음 정밀하게"라는 패턴입니다. 빠른 검색으로 넓게 가져온 다음, 짧은 목록을 더 느리지만 더 정확한 모델로 다시 채점해서 가장 좋은 몇 개만 남깁니다. 비싼 채점 모델을 전체에 돌리지 않고도 더 좋은 최종 결과를 얻습니다.

순수한 근사 최근접 탐색은 강력한 기본값이지만, 알려진 실패 지점이기도 합니다. 이는 의미상의 근접성을 최적화하므로, 질의가 오류 코드, 식별자, SKU, 드문 고유명사처럼 정확한 토큰에 좌우될 때 특히 성능이 떨어집니다. 임베딩이 그 토큰을 의미의 이웃 영역으로 압축해버려서, 그 안에서는 정확한 문자열들이 서로 뭉개지기 때문입니다. 이는 튜닝으로 해결할 문제가 아니라, 밀집 벡터(dense vector)가 원래 하는 일 그 자체입니다.

**하이브리드 검색**은 밀집 검색(벡터, 의미)과 희소 검색(키워드 용어 매칭, 보통 BM25를 사용하며, 정확한 용어 빈도를 용어의 희귀도로 가중해서 점수를 매김)을 함께 실행하고 결과 목록을 융합함으로써 이 문제를 다룹니다. 흔히 상호 순위 융합(reciprocal rank fusion)을 사용하는데, 이는 비교할 수 없는 원본 점수 대신 각 항목의 순위 위치를 기준으로 두 순위를 결합하는 방식입니다. 이 이득은 정확 일치 질의에 대한 recall@k로 측정할 수 있으며, 이는 순수 밀집 검색이 조용히 놓치는 부분입니다. 비용은 두 번째 인덱스와 융합 단계이므로, 트래픽에 식별자나 드문 용어가 섞여 있을 때 확실히 이득을 보고, 전부 자연어로 바꿔 쓴 질의일 때는 이득이 덜합니다.

**재순위화**는 다른 종류의 실패를 겨냅니다. 1차 검색(밀집, 희소, 또는 하이브리드)은 큰 인덱스에서 빠르게 동작하도록 만들어져 있어서 순위를 대략적으로만 매깁니다. 재순위화 모델은 크로스 인코더(cross-encoder, 질의와 후보 청크를 함께 읽고 관련도를 함께 채점하는 모델로, 미리 계산해둔 두 벡터를 비교하는 방식과는 다릅니다)로, 더 정확하지만 코퍼스 전체에 돌리기엔 너무 느립니다. 그래서 저렴한 검색기로 상위 20~50개 정도를 넉넉하게 가져온 다음, 그 짧은 목록만 재순위화하고 최종 몇 개만 남깁니다.

이것이 대부분의 프로덕션 RAG에서 가장 큰 recall 개선을 가져오는 방법입니다. "답은 맞지만 청크가 틀린" 실패를 직접적으로 공략해서, 단지 주제적으로 관련된 청크들보다 실제로 답을 뒷받침하는 청크를 위로 끌어올리며, 이 이득은 재순위화 단계 전후의 recall@k로 측정합니다. 비용을 예산에 넣으세요. 재순위화 모델은 실제 요청 경로에 지연 시간과 질의당 비용을 더하므로, 이론적으로 채점할 수 있는 후보 수가 아니라 여러분의 지연 시간 목표를 기준으로 넉넉하게 가져올 개수를 정해야 합니다.

Juno최근접 탐색을 넘어서: 하이브리드 검색과 재순위화 밀집 벡터는 정확한 토큰을 흐리게 만들므로, 하이브리드 검색은 희소 키워드 검색(BM25)을 밀집 검색과 함께 실행하고 상호 순위 융합으로 목록을 합쳐서 식별자와 드문 용어에 대한 recall@k를 되찾습니다. 재순위화는 더 큰 이득을 가져옵니다. 저렴하게 넉넉히 가져온 다음, 질의와 청크를 함께 읽는 크로스 인코더로 짧은 목록을 다시 채점하는데, 이는 답을 뒷받침하는 청크를 위로 끌어올려서 "답은 맞지만 청크가 틀린" 문제를 직접 고쳐줍니다. 재순위화 전후로 recall@k를 측정하고, 재순위화 모델의 지연 시간과 질의당 비용을 실제 요청 경로의 목표에 맞춰 예산으로 잡으세요.

실전에서

최소한의 RAG 답변 예제입니다. 임베딩 장에서 만든 searchembed 헬퍼를 재사용합니다.

python
def answer_from_docs(question, index):
    # 1. 검색: 질문에 가장 잘 맞는 상위 청크들
    query_vector = embed(question)
    scored = [{"text": item["text"], "score": cosine_similarity(query_vector, item["vector"])} for item in index]
    top = sorted(scored, key=lambda x: x["score"], reverse=True)[:3]

    # 2. 생성: 그 청크들만 사용해서 답하기
    context = "\n\n".join(t["text"] for t in top)
    response = client.chat.completions.create(
        model=MODEL,
        messages=[
            {"role": "system", "content": f'이 컨텍스트만 사용해서 답하세요. 여기 없으면 모른다고 말하세요.\n\n"""{context}"""'},
            {"role": "user", "content": question},
        ],
    )
    return response.choices[0].message.content

가장 가까운 청크 세 개를 검색해서 프롬프트에 넣고, 모델이 그것들을 바탕으로 답하게 하는 것입니다. 이것이 **RAG 파이프라인**의 전부입니다. 임베딩 검색이 모델 호출에 재료를 공급하고, 근거에 기반한 프롬프트가 이를 하나로 묶어줍니다. 품질의 거의 전부는 올바른 청크를 검색하는 데 달려 있습니다.

Juno실전에서 최소한의 RAG 답변은 임베딩 검색이 모델 호출에 재료를 공급하는 구조입니다. 가장 가까운 청크들을 검색해서 프롬프트에 넣고, 모델에게 그것들만 사용해서 답하게 하세요. 검색이 사실을 제공하고, 근거에 기반한 프롬프트가 답을 그 사실에 묶어둡니다. 품질의 거의 전부는 올바른 청크를 검색하는 데 달려 있으니, 시간을 거기에 쓰세요.

동작하는 RAG 호출은 검색, 근거 확보, 생성이 순서대로 이어지며, 앞에서 다룬 인용 방식도 함께 넣습니다.

python
def answer_from_docs(question, index, k=4):
    top = retrieve(question, index, k)  # 패턴 섹션 참고
    context = "\n\n".join(f"[{i}] {t['text']}" for i, t in enumerate(top))
    response = client.chat.completions.create(
        model=MODEL,
        messages=[
            {"role": "system", "content":
                f'이 컨텍스트만 사용해서 답하세요. 여기 없으면 '
                f'"그 정보는 갖고 있지 않습니다"라고 답하세요. [1]처럼 청크를 인용하세요.\n\n"""{context}"""'},
            {"role": "user", "content": question},
        ],
    )
    return response.choices[0].message.content

전체적으로 보면 임베딩 검색이 모델 호출에 재료를 공급하고, 근거에 기반한 프롬프트가 이를 하나로 묶어주는 구조입니다. chat.completions 형태는 OpenAI 스타일이며, 메시지 역할과 호출 시그니처는 제공업체마다 다르지만, "검색-근거 확보-생성"이라는 흐름은 어디서나 똑같습니다.

답이 틀리면 **단계 순서대로 디버깅**하세요. 먼저 검색된 청크를 확인하세요. 관련 텍스트가 top에 들어왔나요? 그렇지 않다면 문제는 검색 단계에 있습니다. k를 올리거나, 청킹을 고치거나, 하이브리드나 재순위화를 추가하세요. 프롬프트 문제가 아닙니다. 올바른 청크가 검색되었는데도 답이 여전히 틀렸다면, 그때는 프롬프트 문제입니다. 이 순서를 지키면 검색 버그를 두고 프롬프트 문구를 조정하느라 시간을 낭비하지 않게 됩니다. 그런 식으로는 절대 해결되지 않습니다.

Juno실전에서 동작하는 RAG 호출은 검색, 인용을 통한 근거 확보, 생성으로 이어지며, OpenAI 스타일의 메시지 형태는 필드 이름은 아니더라도 구조 면에서는 다른 제공업체에도 그대로 옮길 수 있습니다. 정해진 순서로 디버깅하세요. 먼저 검색된 청크를 살펴보세요. 올바른 텍스트가 애초에 들어오지 않았다면 검색(k, 청킹, 하이브리드, 재순위화)을 고치세요, 프롬프트가 아닙니다. 올바른 청크가 컨텍스트에 들어온 다음에야 틀린 답은 프롬프트 문제가 됩니다.

실전에서는 "검색-순위화-생성" 파이프라인이 하나의 함수이며, 계측해야 할 대상은 각 단계 사이의 접합부입니다.

python
def answer_from_docs(question, index, k_fetch=20, k_final=4):
    candidates = retrieve(question, index, k=k_fetch)   # 밀집(또는 하이브리드) 검색
    top = rerank(question, candidates)[:k_final]        # 크로스 인코더 재순위화
    context = "\n\n".join(f"[{c['id']}] {c['text']}" for c in top)
    response = client.chat.completions.create(
        model=MODEL,
        messages=[
            {"role": "system", "content":
                f'이 컨텍스트만 사용해서 답하세요. 뒷받침되지 않으면 '
                f'"그 정보는 갖고 있지 않습니다"라고 답하세요. [doc12]처럼 id를 인용하세요.\n\n"""{context}"""'},
            {"role": "user", "content": question},
        ],
    )
    return response.choices[0].message.content, [c["id"] for c in top]

답변과 함께 청크 id를 반환하는 것이 이 시스템을 관찰 가능하게(observable) 만드는 핵심입니다. 무엇이 검색되었는지, 무엇이 인용되었는지, 인용된 id가 실제로 답을 뒷받침하는지를 로그로 남길 수 있으며, 이는 평가(evals)에서 여러분의 정확성 신호가 됩니다. chat.completions 형태는 OpenAI SDK 방식입니다. 흐름은 이동 가능하다고 보되, 필드 이름은 제공업체마다 다르다고 보세요.

이 파이프라인의 지연 시간과 비용 구조는 머릿속에 담아둘 가치가 있습니다. 최적화는 시간이 실제로 쓰이는 곳으로 향해야 하기 때문입니다.

  • 질의 임베딩은 작지만 고정된 비용입니다.
  • 1차 검색은 저렴합니다.
  • 재순위화는 넉넉하게 가져오는 크기에 비례해 질의당 실제 비용을 더합니다.
  • 생성 호출은 지연 시간과 비용 모두를 지배합니다.

그러니 더 빠른 벡터 스토어를 찾아 나서기 전에, 검색이 정말로 병목이었는지 먼저 확인하세요. 보통은 생성이 병목이었습니다.

그리고 실패를 단계 순서대로 디버깅하세요. 검색된 집합, 재순위화된 집합, 인용, 프롬프트 순입니다. 올바른 청크가 아예 candidates에 들어오지도 않은 채 틀린 답이 나왔다면 이건 검색 버그이며, 어떤 프롬프트 변경도 애초에 존재하지 않던 청크를 고쳐줄 수 없습니다.

Juno실전에서 파이프라인은 검색, 재순위화, 근거 확보, 생성이며, 답변 옆에 청크 id를 반환하는 것이 관찰 가능성을 만들어줍니다. 검색된 것과 인용된 것을 로그로 남기면 추측 대신 평가에서 정확성을 점수로 매길 수 있습니다. 비용 구조를 지켜보세요. 생성이 지연 시간과 비용을 지배하므로, 벡터 스토어를 바꾸기 전에 검색이 정말 병목이었는지 확인하세요. 검색된 것, 재순위화된 것, 인용된 것, 프롬프트 순서로 디버깅하세요. 애초에 후보 집합에 들어오지 않은 청크는 어떤 프롬프트로도 고칠 수 없습니다. 형태는 OpenAI SDK 방식이며, 흐름은 이동하지만 필드 이름은 그렇지 않습니다.

RAG가 필요 없을 때

RAG는 공짜가 아니며, 항상 정답도 아닙니다. 다음의 경우에는 건너뛰세요.

  • 데이터가 작아서 프롬프트에 다 들어갈 때. 전체 지식 베이스가 몇 문단 정도라면 그냥 직접 붙여넣으세요. RAG는 한 번에 다 보낼 수 없을 만큼 많을 때를 위한 것입니다.
  • 긴 컨텍스트 모델이 전부 담을 수 있을 때. 일부 모델은 매우 큰 입력을 받아서 문서 전체를 한 번에 넣을 수 있는데, 이는 검색 시스템을 구축하는 것보다 일이 적을 수 있습니다.

그리고 진짜 한계를 기억하세요. RAG의 품질은 대부분 검색의 품질입니다. 검색 단계가 잘못된 청크를 돌려주면, 완벽한 프롬프트로도 답을 구할 수 없습니다. 실제 RAG 시스템에서 대부분의 노력은 최종 모델 호출이 아니라 올바른 텍스트를 검색하는 데 들어갑니다.

임베딩과 RAG는 모델 자체를 바꾸지 않고도 질문 시점에 지식을 제공해줍니다. 다음 장인 파인튜닝에서는 또 다른 선택지를 다룹니다. 여러분의 예제로 모델 자체를 바꾸는 방법, 그리고 그것이 프롬프팅이나 RAG보다 나은 상황을 살펴봅니다.

JunoRAG가 필요 없을 때 데이터가 프롬프트에 붙여넣을 만큼 작거나, 긴 컨텍스트 모델이 전부를 한 번에 담을 수 있다면 RAG를 건너뛰세요. 그리고 진짜 한계를 기억하세요. RAG의 품질은 대부분 검색의 품질이므로, 검색이 잘못된 청크를 돌려주면 어떤 프롬프트도 답을 구할 수 없습니다. 실제 작업의 대부분은 최종 호출이 아니라 올바른 텍스트를 검색하는 데 있습니다.

RAG는 **인프라**입니다. 인덱스, 임베딩 단계, 검색 단계, 재인덱싱 경로가 필요하며, 데이터가 한 번에 보내기엔 너무 크고 재학습이 답이 될 만큼 자주 바뀌지 않을 때에만 그 오버헤드가 정당화됩니다. 더 가벼운 방법으로 해결되면 건너뛰세요.

  • 코퍼스가 프롬프트에 다 들어갈 때. 몇 문단이나 작은 정책 문서라면 그대로 컨텍스트에 넣으세요. RAG는 한 번에 보낼 수 있는 양보다 많을 때에만 그만한 가치가 있습니다.
  • 긴 컨텍스트 모델이 원본 전체를 담을 수 있을 때. 문서가 큰 윈도우에 들어간다면, 전체를 그대로 보내는 것이 여러분이 직접 구축하고 조정해야 할 검색 파이프라인보다 나을 수 있습니다. 다만 매 호출마다 그만큼의 토큰 비용을 치르고 lost-in-the-middle 문제도 여전히 겪게 됩니다.

지배적인 사실을 기억하세요. RAG의 품질은 검색의 품질에 갇혀 있습니다. 완벽한 프롬프트도 잘못된 청크 위에서는 여전히 틀린 답을 냅니다. 그러니 작업과 이득은 최종 모델 호출이 아니라 인덱스와 검색 단계에 있습니다. 사실을 제공하는 대신 행동 자체를 바꿔야 할 때는 다음 장인 파인튜닝이 다루는 문제입니다. 이는 RAG와는 다른 문제를 해결합니다.

JunoRAG가 필요 없을 때 RAG는 실제 오버헤드가 있는 인프라입니다. 그러니 코퍼스가 프롬프트에 다 들어가거나 긴 컨텍스트 모델이 원본 전체를 삼킬 수 있다면 건너뛰세요. 다만 긴 컨텍스트 방식은 매 호출마다 토큰 비용을 치르고 여전히 중간 지점에서 정확도가 떨어진다는 걸 기억하세요. 지배적인 사실은 그대로입니다. 품질은 검색에 갇혀 있으므로, 완벽한 프롬프트도 잘못된 청크 위에서는 틀린 답을 냅니다. 인덱스와 검색에 투자하세요, 최종 호출이 아니라요.

RAG는 최신성과 근거 확보를 얻는 대가로, 직접 구축하고 조정하고 모니터링하고 재인덱싱해야 하는 검색 시스템을 요구합니다. 그러니 이 결정은 기본값이 아니라 비용 대비 효익을 따져야 하는 판단입니다. 더 가벼운 방식이 기준을 넘으면 건너뛰세요.

  • 작고 안정적인 코퍼스. 프롬프트에 들어가고 거의 바뀌지 않는다면 컨텍스트에 넣고, 프롬프트 캐싱에 의존해서 고정된 블록을 반복 호출에서 저렴하게 서비스하세요. 여기서 검색 파이프라인은 이득 없는 오버헤드입니다.
  • 긴 컨텍스트, 낮은 재사용. 원본이 큰 윈도우에 들어간다면 전체를 그대로 보내는 게 구축 과정을 건너뛸 수 있지만, 매 요청마다 그 입력 토큰 비용을 치르고 recall은 여전히 중간 지점에서 무너지므로, 볼륨이나 문서 크기가 커지면 RAG에 밀리게 됩니다.

지배적인 제약은 이 장 전체를 관통하는 것과 같습니다. 종단 간 품질은 recall@k에 갇혀 있습니다. 어떤 프롬프트도, 어떤 모델 업그레이드도, 어떤 재순위화도 검색이 애초에 컨텍스트에 넣지 않은 사실을 복구해줄 수 없습니다. 그러니 엔지니어링 투자는 검색, 청킹, 하이브리드, 재순위화, 평가, 재인덱싱으로 향해야 하고, 모델 호출은 제대로 맞추기 가장 쉬운 부분입니다. 그리고 RAG와 파인튜닝을 **서로 다른 차선**에 두세요. 검색은 무엇이 사실인지를 제공하고 소스가 바뀌면 최신 상태를 유지하는 반면, 파인튜닝은 모델이 어떻게 반응하는지를 형성하며 사실을 주입하는 방법으로는 오래되고 출처를 알 수 없는 방식입니다. 지식에는 검색을, 형태에는 파인튜닝을 쓰고, 어느 쪽에도 다른 쪽의 역할을 맡기지 마세요.

JunoRAG가 필요 없을 때 RAG는 최신성과 근거 확보를 얻는 대가로, 직접 구축하고 재인덱싱해야 하는 시스템을 요구합니다. 코퍼스가 작고 안정적이라면(붙여넣고 프롬프트 캐싱에 의존하세요), 또는 재사용이 적으면서 긴 윈도우에 들어간다면 건너뛰세요. 모든 걸 관통하는 제약은 recall@k입니다. 검색이 애초에 드러내지 않은 사실은 이후 어떤 것으로도 복구할 수 없으므로, 모델 호출이 아니라 검색에 투자하세요. 차선을 깨끗하게 유지하세요. 검색은 무엇이 사실인지를 담당하고, 파인튜닝은 어떻게 들리는지를 담당하며, 어느 쪽에도 다른 쪽의 역할을 맡기지 마세요.