MINERVA RAG 파이프라인 설계

Hybrid Search + Parent-Child Chunking + Reranker

MINERVA의 RAG 파이프라인은 Hybrid Search(BM25 + Vector), Parent-Child Chunking, Reranker(FlashRank)를 결합하여 한국어 사내 문서에서 높은 검색 정확도를 달성한다. 각 컴포넌트의 설계 결정과 구현 패턴을 정리한다.

Agent
저자

Kwangmin Kim

공개

2026년 05월 05일

1 RAG 파이프라인 전체 흐름

MINERVA의 RAG 파이프라인은 5단계로 구성된다.

사용자 질문
    │
    ▼
[1. 질의 처리]  질문 → 임베딩 벡터
    │
    ▼
[2. Hybrid Search]  BM25(키워드) + Vector(의미) 병렬 검색
    │
    ▼
[3. Reranker]  FlashRank로 관련성 재정렬, 상위 top_n 선택
    │
    ▼
[4. Parent Chunk 매핑]  child chunk → parent chunk로 전체 문맥 복원
    │
    ▼
[5. LLM 생성]  컨텍스트 + 질문 → 답변 + 인용

각 단계가 왜 필요하고, 어떤 설계 결정을 내렸는지를 순서대로 설명한다.

2 문서 청킹: Parent-Child 전략

2.1 왜 Parent-Child인가

일반적인 청킹은 문서를 고정 크기로 자른다. 이 방식에는 두 가지 상충이 있다:

  • 작은 청크: 임베딩 정확도가 높다 (노이즈 적음). 하지만 LLM에 전달할 때 문맥이 부족하다
  • 큰 청크: 문맥이 풍부하다. 하지만 임베딩에 노이즈가 많아 검색 정확도가 떨어진다

Parent-Child Chunking은 이 상충을 해결한다:

  • Child chunk (작은 단위): 검색에 사용한다. 임베딩 정확도가 높다
  • Parent chunk (큰 단위): LLM에 전달한다. 충분한 문맥을 제공한다
원본 문서
├── Parent Chunk 1 (1500자)
│   ├── Child Chunk 1-1 (400자) ← 검색 대상
│   ├── Child Chunk 1-2 (400자) ← 검색 대상
│   └── Child Chunk 1-3 (400자) ← 검색 대상
├── Parent Chunk 2 (1500자)
│   ├── Child Chunk 2-1 (400자)
│   └── Child Chunk 2-2 (400자)
...

검색 시 Child Chunk 1-2가 매칭되면, LLM에는 Parent Chunk 1 전체를 전달한다. 검색은 정확하게, 생성은 풍부한 문맥으로 수행한다.

2.2 구현

from langchain.text_splitter import RecursiveCharacterTextSplitter

class ParentChildChunker:
    def __init__(self, parent_size: int = 1500, child_size: int = 400, overlap: int = 100):
        self.parent_splitter = RecursiveCharacterTextSplitter(
            chunk_size=parent_size,
            chunk_overlap=overlap,
            separators=["\n\n", "\n", ". ", " "],
        )
        self.child_splitter = RecursiveCharacterTextSplitter(
            chunk_size=child_size,
            chunk_overlap=50,
            separators=["\n\n", "\n", ". ", " "],
        )

    def split(self, document: str, metadata: dict) -> tuple[list, list, dict]:
        parent_chunks = self.parent_splitter.split_text(document)
        child_chunks = []
        child_to_parent = {}

        for parent_idx, parent in enumerate(parent_chunks):
            children = self.child_splitter.split_text(parent)
            for child in children:
                child_id = len(child_chunks)
                child_chunks.append(child)
                child_to_parent[child_id] = parent_idx

        return parent_chunks, child_chunks, child_to_parent

child_to_parent 매핑을 유지하여 검색 결과에서 parent chunk를 즉시 복원할 수 있다.

2.3 청크 크기 설정

MINERVA에서 사용하는 설정값과 그 근거이다.

파라미터 근거
Child chunk size 1500자 임베딩 모델 입력에 적합한 길이, BM25에도 충분
Parent chunk size child × 3 (기본 4500자, 설정 가능) LLM에 충분한 문맥 + reranker 토큰 한계 회피
Parent overlap 400자 문장 경계 보존, 정보 손실 방지
Contextual prefix 옵션 청크 앞에 문서 제목·섹션 헤더 자동 부여
Reranker는 child에 적용, 결과는 parent로 매핑

Parent chunk(수천 자)는 cross-encoder의 토큰 한계(대부분 512)를 넘긴다. 따라서 reranker는 항상 child chunk에 적용해 점수를 매기고, 선택된 child가 속한 parent를 LLM에 전달한다. 이 분리가 “검색은 작게, 생성은 크게”의 핵심 메커니즘이다.

이 값들은 YAML 설정 파일로 관리되어 코드 변경 없이 조정할 수 있다. RAGConfig.chunking.parent_size를 명시하지 않으면 child의 ×3으로 자동 계산되며, 실험에서는 이 비율을 dotted-key override로 변경한다.

3 Hybrid Search: BM25 + Vector

3.1 왜 Hybrid인가

검색 방식 강점 약점
BM25 (키워드) 정확한 용어 매칭, 고유 명사 검색에 강함 동의어, 의미적 유사성 처리 불가
Vector (의미) 의미적 유사성 파악, 동의어 처리 정확한 용어 매칭에 약함, 한국어 임베딩 품질 의존

한국어 사내 문서에서 특히 Hybrid가 중요한 이유:

  • 전문 용어: “가용성 존(AZ)”처럼 약어와 원어가 혼재한다. BM25는 정확한 매칭, Vector는 의미 매칭을 담당한다
  • 복합어: 한국어 복합어는 형태소 분석 없이 벡터 검색만으로는 정확도가 떨어진다. BM25 + KiwiPy 형태소 분석이 이를 보완한다
  • 다의어: “배포”가 소프트웨어 배포인지 자료 배포인지 문맥에 따라 다르다. Vector 검색이 문맥을 반영한다

3.2 구현

from langchain_community.retrievers import BM25Retriever
from langchain_community.vectorstores import FAISS

class HybridRetriever:
    def __init__(self, documents: list, embeddings, top_k: int = 20):
        self.bm25 = BM25Retriever.from_documents(documents, k=top_k)
        self.vectorstore = FAISS.from_documents(documents, embeddings)
        self.vector_retriever = self.vectorstore.as_retriever(
            search_kwargs={"k": top_k}
        )
        self.top_k = top_k

    def retrieve(self, query: str) -> list:
        bm25_results = self.bm25.invoke(query)
        vector_results = self.vector_retriever.invoke(query)

        # RRF (Reciprocal Rank Fusion)로 결과 통합
        return self._reciprocal_rank_fusion(bm25_results, vector_results)

    def _reciprocal_rank_fusion(self, *result_lists, k: int = 60) -> list:
        scores = {}
        for results in result_lists:
            for rank, doc in enumerate(results):
                doc_id = doc.metadata.get("id", doc.page_content[:50])
                scores[doc_id] = scores.get(doc_id, 0) + 1 / (rank + k)

        sorted_ids = sorted(scores, key=scores.get, reverse=True)
        # doc_id → document 매핑으로 상위 결과 반환
        ...

3.3 Reciprocal Rank Fusion (RRF)

BM25와 Vector 검색 결과를 통합하는 방법이 필요하다. 두 검색의 스코어는 척도가 다르므로 단순 합산할 수 없다. RRF는 순위(rank)만 사용하여 이 문제를 해결한다.

\[ \text{RRF}(d) = \sum_{r \in R} \frac{1}{\text{rank}_r(d) + k} \]

  • \(d\) : 문서
  • \(R\) : 검색 결과 리스트들 (BM25, Vector)
  • \(\text{rank}_r(d)\) : 검색 \(r\) 에서 문서 \(d\) 의 순위 (0-indexed)
  • \(k\) : 상수 (기본 60, 상위 순위에 과도한 가중치 방지)

예시: 문서 A가 BM25에서 1위, Vector에서 3위이면:

\[ \text{RRF}(A) = \frac{1}{1+60} + \frac{1}{3+60} = \frac{1}{61} + \frac{1}{63} \approx 0.0323 \]

두 검색 모두에서 상위에 있는 문서가 높은 점수를 받는다.

3.4 한국어 전처리

한국어 BM25 검색 성능을 높이기 위해 KiwiPy 형태소 분석기를 사용한다.

from kiwipiepy import Kiwi

kiwi = Kiwi()

def korean_tokenizer(text: str) -> list[str]:
    tokens = kiwi.tokenize(text)
    return [t.form for t in tokens if t.tag.startswith(("NN", "VV", "VA"))]
    # 명사(NN*), 동사(VV), 형용사(VA)만 추출

# BM25에 커스텀 토크나이저 적용
bm25 = BM25Retriever.from_documents(
    documents,
    preprocess_func=korean_tokenizer,
    k=20,
)

형태소 분석 없이 문자열 기반으로 BM25를 적용하면 “데이터베이스”와 “데이터”가 매칭되지 않는다. 형태소 분석으로 “데이터”, “베이스”로 분리하면 부분 매칭이 가능해진다.

4 Reranker: FlashRank

4.1 왜 Reranker가 필요한가

Hybrid Search에서 상위 20개를 가져오지만, 이 중 실제로 질문에 관련된 문서는 일부이다. Reranker는 질문-문서 쌍을 cross-encoder로 직접 비교하여 관련성을 정밀하게 평가한다.

단계 모델 타입 속도 정확도 입력
검색 (BM25 + Vector) Bi-encoder 빠름 보통 질문과 문서를 독립적으로 인코딩
재정렬 (Reranker) Cross-encoder 느림 높음 질문-문서 쌍을 함께 인코딩

Bi-encoder는 질문과 문서를 각각 별도로 벡터화하므로 빠르지만, 질문과 문서 사이의 세밀한 관계를 놓칠 수 있다. Cross-encoder는 질문과 문서를 하나의 입력으로 함께 처리하므로 정확하지만 느리다. 검색(넓게, 빠르게) → 재정렬(좁게, 정밀하게)의 2단계 전략이 이 trade-off를 해결한다.

4.2 구현

from flashrank import Ranker, RerankRequest

class RerankerComponent:
    def __init__(self, model_name: str = "ms-marco-MiniLM-L-12-v2", top_n: int = 3):
        self.ranker = Ranker(model_name=model_name)
        self.top_n = top_n

    def rerank(self, query: str, documents: list) -> list:
        passages = [
            {"id": i, "text": doc.page_content, "meta": doc.metadata}
            for i, doc in enumerate(documents)
        ]
        request = RerankRequest(query=query, passages=passages)
        results = self.ranker.rerank(request)
        return results[:self.top_n]

FlashRank는 경량 cross-encoder이다. GPU 없이도 20개 문서를 50ms 내에 재정렬할 수 있어 실시간 서빙에 적합하다.

4.3 Reranker 선택 기준 — 4단 폴백 체인

MINERVA는 단일 reranker가 아니라 설정 가능한 4단 폴백 체인을 운영한다. config의 retrieval.reranker.type이 우선되고, 실패하거나 미설정이면 다음 단계로 내려간다.

우선순위 Reranker 속도 한국어 정확도 GPU 필요
1 CrossEncoder (BAAI/bge-reranker-v2-m3) 보통 (CPU 가능, GPU 권장) 매우 좋음 — 다국어 sigmoid 정규화 권장
2 FlashRank (ms-marco-MultiBERT-L-12) 매우 빠름 (CPU) 보통 — 한국어 변별력 약함 X
3 Azure Semantic Ranker API 의존 좋음 (semantic config 필요) X
4 (폴백) Cosine similarity 매우 빠름 임베딩 품질에 의존 X
한국어 권장은 CrossEncoder

PoC 운영 결과 FlashRank가 한국어 변별력에서 약점을 보였다. 정확도가 중요한 도메인(데이터 표준화, 거버넌스 문서)은 CrossEncoder를 1순위로 둔다. FlashRank는 영문 키워드 비율이 높거나 GPU가 없는 빠른 응답 환경의 대안이다.

# core/rag/reranker.py — 폴백 체인 골격
def get_reranker(config):
    rtype = config.retrieval.reranker.type
    if rtype == "cross_encoder":
        return CrossEncoderReranker(model="BAAI/bge-reranker-v2-m3")
    if rtype == "flashrank":
        return FlashRankReranker(model="ms-marco-MultiBERT-L-12")
    if rtype == "azure":
        return AzureSemanticReranker()
    return None  # → cosine similarity로 폴백 (numpy batch 계산)

5 Parent Chunk 매핑

Reranker가 선택한 상위 child chunk에서 원본 parent chunk를 복원하여 LLM에 전달한다.

class ParentChunkMapper:
    def __init__(self, parent_chunks: list, child_to_parent: dict):
        self.parent_chunks = parent_chunks
        self.child_to_parent = child_to_parent

    def map_to_parents(self, child_indices: list[int]) -> list[str]:
        parent_indices = set()
        for child_idx in child_indices:
            parent_idx = self.child_to_parent[child_idx]
            parent_indices.add(parent_idx)

        return [self.parent_chunks[idx] for idx in sorted(parent_indices)]

중복 제거가 핵심이다. 같은 parent에 속하는 여러 child가 검색되면 parent를 한 번만 포함한다. 이로써 LLM 컨텍스트 윈도우를 효율적으로 사용한다.

6 전체 파이프라인 조합

class RAGPipeline:
    def __init__(self, config: RAGConfig):
        self.chunker = ParentChildChunker(
            parent_size=config.chunking.chunk_size,
            child_size=config.chunking.child_chunk_size,
        )
        self.retriever = HybridRetriever(
            documents=self._load_documents(),
            embeddings=self._get_embeddings(),
            top_k=config.retriever.top_k,
        )
        self.reranker = RerankerComponent(
            model_name=config.reranker.model,
            top_n=config.reranker.top_n,
        )
        self.llm = self._get_llm(config.llm)

    def run(self, query: str) -> tuple[str, list]:
        # 1. Hybrid Search
        candidates = self.retriever.retrieve(query)

        # 2. Rerank
        reranked = self.reranker.rerank(query, candidates)

        # 3. Parent Chunk 매핑
        parent_chunks = self.parent_mapper.map_to_parents(
            [r["id"] for r in reranked]
        )

        # 4. LLM 생성
        context = "\n\n---\n\n".join(parent_chunks)
        prompt = f"다음 문서를 참고하여 질문에 답하라.\n\n{context}\n\n질문: {query}"
        answer = self.llm.invoke(prompt)

        # 5. 인용 추출
        citations = self._extract_citations(reranked)

        return answer.content, citations

7 벡터스토어 추상화와 메타데이터 병합

MINERVA는 두 벡터스토어를 동시에 지원한다. 환경변수와 RAGConfig 조합으로 결정된다.

환경 Vector Store Embedding 비고
로컬·개발 FAISS text-embedding-3-small (Azure) 또는 ko-sbert (Ollama) 빠른 cold-start, parent_store는 LocalFileStore
클라우드 Azure AI Search ada-002 / text-embedding-3-small 사내 인덱스 공유, semantic config 활성화 가능
# core/rag/vectorstore.py — provider별 분기
def get_vectorstore(config):
    if get_provider() == "azure":
        return AzureSearchStore(index_name=config.indexing.azure_index_name, ...)
    return FAISSStore(index_dir=FAISS_INDEX_DIR / config.indexing.local_index_name, ...)

검색이 끝난 chunk에는 metadata_loader.py가 외부 yml에서 읽은 참고문헌·표준 메타(source_type, source_name, domain, authority_level)를 setdefault로 병합한다. 이 메타가 Citation의 metadata 필드에 들어가 프론트엔드 참조 패널에서 출처 등급·도메인 태그로 렌더링된다.

Citation 점수의 두 얼굴

검색·리랭크 결과는 cosine 점수를 그대로 보존한 Citation.score로, 그리고 한국어 임베딩 분포를 반영해 보정한 Citation.display_score로 동시에 노출된다 (core/relevance.py). 한국어 cosine은 0.5만 되어도 “매우 관련”인 경우가 많아 사용자 UI는 이를 70~90% 구간으로 끌어올려 표시하지만, 로깅·실험·threshold 판정은 원 cosine 기준으로 유지된다.

8 설정 관리

모든 파라미터를 YAML로 외부화하여 코드 변경 없이 파이프라인을 조정한다.

# data/configs/qna_chatbot.yaml
chunking:
  chunk_size: 1500
  parent_size: 4500          # 명시 안 하면 chunk_size * 3 자동
  chunk_overlap: 400

retrieval:
  k: 6
  search_type: hybrid        # hybrid | similarity | mmr
  reranker:
    type: cross_encoder      # cross_encoder | flashrank | azure | (none)
    model: BAAI/bge-reranker-v2-m3
    top_n: 3

embedding:
  model: text-embedding-3-small
  dimension: 1536

indexing:
  azure_index_name: minerva-standardization-prod
  local_index_name: standardization

llm:
  model: gpt-4.1
  temperature: 0.7
  max_tokens: 2048

A/B 실험에서는 이 설정의 일부를 dotted-key override로 변경한다. 예를 들어 retrieval.reranker.top_n: 5로 바꿔서 더 많은 문서를 LLM에 제공하는 실험을 하거나, retrieval.reranker.type: flashrank로 reranker를 교체해 한국어 변별력 차이를 비교할 수 있다.

9 관련 주제

선행 지식

후속 주제

다른 카테고리 연결

Subscribe

Enjoy this blog? Get notified of new posts by email: