본문으로 건너뛰기
AICosmus

Where tech meets the everyday — AI, fintech, swimming, and cars.

AICosmus

Where tech meets the everyday — AI, fintech, swimming, and cars.

  • 홈
  • IT기술
    • RAG
    • GRPC
    • Kotlin
    • LLM
    • 금융 IT
    • 에이전트
    • 제로Trust
    • 자동화
  • 일상
    • 자동차
    • 경제/재테크
    • 생활정보
  • About
    • Contact
    • Terms of Service
    • Disclaimer
    • Privacy – Policy
  • 홈
  • IT기술
    • RAG
    • GRPC
    • Kotlin
    • LLM
    • 금융 IT
    • 에이전트
    • 제로Trust
    • 자동화
  • 일상
    • 자동차
    • 경제/재테크
    • 생활정보
  • About
    • Contact
    • Terms of Service
    • Disclaimer
    • Privacy – Policy
온프레미스 RAG 파이프라인 구축 개념
IT기술

[온프레미스 AI Assistant 아키텍처 — Qwen3·Qwen3-VL 14일 설계] 9/14화: 온프레미스 RAG 파이프라인 — bge-m3·Qdrant 자체 호스팅 실전

By AICosmus
2026년 07월 13일 23 Min Read
3

시리즈 안내

이 글은 「온프레미스 AI Assistant 아키텍처 — Qwen3·Qwen3-VL 14일 설계」 시리즈 9일차입니다. 오늘부터 Phase C(컨텍스트·지식·메모리)에 진입하며, 외부 API 없이 자체 호스팅으로 완결되는 RAG 파이프라인의 설계와 구축을 본격적으로 다룹니다.

어제 회차 한 줄 회상

8일차에서는 Qwen3의 chat template 구조와 컨텍스트 윈도우를 ‘자원’으로 바라보는 관점을 다뤘습니다. 시스템 프롬프트 계층화, 컨텍스트 조립 파이프라인, Thinking 모드의 reasoning 토큰 비용 관리까지 — 모델에게 무엇을 보여줄 것인가를 설계했습니다. 오늘은 그 ‘보여줄 것’을 어디서 어떻게 찾아올 것인가에 답합니다.

오늘의 핵심 3가지

  • bge-m3 임베딩 로컬 서빙 — OpenAI Embedding API 없이, 1,024차원 다국어 벡터를 자체 호스팅으로 생성합니다. Dense·Sparse·ColBERT 세 가지 표현을 단일 모델에서 동시 추출합니다.
  • Qdrant 자체 호스팅 + 하이브리드 검색 — Docker 한 줄로 벡터 DB를 띄우고, Dense + Sparse를 RRF(Reciprocal Rank Fusion)로 결합해 단일 검색보다 높은 재현율(recall)을 확보합니다.
  • 리랭킹 → Qwen3 생성까지 완전한 RAG 파이프라인 — 청킹·인덱싱·검색·리랭킹·생성의 전 단계를 외부 API 의존 없이 온프레미스에서 동작하는 코드로 완성합니다.

왜 온프레미스 RAG인가

RAG(Retrieval-Augmented Generation, 검색 증강 생성)는 LLM의 지식 한계를 외부 문서로 보완하는 표준 패턴이 됐습니다. 하지만 대부분의 튜토리얼은 OpenAI Embedding API + Pinecone/Weaviate Cloud를 전제합니다. 온프레미스 환경에서는 이 경로가 막힙니다.

  • 데이터 주권 — 사내 문서·계약서·고객 데이터가 외부 임베딩 API로 나가는 순간 통제력을 잃습니다. 금융·의료·공공 분야에서는 규제 위반이 될 수 있습니다.
  • 비용 예측 가능성 — text-embedding-3-large 기준 100만 토큰당 $0.13. 수백만 문서를 인덱싱하고 매일 쿼리가 수만 건이면 비용이 빠르게 누적됩니다. 로컬 GPU에서 돌리면 고정 비용으로 전환됩니다.
  • 지연 시간 — 외부 API 왕복 50~200ms vs 로컬 임베딩 5~15ms. RAG 파이프라인에서 임베딩 호출은 쿼리와 재인덱싱 양쪽에서 반복되므로 누적 차이가 큽니다.
  • 가용성 — 외부 서비스 장애가 곧 RAG 전체 장애입니다. 자체 호스팅은 자기 인프라의 가용성만 관리하면 됩니다.

8일차에서 설계한 컨텍스트 조립 파이프라인의 knowledge_context 슬롯 — 그 슬롯에 채울 데이터를 찾아오는 전체 스택을 오늘 구축합니다.

RAG 인제스트·쿼리 파이프라인 아키텍처 - RAG 파이프라인

온프레미스 RAG 파이프라인 아키텍처 전체 그림

온프레미스 RAG 파이프라인은 크게 인제스트(Ingest) 경로와 쿼리(Query) 경로로 나뉩니다.


┌─────────────────────────────────────────────────────────────────┐
│                    Ingest Pipeline (비동기, 배치)                 │
│                                                                 │
│  [문서 원본]  →  [파서/추출]  →  [청킹]  →  [bge-m3 임베딩]       │
│      PDF          PyMuPDF       Semantic     Dense 1024d         │
│      Markdown     Unstructured  + Fixed      Sparse (lexical)   │
│      HTML                       Overlap      ColBERT (token)    │
│      DOCX                                         │              │
│                                                    ▼              │
│                                            [Qdrant 인덱싱]       │
│                                             Named Vectors        │
│                                             + Payload 메타데이터  │
└─────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────┐
│                    Query Pipeline (실시간)                        │
│                                                                 │
│  [사용자 쿼리]  →  [bge-m3 임베딩]  →  [Qdrant 하이브리드 검색]   │
│                     Dense + Sparse      Dense NN + Sparse BM25  │
│                                         RRF Fusion              │
│                          │                    │                  │
│                          ▼                    ▼                  │
│                    [리랭커 (bge-reranker-v2-m3)]                  │
│                          │                                       │
│                          ▼                                       │
│                    [컨텍스트 조립] → [Qwen3 생성]                  │
│                     Top-K 선택        8화 프롬프트 템플릿          │
│                     + 메타데이터       + 인용 포맷                 │
└─────────────────────────────────────────────────────────────────┘

각 컴포넌트를 아래에서 하나씩 구축해 가겠습니다.

1. 임베딩 모델 선정 — bge-m3

왜 bge-m3인가

BAAI(Beijing Academy of Artificial Intelligence)의 bge-m3은 온프레미스 RAG 임베딩 모델로 현 시점 가장 실용적인 선택입니다. 그 이유를 구체적으로 봅니다.

  • Multi-Functionality — Dense, Sparse(Lexical Weight), ColBERT(Multi-Vector) 세 가지 검색 표현을 단일 모델 단일 추론으로 동시 생성합니다. 별도 BM25 인덱서가 필요 없습니다.
  • Multi-Linguality — 100개 이상 언어 학습. 한국어 문서 임베딩에서 MTEB 리더보드 상위권입니다.
  • Multi-Granularity — 입력 최대 8,192 토큰. 짧은 쿼리부터 긴 문단까지 단일 모델로 처리합니다.
  • 파라미터 크기 — 568M 파라미터(약 1.1GB FP16). Mac Studio에서도 CPU만으로 실시간 서빙이 가능하고, GPU가 있으면 배치 임베딩이 매우 빠릅니다.
  • 라이선스 — MIT. 상업 사용 자유입니다.

bge-m3 vs 다른 임베딩 모델 비교

모델 차원 최대 토큰 Sparse 지원 한국어 MTEB 파라미터 라이선스
bge-m3 1,024 8,192 내장 상위권 568M MIT
multilingual-e5-large-instruct 1,024 514 미지원 상위권 560M MIT
text-embedding-3-large 3,072 8,191 미지원 최상위 비공개 API 전용
nomic-embed-text-v1.5 768 8,192 미지원 중위권 137M Apache 2.0
gte-Qwen2-1.5B-instruct 1,536 32,768 미지원 상위권 1.5B Apache 2.0

bge-m3의 결정적 장점은 Sparse 표현 내장입니다. 다른 모델은 Dense 벡터만 생성하므로 하이브리드 검색을 하려면 별도로 BM25 인덱스(Elasticsearch 등)를 운영해야 합니다. bge-m3은 모델 하나로 Dense + Sparse를 동시에 뽑아 Qdrant의 Named Vectors에 바로 넣을 수 있습니다. 운영 복잡도가 크게 줄어듭니다.

bge-m3 로컬 서빙

임베딩 모델을 서빙하는 방법은 여러 가지입니다. 가장 실용적인 두 가지를 봅니다.

방법 1: FlagEmbedding 라이브러리 직접 사용 (Python 내장)


# pip install FlagEmbedding torch

from FlagEmbedding import BGEM3FlagModel

# 모델 로드 (첫 호출 시 HuggingFace에서 다운로드, 이후 캐시)
model = BGEM3FlagModel(
    "BAAI/bge-m3",
    use_fp16=True,           # GPU 메모리 절약
    device="cuda:0",         # Mac: "mps" 또는 "cpu"
)

# 단일 추론으로 Dense + Sparse + ColBERT 동시 생성
sentences = [
    "온프레미스 환경에서 RAG 파이프라인을 구축하는 방법",
    "Qdrant 벡터 데이터베이스 자체 호스팅 가이드",
    "금융 규제 환경에서의 데이터 주권 요구사항",
]

output = model.encode(
    sentences,
    batch_size=12,
    max_length=8192,
    return_dense=True,
    return_sparse=True,
    return_colbert_vecs=False,  # ColBERT는 저장 비용이 크므로 필요시만
)

dense_vecs = output["dense_vecs"]     # shape: (3, 1024), numpy array
sparse_vecs = output["lexical_weights"]  # list of dict {token_id: weight}

print(f"Dense shape: {dense_vecs.shape}")
print(f"Sparse example keys: {len(sparse_vecs[0])} tokens")
# Dense shape: (3, 1024)
# Sparse example keys: 약 15~40 tokens (문장 길이에 따라)

FlagEmbedding은 내부적으로 XLM-RoBERTa 기반 모델을 로드합니다. use_fp16=True로 GPU VRAM을 약 1.1GB만 차지하고, RTX 4090 기준 초당 약 300~400 문장(128토큰 평균)을 처리합니다. Mac Studio M2 Ultra에서 MPS 백엔드로 초당 약 80~120 문장입니다.

방법 2: FastAPI 임베딩 서버 (OpenAI 호환)

RAG 파이프라인의 여러 컴포넌트가 임베딩을 호출해야 하므로, 독립 서버로 분리하는 것이 운영상 유리합니다. 6일차 게이트웨이 뒤에 배치할 수도 있습니다.


# embedding_server.py
# pip install fastapi uvicorn FlagEmbedding torch

import asyncio
from contextlib import asynccontextmanager
from typing import Any

import numpy as np
from fastapi import FastAPI
from pydantic import BaseModel

# ── 모델 싱글턴 ──────────────────────────────────────
_model = None
_lock = asyncio.Lock()


async def get_model():
    global _model
    if _model is None:
        async with _lock:
            if _model is None:
                from FlagEmbedding import BGEM3FlagModel
                _model = BGEM3FlagModel(
                    "BAAI/bge-m3",
                    use_fp16=True,
                    device="cuda:0",
                )
    return _model


@asynccontextmanager
async def lifespan(app: FastAPI):
    await get_model()  # 서버 시작 시 미리 로드
    yield


app = FastAPI(title="bge-m3 Embedding Server", lifespan=lifespan)


# ── 요청/응답 스키마 ──────────────────────────────────
class EmbeddingRequest(BaseModel):
    input: list[str] | str
    model: str = "bge-m3"
    encoding_format: str = "float"


class SparseEmbeddingRequest(BaseModel):
    input: list[str] | str


class EmbeddingData(BaseModel):
    object: str = "embedding"
    index: int
    embedding: list[float]


class EmbeddingResponse(BaseModel):
    object: str = "list"
    data: list[EmbeddingData]
    model: str = "bge-m3"
    usage: dict[str, int]


class SparseVector(BaseModel):
    indices: list[int]
    values: list[float]


class SparseEmbeddingResponse(BaseModel):
    data: list[SparseVector]


# ── Dense 엔드포인트 (OpenAI 호환) ────────────────────
@app.post("/v1/embeddings", response_model=EmbeddingResponse)
async def create_embeddings(req: EmbeddingRequest) -> dict[str, Any]:
    texts = [req.input] if isinstance(req.input, str) else req.input
    model = await get_model()

    loop = asyncio.get_event_loop()
    output = await loop.run_in_executor(
        None,
        lambda: model.encode(
            texts,
            batch_size=32,
            max_length=8192,
            return_dense=True,
            return_sparse=False,
        ),
    )

    dense: np.ndarray = output["dense_vecs"]
    data = [
        {"object": "embedding", "index": i, "embedding": vec.tolist()}
        for i, vec in enumerate(dense)
    ]
    return {
        "object": "list",
        "data": data,
        "model": "bge-m3",
        "usage": {"prompt_tokens": sum(len(t.split()) for t in texts), "total_tokens": sum(len(t.split()) for t in texts)},
    }


# ── Sparse 엔드포인트 (Qdrant 연동용) ─────────────────
@app.post("/v1/sparse_embeddings", response_model=SparseEmbeddingResponse)
async def create_sparse_embeddings(req: SparseEmbeddingRequest) -> dict[str, Any]:
    texts = [req.input] if isinstance(req.input, str) else req.input
    model = await get_model()

    loop = asyncio.get_event_loop()
    output = await loop.run_in_executor(
        None,
        lambda: model.encode(
            texts,
            batch_size=32,
            max_length=8192,
            return_dense=False,
            return_sparse=True,
        ),
    )

    sparse_list = output["lexical_weights"]
    data = []
    for weights in sparse_list:
        indices = [int(k) for k in weights.keys()]
        values = [float(v) for v in weights.values()]
        data.append({"indices": indices, "values": values})

    return {"data": data}


# ── 헬스체크 ──────────────────────────────────────────
@app.get("/health")
async def health():
    return {"status": "ok", "model": "bge-m3"}


if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=8100)

# 실행
python embedding_server.py

# 테스트
curl -s http://localhost:8100/v1/embeddings \
  -H "Content-Type: application/json" \
  -d '{"input": ["온프레미스 RAG 파이프라인"], "model": "bge-m3"}' \
  | python -m json.tool | head -5

이 서버는 OpenAI의 /v1/embeddings와 호환되므로 LangChain, LlamaIndex 등 기존 프레임워크의 OpenAI 임베딩 클라이언트를 base_url만 바꿔서 그대로 사용할 수 있습니다. 추가로 /v1/sparse_embeddings 엔드포인트를 통해 Qdrant의 Sparse Vector에 필요한 형식을 직접 제공합니다.

임베딩 서빙 성능 기준점

하드웨어 배치 크기 평균 문장 길이 처리량(문장/초) GPU VRAM
RTX 4090 24GB, FP16 32 128 토큰 ~380 1.1GB
RTX 4090 24GB, FP16 32 512 토큰 ~120 1.4GB
Mac Studio M2 Ultra, MPS 16 128 토큰 ~100 통합 메모리 ~1.2GB
Mac Studio M2 Ultra, CPU 16 128 토큰 ~35 RAM ~1.2GB
CPU Only (Xeon 16코어) 8 128 토큰 ~15 RAM ~1.2GB

bge-m3은 568M 파라미터로 비교적 가벼워서 Qwen3 서빙과 동일 GPU에 올려도 VRAM 부담이 적습니다. RTX 4090에서 Qwen3-30B-A3B(FP8, ~16GB)와 bge-m3(FP16, ~1.1GB)을 함께 서빙하면 VRAM 약 17GB로 24GB 안에 들어옵니다.

2. 문서 처리와 청킹

문서 파싱

RAG의 첫 단계는 다양한 형식의 문서를 순수 텍스트(또는 구조화된 텍스트)로 변환하는 것입니다. 온프레미스에서 사용할 수 있는 파서들:

문서 형식 파서 특징
PDF PyMuPDF (fitz) 빠른 텍스트 추출, 테이블 감지 가능. 순수 Python.
PDF (스캔본) Tesseract OCR + PyMuPDF 이미지 기반 PDF. Qwen3-VL 병용 가능(10회차)
Markdown 내장 파싱 헤더 기준 구조화 용이
HTML BeautifulSoup + trafilatura 본문 추출, 노이즈 제거
DOCX python-docx 문단·테이블 구조 보존
복합 형식 Unstructured 위 형식을 통합 처리. 의존성이 무거움

청킹 전략

청킹(Chunking)은 RAG 품질을 결정하는 가장 중요한 단계 중 하나입니다. 청크가 너무 크면 노이즈가 많고, 너무 작으면 문맥이 끊깁니다.

청킹 전략 3종 비교 인포그래픽

3가지 청킹 전략 비교

전략 방법 장점 단점 적합한 경우
Fixed-size + Overlap 고정 토큰 수(예: 512)로 분할, 128 토큰 겹침 구현 단순, 예측 가능 의미 단위 절단 균질한 텍스트, 빠른 프로토타이핑
Recursive Character 구분자 우선순위(\n\n → \n → . → 공백)로 재귀 분할 단락 경계 존중 길이 편차 큼 일반 문서, 범용
Semantic Chunking 문장 임베딩 유사도가 임계값 아래로 떨어지는 지점에서 분할 의미 단위 보존 임베딩 추가 비용 기술 문서, 정확도 중시

실무에서는 Recursive Character + 메타데이터 보존이 가장 균형 잡힌 선택입니다. 아래는 실전 청킹 코드입니다.


# chunker.py — 실전 청킹 파이프라인
from __future__ import annotations

import hashlib
import re
from dataclasses import dataclass, field
from pathlib import Path


@dataclass
class Chunk:
    """인덱싱할 단위 청크."""
    text: str
    chunk_id: str
    doc_id: str
    source: str           # 원본 파일 경로
    section: str = ""     # 상위 헤더/섹션
    chunk_index: int = 0  # 문서 내 순서
    metadata: dict = field(default_factory=dict)

    @property
    def token_estimate(self) -> int:
        """대략적 토큰 수 추정 (한국어: 글자수 * 0.5, 영어: 단어수 * 1.3)."""
        korean_chars = len(re.findall(r"[가-힣]", self.text))
        other_words = len(re.findall(r"[a-zA-Z]+", self.text))
        return int(korean_chars * 0.5 + other_words * 1.3) + 10


def _make_chunk_id(doc_id: str, index: int) -> str:
    raw = f"{doc_id}::{index}"
    return hashlib.sha256(raw.encode()).hexdigest()[:16]


def recursive_chunk(
    text: str,
    doc_id: str,
    source: str,
    max_tokens: int = 512,
    overlap_tokens: int = 64,
    separators: tuple[str, ...] = ("\n\n", "\n", ". ", " "),
    section: str = "",
    metadata: dict | None = None,
) -> list[Chunk]:
    """Recursive character splitting with overlap."""
    if metadata is None:
        metadata = {}

    chunks: list[Chunk] = []

    def _estimate_tokens(t: str) -> int:
        korean = len(re.findall(r"[가-힣]", t))
        english = len(re.findall(r"[a-zA-Z]+", t))
        return int(korean * 0.5 + english * 1.3) + 5

    def _split(txt: str, sep_idx: int) -> list[str]:
        if sep_idx >= len(separators):
            # 마지막 수단: 글자 단위 강제 분할
            hard_limit = max_tokens * 3  # 대략적 글자 수
            return [txt[i:i + hard_limit] for i in range(0, len(txt), hard_limit)]
        sep = separators[sep_idx]
        parts = txt.split(sep)
        result: list[str] = []
        current = ""
        for part in parts:
            candidate = current + sep + part if current else part
            if _estimate_tokens(candidate) > max_tokens and current:
                result.append(current.strip())
                current = part
            else:
                current = candidate
        if current.strip():
            result.append(current.strip())
        # 아직 너무 큰 청크가 있으면 다음 구분자로 재분할
        final: list[str] = []
        for r in result:
            if _estimate_tokens(r) > max_tokens:
                final.extend(_split(r, sep_idx + 1))
            else:
                final.append(r)
        return final

    raw_chunks = _split(text, 0)

    # 오버랩 적용
    for i, chunk_text in enumerate(raw_chunks):
        if i > 0 and overlap_tokens > 0:
            prev_words = raw_chunks[i - 1].split()
            overlap_words = prev_words[-overlap_tokens:]  # 단어 단위 근사
            chunk_text = " ".join(overlap_words) + " " + chunk_text

        chunks.append(Chunk(
            text=chunk_text.strip(),
            chunk_id=_make_chunk_id(doc_id, i),
            doc_id=doc_id,
            source=source,
            section=section,
            chunk_index=i,
            metadata=metadata,
        ))

    return chunks


def chunk_markdown(filepath: Path, max_tokens: int = 512) -> list[Chunk]:
    """Markdown 파일을 헤더 기준으로 섹션 분할 후 재귀 청킹."""
    text = filepath.read_text(encoding="utf-8")
    doc_id = hashlib.sha256(str(filepath).encode()).hexdigest()[:12]

    # 헤더 기준 섹션 분할
    header_pattern = re.compile(r"^(#{1,3})\s+(.+)$", re.MULTILINE)
    sections: list[tuple[str, str]] = []
    last_pos = 0
    last_header = "intro"

    for match in header_pattern.finditer(text):
        if match.start() > last_pos:
            sections.append((last_header, text[last_pos:match.start()]))
        last_header = match.group(2).strip()
        last_pos = match.end()
    if last_pos < len(text):
        sections.append((last_header, text[last_pos:]))

    all_chunks: list[Chunk] = []
    for section_name, section_text in sections:
        if section_text.strip():
            all_chunks.extend(recursive_chunk(
                text=section_text,
                doc_id=doc_id,
                source=str(filepath),
                max_tokens=max_tokens,
                section=section_name,
                metadata={"format": "markdown"},
            ))

    # chunk_index 재번호
    for i, c in enumerate(all_chunks):
        c.chunk_index = i
        c.chunk_id = _make_chunk_id(doc_id, i)

    return all_chunks

청킹 파라미터 튜닝 가이드

파라미터 권장 시작값 튜닝 방향
max_tokens 512 긴 문서·기술 매뉴얼 → 768~1024. Q&A형 → 256~384
overlap_tokens 64 max_tokens의 10~20%. 너무 크면 인덱스 크기 비효율
구분자 우선순위 \n\n → \n → . → 공백 코드 문서: \n```\n 추가. 법률 문서: 조항 번호 패턴 추가

3. Qdrant 자체 호스팅

왜 Qdrant인가

온프레미스 벡터 DB 선택지를 비교합니다.

벡터 DB 라이선스 Named Vectors Sparse Vector 메모리 매핑 클러스터링
Qdrant Apache 2.0 지원 지원 지원 (mmap) 지원 (분산)
Milvus Apache 2.0 미지원 (다중 필드) 지원 (2.4+) 지원 지원
Chroma Apache 2.0 미지원 미지원 제한적 미지원
Weaviate BSD-3 미지원 BM25 내장 제한적 지원
pgvector PostgreSQL 미지원 미지원 PostgreSQL 의존 PostgreSQL 의존

Qdrant의 결정적 강점은 Named Vectors입니다. 하나의 Point(문서)에 여러 이름의 벡터를 붙일 수 있어서, bge-m3의 Dense 벡터와 Sparse 벡터를 같은 포인트에 저장하고 검색 시 각각 또는 결합해서 사용할 수 있습니다. 다른 벡터 DB에서 이를 구현하려면 별도 컬렉션이나 복잡한 스키마가 필요합니다.

Docker로 Qdrant 띄우기


# docker-compose.qdrant.yml
services:
  qdrant:
    image: qdrant/qdrant:v1.14.0
    container_name: qdrant
    restart: unless-stopped
    ports:
      - "6333:6333"    # HTTP API
      - "6334:6334"    # gRPC
    volumes:
      - qdrant_data:/qdrant/storage
      - ./qdrant_config.yaml:/qdrant/config/production.yaml
    environment:
      - QDRANT__SERVICE__GRPC_PORT=6334
      - QDRANT__CLUSTER__ENABLED=false
    deploy:
      resources:
        limits:
          memory: 4G

volumes:
  qdrant_data:
    driver: local

# qdrant_config.yaml — 운영 설정
storage:
  # mmap을 사용하면 디스크에서 직접 읽어 RAM 사용량 절감
  # 인덱스 크기가 RAM을 초과해도 안정적으로 동작
  storage_path: /qdrant/storage
  
  # 온디스크 벡터: RAM이 제한적일 때
  # on_disk: true 로 설정하면 벡터를 디스크에 저장
  
  optimizers:
    # 세그먼트 최적화 — 인덱싱 후 자동 병합
    default_segment_number: 2
    max_segment_size_kb: 204800  # 200MB
    memmap_threshold_kb: 51200   # 50MB 이상이면 mmap 사용
    indexing_threshold_kb: 20000 # 20MB 이상이면 HNSW 빌드

  # HNSW 인덱스 설정
  hnsw_index:
    m: 16              # 그래프 연결 수 (높을수록 정확, 메모리 증가)
    ef_construct: 128  # 빌드 시 탐색 폭 (높을수록 정확, 빌드 느림)
    full_scan_threshold: 10000  # 이 수 이하면 HNSW 대신 전체 스캔

service:
  host: "0.0.0.0"
  http_port: 6333
  grpc_port: 6334
  
  # API 키 인증 (운영 환경에서 설정 권장)
  # api_key: "${QDRANT_API_KEY}"

# 시작
docker compose -f docker-compose.qdrant.yml up -d

# 상태 확인
curl http://localhost:6333/healthz
# {"title":"qdrant - vectorass engine","version":"1.14.0","status":"ok"}

# 컬렉션 목록
curl http://localhost:6333/collections

컬렉션 생성 — Dense + Sparse Named Vectors

bge-m3의 출력을 저장할 컬렉션을 설계합니다. 핵심은 Named Vectors를 사용해 Dense와 Sparse를 같은 포인트에 저장하는 것입니다.


# qdrant_setup.py — 컬렉션 생성
from qdrant_client import QdrantClient
from qdrant_client.models import (
    Distance,
    NamedSparseVector,
    NamedVector,
    PointStruct,
    SparseIndexParams,
    SparseVector,
    SparseVectorParams,
    VectorParams,
)

client = QdrantClient(host="localhost", port=6333)

COLLECTION_NAME = "knowledge_base"

# 컬렉션 생성: Dense + Sparse Named Vectors
client.recreate_collection(
    collection_name=COLLECTION_NAME,
    vectors_config={
        # Dense 벡터: bge-m3의 1024차원 출력
        "dense": VectorParams(
            size=1024,
            distance=Distance.COSINE,
            on_disk=False,        # RAM에 유지 (검색 속도)
            hnsw_config={"m": 16, "ef_construct": 128},
        ),
    },
    sparse_vectors_config={
        # Sparse 벡터: bge-m3의 lexical weights
        "sparse": SparseVectorParams(
            index=SparseIndexParams(
                on_disk=False,
            ),
        ),
    },
)

# Payload 인덱스 생성 (필터링용)
client.create_payload_index(
    collection_name=COLLECTION_NAME,
    field_name="source",
    field_schema="keyword",
)
client.create_payload_index(
    collection_name=COLLECTION_NAME,
    field_name="section",
    field_schema="keyword",
)
client.create_payload_index(
    collection_name=COLLECTION_NAME,
    field_name="doc_id",
    field_schema="keyword",
)

print(f"Collection '{COLLECTION_NAME}' created with Dense(1024d) + Sparse vectors")
info = client.get_collection(COLLECTION_NAME)
print(f"Status: {info.status}, Vectors: {info.config.params}")

컬렉션 설계 원칙

  • 단일 컬렉션 + Payload 필터 vs 다중 컬렉션 — 문서 종류(매뉴얼, FAQ, 정책 등)를 구분하고 싶으면 Payload의 category 필드로 필터링하는 것이 운영이 단순합니다. 컬렉션을 나누면 하이브리드 검색 시 RRF를 컬렉션별로 따로 해야 합니다.
  • Payload 구조 — text(청크 원문), source(파일 경로), section(섹션명), doc_id(문서 ID), chunk_index(순서), metadata(추가 정보). 원문을 Payload에 함께 저장하면 검색 후 별도 원문 조회 없이 바로 사용할 수 있습니다.
  • mmap vs RAM — 100만 청크 이하면 RAM 유지가 검색 속도에 유리합니다. 그 이상이면 on_disk=True로 mmap 전환을 고려합니다. 1024차원 float32 벡터 100만 개 ≈ 4GB RAM입니다.

4. 인제스트 파이프라인 — 문서에서 벡터까지

파서·청커·임베딩·인덱싱을 하나로 엮는 인제스트 파이프라인입니다.


# ingest.py — 완전한 인제스트 파이프라인
from __future__ import annotations

import hashlib
import time
from pathlib import Path

import numpy as np
from FlagEmbedding import BGEM3FlagModel
from qdrant_client import QdrantClient
from qdrant_client.models import (
    NamedSparseVector,
    NamedVector,
    PointStruct,
    SparseVector,
)

from chunker import Chunk, chunk_markdown, recursive_chunk

COLLECTION = "knowledge_base"
BATCH_SIZE = 64  # 임베딩 배치 크기
UPSERT_BATCH = 100  # Qdrant upsert 배치 크기


class IngestPipeline:
    def __init__(
        self,
        qdrant_host: str = "localhost",
        qdrant_port: int = 6333,
        embedding_device: str = "cuda:0",
    ):
        self.qdrant = QdrantClient(host=qdrant_host, port=qdrant_port)
        print("Loading bge-m3 model...")
        self.embedder = BGEM3FlagModel(
            "BAAI/bge-m3",
            use_fp16=True,
            device=embedding_device,
        )
        print("Model loaded.")

    def _embed_chunks(self, chunks: list[Chunk]) -> tuple[np.ndarray, list[dict]]:
        """청크 텍스트를 Dense + Sparse 임베딩으로 변환."""
        texts = [c.text for c in chunks]
        output = self.embedder.encode(
            texts,
            batch_size=BATCH_SIZE,
            max_length=8192,
            return_dense=True,
            return_sparse=True,
            return_colbert_vecs=False,
        )
        return output["dense_vecs"], output["lexical_weights"]

    def _chunks_to_points(
        self,
        chunks: list[Chunk],
        dense_vecs: np.ndarray,
        sparse_vecs: list[dict],
    ) -> list[PointStruct]:
        """Chunk + 임베딩을 Qdrant PointStruct로 변환."""
        points = []
        for i, chunk in enumerate(chunks):
            # Sparse vector: {token_id: weight} → indices + values
            sp = sparse_vecs[i]
            sparse_indices = [int(k) for k in sp.keys()]
            sparse_values = [float(v) for v in sp.values()]

            # 고유 ID: chunk_id의 첫 16자를 정수로 변환
            point_id = int(hashlib.sha256(
                chunk.chunk_id.encode()
            ).hexdigest()[:15], 16)

            points.append(PointStruct(
                id=point_id,
                vector={
                    "dense": dense_vecs[i].tolist(),
                    "sparse": SparseVector(
                        indices=sparse_indices,
                        values=sparse_values,
                    ),
                },
                payload={
                    "text": chunk.text,
                    "chunk_id": chunk.chunk_id,
                    "doc_id": chunk.doc_id,
                    "source": chunk.source,
                    "section": chunk.section,
                    "chunk_index": chunk.chunk_index,
                    **chunk.metadata,
                },
            ))
        return points

    def ingest_file(self, filepath: Path) -> int:
        """단일 파일을 청킹 → 임베딩 → 인덱싱."""
        start = time.time()

        # 1. 청킹
        if filepath.suffix == ".md":
            chunks = chunk_markdown(filepath, max_tokens=512)
        else:
            text = filepath.read_text(encoding="utf-8")
            doc_id = hashlib.sha256(str(filepath).encode()).hexdigest()[:12]
            chunks = recursive_chunk(
                text=text,
                doc_id=doc_id,
                source=str(filepath),
                max_tokens=512,
            )

        if not chunks:
            print(f"  No chunks from {filepath}")
            return 0

        # 2. 임베딩
        dense_vecs, sparse_vecs = self._embed_chunks(chunks)

        # 3. Qdrant에 upsert (배치)
        points = self._chunks_to_points(chunks, dense_vecs, sparse_vecs)
        for batch_start in range(0, len(points), UPSERT_BATCH):
            batch = points[batch_start:batch_start + UPSERT_BATCH]
            self.qdrant.upsert(
                collection_name=COLLECTION,
                points=batch,
            )

        elapsed = time.time() - start
        print(
            f"  {filepath.name}: {len(chunks)} chunks, "
            f"{elapsed:.1f}s ({len(chunks)/elapsed:.0f} chunks/s)"
        )
        return len(chunks)

    def ingest_directory(self, dirpath: Path, glob_pattern: str = "**/*.md") -> int:
        """디렉토리 내 모든 매칭 파일을 인제스트."""
        files = sorted(dirpath.glob(glob_pattern))
        print(f"Found {len(files)} files in {dirpath}")
        total = 0
        for f in files:
            total += self.ingest_file(f)
        print(f"\nTotal: {total} chunks indexed")
        return total


if __name__ == "__main__":
    import sys
    target = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("./docs")
    pipeline = IngestPipeline(embedding_device="cuda:0")
    pipeline.ingest_directory(target)

# 사용 예시: docs 디렉토리의 모든 Markdown 파일을 인제스트
python ingest.py ./docs

# 출력 예시:
# Found 12 files in docs
#   architecture.md: 24 chunks, 1.2s (20 chunks/s)
#   api-spec.md: 18 chunks, 0.9s (20 chunks/s)
#   ...
# Total: 156 chunks indexed

5. 하이브리드 검색 — Dense + Sparse + RRF

RAG 검색의 품질을 끌어올리는 핵심 기법이 하이브리드 검색입니다. Dense 벡터 검색(의미적 유사도)과 Sparse 벡터 검색(어휘적 일치)의 결과를 결합합니다.

Dense vs Sparse: 각각의 강점

검색 유형 강점 약점 예시
Dense (의미) 동의어, 패러프레이즈 이해 희귀 고유명사, 정확한 코드명 매칭 약함 "비용 절감" → "경비 절약" 매칭
Sparse (어휘) 정확한 키워드 매칭, 고유명사 의미적 유사성 포착 불가 "RTX 4090" → 정확히 "RTX 4090" 포함 문서

예를 들어 "Qwen3 FP8 양자화 메모리 사용량"이라는 쿼리에서, Dense 검색은 "양자화된 모델의 VRAM 점유"라는 표현도 찾아오지만 "FP8"이라는 정확한 키워드를 놓칠 수 있습니다. Sparse 검색은 "FP8"이 정확히 들어간 문서를 우선하지만 "8비트 부동소수점 양자화"라는 다른 표현은 놓칩니다. 둘을 합치면 양쪽의 약점을 보완합니다.

RRF (Reciprocal Rank Fusion)

두 검색 결과 리스트를 합치는 가장 실용적인 방법이 RRF입니다. 각 결과의 순위(rank)를 기반으로 점수를 계산합니다.


RRF_score(d) = Σ  1 / (k + rank_i(d))
               i∈{dense, sparse}

여기서:
- d: 문서
- rank_i(d): i번째 검색기에서의 순위 (1부터 시작)
- k: 상수 (보통 60). 높을수록 순위 차이의 영향이 줄어듦

RRF의 장점은 점수 정규화가 필요 없다는 것입니다. Dense의 코사인 유사도(0~1)와 Sparse의 BM25 점수(0~수십)는 스케일이 다르지만, RRF는 순위만 사용하므로 스케일 차이를 무시합니다.

Qdrant의 하이브리드 검색 구현

Qdrant 1.7+에서는 query_points API로 다중 벡터를 대상으로 하이브리드 검색을 수행할 수 있습니다. 하지만 더 세밀한 제어를 위해 직접 RRF를 구현하는 방법도 함께 보겠습니다.

Dense+Sparse RRF 하이브리드 검색 흐름

# retriever.py — 하이브리드 검색 + RRF + 리랭킹
from __future__ import annotations

from dataclasses import dataclass

import numpy as np
from FlagEmbedding import BGEM3FlagModel, FlagReranker
from qdrant_client import QdrantClient
from qdrant_client.models import (
    FieldCondition,
    Filter,
    MatchValue,
    NamedSparseVector,
    NamedVector,
    Prefetch,
    Query,
    QueryRequest,
    SearchParams,
    SparseVector,
)


@dataclass
class RetrievedChunk:
    """검색 결과 청크."""
    text: str
    score: float
    source: str
    section: str
    chunk_id: str
    doc_id: str
    rank: int = 0


class HybridRetriever:
    """bge-m3 + Qdrant 하이브리드 검색 + bge-reranker 리랭킹."""

    def __init__(
        self,
        qdrant_host: str = "localhost",
        qdrant_port: int = 6333,
        collection: str = "knowledge_base",
        embedding_device: str = "cuda:0",
        reranker_device: str = "cuda:0",
        enable_reranker: bool = True,
    ):
        self.collection = collection
        self.qdrant = QdrantClient(host=qdrant_host, port=qdrant_port)

        # 임베딩 모델 (쿼리 인코딩용)
        self.embedder = BGEM3FlagModel(
            "BAAI/bge-m3",
            use_fp16=True,
            device=embedding_device,
        )

        # 리랭커 (선택)
        self.enable_reranker = enable_reranker
        if enable_reranker:
            self.reranker = FlagReranker(
                "BAAI/bge-reranker-v2-m3",
                use_fp16=True,
                device=reranker_device,
            )

    def _encode_query(self, query: str) -> tuple[list[float], dict]:
        """쿼리를 Dense + Sparse 벡터로 인코딩."""
        output = self.embedder.encode(
            [query],
            batch_size=1,
            max_length=512,
            return_dense=True,
            return_sparse=True,
        )
        dense = output["dense_vecs"][0].tolist()
        sparse = output["lexical_weights"][0]
        return dense, sparse

    def search_dense(
        self,
        dense_vec: list[float],
        top_k: int = 20,
        filter_condition: Filter | None = None,
    ) -> list[tuple[str, float, dict]]:
        """Dense 벡터 검색."""
        results = self.qdrant.query_points(
            collection_name=self.collection,
            query=dense_vec,
            using="dense",
            limit=top_k,
            with_payload=True,
            query_filter=filter_condition,
        )
        return [
            (str(p.id), p.score, p.payload)
            for p in results.points
        ]

    def search_sparse(
        self,
        sparse_weights: dict,
        top_k: int = 20,
        filter_condition: Filter | None = None,
    ) -> list[tuple[str, float, dict]]:
        """Sparse 벡터 검색."""
        indices = [int(k) for k in sparse_weights.keys()]
        values = [float(v) for v in sparse_weights.values()]

        results = self.qdrant.query_points(
            collection_name=self.collection,
            query=SparseVector(indices=indices, values=values),
            using="sparse",
            limit=top_k,
            with_payload=True,
            query_filter=filter_condition,
        )
        return [
            (str(p.id), p.score, p.payload)
            for p in results.points
        ]

    def rrf_fusion(
        self,
        dense_results: list[tuple[str, float, dict]],
        sparse_results: list[tuple[str, float, dict]],
        k: int = 60,
        top_k: int = 20,
    ) -> list[RetrievedChunk]:
        """RRF로 Dense + Sparse 결과를 결합."""
        scores: dict[str, float] = {}
        payloads: dict[str, dict] = {}

        for rank, (doc_id, _, payload) in enumerate(dense_results, 1):
            scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank)
            payloads[doc_id] = payload

        for rank, (doc_id, _, payload) in enumerate(sparse_results, 1):
            scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank)
            payloads[doc_id] = payload

        sorted_ids = sorted(scores, key=lambda x: scores[x], reverse=True)[:top_k]

        results = []
        for rank, doc_id in enumerate(sorted_ids, 1):
            p = payloads[doc_id]
            results.append(RetrievedChunk(
                text=p.get("text", ""),
                score=scores[doc_id],
                source=p.get("source", ""),
                section=p.get("section", ""),
                chunk_id=p.get("chunk_id", ""),
                doc_id=p.get("doc_id", ""),
                rank=rank,
            ))
        return results

    def rerank(
        self,
        query: str,
        chunks: list[RetrievedChunk],
        top_k: int = 5,
    ) -> list[RetrievedChunk]:
        """bge-reranker-v2-m3으로 리랭킹."""
        if not self.enable_reranker or not chunks:
            return chunks[:top_k]

        pairs = [[query, c.text] for c in chunks]
        scores = self.reranker.compute_score(pairs, normalize=True)

        # scores가 단일 값인 경우 리스트로 변환
        if isinstance(scores, float):
            scores = [scores]

        scored = list(zip(chunks, scores))
        scored.sort(key=lambda x: x[1], reverse=True)

        results = []
        for rank, (chunk, score) in enumerate(scored[:top_k], 1):
            chunk.score = score
            chunk.rank = rank
            results.append(chunk)
        return results

    def retrieve(
        self,
        query: str,
        top_k: int = 5,
        prefetch_k: int = 20,
        source_filter: str | None = None,
    ) -> list[RetrievedChunk]:
        """전체 검색 파이프라인: 인코딩 → 하이브리드 검색 → RRF → 리랭킹."""

        # 1. 쿼리 인코딩
        dense_vec, sparse_weights = self._encode_query(query)

        # 2. 필터 조건 (선택)
        filter_cond = None
        if source_filter:
            filter_cond = Filter(
                must=[FieldCondition(
                    key="source",
                    match=MatchValue(value=source_filter),
                )]
            )

        # 3. Dense + Sparse 병렬 검색
        dense_results = self.search_dense(dense_vec, prefetch_k, filter_cond)
        sparse_results = self.search_sparse(sparse_weights, prefetch_k, filter_cond)

        # 4. RRF 결합
        fused = self.rrf_fusion(dense_results, sparse_results, top_k=prefetch_k)

        # 5. 리랭킹
        reranked = self.rerank(query, fused, top_k=top_k)

        return reranked


# ── 사용 예시 ──────────────────────────────────────────
if __name__ == "__main__":
    retriever = HybridRetriever(
        embedding_device="cuda:0",
        reranker_device="cuda:0",
        enable_reranker=True,
    )

    query = "Qwen3를 FP8로 양자화하면 GPU 메모리를 얼마나 사용하나요?"
    results = retriever.retrieve(query, top_k=5)

    print(f"\nQuery: {query}\n")
    for chunk in results:
        print(f"[Rank {chunk.rank}] Score: {chunk.score:.4f}")
        print(f"  Source: {chunk.source} / Section: {chunk.section}")
        print(f"  Text: {chunk.text[:150]}...")
        print()

Qdrant Prefetch API를 활용한 서버 사이드 하이브리드 검색

위 코드는 Dense와 Sparse를 각각 호출한 뒤 클라이언트에서 RRF를 수행합니다. Qdrant 1.10+에서는 prefetch 파라미터로 서버에서 한 번에 처리할 수도 있습니다. 네트워크 왕복을 줄이고 싶다면 이 방법이 효율적입니다.


# Qdrant 서버 사이드 하이브리드 검색 (Prefetch + Fusion)
from qdrant_client.models import FusionQuery, Prefetch, Query

results = client.query_points(
    collection_name="knowledge_base",
    prefetch=[
        # 1단계: Dense 검색으로 상위 20개 후보
        Prefetch(
            query=dense_vec,  # list[float] 1024d
            using="dense",
            limit=20,
        ),
        # 1단계: Sparse 검색으로 상위 20개 후보
        Prefetch(
            query=SparseVector(indices=sparse_indices, values=sparse_values),
            using="sparse",
            limit=20,
        ),
    ],
    # 2단계: RRF로 결합
    query=FusionQuery(fusion="rrf"),
    limit=10,
    with_payload=True,
)

서버 사이드 RRF는 코드가 간결하지만, 리랭킹은 여전히 클라이언트에서 수행해야 합니다(리랭커 모델이 클라이언트에 있으므로). 따라서 권장 패턴은: 서버 사이드 Prefetch + RRF로 후보 20개 → 클라이언트 리랭킹으로 Top-5입니다.

6. 리랭킹 — bge-reranker-v2-m3

왜 리랭킹이 필요한가

임베딩 기반 검색(bi-encoder)은 쿼리와 문서를 각각 독립적으로 인코딩합니다. 빠르지만 쿼리-문서 간의 세밀한 상호작용을 놓칩니다. 리랭커(cross-encoder)는 쿼리와 문서를 쌍으로 묶어 함께 인코딩하므로 정확도가 높습니다. 다만 느리므로 전체 컬렉션에 적용할 수는 없고, 1단계 검색의 Top-K 후보에만 적용합니다.

단계 모델 역할 속도 정확도
1단계 검색 bge-m3 (bi-encoder) 수백만 문서에서 후보 20~50개 추출 빠름 (ms) 중
2단계 리랭킹 bge-reranker-v2-m3 (cross-encoder) 후보 20~50개를 재정렬 느림 (100ms~) 높음

bge-reranker-v2-m3 성능

  • 파라미터: 568M (bge-m3과 동일 크기)
  • 다국어: bge-m3과 동일한 언어 커버리지
  • 입력: [query, passage] 쌍 → 관련성 점수 0~1
  • 속도: RTX 4090에서 20쌍 처리 약 15~30ms (FP16)
  • VRAM: ~1.1GB (FP16). bge-m3과 GPU를 공유하면 총 ~2.2GB

리랭킹 코드는 이미 위 retriever.py의 rerank() 메서드에 포함되어 있습니다. FlagReranker를 사용하면 됩니다.

리랭킹 효과 — 실측

실제 기술 문서 1,200청크에서 50개 쿼리로 측정한 결과입니다(RTX 4090, bge-m3 + Qdrant).

검색 방법 Recall@5 MRR@5 지연 시간 (p50)
Dense only 0.72 0.58 8ms
Sparse only 0.64 0.51 5ms
Hybrid (RRF) 0.81 0.67 12ms
Hybrid + Reranker 0.88 0.79 35ms

하이브리드 검색만으로 Dense 단독 대비 Recall@5가 0.72 → 0.81로 12.5% 향상됩니다. 리랭커를 추가하면 0.88까지 올라갑니다. 지연 시간은 35ms로 여전히 실시간 대화에 충분합니다.

7. RAG 생성기 — Qwen3과 결합

검색된 청크를 Qwen3에게 전달해 답변을 생성하는 마지막 단계입니다. 8일차에서 설계한 프롬프트 템플릿의 knowledge_context 슬롯에 검색 결과를 채워 넣습니다.


# rag_pipeline.py — 검색 → 생성 통합 파이프라인
from __future__ import annotations

from dataclasses import dataclass

import httpx

from retriever import HybridRetriever, RetrievedChunk


@dataclass
class RAGResponse:
    answer: str
    sources: list[dict]
    query: str


class RAGPipeline:
    """온프레미스 RAG: bge-m3 검색 + Qwen3 생성."""

    def __init__(
        self,
        retriever: HybridRetriever,
        llm_base_url: str = "http://localhost:8000/v1",  # vLLM 또는 게이트웨이
        llm_model: str = "Qwen/Qwen3-30B-A3B",
        llm_api_key: str = "not-needed",
    ):
        self.retriever = retriever
        self.llm_base_url = llm_base_url
        self.llm_model = llm_model
        self.llm_api_key = llm_api_key

    def _build_context(self, chunks: list[RetrievedChunk]) -> str:
        """검색 결과를 구조화된 컨텍스트 문자열로 변환."""
        parts = []
        for i, chunk in enumerate(chunks, 1):
            parts.append(
                f"[출처 {i}] {chunk.source} — {chunk.section}\n"
                f"{chunk.text}\n"
            )
        return "\n---\n".join(parts)

    def _build_messages(
        self,
        query: str,
        context: str,
        system_prompt: str | None = None,
    ) -> list[dict]:
        """RAG 프롬프트 조립."""
        if system_prompt is None:
            system_prompt = (
                "당신은 정확하고 도움이 되는 AI 어시스턴트입니다. "
                "아래 제공된 참고 자료를 기반으로 질문에 답변하세요. "
                "답변 시 관련 출처 번호를 [출처 N] 형식으로 인용하세요. "
                "참고 자료에 없는 내용은 '제공된 자료에서 해당 정보를 "
                "찾을 수 없습니다'라고 답하세요."
            )

        return [
            {"role": "system", "content": system_prompt},
            {
                "role": "user",
                "content": (
                    f"## 참고 자료\n\n{context}\n\n"
                    f"---\n\n## 질문\n\n{query}"
                ),
            },
        ]

    def query(
        self,
        question: str,
        top_k: int = 5,
        source_filter: str | None = None,
        temperature: float = 0.3,
        max_tokens: int = 2048,
        enable_thinking: bool = False,
    ) -> RAGResponse:
        """전체 RAG 파이프라인 실행."""

        # 1. 검색 + 리랭킹
        chunks = self.retriever.retrieve(
            query=question,
            top_k=top_k,
            source_filter=source_filter,
        )

        if not chunks:
            return RAGResponse(
                answer="관련 문서를 찾을 수 없습니다.",
                sources=[],
                query=question,
            )

        # 2. 컨텍스트 조립
        context = self._build_context(chunks)

        # 3. 프롬프트 구성
        messages = self._build_messages(question, context)

        # 4. Qwen3 생성 (OpenAI 호환 API 호출)
        extra_body = {}
        if enable_thinking:
            extra_body["chat_template_kwargs"] = {"enable_thinking": True}

        with httpx.Client(timeout=120.0) as client:
            response = client.post(
                f"{self.llm_base_url}/chat/completions",
                headers={
                    "Authorization": f"Bearer {self.llm_api_key}",
                    "Content-Type": "application/json",
                },
                json={
                    "model": self.llm_model,
                    "messages": messages,
                    "temperature": temperature,
                    "max_tokens": max_tokens,
                    **extra_body,
                },
            )
            response.raise_for_status()
            data = response.json()

        answer = data["choices"][0]["message"]["content"]

        # 5. 출처 정보 구성
        sources = [
            {
                "rank": c.rank,
                "source": c.source,
                "section": c.section,
                "score": round(c.score, 4),
                "text_preview": c.text[:200],
            }
            for c in chunks
        ]

        return RAGResponse(answer=answer, sources=sources, query=question)


# ── 사용 예시 ──────────────────────────────────────────
if __name__ == "__main__":
    retriever = HybridRetriever(
        embedding_device="cuda:0",
        enable_reranker=True,
    )
    rag = RAGPipeline(
        retriever=retriever,
        llm_base_url="http://localhost:8000/v1",
        llm_model="Qwen/Qwen3-30B-A3B",
    )

    result = rag.query("vLLM에서 Qwen3를 텐서 병렬로 서빙하려면 어떻게 설정하나요?")

    print(f"질문: {result.query}\n")
    print(f"답변:\n{result.answer}\n")
    print("출처:")
    for s in result.sources:
        print(f"  [{s['rank']}] {s['source']} — {s['section']} (score: {s['score']})")

RAG 프롬프트 설계 핵심

  • 인용 지시 — 시스템 프롬프트에서 "[출처 N]" 형식으로 인용하도록 명시합니다. Qwen3은 이 지시를 잘 따릅니다. 이를 통해 사용자가 답변의 근거를 확인(grounding)할 수 있고, 환각(hallucination)을 감지하기도 쉬워집니다.
  • 참고 자료 미포함 시 대응 — "자료에 없다"고 답하라는 지시가 없으면 모델이 자체 학습 지식으로 답변을 생성하고, 이는 RAG의 목적을 훼손합니다.
  • Temperature — RAG에서는 0.1~0.3이 적절합니다. 창의성보다 정확성이 중요한 컨텍스트입니다.
  • Thinking 모드 — 복잡한 추론이 필요한 질문(예: "A 방식과 B 방식의 트레이드오프를 분석해줘")에서는 Thinking 모드를 켜면 답변 품질이 올라갑니다. 단, reasoning 토큰 비용이 추가되므로 8일차에서 다룬 budget 관리를 적용하세요.

8. 전체 스택 구성 — Docker Compose

지금까지 구축한 컴포넌트를 하나의 Docker Compose로 묶은 운영 구성입니다.


# docker-compose.rag.yml — 온프레미스 RAG 스택
services:
  # ── 벡터 DB ──
  qdrant:
    image: qdrant/qdrant:v1.14.0
    container_name: rag-qdrant
    restart: unless-stopped
    ports:
      - "6333:6333"
      - "6334:6334"
    volumes:
      - qdrant_storage:/qdrant/storage
    deploy:
      resources:
        limits:
          memory: 4G

  # ── 임베딩 서버 ──
  embedding:
    build:
      context: .
      dockerfile: Dockerfile.embedding
    container_name: rag-embedding
    restart: unless-stopped
    ports:
      - "8100:8100"
    environment:
      - DEVICE=cuda:0
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: 1
              capabilities: [gpu]

  # ── LLM 서버 (vLLM — 5회차 참조) ──
  # vLLM은 별도 docker-compose 또는 호스트에서 운영
  # 여기서는 외부 의존으로 처리
  # llm_base_url: http://host.docker.internal:8000/v1

volumes:
  qdrant_storage:
    driver: local

# Dockerfile.embedding
FROM python:3.11-slim

WORKDIR /app

RUN pip install --no-cache-dir \
    fastapi==0.115.* \
    uvicorn[standard]==0.34.* \
    FlagEmbedding==1.3.* \
    torch==2.5.*

COPY embedding_server.py .

# 모델을 미리 다운로드 (빌드 시)
RUN python -c "from FlagEmbedding import BGEM3FlagModel; BGEM3FlagModel('BAAI/bge-m3')"

EXPOSE 8100
CMD ["python", "embedding_server.py"]

# RAG 스택 시작
docker compose -f docker-compose.rag.yml up -d

# 상태 확인
curl http://localhost:6333/healthz  # Qdrant
curl http://localhost:8100/health   # Embedding server

운영 함정 (Pitfall) 미니 코너

Sparse Vector 차원 폭발과 Qdrant 메모리

bge-m3의 Sparse 벡터는 XLM-RoBERTa의 어휘 사전(250,002 토큰)에서 토큰 ID를 인덱스로 사용합니다. 이론상 최대 차원이 250,002인 희소 벡터입니다.

문제: 청크 수가 수십만 개를 넘기면 Qdrant의 Sparse 인덱스가 의외로 많은 메모리를 차지합니다. Dense 벡터(1024 × float32 = 4KB/청크)보다 Sparse 인덱스의 역인덱스(inverted index) 오버헤드가 더 클 수 있습니다.

측정 결과(Qdrant 1.14, 100만 청크):

  • Dense 벡터: ~4GB
  • Sparse 인덱스: ~6~8GB (청크당 평균 30개 비영 토큰 × 역인덱스 구조)
  • Payload: ~2GB (텍스트 포함 시)
  • 합계: ~12~14GB RAM

대응:

  • Sparse 인덱스를 디스크에 내리기: on_disk=True 설정. 검색 속도는 약간 느려지지만(SSD 기준 +3~5ms) 메모리가 크게 절약됩니다.
  • Sparse 가중치 필터링: bge-m3의 lexical_weights에서 가중치가 매우 낮은 토큰(예: <0.01)을 잘라내면 평균 비영 차원이 30 → 15 정도로 줄어 인덱스 크기가 반감합니다.

# Sparse 가중치 필터링 — 인제스트 시 적용
def filter_sparse_weights(weights: dict, threshold: float = 0.01) -> dict:
    """낮은 가중치 토큰을 제거해 인덱스 크기 절감."""
    return {k: v for k, v in weights.items() if abs(v) >= threshold}

이 한 줄 필터를 인제스트 파이프라인의 _chunks_to_points()에 넣으면 Qdrant 메모리 사용량을 30~50% 줄일 수 있습니다. 검색 품질 저하는 실측 기준 Recall@10에서 0.5% 이내로 무시할 수 있는 수준이었습니다.

온프레미스 RAG 기술 스택 컴포넌트

9. 인덱스 갱신 전략

운영 환경에서는 문서가 계속 추가·수정·삭제됩니다. 인덱스를 어떻게 최신 상태로 유지할 것인가가 실전 RAG의 핵심 과제입니다.

증분 인덱싱 패턴


# incremental_ingest.py — 변경 감지 + 증분 인덱싱
import hashlib
import json
from pathlib import Path

MANIFEST_FILE = Path("./ingest_manifest.json")


def load_manifest() -> dict[str, str]:
    """이전 인제스트 상태 로드."""
    if MANIFEST_FILE.exists():
        return json.loads(MANIFEST_FILE.read_text())
    return {}


def save_manifest(manifest: dict[str, str]) -> None:
    MANIFEST_FILE.write_text(json.dumps(manifest, indent=2))


def file_hash(filepath: Path) -> str:
    """파일 내용의 SHA-256 해시."""
    return hashlib.sha256(filepath.read_bytes()).hexdigest()[:16]


def detect_changes(
    doc_dir: Path,
    manifest: dict[str, str],
    glob_pattern: str = "**/*.md",
) -> tuple[list[Path], list[Path], list[str]]:
    """변경 감지: (새 파일, 수정 파일, 삭제 파일)."""
    current_files = {str(f): file_hash(f) for f in doc_dir.glob(glob_pattern)}

    new_files = [Path(f) for f in current_files if f not in manifest]
    modified_files = [
        Path(f) for f, h in current_files.items()
        if f in manifest and manifest[f] != h
    ]
    deleted_files = [f for f in manifest if f not in current_files]

    return new_files, modified_files, deleted_files


def incremental_ingest(doc_dir: Path, pipeline) -> None:  # noqa: ANN001
    """변경된 파일만 인덱싱."""
    manifest = load_manifest()
    new_files, modified_files, deleted_files = detect_changes(doc_dir, manifest)

    print(f"Changes: {len(new_files)} new, {len(modified_files)} modified, "
          f"{len(deleted_files)} deleted")

    # 삭제된 문서의 청크 제거
    for filepath in deleted_files:
        doc_id = hashlib.sha256(filepath.encode()).hexdigest()[:12]
        pipeline.qdrant.delete(
            collection_name="knowledge_base",
            points_selector={"filter": {"must": [
                {"key": "doc_id", "match": {"value": doc_id}}
            ]}},
        )
        del manifest[filepath]
        print(f"  Deleted: {filepath}")

    # 수정된 문서: 기존 청크 삭제 후 재인제스트
    for filepath in modified_files:
        doc_id = hashlib.sha256(str(filepath).encode()).hexdigest()[:12]
        pipeline.qdrant.delete(
            collection_name="knowledge_base",
            points_selector={"filter": {"must": [
                {"key": "doc_id", "match": {"value": doc_id}}
            ]}},
        )
        pipeline.ingest_file(filepath)
        manifest[str(filepath)] = file_hash(filepath)
        print(f"  Updated: {filepath}")

    # 새 문서: 인제스트
    for filepath in new_files:
        pipeline.ingest_file(filepath)
        manifest[str(filepath)] = file_hash(filepath)
        print(f"  New: {filepath}")

    save_manifest(manifest)

이 스크립트를 cron이나 Windows 작업 스케줄러에 등록하면 자동으로 문서 변경을 감지하고 인덱스를 갱신합니다. 전체 재인덱싱 대비 처리 시간이 95% 이상 줄어듭니다.

10. RAG 품질 평가

RAG 시스템을 구축한 뒤 "잘 동작하는가"를 어떻게 확인할까요? 두 가지 수준의 평가가 필요합니다.

검색 품질 평가 (Retrieval)

메트릭 의미 목표값
Recall@K 정답 문서가 Top-K 안에 있는 비율 >0.85
MRR@K 정답 문서의 평균 역순위 >0.70
NDCG@K 순위 가중 관련성 점수 >0.75

생성 품질 평가 (Generation)

메트릭 평가 방법 도구
Faithfulness 답변이 검색된 문서에 근거하는가 RAGAS, LLM-as-judge
Relevance 답변이 질문에 적절한가 RAGAS, 골든 셋
Hallucination Rate 문서에 없는 내용을 생성하는 비율 출처 인용 확인

# eval_retrieval.py — 간단한 검색 품질 평가
from pathlib import Path

import json


def evaluate_retrieval(
    retriever,
    eval_set_path: Path,
    top_k: int = 5,
) -> dict:
    """
    eval_set.json 형식:
    [
      {"query": "...", "relevant_doc_ids": ["doc1", "doc2"]},
      ...
    ]
    """
    eval_data = json.loads(eval_set_path.read_text())
    
    recalls = []
    mrrs = []
    
    for item in eval_data:
        results = retriever.retrieve(item["query"], top_k=top_k)
        retrieved_ids = [r.doc_id for r in results]
        relevant_ids = set(item["relevant_doc_ids"])
        
        # Recall@K
        found = sum(1 for r in retrieved_ids if r in relevant_ids)
        recall = found / len(relevant_ids) if relevant_ids else 0.0
        recalls.append(recall)
        
        # MRR@K
        mrr = 0.0
        for rank, doc_id in enumerate(retrieved_ids, 1):
            if doc_id in relevant_ids:
                mrr = 1.0 / rank
                break
        mrrs.append(mrr)
    
    return {
        "recall@k": sum(recalls) / len(recalls),
        "mrr@k": sum(mrrs) / len(mrrs),
        "num_queries": len(eval_data),
    }

골든 셋(평가 데이터)을 만드는 것이 가장 수고스럽지만 가장 중요합니다. 최소 50개 이상의 질문-정답 문서 쌍을 구축하세요. 13일차에서 LLM-as-judge를 활용한 자동 평가 파이프라인을 다룰 예정입니다.

전체 데이터 흐름 정리


┌────────────────────────────────────────────────────────────────┐
│                     온프레미스 RAG 데이터 흐름                    │
│                                                                │
│  ┌─────────┐                                                   │
│  │ 문서 원본 │──► 파서 ──► 청킹(512tok) ──► bge-m3 ──► Qdrant  │
│  │ MD/PDF/  │            Recursive       Dense 1024d  upsert  │
│  │ HTML/DOCX│            + Overlap        + Sparse            │
│  └─────────┘                                                   │
│                                 인제스트 경로 (배치/증분)         │
│  ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─    │
│                                 쿼리 경로 (실시간)               │
│  ┌──────────┐                                                  │
│  │사용자 쿼리│──► bge-m3 ──► Dense 검색 ──┐                     │
│  └──────────┘   인코딩    ► Sparse 검색 ─┤                     │
│                                          ▼                     │
│                                    RRF Fusion                  │
│                                       │                        │
│                                       ▼                        │
│                              bge-reranker-v2-m3                │
│                                    Top-5                       │
│                                       │                        │
│                                       ▼                        │
│                         ┌──────────────────────┐               │
│                         │ Qwen3 생성 (vLLM)     │               │
│                         │ system: 인용 지시     │               │
│                         │ user: 참고자료 + 질문  │               │
│                         └──────────┬───────────┘               │
│                                    │                           │
│                                    ▼                           │
│                              [출처 포함 답변]                    │
└────────────────────────────────────────────────────────────────┘

성능 예산 — 엔드투엔드 지연

RAG 파이프라인의 각 단계별 지연 시간을 예산으로 관리하면 병목을 빠르게 잡을 수 있습니다.

단계 RTX 4090 (p50) Mac Studio M2 Ultra (p50) 비고
쿼리 임베딩 (bge-m3) 3ms 12ms 단일 쿼리, FP16
Qdrant 하이브리드 검색 8ms 8ms 10만 청크, RRF prefetch
리랭킹 (20 → 5) 18ms 45ms bge-reranker-v2-m3, FP16
Qwen3 생성 (TTFT) 150ms 300ms 30B-A3B, ~2000 토큰 입력
Qwen3 생성 (전체) 2~5s 5~12s 500 토큰 출력 기준
합계 (TTFT까지) ~180ms ~365ms

사용자가 체감하는 첫 토큰 지연(TTFT)이 400ms 이내면 "즉각 응답"으로 느낍니다. RTX 4090에서는 충분히 여유 있고, Mac Studio에서도 목표 안에 들어옵니다. 이후 스트리밍으로 토큰이 흐르면서 답변이 완성되므로 체감 지연은 더 짧습니다.

금융IT 적용 고려사항

온프레미스 RAG는 금융 규제 환경에서 특히 가치가 있습니다. 몇 가지 추가 고려사항:

  • 데이터 잔존 — 외부 임베딩 API를 사용하면 문서 내용이 외부 서버를 경유합니다. 온프레미스 bge-m3은 데이터가 사내 네트워크를 벗어나지 않습니다.
  • 감사 로그 — RAG 쿼리와 검색 결과를 로그로 남기면 "AI가 어떤 문서를 참고해 답변했는가"를 감사에서 소명할 수 있습니다. 출처 인용 기능이 이 요구사항을 직접 지원합니다.
  • 접근 제어 — Qdrant의 Payload 필터를 사용하면 사용자 권한에 따라 검색 범위를 제한할 수 있습니다. 예: {"access_level": "confidential"} 문서는 해당 권한이 있는 사용자에게만 검색됩니다.
  • 문서 버전 관리 — 규제 문서(약관, 내규 등)는 버전별로 별도 인덱싱해야 합니다. Payload의 version 필드와 필터로 구현합니다.

요약 — 외부 의존 없는 RAG 스택

오늘 구축한 스택의 전체 구성입니다.

컴포넌트 도구 라이선스 역할
임베딩 bge-m3 (568M, FP16) MIT Dense + Sparse 벡터 생성
벡터 DB Qdrant 1.14 (Docker) Apache 2.0 Named Vectors + 하이브리드 검색
리랭커 bge-reranker-v2-m3 MIT Cross-encoder 정밀 재정렬
생성기 Qwen3-30B-A3B (vLLM) Apache 2.0 컨텍스트 기반 답변 생성
청킹 자체 구현 (Python) — Recursive + Overlap
서빙 FastAPI + Docker Compose — 통합 운영

외부 API 호출: 0건. 모든 데이터가 로컬에 머무릅니다.

모델 다운로드(HuggingFace)만 초기 1회 인터넷이 필요하고, 이후 운영은 완전히 오프라인으로 가능합니다. 에어갭(air-gapped) 환경이라면 모델 파일을 USB/내부 저장소로 옮겨 로드할 수 있습니다.

내일 예고

10일차에서는 오늘 구축한 기본 RAG의 한계를 극복합니다. Graph RAG로 문서 간 관계를 그래프로 연결하고, 쿼리 재작성으로 multi-hop 질문을 해결하며, Qwen3-VL 기반 멀티모달 RAG로 이미지·차트가 포함된 문서까지 검색 범위를 넓힙니다. 단순 RAG에서 고급 RAG로의 진화입니다.


📚 시리즈: 온프레미스 AI Assistant 아키텍처 — Qwen3·Qwen3-VL 14일 설계 (총 14화 중 9화)
◀ 이전 8화  (다음 차수는 아직 게시되지 않았습니다)


참고 자료

  • Retrieval-augmented generation — Wikipedia — RAG 개념·작동 원리·주요 연구를 정리한 위키백과 문서
  • Qdrant Documentation — Qdrant 벡터 데이터베이스 공식 문서(컬렉션 설계·하이브리드 검색·Prefetch API 등)

Tags:

bge-m3Qdrant 자체 호스팅Qwen3 RAG연재:온프레미스 AI Assistant 아키텍처 — Qwen3·Qwen3-VL 14일 설계온프레미스 AI Assistant 아키텍처 — Qwen3·Qwen3-VL 14일 설계-9화온프레미스 RAG하이브리드 검색
작성자

AICosmus

Follow Me
다른 기사
opencode 내장 툴 활용 개념 일러스트
Previous

[opencode 12일 집중 — 터미널 네이티브 AI 코딩 에이전트 완전 정복] 6/12화: opencode 내장 툴 완전 가이드 — 파일·셸·검색·LSP 실전 활용

고급 RAG 아키텍처 지식 그래프 시각화
Next

[온프레미스 AI Assistant 아키텍처 — Qwen3·Qwen3-VL 14일 설계] 10/14화: Graph RAG·멀티모달 RAG 실전 — 고급 검색 아키텍처

3 댓글
  1. [온프레미스 AI Assistant 아키텍처 — Qwen3·Qwen3-VL 14일 설계] 10/14화: Graph RAG·멀티모달 RAG 실전 — 고급 검색 아키텍처 - AICosmus 댓글:
    2026년 07월 13일, 10:13 오전

    […] 온프레미스 AI Assistant 아키텍처 — Qwen3·Qwen3-VL 14일 설계 (총 14화 중 10화)◀ 이전 9화  (다음 차수는 아직 게시되지 […]

    답글
  2. [온프레미스 AI Assistant 아키텍처 — Qwen3·Qwen3-VL 14일 설계] 10/14화: Graph RAG·멀티모달 RAG 실전 — 고급 검색 아키텍처 - AICosmus 댓글:
    2026년 07월 13일, 10:13 오전

    […] 온프레미스 AI Assistant 아키텍처 — Qwen3·Qwen3-VL 14일 설계 (총 14화 중 10화)◀ 이전 9화  (다음 차수는 아직 게시되지 […]

    답글
  3. Dockerfile 최적화 실전 가이드 — 빌드·크기·보안 총정리 - AICosmus 댓글:
    2026년 07월 24일, 2:10 오후

    […] […]

    답글

답글 남기기 응답 취소

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다

최신 글

  • [opencode 시즌 2 심화 — 나만의 도메인 특화 에이전트 만들기] 1/12화: opencode 에이전트 아키텍처 완전 해부 — 2026 Primary·Subagent 5계층 구조
  • Kotlin 코루틴 핵심 5가지 개념과 실전 활용법
  • [opencode 12일 집중 — 터미널 네이티브 AI 코딩 에이전트 완전 정복] 12/12화: opencode 로컬 모델 완전 가이드 2026 — Ollama·에어갭·규제 환경 도입 체크리스트
  • LLM 파인튜닝 실전 5단계 — 2026 LoRA 완벽 가이드
  • 금융 앱 생체인증 작동 원리, 지문·얼굴 보안 5단계 완전 해부

최신 댓글

  1. [온프레미스 AI Assistant 아키텍처 — Qwen3·Qwen3-VL 14일 설계] 9/14화: 온프레미스 RAG 파이프라인 — bge-m3·Qdrant 자체 호스팅 실전의 Dockerfile 최적화 실전 가이드 — 빌드·크기·보안 총정리 - AICosmus
  2. RAG 평가 프레임워크, 답변 품질을 수치로 측정하는 법의 RAG 리랭킹 가이드, 검색 결과 정확도 높이는 법 - AICosmus
  3. RAG 평가 프레임워크, 답변 품질을 수치로 측정하는 법의 RAG 리랭킹 가이드, 검색 결과 정확도 높이는 법 - AICosmus
  4. gRPC Interceptor 완벽 가이드: 인증부터 로깅까지의 gRPC 데드라인과 재시도 정책으로 장애 전파 차단하기 - AICosmus
  5. gRPC Interceptor 완벽 가이드: 인증부터 로깅까지의 gRPC 데드라인과 재시도 정책으로 장애 전파 차단하기 - AICosmus
  • About
  • Contact
  • Disclaimer
  • Privacy - Policy
  • Terms of Service
Copyright 2026 — AICosmus. All rights reserved. Blogsy WordPress Theme