AI 공부

12. RAG의 한계를 어떻게 깰까 — Advanced RAG (Semantic Chunking·Reranking·Query Rewriting 등)

kdb1248 2026. 5. 9. 21:32

목차


0. 들어가며
1. Advanced RAG로 - 10가지 개선 기법
2. 개선 ① - LLM 기반 지능형 청킹
3. 개선 ② - Reranking
4. 개선 ③ - Query Rewriting
5. 최종 파이프라인 - 모두 합치기
6. 마무리


 

0. 들어가며

지난 글에서 RAG 평가 시스템을 만들고, 기본 RAG의 한계를 점수로 정량화했다.

요약하면 이렇다.

- 검색 메트릭(MRR / nDCG / Keyword Coverage)에서 전반적으로 점수가 낮고, 특히 spanning·holistic 카테고리가 약했다.
- 답변 메트릭에서 풀네임 누락 같은 completeness 문제가 잡혔다.
- 청크 크기·임베딩 모델만 바꾸는 단순 튜닝으로는 이 한계를 못 깬다는 결론이었다.

이번 글에서는 이 한계를 깨기 위한 본질적 개선, 즉 Advanced RAG 기법으로 들어간다.

* 청크를 더 똑똑하게 만들고 (시맨틱 청킹 + 문서 전처리 결합)
* 검색 결과를 한 번 더 거르고 (Reranking)
* 사용자 질문을 검색 친화적으로 다듬는다 (Query Rewriting)

이 세 가지를 직접 구현하고, 마지막에 기본 RAG와 점수를 비교해본다.

그 전에 RAG라는 기술 자체가 여전히 유효한지 잠깐 짚고 시작한다.


1. Advanced RAG로- 10가지 개선 기법 

평가 결과를 통해 확인된 한계를 어떻게 극복할지 본격적으로 보자.

그 전에 먼저 RAG가 여전히 유효한 기술인지부터 짚고 넘어간다.

1) 잠깐, RAG는 죽었나?

요즘 LLM 트렌드를 보면 RAG가 좀 위태로워 보이는 변화가 있다.

첫째, 컨텍스트 창이 엄청나게 커졌다. Gemini나 GPT 계열 최신 모델은 100만 토큰까지 컨텍스트 윈도우로 받는다.

"그럼 그냥 문서 다 때려넣으면 되는 거 아냐?" 라는 의견이 나올 만하다.

 

둘째, 에이전틱 RAG가 등장했다. 에이전틱 RAG는 LLM이 매 턴마다 "벡터 검색을 할까, SQL 쿼리를 날릴까, 아니면 외부 API를 호출할까"를 직접 결정하고 도구를 골라 쓰는 구조다. 단순한 벡터 검색에서 한참 진화한 모습이다. "이런 게 있는데 일반 RAG는 구식 아니야?" 라는 주장도 있다.

근데 둘 다 "RAG가 죽었다"는 결론을 내릴 만한 근거는 아니다.

긴 컨텍스트를 그냥 다 넣는 방식은, KB(Knowledge Base, RAG가 참조하는 문서 저장소)가 GB 단위로 커지면 비용·속도가 감당이 안 된다. 매번 full로 넣으면 비싸고 느리다.

그리고 핵심 정보가 긴 컨텍스트 어디에 박혀 있느냐에 따라 LLM이 정보를 놓치는 "lost in the middle" 현상도 있어, 컨텍스트가 크다고 무조건 좋은 답을 보장하는 것도 아니다.

에이전틱 RAG도 결국 도구로 "벡터 검색"을 부르는 경우가 대부분이다. 즉 에이전틱 RAG도 RAG의 한 형태이지 RAG를 대체하는 게 아니다.

결론은 RAG는 안 죽었고, 오히려 그 안에서 더 정교한 형태로 진화하고 있다는 거다.

이번 섹션에서 보는 10가지 기법이 그 진화중 일부라고 볼 수 있다.

2) 10가지 기법 한눈에

강의에서는 RAG 개선 기법을 10가지로 정리한다. 표로 먼저 한 번 훑고, 아래에 하나씩 풀어쓴다.

 

# 기법 한 줄 설명 이번 글에서
1 시맨틱 청킹 의미 단위로 분할 직접 구현
2 인코더 선택 임베딩 모델 / 멀티모달 컨셉만
3 프롬프트 개선 날짜·이력·컨텍스트 구조화 컨셉만
4 문서 전처리 LLM이 청크 재작성 직접 구현 (1과 결합)
5 Query Rewriting 사용자 질문 → KB 친화 쿼리 직접 구현
6 Query Expansion 한 쿼리 → 여러 쿼리 컨셉만
7 Reranking 검색 후 LLM이 재정렬 직접 구현
8 Hierarchical RAG 요약 검색 → 드릴다운 컨셉만
9 Graph RAG 청크 관계 메타데이터 활용 컨셉만
10 Agentic RAG LLM이 도구 선택 컨셉만

 

각 기법을 좀 풀어보면 이렇다.

 

1. 시맨틱 청킹

- 단순히 "문자 1000자마다 자른다" 같은 길이 기반 청킹이 아니라, 텍스트의 의미 경계를 인식해서 자르는 방식

- 문단·주제가 바뀌는 지점에서 청크를 끊는다.

- 청크 안에 의미가 끊기지 않은 한 덩어리가 들어가서 검색 품질이 올라간다.

- 이번 글에서는 LLM에게 직접 청킹을 시키는 방식으로 구현한다.

 

2. 인코더(임베딩) 선택

- 도메인이나 언어에 잘 맞는 임베딩 모델을 고르는 거다.

- 한국어 데이터가 많은 KB라면 한국어 데이터로 학습된 모델이 유리하다.

- 멀티모달 RAG의 경우 이미지를 직접 임베딩하기보단, LLM이 캡션을 만들어 텍스트로 임베딩하는 쪽이 잘 작동하는 경우가 많다.

- 텍스트 임베딩이 보통 더 잘 학습되어 있기 때문이다.

 

3. 프롬프트 개선

- 시스템 프롬프트에 일반 컨텐츠, 현재 날짜, 검색된 컨텍스트, 대화 이력을 명시적으로 구조화해서 넣는 거다.

- 예를 들어 "최신 가격은?" 같은 시간 의존 질문은 모델에게 "오늘 날짜가 X다"라고 알려주지 않으면 정확히 답할 수 없다.

- 단순한 디테일이지만 답변 품질에 영향이 크다.

 

4. 문서 전처리

- LLM에게 문서나 청크를 KB 검색에 더 친화적인 형태로 다시 작성하게 시키는 거다.

- 예를 들어 항공권 가격 테이블을 그냥 두면 자연어 질문과 잘 매칭이 안 되는데, LLM에게 "이 표를 자연어로 풀어서 다시 써줘"라고 하면 검색에 잘 잡히는 형태가 된다.

- 1번(시맨틱 청킹)과 결합해서 "의미 단위로 자르면서 동시에 검색 친화적으로 재작성"하는 한 단계로 묶는 게 강력하다. 이번 글에서 그 결합 형태로 구현한다.

 

5. Query Rewriting

- 사용자가 자연어로 던진 질문을 LLM이 KB 검색에 최적화된 짧은 쿼리로 다시 쓰는 거다.

- 사용자 질문은 보통 "그 사람은 어떤 부서에서 일했지?" 같이 모호하거나 후속 질문 형태인데, 그대로 검색하면 답을 못 찾는다.

- 이전 대화 맥락을 결합해서 "Maxine Thompson 부서" 같은 명확한 검색 쿼리로 바꾸면 검색 적중률이 올라간다.

- 이번 글에서 직접 구현한다.

 

6. Query Expansion

- 한 질문을 LLM이 여러 변형 쿼리로 만들어 다중 검색을 한 뒤, 결과를 통합하는 방식이다.

- recall(자료가 컨텍스트에 들어왔는지)을 끌어올리는 데 효과적이다.

- 예를 들어 "보험 청구 절차"라는 질문을 "claim filing process", "insurance reimbursement steps", "policy claim workflow" 같은 여러 쿼리로 확장해 검색한다.

 

7. Reranking

- 1차 벡터 검색 결과를 LLM이 다시 관련성 순으로 재정렬하는 단계다.

- 1차 검색은 recall 우선으로 top-K를 크게(10-20개) 가져오고, 그걸 LLM이 보면서 정말 관련 있는 순서로 재배열한다.

- recall과 precision을 두 단계로 나눠서 각각 최적화하는 셈이다. 이번 글에서 직접 구현한다.

 

8. Hierarchical RAG

- KB에 미리 요약을 만들어두고, 질문이 오면 요약을 먼저 검색한 뒤 거기서 더 세부 청크로 드릴다운하는 2단계 구조다.

- 다중 문서에 걸친 holistic 질문 (예: "회사의 전체 보상 정책은?") 처리에 유리하다.

- 직원별 임금 정보가 흩어져 있다면, 직원별 임금 요약 페이지를 먼저 만든 다음 그걸 KB에 같이 넣어두는 식이다.

- 평가 결과로 본 한계에서 spanning/holistic 카테고리가 약했는데, 거기에 직접 들어맞는 기법이다.

 

*헷갈리기 쉬운 지점 하나. 청크에 summary가 들어간다고 해서 이게 표의 8번 "Hierarchical RAG"는 아니다.

Hierarchical은 요약 인덱스와 청크 인덱스를 따로 두고 두 단계로 검색하는 구조인데, 여기서는 headline·summary·원문을 한 덩어리로 합쳐 하나의 인덱스에 저장한다.

단일 인덱스, 단일 검색이다. 표의 4번 "문서 전처리"에 가까운 형태로 보면 된다.

 

9. Graph RAG

- 청크에 메타데이터로 관계 정보(예: "이 청크는 X 부서와 관련됨", "이 청크는 Y 정책과 연결됨")를 부여하고, 그래프 DB에 저장한다.

- 특정 청크를 조회할 때 관계로 연결된 청크도 함께 컨텍스트에 포함시킬 수 있다. "X와 협업했던 사람의 부서는?" 같이 관계 추적이 필요한 질문에 강하다.

 

10. Agentic RAG

- LLM이 매 턴마다 "벡터 검색을 할까, SQL을 쓸까, API를 호출할까"를 스스로 결정하고 도구를 골라 쓰는 구조다.

- 위 1~9를 도구로 갖되, 그중 무엇을 언제 쓸지를 LLM이 판단한다.

- 멀티턴으로 계속 작동하면서 답을 정제해나갈 수도 있다. 

3) 이번 글의 집중 포인트

10가지 중에 이번 글(강의)에서는 4개를 직접 구현한다.

  • 1번(시맨틱 청킹) + 4번(문서 전처리) → 한 단계로 결합
  • 7번(Reranking)
  • 5번(Query Rewriting)

이 네 가지를 고른 이유는, 평가 결과로 본 한계(검색 품질 부족, spanning 카테고리 약함, 풀네임 누락 같은 completeness 문제)를 구조적으로 풀어줄 수 있는 가장 효과적인 조합이기 때문이다.

 

나머지(인코더 선택, 프롬프트 구조화, Query Expansion, Hierarchical, Graph, Agentic)는 위 표 풀이 정도로 컨셉만 잡고 넘어간다. 

4) LangChain을 뺀다

이전 글에서는 LangChain의 DirectoryLoader, RecursiveCharacterTextSplitter, Chroma 헬퍼 등을 적극적으로 썼다. 

근데 Advanced RAG로 들어가면서는 LangChain을 빼고 native 코드로 다시 짠다.

 

이유는 명확하다. 청킹·검색·생성 로직 하나하나에 손을 대야 진짜 개선이 가능한데,

LangChain 추상화 레이어 위에서 그걸 하기엔 제약이 많다.

 

청크 구조를 바꾸려면 LangChain Document를 그대로 쓰기 어렵고, 검색 후 reranking을 끼워넣으려면 Chain을 분해해야 한다.

결국 추상화를 우회해서 native 코드를 써야 하는데, 그럴 거면 처음부터 native가 깔끔하다.

다만 처음부터 native로 가는 건 추천하지 않는다.

LangChain으로 한 번 RAG의 기본 흐름을 따라가본 다음, 한계가 보일 때 native로 내려가는 순서가 효율적이다. 

5) 느낀 점

  • 빠른 프로토타이핑 단계에서 LangChain은 정말 강력하지만, 프로덕션에서 RAG 품질을 더 높이려면 결국 추상화 레이어를 벗기게 된다는 점이 인상적이었다. 
  • "RAG가 죽었다"는 말이 자꾸 들리는데, 실제로는 RAG의 정의가 넓어지고 있다는 게 더 정확한 표현 같다. 단순 벡터 검색은 한계가 있지만, 그 위에 reranking, query rewriting, agentic 같은 레이어가 쌓이면서 RAG는 오히려 더 풍성해지는 중인 거 같다
  • 10가지 기법을 한 번에 적용하려고 하면 길을 잃기 쉽다. 평가 결과를 보고 약한 부분에 맞는 기법부터 하나씩 적용하는 순서가 중요해 보인다. 적용 우선순위를 "평가 점수가 가리키는 약점"에 맞춰서 정하는 게 중요하겠다.

2. 개선 ① - LLM 기반 지능형 청킹

표의 1번(시맨틱 청킹)과 4번(문서 전처리)을 한 단계로 묶어 구현한다.

LLM이 의미 단위로 청크를 자르면서, 동시에 각 청크에 검색용 메타텍스트(headline, summary)를 만들어 붙이는 구조다.

1) 단순 길이 기반 청킹의 한계

이전 글의 RAG 파이프라인에서는 LangChain의 RecursiveCharacterTextSplitter로 청크를 만들었다.

  • 문자 1000자마다 자르고, 200자씩 오버랩.
  • 문단 경계를 어느 정도 봐주긴 하지만, 결국 길이 기준이 우선이다.

이 방식의 문제는 두 가지다.

  • 의미 단위로 안 끊긴다. 한 주제가 두 청크에 걸쳐 잘리면, 검색 결과로 한쪽만 들어왔을 때 답을 못 만든다.
  • 청크 본문 그대로 임베딩되기 때문에 검색 키가 풍부하지 않다.
  • 짧은 사실 정보가 들어 있는 청크가 자연어 질문과 잘 매칭이 안 되는 경우가 많다.

해결의 핵심은 LLM에게 청킹을 맡기는 거다.

1) 길이가 아니라 "이 문장과 다음 문장이 같은 주제인가?"를 LLM이 판단하면서 자르고,

2) 각 청크에 검색을 도와줄 메타텍스트를 같이 만들어 붙인다.

2) Chunk Pydantic 모델 - 3-필드 구조

먼저 청크 하나의 형태를 Pydantic으로 정의한다.

class Chunk(BaseModel):
    headline: str = Field(description="A brief heading for this chunk, typically a few words, that is most likely to be surfaced in a query")
    summary: str = Field(description="A few sentences summarizing the content of this chunk to answer common questions")
    original_text: str = Field(description="The original text of this chunk from the provided document, exactly as is, not changed in any way")

    def as_result(self, document):
        metadata = {"source": document["source"], "type": document["type"]}
        return Result(
            page_content=self.headline + "\n\n" + self.summary + "\n\n" + self.original_text,
            metadata=metadata
        )


class Chunks(BaseModel):
    chunks: list[Chunk]

세 필드를 분리한 이유가 핵심이다.

  • headline : 청크의 짧은 제목. 검색 키 역할.
  • summary : 몇 문장 요약. 검색 키이자 컨텍스트 보강 역할.
  • original_text : 원문 그대로, 수정 X. 답변 근거 역할.

왜 이렇게 분리하나? 검색에 잘 잡혀야 하는 텍스트와, 답변 근거로 신뢰할 수 있어야 하는 텍스트의 역할이 다르기 때문이다.

  • headline·summary는 LLM이 가공한 텍스트라 자연어 질문과의 매칭은 좋지만, 그 자체는 원문이 아니라 환각이 섞일 가능성이 있다.
  • original_text는 손대지 말고 그대로 둬야 환각 위험이 없고 출처 인용·추적이 가능하다.

이 셋을 어떻게 쓰는지가 as_result 메서드에 들어 있다. 세 필드를 한 덩어리로 합쳐서 page_content를 만들고, 이걸 통째로 임베딩한다.

page_content = headline + "\n\n" + summary + "\n\n" + original_text

검색 시점에는 headline·summary 덕분에 자연어 질문과 매칭이 잘 되고, LLM이 답변할 때는 같은 청크 안에 original_text가 들어 있어 정확한 근거로 인용할 수 있다.

(앞 섹션에서도 짚었지만, 이건 Hierarchical RAG가 아니다. 인덱스도 한 개, 검색도 한 번이다. 표의 4번 "문서 전처리"에 가까운 형태다.)

3) 청킹 프롬프트

LLM에게 줄 프롬프트는 다음과 같다.

AVERAGE_CHUNK_SIZE = 500

def make_prompt(document):
    how_many = (len(document["text"]) // AVERAGE_CHUNK_SIZE) + 1
    return f"""
You take a document and you split the document into overlapping chunks for a KnowledgeBase.

The document is from the shared drive of a company called Insurellm.
The document is of type: {document["type"]}
The document has been retrieved from: {document["source"]}

A chatbot will use these chunks to answer questions about the company.
You should divide up the document as you see fit, being sure that the entire document is returned in the chunks - don't leave anything out.
This document should probably be split into {how_many} chunks, but you can have more or less as appropriate.
There should be overlap between the chunks as appropriate; typically about 25% overlap or about 50 words, so you have the same text in multiple chunks for best retrieval results.

For each chunk, you should provide a headline, a summary, and the original text of the chunk.
Together your chunks should represent the entire document with overlap.

Here is the document:

{document["text"]}

Respond with the chunks.
"""

핵심 디자인 포인트:

  • how_many : 문서 길이를 평균 청크 크기(500자)로 나눠서 적정 청크 수를 동적으로 결정한다. 짧은 문서는 1~2개, 긴 문서는 그에 맞게.
  • 오버랩 25% (약 50단어): 인접 청크 사이에 겹치는 텍스트가 있으면, 청크 경계에 걸친 정보도 양쪽에서 검색이 잡힌다.
  • "전체 문서가 빠짐없이 반환되어야 한다" 명시 : LLM이 임의로 일부를 누락하지 않도록.
  • type, source 정보 제공 : LLM이 도메인 컨텍스트를 알고 청킹·요약하도록.

이 프롬프트와 함께 LLM을 호출하는 코드는 다음과 같다.

def make_messages(document):
    return [
        {"role": "user", "content": make_prompt(document)},
    ]


def process_document(document):
    messages = make_messages(document)
    response = completion(model=MODEL, messages=messages, response_format=Chunks)
    reply = response.choices[0].message.content
    doc_as_chunks = Chunks.model_validate_json(reply).chunks
    return [chunk.as_result(document) for chunk in doc_as_chunks]


def create_chunks(documents):
    chunks = []
    for doc in tqdm(documents):
        chunks.extend(process_document(doc))
    return chunks

여기서도 structured output이 핵심이다.

  • response_format=Chunks : LLM이 자유 텍스트가 아니라 Chunks 스키마(chunks 배열, 각 원소가 Chunk 객체)에 맞춘 JSON으로 응답하게 강제.
  • Chunks.model_validate_json(...) : 받은 JSON을 Pydantic으로 검증·파싱.

이렇게 LLM 청킹의 결과물을 안전하게 받아낼 수 있다. 자유 텍스트로 받았다면 "어디부터 어디까지가 한 청크지?" 같은 파싱 문제로 골치를 앓았을 거다.

4) 자체 문서 로더 - LangChain DirectoryLoader 빼기

이전 글에서는 LangChain의 DirectoryLoader가 폴더에서 .md 파일을 알아서 긁어왔는데, 이번엔 그것도 직접 짠다.

KNOWLEDGE_BASE_PATH = Path("knowledge-base")

def fetch_documents():
    """A homemade version of the LangChain DirectoryLoader"""
    documents = []
    for folder in KNOWLEDGE_BASE_PATH.iterdir():
        doc_type = folder.name
        for file in folder.rglob("*.md"):
            with open(file, "r", encoding="utf-8") as f:
                documents.append({
                    "type": doc_type,
                    "source": file.as_posix(),
                    "text": f.read()
                })
    print(f"Loaded {len(documents)} documents")
    return documents

pathlib의 rglob으로 재귀적으로 .md 파일을 찾고, 폴더명을 type으로 쓴다 (products, employees, contracts, company)

class Result(BaseModel):
    page_content: str
    metadata: dict

이건 LangChain의 Document를 모방한 클래스다. as_result에서 봤듯이 page_content에 합쳐진 텍스트를 담고, metadata에 source/type 정보를 담는다.

5) ChromaDB에 저장

청크가 만들어졌으면 임베딩해서 벡터 DB에 넣어야 한다.

DB_NAME = "preprocessed_db"
collection_name = "docs"
embedding_model = "text-embedding-3-large"

def create_embeddings(chunks):
    chroma = PersistentClient(path=DB_NAME)
    if collection_name in [c.name for c in chroma.list_collections()]:
        chroma.delete_collection(collection_name)

    texts = [chunk.page_content for chunk in chunks]
    emb = openai.embeddings.create(model=embedding_model, input=texts).data
    vectors = [e.embedding for e in emb]

    collection = chroma.get_or_create_collection(collection_name)

    ids = [str(i) for i in range(len(chunks))]
    metas = [chunk.metadata for chunk in chunks]

    collection.add(ids=ids, embeddings=vectors, documents=texts, metadatas=metas)
    print(f"Vectorstore created with {collection.count()} documents")

흐름:

  1. ChromaDB의 PersistentClient를 띄우고, 같은 이름의 컬렉션이 있으면 삭제 (재생성 보장).
  2. 청크의 page_content를 한 번에 OpenAI 임베딩 모델로 보내서 벡터로 변환.
  3. ids, embeddings, documents, metadatas를 같이 add.

여기서도 LangChain의 Chroma 헬퍼를 쓰지 않고 ChromaDB 클라이언트를 직접 쓴다. 추상화 한 겹이 빠지면서 "내가 무엇을 저장하고 있는지" (page_content는 합쳐진 텍스트, metadata는 source/type)가 명확해진다.

6) t-SNE로 시각화 (재등장)

벡터 DB가 잘 쌓였는지 확인하는 단골 도구가 t-SNE 시각화다.

이전 글에서도 한 번 봤는데, 여기서는 청크 메타텍스트가 추가됨에 따라 클러스터링이 어떻게 변하는지 비교해볼 수 있다.

chroma = PersistentClient(path=DB_NAME)
collection = chroma.get_or_create_collection(collection_name)
result = collection.get(include=['embeddings', 'documents', 'metadatas'])
vectors = np.array(result['embeddings'])
metadatas = result['metadatas']
doc_types = [metadata['type'] for metadata in metadatas]
colors = [['blue', 'green', 'red', 'orange'][['products', 'employees', 'contracts', 'company'].index(t)] for t in doc_types]

tsne = TSNE(n_components=2, random_state=42)
reduced_vectors = tsne.fit_transform(vectors)

products(파랑), employees(초록), contracts(빨강), company(주황) 별로 색을 다르게 칠해 plot한다.

이전 글의 단순 청킹 결과와 비교했을 때, 청크 안에 headline과 summary가 들어가면서 같은 type끼리 더 뚜렷하게 뭉쳐 보이는 경향을 확인할 수 있다.

 

t-SNE 2D/3D 시각화 결과

7) 느낀 점

  • "청크 = 검색 단위"라는 정의를 "청크 = 검색용 메타 + 답변용 원문"으로 확장한 게 이번 기법의 핵심 같다. headline·summary로 검색 매칭률을 올리고, original_text로 답변 신뢰성을 지키는 역할 분리. 회사 RAG 기반 에이전트에도, 청크에 부가 정보를 LLM이 만들어 붙여 검색 품질을 끌어올리는 형태로 시도해볼 만한 패턴이다.
  • "시맨틱 청킹"이라는 단어는 회사에서도 자주 들었는데, 직접 작게나마 구현해보니까 "왜 길이 기반 청킹보다 좋은지"가 감이 잡혔다. 길이로 자르면 검색 시 어떤 청크가 잡혀도 의미가 끊겨 있지 않은지 보장이 안 되는데, LLM이 자르면 적어도 한 청크 안에서는 의미가 덜 끊기도록 만들 수 있는게 장점인거 같다. 
  • LLM 청킹은 비용이 들고 느리다는 단점이 있다. 큰 KB를 한 번에 청킹하기엔 부담이 크다. 근데 임베딩이나 청킹은 한 번 만들어두면 계속 쓰는 자산이라(물론 업뎃은 필요), 청킹 단계의 비용·시간보다는 검색 품질이 더 중요한 경우가 많다. 회사 RAG에서도 운영 트래픽이 아닌 "초기 인덱싱" 단계의 비용이라 감수할 만한 트레이드오프로 보인다.

3. 개선 ② - Reranking

LLM 청킹으로 청크 품질을 올렸으니, 이번엔 검색 단계 자체에 손을 댄다.

1) 왜 reranking이 필요한가

벡터 검색의 작동 원리는 단순하다. 질문을 임베딩하고, KB 안의 청크 임베딩과 코사인 유사도를 계산해서 가까운 순서대로 가져온다.

문제는 "임베딩 공간에서 가까운 것"과 "질문에 진짜 답이 되는 것"이 항상 일치하지는 않는다는 점이다.

  • 단어가 많이 겹치는 청크가 위로 올라오지만, 정작 질문의 답은 다른 청크에 있을 수 있다.
  • 같은 키워드가 들어갔다는 이유로 무관한 청크가 끌려올 수도 있다 

해결의 방향은 검색을 두 단계로 쪼개는 것이다.

  • 1단계 - 1차 검색 : recall 우선. top-K를 넉넉히(10-20개) 가져와서 정답 후보가 컨텍스트에 들어올 가능성을 최대한 높인다.
  • 2단계 - reranking : precision 우선. 1차 결과를 LLM이 보면서 질문에 가장 잘 맞는 순서로 다시 정렬한다.

벡터 검색은 의미 유사도를 잘 잡아주지만 질문의 의도까지 정밀하게 읽지는 못하고,

LLM은 질문 의도를 잘 읽지만 KB 전체를 훑기엔 너무 비싸다.

두 단계로 쪼개면 각자 잘 하는 걸 시킬 수 있다.

2) RankOrder Pydantic 모델과 rerank 코드

먼저 LLM이 반환할 형식을 Pydantic으로 정의한다.

class RankOrder(BaseModel):
    order: list[int] = Field(
        description="The order of relevance of chunks, from most relevant to least relevant, by chunk id number"
    )

LLM이 청크를 다시 정렬한 결과를 "ID 리스트"로 받는다. 청크 본문을 다시 출력하게 하면 토큰을 너무 많이 쓰니까 ID만 받는 거다.

rerank 함수는 다음과 같다.

def rerank(question, chunks):
    system_prompt = """
You are a document re-ranker.
You are provided with a question and a list of relevant chunks of text from a query of a knowledge base.
The chunks are provided in the order they were retrieved; this should be approximately ordered by relevance, but you may be able to improve on that.
You must rank order the provided chunks by relevance to the question, with the most relevant chunk first.
Reply only with the list of ranked chunk ids, nothing else. Include all the chunk ids you are provided with, reranked.
"""
    user_prompt = f"The user has asked the following question:\n\n{question}\n\nOrder all the chunks of text by relevance to the question, from most relevant to least relevant. Include all the chunk ids you are provided with, reranked.\n\n"
    user_prompt += "Here are the chunks:\n\n"
    for index, chunk in enumerate(chunks):
        user_prompt += f"# CHUNK ID: {index + 1}:\n\n{chunk.page_content}\n\n"
    user_prompt += "Reply only with the list of ranked chunk ids, nothing else."
    messages = [
        {"role": "system", "content": system_prompt},
        {"role": "user", "content": user_prompt},
    ]
    response = completion(model=MODEL, messages=messages, response_format=RankOrder)
    reply = response.choices[0].message.content
    order = RankOrder.model_validate_json(reply).order
    return [chunks[i - 1] for i in order]

흐름:

  1. system 프롬프트로 "넌 reranker야, ID 리스트만 반환해라" 역할 부여.
  2. user 프롬프트에 질문과 청크들을 ID(1, 2, 3...)와 함께 나열.
  3. LLM이 RankOrder 스키마로 ID 리스트 반환.
  4. 그 ID 순서대로 chunks를 재배열해서 반환.

여기서도 structured output(response_format=RankOrder)이 핵심이다.

LLM이 자유 텍스트로 "1번이 제일 좋고, 그 다음은 5번이고..." 식으로 답하면 파싱이 골치 아픈데, ID 리스트로 강제하면 한 줄에 처리된다.

 

벡터 검색에서 1차로 청크를 가져오는 함수도 짚고 가자.

RETRIEVAL_K = 10

def fetch_context_unranked(question):
    query = openai.embeddings.create(model=embedding_model, input=[question]).data[0].embedding
    results = collection.query(query_embeddings=[query], n_results=RETRIEVAL_K)
    chunks = []
    for result in zip(results["documents"][0], results["metadatas"][0]):
        chunks.append(Result(page_content=result[0], metadata=result[1]))
    return chunks

여기서도 LangChain의 retriever 추상화를 쓰지 않고, ChromaDB 클라이언트를 직접 호출한다.

RETRIEVAL_K=10이 기본값인데, reranking과 함께 쓸 때는 더 크게(20개 등) 가져와도 좋다.

3) 효과 검증 - Manchester University 사례

reranking이 정말 효과가 있는지 확인하기 위해 의도적으로 까다로운 질문 하나를 던져본다.

question = "Who went to Manchester University?"
RETRIEVAL_K = 20
chunks = fetch_context_unranked(question)
for index, c in enumerate(chunks):
    if "manchester" in c.page_content.lower():
        print(index)

출력:

6

→ 1차 검색 결과 20개 중에서 "manchester"라는 단어가 들어 있는 청크가 7번째(인덱스 6)에 있다. 즉 질문의 답에 직접 닿는 청크가 위쪽이 아니라 중간쯤에 있는 거다.

이걸 reranking에 통과시키면 어떻게 되는지 보자.

reranked = rerank(question, chunks)
for index, c in enumerate(reranked):
    if "manchester" in c.page_content.lower():
        print(index)

출력:

0

→ 0번. manchester가 들어 있는 청크가 가장 위로 올라왔다.

LLM이 청크들을 보면서 "아, 이 질문은 manchester university를 물어본 거니까 manchester가 들어 있는 청크가 1번이지" 라고 판단해서 재정렬해준 거다.

벡터 유사도만으로는 못 잡았던 의도를, LLM은 청크 내용을 직접 읽고 잡아낸다.

 

4) 1차 검색 + Reranking 한 함수로 합치기

이제 1차 검색과 reranking을 한 흐름으로 묶는다.

def fetch_context(question):
    chunks = fetch_context_unranked(question)
    return rerank(question, chunks)

이후 RAG 파이프라인에서는 그냥 fetch_context만 호출하면 1차 검색 + reranking이 한 번에 처리된다.

5) 느낀 점

  • 1차 검색의 top-K를 작게(3-5개) 두면 reranking 효과가 거의 없다. 1차 결과 안에 정답 후보가 충분히 들어와야 LLM이 그걸 위로 올릴 수 있는데, top-K가 작으면 후보 자체가 적어서 재정렬할 게 없다. 일부러 크게(10-20개) 가져오고 LLM이 거르는 구조가 정석이다. recall과 precision의 역할 분담이 명확해지는 지점이다.
  • 비용은 추가되지만 응답 정확도 체감 차이가 크다. 특히 이름·고유명사·동일 키워드가 여러 곳에 등장하는 KB에서 reranking이 강력하다. 
  • reranking 자체도 LLM 호출이라 latency가 추가된다. 실시간 챗봇이라면 이 latency가 서비스 응답시간에 영향을 줄 수 있어, "어떤 질문에 reranking을 적용할지"를 게이트웨이로 결정하는 등의 최적화가 추가로 필요해 보인다.

4. 개선 ③ - Query Rewriting

검색 품질의 시작점은 "좋은 검색 쿼리"다. 사용자가 던진 질문을 그대로 검색에 쓰는 게 늘 최선은 아니다.

1) 왜 필요한가

사용자 질문은 보통 두 가지 형태로 검색에 부적합하다.

첫째, 자연어 질문은 검색에 최적화되어 있지 않다. 사람은 "근데 그 IIOTY라는 상은 누가 받았어?" 같이 말하는데,

임베딩 검색에는 "IIOTY award winner" 같은 짧고 명확한 키워드 형태가 더 잘 맞는다.

 

둘째, 후속 질문은 단독으로 의미가 안 통한다. 멀티턴 대화에서 사용자가 "그 사람은 어떤 부서에서 일해?" 라고 던지면, "

그 사람"이 누구인지 이전 대화 맥락을 봐야 안다. 후속 질문을 그대로 검색하면 답을 못 찾는다.

해결책은 LLM에게 질문을 검색 쿼리로 다시 쓰게 시키는 것이다.

대화 히스토리를 같이 던져 주면, LLM이 맥락을 보고 짧고 구체적인 검색 쿼리로 변환한다.

2) rewrite_query 코드

def rewrite_query(question, history=[]):
    """Rewrite the user's question to be a more specific question that is more likely to surface relevant content in the Knowledge Base."""
    message = f"""
You are in a conversation with a user, answering questions about the company Insurellm.
You are about to look up information in a Knowledge Base to answer the user's question.

This is the history of your conversation so far with the user:
{history}

And this is the user's current question:
{question}

Respond only with a single, refined question that you will use to search the Knowledge Base.
It should be a VERY short specific question most likely to surface content. Focus on the question details.
Don't mention the company name unless it's a general question about the company.
IMPORTANT: Respond ONLY with the knowledgebase query, nothing else.
"""
    response = completion(model=MODEL, messages=[{"role": "system", "content": message}])
    return response.choices[0].message.content

프롬프트 핵심 디자인 포인트:

  • 대화 히스토리(history)를 같이 넣어 후속 질문을 처리할 수 있게 한다.
  • "VERY short specific question" 명시: 짧을수록 검색에 잘 잡힌다.
  • "Don't mention the company name" : 모든 청크가 같은 회사(Insurellm) 관련이라 회사명을 매번 넣으면 의미 없는 토큰만 늘어난다.
  • "Respond ONLY with the knowledgebase query" : LLM이 "여기 쿼리입니다: ..." 같은 부연 설명을 못 붙이게 강제.

3) 동작 예시

rewrite_query("Who won the IIOTY award?", [])

→ 히스토리가 없는 단독 질문도 검색에 더 친화적인 짧은 형태로 다듬어진다.

후속 질문 케이스도 그려보자. 사용자가 다음과 같이 멀티턴 대화를 한다고 해보자.

  • User: "Who won the IIOTY award in 2023?"
  • Bot: "Maxine Thompson won the IIOTY award in 2023."
  • User: "What department does she work in?"

마지막 질문 "What department does she work in?"을 그대로 검색하면 "she"가 누구인지 알 수 없으니 답을 못 찾는다.

그런데 rewrite_query가 히스토리를 보면, 직전 답변에 "Maxine Thompson"이 등장했다는 걸 인식해서 검색 쿼리를

"Maxine Thompson department" 같은 형태로 다시 써준다.

이렇게 변환된 쿼리로 검색하면 정확한 청크가 잡힌다.

4) 느낀 점

  • 검색 품질의 시작은 "좋은 쿼리". 사용자 입력을 LLM이 한 번 다듬는 단계가 작지만 큰 차이를 만든다.
    RAG의 다른 모든 개선이 좋아져도 검색 쿼리가 부실하면 효과가 반감된다.
  • 회사에서 멀티턴 대화 에이전트를 만들 때, 단순히 "이전 대화를 메모리로 저장"만 신경 썼었다. 근데 그 메모리를 검색 쿼리로 어떻게 압축·재구성할지(즉 query rewriting 단계)까지 같이 설계해야 멀티턴 RAG가 자연스러워진다는 걸 느꼈다. 메모리는 저장이고, query rewriting은 그 메모리를 검색용으로 가공하는 단계다.
  • rewrite_query 자체도 LLM 호출이라 latency가 한 단계 추가된다.

5. 최종 파이프라인 - 모두 합치기

지금까지 구현한 LLM 청킹, Reranking, Query Rewriting을 하나의 RAG 파이프라인으로 묶는다.

1) answer_question - 전체 흐름

def answer_question(question: str, history: list[dict] = []) -> tuple[str, list]:
    """
    Answer a question using RAG and return the answer and the retrieved context
    """
    query = rewrite_query(question, history)
    chunks = fetch_context(query)
    messages = make_rag_messages(question, history, chunks)
    response = completion(model=MODEL, messages=messages)
    return response.choices[0].message.content, chunks

흐름을 풀어보면 이렇다.

  1. rewrite_query : 사용자 질문 + 히스토리 → KB 검색용으로 다듬은 쿼리.
  2. fetch_context : 1차 벡터 검색(top-10) + LLM reranking → 정렬된 청크 리스트.
  3. make_rag_messages : 시스템 프롬프트에 청크들을 컨텍스트로 주입한 뒤 message 리스트 생성.
  4. completion : LLM이 컨텍스트를 보고 최종 답변 생성.

이전 글의 LangChain RAG 체인이 한 줄(chain.invoke)로 끝났던 것에 비하면 단계가 늘어 보이지만,

각 단계가 명확히 분리되어 있어서 어디서 문제가 나는지 추적하기가 쉽다.

2) 시스템 프롬프트 설계

LLM이 답변을 만들 때 쓰는 시스템 프롬프트는 다음과 같다.

SYSTEM_PROMPT = """
You are a knowledgeable, friendly assistant representing the company Insurellm.
You are chatting with a user about Insurellm.
Your answer will be evaluated for accuracy, relevance and completeness, so make sure it only answers the question and fully answers it.
If you don't know the answer, say so.
For context, here are specific extracts from the Knowledge Base that might be directly relevant to the user's question:
{context}

With this context, please answer the user's question. Be accurate, relevant and complete.
"""


def make_rag_messages(question, history, chunks):
    context = "\n\n".join(f"Extract from {chunk.metadata['source']}:\n{chunk.page_content}" for chunk in chunks)
    system_prompt = SYSTEM_PROMPT.format(context=context)
    return [{"role": "system", "content": system_prompt}] + history + [{"role": "user", "content": question}]

두 가지 디자인 포인트가 인상적이다.

첫째, 답변이 어떻게 평가될지를 모델에게 미리 알려준다.

"Your answer will be evaluated for accuracy, relevance and completeness."

이게 Day 4에서 LLM-as-a-Judge가 사용한 평가 차원과 정확히 같다.

LLM에게 "어떤 기준으로 채점될지"를 미리 알려주면 그 기준에 맞춰 답하려고 노력하게 된다. 메타 의식을 부여하는 셈이다.

 

둘째, 각 청크 앞에 출처(source)를 명시한다.

Extract from knowledge-base/employees/Maxine_Thompson.md:
[청크 본문]

이렇게 출처를 같이 넣으면 LLM이 답변할 때 "어느 문서에서 봤는지"를 인지하고 인용 정확도가 올라간다. 

3) before / after 비교

같은 테스트셋을 기본 RAG (이전 글의 LangChain RAG)와 Advanced RAG (지금까지 구현한 파이프라인)로 각각 돌려서 점수를 비교한다.

(이미지 - 기본 버전 점수)

(이미지 - Advanced 버전 점수)

 

스코어 카드를 비교해보면 패턴이 보인다.

  • 검색 메트릭이 전반적으로 올랐다. 
    • MRR 0.76 → 0.88, nDCG 0.77 → 0.85. 특히 약점이었던 카테고리들에서 큰 개선이 나왔다 (spanning 0.45 → 0.75, comparative 0.67 → 0.97).
    • LLM 청킹으로 청크에 headline·summary 메타텍스트가 붙으면서 다중 청크 질문도 검색 매칭이 잘 되는 것으로 해석할 수 있다.
  • 답변 메트릭은 결과가 살짝 섞여 있다. 
    • 평균은 다 올랐고(Accuracy 4.15 → 4.40, Completeness 4.03 → 4.19),
    • 특히 holistic 카테고리 정확도가 2.6 → 3.3으로 의미 있게 올라갔다.
    • 풀네임 누락 같은 completeness 문제가 메타텍스트 덕에 완화된 결과로 보인다.
  • 흥미로운 발견 하나. 
    • spanning은 검색 MRR이 가장 크게 올랐는데(+0.30), 답변 Accuracy는 오히려 떨어졌다(4.25 → 3.6).
    • 검색이 정답 자료는 잘 잡아오기 시작했지만, 여러 청크의 정보를 LLM이 통합해 답을 합성하는 단계에서 여전히 약하다는 뜻이다.
    • 검색 개선만으로 안 풀리는 약점은 hierarchical RAG나 단계적 추론 프롬프트 같은 추가 기법이 필요해 보인다 — 평가 시스템이 있어야 보이는 디테일이다.

 

4) 더 가볼 수 있는 길

이 파이프라인은 끝이 아니라 출발점이다.

  • 점수가 일정 수준 이상 나올 때까지 자동 재시도 : 답변 평가 점수가 임계치 이하면 다른 청크 조합 / 다른 프롬프트로 다시 시도하는 루프.
  • 외부 데이터 소스 연결 : Google Docs, Confluence, Notion 같은 협업 도구를 KB로 연결하면 회사 RAG로 확장 가능.
  • 에이전틱 RAG로 진화 : 매 질문마다 "벡터 검색을 할까, SQL 쿼리를 날릴까, API를 호출할까"를 LLM이 결정하는 구조. (표의 10번)

5) 느낀 점

  • 세 가지 개선이 곱셈 효과로 작용한다는 점이 인상적이다. 하나만 적용하면 효과가 작은데 셋을 다 적용하면 체감 차이가 크다. 측정 → 개선 → 재측정의 루프를 한 사이클 돌고 나니, "RAG는 한 번에 완성하는 게 아니라 평가 점수에 따라 반복적으로 다듬는 시스템이구나" 하는 감이 잡혔다.

6. 마무리

1) 이번 글 요약

이번 글에서는 지난 평가편에서 드러난 한계를 Advanced RAG 기법으로 풀어봤다.

- LLM 기반 지능형 청킹 (시맨틱 청킹 + 문서 전처리 결합) : 청크에 headline / summary / original_text 필드를 부여해 검색 매칭과 답변 신뢰성을 동시에 잡았다.
- Reranking : 1차 벡터 검색 결과를 LLM이 다시 정렬해서 정답 후보를 위로 올렸다.
- Query Rewriting : 사용자 질문을 KB 검색 친화적인 짧은 쿼리로 변환했다.


세 가지 개선을 바탕으로, 평가편에서 약했던 spanning 카테고리와 completeness 점수가 의미 있게 올라가는 걸 확인할 수 있었다.

 

2) Week 5 시리즈 전체 회고

5주 동안의 흐름을 돌아보면 이렇다.

  • Day 1~3 : 단순 키워드 매칭 → 벡터 임베딩 → LangChain 기반 RAG 구축.
  • Day 4 : 평가 메트릭 도입. RAG의 한계를 정량화.
  • Day 5 : 평가 결과를 보고 Advanced RAG 기법 적용.

5주차의 키워드를 한 단어로 줄이면 "측정"이다. "그럴듯한 RAG"에서 "측정 가능한 RAG"로 옮겨가는 과정이었다고 정리할 수 있다.

3) 다음 시리즈 예고

다음 주차부터는 잠시 RAG에서 벗어나, 모델 자체를 똑똑하게 만드는 영역으로 넘어간다.

  • Week 6 : 프론티어 모델 파인튜닝 — "THE PRICE IS RIGHT" 캡스톤 프로젝트. 데이터 큐레이션, 전처리, 전통 ML 베이스라인을 거쳐 프론티어 모델 fine-tuning까지.
  • Week 7 : 오픈소스 모델 파인튜닝 — QLoRA로 직접 fine-tuning, prompt data 구성, train/eval.

4) 마치며

5주차는 개인적으로 가장 실무에 직접 와닿는 내용이었고, 

계속 이론적으로 알고는 있었던 RAG를 실제 코드 단에서 구현해보며 더 깊게 파고들어가볼 수 있던게 좋았던 거 같다. 

특히나 RAG는 한번에 완성되는 게 아니고 trial & error가 필요하다고 느꼈다. 

뿐만 아니라, RAG 평가 프레임이나, RAG성능을 올리기 위한 Advanced RAG 기법 등이 회사에서 에이전트를 만드는 과정에서도 

참고할 만한 것들이 꽤 있었던 거 같아 의미 있었다. 


 

(꾸준히 공부하고 적을테니 많은 관심 부탁드립니다.)

 

Profile:
Linkedin