MINERVA Config 의존성 추적

.env → RAGConfig → 실험 override 전파 흐름

MINERVA에서 하나의 설정값이 런타임 동작에 도달하기까지 .env → YAML 프로파일 → RAGConfig → A/B override를 거치는 경로를 코드 수준에서 추적한다. 설정 변경의 실제 효력 범위와 숨겨진 함정을 진단한다.

Agent
저자

Kwangmin Kim

공개

2026년 05월 05일

1 개요

MINERVA의 설정은 세 레이어를 순서대로 통과해 런타임 동작을 결정한다.

Layer 1: 환경변수 (.env)
    ↓  os.getenv() — dataclass 기본값에서 읽음
Layer 2: YAML 프로파일 (data/configs/*.yaml)
    ↓  RAGConfig.from_yaml_file() — ${VAR:default} 치환 후 파싱
Layer 3: A/B 실험 override (data/experiments/*.yaml)
    ↓  apply_overrides() — dotted key → RAGConfig 필드 덮어쓰기
Layer 4: 런타임 동작
    └  RAGConfig.retrieval.k = 6, llm.model = "gpt-4.1", ...

각 레이어를 코드와 함께 분석한다.

2 Layer 1: 환경변수

2.1 .env 파일 로딩 우선순위

main.py가 라우터 import 전에 _load_env_files()를 실행해 .env 파일을 로드한다. 우선순위는 .env > .env.cloud > .env.local이고, 첫 번째 존재하는 파일만 사용한다(merge 아님).

# src/services/api/main.py
def _load_env_files() -> None:
    repo_root = Path(__file__).resolve().parent.parent.parent.parent
    for env_name in [".env", ".env.cloud", ".env.local"]:
        env_path = repo_root / env_name
        if env_path.exists():
            load_dotenv(env_path, override=False)  # 시스템 env가 이김
            return
    # 셋 다 없으면 시스템 env만 사용

override=False가 핵심이다. Docker --env-file이나 K8s Secret으로 주입한 환경변수가 .env 파일보다 우선한다. 운영 환경에서는 시크릿을 시스템 env로 주입하고, 개발 환경에서는 .env.local을 사용하는 패턴이 가능하다.

환경 사용 파일 시스템 env 우선순위
로컬 개발 .env.local 비활성 파일이 유일한 소스
Docker (.env 없음) --env-file 시스템 env만
K8s (.env 없음) Secret 주입 시스템 env만
클라우드 VM .env.cloud 일부 override 시스템 env > .env.cloud

2.2 서비스 운영 토글

main.py가 직접 읽는 환경변수는 두 개다.

_LOG_LEVEL = os.getenv("MINERVA_LOG_LEVEL", "INFO").upper()
logging.basicConfig(level=getattr(logging, _LOG_LEVEL, logging.INFO), ...)

if os.getenv("WARMUP_ON_STARTUP", "true").lower() in ("true", "1", "yes"):
    warmup_qna()
    warmup_ds()
변수 영향
MINERVA_LOG_LEVEL DEBUG/INFO/WARNING/ERROR 전역 로그 레벨
WARMUP_ON_STARTUP true/false 기동 시 ALBERT/임베딩 모델 로딩 (~10-15초)
LLM_PROVIDER azure/ollama LLM 공급자 분기 (core/llm.py get_provider())

dev 환경에서 빠른 재기동이 필요하면 WARMUP_ON_STARTUP=false. 첫 실 요청은 cold start를 감수한다.

LLM_PROVIDERRAGConfig의 어떤 필드보다도 먼저 분기되는 토글이다. azure로 설정되면 AzureChatOpenAI/AzureOpenAIEmbeddings/AzureSearch로 묶인 클라우드 스택, ollama(또는 local)이면 ChatOllama/HuggingFaceEmbeddings/FAISS로 묶인 로컬 스택이 활성화된다. 같은 RAGConfig라도 provider가 바뀌면 실제 호출되는 SDK·인덱스·임베딩 차원이 모두 달라진다.

2.3 환경변수 의존 필드

RAGConfig의 세 sub-config이 os.getenv()를 기본값으로 사용한다.

# src/core/config.py

@dataclass
class EmbeddingConfig:
    model: str = field(default_factory=lambda: os.getenv(
        "AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME",
        "text-embedding-ada-002"
    ))
    api_version: str = field(default_factory=lambda: os.getenv(
        "AZURE_OPENAI_API_VERSION", "2024-08-01-preview"
    ))

@dataclass
class IndexConfig:
    azure_index_name: str = field(default_factory=lambda: os.getenv(
        "AZURE_VECTOR_STORE_INDEX_NAME", "data-standardization-prod"
    ))
    local_index_name: str = field(default_factory=lambda: os.getenv(
        "LOCAL_VECTOR_STORE_INDEX_NAME", "local-chatbot-index"
    ))

@dataclass
class LLMConfig:
    api_version: str = field(default_factory=lambda: os.getenv(
        "AZURE_OPENAI_API_VERSION", "2024-08-01-preview"
    ))

field(default_factory=lambda: os.getenv(...)) 패턴은 dataclass 인스턴스 생성 시점에 환경변수를 읽는다. 프로세스 시작 후 환경변수를 변경해도 기존 인스턴스에는 반영되지 않는다.

2.4 환경변수가 없는 필드

LLM 모델명과 대부분의 검색 파라미터는 환경변수 의존 없이 코드 내 기본값을 사용한다.

@dataclass
class LLMConfig:
    model: str = "gpt-4.1"         # 하드코딩
    temperature: float = 0.7       # 하드코딩
    max_tokens: int = 4000         # 하드코딩

@dataclass
class RetrievalConfig:
    k: int = 6                     # 하드코딩
    search_type: str = "hybrid"    # 하드코딩
    reranker_enabled: bool = False  # 하드코딩

이 값들을 변경하려면 YAML 프로파일 또는 A/B 실험 override를 사용해야 한다.

3 Layer 2: YAML 프로파일

3.1 프로파일 디렉토리 구조

data/
  configs/
    default.yaml          ← get_config("default") 가 읽는 파일
    experiment_v2.yaml    ← 실험 전용 프로파일
    reranker_test.yaml

3.2 YAML 로드 흐름

# src/core/config.py

def get_config(profile_name: str = "default") -> RAGConfig:
    """YAML 우선 → 파일 없으면 _FALLBACK_DEFAULT 반환"""
    config = _load_profile_from_yaml(profile_name)
    if config:
        return config
    return _FALLBACK_DEFAULT

def _load_profile_from_yaml(profile_name: str) -> Optional[RAGConfig]:
    yaml_path = CONFIG_DIR / f"{profile_name}.yaml"
    if yaml_path.exists() and YAML_AVAILABLE:
        try:
            return RAGConfig.from_yaml_file(str(yaml_path))
        except Exception as e:
            print(f"⚠️ {profile_name}.yaml 로드 실패: {e}")
    return None

data/configs/default.yaml이 없으면 코드에 하드코딩된 _FALLBACK_DEFAULT가 사용된다.

_FALLBACK_DEFAULT = RAGConfig(
    name="default",
    chunking=ChunkingConfig(chunk_size=1500, chunk_overlap=400),
    retrieval=RetrievalConfig(k=6, search_type="hybrid"),
    llm=LLMConfig(model="gpt-4o-mini", temperature=0.7, max_tokens=4000)
)

3.3 ${VAR:default} 환경변수 치환

YAML 파일 내에서 환경변수를 참조할 수 있다.

# data/configs/default.yaml (예시)
name: default
embedding:
  model: ${AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME:text-embedding-ada-002}
  api_version: ${AZURE_OPENAI_API_VERSION:2024-08-01-preview}
index:
  azure_index_name: ${AZURE_VECTOR_STORE_INDEX_NAME:data-standardization-prod}
retrieval:
  k: 6
  search_type: hybrid
  reranker_enabled: false
llm:
  model: gpt-4.1
  temperature: 0.7
  max_tokens: 4000

치환 로직은 정규식 기반이다.

@classmethod
def from_yaml_file(cls, file_path: str) -> "RAGConfig":
    with open(file_path, 'r', encoding='utf-8') as f:
        content = f.read()

    def replace_env_var(match):
        var_expr = match.group(1)
        if ':' in var_expr:
            var_name, default_value = var_expr.split(':', 1)
        else:
            var_name, default_value = var_expr, ''
        return os.getenv(var_name, default_value)

    # ${VAR_NAME:default_value} 패턴 치환
    content = re.sub(r'\$\{([^}]+)\}', replace_env_var, content)
    data = yaml.safe_load(content)
    return cls.from_dict(data)

주의: ${VAR} (콜론 없음) 형식은 환경변수가 없으면 빈 문자열로 치환된다.

3.4 from_dict — YAML 파싱의 끝점

@classmethod
def from_dict(cls, data: dict) -> "RAGConfig":
    return cls(
        name=data.get("name", "default"),
        chunking=ChunkingConfig(**data.get("chunking", {})),
        embedding=EmbeddingConfig(**data.get("embedding", {})),
        index=IndexConfig(**data.get("index", {})),
        retrieval=RetrievalConfig(**data.get("retrieval", {})),
        prompt=PromptConfig(**data.get("prompt", {})),
        conversation=ConversationConfig(**data.get("conversation", {})),
        llm=LLMConfig(**data.get("llm", {}))
    )

YAML에 없는 섹션은 빈 dict {}로 전달되어 dataclass 기본값이 사용된다. YAML에 있는 키만 override되므로 부분 설정이 가능하다.

4 Layer 3: A/B 실험 Override

4.1 실험 YAML 구조

# data/experiments/reranker_ab_test.yaml
name: reranker_ab_test
agent: qna_chatbot
active: true
assignment: sticky_hash
description: "FlashRank reranker vs. 기본 검색 A/B 테스트"
arms:
  control:
    traffic: 0.5
    overrides: {}
  treatment:
    traffic: 0.5
    overrides:
      retrieval.reranker_enabled: true
      retrieval.reranker_type: flashrank
      retrieval.reranker_model: ms-marco-MultiBERT-L-12
      retrieval.reranker_top_n: 5
metrics:
  - latency_ms
  - citation_count

4.2 실험 resolve 흐름

# src/core/experiments.py

def resolve_config_for_user(
    agent_name: str,
    user_id: Optional[str] = None,
    base_config: Optional[RAGConfig] = None,
    force_arm: Optional[str] = None,
) -> tuple[RAGConfig, Optional[str], Optional[str], dict]:

    base = base_config or get_config("default")  # Layer 2 결과
    assignment = get_experiment_assignment(agent_name, user_id, force_arm=force_arm)

    if assignment is None:
        return base, None, None, {}  # 실험 없음 → base config 그대로

    exp, arm_id, overrides = assignment
    return apply_overrides(base, overrides), exp.name, arm_id, overrides

4.3 Arm 할당 — sticky_hash

def assign_arm(experiment: Experiment, user_id: Optional[str] = None) -> str:
    if experiment.assignment == "sticky_hash" and user_id:
        key = f"{user_id}:{experiment.name}".encode("utf-8")
        digest = hashlib.md5(key).hexdigest()
        rand_val = int(digest[:8], 16) / 0xFFFFFFFF  # 0~1 결정론적 값
    else:
        rand_val = random.random()

    cumulative = 0.0
    for arm_id, arm in experiment.arms.items():
        cumulative += arm.traffic / experiment.total_weight()
        if rand_val <= cumulative:
            return arm_id
    return list(experiment.arms.keys())[-1]  # rounding fallback

sticky_hashmd5(f"{user_id}:{experiment.name}")로 결정론적 값을 생성한다. 같은 사용자는 항상 같은 arm에 배정된다. user_id가 None이면 매 요청마다 랜덤 배정으로 폴백한다.

4.4 Override 적용 — dotted key

def apply_overrides(config: RAGConfig, overrides: dict) -> RAGConfig:
    new_config = copy.deepcopy(config)  # 원본 보존
    for dotted_key, value in overrides.items():
        parts = dotted_key.split(".")
        target = new_config
        for part in parts[:-1]:
            target = getattr(target, part)
        setattr(target, parts[-1], value)
    return new_config

"retrieval.reranker_enabled": True는 다음과 동일하다.

new_config = copy.deepcopy(base)
new_config.retrieval.reranker_enabled = True

copy.deepcopy()로 원본 config가 보존되므로, 실험 arm별로 독립된 config 인스턴스가 생성된다.

타입 검증 없음: override value는 그대로 setattr()로 설정된다. YAML에서 retrieval.k: "six"처럼 문자열이 들어와도 오류 없이 설정된다. 실제 사용 시점에 타입 오류가 발생한다.

5 Reranker Config 흐름

RAGConfig.retrieval의 reranker 관련 필드가 어떻게 ParentChunkRetriever 동작을 제어하는지 추적한다.

5.1 RAGConfig.retrieval → reranker_config dict

RetrievalConfig의 reranker 필드는 4개다.

@dataclass
class RetrievalConfig:
    reranker_enabled: bool = False
    reranker_type: Optional[str] = None    # "flashrank" | "cross_encoder" | "azure_semantic"
    reranker_model: Optional[str] = None
    reranker_top_n: int = 5

chatbot_retriever()(코드 미공개)가 이 필드를 dict로 변환해 ParentChunkRetriever에 전달한다.

# 추정 흐름 (chatbot_retriever 내부)
reranker_config = None
if config.retrieval.reranker_enabled and config.retrieval.reranker_type:
    reranker_config = {
        "type": config.retrieval.reranker_type,
        "model": config.retrieval.reranker_model,
        "top_n": config.retrieval.reranker_top_n,
    }

retriever = ParentChunkRetriever(
    vectorstore=azure_search,
    parent_store=parent_store,
    reranker_config=reranker_config,
    embeddings=embeddings,
)

5.2 동작 분기

ParentChunkRetrieverreranker_configtype 값으로 검색 경로를 결정한다.

# src/core/rag/retriever.py hybrid_search()
if self._reranker_type == "azure_semantic":
    return self._hybrid_search_with_semantic(query, k)

use_cosine = (
    self._reranker_type not in ("flashrank", "cross_encoder")
    and self._embeddings is not None
)
if use_cosine:
    child_docs, query_vec = self._hybrid_search_with_vectors(query=query, k=k * 2)
else:
    child_docs = self.vectorstore.hybrid_search(query=query, k=k * 2)

if self._reranker_type in ("flashrank", "cross_encoder"):
    child_docs = self._rerank_children(child_docs, query)
elif self._embeddings is not None:
    child_docs = self._compute_cosine_scores(child_docs, query, query_vec=query_vec)

5.3 설정 → 동작 매트릭스

reranker_enabled reranker_type 검색 경로 점수 부여
false - 표준 hybrid cosine 유사도 (embeddings 있을 때)
true flashrank 표준 hybrid FlashRank score
true cross_encoder 표준 hybrid Cross-Encoder score
true azure_semantic semantic_hybrid_search Azure Semantic rerankerScore (정규화)

5.4 A/B 실험에서 reranker 비교

# data/experiments/reranker_comparison.yaml
name: reranker_comparison
agent: qna_chatbot
arms:
  baseline:
    traffic: 0.33
    overrides:
      retrieval.reranker_enabled: false
  flashrank:
    traffic: 0.33
    overrides:
      retrieval.reranker_enabled: true
      retrieval.reranker_type: flashrank
      retrieval.reranker_model: ms-marco-MultiBERT-L-12
      retrieval.reranker_top_n: 5
  cross_encoder:
    traffic: 0.34
    overrides:
      retrieval.reranker_enabled: true
      retrieval.reranker_type: cross_encoder
      retrieval.reranker_model: BAAI/bge-reranker-v2-m3
      retrieval.reranker_top_n: 5

세 arm 모두 같은 base config를 deepcopy 후 override되므로 다른 모든 설정(LLM 모델, k, history_turns)은 동일하다.

6 RAGConfig 외부의 분기점 — 라우터 레벨 매핑

설정의 대부분은 RAGConfig로 흘러들어가지만, 라우터 레벨에서 RAGConfig를 거치지 않고 결정되는 분기도 있다. 가장 대표적인 것이 DEFAULT_DOCUMENTS_PROMPTS다(Phase 10.92 도입).

# src/services/api/routers/qna_chatbot.py
DEFAULT_DOCUMENTS_PROMPTS = {
    "DAMA": "governance",   # DAMA 거버넌스 문서 → governance 시스템 프롬프트
    # 그 외는 default
}

라우터는 사용자 요청의 agent_params.document_id를 보고 이 매핑에서 시스템 프롬프트 매니페스트를 결정한 뒤, agent의 _get_llm_for_query()에 전달한다. 즉:

  • RAGConfig.prompt: 에이전트 단위 프롬프트 (template_name, version)
  • DEFAULT_DOCUMENTS_PROMPTS: 문서 단위 시스템 프롬프트 override
두 분기점이 한 응답에서 만난다

같은 사용자가 같은 실험 arm에 있어도, 요청한 document_id가 DAMA면 governance 톤으로, 그 외면 default 톤으로 답변이 나온다. A/B 실험 결과를 분석할 때 document_id별로 구분하지 않으면 “거버넌스 답변과 일반 답변이 섞인 평균”을 비교하게 되어 효과 추정이 흐려진다. runs.jsonlextras 필드에 document_idprompt_manifest를 함께 기록해두면 사후 슬라이싱이 가능하다.

7 Config 디버깅

7.1 현재 활성 설정 확인

운영 중 어떤 RAGConfig가 사용 중인지 확인하려면 디버그 엔드포인트가 필요하다(현재 미구현).

# 권장 패턴 (services/api/routers/qna_chatbot.py에 추가)
@router.get("/_debug/config/{user_id}")
def debug_config(user_id: str):
    config, exp_name, arm_id, overrides = resolve_config_for_user(
        "qna_chatbot", user_id=user_id,
    )
    return {
        "experiment": exp_name,
        "arm_id": arm_id,
        "overrides": overrides,
        "effective_config": config.to_dict(),
    }

7.2 YAML 치환 결과 확인

${VAR:default} 치환 결과를 검증하려면:

from core.config import RAGConfig
config = RAGConfig.from_yaml_file("data/configs/default.yaml")
print(config.to_json(indent=2))  # 치환 후 결과

7.3 환경변수가 반영되지 않을 때 체크리스트

  1. .env 파일이 repo root에 있는가? (src/services/api/main.py 기준 4 parents)
  2. 시스템 env에 같은 변수가 있는가? (override=False로 시스템 env가 이김)
  3. _load_env_files()가 어떤 파일을 로드했는지 로그 확인 (loaded /path/to/.env)
  4. field(default_factory=lambda: os.getenv(...))은 dataclass 인스턴스 생성 시점에 평가된다. 프로세스 시작 후 os.environ을 변경해도 기존 인스턴스에는 반영되지 않는다.

8 런타임 설정 싱글톤

8.1 get_active_config() / set_active_config()

_active_config: RAGConfig = None

def get_active_config() -> RAGConfig:
    global _active_config
    if _active_config is None:
        _active_config = get_config("default")
    return _active_config

def set_active_config(config_or_name):
    global _active_config
    if isinstance(config_or_name, str):
        _active_config = get_config(config_or_name)
    elif isinstance(config_or_name, RAGConfig):
        _active_config = config_or_name

QnaChatbotAgent._get_retriever()set_active_config(self.config)를 호출한다. 이는 retriever 생성 시 글로벌 설정을 agent config로 덮어쓴다.

def _get_retriever(self, document_id: str):
    if document_id not in self._retrievers:
        set_active_config(self.config)  # 글로벌 singleton 변경
        # ... chatbot_retriever() 생성

멀티 스레드 위험: 여러 스레드가 다른 config의 agent를 동시에 _get_retriever()를 호출하면 글로벌 _active_config가 경쟁 상태에 놓인다. chatbot_retriever() 내부가 get_active_config()를 사용한다면 의도치 않은 config로 retriever가 초기화될 수 있다.

9 설정 전파 완성 경로

하나의 요청이 최종 LLM 호출에 도달하기까지의 설정 전파를 추적한다.

1. 프로세스 시작
   → os.getenv("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME", "text-embedding-ada-002")
   → EmbeddingConfig.model = "text-embedding-3-small" (예: .env에 설정된 경우)

2. get_config("default")
   → data/configs/default.yaml 읽기
   → ${AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME:text-embedding-ada-002} 치환
   → RAGConfig.embedding.model = "text-embedding-3-small" ✓
   → RAGConfig.retrieval.k = 6 (YAML에 명시된 값)
   → RAGConfig.llm.model = "gpt-4.1" (YAML에 명시된 값)

3. resolve_config_for_user("qna_chatbot", user_id="user-42")
   → data/experiments/reranker_ab_test.yaml 로드
   → md5("user-42:reranker_ab_test") → treatment arm (예: 0.73 > 0.5)
   → apply_overrides: retrieval.reranker_enabled = True

4. _build_agent() → agent cache [("reranker_ab_test", "treatment")]
   → QnaChatbotAgent(config=modified_config)

5. agent._prepare(query)
   → _ensure_initialized(): self._llm = get_llm(model="gpt-4.1", temperature=0.7)
   → _retrieve_docs(): hybrid_search(k=6) → rerank(top_n=5) ← reranker_enabled=True

6. chain = self._prompt | llm | StrOutputParser()
   → llm.invoke() with model="gpt-4.1"

10 설계 평가

10.1 강점

계층화된 override: 기본값(코드) → YAML → 실험 순으로 override되어 변경 범위를 명확히 제어할 수 있다.

부분 YAML 허용: from_dict()가 누락 섹션을 빈 dict로 처리하므로, YAML에서 변경이 필요한 섹션만 작성하면 된다.

원본 config 보존: apply_overrides()copy.deepcopy()를 사용해 실험 arm별 독립 config를 보장한다.

10.2 개선 여지

YAML 로드 실패 시 무음 폴백: _load_profile_from_yaml()이 파싱 오류를 print()로만 출력하고 _FALLBACK_DEFAULT로 조용히 폴백한다. 잘못된 YAML이 배포되어도 에러가 발생하지 않는다.

override 타입 검증 없음: apply_overrides()setattr()을 바로 호출해 타입 불일치를 런타임까지 잡지 못한다.

글로벌 singleton과 thread safety: _active_config 싱글톤과 set_active_config() 호출이 멀티 스레드 환경에서 경쟁 상태를 야기할 수 있다. 특히 다른 config를 가진 agent들이 동시에 _get_retriever()를 최초 호출할 때 위험하다.

실험 캐시 무효화 없음: 실험 YAML을 수정해도 agent 캐시가 갱신되지 않는다. 이전 config를 가진 agent가 계속 사용된다.

복수 실험 미지원: get_experiment_assignment()active[0]만 사용한다. 복수 실험이 활성화된 경우 첫 번째만 적용된다.

운영 패턴 — 별편으로 분리

설정의 정적 전파 흐름은 본 글에서 끝나고, 운영에서 그 흐름을 다루는 패턴(Hot Reload 가능성과 한계, Docker/K8s 시크릿 주입, 도커로 Config를 고정시켜 재현 가능한 빌드를 만드는 방법)은 11-1편 Config 운영 패턴으로 분리했다. 정적 추적이 끝난 다음 자연스럽게 이어 읽으면 된다.

11 정리

MINERVA의 설정은 .env → YAML 프로파일 → 실험 override 세 레이어를 순서대로 통과한다. 각 레이어가 이전 레이어의 값을 덮어쓰는 단방향 흐름이라 추적이 명확하다. 그러나 타입 검증 부재, 글로벌 singleton의 thread safety 문제, 실험 캐시 무효화 부재가 프로덕션 안정성 리스크다. 다음 포스트에서는 현재 테스트 커버리지가 이 구조의 어느 범위를 검증하고 있는지 분석한다.

다음: 테스트 전략 분석

Subscribe

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