블로그 목록으로

[AI 에이전트] 4주차 RAG #2 실습 - Hybrid Search와 Cohere Re-ranking으로 다년도 의료급여 검색 정확도 개선하기

원문 보기
#AI Agent

FastAPI 기반 RAG 서버에 BM25+RRF Hybrid Search와 Cohere 재랭킹을 적용해 2025·2026년 의료급여 PDF를 검색했습니다. 4가지 후보군 설정 실험으로 Basic RAG 73.3% → Advanced RAG 96.7% 달성 과정을 기록...


지난 3주차 실습에서는 의료급여 PDF를 청킹하고 임베딩하여 Chroma 벡터 데이터베이스에 저장하는 기본 RAG 파이프라인을 구축했습니다.

이번 시간에는 한 단계 더 나아가 2025년과 2026년 의료급여 문서를 하나의 벡터 데이터베이스에서 관리하고, 질문에 맞는 연도의 정보를 정확하게 검색하는 RAG 시스템을 구현했습니다.

기본 벡터 검색만 사용한 RAG를 시작으로, 한국어 BM25 검색과 RRF 기반 Hybrid Search를 적용하고 Cohere Re-ranking까지 연결하여 검색 성능이 어떻게 달라지는지 비교했습니다.

또한 총 30개의 Golden Dataset을 구성하고 Basic RAG, Hybrid RAG, Advanced RAG를 동일한 질문으로 평가하여 검색 방식별 정확도를 정량적으로 확인했습니다.

이론 정리 글
https://velog.io/@hongchee/AI-%EC%97%90%EC%9D%B4%EC%A0%84%ED%8A%B8-4%EC%A3%BC%EC%B0%A8-RAG-2

실습 GitHub
https://github.com/DChanHong/aiagent-repo/blob/week-4/DChanHong/week-4/DChanHong/README.md


1. 실습 목표

이번 실습의 목표는 벡터 검색만 사용하는 기본 RAG의 한계를 확인하고, 키워드 검색과 Re-ranking을 결합하여 검색 품질을 개선하는 것입니다.

구현한 주요 기능은 다음과 같습니다.

  • 2025년과 2026년 의료급여 PDF 동시 인덱싱
  • 각 문서 청크에 source_year 메타데이터 저장
  • FastAPI 기반 RAG API 서버 구축
  • ChromaDB 기반 벡터 검색 구현
  • Kiwi 형태소 분석기를 적용한 한국어 BM25 검색 구현
  • 벡터 검색과 BM25를 결합한 Hybrid Search 구현
  • RRF를 이용한 검색 결과 융합
  • Cohere Re-ranker를 이용한 후보 문서 재정렬
  • Basic, Hybrid, Re-ranking 파이프라인별 엔드포인트 제공
  • Golden Dataset 30문항 기반 정확도 평가
  • 후보 문서 크기에 따른 CASE A~D 비교 실험

2. 개발 환경

이번 실습에서 사용한 환경은 다음과 같습니다.

구분사용 기술
운영체제macOS
PythonPython 3.12 이상
웹 프레임워크FastAPI, Uvicorn
LLMGoogle gemini-3-flash-preview
임베딩 모델OpenAI text-embedding-3-small
벡터 저장소ChromaDB
BM25rank_bm25BM25Okapi
한국어 토크나이저kiwipiepy의 Kiwi 형태소 분석기
Re-rankerCohere rerank-multilingual-v3.0
PDF 파서pdfplumber, pandas
소스 문서2025 알기 쉬운 의료급여제도.pdf
소스 문서2026 알기 쉬운 의료급여제도.pdf

필요한 패키지를 설치합니다.

pip install fastapi uvicorn pydantic pydantic-settings \
    langchain langchain-google-genai langchain-community \
    langchain-openai langchain-core chromadb pdfplumber \
    python-dotenv rank_bm25==0.2.2 \
    kiwipiepy==0.17.0 cohere

API 키는 .env 파일로 관리합니다.

OPENAI_API_KEY=YOUR_OPENAI_API_KEY
GEMINI_API_KEY=YOUR_GEMINI_API_KEY
COHERE_API_KEY=YOUR_COHERE_API_KEY

실제 API 키는 코드나 GitHub 저장소에 직접 작성하지 않아야 합니다.


3. 프로젝트 구조

프로젝트는 FastAPI 서버와 RAG 도메인 로직을 분리하는 구조로 작성했습니다.

week-4/DChanHong/
├── src/
│   ├── core/
│   │   └── config.py
│   ├── domains/
│   │   └── rag/
│   │       ├── service.py
│   │       ├── router.py
│   │       └── schemas.py
│   └── utils/
│       └── pdf_parser.py
├── data/
│   ├── golden_dataset.jsonl
│   ├── basic/
│   │   ├── 0/
│   │   └── 1/
│   ├── hybrid/
│   │   └── 0/
│   └── rerank/
│       ├── 0/
│       └── test_A~D/
├── run_case_a.py
├── run_case_b.py
├── run_case_c.py
├── run_case_d.py
├── main.py
└── requirements.txt

각 파일의 역할은 다음과 같습니다.

파일역할
config.pyAPI 키, 모델명 등 환경 변수와 공통 설정 관리
service.py인덱싱, Basic, Hybrid, Re-ranking 핵심 로직
router.pyFastAPI RAG 엔드포인트 정의
schemas.py요청과 응답 Pydantic 스키마 정의
pdf_parser.pyPDF 파싱과 표 Markdown 변환
golden_dataset.jsonl평가용 질문과 기준 정답 저장
run_case_a.py~run_case_d.py후보 문서 크기별 실험 실행
main.pyFastAPI 애플리케이션 실행 진입점

4. 전체 RAG 파이프라인

이번 프로젝트에서는 검색 방식을 세 단계로 구분했습니다.

Basic RAG

사용자 질문
→ ChromaDB 벡터 검색
→ 상위 10개 문서
→ 컨텍스트 구성
→ Gemini 답변 생성

Hybrid RAG

사용자 질문
├─ ChromaDB 벡터 검색 Top-20
└─ BM25 키워드 검색 Top-20
              ↓
        RRF 결과 융합
              ↓
         최종 Top-10
              ↓
        Gemini 답변 생성

Advanced RAG

사용자 질문
├─ ChromaDB 벡터 검색 Top-20
└─ BM25 키워드 검색 Top-20
              ↓
         중복 문서 제거
              ↓
       Cohere Re-ranking
              ↓
         최종 Top-10
              ↓
        Gemini 답변 생성

5. 다년도 PDF 인덱싱

이번 실습에서는 2025년과 2026년 의료급여 PDF를 하나의 ChromaDB에 저장합니다.

두 연도의 문서 내용이 유사하기 때문에, 질문에서 요구하는 연도와 다른 문서가 검색될 가능성이 있습니다.

이를 방지하기 위해 PDF 파일명에서 연도를 추출하고 각 Documentsource_year 메타데이터로 저장했습니다.

year_match = re.search(r"202\d", file_name)
source_year = (
    year_match.group()
    if year_match
    else "unknown"
)

doc = Document(
    page_content=combined_content,
    metadata={
        "source": file_name,
        "source_year": source_year,
        "page": page_num + 1,
    },
)

예를 들어 2026년 PDF에서 생성된 청크에는 다음과 같은 메타데이터가 저장됩니다.

{
    "source": "2026 알기 쉬운 의료급여제도.pdf",
    "source_year": "2026",
    "page": 10,
}

LLM에 컨텍스트를 전달할 때도 출처와 연도를 함께 표시합니다.

context_text = (
    f"[출처: {source_year}년 "
    f"{source_file}, {page}p]\n"
    f"{doc.page_content}"
)

이를 통해 LLM이 질문에서 요구한 연도의 문서만 구분하여 참고하도록 유도했습니다.


6. PDF 파싱과 표 처리

3주차와 마찬가지로 pdfplumber를 이용해 PDF를 파싱했습니다.

의료급여 문서에는 본인부담금과 적용 기준이 표 형태로 포함되어 있기 때문에, 표 구조를 유지하는 것이 중요합니다.

표가 존재하는 페이지에서는 일반 텍스트 아래에 pandas.DataFrame으로 변환한 Markdown 표를 추가했습니다.

combined_content += (
    "\n\n### [표 데이터]\n"
    + "\n".join(table_markdowns)
)

최종 Document에는 페이지의 일반 텍스트와 Markdown으로 변환된 표가 함께 포함됩니다.

다만 이번 프로젝트에서는 표를 별도의 Document로 분리하지 않고 페이지 단위 문서에 포함했습니다.

이 방식은 페이지의 전체 문맥을 함께 유지할 수 있지만, 하나의 페이지에 여러 표와 규정이 포함된 경우 검색 단위가 커질 수 있다는 한계도 있습니다.


7. 문서 청킹

PDF에서 추출한 문서는 RecursiveCharacterTextSplitter로 분할했습니다.

설정
chunk_size1,000
chunk_overlap200
Text SplitterRecursiveCharacterTextSplitter
시작 위치 저장add_start_index=True
text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=1000,
    chunk_overlap=200,
    add_start_index=True,
)

chunks = text_splitter.split_documents(
    all_documents
)

chunk_overlap=200을 적용하여 청크 경계에서 문장이 잘리더라도 앞뒤 문맥이 일정 부분 유지되도록 했습니다.

또한 add_start_index=True를 통해 각 청크가 원문에서 시작되는 위치를 메타데이터에 저장했습니다.


8. Golden Dataset 구성

세 가지 RAG 파이프라인을 동일한 기준으로 평가하기 위해 총 30개의 질문으로 Golden Dataset을 구성했습니다.

구분문항 수
전체30
2025년 관련 질문11
2026년 관련 질문19
Easy2
Medium12
Hard11
Cross-year5

Cross-year 문항은 2025년과 2026년의 규정 차이를 비교해야 하므로 두 문서의 정보를 동시에 검색해야 합니다.

대표 문항은 다음과 같습니다.

ID질문 요약기준 정답난이도
12026년 1종 수급권자 상급종합병원 외래 본인부담금방문당 2,000원, CT·MRI·PET 촬영 시 5%Hard
3항정신병 장기지속형 주사제 본인부담률 변화폭3%p 감소, 5%에서 2%Cross-year
4외래진료 적정관리 제도의 연간 방문 기준연간 365회 초과 시 30% 적용Hard
14경증 응급환자 본인부담 기준 변경의 의미응급증상 판단에서 KTAS 등급 체계로 객관화Cross-year
19폐쇄병동 가산율의 단계적 인상2025년 7월 12%, 2026년 1월 16%Cross-year

9. Basic RAG 구현

Basic RAG에서는 ChromaDB의 벡터 검색만 사용합니다.

전체 흐름은 다음과 같습니다.

질문
→ ChromaDB Similarity Search
→ 상위 10개 문서 검색
→ 컨텍스트 생성
→ Gemini 답변 생성
async def get_answer(
    self,
    request: QueryRequest,
) -> QueryResponse:
    retriever = self.vector_store.as_retriever(
        search_kwargs={"k": 10}
    )

    relevant_docs = retriever.invoke(
        request.question
    )

    return await self._generate_answer_from_docs(
        request,
        relevant_docs,
    )

질문에 특정 연도가 포함된 경우 해당 연도의 정보만 사용하도록 프롬프트에 명시했습니다.

prompt = f"""
아래 컨텍스트를 바탕으로 질문에 답하세요.

각 컨텍스트에는 출처 연도가 표시되어 있습니다.
질문이 특정 연도를 묻는 경우 해당 연도의 정보만 사용하세요.

컨텍스트에 없는 내용은
"정보를 찾을 수 없습니다"라고 답하세요.

컨텍스트:
{context}

질문:
{request.question}

답변:
"""

10. Basic RAG 평가 결과

Basic RAG는 총 30문항 중 22문항을 맞혔습니다.

항목결과
전체 문항30
정답22
오답8
전체 정답률73.3%
연도 검색 정확도100%

실제 저장된 최종 평가 결과의 난이도별 성능은 다음과 같습니다.

난이도문항 수정답 수정답률
Easy3266.7%
Medium13969.2%
Hard9777.8%
Cross-year5480.0%

Golden Dataset의 최초 분류와 최종 평가 보고서의 난이도별 문항 수에는 일부 차이가 있었지만, 전체 문항 수는 모두 30개로 동일했습니다.

주요 오답 원인

ID원인내용
0, 2, 5, 25검색 실패추상적인 질문 표현으로 관련 청크가 Top-10에 포함되지 않음
15수치 선택 오류유사한 수치가 여러 청크에 존재하여 잘못된 값을 선택
11, 18추론 실패문서에 직접 명시되지 않은 비교와 계산이 필요함

근로능력 유무, 입원 면제 원칙과 같이 추상적으로 표현된 질문은 질문과 문서 사이의 표면적인 표현 차이가 컸습니다.

벡터 검색은 의미적 유사성에 강하지만, 특정 키워드나 수치가 중요한 질문에서는 관련 청크를 놓치는 문제가 있었습니다.


11. 한국어 BM25 검색 구현

벡터 검색의 한계를 보완하기 위해 BM25 기반 키워드 검색을 추가했습니다.

ChromaDB는 Sparse Search를 직접 지원하지 않으므로, 애플리케이션 시작 시 ChromaDB의 전체 문서를 읽어 메모리 기반 BM25 인덱스를 별도로 생성했습니다.

def _sync_bm25_index(self):
    all_data = self.vector_store.get()

    documents = [
        Document(
            page_content=all_data["documents"][i],
            metadata=all_data["metadatas"][i],
        )
        for i in range(
            len(all_data["documents"])
        )
    ]

    tokenized_corpus = [
        korean_tokenizer(doc.page_content)
        for doc in documents
    ]

    self.bm25 = BM25Okapi(
        tokenized_corpus
    )

벡터 데이터베이스의 문서와 BM25 문서 목록을 동기화하여 동일한 데이터셋에서 두 가지 검색을 수행할 수 있도록 구성했습니다.


12. Kiwi 한국어 형태소 분석기

영어는 공백을 기준으로 단어를 분리해도 비교적 정확하게 토큰화할 수 있습니다.

하지만 한국어는 단어에 조사와 어미가 결합되므로 단순 공백 분리만 사용하면 BM25 검색 정확도가 낮아질 수 있습니다.

예를 들어 다음 표현은 같은 핵심 단어를 포함하지만 공백 분리 결과가 다를 수 있습니다.

수급권자의 본인부담금
수급권자는 본인부담금을
수급권자 본인부담금

이를 해결하기 위해 Kiwi 형태소 분석기를 적용했습니다.

kiwi = Kiwi()

def korean_tokenizer(
    text: str,
) -> List[str]:
    return [
        token.form
        for token in kiwi.tokenize(text)
    ]

Kiwi를 적용하면 임신부, 수급권자, 본인부담금과 같은 주요 단어를 조사와 분리하여 BM25가 더 정확하게 매칭할 수 있습니다.


13. Hybrid Search 구현

Hybrid Search에서는 벡터 검색과 BM25 검색을 동시에 실행합니다.

async def get_hybrid_answer(
    self,
    request: QueryRequest,
) -> QueryResponse:
    vector_results = (
        self.vector_store.similarity_search(
            request.question,
            k=20,
        )
    )

    tokenized_query = korean_tokenizer(
        request.question
    )

    bm25_results = self.bm25.get_top_n(
        tokenized_query,
        self.bm25_docs,
        n=20,
    )

    # 이후 RRF 융합

각 검색 방식은 다음 역할을 담당합니다.

검색 방식주요 역할
벡터 검색의미와 문맥이 유사한 문서 검색
BM25질문과 정확한 키워드가 일치하는 문서 검색

벡터 검색은 질문과 문서의 표현이 달라도 의미가 유사하면 검색할 수 있습니다.

반면 BM25는 365회, 근로능력, 임신부처럼 정확한 숫자나 용어가 포함된 문서를 찾는 데 효과적입니다.


14. RRF 검색 결과 융합

벡터 검색 점수와 BM25 점수는 서로 다른 기준과 범위를 사용합니다.

벡터 검색의 유사도 점수와 BM25 점수를 단순 합산하면 특정 검색기의 점수가 과도하게 반영될 수 있습니다.

이를 해결하기 위해 점수 자체가 아니라 각 검색 결과의 순위를 이용하는 RRF를 직접 구현했습니다.

RRF는 Reciprocal Rank Fusion의 약자입니다.

각 검색 결과에서 문서가 높은 순위에 있을수록 더 높은 점수를 부여합니다.

RRF 점수
= 1 / (순위 + RRF 상수)

프로젝트에서는 RRF 상수로 k=60을 사용했습니다.

k = 60
rrf_scores = {}

for rank, doc in enumerate(
    vector_results
):
    doc_id = doc.page_content

    rrf_scores[doc_id] = (
        rrf_scores.get(doc_id, 0)
        + 1 / (rank + 1 + k)
    )

for rank, doc in enumerate(
    bm25_results
):
    doc_id = doc.page_content

    rrf_scores[doc_id] = (
        rrf_scores.get(doc_id, 0)
        + 1 / (rank + 1 + k)
    )

두 검색 결과에서 동일한 문서가 높은 순위에 등장하면 점수가 누적됩니다.

all_docs_map = {
    doc.page_content: doc
    for doc in (
        vector_results
        + list(bm25_results)
    )
}

sorted_contents = sorted(
    rrf_scores.keys(),
    key=lambda content: rrf_scores[content],
    reverse=True,
)

final_docs = [
    all_docs_map[content]
    for content in sorted_contents[:10]
]

Hybrid Search의 최종 설정은 다음과 같습니다.

설정
Vector SearchTop-20
BM25 SearchTop-20
RRF 상수60
최종 LLM 전달 문서Top-10

15. Hybrid RAG 평가 결과

Hybrid RAG는 총 30문항 중 27문항을 맞혔습니다.

항목결과
전체 문항30
정답27
오답3
전체 정답률90.0%
연도 검색 정확도100%

난이도별 결과는 다음과 같습니다.

난이도문항 수정답 수정답률
Easy2150.0%
Medium121083.3%
Hard1111100%
Cross-year55100%

Basic RAG에서 실패했던 ID 25 질문은 1종과 2종의 결정적 차이를 묻는 질문이었습니다.

벡터 검색에서는 관련 문서를 찾지 못했지만, BM25가 근로능력이라는 핵심 키워드를 정확하게 매칭하면서 정답으로 전환되었습니다.

Basic RAG: 73.3%
Hybrid RAG: 90.0%

개선 폭: +16.7%p

특히 Hard와 Cross-year 문항에서 모두 100%의 정확도를 기록했습니다.

정확한 용어와 숫자를 찾는 BM25가 벡터 검색의 부족한 부분을 보완한 결과였습니다.


16. Cohere Re-ranking 적용

Hybrid Search는 검색 후보를 확보하는 데 효과적이지만, 최종 순위가 질문과의 실제 관련성을 완벽하게 반영하지는 않습니다.

이를 보완하기 위해 Hybrid Search에서 확보한 후보 문서를 Cohere Re-ranker로 다시 정렬했습니다.

사용한 모델은 다음과 같습니다.

rerank-multilingual-v3.0

이 모델은 한국어를 포함한 다국어 질문과 문서의 관계를 함께 분석할 수 있습니다.

먼저 벡터 검색과 BM25에서 각각 20개의 후보 문서를 가져옵니다.

vector_results = (
    self.vector_store.similarity_search(
        request.question,
        k=20,
    )
)

bm25_results = self.bm25.get_top_n(
    tokenized_query,
    self.bm25_docs,
    n=20,
)

두 검색 결과에 동일한 문서가 포함될 수 있으므로 중복을 제거합니다.

seen_contents = set()
candidates = []

for doc in (
    vector_results
    + list(bm25_results)
):
    if doc.page_content in seen_contents:
        continue

    candidates.append(doc)
    seen_contents.add(doc.page_content)

중복 제거 후 후보 문서를 Cohere에 전달합니다.

doc_texts = [
    doc.page_content
    for doc in candidates
]

response = self.cohere_client.rerank(
    model="rerank-multilingual-v3.0",
    query=request.question,
    documents=doc_texts,
    top_n=10,
)

Re-ranking 결과의 문서 인덱스를 이용해 최종 문서 목록을 구성합니다.

final_docs = [
    candidates[result.index]
    for result in response.results
]

return await self._generate_answer_from_docs(
    request,
    final_docs,
)

최종 설정은 다음과 같습니다.

설정
벡터 검색 후보20
BM25 검색 후보20
최대 후보 문서40
중복 처리문서 내용 기준 제거
Re-ranking 모델rerank-multilingual-v3.0
최종 LLM 전달 문서Top-10

17. Advanced RAG 평가 결과

Cohere Re-ranking을 적용한 Advanced RAG는 30문항 중 29문항을 맞혔습니다.

항목결과
전체 문항30
정답29
오답1
전체 정답률96.7%
연도 검색 정확도100%

난이도별 결과는 다음과 같습니다.

난이도문항 수정답 수정답률
Easy22100%
Medium121191.7%
Hard1111100%
Cross-year55100%

Hybrid RAG에서 실패했던 ID 2 질문은 입원 본인부담금 면제의 원칙적인 이유를 묻는 질문이었습니다.

Cohere Re-ranker가 표에 포함된 조건과 제도적 관계를 더 정확하게 분석하면서 정답 문서를 상위로 재배치했습니다.

Basic RAG: 73.3%
Hybrid RAG: 90.0%
Advanced RAG: 96.7%

Basic RAG와 비교하면 총 23.4%p의 정확도 향상이 발생했습니다.


18. Bi-encoder와 Cross-encoder

이번 프로젝트는 Two-stage Retrieval 구조를 사용합니다.

첫 번째 단계에서는 Bi-encoder 방식의 벡터 검색으로 전체 문서에서 후보 문서를 빠르게 찾습니다.

두 번째 단계에서는 Cross-encoder 방식의 Re-ranker가 질문과 각 후보 문서를 함께 입력받아 관련성을 다시 계산합니다.

구분Bi-encoderCross-encoder
적용 위치1차 검색2차 Re-ranking
입력 방식질문과 문서를 각각 인코딩질문과 문서를 하나의 쌍으로 입력
속도빠름상대적으로 느림
정확도후보 검색에 적합세밀한 관련성 판정에 적합
처리 범위전체 문서검색된 소수의 후보 문서
프로젝트 적용ChromaDB 벡터 검색Cohere Re-ranking

전체 문서에 Cross-encoder를 바로 적용하면 질문과 모든 문서 조합을 계산해야 하므로 처리 비용이 매우 커집니다.

따라서 다음과 같은 구조를 사용했습니다.

전체 문서
→ Bi-encoder로 후보 20개 검색
→ BM25로 후보 20개 검색
→ 중복 제거
→ 최대 40개 후보 확보
→ Cross-encoder Re-ranking
→ 최종 10개 문서 선택

이 구조를 통해 검색 속도와 정확도를 함께 확보할 수 있었습니다.


19. CASE A~D 후보군 크기 실험

Re-ranking에 전달하는 후보 문서 수가 결과에 어떤 영향을 주는지 확인하기 위해 네 가지 추가 실험을 진행했습니다.

실험DenseSparse최대 후보최종 Top-K정확도
CASE A: Small & Sharp101020570.0%
CASE B: Semantic Heavy3010401080.0%
CASE C: Keyword Heavy1030401076.7%
CASE D: High Recall50501001580.0%
Best2020401096.7%

CASE A: 후보 문서 부족

Dense 10개와 BM25 10개만 검색했을 때 정확도는 70%까지 낮아졌습니다.

Re-ranker의 성능이 뛰어나더라도 후보 문서 안에 정답 청크가 없다면 정답을 선택할 수 없습니다.

정답 문서가 후보에 없음
→ Re-ranking으로 해결할 수 없음

CASE B: 벡터 검색 편중

벡터 검색 후보를 30개로 늘리자 의미 중심의 질문은 개선되었습니다.

하지만 365회처럼 정확한 숫자가 중요한 질문에서는 BM25 후보가 부족해 정답 문서를 놓쳤습니다.

CASE C: BM25 검색 편중

BM25 후보를 30개로 늘리자 숫자와 키워드가 중요한 질문은 개선되었습니다.

반면 벡터 검색 후보가 줄어들면서 제도적 맥락을 파악해야 하는 질문의 성능이 낮아졌습니다.

CASE D: 후보 문서 과다

벡터 검색과 BM25에서 각각 50개씩, 최대 100개의 후보 문서를 Re-ranking에 전달했습니다.

하지만 정확도는 80%에 그쳤습니다.

후보 문서를 많이 가져오는 것만으로 성능이 계속 향상되지는 않았습니다.

불필요한 문서가 늘어나면서 Re-ranking에도 노이즈가 증가한 것으로 볼 수 있습니다.

최적 설정

가장 높은 성능은 다음 설정에서 발생했습니다.

Vector Search Top-20
+
BM25 Search Top-20
=
최대 후보 40개
→ Cohere Re-ranking
→ 최종 Top-10

Dense와 Sparse 후보를 균형 있게 구성했을 때 96.7%의 정확도를 기록했습니다.


20. 최종 파이프라인 비교

세 가지 RAG 파이프라인의 결과를 비교하면 다음과 같습니다.

방식전체 정확도EasyMediumHardCross-year
Basic RAG73.3%66.7%69.2%77.8%80.0%
Hybrid RAG90.0%50.0%83.3%100%100%
Advanced RAG96.7%100%91.7%100%100%

검색 흐름별 특징은 다음과 같습니다.

방식검색 구조특징
BasicVector Top-10의미 검색만 사용
HybridVector 20 + BM25 20 → RRF → Top-10의미와 키워드를 함께 반영
AdvancedVector 20 + BM25 20 → Re-ranking → Top-10질문과 문서의 세부 관계를 다시 평가
Basic RAG
73.3%

→ BM25와 RRF 적용

Hybrid RAG
90.0%

→ Cohere Re-ranking 적용

Advanced RAG
96.7%

최종적으로 Basic RAG 대비 23.4%p의 성능 향상을 확인했습니다.


21. FastAPI 서버 구성

이번 프로젝트는 단순 스크립트가 아니라 FastAPI 서버 형태로 구현했습니다.

서버는 다음 명령어로 실행합니다.

uvicorn main:app \
    --host 0.0.0.0 \
    --port 8000 \
    --reload

PDF 인덱싱은 다음 엔드포인트를 이용합니다.

POST /api/v1/rag/index

각 검색 방식은 별도의 엔드포인트로 제공합니다.

POST /api/v1/rag/query
POST /api/v1/rag/hybrid-query
POST /api/v1/rag/rerank-query

각 엔드포인트의 역할은 다음과 같습니다.

엔드포인트역할
/api/v1/rag/queryChromaDB 벡터 검색 기반 Basic RAG
/api/v1/rag/hybrid-queryBM25와 벡터 검색을 결합한 Hybrid RAG
/api/v1/rag/rerank-queryHybrid 후보에 Cohere Re-ranking 적용

Golden Dataset 평가는 다음 엔드포인트에서 실행합니다.

POST /api/v1/rag/basic
POST /api/v1/rag/hybrid
POST /api/v1/rag/rerank

평가 결과는 다음 경로에 JSONL 형식으로 저장합니다.

data/{version}/{index}/evaluation_results.jsonl

검색 방식을 별도 API로 분리하여 동일한 질문을 각각 실행하고 결과를 비교할 수 있도록 구성했습니다.


22. 실습 중 발생한 문제

문제 1. ID 5 질문을 끝까지 해결하지 못함

다음 질문은 Basic, Hybrid, Re-ranking 및 CASE A~D 실험에서 모두 해결하지 못했습니다.

2025년 자료를 참고하여 조산아 지원이
만 5세가 되는 시점에 종료되는 구체적인 시점은?

모든 파이프라인은 다음과 유사하게 답변했습니다.

정보를 찾을 수 없습니다.

원인

2025년 문서에는 5세까지라는 표현만 명시되어 있었고, 출생일로부터 5년이 되는 날과 같은 구체적인 법적 표현은 다른 조항이나 별도의 청크에 분리되어 있었습니다.

또한 2026년의 지원 확대 정책인 5년 4개월 정보가 검색 결과에 함께 포함되면서 2025년의 기준 문서가 상대적으로 밀릴 가능성도 있었습니다.

결과

검색 후보 수와 Re-ranking 설정을 변경하는 것만으로는 해결되지 않았습니다.

해당 문제는 검색 파라미터가 아니라 데이터 파싱과 청킹 단계에서 관련 조항을 하나의 문맥으로 연결해야 해결할 수 있는 문제로 판단했습니다.


문제 2. CASE A의 후보 문서 부족

CASE A에서는 다음 설정을 사용했습니다.

Vector Search Top-10
BM25 Search Top-10
최대 후보 20개
Re-ranking Top-5

정확도는 70%까지 낮아졌습니다.

의료급여 문서는 하나의 질문에 필요한 정보가 여러 페이지와 표에 나누어져 있는 경우가 많습니다.

후보 문서 수가 너무 적으면 Re-ranker를 적용하기 전에 정답 문서가 이미 누락됩니다.

해결 방법

후보 문서 수를 다음과 같이 늘렸습니다.

Vector Search Top-20
BM25 Search Top-20
최대 후보 40개
Re-ranking Top-10

그 결과 정확도가 96.7%까지 향상되었습니다.


문제 3. README와 실제 평가 결과의 차이

README에는 Basic RAG 정확도가 60%, 연도 검색 정확도가 66.7%로 기록된 부분이 있었습니다.

하지만 실제 저장된 최종 결과인 basic/1/REPORT.md에서는 다음 결과를 확인했습니다.

Basic RAG 정확도: 73.3%
연도 검색 정확도: 100%

README의 60% 결과는 gemini-1.5-flash를 사용한 초기 실험이나 이전 평가 결과에서 작성된 것으로 보입니다.

최종 실험에서는 gemini-3-flash-preview를 사용했으며, 실제 저장된 최종 결과인 73.3%를 기준으로 분석했습니다.


23. 실습을 통해 확인한 점

Re-ranking 이전에 충분한 후보가 필요하다

CASE A에서 확인했듯이 Re-ranker가 아무리 정교하더라도 후보 문서 안에 정답이 없다면 결과를 개선할 수 없습니다.

1차 검색
→ 정답 후보 확보

2차 Re-ranking
→ 후보 중 최적 문서 선택

Re-ranking은 잘못된 검색을 처음부터 복구하는 기능이 아니라, 확보된 후보의 순서를 더 정확하게 조정하는 기능입니다.

따라서 1차 검색의 Recall과 2차 검색의 Precision을 함께 고려해야 합니다.


Dense와 Sparse 검색의 균형이 중요하다

Dense 검색은 문맥과 의미를 찾는 데 강하고, BM25는 정확한 키워드와 수치를 찾는 데 강합니다.

의료급여 도메인은 다음 두 종류의 질문이 모두 존재합니다.

  • 365회, 2,000원, 임신부처럼 정확한 표현이 중요한 질문
  • 제도의 목적과 예외 관계처럼 전체 문맥을 이해해야 하는 질문

Dense에 편중된 CASE B와 BM25에 편중된 CASE C보다, 두 검색 방식을 20개씩 균형 있게 구성한 설정에서 가장 높은 정확도를 기록했습니다.


한국어 검색에는 형태소 분석이 중요하다

한국어는 조사와 어미가 단어에 결합되므로 공백 기준 토큰화만으로는 BM25의 장점을 충분히 활용하기 어렵습니다.

Kiwi 형태소 분석기를 사용하면서 임신부, 조산아, 수급권자, 본인부담금 등의 주요 단어를 안정적으로 추출할 수 있었습니다.

영어 중심 RAG 예제에서 단순 공백 분리로 구현한 BM25를 한국어 서비스에 그대로 적용하면 검색 품질이 낮아질 수 있다는 점을 확인했습니다.


후보 문서가 많다고 항상 좋은 것은 아니다

CASE D에서는 최대 100개의 문서를 후보로 제공했지만 정확도는 80%에 그쳤습니다.

후보 증가
→ 정답 문서 포함 가능성 증가
→ 관련 없는 문서도 함께 증가
→ Re-ranking 난이도와 비용 증가

후보 문서 수를 무조건 늘리는 것보다 적절한 후보 수를 확보하고, 청킹과 검색 품질을 개선하는 것이 더 중요했습니다.


24. 개선해 보고 싶은 부분

메타데이터 Hard Filter

현재는 모든 연도의 문서를 검색한 후 LLM이 컨텍스트의 출처를 보고 연도를 구분합니다.

질문에 2026년처럼 연도가 명확하게 포함된 경우에는 ChromaDB 검색 단계에서 해당 연도의 문서만 필터링할 수 있습니다.

search_kwargs={
    "filter": {
        "source_year": "2026"
    }
}

이 방식을 적용하면 검색 대상 문서를 줄이고 다른 연도의 문서가 컨텍스트에 포함되는 문제를 줄일 수 있습니다.

다만 두 연도를 비교하는 Cross-year 질문에서는 필터를 적용하면 안 되므로 질문 유형에 따라 필터를 동적으로 적용해야 합니다.


Query Rewriting

입원 면제의 원칙적인 이유처럼 추상적으로 표현된 질문은 벡터 검색과 BM25 모두에서 관련 문서를 찾기 어려울 수 있습니다.

LLM을 이용해 질문을 문서의 용어와 가까운 검색어로 다시 작성하면 검색 품질을 개선할 수 있습니다.

원래 질문:
입원 면제의 원칙적인 이유는?

검색용 질문:
의료급여 1종 수급권자 입원 본인부담금 면제 기준

표 단위 청킹

현재는 페이지의 일반 텍스트와 표를 하나의 Document에 함께 저장합니다.

표의 크기가 크거나 여러 규정이 한 페이지에 포함되면 하나의 청크에 여러 수치가 섞일 수 있습니다.

3주차 실습처럼 표를 독립적인 Document로 분리하면 표의 행과 열 관계를 더 명확하게 유지할 수 있습니다.


검색 평가 지표 확장

현재 평가는 최종 답변의 정답 포함 여부를 기준으로 이진 판정합니다.

향후 다음 지표를 함께 적용하면 검색 단계와 답변 생성 단계의 실패를 구분할 수 있습니다.

  • Precision@K
  • Recall@K
  • MRR
  • NDCG
  • Context Precision
  • Context Recall
  • Faithfulness
  • Answer Correctness

25. 느낀 점

이번 실습을 통해 가장 크게 확인한 점은 Re-ranking만 추가한다고 검색 성능이 자동으로 좋아지는 것은 아니라는 점입니다.

CASE A처럼 1차 검색의 후보 문서 수가 부족하면 Cohere와 같은 Re-ranker도 오답 후보의 순서를 변경할 뿐, 없는 정답 문서를 새로 찾을 수는 없습니다.

반면 벡터 검색과 BM25에서 각각 20개의 후보를 확보한 뒤 Re-ranking을 적용했을 때 정확도가 96.7%까지 올라갔습니다.

이 결과를 통해 1차 검색의 Recall과 2차 Re-ranking의 Precision을 균형 있게 설계해야 한다는 점을 알 수 있었습니다.

한국어 형태소 분석기의 효과도 인상적이었습니다.

Kiwi를 적용한 BM25가 임신부, 조산아, 근로능력과 같은 구체적인 용어를 정확하게 검색하면서 벡터 검색에서 누락된 질문을 보완했습니다.

영어 중심의 RAG 구현 방법을 그대로 적용하는 것보다, 한국어의 조사와 형태적 특성을 반영한 전처리가 실제 검색 성능에 큰 영향을 준다는 점을 확인했습니다.

또한 후보 문서를 100개까지 늘린 CASE D가 40개 후보를 사용한 최적 설정보다 낮은 결과를 기록하면서, 검색량을 늘리는 것이 항상 정답은 아니라는 점도 알게 되었습니다.

RAG의 품질을 높이려면 단순히 LLM 모델을 변경하는 것이 아니라 문서 파싱, 청킹, 토큰화, 후보 검색, 결과 융합, Re-ranking을 하나의 검색 시스템으로 함께 설계해야 합니다.


26. 핵심 정리

  1. 2025년과 2026년 의료급여 PDF를 pdfplumber로 파싱했습니다.

  2. 파일명에서 연도를 추출하여 각 문서에 source_year 메타데이터를 저장했습니다.

  3. RecursiveCharacterTextSplitter를 사용하고 chunk_size=1000, chunk_overlap=200으로 설정했습니다.

  4. OpenAI text-embedding-3-small과 ChromaDB를 이용해 벡터 검색을 구현했습니다.

  5. FastAPI 서버에서 Basic, Hybrid, Re-ranking 파이프라인을 각각 별도 엔드포인트로 제공했습니다.

  6. Kiwi 형태소 분석기와 BM25Okapi를 이용해 한국어 키워드 검색을 구현했습니다.

  7. 벡터 검색 Top-20과 BM25 Top-20을 RRF로 융합하고 최종 Top-10을 LLM에 전달했습니다.

  8. Hybrid RAG의 정확도는 Basic RAG의 73.3%에서 90.0%로 향상되었습니다.

  9. Cohere rerank-multilingual-v3.0을 적용하여 정확도를 96.7%까지 높였습니다.

  10. Basic RAG 대비 최종 성능이 총 23.4%p 향상되었습니다.

  11. CASE A~D 실험 결과, Dense 20개와 BM25 20개를 결합한 후보 40개와 Re-ranking Top-10 구성이 가장 높은 성능을 기록했습니다.

  12. 후보 문서가 너무 적으면 정답 청크를 놓치고, 너무 많으면 노이즈가 증가할 수 있다는 점을 확인했습니다.

  13. 조산아 지원 종료 시점을 묻는 ID 5 질문은 검색 설정 변경으로 해결되지 않았으며, 데이터 파싱과 청킹 단계의 개선이 필요했습니다.


참고 자료