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_parentchild_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 | 옵션 | 청크 앞에 문서 제목·섹션 헤더 자동 부여 |
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 |
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, citations7 벡터스토어 추상화와 메타데이터 병합
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 필드에 들어가 프론트엔드 참조 패널에서 출처 등급·도메인 태그로 렌더링된다.
검색·리랭크 결과는 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: 2048A/B 실험에서는 이 설정의 일부를 dotted-key override로 변경한다. 예를 들어 retrieval.reranker.top_n: 5로 바꿔서 더 많은 문서를 LLM에 제공하는 실험을 하거나, retrieval.reranker.type: flashrank로 reranker를 교체해 한국어 변별력 차이를 비교할 수 있다.
9 관련 주제
선행 지식
- MINERVA 아키텍처 개요 – 전체 구조에서 RAG의 위치
후속 주제
- BaseAgent 계약 패턴 – RAG 파이프라인을 감싸는 에이전트 인터페이스
- A/B 실험 프레임워크 – RAG 설정 변형 실험
다른 카테고리 연결